🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 02:00:33

This commit is contained in:
2026-05-24 02:00:33 +00:00
parent c839653a97
commit c8d51e0e1b
7 changed files with 170 additions and 26 deletions
+21 -4
View File
@@ -59,6 +59,15 @@ export function findElementRoot(obj: THREE.Object3D | null): THREE.Object3D | nu
return fallback;
}
export function findModelId(obj: THREE.Object3D | null): string | undefined {
let cur: THREE.Object3D | null = obj;
while (cur) {
if (cur.userData?.modelId) return cur.userData.modelId as string;
cur = cur.parent;
}
return undefined;
}
function hasIfcAncestor(obj: THREE.Object3D): boolean {
let cur = obj.parent;
while (cur) {
@@ -574,7 +583,9 @@ function SmartSnapHandler() {
}
}
if (bestHole) {
setSnapPoint(bestHole);
// Find the measurement we matched
const matched = st.measurements.find(m => m.pointA.x === bestHole.x && m.pointA.y === bestHole.y && m.pointA.z === bestHole.z);
setSnapPoint({ ...bestHole, modelId: matched?.modelId });
return;
}
@@ -585,7 +596,8 @@ function SmartSnapHandler() {
10
);
if (snap) {
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z });
const hitModelId = findModelId(hit.object);
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId });
} else {
setSnapPoint(null);
}
@@ -653,11 +665,13 @@ function HoverDetector() {
// 1) Try circle fit from edge vertices around the hit (catches holes on flat faces)
const circ = detectCircularEdgeAtPoint(hit.object, hit.point, camera, canvasSize, 50);
const hitModelId = findModelId(hit.object);
if (circ) {
setHoverInfo({
type: 'hole',
position: [circ.center.x, circ.center.y, circ.center.z],
value: circ.diameterMM,
modelId: hitModelId,
});
} else if (hit.faceIndex !== undefined && (() => {
const hole = detectHoleAtFace(hit.object, hit.faceIndex);
@@ -666,6 +680,7 @@ function HoverDetector() {
type: 'hole',
position: [hole.center.x, hole.center.y, hole.center.z],
value: hole.diameterMM,
modelId: hitModelId,
});
return true;
}
@@ -680,6 +695,7 @@ function HoverDetector() {
type: 'edge',
position: [edge.midpoint.x, edge.midpoint.y, edge.midpoint.z],
value: edge.lengthMM,
modelId: hitModelId,
endpoints: {
a: { x: edge.a.x, y: edge.a.y, z: edge.a.z },
b: { x: edge.b.x, y: edge.b.y, z: edge.b.z },
@@ -763,7 +779,7 @@ function MeasureClickHandler() {
// Prefer snap (vertex snap, or hole-center snap from SmartSnapHandler)
if (st.snapPoint) {
st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z });
st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z, modelId: st.snapPoint.modelId });
return;
}
@@ -782,7 +798,8 @@ function MeasureClickHandler() {
return true;
});
if (hit) {
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
const hitModelId = findModelId(hit.object);
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId });
}
};
@@ -26,6 +26,31 @@ function isPickableModelMesh(o: THREE.Object3D): boolean {
return false;
}
function findModelId(obj: THREE.Object3D): string | undefined {
let cur: THREE.Object3D | null = obj;
while (cur) {
if (cur.userData?.modelId) return cur.userData.modelId as string;
cur = cur.parent;
}
return undefined;
}
function triggerHaptic(session: XRSession | null, handedness: 'left' | 'right', intensity = 0.5, duration = 40) {
if (!session) return;
try {
for (const source of session.inputSources) {
if (source.handedness === handedness) {
const gp = source.gamepad;
if (gp && gp.hapticActuators && gp.hapticActuators.length > 0) {
gp.hapticActuators[0].pulse(intensity, duration).catch(() => {});
}
}
}
} catch (e) {
console.warn('[XR][Haptic] erro ao vibrar controle:', e);
}
}
/**
* Right-controller trigger driven measurement for AR.
*
@@ -65,6 +90,7 @@ export function XRControllerMeasure() {
const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity));
const dwellStart = useRef(0);
const dwellFired = useRef(false);
const lastSnapKind = useRef<string | null>(null);
// Throttling: raycast + snap analysis are heavy on dense IFC models and
// running them every frame at 72fps can stall the Quest right after
@@ -177,6 +203,12 @@ export function XRControllerMeasure() {
}
}
// ── Haptic feedback on snap state changes ────────────────────────
if (snappedPoint && snapKind !== 'surface' && lastSnapKind.current !== snapKind) {
triggerHaptic(session, 'right', 0.4, 15);
}
lastSnapKind.current = snappedPoint ? snapKind : null;
// ── Dwell detection (1 s) to auto-register hovered hole/edge ─────
const measureModeNow = useModelStore.getState().measureMode;
const nowT = performance.now();
@@ -189,15 +221,18 @@ export function XRControllerMeasure() {
} else if (!dwellFired.current && nowT - dwellStart.current > 1000) {
dwellFired.current = true;
if (measureModeNow) {
const hitModelId = hit ? findModelId(hit.object) : undefined;
useModelStore.getState().registerHoverMeasurement({
type: hoverDetected.kind,
value: hoverDetected.value,
position: [hoverDetected.position.x, hoverDetected.position.y, hoverDetected.position.z],
modelId: hitModelId,
endpoints: hoverDetected.endpoints && {
a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z },
b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z },
},
});
triggerHaptic(session, 'right', 0.6, 50);
}
}
} else {
@@ -270,10 +305,13 @@ export function XRControllerMeasure() {
}
}
} else if (snappedPoint) {
const hitModelId = hit ? findModelId(hit.object) : undefined;
sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint);
st.addMeasurePoint({
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
modelId: hitModelId,
});
triggerHaptic(session, 'right', 0.8, 25);
}
} else if (trigState.current && trigVal < TRIG_OFF) {
trigState.current = false;
@@ -285,6 +323,7 @@ export function XRControllerMeasure() {
if (!aState.current && aVal > BTN_ON) {
aState.current = true;
useModelStore.getState().undoLastMeasurement();
triggerHaptic(session, 'right', 0.5, 30);
} else if (aState.current && aVal < TRIG_OFF) {
aState.current = false;
}
@@ -295,6 +334,7 @@ export function XRControllerMeasure() {
if (!bState.current && bVal > BTN_ON) {
bState.current = true;
useModelStore.getState().clearMeasurements();
triggerHaptic(session, 'right', 0.7, 60);
} else if (bState.current && bVal < TRIG_OFF) {
bState.current = false;
}
+75 -5
View File
@@ -1,9 +1,25 @@
import { useRef, ReactNode, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
import { useModelStore } from '@/stores/useModelStore';
function triggerHaptic(session: XRSession | null, handedness: 'left' | 'right', intensity = 0.5, duration = 40) {
if (!session) return;
try {
for (const source of session.inputSources) {
if (source.handedness === handedness) {
const gp = source.gamepad;
if (gp && gp.hapticActuators && gp.hapticActuators.length > 0) {
gp.hapticActuators[0].pulse(intensity, duration).catch(() => {});
}
}
}
} catch (e) {
console.warn('[XR][Haptic] erro ao vibrar controle no grab:', e);
}
}
interface XRGrabbableProps {
/**
* Quando true (padrão), o grip duplo aplica ZOOM uniforme além da rotação.
@@ -70,9 +86,12 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
single.current = null;
}, [scaleResetNonce]);
useFrame(() => {
const { gl } = useThree();
useFrame((_state, dt) => {
const group = groupRef.current;
if (!group) return;
const session = gl.xr.getSession();
const snap: ControllerGrabSnapshot = grab.current;
const L = snap.left;
const R = snap.right;
@@ -95,6 +114,33 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
haloRef.current.visible = maxGrip > 0.05;
}
const commitGrabTransforms = () => {
const activeId = useModelStore.getState().activeModelId;
if (!activeId) return;
const active = useModelStore.getState().models.find(m => m.id === activeId);
if (!active) return;
const ft = { ...active.fineTuning };
ft.posX += group.position.x;
ft.posY += group.position.y;
ft.posZ += group.position.z;
ft.rotX += (group.rotation.x * 180) / Math.PI;
ft.rotY += (group.rotation.y * 180) / Math.PI;
ft.rotZ += (group.rotation.z * 180) / Math.PI;
ft.scale = (ft.scale ?? 1) * group.scale.x;
useModelStore.getState().setFineTuning(ft);
group.position.set(0, 0, 0);
group.rotation.set(0, 0, 0);
group.scale.set(1, 1, 1);
triggerHaptic(session, 'left', 0.25, 12);
triggerHaptic(session, 'right', 0.25, 12);
};
// ─── Two-hand mode (orbit + zoom, sempre) ────────────────────
if (lActive && rActive) {
_tmpVecL.setFromMatrixPosition(L.gripWorld);
@@ -116,12 +162,13 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
};
single.current = null;
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
triggerHaptic(session, 'left', 0.5, 30);
triggerHaptic(session, 'right', 0.5, 30);
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
}
const d = dual.current;
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
// ZOOM só quando allowScale=true. Caso contrário, mantém escala 1× (só orbita).
const scaleRatio = allowScale
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
: 1;
@@ -137,10 +184,27 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
.multiply(tToOrigin)
.multiply(d.startGroupWorld);
applyWorldMatrixToLocal(group, m);
// Decompose target world matrix
const targetPos = new THREE.Vector3();
const targetQuat = new THREE.Quaternion();
const targetScale = new THREE.Vector3();
let parentLocalMatrix = m;
if (group.parent) {
group.parent.updateMatrixWorld();
parentLocalMatrix = new THREE.Matrix4().copy(group.parent.matrixWorld).invert().multiply(m);
}
parentLocalMatrix.decompose(targetPos, targetQuat, targetScale);
// Apply smooth interpolation (anti-jitter dampening)
const t = Math.min(1.0, dt * 18);
group.position.lerp(targetPos, t);
group.quaternion.slerp(targetQuat, t);
group.scale.lerp(targetScale, t);
return;
} else if (dual.current) {
console.log('[XR][grab] ◆ TWO-HAND end');
commitGrabTransforms();
dual.current = null;
}
@@ -156,6 +220,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
single.current = { hand: activeHand, offsetWorld };
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
triggerHaptic(session, activeHand, 0.45, 25);
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`);
}
@@ -168,8 +233,13 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
targetWorldPos.applyMatrix4(invParent);
}
group.position.copy(targetWorldPos);
// Amortecimento suave da translação
const t = Math.min(1.0, dt * 18);
group.position.lerp(targetWorldPos, t);
} else if (single.current) {
console.log('[XR][grab] ● ONE-HAND end');
commitGrabTransforms();
single.current = null;
}
});
+28 -13
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { Grid } from '@react-three/drei';
import { Grid, Billboard } from '@react-three/drei';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as THREE from 'three';
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
@@ -8,6 +8,15 @@ import { findNearestVertex } from './SmartMeasure';
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
import { parseIFCtoThree } from '@/lib/convertIFC';
function findModelId(obj: THREE.Object3D): string | undefined {
let cur: THREE.Object3D | null = obj;
while (cur) {
if (cur.userData?.modelId) return cur.userData.modelId as string;
cur = cur.parent;
}
return undefined;
}
// ─── XRModel ───────────────────────────────────────────
export function XRModel({ sceneModel }: { sceneModel: SceneModel }) {
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
@@ -282,15 +291,17 @@ function XRMeasurementText({ text, color }: { text: string; color: string }) {
}, [text, color]);
return (
<mesh renderOrder={999}>
<planeGeometry args={[0.08, 0.02]} />
<meshBasicMaterial
map={texture}
transparent
depthTest={false}
side={THREE.DoubleSide}
/>
</mesh>
<Billboard follow={true}>
<mesh renderOrder={999}>
<planeGeometry args={[0.08, 0.02]} />
<meshBasicMaterial
map={texture}
transparent
depthTest={false}
side={THREE.DoubleSide}
/>
</mesh>
</Billboard>
);
}
@@ -444,7 +455,8 @@ export function XRSnapHandler() {
if (hit && hit.object instanceof THREE.Mesh) {
const canvas = gl.domElement;
const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10);
setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z } : null);
const hitModelId = findModelId(hit.object);
setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId } : null);
} else {
setSnapPoint(null);
}
@@ -455,7 +467,7 @@ export function XRSnapHandler() {
if (gl.xr.isPresenting) return;
const onClick = (e: MouseEvent) => {
if (snapPoint) {
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z, modelId: snapPoint.modelId });
return;
}
const rect = gl.domElement.getBoundingClientRect();
@@ -464,7 +476,10 @@ export function XRSnapHandler() {
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine);
if (hit) addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
if (hit) {
const hitModelId = findModelId(hit.object);
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId });
}
};
gl.domElement.addEventListener('click', onClick);
return () => gl.domElement.removeEventListener('click', onClick);
+1 -1
View File
@@ -327,7 +327,7 @@ const Index = () => {
{/* Footer */}
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
TrackSteelXR v1.05 Q.C. Inspection
TrackSteelXR v1.06 Q.C. Inspection
</p>
{showLogs && (
+4 -2
View File
@@ -133,6 +133,7 @@ export interface MeasurePoint {
x: number;
y: number;
z: number;
modelId?: string;
}
export interface Measurement {
@@ -155,6 +156,7 @@ export interface HoverInfo {
value: number; // mm (diameter or length)
/** For edges: the two endpoints in world coords. */
endpoints?: { a: MeasurePoint; b: MeasurePoint };
modelId?: string;
}
export interface ChecklistItem {
@@ -564,7 +566,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
const factor = state.scaleRatio?.factor ?? 1;
const distanceMM = distanceWorldMM / factor;
const modelId = state.activeModelId ?? undefined;
const modelId = a.modelId ?? b.modelId ?? state.activeModelId ?? undefined;
const aConv = modelId ? worldToModelLocal(modelId, a) : null;
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
const attached = !!(aConv && bConv);
@@ -602,7 +604,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
return { measurements: state.measurements.slice(0, -1) };
}),
registerHoverMeasurement: (info) => set((state) => {
const modelId = state.activeModelId ?? undefined;
const modelId = info.modelId ?? state.activeModelId ?? undefined;
const toLocal = (p: MeasurePoint): { point: MeasurePoint; attached: boolean } => {
const conv = modelId ? worldToModelLocal(modelId, p) : null;
return conv ? { point: conv, attached: true } : { point: p, attached: false };