🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 02:00:33
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user