Reverted to commit c93e56640d

This commit is contained in:
gpt-engineer-app[bot]
2026-05-24 18:56:17 +00:00
parent 13c1ad315d
commit 79b95ce3c8
17 changed files with 1069 additions and 1878 deletions
+86 -399
View File
@@ -3,38 +3,12 @@ import { Canvas, useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
import { useModelStore, type SceneModel, type HoverInfo } from '@/stores/useModelStore';
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
import { parseIFCtoThree } from '@/lib/convertIFC';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
// WebXR imersivo
import { XR, useXR, XROrigin } from '@react-three/xr';
import { xrStore } from '@/lib/xrStore';
import { toast } from 'sonner';
import {
XRBackgroundModels,
XRActiveModel,
XRMeasurementOverlay,
XRSnapHandler,
XRGrid,
XRGridAutoFollower,
XREnvironmentSelector,
} from './XRSceneComponents';
import { XRGrabbable } from './XRGrabbable';
import { DesktopFirstPersonControls } from './DesktopFirstPersonControls';
import { XRHitTestPlacement } from './XRHitTestPlacement';
import { XRControllerMeasure } from './XRControllerMeasure';
import { ControllerLocomotion } from './ControllerLocomotion';
import { XRHudInWorld } from './XRHudInWorld';
import { XRBroadcastMirror } from './XRBroadcastMirror';
import { XRHud } from '@/components/XRHud';
import { ShareButton } from '@/components/ShareButton';
import { useDevKit } from '@/devkit/useDevKit';
import { DevPanel } from '@/devkit/DevPanel';
import { FakeControllers } from '@/devkit/FakeControllers';
interface ModelViewerProps {
url?: string; // legacy, ignored — uses store.models
}
@@ -61,15 +35,6 @@ 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) {
@@ -196,19 +161,25 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
originalColors.current.set(mat, mat.color.clone());
}
const isEdgesOrMeasure = renderMode === 'edges' || measureMode;
mat.visible = true;
const targetOpacity = isEdgesOrMeasure ? 0.25 : opacity;
mat.transparent = targetOpacity < 1;
mat.opacity = targetOpacity;
mat.wireframe = false;
if (hasRejected) {
mat.color.setHSL(0, 0.7, 0.5);
} else if (allApproved) {
mat.color.setHSL(0.38, 0.7, 0.45);
if (renderMode === 'edges' && !measureMode) {
mat.visible = false;
} else {
// Tint with the per-model color (subtle)
mat.color.set(sceneModel.color);
mat.visible = true;
const targetOpacity = measureMode ? 0.25 : opacity;
mat.transparent = targetOpacity < 1;
mat.opacity = targetOpacity;
mat.wireframe = renderMode === 'wireframe';
if (renderMode === 'wireframe') {
mat.wireframeLinewidth = wireframeThickness;
mat.color.set(wireframeColor);
} else if (hasRejected) {
mat.color.setHSL(0, 0.7, 0.5);
} else if (allApproved) {
mat.color.setHSL(0.38, 0.7, 0.45);
} else {
// Tint with the per-model color (subtle)
mat.color.set(sceneModel.color);
}
}
mat.needsUpdate = true;
}
@@ -217,8 +188,8 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
if ((renderMode === 'edges' || measureMode) && child.geometry) {
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
const lineMat = new THREE.LineBasicMaterial({
color: (renderMode === 'edges' || measureMode) ? '#00f3ff' : wireframeColor,
linewidth: (renderMode === 'edges' || measureMode) ? 2 : wireframeThickness,
color: measureMode ? '#00f3ff' : wireframeColor,
linewidth: measureMode ? 2 : wireframeThickness,
});
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
lineSegments.userData.__edgeLine = true;
@@ -384,28 +355,16 @@ function useLabelDistanceFactor(position: [number, number, number]): number {
const dist = camera.position.distanceTo(v);
const perspCam = camera as THREE.PerspectiveCamera;
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
const height = size.height || 1;
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / height;
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
// distanceFactor scales the html so it stays ~140 px tall at this distance
ref.current = isNaN(worldPerPixel) || !isFinite(worldPerPixel) ? 1.5 : Math.max(0.05, worldPerPixel * 140);
ref.current = Math.max(0.05, worldPerPixel * 140);
});
return ref.current;
}
function DynamicLabel({ position, text, borderClass, textClass }: { position: [number, number, number]; text: string; borderClass: string; textClass: string }) {
const dfDynamic = useLabelDistanceFactor(position);
return (
<Html position={position} center distanceFactor={dfDynamic} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
<span className={`font-mono text-[11px] font-bold ${textClass} whitespace-nowrap`}>
{text}
</span>
</div>
</Html>
);
}
function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) {
const dfDynamic = useLabelDistanceFactor(position);
const df = fixed ? dfDynamic : 1.5;
const borderClass = variant === 'success'
? 'border-success/60'
: variant === 'accent'
@@ -416,11 +375,8 @@ function MeasurementLabel({ position, text, variant, fixed = false }: { position
: variant === 'accent'
? 'text-amber-400'
: 'text-primary';
return fixed ? (
<DynamicLabel position={position} text={text} borderClass={borderClass} textClass={textClass} />
) : (
<Html position={position} center distanceFactor={1.5} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
return (
<Html position={position} center distanceFactor={df} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
<span className={`font-mono text-[11px] font-bold ${textClass} whitespace-nowrap`}>
{text}
@@ -486,43 +442,6 @@ function ModelMeasurements({ modelId }: { modelId: string }) {
);
}
/** Renders snap indicator, hover tooltip, pending point and any *legacy*
* world-frame measurements that aren't attached to a specific model. */
function HoverLabelOverlay({ hoverInfo }: { hoverInfo: HoverInfo }) {
const position = hoverInfo.position;
const text = hoverInfo.type === 'hole'
? `Ø ${hoverInfo.value.toFixed(1)}`
: `${hoverInfo.value.toFixed(1)} mm`;
const variant = hoverInfo.type === 'hole' ? 'accent' : 'primary';
const dfDynamic = useLabelDistanceFactor(position);
const borderClass = variant === 'accent'
? 'border-amber-400/70'
: 'border-primary/60';
const textClass = variant === 'accent'
? 'text-amber-400'
: 'text-primary';
return (
<Html
position={position}
center
distanceFactor={dfDynamic}
zIndexRange={[100, 0]}
style={{
pointerEvents: 'none',
}}
>
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
<span className={`font-mono text-[11px] font-bold ${textClass} whitespace-nowrap`}>
{text}
</span>
</div>
</Html>
);
}
/** Renders snap indicator, hover tooltip, pending point and any *legacy*
* world-frame measurements that aren't attached to a specific model. */
function MeasurementOverlay() {
@@ -541,7 +460,14 @@ function MeasurementOverlay() {
{/* Hover auto-detect tooltip (works both inside and outside measure mode) */}
{hoverInfo && (
<HoverLabelOverlay hoverInfo={hoverInfo} />
<MeasurementLabel
position={hoverInfo.position}
text={hoverInfo.type === 'hole'
? `Ø ${hoverInfo.value.toFixed(1)}`
: `${hoverInfo.value.toFixed(1)} mm`}
variant={hoverInfo.type === 'hole' ? 'accent' : 'primary'}
fixed
/>
)}
{/* Pending first point (always in world coords) */}
@@ -630,9 +556,7 @@ function SmartSnapHandler() {
}
}
if (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 });
setSnapPoint(bestHole);
return;
}
@@ -643,8 +567,7 @@ function SmartSnapHandler() {
10
);
if (snap) {
const hitModelId = findModelId(hit.object);
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId });
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z });
} else {
setSnapPoint(null);
}
@@ -712,13 +635,11 @@ 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);
@@ -727,7 +648,6 @@ function HoverDetector() {
type: 'hole',
position: [hole.center.x, hole.center.y, hole.center.z],
value: hole.diameterMM,
modelId: hitModelId,
});
return true;
}
@@ -742,7 +662,6 @@ 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 },
@@ -826,7 +745,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, modelId: st.snapPoint.modelId });
st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z });
return;
}
@@ -845,8 +764,7 @@ function MeasureClickHandler() {
return true;
});
if (hit) {
const hitModelId = findModelId(hit.object);
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId });
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
}
};
@@ -1094,8 +1012,8 @@ function PositionDragHandler() {
const ft = active.fineTuning;
const factor = st.scaleRatio?.factor ?? 1;
if (button === 0) {
// Left button (Trigger in Quest emulated mouse): rotate (unifies with AR / VR standard)
if (button === 2) {
// Right button: rotate
const sens = 0.4; // deg per pixel
if (shiftKey) {
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens });
@@ -1122,8 +1040,8 @@ function PositionDragHandler() {
} else {
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta });
}
} else if (button === 2 && shiftKey) {
// Shift+Right button: depth (Z in camera space)
} else if (button === 0 && shiftKey) {
// Shift+left: depth (Z in camera space)
const worldDelta = dy * pixelsPerWorldUnit;
const camDir = new THREE.Vector3();
camera.getWorldDirection(camDir);
@@ -1134,7 +1052,7 @@ function PositionDragHandler() {
posZ: ft.posZ + move.z,
});
} else {
// Right button (Grip in Quest emulated mouse): translate in camera plane (matches AR pan-only grip)
// Left: translate in camera plane
const right = new THREE.Vector3();
const up = new THREE.Vector3();
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
@@ -1176,288 +1094,57 @@ function PositionDragHandler() {
interface SceneInsideXRProps {
placementMode: boolean;
setPlacementMode: (v: boolean) => void;
snapToPlanes: boolean;
setSnapToPlanes: (v: boolean) => void;
allowScale: boolean;
setAllowScale: (v: boolean) => void;
liveCode: string | null;
setLiveCode: (v: string | null) => void;
liveViewers: number;
setLiveViewers: (v: number) => void;
freeMove: boolean;
setFreeMove: (v: boolean) => void;
simXR: boolean;
setSimXR: (v: boolean) => void;
onPresentationChange: (v: boolean) => void;
}
function SceneInsideXR({
placementMode,
setPlacementMode,
snapToPlanes,
setSnapToPlanes,
allowScale,
setAllowScale,
liveCode,
setLiveCode,
liveViewers,
setLiveViewers,
freeMove,
setFreeMove,
simXR,
setSimXR,
onPresentationChange,
}: SceneInsideXRProps) {
const isPresenting = useXR((state) => !!state.session);
export function ModelViewerCanvas({ url }: ModelViewerProps) {
const positionMode = useModelStore((s) => s.positionMode);
const activeId = useModelStore((s) => s.activeModelId);
const models = useModelStore((s) => s.models);
const activeModel = models.find(m => m.id === activeId);
const isActiveLocked = !!activeModel?.locked;
const xrEnvironment = useModelStore((s) => s.xrEnvironment);
const desktopCameraMode = useModelStore((s) => s.desktopCameraMode);
const rigRef = useRef<THREE.Group>(null);
const devkit = useDevKit();
const effectiveInXR = isPresenting || simXR;
useEffect(() => {
onPresentationChange(isPresenting);
}, [isPresenting, onPresentationChange]);
// Monitorar início e fim da sessão
useEffect(() => {
if (isPresenting) {
toast.success('Sessão AR iniciada!');
}
}, [isPresenting]);
if (!effectiveInXR) {
// Modo 2D normal (não imersivo)
return (
<>
{xrEnvironment === 'passthrough' && (
<>
<ambientLight intensity={0.6} />
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
</>
)}
<XREnvironmentSelector />
<Suspense fallback={<LoadingFallback />}>
<SceneModels />
</Suspense>
<MeasurementOverlay />
<MeasureClickHandler />
<SmartSnapHandler />
<HoverDetector />
<GridLayer />
<GridAutoFollower />
<PositionDragHandler />
<SelectionHandler />
<VisibilityApplier />
<SceneRefCapture />
{desktopCameraMode === 'orbit' ? (
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
enabled={!positionMode}
/>
) : (
<DesktopFirstPersonControls />
)}
</>
);
}
// Modo AR imersivo (ou SimXR)
const measureMode = useModelStore((s) => s.measureMode);
const selectionMode = useModelStore((s) => s.selectionMode);
return (
<>
{xrEnvironment === 'passthrough' && (
<>
<ambientLight intensity={0.8} />
<directionalLight position={[3, 5, 3]} intensity={1.2} />
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
</>
)}
<Canvas
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
frameloop="always"
className="!bg-background"
onPointerMissed={(e) => {
if (useModelStore.getState().selectionMode) {
(e.target as HTMLCanvasElement).dispatchEvent(new CustomEvent('tsxr-selection-miss', { detail: e }));
}
}}
onCreated={({ gl }) => {
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
const maxRatio = isApple ? 2 : 1.5;
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
}}
>
<ambientLight intensity={0.6} />
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
<Suspense fallback={<LoadingFallback />}>
<SceneModels />
</Suspense>
<XREnvironmentSelector />
<MeasurementOverlay />
<MeasureClickHandler />
<SmartSnapHandler />
<HoverDetector />
<group ref={rigRef}>
<XROrigin />
</group>
<GridLayer />
<GridAutoFollower />
{simXR ? (
<group position={[0, 1.0, -1.2]}>
<XRBackgroundModels />
<XRGrabbable
allowScale={allowScale}
lockedActive={isActiveLocked}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRActiveModel />
</XRGrabbable>
</group>
) : (
<XRHitTestPlacement
placementMode={placementMode}
snapToPlanes={snapToPlanes}
onPlace={() => {
setPlacementMode(false);
toast.success('Modelo posicionado na superfície!');
}}
>
<XRBackgroundModels />
<XRGrabbable
allowScale={allowScale}
lockedActive={isActiveLocked}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRActiveModel />
</XRGrabbable>
</XRHitTestPlacement>
)}
<XRMeasurementOverlay />
<XRControllerMeasure />
<ControllerLocomotion rigRef={rigRef} />
<PositionDragHandler />
<SelectionHandler />
<VisibilityApplier />
<SceneRefCapture />
<XRHudInWorld
freeMove={freeMove}
onToggleFreeMove={() => setFreeMove(!freeMove)}
placementMode={placementMode}
onTogglePlacement={() => setPlacementMode(!placementMode)}
snapToPlanes={snapToPlanes}
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
allowScale={allowScale}
onToggleAllowScale={() => setAllowScale(!allowScale)}
liveCode={liveCode}
liveViewers={liveViewers}
onStartLive={() => (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive?.()}
onStopLive={() => (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive?.()}
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
enabled={!positionMode}
/>
<XRBroadcastMirror enabled />
{devkit && simXR && <FakeControllers />}
{simXR && (
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
target={[0, 1.0, -1.2]}
/>
)}
<XRSnapHandler />
<XRGrid />
<XRGridAutoFollower />
</>
);
}
export function ModelViewerCanvas({ url }: ModelViewerProps) {
const [placementMode, setPlacementMode] = useState(true);
const [snapToPlanes, setSnapToPlanes] = useState(true);
const [allowScale, setAllowScale] = useState(true);
const [liveCode, setLiveCode] = useState<string | null>(null);
const [liveViewers, setLiveViewers] = useState(0);
const [freeMove, setFreeMove] = useState(true);
const [simXR, setSimXR] = useState(false);
const [isPresenting, setIsPresenting] = useState(false);
const devkit = useDevKit();
const effectiveInXR = isPresenting || simXR;
return (
<div className="relative w-full h-full">
<Canvas
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
frameloop="always"
className="!bg-background"
onPointerMissed={(e) => {
if (useModelStore.getState().selectionMode) {
(e.target as HTMLCanvasElement).dispatchEvent(new CustomEvent('tsxr-selection-miss', { detail: e }));
}
}}
onCreated={({ gl }) => {
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
const maxRatio = isApple ? 2 : 1.5;
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
}}
>
<XR store={xrStore}>
<SceneInsideXR
placementMode={placementMode}
setPlacementMode={setPlacementMode}
snapToPlanes={snapToPlanes}
setSnapToPlanes={setSnapToPlanes}
allowScale={allowScale}
setAllowScale={setAllowScale}
liveCode={liveCode}
setLiveCode={setLiveCode}
liveViewers={liveViewers}
setLiveViewers={setLiveViewers}
freeMove={freeMove}
setFreeMove={setFreeMove}
simXR={simXR}
setSimXR={setSimXR}
onPresentationChange={setIsPresenting}
/>
</XR>
</Canvas>
{/* Floating HUD overlay - visível apenas fora do headset passthrough */}
{effectiveInXR && !isPresenting && (
<div className="absolute inset-0 pointer-events-none z-30">
<div className="pointer-events-auto">
<XRHud
freeMove={freeMove}
onToggleFreeMove={() => setFreeMove(!freeMove)}
placementMode={placementMode}
onTogglePlacement={() => setPlacementMode(!placementMode)}
snapToPlanes={snapToPlanes}
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
allowScale={allowScale}
onToggleAllowScale={() => setAllowScale(!allowScale)}
/>
</div>
</div>
)}
{/* Compartilhar botões ocultos para WebRTC e HUD de fora */}
<div className="hidden">
<ShareButton
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
onViewerCountChange={setLiveViewers}
/>
</div>
{/* DevPanel se devkit habilitado */}
{devkit && (
<div className="absolute inset-0 pointer-events-none z-40">
<div className="pointer-events-auto">
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
</div>
</div>
)}
</div>
</Canvas>
);
}