From 243cfc982525d975aa88d2548bde2639059ac05b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 19 Oct 2026 17:54:47 +0800 Subject: [PATCH] Use furniture Z-up axes in installation demo editing Map X to width, Y to depth, and Z to height in numeric movement, drag and rotation controls, and feedback. Preserve existing Y-up model poses and saved data, and extract transform-anchor helpers. --- .../InstallationExplosionMoveInput.test.tsx | 3 +- .../InstallationExplosionMoveInput.tsx | 7 +- .../components/InstallationPreviewStage.tsx | 1 + .../src/components/3d/ModelViewer.tsx | 227 ++++-------------- .../3d/__tests__/ModelViewer.gizmo.test.tsx | 85 ++++++- .../3d/model-viewer-transform-anchor.ts | 163 +++++++++++++ .../components/platform/ModelPreviewPanel.tsx | 4 + .../__tests__/ModelPreviewPanel.test.tsx | 7 + .../3d/__tests__/coordinate-system.test.ts | 38 +++ .../src/lib/3d/coordinate-system.ts | 41 ++++ 10 files changed, 390 insertions(+), 186 deletions(-) create mode 100644 furniture-3d-system/src/components/3d/model-viewer-transform-anchor.ts create mode 100644 furniture-3d-system/src/lib/3d/__tests__/coordinate-system.test.ts create mode 100644 furniture-3d-system/src/lib/3d/coordinate-system.ts diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationExplosionMoveInput.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationExplosionMoveInput.test.tsx index 97c5dc6b..7c26f6da 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationExplosionMoveInput.test.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationExplosionMoveInput.test.tsx @@ -9,7 +9,8 @@ describe('explosion movement inputs', () => { expect(onTranslate).not.toHaveBeenCalled() fireEvent.change(screen.getByRole('spinbutton', { name: 'Y 轴位移 mm' }), { target: { value: '-12.5' } }) fireEvent.submit(screen.getByRole('form', { name: '精确移动部件' })) - expect(onTranslate).toHaveBeenCalledWith([0, -12.5, 200]) + expect(onTranslate).toHaveBeenCalledWith([0, 200, 12.5]) + expect(screen.getByRole('status')).toHaveTextContent('已移动:Y -12.5 mm,Z +200 mm') expect(screen.getByRole('spinbutton', { name: 'Z 轴位移 mm' })).toHaveValue(0) expect(screen.getByRole('button', { name: '应用位移' })).toBeDisabled() fireEvent.submit(screen.getByRole('form', { name: '精确移动部件' })) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationExplosionMoveInput.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationExplosionMoveInput.tsx index 22833cea..f3a6cd65 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationExplosionMoveInput.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationExplosionMoveInput.tsx @@ -1,6 +1,7 @@ 'use client' import { useState } from 'react' +import { toWorldVector3 } from '@/lib/3d/coordinate-system' import type { InstallationVector3 } from '../types' interface InstallationExplosionMoveInputProps { @@ -21,7 +22,7 @@ export function InstallationExplosionMoveInput({ disabled, onTranslate }: Instal onSubmit={event => { event.preventDefault() if (!canApply) return - onTranslate(delta) + onTranslate(toWorldVector3(delta, 'z-up')) setLastMove(delta.map((value, axis) => value === 0 ? '' : `${['X', 'Y', 'Z'][axis]} ${value > 0 ? '+' : ''}${value} mm`).filter(Boolean).join(',')) setDraft(['0', '0', '0']) }} @@ -30,7 +31,7 @@ export function InstallationExplosionMoveInput({ disabled, onTranslate }: Instal
{(['X', 'Y', 'Z'] as const).map((axis, index) => (

- {lastMove ? `已移动:${lastMove}` : '正数沿轴正方向移动,负数反向移动;按回车应用。'} + {lastMove ? `已移动:${lastMove}` : '正方向:X 向右 · Y 向后 · Z 向上;负数反向,按回车应用。'}

) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx index fb66fa32..26f1b340 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx @@ -615,6 +615,7 @@ export function InstallationPreviewStage({ preserveDrawingBuffer repairDisplayNormals installationPerformance={installationPerformance} + coordinateSystem="z-up" showFpsBadge={false} className="h-full" viewportControlsEnabled={ diff --git a/furniture-3d-system/src/components/3d/ModelViewer.tsx b/furniture-3d-system/src/components/3d/ModelViewer.tsx index f44bd4a9..93daca59 100644 --- a/furniture-3d-system/src/components/3d/ModelViewer.tsx +++ b/furniture-3d-system/src/components/3d/ModelViewer.tsx @@ -1,6 +1,19 @@ "use client"; import * as THREE from "three"; +import { + configureGizmoCoordinateFrame, + fromWorldVector3, + getCoordinateWorldAxes, + type ViewportCoordinateSystem, +} from "@/lib/3d/coordinate-system"; +import { + applyGizmoAnchorTransformToTarget, + getObjectWorldBoundsCenter, + MODEL_VIEWER_GIZMO_ANCHOR_NAME, + syncGizmoAnchorToSelectionCenter, + type TransformGizmoTargetState, +} from "./model-viewer-transform-anchor"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { AlertTriangle, RefreshCw } from "lucide-react"; @@ -104,6 +117,7 @@ interface ModelViewerProps { gizmoMode?: 'translate' | 'rotate'; gizmoSize?: number; gizmoAxisColors?: Partial>; + coordinateSystem?: ViewportCoordinateSystem; onGizmoInteractionStart?: (nodeName: string) => void; onGizmoInteractionEnd?: (nodeName: string) => void; onNodePositionOffsetChange?: (payload: { @@ -146,179 +160,12 @@ interface TransformFeedbackBaseline { worldQuaternion: THREE.Quaternion; } -interface TransformGizmoTargetState { - nodeName: string | null; - nodeNames: string[]; - object: THREE.Object3D; - targetObjects: Array<{ - nodeName: string; - object: THREE.Object3D; - baselineTargetMatrixWorld: THREE.Matrix4 | null; - }>; - anchor: THREE.Object3D; - isFallbackModelTarget: boolean; - baselineAnchorPosition: THREE.Vector3 | null; - baselineAnchorQuaternion: THREE.Quaternion | null; - baselineTargetMatrixWorld: THREE.Matrix4 | null; -} - const TRANSFORM_FEEDBACK_AXIS_KEYS: AxisKey[] = ["X", "Y", "Z"]; const TRANSFORM_FEEDBACK_AXIS_COLORS: Record = { X: "#8f625d", Y: "#5f766e", Z: "#5f6d82", }; -const TRANSFORM_FEEDBACK_WORLD_AXIS: Record = { - X: new THREE.Vector3(1, 0, 0), - Y: new THREE.Vector3(0, 1, 0), - Z: new THREE.Vector3(0, 0, 1), -}; -const MODEL_VIEWER_GIZMO_ANCHOR_NAME = "__model_viewer_gizmo_anchor__"; - -function getObjectWorldBoundsCenter(object: THREE.Object3D): THREE.Vector3 { - object.updateMatrixWorld(true); - const box = new THREE.Box3().setFromObject(object); - if (!box.isEmpty()) { - return box.getCenter(new THREE.Vector3()); - } - return object.getWorldPosition(new THREE.Vector3()); -} - -function getObjectsWorldBoundsCenter(objects: THREE.Object3D[]): THREE.Vector3 { - if (objects.length === 0) { - return new THREE.Vector3(); - } - - const box = new THREE.Box3(); - let hasBounds = false; - objects.forEach((object) => { - object.updateMatrixWorld(true); - const objectBox = new THREE.Box3().setFromObject(object); - if (objectBox.isEmpty()) { - return; - } - if (!hasBounds) { - box.copy(objectBox); - hasBounds = true; - return; - } - box.union(objectBox); - }); - - if (hasBounds) { - return box.getCenter(new THREE.Vector3()); - } - - return objects[0]?.getWorldPosition(new THREE.Vector3()) ?? new THREE.Vector3(); -} - -function syncGizmoAnchorToObjectCenter( - anchor: THREE.Object3D, - object: THREE.Object3D, - nodeName: string | null, -) { - anchor.name = nodeName - ? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}` - : MODEL_VIEWER_GIZMO_ANCHOR_NAME; - anchor.position.copy(getObjectWorldBoundsCenter(object)); - anchor.quaternion.copy(object.getWorldQuaternion(new THREE.Quaternion())); - anchor.scale.set(1, 1, 1); - anchor.updateMatrixWorld(true); -} - -function syncGizmoAnchorToSelectionCenter( - anchor: THREE.Object3D, - objects: THREE.Object3D[], - nodeName: string | null, -) { - const primaryObject = objects[0]; - if (!primaryObject) { - syncGizmoAnchorToObjectCenter(anchor, new THREE.Object3D(), nodeName); - return; - } - - anchor.name = nodeName - ? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}` - : MODEL_VIEWER_GIZMO_ANCHOR_NAME; - anchor.position.copy(getObjectsWorldBoundsCenter(objects)); - anchor.quaternion.copy(primaryObject.getWorldQuaternion(new THREE.Quaternion())); - anchor.scale.set(1, 1, 1); - anchor.updateMatrixWorld(true); -} - -function applyGizmoAnchorTransformToTarget( - targetState: TransformGizmoTargetState, - mode: "translate" | "rotate", -) { - const { - anchor, - baselineAnchorPosition, - baselineAnchorQuaternion, - } = targetState; - if ( - !baselineAnchorPosition || - !baselineAnchorQuaternion - ) { - return; - } - - anchor.updateMatrixWorld(true); - const targetObjects = - targetState.targetObjects.length > 0 - ? targetState.targetObjects - : targetState.nodeName && targetState.baselineTargetMatrixWorld - ? [ - { - nodeName: targetState.nodeName, - object: targetState.object, - baselineTargetMatrixWorld: targetState.baselineTargetMatrixWorld, - }, - ] - : []; - - targetObjects.forEach((targetObjectState) => { - const { object, baselineTargetMatrixWorld } = targetObjectState; - if (!baselineTargetMatrixWorld) { - return; - } - - object.parent?.updateMatrixWorld(true); - const nextWorldMatrix = new THREE.Matrix4(); - - if (mode === "rotate") { - const currentAnchorQuaternion = anchor.getWorldQuaternion( - new THREE.Quaternion(), - ); - const deltaQuaternion = currentAnchorQuaternion - .clone() - .multiply(baselineAnchorQuaternion.clone().invert()) - .normalize(); - const pivot = baselineAnchorPosition.clone(); - const rotateAroundPivotMatrix = new THREE.Matrix4() - .makeTranslation(pivot.x, pivot.y, pivot.z) - .multiply(new THREE.Matrix4().makeRotationFromQuaternion(deltaQuaternion)) - .multiply(new THREE.Matrix4().makeTranslation(-pivot.x, -pivot.y, -pivot.z)); - nextWorldMatrix - .copy(rotateAroundPivotMatrix) - .multiply(baselineTargetMatrixWorld); - } else { - const currentAnchorPosition = anchor.getWorldPosition(new THREE.Vector3()); - const deltaPosition = currentAnchorPosition.sub(baselineAnchorPosition); - nextWorldMatrix - .makeTranslation(deltaPosition.x, deltaPosition.y, deltaPosition.z) - .multiply(baselineTargetMatrixWorld); - } - - const parentWorldInverse = object.parent - ? object.parent.matrixWorld.clone().invert() - : new THREE.Matrix4(); - const nextLocalMatrix = parentWorldInverse.multiply(nextWorldMatrix); - nextLocalMatrix.decompose(object.position, object.quaternion, object.scale); - object.updateMatrix(); - object.updateMatrixWorld(true); - }); -} - function normalizeAngleRadians(value: number): number { let normalized = value; while (normalized <= -Math.PI) { @@ -751,6 +598,7 @@ function ModelViewerComponent({ gizmoMode = 'translate', gizmoSize = 0.72, gizmoAxisColors, + coordinateSystem = "y-up", onGizmoInteractionStart, onGizmoInteractionEnd, onNodePositionOffsetChange, @@ -787,6 +635,7 @@ function ModelViewerComponent({ ); const enableTranslateGizmoRef = useRef(enableTranslateGizmo); const gizmoModeRef = useRef(gizmoMode); + const coordinateSystemRef = useRef(coordinateSystem); const gizmoSizeRef = useRef(resolveGizmoSize(gizmoSize)); const gizmoAxisColorsRef = useRef(resolveGizmoAxisColors(gizmoAxisColors)); const selectedNodeNameRef = useRef(selectedNodeName); @@ -1008,6 +857,11 @@ function ModelViewerComponent({ gizmoModeRef.current = gizmoMode; }, [gizmoMode]); + useEffect(() => { + coordinateSystemRef.current = coordinateSystem; + syncTransformControlsRef.current?.(); + }, [coordinateSystem]); + useEffect(() => { gizmoSizeRef.current = resolveGizmoSize(gizmoSize); transformControlsRef.current?.setSize(gizmoSizeRef.current); @@ -1214,7 +1068,9 @@ function ModelViewerComponent({ } object.updateMatrixWorld(true); - const currentWorldPosition = object.getWorldPosition(new THREE.Vector3()); + const currentWorldPosition = coordinateSystemRef.current === "z-up" + ? getObjectWorldBoundsCenter(object) + : object.getWorldPosition(new THREE.Vector3()); const currentWorldQuaternion = object.getWorldQuaternion( new THREE.Quaternion(), ); @@ -1231,14 +1087,12 @@ function ModelViewerComponent({ transformControlsRef.current as TransformControls & { axis?: string | null } )?.axis; const activeAxisSet = getTransformFeedbackActiveAxes(activeAxis); + const worldAxes = getCoordinateWorldAxes(coordinateSystemRef.current); if (baseline.mode === "translate") { const delta = currentWorldPosition.clone().sub(baseline.worldPosition); - const valueByAxis: Record = { - X: delta.x, - Y: delta.y, - Z: delta.z, - }; + const [x, y, z] = fromWorldVector3(delta.toArray(), coordinateSystemRef.current); + const valueByAxis: Record = { X: x, Y: y, Z: z }; return TRANSFORM_FEEDBACK_AXIS_KEYS.flatMap((axis) => { const value = valueByAxis[axis]; @@ -1251,8 +1105,8 @@ function ModelViewerComponent({ const direction = value < 0 - ? TRANSFORM_FEEDBACK_WORLD_AXIS[axis].clone().multiplyScalar(-1) - : TRANSFORM_FEEDBACK_WORLD_AXIS[axis].clone(); + ? worldAxes[axis].clone().multiplyScalar(-1) + : worldAxes[axis].clone(); const screenPoint = projectWorldPointToContainer( currentWorldPosition .clone() @@ -1284,15 +1138,15 @@ function ModelViewerComponent({ const degreeByAxis: Record = { X: getSignedWorldAxisRotationDegrees( worldDeltaQuaternion, - TRANSFORM_FEEDBACK_WORLD_AXIS.X, + worldAxes.X, ), Y: getSignedWorldAxisRotationDegrees( worldDeltaQuaternion, - TRANSFORM_FEEDBACK_WORLD_AXIS.Y, + worldAxes.Y, ), Z: getSignedWorldAxisRotationDegrees( worldDeltaQuaternion, - TRANSFORM_FEEDBACK_WORLD_AXIS.Z, + worldAxes.Z, ), }; @@ -1307,8 +1161,8 @@ function ModelViewerComponent({ const direction = value < 0 - ? TRANSFORM_FEEDBACK_WORLD_AXIS[axis].clone().multiplyScalar(-1) - : TRANSFORM_FEEDBACK_WORLD_AXIS[axis].clone(); + ? worldAxes[axis].clone().multiplyScalar(-1) + : worldAxes[axis].clone(); const screenPoint = projectWorldPointToContainer( currentWorldPosition .clone() @@ -1898,7 +1752,9 @@ function ModelViewerComponent({ transformFeedbackBaselineRef.current = { nodeName: activeNodeName, mode: gizmoModeRef.current, - worldPosition: activeTargetState.baselineAnchorPosition.clone(), + worldPosition: coordinateSystemRef.current === "z-up" + ? getObjectWorldBoundsCenter(object) + : activeTargetState.baselineAnchorPosition.clone(), worldQuaternion: object.getWorldQuaternion(new THREE.Quaternion()), }; setTransformFeedbackLabels([]); @@ -1934,6 +1790,9 @@ function ModelViewerComponent({ } else { setTransformFeedbackLabels([]); } + if (coordinateSystemRef.current === "z-up") { + syncTransformControlsRef.current?.(); + } scheduleTransformFeedbackHide(); }; const handleMouseDown = () => { @@ -2182,6 +2041,11 @@ function ModelViewerComponent({ targetObjects.length > 0 ? targetObjects.map((item) => item.object) : [targetObject], activeNodeName, ); + configureGizmoCoordinateFrame( + currentTransformControls, + transformAnchor, + coordinateSystemRef.current, + ); } const isSamePrimaryTarget = previousTargetState?.anchor === transformAnchor && @@ -2930,6 +2794,7 @@ export const ModelViewer = memo( prevProps.installationPerformance === nextProps.installationPerformance && prevProps.enableTranslateGizmo === nextProps.enableTranslateGizmo && prevProps.gizmoMode === nextProps.gizmoMode && + prevProps.coordinateSystem === nextProps.coordinateSystem && prevProps.gizmoSize === nextProps.gizmoSize && areStringRecordsEqual( prevProps.gizmoAxisColors, diff --git a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.gizmo.test.tsx b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.gizmo.test.tsx index 535c7170..ed8fbf3b 100644 --- a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.gizmo.test.tsx +++ b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.gizmo.test.tsx @@ -31,7 +31,7 @@ const resolveDefaultTransformTargetMock = jest.fn(() => ({ })); const sceneAddMock = jest.fn(); const sceneRemoveMock = jest.fn(); -const orbitControls = { enabled: true }; +const orbitControls = { enabled: true, update: jest.fn() }; const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 5000); camera.position.set(0, 0, 1000); camera.lookAt(0, 0, 0); @@ -103,6 +103,7 @@ jest.mock("three/examples/jsm/controls/TransformControls.js", () => { jest.mock("@/lib/3d/engine", () => ({ ThreeDEngine: jest.fn().mockImplementation(() => ({ loadModel: loadModelMock, + setRepairDisplayNormals: jest.fn(), getNamedNodeNames: getNamedNodeNamesMock, setHiddenNodes: setHiddenNodesMock, getModelRoot: getModelRootMock, @@ -264,6 +265,88 @@ describe("ModelViewer gizmo mode switching", () => { ); }); + it('switches only the gizmo frame when the coordinate convention changes', async () => { + const view = render(); + await flushViewer(); + const controls = transformControlsInstances[0]!; + expect(controls.setSpace).toHaveBeenLastCalledWith('world'); + view.rerender(); + await flushViewer(); + expect(transformControlsInstances).toHaveLength(1); + expect(controls.setSpace).toHaveBeenLastCalledWith('local'); + view.rerender(); + await flushViewer(); + expect(controls.setSpace).toHaveBeenLastCalledWith('world'); + expect(targetObject.position.toArray()).toEqual([0, 0, 0]); + expect(targetObject.quaternion.toArray()).toEqual([0, 0, 0, 1]); + }); + + it.each([ + ["X", [200, 0, 0]], + ["Y", [0, 0, -200]], + ["Z", [0, 200, 0]], + ] as const)("Z-up %s drag uses the matching furniture axis and preserves stored pose", async (axis, delta) => { + targetObject.traverse(object => { + if (object instanceof THREE.Mesh) object.geometry.translate(40, -30, -20); + }); + targetObject.position.set(10, 20, 30); + targetObject.rotation.set(0.3, 0.7, 0.2); + targetObject.updateMatrixWorld(true); + const originalRotation = targetObject.quaternion.clone(); + getNodeLocalRotationMock.mockReturnValue(originalRotation.toArray()); + const onOffset = jest.fn(); + getNodeWorldOffsetMock.mockImplementation(() => targetObject.position.toArray()); + render(); + await flushViewer(); + const controls = transformControlsInstances[0]!; + expect(controls.setSpace).toHaveBeenLastCalledWith("local"); + expect(targetObject.position.toArray()).toEqual([10, 20, 30]); + expect(targetObject.quaternion.angleTo(originalRotation)).toBeCloseTo(0); + const basis = new THREE.Vector3(...({ X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] }[axis] as [number, number, number])); + expect(basis.applyQuaternion(controls.object!.quaternion).distanceTo(new THREE.Vector3(...delta).normalize())).toBeLessThan(1e-12); + act(() => { + controls.axis = axis; + controls.dispatchEvent({ type: "mouseDown" }); + controls.object!.position.add(new THREE.Vector3(...delta)); + controls.object!.updateMatrixWorld(true); + controls.dispatchEvent({ type: "objectChange" }); + }); + expect(onOffset).toHaveBeenCalledWith({ nodeName: "panel-a", offset: delta.map((value, index) => value + [10, 20, 30][index]!) }); + expect(targetObject.quaternion.angleTo(originalRotation)).toBeCloseTo(0); + await waitFor(() => expect(screen.getByText(`${axis} +200mm`)).toBeInTheDocument()); + for (const other of ["X", "Y", "Z"].filter(other => other !== axis)) expect(screen.queryByText(new RegExp(`${other} `))).not.toBeInTheDocument(); + }); + + it.each([ + ["Y", [0, 0, -1]], + ["Z", [0, 1, 0]], + ] as const)("Z-up %s rotation reports its furniture axis and resets only the gizmo frame", async (axis, worldAxis) => { + targetObject.rotation.set(0.3, 0.7, 0.2); + targetObject.updateMatrixWorld(true); + const originalRotation = targetObject.quaternion.clone(); + getNodeLocalRotationMock.mockReturnValue(originalRotation.toArray()); + const selectable = ['panel-a']; + const rotations = [{ nodeName: 'panel-a', rotation: originalRotation.toArray() }]; + render(); + await flushViewer(); + const controls = transformControlsInstances[0]!; + const frame = controls.object!.quaternion.clone(); + const delta = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(...worldAxis), Math.PI / 12); + act(() => { + controls.axis = axis; + controls.dispatchEvent({ type: "mouseDown" }); + controls.object!.quaternion.copy(delta.clone().multiply(frame)); + controls.object!.updateMatrixWorld(true); + controls.dispatchEvent({ type: "objectChange" }); + }); + await waitFor(() => expect(screen.getByText(`${axis} +15°`)).toBeInTheDocument()); + const expected = delta.clone().multiply(originalRotation); + expect(targetObject.quaternion.angleTo(expected)).toBeCloseTo(0); + act(() => controls.dispatchEvent({ type: "mouseUp" })); + expect(controls.object!.quaternion.angleTo(frame)).toBeCloseTo(0); + expect(targetObject.quaternion.angleTo(expected)).toBeCloseTo(0); + }); + it("allows callers to enlarge the transform gizmo for compact viewports", async () => { const view = render( ; + anchor: THREE.Object3D; + isFallbackModelTarget: boolean; + baselineAnchorPosition: THREE.Vector3 | null; + baselineAnchorQuaternion: THREE.Quaternion | null; + baselineTargetMatrixWorld: THREE.Matrix4 | null; +} + +export const MODEL_VIEWER_GIZMO_ANCHOR_NAME = "__model_viewer_gizmo_anchor__"; + +export function getObjectWorldBoundsCenter(object: THREE.Object3D): THREE.Vector3 { + object.updateMatrixWorld(true); + const box = new THREE.Box3().setFromObject(object); + if (!box.isEmpty()) { + return box.getCenter(new THREE.Vector3()); + } + return object.getWorldPosition(new THREE.Vector3()); +} + +function getObjectsWorldBoundsCenter(objects: THREE.Object3D[]): THREE.Vector3 { + if (objects.length === 0) { + return new THREE.Vector3(); + } + + const box = new THREE.Box3(); + let hasBounds = false; + objects.forEach((object) => { + object.updateMatrixWorld(true); + const objectBox = new THREE.Box3().setFromObject(object); + if (objectBox.isEmpty()) { + return; + } + if (!hasBounds) { + box.copy(objectBox); + hasBounds = true; + return; + } + box.union(objectBox); + }); + + if (hasBounds) { + return box.getCenter(new THREE.Vector3()); + } + + return objects[0]?.getWorldPosition(new THREE.Vector3()) ?? new THREE.Vector3(); +} + +function syncGizmoAnchorToObjectCenter( + anchor: THREE.Object3D, + object: THREE.Object3D, + nodeName: string | null, +) { + anchor.name = nodeName + ? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}` + : MODEL_VIEWER_GIZMO_ANCHOR_NAME; + anchor.position.copy(getObjectWorldBoundsCenter(object)); + anchor.quaternion.copy(object.getWorldQuaternion(new THREE.Quaternion())); + anchor.scale.set(1, 1, 1); + anchor.updateMatrixWorld(true); +} + +export function syncGizmoAnchorToSelectionCenter( + anchor: THREE.Object3D, + objects: THREE.Object3D[], + nodeName: string | null, +) { + const primaryObject = objects[0]; + if (!primaryObject) { + syncGizmoAnchorToObjectCenter(anchor, new THREE.Object3D(), nodeName); + return; + } + + anchor.name = nodeName + ? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}` + : MODEL_VIEWER_GIZMO_ANCHOR_NAME; + anchor.position.copy(getObjectsWorldBoundsCenter(objects)); + anchor.quaternion.copy(primaryObject.getWorldQuaternion(new THREE.Quaternion())); + anchor.scale.set(1, 1, 1); + anchor.updateMatrixWorld(true); +} + +export function applyGizmoAnchorTransformToTarget( + targetState: TransformGizmoTargetState, + mode: "translate" | "rotate", +) { + const { + anchor, + baselineAnchorPosition, + baselineAnchorQuaternion, + } = targetState; + if ( + !baselineAnchorPosition || + !baselineAnchorQuaternion + ) { + return; + } + + anchor.updateMatrixWorld(true); + const targetObjects = + targetState.targetObjects.length > 0 + ? targetState.targetObjects + : targetState.nodeName && targetState.baselineTargetMatrixWorld + ? [ + { + nodeName: targetState.nodeName, + object: targetState.object, + baselineTargetMatrixWorld: targetState.baselineTargetMatrixWorld, + }, + ] + : []; + + targetObjects.forEach((targetObjectState) => { + const { object, baselineTargetMatrixWorld } = targetObjectState; + if (!baselineTargetMatrixWorld) { + return; + } + + object.parent?.updateMatrixWorld(true); + const nextWorldMatrix = new THREE.Matrix4(); + + if (mode === "rotate") { + const currentAnchorQuaternion = anchor.getWorldQuaternion( + new THREE.Quaternion(), + ); + const deltaQuaternion = currentAnchorQuaternion + .clone() + .multiply(baselineAnchorQuaternion.clone().invert()) + .normalize(); + const pivot = baselineAnchorPosition.clone(); + const rotateAroundPivotMatrix = new THREE.Matrix4() + .makeTranslation(pivot.x, pivot.y, pivot.z) + .multiply(new THREE.Matrix4().makeRotationFromQuaternion(deltaQuaternion)) + .multiply(new THREE.Matrix4().makeTranslation(-pivot.x, -pivot.y, -pivot.z)); + nextWorldMatrix + .copy(rotateAroundPivotMatrix) + .multiply(baselineTargetMatrixWorld); + } else { + const currentAnchorPosition = anchor.getWorldPosition(new THREE.Vector3()); + const deltaPosition = currentAnchorPosition.sub(baselineAnchorPosition); + nextWorldMatrix + .makeTranslation(deltaPosition.x, deltaPosition.y, deltaPosition.z) + .multiply(baselineTargetMatrixWorld); + } + + const parentWorldInverse = object.parent + ? object.parent.matrixWorld.clone().invert() + : new THREE.Matrix4(); + const nextLocalMatrix = parentWorldInverse.multiply(nextWorldMatrix); + nextLocalMatrix.decompose(object.position, object.quaternion, object.scale); + object.updateMatrix(); + object.updateMatrixWorld(true); + }); +} diff --git a/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx b/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx index 54c62f8f..1102bd14 100644 --- a/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx +++ b/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx @@ -14,6 +14,7 @@ import { } from '@/lib/3d/engine' import { CameraController } from '@/lib/3d/camera-controller' import { ModelViewer, type ModelViewerWorldNode } from '@/components/3d/ModelViewer' +import type { ViewportCoordinateSystem } from '@/lib/3d/coordinate-system' import type { HolePanelBounds } from '@/components/3d/HoleOverlay2D' import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer' import { readPreviewWorldNodes } from '@/lib/open-product/platform-view' @@ -55,6 +56,7 @@ interface ModelPreviewPanelProps { gizmoMode?: 'translate' | 'rotate' gizmoSize?: number gizmoAxisColors?: Partial> + coordinateSystem?: ViewportCoordinateSystem onGizmoModeChange?: (mode: 'translate' | 'rotate') => void selectedNodeName?: string | null selectedNodeNames?: string[] @@ -158,6 +160,7 @@ export function ModelPreviewPanel({ gizmoMode = 'translate', gizmoSize, gizmoAxisColors, + coordinateSystem = 'y-up', onGizmoModeChange, selectedNodeName = null, selectedNodeNames = [], @@ -419,6 +422,7 @@ export function ModelPreviewPanel({ gizmoMode={gizmoMode} gizmoSize={gizmoSize} gizmoAxisColors={gizmoAxisColors} + coordinateSystem={coordinateSystem} /> diff --git a/furniture-3d-system/src/components/platform/__tests__/ModelPreviewPanel.test.tsx b/furniture-3d-system/src/components/platform/__tests__/ModelPreviewPanel.test.tsx index 0725159d..0ca8cfdd 100644 --- a/furniture-3d-system/src/components/platform/__tests__/ModelPreviewPanel.test.tsx +++ b/furniture-3d-system/src/components/platform/__tests__/ModelPreviewPanel.test.tsx @@ -37,6 +37,13 @@ describe('ModelPreviewPanel', () => { mockModelViewer.mockClear() }) + it('uses Y-up by default and forwards the opt-in furniture frame', () => { + const view = render() + expect(mockModelViewer).toHaveBeenLastCalledWith(expect.objectContaining({ coordinateSystem: 'y-up' })) + view.rerender() + expect(mockModelViewer).toHaveBeenLastCalledWith(expect.objectContaining({ coordinateSystem: 'z-up' })) + }) + it('keeps the top-right actions visible in empty state', () => { render( { + it('maps right, back and up into a right-handed world frame', () => { + expect(toWorldVector3([200, 0, 0], 'z-up')).toEqual([200, 0, 0]) + expect(toWorldVector3([0, 200, 0], 'z-up')).toEqual([0, 0, -200]) + expect(toWorldVector3([0, 0, 200], 'z-up')).toEqual([0, 200, 0]) + const axes = getCoordinateWorldAxes('z-up') + expect(axes.X.clone().cross(axes.Y).dot(axes.Z)).toBeCloseTo(1) + }) + + it('round-trips signed offsets without mutating stored positions', () => { + const saved: [number, number, number] = [-12.5, 200, -46.75] + expect(toWorldVector3(fromWorldVector3(saved, 'z-up'), 'z-up')).toEqual(saved) + expect(saved).toEqual([-12.5, 200, -46.75]) + expect(toWorldVector3(saved, 'y-up')).toEqual(saved) + expect(fromWorldVector3(saved, 'y-up')).toEqual(saved) + }) + + it('keeps the gizmo in furniture axes regardless of the selected model rotation', () => { + const anchor = new THREE.Object3D() + anchor.rotation.set(0.3, 0.7, 0.2) + anchor.position.set(11, 22, 33) + const controls = { setSpace: jest.fn() } + configureGizmoCoordinateFrame(controls, anchor, 'z-up') + expect(controls.setSpace).toHaveBeenLastCalledWith('local') + const axes = getCoordinateWorldAxes('z-up') + for (const [axis, vector] of Object.entries(getCoordinateWorldAxes('y-up'))) { + expect(vector.applyQuaternion(anchor.quaternion).distanceTo(axes[axis as keyof typeof axes])).toBeLessThan(1e-12) + } + expect(anchor.position.toArray()).toEqual([11, 22, 33]) + const previousQuaternion = anchor.quaternion.clone() + configureGizmoCoordinateFrame(controls, anchor, 'y-up') + expect(controls.setSpace).toHaveBeenLastCalledWith('world') + expect(anchor.quaternion.equals(previousQuaternion)).toBe(true) + }) +}) diff --git a/furniture-3d-system/src/lib/3d/coordinate-system.ts b/furniture-3d-system/src/lib/3d/coordinate-system.ts new file mode 100644 index 00000000..f5f87e18 --- /dev/null +++ b/furniture-3d-system/src/lib/3d/coordinate-system.ts @@ -0,0 +1,41 @@ +import * as THREE from 'three' +import type { TransformControls } from 'three/examples/jsm/controls/TransformControls.js' + +export type ViewportCoordinateSystem = 'y-up' | 'z-up' +export type CoordinateVector3 = [number, number, number] + +/** Z-up furniture coordinates: +X right, +Y back, +Z up. Storage stays Y-up. */ +export function toWorldVector3( + [x, y, z]: readonly [number, number, number], + system: ViewportCoordinateSystem, +): CoordinateVector3 { + return system === 'z-up' ? [x, z, y === 0 ? 0 : -y] : [x, y, z] +} + +export function fromWorldVector3( + [x, y, z]: readonly [number, number, number], + system: ViewportCoordinateSystem, +): CoordinateVector3 { + return system === 'z-up' ? [x, z === 0 ? 0 : -z, y] : [x, y, z] +} + +export function getCoordinateWorldAxes(system: ViewportCoordinateSystem) { + return { + X: new THREE.Vector3(...toWorldVector3([1, 0, 0], system)), + Y: new THREE.Vector3(...toWorldVector3([0, 1, 0], system)), + Z: new THREE.Vector3(...toWorldVector3([0, 0, 1], system)), + } +} + +/** Orient only the detached gizmo anchor; never rotate the model or saved poses. */ +export function configureGizmoCoordinateFrame( + controls: Pick, + anchor: THREE.Object3D, + system: ViewportCoordinateSystem, +) { + controls.setSpace(system === 'z-up' ? 'local' : 'world') + if (system === 'z-up') { + anchor.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2) + anchor.updateMatrixWorld(true) + } +}