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)
+ }
+}