diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx
index b7eb4cf..741304d 100644
--- a/src/components/ViewerControls.tsx
+++ b/src/components/ViewerControls.tsx
@@ -36,6 +36,7 @@ export function ViewerControls() {
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
cameraMode, setCameraMode,
+ gridCalibMode, setGridCalibMode, setGridAutoFollow,
} = useModelStore();
const activeModel = models.find(m => m.id === activeModelId);
const hasSelection = selectedElementKeys.size > 0;
@@ -106,6 +107,30 @@ export function ViewerControls() {
Grid
+ {/* Grid calibration: click a face on the piece to anchor the grid there */}
+
+
{/* Scale presets */}
diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx
index 2ff0ced..992c984 100644
--- a/src/components/three/ModelViewer.tsx
+++ b/src/components/three/ModelViewer.tsx
@@ -5,7 +5,7 @@ import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
-import { registerModelLocalGroup, unregisterModelLocalGroup, getModelWorldScaleFactor } from '@/lib/modelTransforms';
+import { registerModelLocalGroup, unregisterModelLocalGroup, getModelWorldScaleFactor, getAllModelLocalGroups } from '@/lib/modelTransforms';
import { parseIFCtoThree } from '@/lib/convertIFC';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { mainCameraRef, mainControlsRef, viewAnim, calibration, pushModelFaceNormal, computeCalibrationQuaternion, subscribeCalibration } from './viewCubeBus';
@@ -1035,34 +1035,39 @@ function GridLayer() {
);
}
-/** Continuously aligns the grid Y to the bottom of all visible models so the
- * piece always looks like it is sitting on the grid — including after
- * calibration, fine-tuning, repositioning or scale changes. */
+/** Continuously aligns the grid Y to the bottom of all registered model
+ * groups (not the entire scene), so the piece always sits on the grid —
+ * including after calibration, fine-tuning or repositioning. */
function GridAutoFollower() {
- const { scene } = useThree();
const lastY = useRef(Number.NaN);
const acc = useRef(0);
useFrame((_, dt) => {
const auto = useModelStore.getState().gridAutoFollow;
if (!auto) return;
- // Throttle to ~10 Hz to keep this cheap.
acc.current += dt;
if (acc.current < 0.1) return;
acc.current = 0;
+ const groups = getAllModelLocalGroups();
+ if (groups.length === 0) return;
const box = new THREE.Box3();
let has = false;
- scene.traverse((obj) => {
- if (obj instanceof THREE.Mesh && obj.geometry) {
+ const tmp = new THREE.Box3();
+ for (const g of groups) {
+ g.updateWorldMatrix(true, true);
+ tmp.makeEmpty();
+ g.traverse((obj) => {
+ if (!(obj instanceof THREE.Mesh) || !obj.geometry) return;
if (obj.geometry instanceof THREE.SphereGeometry) return;
if (obj.geometry instanceof THREE.RingGeometry) return;
if (obj.userData.__edgeLine) return;
const b = new THREE.Box3().setFromObject(obj);
- if (isFinite(b.min.y)) {
- if (!has) { box.copy(b); has = true; }
- else box.union(b);
- }
+ if (isFinite(b.min.y)) tmp.union(b);
+ });
+ if (!tmp.isEmpty() && isFinite(tmp.min.y)) {
+ if (!has) { box.copy(tmp); has = true; }
+ else box.union(tmp);
}
- });
+ }
if (!has) return;
const target = box.min.y - 0.005;
if (!Number.isFinite(lastY.current) || Math.abs(target - lastY.current) > 1e-4) {
@@ -1073,6 +1078,43 @@ function GridAutoFollower() {
return null;
}
+/** When gridCalibMode is on, the next click on the model sets the grid Y to
+ * the world Y of the clicked surface point and disables auto-follow. */
+function GridCalibrationHandler() {
+ const { camera, scene, gl } = useThree();
+ const gridCalibMode = useModelStore((s) => s.gridCalibMode);
+ const raycaster = useMemo(() => new THREE.Raycaster(), []);
+ const mouse = useMemo(() => new THREE.Vector2(), []);
+ useEffect(() => {
+ if (!gridCalibMode) return;
+ const canvas = gl.domElement;
+ const prevCursor = canvas.style.cursor;
+ canvas.style.cursor = 'crosshair';
+ const onPointerUp = (ev: PointerEvent) => {
+ if (ev.button !== 0) return;
+ const rect = canvas.getBoundingClientRect();
+ mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
+ mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
+ raycaster.setFromCamera(mouse, camera);
+ const hits = raycaster.intersectObjects(scene.children, true);
+ const hit = hits.find((h) => isPickableModelMesh(h.object));
+ if (!hit) return;
+ useModelStore.setState({
+ gridY: hit.point.y - 0.001,
+ gridAutoFollow: false,
+ gridCalibMode: false,
+ showGrid: true,
+ });
+ };
+ canvas.addEventListener('pointerup', onPointerUp);
+ return () => {
+ canvas.removeEventListener('pointerup', onPointerUp);
+ canvas.style.cursor = prevCursor;
+ };
+ }, [gridCalibMode, camera, scene, gl, raycaster, mouse]);
+ return null;
+}
+
/** Mouse drag handler for desktop "Posicionar" mode: translates/rotates the active model. */
function PositionDragHandler() {
const { camera, gl, scene } = useThree();
@@ -1259,6 +1301,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
+
diff --git a/src/lib/modelTransforms.ts b/src/lib/modelTransforms.ts
index c3ef211..c69f135 100644
--- a/src/lib/modelTransforms.ts
+++ b/src/lib/modelTransforms.ts
@@ -22,6 +22,10 @@ export function getModelLocalGroup(modelId: string | null | undefined): THREE.Ob
return localGroups.get(modelId);
}
+export function getAllModelLocalGroups(): THREE.Object3D[] {
+ return Array.from(localGroups.values());
+}
+
export function worldToModelLocal(
modelId: string | null | undefined,
p: { x: number; y: number; z: number },
diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts
index 82680f3..ba96846 100644
--- a/src/stores/useModelStore.ts
+++ b/src/stores/useModelStore.ts
@@ -257,6 +257,9 @@ interface ModelStore {
setGridAutoFollow: (b: boolean) => void;
/** Nudge grid by delta meters and disable auto-follow. */
nudgeGridY: (delta: number) => void;
+ /** When true, the next click on a model face sets grid Y to that point. */
+ gridCalibMode: boolean;
+ setGridCalibMode: (b: boolean) => void;
wireframeColor: string;
setWireframeColor: (color: string) => void;
@@ -535,6 +538,8 @@ export const useModelStore = create((set, get) => ({
gridAutoFollow: true,
setGridAutoFollow: (gridAutoFollow) => set({ gridAutoFollow }),
nudgeGridY: (delta) => set((s) => ({ gridY: s.gridY + delta, gridAutoFollow: false })),
+ gridCalibMode: false,
+ setGridCalibMode: (gridCalibMode) => set({ gridCalibMode }),
wireframeColor: '#00f3ff',
setWireframeColor: (wireframeColor) => set({ wireframeColor }),