Adicionou ViewCube em Viewer
X-Lovable-Edit-ID: edt-9f7240de-a34f-415d-9dd8-de7b20a1f746 Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
|||||||
|
import { useRef, useMemo, useEffect, useState } from 'react';
|
||||||
|
import { Canvas, useFrame, useThree, ThreeEvent } from '@react-three/fiber';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { mainCameraRef, mainControlsRef, requestView } from './three/viewCubeBus';
|
||||||
|
|
||||||
|
/** Builds a square canvas texture with a label centered on it. */
|
||||||
|
function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
|
||||||
|
const size = 256;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = size;
|
||||||
|
c.height = size;
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
|
// Background
|
||||||
|
const grad = ctx.createLinearGradient(0, 0, 0, size);
|
||||||
|
grad.addColorStop(0, accent ? '#1a2742' : '#101725');
|
||||||
|
grad.addColorStop(1, accent ? '#0c1424' : '#070b14');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = '#00f3ff';
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.strokeRect(3, 3, size - 6, size - 6);
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = '#e6faff';
|
||||||
|
ctx.font = '700 56px JetBrains Mono, ui-monospace, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.shadowColor = '#00f3ff';
|
||||||
|
ctx.shadowBlur = 18;
|
||||||
|
ctx.fillText(label, size / 2, size / 2);
|
||||||
|
const tex = new THREE.CanvasTexture(c);
|
||||||
|
tex.anisotropy = 4;
|
||||||
|
tex.needsUpdate = true;
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACE_DEFS: { label: string; dir: [number, number, number] }[] = [
|
||||||
|
// BoxGeometry material order: +X, -X, +Y, -Y, +Z, -Z
|
||||||
|
{ label: 'DIR', dir: [ 1, 0, 0] },
|
||||||
|
{ label: 'ESQ', dir: [-1, 0, 0] },
|
||||||
|
{ label: 'TOPO', dir: [ 0, 1, 0] },
|
||||||
|
{ label: 'BASE', dir: [ 0,-1, 0] },
|
||||||
|
{ label: 'FRENTE', dir: [ 0, 0, 1] },
|
||||||
|
{ label: 'ATRÁS', dir: [ 0, 0,-1] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CubeMesh() {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
const { camera: localCam } = useThree();
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const materials = useMemo(() => {
|
||||||
|
return FACE_DEFS.map((f, i) => {
|
||||||
|
const tex = makeFaceTexture(f.label, false);
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ map: tex });
|
||||||
|
mat.userData.baseTex = tex;
|
||||||
|
mat.userData.hoverTex = makeFaceTexture(f.label, true);
|
||||||
|
mat.userData.faceIndex = i;
|
||||||
|
return mat;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
materials.forEach((m) => {
|
||||||
|
m.userData.baseTex?.dispose?.();
|
||||||
|
m.userData.hoverTex?.dispose?.();
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// Apply hover tint
|
||||||
|
useEffect(() => {
|
||||||
|
materials.forEach((m, i) => {
|
||||||
|
m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex;
|
||||||
|
m.needsUpdate = true;
|
||||||
|
});
|
||||||
|
}, [hover, materials]);
|
||||||
|
|
||||||
|
// Mirror main camera orientation each frame
|
||||||
|
useFrame(() => {
|
||||||
|
const main = mainCameraRef.current;
|
||||||
|
const controls = mainControlsRef.current;
|
||||||
|
if (!main) return;
|
||||||
|
const target: THREE.Vector3 = controls?.target ?? new THREE.Vector3();
|
||||||
|
const dir = new THREE.Vector3().subVectors(main.position, target).normalize();
|
||||||
|
// Place local cam along same direction at fixed distance
|
||||||
|
localCam.position.copy(dir.multiplyScalar(3));
|
||||||
|
localCam.up.copy(main.up);
|
||||||
|
localCam.lookAt(0, 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClick = (e: ThreeEvent<MouseEvent>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const idx = e.face?.materialIndex;
|
||||||
|
if (idx == null) return;
|
||||||
|
const def = FACE_DEFS[idx];
|
||||||
|
if (!def) return;
|
||||||
|
requestView(new THREE.Vector3(...def.dir));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
ref={meshRef}
|
||||||
|
material={materials}
|
||||||
|
onClick={onClick}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const idx = e.face?.materialIndex ?? null;
|
||||||
|
setHover(idx);
|
||||||
|
}}
|
||||||
|
onPointerOut={() => setHover(null)}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[1.4, 1.4, 1.4]} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiny axis tripod next to the cube to reinforce the UCS reading. */
|
||||||
|
function AxisTripod() {
|
||||||
|
return (
|
||||||
|
<group position={[-1.2, -1.2, 0]}>
|
||||||
|
<arrowHelper args={[new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0), 0.7, 0xff4444, 0.18, 0.12]} />
|
||||||
|
<arrowHelper args={[new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0), 0.7, 0x44ff66, 0.18, 0.12]} />
|
||||||
|
<arrowHelper args={[new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 0), 0.7, 0x4488ff, 0.18, 0.12]} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewCube() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute right-3 top-3 z-20 h-[120px] w-[120px] rounded-md border border-primary/30 bg-background/40 backdrop-blur-sm shadow-lg"
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
|
title="Cubo de vistas — clique numa face para girar"
|
||||||
|
>
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [2, 2, 2], fov: 35, near: 0.1, far: 50 }}
|
||||||
|
gl={{ antialias: true, alpha: true }}
|
||||||
|
dpr={[1, 2]}
|
||||||
|
>
|
||||||
|
<ambientLight intensity={1} />
|
||||||
|
<CubeMesh />
|
||||||
|
<AxisTripod />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCirc
|
|||||||
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
import { parseIFCtoThree } from '@/lib/convertIFC';
|
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
import { mainCameraRef, mainControlsRef, viewAnim } from './viewCubeBus';
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
url?: string; // legacy, ignored — uses store.models
|
url?: string; // legacy, ignored — uses store.models
|
||||||
@@ -54,11 +55,36 @@ export function elementKey(modelId: string, el: THREE.Object3D): string {
|
|||||||
export const sceneRef: { current: THREE.Scene | null } = { current: null };
|
export const sceneRef: { current: THREE.Scene | null } = { current: null };
|
||||||
|
|
||||||
function SceneRefCapture() {
|
function SceneRefCapture() {
|
||||||
const { scene } = useThree();
|
const { scene, camera } = useThree();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sceneRef.current = scene;
|
sceneRef.current = scene;
|
||||||
return () => { if (sceneRef.current === scene) sceneRef.current = null; };
|
return () => { if (sceneRef.current === scene) sceneRef.current = null; };
|
||||||
}, [scene]);
|
}, [scene]);
|
||||||
|
useEffect(() => {
|
||||||
|
mainCameraRef.current = camera;
|
||||||
|
return () => {
|
||||||
|
if (mainCameraRef.current === camera) mainCameraRef.current = null;
|
||||||
|
};
|
||||||
|
}, [camera]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives the camera animation requested by the ViewCube. */
|
||||||
|
function ViewCubeAnimator() {
|
||||||
|
const { camera } = useThree();
|
||||||
|
useFrame((_state, delta) => {
|
||||||
|
if (!viewAnim.active) return;
|
||||||
|
viewAnim.t = Math.min(1, viewAnim.t + delta / viewAnim.duration);
|
||||||
|
const k = viewAnim.t < 0.5
|
||||||
|
? 2 * viewAnim.t * viewAnim.t
|
||||||
|
: 1 - Math.pow(-2 * viewAnim.t + 2, 2) / 2;
|
||||||
|
camera.position.lerpVectors(viewAnim.startPos, viewAnim.endPos, k);
|
||||||
|
camera.up.lerpVectors(viewAnim.startUp, viewAnim.endUp, k).normalize();
|
||||||
|
const controls = mainControlsRef.current;
|
||||||
|
if (controls?.target) camera.lookAt(controls.target);
|
||||||
|
controls?.update?.();
|
||||||
|
if (viewAnim.t >= 1) viewAnim.active = false;
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1138,6 +1164,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
<SelectionHandler />
|
<SelectionHandler />
|
||||||
<VisibilityApplier />
|
<VisibilityApplier />
|
||||||
<SceneRefCapture />
|
<SceneRefCapture />
|
||||||
|
<ViewCubeAnimator />
|
||||||
|
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
makeDefault
|
makeDefault
|
||||||
@@ -1146,6 +1173,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
minDistance={0.05}
|
minDistance={0.05}
|
||||||
maxDistance={50}
|
maxDistance={50}
|
||||||
enabled={!positionMode}
|
enabled={!positionMode}
|
||||||
|
ref={(c: any) => {
|
||||||
|
mainControlsRef.current = c;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared bus between the main 3D viewer and the floating ViewCube widget.
|
||||||
|
* - mainCameraRef / mainControlsRef: written by ModelViewer each frame
|
||||||
|
* - requestView(dir): asks the main camera to animate to a canonical view
|
||||||
|
* along the given world-space direction (unit vector pointing FROM target
|
||||||
|
* TO camera). e.g. (0,1,0) = top view, (0,0,1) = front.
|
||||||
|
*/
|
||||||
|
export const mainCameraRef: { current: THREE.Camera | null } = { current: null };
|
||||||
|
// OrbitControls instance — typed loosely to avoid drei type churn
|
||||||
|
export const mainControlsRef: { current: any | null } = { current: null };
|
||||||
|
|
||||||
|
export interface ViewAnim {
|
||||||
|
active: boolean;
|
||||||
|
startPos: THREE.Vector3;
|
||||||
|
endPos: THREE.Vector3;
|
||||||
|
startUp: THREE.Vector3;
|
||||||
|
endUp: THREE.Vector3;
|
||||||
|
t: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewAnim: ViewAnim = {
|
||||||
|
active: false,
|
||||||
|
startPos: new THREE.Vector3(),
|
||||||
|
endPos: new THREE.Vector3(),
|
||||||
|
startUp: new THREE.Vector3(0, 1, 0),
|
||||||
|
endUp: new THREE.Vector3(0, 1, 0),
|
||||||
|
t: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function requestView(dir: THREE.Vector3) {
|
||||||
|
const cam = mainCameraRef.current;
|
||||||
|
const controls = mainControlsRef.current;
|
||||||
|
if (!cam || !controls) return;
|
||||||
|
const target: THREE.Vector3 = controls.target ?? new THREE.Vector3();
|
||||||
|
const distance = cam.position.distanceTo(target);
|
||||||
|
const d = dir.clone().normalize();
|
||||||
|
const endPos = target.clone().add(d.multiplyScalar(distance));
|
||||||
|
// Up vector: keep Y up unless we're looking straight up/down
|
||||||
|
const up = new THREE.Vector3(0, 1, 0);
|
||||||
|
if (Math.abs(dir.y) > 0.99) {
|
||||||
|
up.set(0, 0, dir.y > 0 ? -1 : 1);
|
||||||
|
}
|
||||||
|
viewAnim.startPos.copy(cam.position);
|
||||||
|
viewAnim.endPos.copy(endPos);
|
||||||
|
viewAnim.startUp.copy(cam.up);
|
||||||
|
viewAnim.endUp.copy(up);
|
||||||
|
viewAnim.t = 0;
|
||||||
|
viewAnim.active = true;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { InspectionChecklist } from "@/components/InspectionChecklist";
|
|||||||
import { FineTuningControls } from "@/components/FineTuningControls";
|
import { FineTuningControls } from "@/components/FineTuningControls";
|
||||||
import { MeasurementsList } from "@/components/MeasurementsList";
|
import { MeasurementsList } from "@/components/MeasurementsList";
|
||||||
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
||||||
|
import { ViewCube } from "@/components/ViewCube";
|
||||||
import { ShareButton } from "@/components/ShareButton";
|
import { ShareButton } from "@/components/ShareButton";
|
||||||
import { CloudLoader } from "@/components/CloudLoader";
|
import { CloudLoader } from "@/components/CloudLoader";
|
||||||
import { SceneModelList } from "@/components/SceneModelList";
|
import { SceneModelList } from "@/components/SceneModelList";
|
||||||
@@ -138,6 +139,7 @@ const Viewer = () => {
|
|||||||
{/* 3D Canvas with floating controls */}
|
{/* 3D Canvas with floating controls */}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<ModelViewerCanvas />
|
<ModelViewerCanvas />
|
||||||
|
<ViewCube />
|
||||||
<ViewerControls />
|
<ViewerControls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user