Stabilize machine manager WebGL resources

This commit is contained in:
2026-09-20 20:14:27 +08:00
parent 72b38b7d43
commit 320a69a916
11 changed files with 86 additions and 31 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 235 KiB

View File

@@ -26,6 +26,14 @@
- MILL700 验证 132 节点、126 网格、深度 6 的树显示;选择 `MILL700-A-主轴-JT2` 得到父级局部原点 `X=-3.969, Y=-204.276, Z=405.633 mm`;内部节点拖放及八部件根节点拖放通过。
- 为避免大模型选择卡顿,树选择只更新坐标面板;预览使用共享只读几何/材质的节点克隆,层级变更后才重建变换树。MGR-12 verified。
## R06 / 2026-09-20 / WebGL framebuffer 稳定性
- 用户截图显示 `texImage2D width or height out of range`、`glRenderbufferStorageMultisample` 超限和 framebuffer zero-size 连续错误,导致层级管理界面不稳定。
- MILL700 三张纹理实际为 `1024x1024`、`1604x1205`、`1604x1205`,不是纹理超限;风险来自特定 GPU、DPR 和 DevTools 短视口组合下的多重采样 drawing buffer。
- 管理器 WebGL renderer 关闭 MSAA,读取 `MAX_RENDERBUFFER_SIZE`、`MAX_TEXTURE_SIZE` 和 `MAX_VIEWPORT_DIMS`,按 90% 安全边界及最多 4096² 像素动态降低 DPR;宽高不足 2px 时暂停 resize/render。
- 发布 `render-state`、实际 pixel ratio、buffer size 和 GPU 上限诊断;context lost 后停止动画循环,避免重复 framebuffer 错误刷屏。
- 模拟页面 `1154x220`、DPR 3:CSS `656x655`、drawing buffer `984x982`、GPU 上限 `8192`,控制台警告/错误 0。MILL700 层级树及 AC/BC 生产回归通过。MGR-13 verified。
## R02 / 2026-09-20 / 部件装配、标定、应用与验收完成
- 新增 `machine-manager.ts`:通用自包含 GLB 部件加载、SHA-256、深复制、八角色和两条机械链校验、AC/BC URDF 生成、组合场景、底座/整机 userData、组合哈希。

View File

@@ -14,3 +14,4 @@
|MGR-10 PBR 预览照明|verified|MGR-04|高金属度 GLB 在管理器中具有可辨识表面;PMREM、色彩空间、曝光和多向补光生效|
|MGR-11 Z-up 机床坐标|verified|MGR-04,MGR-05|`camera.up=(0,0,1)`,机床 Z 竖直、XY 地面;导入模型信息和原始变换不修改|
|MGR-12 GLB 层级树管理|verified|MGR-02,MGR-04|原始层级按 tree 显示;根/内部节点拖放、循环拒绝、节点原点坐标和 MILL700 深层结构验收通过|
|MGR-13 WebGL 资源稳定性|verified|MGR-10,MGR-12|禁用超限 MSAA;drawing buffer 按 GPU 上限和像素预算缩放;零尺寸/context lost 不连续绘制;短视口 DPR3 无警告|

View File

@@ -16,8 +16,8 @@
- 状态:verified;浏览器错误 0。
- 覆盖:8 部件导入和预览、原点显隐、数值/TransformControls 变换、组合 GLB 导出、AC/BC 应用、底座 userData、物理零点、非法层级拒绝、BC 五轴程序。
- 截图:
- `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `70238293d81a0063e413d4bf3b2b0f0bc4c8453e45256e32fb625ff9e92b9609`
- `coverage/glb-machine-manager/managed-bc-running.png`,SHA-256 `6e14ff6bc4e493f57e55bb601ded84319ce86f064f7a811595c1e4498b1827a0`
- `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `6e53968722744d8fc4dc753d83ee8bad3c6c3ce0d41c34d64214b5586faf2dc5`
- `coverage/glb-machine-manager/managed-bc-running.png`,SHA-256 `2a28290ff58b3a0d3250ee5ebb1bc636a2cc8b8f62828b41764435501f97d385`
## E03 / R02 / 生产构建浏览器闭环
@@ -46,7 +46,7 @@
- report_id:`GLB-MGR-E05`;报告 SHA-256 `7c479b36396abc6f7466ed8b54cc83216dd72c30ba11fa0c667e6491d47c2171`。
- 结构:132 节点、126 网格、深度 6、126 材质,其中 110 个 `metalness >= 0.9`。
- 检查:已有内部层级完整保留;管理器使用 `studio-pmrem`、sRGB 输出、ACES、曝光 `1.45` 和多向补光;浏览器错误为 0。
- 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `e741ae55623f28399565c4296e5d679a71fba346232ce043edc5ab0c8901d6b4`。
- 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `289255511eccce697ec0c6f002a44588475e9042fa12f23646ea02c4c63a3b60`。
## E06 / R04 / Z-up 坐标
@@ -62,3 +62,11 @@
- 拖放:`MILL700-A-门1-JT1` 拖到 `MILL700-A底座` 后,草稿父路径等于目标稳定源路径;原始 GLB SHA-256 保持 `bf37130681cc6462412e52744519516e8ade2a3212dd69153c0b1b3e415bcf66`。
- 八个独立 GLB 的根树节点拖放通过;随后 AC/BC 应用、导出和程序运动回归仍通过。
- 控件契约:101 个控件,新增 `machine-tree-node` 和 `machine-tree-origin`;TypeScript、生产构建、双画布测试通过,浏览器错误 0。
## E08 / R06 / WebGL framebuffer 稳定性
- 状态:verified。
- 触发环境:页面 viewport `1154x220`,deviceScaleFactor 3,模拟底部停靠 DevTools 和高 DPI。
- 修复后:管理器 CSS `656x655`,drawing buffer `984x982`,`MAX_RENDERBUFFER_SIZE=8192`,MSAA=false,控制台 warning/error=0。
- renderer 诊断字段:`machine-manager-webgl-antialias=false`、`render-state=ready`、动态 `pixel-ratio/buffer-size/max-buffer`。
- MILL700 132 节点树选择/拖放通过;生产 AC/BC 管理器闭环通过,浏览器错误 0。

View File

@@ -39,3 +39,7 @@ UI 允许用户选择父级以建立层级和发现错误;应用时只接受
## D10 / 层级编辑使用稳定源路径覆盖
GLB 树节点 ID 使用导入时的子节点索引路径,不使用可能重复或缺失的节点名称。拖放保存 `sourcePath -> targetPath` 草稿覆盖,每次从原始场景克隆后应用,因此重复编辑稳定且不污染导入资产。根 GLB 之间继续使用部件 `parentId`,内部节点不允许跨 GLB 直接重挂。
## D11 / 管理器优先保证 framebuffer 可创建
管理器不依赖 MSAA;高 DPI 清晰度由受限 DPR 提供。drawing buffer 必须同时满足 GPU 维度上限和 4096² 像素预算,不能把异常 DOM 尺寸直接传给 WebGL。零尺寸和 context lost 视为暂停状态,不继续调用 render 产生重复错误。

View File

@@ -11,7 +11,7 @@
现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。
当前状态:MGR-01 至 MGR-12 全部 verified,MILL700 实测覆盖照明、Z-up、132 节点层级树、拖放和节点原点。最新台账 R05,浏览器证据 `GLB-MGR-E01/E02/E05/E07`,截图和报告位于 `coverage/glb-machine-manager/`。
当前状态:MGR-01 至 MGR-13 全部 verified,MILL700 实测覆盖照明、Z-up、132 节点层级树、拖放、节点原点和 WebGL 资源降级。最新台账 R06,浏览器证据 `GLB-MGR-E01/E02/E05/E07/E08`,截图和报告位于 `coverage/glb-machine-manager/`。
文档:

View File

@@ -20,7 +20,7 @@ const imported = await import(pathToFileURL(modulePath).href); const playwright
const output = new URL("../coverage/glb-machine-manager/", import.meta.url); await mkdir(output, { recursive: true });
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); const errors = [];
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); });
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (["warning", "error"].includes(message.type())) errors.push(`console:${message.type()}:${message.text()}`); });
const control = (id) => page.locator(`[data-axis-control="${id}"]`); const main = page.locator("main.axis-app");
const production = process.env.MACHINE_MANAGER_SOURCE_MODULES === "0";
const report = { schema: "machine-manager-browser.v1", report_id: production ? "GLB-MGR-E02" : "GLB-MGR-E01", mode: production ? "production-preview" : "development-source", status: "running", checks: [] };
@@ -30,7 +30,7 @@ try {
await control("machine-manager-settings").click(); assert.equal(await page.locator(".machine-manager-dialog[open]").count(), 1); assert.equal(await page.locator("[data-machine-manager-preview] canvas").count(), 1);
await control("machine-manager-files").setInputFiles(fixtures);
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "8");
assert.equal(await page.locator('[data-machine-tree-path="scene"]').count(), 8); assert.ok(await page.locator('[data-axis-control="machine-tree-node"]').count() >= 16); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8"); 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-exposure"), "1.45");
assert.equal(await page.locator('[data-machine-tree-path="scene"]').count(), 8); assert.ok(await page.locator('[data-axis-control="machine-tree-node"]').count() >= 16); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8"); 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-exposure"), "1.45"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-render-state"), "ready"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
report.checks.push("eight GLB parts rendered with studio PMREM lighting and calibrated exposure");
const roots = page.locator('[data-machine-tree-path="scene"]'); const sourceRoot = roots.nth(7), targetRoot = roots.nth(6), sourcePart = await sourceRoot.getAttribute("data-machine-tree-part"), targetPart = await targetRoot.getAttribute("data-machine-tree-part"), transfer = await page.evaluateHandle(() => new DataTransfer()); await sourceRoot.locator("button").first().dispatchEvent("dragstart", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("dragover", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("drop", { dataTransfer: transfer }); await page.waitForFunction(({ sourcePart, targetPart }) => document.querySelector(`[data-machine-tree-part="${sourcePart}"][data-machine-tree-path="scene"]`)?.getAttribute("data-machine-tree-parent") === targetPart, { sourcePart, targetPart });
report.checks.push("dragging GLB root tree nodes updates component parent hierarchy");
@@ -39,6 +39,7 @@ try {
const translation = page.locator("fieldset.machine-vector").filter({ hasText: "部件平移" }); await translation.getByLabel("X", { exact: true }).fill("10");
const rotation = page.locator("fieldset.machine-vector").filter({ hasText: "部件旋转" }); await rotation.getByLabel("Z", { exact: true }).fill("15");
await control("machine-manager-rotate").click(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-transform-mode"), "rotate");
assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
await page.locator(".machine-manager-dialog").screenshot({ path: new URL("manager-ac-config.png", output).pathname });
report.checks.push("origin visibility, numeric translation/rotation and TransformControls mode update");

View File

@@ -17,7 +17,7 @@ const output = new URL("../coverage/glb-machine-manager/mill700/", import.meta.u
const imported = await import(pathToFileURL(modulePath).href); const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); const errors = [];
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); });
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (["warning", "error"].includes(message.type())) errors.push(`console:${message.type()}:${message.text()}`); });
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const report = { schema: "machine-manager-mill700-browser.v1", report_id: "GLB-MGR-E05", status: "running", model: { path: modelPath, bytes: model.length, sha256 }, checks: [] };
try {
@@ -27,9 +27,9 @@ try {
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "1" && document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerRendered === "true" && document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132, 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);
assert.ok(await page.locator('[data-axis-control="machine-tree-node"]').count() >= 132); const selectedNode = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A-主轴-JT2" }).first(); await selectedNode.dispatchEvent("click"); assert.match(await control("machine-tree-origin").textContent(), /相对父节点原点.*X .*Y .*Z /s); await page.screenshot({ path: new URL("mill700-studio-lighting.png", output).pathname, timeout: 60000 });
const source = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A-门1-JT1" }).first(), target = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A底座" }).first(); const sourceItem = source.locator("xpath=ancestor::li[1]"), targetItem = target.locator("xpath=ancestor::li[1]"), sourcePath = await sourceItem.getAttribute("data-machine-tree-path"), targetPath = await targetItem.getAttribute("data-machine-tree-path"), partId = await sourceItem.getAttribute("data-machine-tree-part"); await target.evaluate((element, payload) => { const transfer = new DataTransfer(); transfer.setData("application/x-linuxcnc-machine-tree", JSON.stringify({ partId: payload.partId, path: payload.sourcePath })); element.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer })); }, { partId, sourcePath }); await page.waitForFunction(({ sourcePath, targetPath }) => document.querySelector(`[data-machine-tree-path="${sourcePath}"]`)?.getAttribute("data-machine-tree-parent") === targetPath, { sourcePath, targetPath }, { timeout: 60000 });
const source = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A-门1-JT1" }).first(), target = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A底座" }).first(); const sourceItem = source.locator("xpath=ancestor::li[1]"), targetItem = target.locator("xpath=ancestor::li[1]"), sourcePath = await sourceItem.getAttribute("data-machine-tree-path"), targetPath = await targetItem.getAttribute("data-machine-tree-path"), partId = await sourceItem.getAttribute("data-machine-tree-part"); await target.evaluate((element, payload) => { const transfer = new DataTransfer(); transfer.setData("application/x-linuxcnc-machine-tree", JSON.stringify({ partId: payload.partId, path: payload.sourcePath })); element.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer })); }, { partId, sourcePath }); await page.waitForFunction(({ sourcePath, targetPath }) => document.querySelector(`[data-machine-tree-path="${sourcePath}"]`)?.getAttribute("data-machine-tree-parent") === targetPath, { sourcePath, targetPath }, { timeout: 60000 }); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
report.hierarchy = hierarchy; report.checks.push("existing 132-node, depth-6 hierarchy displayed as a draggable tree with node origins");
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");
const preview = page.locator("[data-machine-manager-preview]"); assert.equal(await preview.getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await preview.getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await preview.getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await preview.getAttribute("data-machine-manager-camera-up"), "0,0,1"); assert.equal(await preview.getAttribute("data-machine-manager-model-transform"), "none-on-import"); assert.equal(await preview.getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await preview.getAttribute("data-machine-manager-render-state"), "ready"); assert.equal(await preview.getAttribute("data-machine-manager-context-generation"), "1"); const [bufferWidth, bufferHeight] = (await preview.getAttribute("data-machine-manager-buffer-size")).split("x").map(Number), maxBuffer = Number(await preview.getAttribute("data-machine-manager-max-buffer")); assert.ok(bufferWidth > 0 && bufferHeight > 0 && bufferWidth <= maxBuffer && bufferHeight <= maxBuffer);
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, timeout: 5000 }).catch(() => undefined); throw error; }
finally { report.browser_errors = errors; await writeFile(new URL("browser.report.json", output), JSON.stringify(report, null, 2) + "\n"); await browser.close(); }

View File

@@ -14,12 +14,21 @@ const zero3 = (): Vec3 => [0, 0, 0];
const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0;
function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: MachinePartDraft[]; selectedId: string | null; mode: "translate" | "rotate"; onTransform: (id: string, translationMm: Vec3, rotationDeg: Vec3) => void }) {
const host = useRef<HTMLDivElement>(null);
type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, mode: "translate" | "rotate") => void };
const host = useRef<PreviewHost>(null);
const stateRef = useRef({ parts, selectedId, mode }); stateRef.current = { parts, selectedId, mode };
const onTransformRef = useRef(onTransform); onTransformRef.current = onTransform;
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, mode); }, [parts, selectedId, mode]);
useEffect(() => {
const element = host.current; if (!element) return;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.45; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; renderer.setSize(Math.max(element.clientWidth, 320), Math.max(element.clientHeight, 360), false); element.appendChild(renderer.domElement);
// Large industrial GLBs can exceed small-GPU multisample renderbuffer
// limits when DevTools or display scaling changes the drawing-buffer size.
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, depth: true, stencil: false, powerPreference: "high-performance" });
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.45; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; element.appendChild(renderer.domElement);
const gl = renderer.getContext(), viewportLimit = gl.getParameter(gl.MAX_VIEWPORT_DIMS) as Int32Array;
const maxDimension = Math.max(1, Math.floor(Math.min(Number(gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 4096), Number(gl.getParameter(gl.MAX_TEXTURE_SIZE) || 4096), Number(viewportLimit?.[0] || 4096), Number(viewportLimit?.[1] || 4096)) * .9));
const maxPixels = Math.min(maxDimension * maxDimension, 4096 * 4096);
element.dataset.machineManagerWebglAntialias = String(Boolean(gl.getContextAttributes()?.antialias)); element.dataset.machineManagerMaxBuffer = String(maxDimension);
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x263438);
const studio = new RoomEnvironment(); const pmrem = new THREE.PMREMGenerator(renderer); const environment = pmrem.fromScene(studio, .04); scene.environment = environment.texture; scene.environmentIntensity = 1.25; studio.dispose(); pmrem.dispose();
scene.add(new THREE.HemisphereLight(0xffffff, 0x728083, 2.6)); scene.add(new THREE.AmbientLight(0xffffff, .65));
@@ -30,30 +39,54 @@ function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: Machi
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<string, THREE.Group>(); const helpers: THREE.AxesHelper[] = [];
for (const part of parts) {
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);
// Preview clones only Object3D transforms/hierarchy. Geometry, material,
// texture and source node transforms remain untouched on the imported GLB.
const visual = part.scene.clone(true); applyMachineNodeParents(visual, part.nodeParents); visual.position.fromArray(part.originMm).multiplyScalar(-.001); node.add(visual);
const axes = new THREE.AxesHelper(.12); helpers.push(axes); 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();
let nodes = new Map<string, THREE.Group>(), helpers: THREE.AxesHelper[] = [], selectedObject: THREE.Group | null = null, selectedPartId: string | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "";
const transform = new TransformControls(camera, renderer.domElement); transform.setMode(mode); transform.setSpace("local"); scene.add(transform.getHelper());
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) 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)]);
if (!selectedPartId || !selectedObject) return;
onTransformRef.current(selectedPartId, selectedObject.position.toArray().map((value) => value * 1000) as Vec3, [THREE.MathUtils.radToDeg(selectedObject.rotation.x), THREE.MathUtils.radToDeg(selectedObject.rotation.y), THREE.MathUtils.radToDeg(selectedObject.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); };
element.machineManagerSync = (nextParts, nextSelectedId, nextMode) => {
transform.setMode(nextMode);
if (nextParts !== lastParts) {
transform.detach(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; nodes = new Map();
for (const part of nextParts) {
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);
// Clone transforms/hierarchy only. Geometry, materials, textures and
// source-node transforms remain untouched on the imported GLB.
const visual = part.scene.clone(true); applyMachineNodeParents(visual, part.nodeParents); visual.position.fromArray(part.originMm).multiplyScalar(-.001); node.add(visual);
const axes = new THREE.AxesHelper(.12); helpers.push(axes); 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 = nextParts.find((part) => part.id === cursor)?.parentId ?? null; } return false; };
for (const part of nextParts) if (part.parentId && nodes.has(part.parentId) && !cycle(part.id, part.parentId)) nodes.get(part.parentId)!.add(nodes.get(part.id)!);
const signature = nextParts.map((part) => `${part.id}:${part.hash}`).join("|");
if (signature !== fitSignature) {
const bounds = new THREE.Box3().setFromObject(root), center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3()), 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(); fitSignature = signature;
}
lastParts = nextParts;
}
selectedPartId = nextSelectedId; selectedObject = nextSelectedId ? nodes.get(nextSelectedId) ?? null : null; transform.detach(); if (selectedObject) transform.attach(selectedObject);
element.dataset.machineManagerContextGeneration = "1";
};
element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.mode);
let renderable = false, contextLost = false;
const resize = () => {
const width = element.clientWidth, height = element.clientHeight;
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 2 || height < 2) { renderable = false; element.dataset.machineManagerRenderState = "suspended-zero-size"; return; }
const requested = Math.min(globalThis.devicePixelRatio || 1, 1.5);
const scale = Math.min(1, maxDimension / (width * requested), maxDimension / (height * requested), Math.sqrt(maxPixels / (width * height * requested * requested)));
const pixelRatio = Math.max(.5, requested * scale);
renderer.setPixelRatio(pixelRatio); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false);
renderable = renderer.domElement.width > 0 && renderer.domElement.height > 0 && renderer.domElement.width <= maxDimension && renderer.domElement.height <= maxDimension;
element.dataset.machineManagerPixelRatio = pixelRatio.toFixed(3); element.dataset.machineManagerBufferSize = `${renderer.domElement.width}x${renderer.domElement.height}`; element.dataset.machineManagerRenderState = renderable ? "ready" : "suspended-limit";
};
const contextLostHandler = (event: Event) => { event.preventDefault(); contextLost = true; renderable = false; element.dataset.machineManagerRenderState = "context-lost"; };
renderer.domElement.addEventListener("webglcontextlost", contextLostHandler);
const observer = new ResizeObserver(resize); observer.observe(element); resize(); let frame = 0;
const render = () => { orbit.update(); renderer.render(scene, camera); element.dataset.machineManagerRendered = "true"; frame = requestAnimationFrame(render); }; render();
return () => { cancelAnimationFrame(frame); observer.disconnect(); transform.dispose(); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); };
}, [parts, selectedId, mode]);
const render = () => { if (!contextLost && renderable) { orbit.update(); renderer.render(scene, camera); element.dataset.machineManagerRendered = "true"; } frame = contextLost ? 0 : requestAnimationFrame(render); }; render();
return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); transform.dispose(); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); };
}, []);
return <div ref={host} className="machine-manager-preview" data-machine-manager-preview data-machine-manager-coordinate-system="machine-z-up" data-machine-manager-axis-map="X:x,Y:y,Z:z" data-machine-manager-camera-up="0,0,1" data-machine-manager-model-transform="none-on-import" data-machine-manager-lighting="studio-pmrem" data-machine-manager-exposure="1.45" data-machine-manager-part-count={parts.length} data-machine-manager-origin-count={parts.filter((part) => part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览,机床 Z 轴朝上" />;
}