diff --git a/docs/glb-machine-manager/01-项目功能内容.md b/docs/glb-machine-manager/01-项目功能内容.md index 6b61baf..5e13332 100644 --- a/docs/glb-machine-manager/01-项目功能内容.md +++ b/docs/glb-machine-manager/01-项目功能内容.md @@ -13,7 +13,7 @@ - 转台链:底座 → A/B → C → 工件。 4. 每个部件可定义局部坐标原点。模型网格相对该原点偏移,运动关节围绕该原点运动。 5. 每个部件可独立显示或隐藏原点坐标轴辅助线。 -6. 每个部件可用数值和 Three.js 操纵器平移、旋转;变换只修改装配配置,不修改原 GLB 字节。Three.js 管理器使用机床右手坐标:机床 Z 轴朝上,机床 XY 为地面平面;GLB Y-up 坐标映射为 `Three(x,y,z) = Machine(x,z,-y)`。编辑器坐标轴、网格、平移、旋转和原点辅助统一使用此映射。 +6. 每个部件可用数值和 Three.js 操纵器平移、旋转;变换只修改装配配置,不修改原 GLB 字节。Three.js 管理器通过 `camera.up.set(0, 0, 1)` 使用机床 Z-up 场景,机床 XY 为地面平面;导入 GLB 的节点、几何、材质、内部层级和原始变换保持不变。 7. 可定义 C 轴原点位置和 A/B 到 C 的运动学偏置。应用时写入底座场景节点及整机场景的 `userData.linuxcncMachineGeometry`,同时生成 URDF: - `C 原点 P = (x-rot-point, y-rot-point, z-rot-point)`。 - `A/B 偏置 D = (x-offset, y-offset, z-offset)`。 diff --git a/docs/glb-machine-manager/05-验收证据.md b/docs/glb-machine-manager/05-验收证据.md index d46431b..640071e 100644 --- a/docs/glb-machine-manager/05-验收证据.md +++ b/docs/glb-machine-manager/05-验收证据.md @@ -51,5 +51,5 @@ ## E06 / R04 / Z-up 坐标 - 状态:verified。 -- 检查:管理器预览 DOM 标记 `data-machine-manager-coordinate-system=machine-z-up`、`data-machine-manager-axis-map=X:x,Y:-z,Z:y`;MILL700 截图中蓝色 Z 轴竖直,机床 XY 网格水平。 +- 检查:管理器预览 DOM 标记 `data-machine-manager-coordinate-system=machine-z-up`、`data-machine-manager-camera-up=0,0,1`、`data-machine-manager-axis-map=X:x,Y:y,Z:z` 和 `data-machine-manager-model-transform=none-on-import`;MILL700 截图中蓝色 Z 轴竖直,机床 XY 网格水平,导入模型信息未被改写。 - AC/BC 管理器浏览器闭环、MILL700 浏览器闭环、TypeScript、生产构建和控件契约继续通过。 diff --git a/docs/glb-machine-manager/06-决策记录.md b/docs/glb-machine-manager/06-决策记录.md index f32203b..7a18a27 100644 --- a/docs/glb-machine-manager/06-决策记录.md +++ b/docs/glb-machine-manager/06-决策记录.md @@ -32,6 +32,6 @@ UI 允许用户选择父级以建立层级和发现错误;应用时只接受 模型过暗通过 Three.js 场景的 PMREM 环境、色彩管理、曝光和补光解决。不得为了预览可见而覆盖导入材质的颜色、金属度、粗糙度或纹理,否则导出结果会偏离用户资产。 -## D09 / 管理器采用 Z-up 机床坐标 +## D09 / 管理器采用相机 Z-up 机床坐标 -Three.js/glTF 资产保持 Y-up 原始数据。管理器输入和显示采用机床右手坐标,映射为 `machine (x,y,z) -> Three (x,z,-y)`;这样机床 Z 对应 Three.js 竖直方向,机床 XY 落在默认地面网格,且不破坏右手系。所有部件平移、局部原点和旋转变换共用这套映射。 +管理器明确调用 `camera.up.set(0, 0, 1)`,并将默认网格旋转到机床 XY 水平面。GLB 导入不修改模型节点、几何、材质、内部层级或原始变换;相机、轨道控制器和坐标辅助负责呈现 Z-up 场景。只有用户主动编辑部件变换时才记录新的装配值。 diff --git a/scripts/test_machine_manager_browser.mjs b/scripts/test_machine_manager_browser.mjs index 47d938d..b2f02e4 100644 --- a/scripts/test_machine_manager_browser.mjs +++ b/scripts/test_machine_manager_browser.mjs @@ -44,7 +44,7 @@ try { const cOrigin = calibrations.filter({ hasText: "C 轴原点" }); await cOrigin.getByLabel("X", { exact: true }).fill("24"); await cOrigin.getByLabel("Y", { exact: true }).fill("-12"); await cOrigin.getByLabel("Z", { exact: true }).fill("8"); const offset = calibrations.filter({ hasText: "A 到 C 轴偏置" }); await offset.getByLabel("X", { exact: true }).fill("0"); await offset.getByLabel("Y", { exact: true }).fill("18"); await offset.getByLabel("Z", { exact: true }).fill("-45"); const zeros = page.locator("fieldset.machine-zero"); await zeros.getByLabel("X", { exact: true }).fill("5"); await zeros.getByLabel("A", { exact: true }).fill("10"); await zeros.getByLabel("C", { exact: true }).fill("20"); - assert.equal(await control("machine-manager-export").isDisabled(), false); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-axis-map"), "X:x,Y:-z,Z:y"); const downloadPromise = page.waitForEvent("download"); await control("machine-manager-export").click(); const download = await downloadPromise; assert.equal(download.suggestedFilename(), "managed-xyzac.glb"); + assert.equal(await control("machine-manager-export").isDisabled(), false); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-camera-up"), "0,0,1"); const downloadPromise = page.waitForEvent("download"); await control("machine-manager-export").click(); const download = await downloadPromise; assert.equal(download.suggestedFilename(), "managed-xyzac.glb"); report.checks.push("validated assembly exports a self-contained managed GLB"); assert.equal(await control("machine-manager-apply").isDisabled(), false); await control("machine-manager-apply").click(); await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineManagerSchema === "linuxcnc.machine-manager/1" && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", null, { timeout: 45000 }); diff --git a/scripts/test_machine_manager_mill700_browser.mjs b/scripts/test_machine_manager_mill700_browser.mjs index 410d9a8..82c4911 100644 --- a/scripts/test_machine_manager_mill700_browser.mjs +++ b/scripts/test_machine_manager_mill700_browser.mjs @@ -27,7 +27,7 @@ try { await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "1" && document.querySelector(".machine-part-list small")?.textContent === "MILL700-U_1.glb", null, { timeout: 60000 }); assert.equal(hierarchy.root, "MILL700-U_1"); assert.equal(hierarchy.nodes, 132); assert.equal(hierarchy.meshes, 126); assert.ok(hierarchy.maxDepth >= 6); assert.ok(hierarchy.metallic > 20); report.hierarchy = hierarchy; report.checks.push("existing 132-node, depth-6 hierarchy preserved"); - assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-axis-map"), "X:x,Y:-z,Z:y"); + assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-camera-up"), "0,0,1"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-model-transform"), "none-on-import"); await page.locator(".machine-manager-dialog").screenshot({ path: new URL("mill700-studio-lighting.png", output).pathname }); report.checks.push("metallic MILL700 surfaces rendered with studio PMREM lighting; screenshot captured for visual review"); assert.deepEqual(errors, []); report.status = "verified"; } catch (error) { report.status = "failed"; report.error = error instanceof Error ? error.stack : String(error); await page.screenshot({ path: new URL("failure.png", output).pathname, fullPage: true }); throw error; } finally { report.browser_errors = errors; await writeFile(new URL("browser.report.json", output), JSON.stringify(report, null, 2) + "\n"); await browser.close(); } diff --git a/web/react/src/MachineManagerPanel.tsx b/web/react/src/MachineManagerPanel.tsx index 824dbbd..d756813 100644 --- a/web/react/src/MachineManagerPanel.tsx +++ b/web/react/src/MachineManagerPanel.tsx @@ -6,7 +6,7 @@ import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import { DraggableDialog } from "./DraggableDialog"; import { disposeMachineScene, type ImportedMachine } from "./imported-machine"; -import { buildManagedMachine, cloneMachinePartScene, expectedParentRole, loadMachinePart, machineEulerToThreeQuaternion, machineFrameQuaternion, machineToThree, threeQuaternionToMachineEuler, threeToMachine, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type PhysicalZero } from "./machine-manager"; +import { buildManagedMachine, cloneMachinePartScene, expectedParentRole, loadMachinePart, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type PhysicalZero } from "./machine-manager"; import type { Vec3 } from "./machine-urdf"; type Props = { coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise }; @@ -26,39 +26,33 @@ function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: Machi const light = new THREE.DirectionalLight(0xfff5e5, 4.2); light.position.set(3, 5, 4); light.castShadow = true; scene.add(light); const fill = new THREE.DirectionalLight(0xdcefff, 2.4); fill.position.set(-4, 2, 3); scene.add(fill); const rim = new THREE.DirectionalLight(0xffffff, 1.8); rim.position.set(2, 4, -5); scene.add(rim); - const grid = new THREE.GridHelper(4, 40, 0x8eb0b2, 0x466266); grid.name = "machine-xy-ground-grid-z-up"; scene.add(grid); - const camera = new THREE.PerspectiveCamera(42, 1, .005, 100); camera.position.set(2.4, 1.8, 2.8); + const grid = new THREE.GridHelper(4, 40, 0x8eb0b2, 0x466266); grid.name = "machine-xy-ground-grid-z-up"; grid.rotation.x = Math.PI / 2; scene.add(grid); + const camera = new THREE.PerspectiveCamera(42, 1, .005, 100); camera.up.set(0, 0, 1); camera.position.set(2.4, -2.8, 1.8); const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true; const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root); const nodes = new Map(); const disposable: THREE.Object3D[] = []; for (const part of parts) { - const node = new THREE.Group(); node.name = `draft_${part.id}`; node.visible = part.visible; node.position.fromArray(machineToThree(part.translationMm)).multiplyScalar(.001); node.quaternion.copy(machineEulerToThreeQuaternion(part.rotationDeg)); - const visual = cloneMachinePartScene(part.scene); visual.position.fromArray(machineToThree(part.originMm).map((value) => -value) as Vec3).multiplyScalar(.001); node.add(visual); disposable.push(visual); - const axes = new THREE.Group(); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; - const axis = (end: THREE.Vector3, color: number) => { const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), end]); axes.add(new THREE.Line(geometry, new THREE.LineBasicMaterial({ color }))); }; - axis(new THREE.Vector3(.12, 0, 0), 0xff3333); axis(new THREE.Vector3(0, 0, -.12), 0x33cc66); axis(new THREE.Vector3(0, .12, 0), 0x3388ff); node.add(axes); root.add(node); nodes.set(part.id, node); + const node = new THREE.Group(); node.name = `draft_${part.id}`; node.visible = part.visible; node.position.fromArray(part.translationMm).multiplyScalar(.001); node.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3); + const visual = cloneMachinePartScene(part.scene); visual.position.fromArray(part.originMm).multiplyScalar(-.001); node.add(visual); disposable.push(visual); + const axes = new THREE.AxesHelper(.12); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; node.add(axes); root.add(node); nodes.set(part.id, node); } const cycle = (child: string, parent: string) => { let cursor: string | null = parent; const seen = new Set([child]); while (cursor) { if (seen.has(cursor)) return true; seen.add(cursor); cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } return false; }; for (const part of parts) if (part.parentId && nodes.has(part.parentId) && !cycle(part.id, part.parentId)) nodes.get(part.parentId)!.add(nodes.get(part.id)!); const bounds = new THREE.Box3().setFromObject(root); const center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3()); const size = bounds.isEmpty() ? 1 : Math.max(bounds.getSize(new THREE.Vector3()).length(), .2); orbit.target.copy(center); camera.position.copy(center).add(new THREE.Vector3(size, size * .7, size * 1.15)); orbit.update(); const transform = new TransformControls(camera, renderer.domElement); transform.setMode(mode); transform.setSpace("local"); scene.add(transform.getHelper()); - const selected = selectedId ? nodes.get(selectedId) : null; const proxy = selected ? new THREE.Group() : null; - const frameQuat = machineFrameQuaternion(); - if (selected && proxy) { proxy.name = "machine-coordinate-transform-proxy"; proxy.position.copy(selected.getWorldPosition(new THREE.Vector3())); proxy.quaternion.copy(frameQuat).multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(...parts.find((part) => part.id === selectedId)!.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3, "XYZ"))); scene.add(proxy); transform.attach(proxy); } + const selected = selectedId ? nodes.get(selectedId) : null; if (selected) transform.attach(selected); transform.addEventListener("dragging-changed", ((event: { value?: boolean }) => { orbit.enabled = !event.value; }) as never); transform.addEventListener("objectChange", (() => { - if (!selectedId || !selected || !proxy) return; - const local = selected.parent ? selected.parent.worldToLocal(proxy.position.clone()) : proxy.position.clone(); selected.position.copy(local); - const machineRotation = threeQuaternionToMachineEuler(proxy.quaternion); selected.quaternion.copy(machineEulerToThreeQuaternion(machineRotation)); - onTransformRef.current(selectedId, threeToMachine(selected.position.toArray() as Vec3).map((value) => value * 1000) as Vec3, machineRotation); + if (!selectedId || !selected) return; + onTransformRef.current(selectedId, selected.position.toArray().map((value) => value * 1000) as Vec3, [THREE.MathUtils.radToDeg(selected.rotation.x), THREE.MathUtils.radToDeg(selected.rotation.y), THREE.MathUtils.radToDeg(selected.rotation.z)]); }) as never); const resize = () => { const width = Math.max(element.clientWidth, 1), height = Math.max(element.clientHeight, 1); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); }; const observer = new ResizeObserver(resize); observer.observe(element); resize(); let frame = 0; const render = () => { orbit.update(); renderer.render(scene, camera); frame = requestAnimationFrame(render); }; render(); - return () => { cancelAnimationFrame(frame); observer.disconnect(); transform.dispose(); orbit.dispose(); if (proxy) proxy.removeFromParent(); for (const object of disposable) disposeMachineScene(object); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); }; + return () => { cancelAnimationFrame(frame); observer.disconnect(); transform.dispose(); orbit.dispose(); for (const object of disposable) disposeMachineScene(object); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); }; }, [parts, selectedId, mode]); - return
part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览,机床 Z 轴朝上" />; + return
part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览,机床 Z 轴朝上" />; } export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) { diff --git a/web/react/src/machine-manager.ts b/web/react/src/machine-manager.ts index e42ee90..0119a04 100644 --- a/web/react/src/machine-manager.ts +++ b/web/react/src/machine-manager.ts @@ -28,22 +28,6 @@ export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "to export const roleLabel = (role: MachinePartRole, coordinates: string) => ({ base: "床身底座", x: "X 轴部件", y: "Y 轴部件", z: "Z 轴部件", tool: "主轴/刀具", tilt: `${coordinates === "XYZBC" ? "B" : "A"} 轴部件`, c: "C 轴部件", work: "工件/工作台" }[role]); export const expectedParentRole = (role: MachinePartRole): MachinePartRole | null => ({ base: null, x: "base", y: "x", z: "y", tool: "z", tilt: "base", c: "tilt", work: "c" }[role] as MachinePartRole | null); -// glTF uses Y-up. The editor exposes the machine's right-handed X/Y/Z frame -// with Z-up: machine (x, y, z) -> Three.js (x, z, -y). -export const machineToThree = (value: Vec3): Vec3 => [value[0], value[2], -value[1]]; -export const threeToMachine = (value: Vec3): Vec3 => [value[0], -value[2], value[1]]; -export const machineFrameQuaternion = () => new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().set(1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1)); -export function machineEulerToThreeQuaternion(rotationDeg: Vec3) { - const machine = new THREE.Euler(...rotationDeg.map(THREE.MathUtils.degToRad) as Vec3, "XYZ"); - const source = new THREE.Matrix4().makeRotationFromEuler(machine); - const frame = new THREE.Matrix4().set(1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1); - return new THREE.Quaternion().setFromRotationMatrix(frame.clone().multiply(source).multiply(frame.clone().invert())); -} -export function threeQuaternionToMachineEuler(rotation: THREE.Quaternion) { - const frame = machineFrameQuaternion(); - const machine = frame.clone().invert().multiply(rotation); - return new THREE.Euler().setFromQuaternion(machine, "XYZ").toArray().slice(0, 3).map((radians) => THREE.MathUtils.radToDeg(Number(radians))) as Vec3; -} const finite3 = (value: Vec3, label: string) => { if (value.length !== 3 || value.some((number) => !Number.isFinite(number))) throw new Error(`${label}需要三个有限数值`); @@ -137,10 +121,10 @@ export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC for (const role of MACHINE_PART_ROLES) { const part = byRole.get(role)!; const placement = new THREE.Group(); placement.name = `component_${part.id}`; placement.visible = part.visible; - placement.position.fromArray(machineToThree(part.translationMm)).multiplyScalar(.001); - placement.quaternion.copy(machineEulerToThreeQuaternion(part.rotationDeg)); + placement.position.fromArray(part.translationMm).multiplyScalar(.001); + placement.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3); placement.userData.machinePart = { role, filename: part.filename, hash: part.hash, originMm: [...part.originMm] }; - const visual = cloneMachinePartScene(part.scene); visual.position.fromArray(machineToThree(part.originMm).map((value) => -value) as Vec3).multiplyScalar(.001); placement.add(visual); links[role].add(placement); + const visual = cloneMachinePartScene(part.scene); visual.position.fromArray(part.originMm).multiplyScalar(-.001); placement.add(visual); links[role].add(placement); } links.base.userData.linuxcncMachineGeometry = metadata; const signature = JSON.stringify({ metadata, hashes: MACHINE_PART_ROLES.map((role) => byRole.get(role)!.hash) });