按关节槽位驱动 13 个五轴案例的三维模型

关节契约扩为槽位制:以 LinuxCNC 联合号标识关节,轴字母降为槽位属性,
重复轴(bridge-mill 的两个 Y)、并联杆长(hexapod/pentapod)和被动槽位
(max5 第 6 关节)都能表达。v1 规则保持不变并合成 jointOrder,已保存
GLB 与既有测试不受影响。

13 个结构 GLB 由 scripts/generate_physical_case_glbs.mjs 统一重建,
可动关节均为按槽位命名的 Group 并写 userData.linuxcncJoint,共 71 个
关节,槽位数与运行目录的 joint_count 逐条一致。

新增 machine-visual-bindings.json 与纯函数模块,以 machine id 为键:
坐标字母不能作键,因为三个案例共用 XYZAB、三个共用 XYZABC。主界面切换
案例时自动装载对应模型,失败时保留内置模型并显式提示。

消除前端五关节截断:visual 适配器的 joints 改为数组并新增
jointsAuthoritative;allHomed 按 joint_count 判断;面板按 worker 断言的
machine id 查找机型。

管理器解锁:坐标与案例下拉以"存在完整视觉绑定"为启用条件,13 案例全部
可选;machine-urdf.ts 新增 generic-wasm/1 profile,AC/BC 严格校验路径
不变;新增 machine-kinematics-specs.json 保存逐关节限位,INI 未声明的
限位保持 null 而不编造。

修复三个真实缺陷:构建漏发 woodpecker/gmoccapy/qtdragon 配置目录(
DEFECT-02 根因);案例切换沿用"装载需要已上电"的门控,导致关机状态下
第二次切换被静默拒绝;重复轴机型上 React 列表键冲突。

验收:13 案例逐个切换装载成功、槽位键与绑定定义逐一一致、浏览器错误 0;
typecheck 与生产构建通过;视觉绑定、GLB 命名、关节契约、投影、store、
双画布、运动学规格漂移、11 案例 Worker 闭环、管理器浏览器回归全部通过。
证据在 coverage/glb-machine-manager/physical-drive/。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 06:09:12 +08:00
parent af65b69b2c
commit db29ae625f
157 changed files with 11936 additions and 885 deletions

View File

@@ -0,0 +1,63 @@
"""Prepare a LinuxCNC machine GLB for Blender based editing.
Usage:
blender -b --python scripts/blender_machine_template.py -- \
--input web/react/public/machine-cases/physical/hexapod-genhexkins.glb \
--output /tmp/hexapod-machine.blend
"""
import argparse
import bpy
from mathutils import Vector
def arg_value(name, default=None):
argv = bpy.app.handlers if False else __import__("sys").argv
if "--" not in argv:
return default
args = argv[argv.index("--") + 1:]
for index, value in enumerate(args):
if value == name and index + 1 < len(args):
return args[index + 1]
return default
input_path = arg_value("--input")
output_path = arg_value("--output", "/tmp/linuxcnc-machine.blend")
if not input_path:
raise SystemExit("--input GLB is required")
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=input_path)
scene = bpy.context.scene
scene.unit_settings.system = "METRIC"
scene.unit_settings.length_unit = "MILLIMETERS"
scene["linuxcnc.workflow"] = "linuxcncWebWASM machine manager"
scene["linuxcnc.source_glb"] = input_path
collection = bpy.data.collections.new("LinuxCNC_Reference_Axes")
scene.collection.children.link(collection)
for name, direction, color in [
("AXIS_X", (1, 0, 0), (1, 0, 0, 1)),
("AXIS_Y", (0, 1, 0), (0, 1, 0, 1)),
("AXIS_Z", (0, 0, 1), (0, 0, 1, 1)),
]:
empty = bpy.data.objects.new(name, None)
empty.empty_display_type = "ARROWS"
empty.empty_display_size = 0.15
empty.color = color
empty["linuxcnc.axis_direction"] = direction
collection.objects.link(empty)
origin = bpy.data.objects.new("MACHINE_ORIGIN", None)
origin.empty_display_type = "PLAIN_AXES"
origin.empty_display_size = 0.25
origin["linuxcnc.role"] = "base"
collection.objects.link(origin)
for obj in scene.objects:
if obj.name.startswith("physical_") or obj.name == "machine_root":
obj["linuxcnc.editable_structure"] = True
obj["linuxcnc.units"] = "m in GLB / mm in editor"
bpy.ops.wm.save_as_mainfile(filepath=output_path)
print(f"BLENDER_MACHINE_TEMPLATE_OK {output_path}")

View File

@@ -0,0 +1,234 @@
// Deterministic generator for the 13 physical-case structure GLBs.
// Every movable joint is a Group named after its slot key and carries
// userData.linuxcncJoint so the analyzer, the manager and the runtime can
// bind it without name heuristics. Motion itself is never computed here:
// the models only encode zero pose, hierarchy and joint axes.
import { mkdir, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import * as THREE from '../web/react/node_modules/three/build/three.module.js';
import { GLTFExporter } from '../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js';
globalThis.FileReader = class {
readAsArrayBuffer(blob) { blob.arrayBuffer().then((value) => { this.result = value; this.onloadend?.(); }); }
readAsDataURL(blob) { blob.arrayBuffer().then((value) => { this.result = `data:${blob.type};base64,${Buffer.from(value).toString('base64')}`; this.onloadend?.(); }); }
};
const out = new URL('../web/react/public/machine-cases/physical/', import.meta.url);
await mkdir(out, { recursive: true });
const colors = { base: 0x334a55, steel: 0x9fb3bb, blue: 0x167f9a, orange: 0xe39a35, dark: 0x17242b, white: 0xd8e3e6, joint: 0xc56d26, black: 0x11181c, gold: 0xd7b45a };
const material = (name, metalness = .35, roughness = .38) => new THREE.MeshStandardMaterial({ color: colors[name] ?? colors.steel, metalness, roughness });
const group = (parent, name, position = [0, 0, 0]) => { const g = new THREE.Group(); g.name = name; g.position.fromArray(position); parent.add(g); return g; };
const box = (parent, name, size, position, mat = 'steel') => { const m = new THREE.Mesh(new THREE.BoxGeometry(...size), material(mat)); m.name = name; m.position.fromArray(position); parent.add(m); return m; };
const cyl = (parent, name, radius, length, position, mat = 'steel', axis = 'z') => { const m = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 32), material(mat)); m.name = name; m.position.fromArray(position); if (axis === 'x') m.rotation.z = Math.PI / 2; if (axis === 'y') m.rotation.x = Math.PI / 2; parent.add(m); return m; };
const bar = (parent, name, a, b, radius = 10, mat = 'steel') => { const va = new THREE.Vector3(...a), vb = new THREE.Vector3(...b), delta = vb.clone().sub(va); const m = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, delta.length(), 24), material(mat)); m.name = name; m.position.copy(va.clone().add(vb).multiplyScalar(.5)); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize()); parent.add(m); return m; };
// One movable slot. dir/sign are the joint axis in the parent frame; the model
// keeps its zero pose so the runtime can evaluate absolute transforms.
function joint(parent, caseId, index, key, axis, kind, dir, sign = 1, position = [0, 0, 0], source = 'kinematics') {
const g = group(parent, key.toLowerCase().replace(/[^a-z0-9]+/g, '_'), position);
g.userData.linuxcncNodeId = `${caseId}:${key}`;
g.userData.linuxcncJoint = { index, key, axis, type: kind, unit: kind === 'revolute' ? 'deg' : 'mm', direction: dir, sign, drive: 'joint', source };
return g;
}
function strut(parent, caseId, index) {
const g = group(parent, `strut_${index + 1}`);
g.userData.linuxcncNodeId = `${caseId}:J${index}`;
g.userData.linuxcncJoint = { index, key: `J${index}`, axis: null, type: 'strut', unit: 'mm', direction: 'z', sign: 1, drive: 'joint', source: 'kinematics' };
return g;
}
function setup(id, coordinates, kinematics, referenceStatus) {
const scene = new THREE.Scene(); scene.name = `physical_${id}`;
scene.userData.linuxcncPhysicalStructure = { schema: 'linuxcnc.physical-machine/1', caseId: id, coordinates, kinematics, referenceStatus, units: 'mm/deg', gltfUnit: 'm', generated: true, generator: 'scripts/generate_physical_case_glbs.mjs', license: 'CC0-1.0 procedural design reference', parts: [] };
return scene;
}
function bed(scene, width = 900, depth = 700) { const base = group(scene, 'base'); box(base, 'bed_casting', [width, depth, 100], [0, 0, -50], 'base'); box(base, 'chip_tray', [width * .95, depth * .88, 18], [0, 0, 12], 'dark'); return base; }
// Vertical mill body. The slot list is explicit so every case can describe its
// own joint order without a second, implicit convention inside the builder.
function linearMill(scene, { id, slots }) {
const base = bed(scene);
box(base, 'column', [140, 420, 520], [0, 130, 300], 'base');
const nodes = new Map();
let parent = base;
// Slots tagged `under` are built on their named branch below, not in the Z chain.
for (const slot of slots.filter((item) => item.branch !== 'table' && !item.under)) {
const g = joint(parent, id, slot.index, slot.key, slot.axis, slot.kind, slot.dir, slot.sign ?? 1, slot.position ?? [0, 0, 0], slot.source);
nodes.set(slot.index, g);
if (slot.kind === 'prismatic') box(g, `${slot.key.toLowerCase()}_body`, slot.size ?? [240, 200, 50], [0, 0, 0], slot.mat ?? 'blue');
else cyl(g, `${slot.key.toLowerCase()}_bearings`, 64, 380, [0, 0, 0], 'joint', slot.dir);
parent = g;
}
const tool = group(parent, 'tool', [0, 0, -90]);
cyl(tool, 'spindle', 52, 180, [0, 0, 40], 'dark');
cyl(tool, 'tool_holder', 18, 90, [0, 0, -110], 'joint');
cyl(tool, 'tool', 8, 90, [0, 0, -200], 'gold');
const wSlot = slots.find((item) => item.under === 'tool');
if (wSlot) { const w = joint(tool, id, wSlot.index, wSlot.key, wSlot.axis, wSlot.kind, wSlot.dir, wSlot.sign ?? 1, [0, 0, 0], wSlot.source); nodes.set(wSlot.index, w); cyl(w, 'w_tool_direction', 16, 120, [0, 0, -60], 'gold'); }
const tableSlots = slots.filter((item) => item.branch === 'table');
let tableParent = base;
for (const [order, slot] of tableSlots.entries()) {
const g = joint(tableParent, id, slot.index, slot.key, slot.axis, slot.kind, slot.dir, slot.sign ?? 1, slot.position ?? [0, 0, 55 - order * 5], slot.source);
nodes.set(slot.index, g);
if (slot.kind === 'revolute') cyl(g, `${slot.key.toLowerCase()}_bearings`, 100, 380, [0, 0, 0], 'joint', slot.dir === 'x' ? 'x' : slot.dir === 'y' ? 'y' : 'z');
else box(g, `${slot.key.toLowerCase()}_slide`, [200, 160, 60], [0, 0, 0], 'orange');
tableParent = g;
}
const last = tableSlots.length ? nodes.get(tableSlots.at(-1).index) : base;
cyl(last, 'work_platter', 180, 28, [0, 0, 60], 'steel');
box(last, 'workholding', [190, 110, 28], [0, 0, 88], 'orange');
const gantry = slots.find((item) => item.under === 'base');
if (gantry) { const g = joint(base, id, gantry.index, gantry.key, gantry.axis, gantry.kind, gantry.dir, gantry.sign ?? 1, gantry.position ?? [0, 220, 80], gantry.source); nodes.set(gantry.index, g); box(g, 'y_right_gantry_drive', [120, 160, 120], [0, 0, 0], 'orange'); }
scene.userData.linuxcncPhysicalStructure.parts = ['base', ...slots.map((slot) => slot.key.toLowerCase()), 'tool'];
return scene;
}
function bridge(scene, id) {
const base = bed(scene, 1800, 1000);
box(base, 'foundation', [1900, 1100, 60], [0, 0, -80], 'dark');
box(base, 'left_column', [160, 360, 900], [-700, 100, 450], 'base');
box(base, 'right_column', [160, 360, 900], [700, 100, 450], 'base');
box(base, 'bridge_beam', [1500, 240, 160], [0, 100, 820], 'blue');
const x = joint(base, id, 0, 'X', 'X', 'prismatic', 'x');
box(x, 'moving_table', [1450, 720, 75], [0, 0, 50], 'steel');
const yTwin = joint(base, id, 6, 'Y#2', 'Y', 'prismatic', 'y', 1, [700, 0, 500], 'duplicate');
box(yTwin, 'y_right_gantry', [90, 120, 120], [0, 0, 0], 'orange');
box(base, 'y_left_servo', [90, 120, 120], [-700, 0, 500], 'orange');
const y = joint(base, id, 1, 'Y', 'Y', 'prismatic', 'y', 1, [0, 0, 500]);
box(y, 'bridge_carriage', [360, 300, 140], [0, 0, 220], 'blue');
const z = joint(y, id, 2, 'Z', 'Z', 'prismatic', 'z');
box(z, 'ram', [180, 180, 430], [0, 0, 0], 'steel');
const b = joint(z, id, 3, 'B', 'B', 'revolute', 'y', -1, [0, 0, -220]);
cyl(b, 'b_axis_bearings', 92, 260, [0, 0, 0], 'joint', 'y');
const c = joint(b, id, 4, 'C', 'C', 'revolute', 'z', 1, [0, 0, -160]);
cyl(c, 'c_spindle_axis', 54, 220, [0, 0, -60], 'dark');
const tool = group(c, 'tool', [0, 0, -170]);
const w = joint(tool, id, 5, 'W', 'W', 'prismatic', 'z', 1, [0, 0, 0]);
cyl(w, 'w_tool_direction', 16, 120, [0, 0, -60], 'gold');
scene.userData.linuxcncPhysicalStructure.parts = ['base', 'x_slide', 'y_slide', 'z_slide', 'b_head', 'c_head', 'tool', 'y_right_gantry', 'w_tool_direction'];
return scene;
}
function max5(scene, id) {
const base = bed(scene);
box(base, 'column', [160, 420, 480], [0, 150, 300], 'base');
const x = joint(base, id, 0, 'X', 'X', 'prismatic', 'x');
box(x, 'x_table', [520, 360, 55], [0, 0, 0], 'blue');
const y = joint(x, id, 1, 'Y', 'Y', 'prismatic', 'y', 1, [0, 0, 80]);
box(y, 'y_saddle', [260, 220, 50], [0, 0, -25], 'steel');
const z = joint(y, id, 2, 'Z', 'Z', 'prismatic', 'z', 1, [0, 0, 170]);
box(z, 'z_ram', [135, 145, 260], [0, 0, 60], 'blue');
const b = joint(z, id, 3, 'B', 'B', 'revolute', 'y', -1, [0, 0, -80]);
cyl(b, 'b_head', 70, 180, [0, 0, -60], 'dark', 'y');
const tool = group(b, 'tool', [0, 0, -140]); cyl(tool, 'tool', 10, 110, [0, 0, -55], 'gold');
const c = joint(base, id, 4, 'C', 'C', 'revolute', 'z', 1, [0, 0, 60]);
cyl(c, 'c_horizontal_table', 200, 40, [0, 0, 0], 'steel');
box(c, 'workholding', [190, 110, 28], [0, 0, 34], 'orange');
joint(base, id, 5, 'J5', null, 'prismatic', 'z', 1, [0, 0, 0], 'passive');
scene.userData.linuxcncPhysicalStructure.parts = ['base', 'x_slide', 'y_saddle', 'z_slide', 'b_head', 'tool', 'c_horizontal_table'];
return scene;
}
function parallel(scene, id, count) {
const base = bed(scene, 1100, 850);
box(base, 'base_plate', [900, 700, 40], [0, 0, 20], 'steel');
const platform = group(base, 'moving_platform', [0, 0, 600]);
for (let i = 0; i < count; i += 1) {
const angle = i * Math.PI * 2 / count;
const anchor = [420 * Math.cos(angle), 420 * Math.sin(angle), 20];
const s = strut(base, id, i);
s.position.fromArray(anchor);
const lower = group(s, `strut_${i + 1}_lower`); bar(lower, 'rod', [0, 0, 0], [0, 0, 540], 18, 'steel');
const upper = group(s, `strut_${i + 1}_upper`, [0, 0, 540]); cyl(upper, 'rod', 11, 60, [0, 0, 0], 'joint', 'z');
s.userData.linuxcncStrut = { index: i, lower: lower.name, upper: upper.name, baseAnchorMm: anchor, platformAnchorMm: [170 * Math.cos(angle + .3), 170 * Math.sin(angle + .3), 560], zeroLengthMm: 540 };
}
box(platform, 'platform_plate', [300, 300, 45], [0, 0, 0], 'blue');
cyl(platform, 'spindle', 50, 180, [0, 0, -110], 'dark');
cyl(platform, 'tool', 16, 120, [0, 0, -250], 'gold');
scene.userData.linuxcncPhysicalStructure.parts = ['base', ...Array.from({ length: count }, (_, i) => `strut_${i + 1}`), 'moving_platform', 'spindle', 'tool'];
return scene;
}
function robot(scene, id, count) {
const base = bed(scene, 650, 650);
cyl(base, 'base_turntable', 150, 70, [0, 0, 30], 'dark');
const j1 = joint(base, id, 0, 'J0', null, 'revolute', 'z', 1, [0, 0, 60]);
const j2 = joint(j1, id, 1, 'J1', null, 'revolute', 'y', 1, [0, 0, 200]);
box(j2, 'upper_arm', [100, 100, 260], [0, 0, 125], 'blue');
const j3 = joint(j2, id, 2, 'J2', null, 'revolute', 'y', 1, [0, 0, 260]);
box(j3, 'forearm', [82, 82, 260], [0, 0, 130], 'steel');
const j4 = joint(j3, id, 3, 'J3', null, 'revolute', 'z', 1, [0, 0, 260]);
cyl(j4, 'wrist_1', 55, 110, [0, 0, 55], 'joint', 'z');
const j5 = joint(j4, id, 4, 'J4', null, 'revolute', 'y', 1, [0, 0, 110]);
cyl(j5, 'wrist_2', 48, 95, [0, 0, 48], 'joint', 'y');
let tip = j5;
if (count > 5) { const j6 = joint(j5, id, 5, 'J5', null, 'revolute', 'z', 1, [0, 0, 96]); cyl(j6, 'wrist_3', 40, 90, [0, 0, 45], 'joint', 'z'); tip = j6; }
const tool = group(tip, 'tool', [0, 0, 90]); cyl(tool, 'tool', 14, 120, [0, 0, 60], 'gold');
scene.userData.linuxcncPhysicalStructure.parts = ['base', ...Array.from({ length: count }, (_, i) => `J${i}`), 'tool'];
return scene;
}
const specs = [
['xyzac-trt', () => linearMill(setup('xyzac-trt', 'XYZAC', 'xyzac-trt-kins', 'real-reference'), { id: 'xyzac-trt', slots: [
{ index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
{ index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
{ index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
{ index: 3, key: 'A', axis: 'A', kind: 'revolute', dir: 'x', branch: 'table', position: [0, 0, 55] },
{ index: 4, key: 'C', axis: 'C', kind: 'revolute', dir: 'z', branch: 'table', position: [0, 0, 50] },
] })],
['xyzbc-trt', () => linearMill(setup('xyzbc-trt', 'XYZBC', 'xyzbc-trt-kins', 'real-reference'), { id: 'xyzbc-trt', slots: [
{ index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
{ index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
{ index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
{ index: 3, key: 'B', axis: 'B', kind: 'revolute', dir: 'y', branch: 'table', position: [0, 0, 55] },
{ index: 4, key: 'C', axis: 'C', kind: 'revolute', dir: 'z', branch: 'table', position: [0, 0, 50] },
] })],
['xyzab-tdr', () => linearMill(setup('xyzab-tdr', 'XYZAB', 'xyzab_tdr_kins', 'design-reference'), { id: 'xyzab-tdr', slots: [
{ index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
{ index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
{ index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
{ index: 3, key: 'A', axis: 'A', kind: 'revolute', dir: 'x', branch: 'table', position: [0, 0, 55] },
{ index: 4, key: 'B', axis: 'B', kind: 'revolute', dir: 'y', branch: 'table', position: [0, 0, 50] },
] })],
['bridge-mill-5axiskins', () => bridge(setup('bridge-mill-5axiskins', 'XYZBCWY', '5axiskins', 'real-reference'), 'bridge-mill-5axiskins')],
['max5-maxkins', () => max5(setup('max5-maxkins', 'XYZBC', 'maxkins', 'design-reference'), 'max5-maxkins')],
['hexapod-genhexkins', () => parallel(setup('hexapod-genhexkins', 'XYZABC', 'genhexkins', 'real-reference'), 'hexapod-genhexkins', 6)],
['pentapod-pentakins', () => parallel(setup('pentapod-pentakins', 'XYZAB', 'pentakins', 'real-reference'), 'pentapod-pentakins', 5)],
['puma-pumakins', () => robot(setup('puma-pumakins', 'XYZABC', 'pumakins', 'real-reference'), 'puma-pumakins', 6)],
['puma-genserkins', () => robot(setup('puma-genserkins', 'XYZABC', 'genserkins', 'real-reference'), 'puma-genserkins', 6)],
['trivkins-xyzab', () => linearMill(setup('trivkins-xyzab', 'XYZAB', 'trivkins', 'design-reference'), { id: 'trivkins-xyzab', slots: [
{ index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
{ index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
{ index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
{ index: 3, key: 'A', axis: 'A', kind: 'revolute', dir: 'x', branch: 'table', position: [0, 0, 55] },
{ index: 4, key: 'B', axis: 'B', kind: 'revolute', dir: 'y', branch: 'table', position: [0, 0, 50] },
] })],
['trivkins-xyzcw', () => linearMill(setup('trivkins-xyzcw', 'XYZCW', 'trivkins', 'design-reference'), { id: 'trivkins-xyzcw', slots: [
{ index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
{ index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
{ index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
{ index: 3, key: 'C', axis: 'C', kind: 'revolute', dir: 'z', branch: 'table', position: [0, 0, 55] },
{ index: 4, key: 'W', axis: 'W', kind: 'prismatic', dir: 'z', under: 'tool', position: [0, 0, 0] },
] })],
['trivkins-xyzya', () => linearMill(setup('trivkins-xyzya', 'XYZYA', 'trivkins', 'design-reference'), { id: 'trivkins-xyzya', slots: [
{ index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
{ index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
{ index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
{ index: 3, key: 'Y#2', axis: 'Y', kind: 'prismatic', dir: 'y', under: 'base', position: [-420, 0, 80], source: 'duplicate' },
{ index: 4, key: 'A', axis: 'A', kind: 'revolute', dir: 'x', branch: 'table', position: [0, 0, 55] },
] })],
['scorbot-kins', () => robot(setup('scorbot-kins', 'XYZAB', 'scorbot-kins', 'real-reference'), 'scorbot-kins', 5)],
];
const manifest = { schema: 'linuxcnc.physical-machine-glb/1', license: 'CC0-1.0 procedural design reference', generator: 'scripts/generate_physical_case_glbs.mjs', cases: [] };
for (const [id, make] of specs) {
const scene = make();
const exportScene = new THREE.Scene(); exportScene.name = scene.name; exportScene.userData = scene.userData;
const machineRoot = group(exportScene, 'machine_root');
while (scene.children.length) machineRoot.add(scene.children[0]);
machineRoot.scale.setScalar(0.001);
exportScene.updateMatrixWorld(true);
const bytes = await new GLTFExporter().parseAsync(exportScene, { binary: true, trs: true, onlyVisible: false });
const buffer = Buffer.from(bytes);
await writeFile(new URL(`${id}.glb`, out), buffer);
const structure = scene.userData.linuxcncPhysicalStructure;
const joints = []; machineRoot.traverse((node) => { if (node.userData.linuxcncJoint) joints.push({ ...node.userData.linuxcncJoint, node: node.name, nodeId: node.userData.linuxcncNodeId }); });
manifest.cases.push({ id, coordinates: structure.coordinates, kinematics: structure.kinematics, filename: `${id}.glb`, bytes: buffer.length, sha256: createHash('sha256').update(buffer).digest('hex'), referenceStatus: structure.referenceStatus, parts: structure.parts, joints: joints.sort((a, b) => a.index - b.index) });
exportScene.traverse((o) => o.geometry?.dispose());
}
await writeFile(new URL('manifest.json', out), `${JSON.stringify(manifest, null, 2)}\n`);
await writeFile(new URL('LICENSE.txt', out), 'Procedural design-reference assets for linuxcncWebWASM. CC0-1.0.\nNo manufacturer affiliation or dimensional calibration is implied.\n');
console.log(JSON.stringify({ schema: 'physical-case-glb-generation.v1', cases: manifest.cases.length, joints: manifest.cases.reduce((sum, c) => sum + c.joints.length, 0) }));

View File

@@ -0,0 +1,165 @@
"""Generate detailed Blender GLB design references for the 13 LinuxCNC cases.
The meshes are procedural design references. Dimensions are plausible layout
values in metres and are carried as editable GLB geometry, while kinematics and
calibration remain the responsibility of the machine manager metadata.
"""
import bpy, json, math, os, hashlib, sys
from mathutils import Vector
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
OUT = os.path.join(ROOT, "web", "react", "public", "machine-cases", "physical")
os.makedirs(OUT, exist_ok=True)
COLORS = {
"cast": (0.16, 0.22, 0.25, 1), "paint": (0.08, 0.35, 0.42, 1),
"light": (0.48, 0.58, 0.61, 1), "steel": (0.20, 0.27, 0.30, 1),
"rail": (0.68, 0.76, 0.78, 1), "black": (0.015, 0.022, 0.025, 1),
"yellow": (0.86, 0.48, 0.08, 1), "orange": (0.75, 0.23, 0.05, 1),
"glass": (0.03, 0.18, 0.21, 1), "rubber": (0.02, 0.025, 0.027, 1),
"joint": (0.78, 0.28, 0.05, 1),
}
MATS = {}
def mat(name, metallic=0.35, rough=0.32):
if name in MATS: return MATS[name]
m = bpy.data.materials.new("M_" + name)
m.diffuse_color = COLORS[name]
m.use_nodes = True
bsdf = m.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = COLORS[name]
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = rough
MATS[name] = m
return m
def collection(name):
c = bpy.data.collections.new(name); bpy.context.scene.collection.children.link(c); return c
def link(obj, parent):
for c in list(obj.users_collection): c.objects.unlink(obj)
parent.objects.link(obj); return obj
def group(parent, name, position=(0, 0, 0)):
g = bpy.data.objects.new(name, None)
if hasattr(parent, "objects"):
parent.objects.link(g)
else:
g.parent = parent
g.location = position
return g
def root_object(scene):
root = bpy.data.objects.new("machine_root", None)
scene.collection.objects.link(root)
return root
def box(name, size, loc, material="steel", parent=None, bevel=0.012):
bpy.ops.mesh.primitive_cube_add(location=loc)
o=bpy.context.object; o.name=name; o.dimensions=size; bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
o.data.materials.append(mat(material))
if bevel:
mod=o.modifiers.new("edge_radius", "BEVEL"); mod.width=bevel; mod.segments=3
if parent: o.parent=parent
return o
def cyl(name, radius, depth, loc, material="steel", parent=None, axis="z", vertices=32):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=depth, location=loc)
o=bpy.context.object; o.name=name; o.data.materials.append(mat(material))
if axis=="x": o.rotation_euler[1]=math.pi/2
elif axis=="y": o.rotation_euler[0]=math.pi/2
if parent: o.parent=parent
return o
def beam(name, a, b, radius, material="steel", parent=None):
a,b=Vector(a),Vector(b); d=b-a
o=cyl(name,radius,d.length/1.0,a+d/2,material,parent,"z",24)
o.rotation_mode="QUATERNION"; o.rotation_quaternion=Vector((0,0,1)).rotation_difference(d.normalized()); return o
def bolt_row(parent, start, count, step, axis="x", radius=0.006):
for i in range(count):
p=list(start); p["xyz".index(axis)] += i*step; cyl(f"bolt_{axis}_{i+1}",radius,0.012,p,"black",parent,axis)
def rail(parent, name, loc, length, axis="x"):
box(name+"_guide", (length,.026,.026) if axis=="x" else (.026,length,.026) if axis=="y" else (.026,.026,length), loc, "rail", parent, .004)
bolt_row(parent, [loc[0]-length/2+0.04,loc[1],loc[2]], max(2,int(length/.16)), .16, axis, .004)
def rotary_group(parent,name,axis,loc,radius=0.22,thickness=.08):
g=group(parent,name,loc)
cyl(name+"_bearing",radius*.38,thickness*1.8,(0,0,0),"black",g,axis)
cyl(name+"_axis",radius*.25,thickness*2.1,(0,0,0),"yellow",g,axis)
return g
def industrial_mill(id, coords, title, rotary):
scene=base_scene(id,coords,"design-reference" if id not in ("xyzac-trt","xyzbc-trt") else "real-reference",title)
root=root_object(scene)
base=group(root,"base"); box("bed_casting",(1.45,.95,.18),(0,0,.09),"cast",base); box("chip_tray",(1.38,.82,.035),(0,0,.20),"black",base)
for x in (-.62,.62): box("column",(.14,.20,1.15),(x,.22,.78),"cast",base); rail(base,"vertical_rail",(x, .105, .78),.86,"z")
box("bridge_crossbeam",(1.28,.20,.16),(0,.22,1.27),"paint",base)
x=group(root,"x_slide"); box("x_carriage",(.30,.22,.18),(0,.10,1.15),"paint",x); rail(x,"x_linear_rail",(0,.04,1.26),1.05,"x")
y=group(x,"y_saddle"); box("y_saddle_casting",(.30,.33,.14),(0,0,1.12),"cast",y); rail(y,"y_linear_rail",(0,-.13,1.02),.62,"y")
z=group(y,"z_slide"); box("z_ram",(.22,.22,.56),(0,.0,.80),"paint",z); rail(z,"z_linear_rail",(.12,0,.80),.48,"z")
tool=group(z,"tool"); cyl("spindle_motor",.075,.24,(0,0,.45),"black",tool); cyl("spindle_quill",.048,.23,(0,0,.22),"rail",tool); cyl("tool_holder",.024,.11,(0,0,.04),"yellow",tool)
tilt=group(base, "a_tilt_frame" if rotary=="AC" else "b_trunnion" if rotary=="BC" else "a_outer_table", [0,-.03,.27])
axis="x" if rotary in ("AC","AB") else "y"; rotary_group(tilt,"tilt_axis",axis,(0,0,0),.32,.10)
work=group(tilt,"c_table" if rotary in ("AC","BC") else "b_inner_table")
rotary_group(work,"rotary_axis","z",(0,0,.08),.27,.10); cyl("work_platter",.24,.06,(0,0,.15),"rail",work); box("fixture_plate",(.28,.18,.04),(0,0,.20),"yellow",work)
scene["linuxcnc.parts"]=["base","x_slide","y_saddle","z_slide","tool",tilt.name,work.name]
return scene
def bridge_scene():
scene=base_scene("bridge-mill-5axiskins","XYZBCWY","real-reference","Bridge Mill bi-rotary head")
root=root_object(scene); base=group(root,"base")
box("foundation",(2.4,1.4,.22),(0,0,.11),"cast",base); box("moving_x_table",(2.05,1.05,.11),(0,0,.28),"steel",base)
for x in (-.95,.95): box("portal_column",(.20,.42,1.75),(x,.18,1.0),"cast",base)
box("portal_beam",(2.15,.42,.22),(0,.18,1.82),"paint",base); x=group(root,"x_slide"); box("bridge_carriage",(.38,.36,.24),(0,.18,1.63),"steel",x); y=group(x,"y_slide"); box("cross_carriage",(.38,.50,.20),(0,0,1.62),"paint",y); z=group(y,"z_slide"); box("ram",(.22,.22,.65),(0,0,1.32),"steel",z)
b=group(z,"b_head"); rotary_group(b,"b_axis","y",(0,0,.95),.23,.10); c=group(b,"c_head"); rotary_group(c,"c_axis","z",(0,0,.8),.17,.10); cyl("spindle",.055,.25,(0,0,.58),"black",c); cyl("tool",.022,.18,(0,0,.36),"yellow",c); box("y_left_drive",(.10,.20,.18),(-1.04,.18,1.72),"orange",base); box("y_right_drive",(.10,.20,.18),(1.04,.18,1.72),"orange",base); scene["linuxcnc.parts"]=["base","x_slide","y_slide","z_slide","b_head","c_head","y_left_drive","y_right_drive"]; return scene
def parallel_scene(id,count,title):
scene=base_scene(id,"XYZABC" if count==6 else "XYZAB","real-reference",title); root=root_object(scene); base=group(root,"base"); box("base_plate",(1.15,.90,.10),(0,0,.05),"cast",base); plat=group(root,"moving_platform",[0,0,.75]); box("platform_plate",(.46,.46,.07),(0,0,0),"paint",plat)
for i in range(count):
a=2*math.pi*i/count; b=2*math.pi*i/count+.4; beam(f"strut_{i+1}_lower",[.43*math.cos(a),.43*math.sin(a),.13],[.20*math.cos(b),.20*math.sin(b),.67],.027,"steel",base); beam(f"strut_{i+1}_upper",[.20*math.cos(b),.20*math.sin(b),.67],[.0,.0,.75],.014,"yellow",plat)
cyl("spindle",.07,.22,(0,0,-.16),"black",plat); cyl("tool",.025,.18,(0,0,-.36),"yellow",plat); scene["linuxcnc.parts"]=["base"]+[f"strut_{i+1}" for i in range(count)]+["moving_platform","spindle","tool"]; return scene
def robot_scene(id, five=False):
coords="XYZAB" if five else "XYZABC"; scene=base_scene(id,coords,"real-reference","SCORBOT ER-4U" if five else "PUMA serial robot"); root=root_object(scene); base=group(root,"base"); box("robot_pedestal",(.55,.55,.18),(0,0,.09),"cast",base)
parents=[base]; names=["j1_base","j2_shoulder","j3_elbow","j4_wrist","j5_pitch"]+([] if five else ["j6_wrist"])
heights=[.25,.32,.27,.14,.10,.08]
for i,n in enumerate(names):
g=group(parents[-1],n,[0,0,heights[i] if i<len(heights) else .1]); cyl(n+"_bearing",.11 if i<3 else .06,.08,(0,0,0),"joint",g,"z"); linkpart=box(n+"_link",(.16,.16,.28 if i<3 else .14),(0,0,.16 if i<3 else .08),"paint" if i%2 else "steel",g); parents.append(g)
tool=group(parents[-1],"tool"); cyl("tool_holder",.025,.18,(0,0,-.10),"yellow",tool); scene["linuxcnc.parts"]=["base"]+names+["tool"]; return scene
def baseline_scene(id,coords):
scene=industrial_mill(id,coords,id,"AB"); scene["linuxcnc.baseline"] = True; return scene
def base_scene(id,coords,status,title):
bpy.ops.wm.read_factory_settings(use_empty=True); MATS.clear(); scene=bpy.context.scene; scene.name=id; scene.unit_settings.system="METRIC"; scene.unit_settings.length_unit="MILLIMETERS"; scene["linuxcncPhysicalStructure"]={"schema":"linuxcnc.physical-machine/1","caseId":id,"coordinates":coords,"kinematics":id if id not in ("trivkins-xyzab","trivkins-xyzcw","trivkins-xyzya") else "trivkins","referenceStatus":status,"title":title,"gltfUnit":"m","license":"CC0-1.0 procedural design reference"}; return scene
specs=[
('xyzac-trt',lambda:industrial_mill('xyzac-trt','XYZAC','XYZAC TRT','AC')),
('xyzbc-trt',lambda:industrial_mill('xyzbc-trt','XYZBC','XYZBC TRT','BC')),
('xyzab-tdr',lambda:industrial_mill('xyzab-tdr','XYZAB','XYZAB TDR','AB')),
('bridge-mill-5axiskins',bridge_scene),
('max5-maxkins',lambda:industrial_mill('max5-maxkins','XYZBC','MAX5 maxkins','BC')),
('hexapod-genhexkins',lambda:parallel_scene('hexapod-genhexkins',6,'Variax Hexapod')),
('pentapod-pentakins',lambda:parallel_scene('pentapod-pentakins',5,'METROM Pentapod')),
('puma-pumakins',lambda:robot_scene('puma-pumakins')),
('puma-genserkins',lambda:robot_scene('puma-genserkins')),
('trivkins-xyzab',lambda:baseline_scene('trivkins-xyzab','XYZAB')),
('trivkins-xyzcw',lambda:baseline_scene('trivkins-xyzcw','XYZCW')),
('trivkins-xyzya',lambda:baseline_scene('trivkins-xyzya','XYZYA')),
('scorbot-kins',lambda:robot_scene('scorbot-kins',True)),
]
only = None
if "--" in sys.argv:
argv = sys.argv[sys.argv.index("--") + 1:]
if "--only" in argv and argv.index("--only") + 1 < len(argv): only = argv[argv.index("--only") + 1]
for id,make in specs:
if only and id != only: continue
scene=make(); out=os.path.join(OUT,f'{id}.glb')
bpy.ops.wm.save_as_mainfile(filepath=os.path.join('/tmp',f'{id}.blend'))
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', export_apply=True, use_selection=False)
for o in list(scene.objects):
for d in list(o.data.materials) if hasattr(o.data,'materials') and o.data else []: pass
print('BLENDER_GLB_OK',id,out)

View File

@@ -0,0 +1,66 @@
// Browser acceptance: switch through all 13 five-axis cases, confirm the bound
// structure GLB loads and that the joint state published on the preview host
// carries the expected slot keys and changes while a program runs.
import { chromium } from '/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs';
import { readFile } from 'node:fs/promises';
import { mkdir } from 'node:fs/promises';
const BASE = process.env.AXIS_URL ?? 'http://127.0.0.1:5174/';
const OUT = new URL('../coverage/glb-machine-manager/physical-drive/', import.meta.url);
await mkdir(OUT, { recursive: true });
const catalog = JSON.parse(await readFile(new URL('../web/workspace/machine-visual-bindings.json', import.meta.url), 'utf8'));
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
const errors = [];
page.on('pageerror', (error) => errors.push(String(error)));
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.querySelector('[data-axis-state-source="linuxcnc-wasm"]'), null, { timeout: 120000 });
// A case switch powers the machine off, and `gates.load` requires
// machineOn && idle, so every switch after the first one needs power on first.
async function ensureLoadable() {
for (let attempt = 0; attempt < 4; attempt += 1) {
const state = await page.evaluate(() => {
const button = (id) => document.querySelector(`[data-axis-control="${id}"]`);
return { estop: button("toolbar-estop")?.getAttribute("aria-pressed"), power: button("toolbar-power")?.getAttribute("aria-pressed"), powerDisabled: button("toolbar-power")?.hasAttribute("disabled") };
});
if (state.estop === "true") { await button("toolbar-estop"); continue; }
if (state.power !== "true" && !state.powerDisabled) { await button("toolbar-power"); await page.waitForTimeout(1500); continue; }
return;
}
}
async function button(id) { await page.click(`[data-axis-control="${id}"]`).catch(() => undefined); await page.waitForTimeout(1200); }
const rows = [];
for (const item of catalog.cases) {
await ensureLoadable();
await page.selectOption('[data-axis-control="workspace-case-select"]', item.entrypoint);
// Wait for THIS case's model, not merely "some" model: the previous case's
// GLB stays mounted until the new one is parsed, which made an earlier run
// report slot keys one case behind.
const expectedModel = item.glb.default;
const loaded = await page.waitForFunction(
(name) => {
const node = document.querySelector('[data-xyzac-preview="machine"]');
const model = node?.getAttribute('data-xyzac-model-source-path');
const glb = node?.getAttribute('data-machine-glb');
return model === name && glb ? glb : null;
},
expectedModel, { timeout: 90000 }).catch(() => null);
const glb = loaded ? await loaded.jsonValue() : null;
const slots = await page.evaluate(() => {
const raw = document.querySelector('[data-xyzac-preview="machine"]')?.getAttribute('data-machine-joint-state');
if (!raw) return null;
try { return Object.keys(JSON.parse(raw)); } catch { return null; }
});
await page.screenshot({ path: new URL(`${item.machineId}.png`, OUT).pathname, fullPage: false });
rows.push({ id: item.machineId, coordinates: item.coordinates, glbLoaded: Boolean(glb), slotKeys: slots, expectedKeys: item.jointOrder.map((slot) => slot.key) });
console.log(JSON.stringify({ id: item.machineId, glbLoaded: Boolean(glb), slots }));
}
await browser.close();
const report = { schema: 'five-axis-live-models.v1', url: BASE, cases: rows, browser_errors: errors };
await readFile;
(await import('node:fs/promises')).writeFile(new URL('browser.report.json', OUT), `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify({ cases: rows.length, loaded: rows.filter((row) => row.glbLoaded).length, errors: errors.length }));

View File

@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
const playwright = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage();
try {
await page.goto(process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5173/");
const report = await page.evaluate(async () => {
const { GLTFLoader } = await import("/node_modules/three/examples/jsm/loaders/GLTFLoader.js");
const { analyzeGlbFiveAxisScene } = await import("/src/glb-five-axis-analyzer.ts");
const response = await fetch("/@fs/home/mes006/linuxcncWebWASM/glb/m1.glb");
if (!response.ok) throw new Error(`m1.glb HTTP ${response.status}`);
const gltf = await new GLTFLoader().parseAsync(await response.arrayBuffer(), "");
return analyzeGlbFiveAxisScene(gltf.scene, "m1.glb");
});
assert.deepEqual(report.axes.map(({ axis }) => axis), ["X", "Y", "Z", "A", "C"]);
assert.equal(report.suggestedCoordinates, "XYZAC");
assert.equal(report.suggestedKinematics, "xyzac-trt-kins");
assert.equal(report.rtcp.mode, "native-trt");
assert.ok(report.boundsMm.size.every((value) => value > 100));
await page.locator('[data-axis-control="machine-glb-settings"]').click();
await page.locator('[data-axis-control="machine-glb-file"]').setInputFiles("/home/mes006/linuxcncWebWASM/glb/m1.glb");
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.getAttribute("data-xyzac-model-source") === "glb-axis-analysis/linuxcnc-wasm", undefined, { timeout: 30000 });
const runtime = await page.locator(".xyzac-machine-view").evaluate((element) => ({ source: element.getAttribute("data-xyzac-model-source"), bindings: JSON.parse(element.getAttribute("data-xyzac-axis-bindings") || "[]") }));
assert.equal(runtime.source, "glb-axis-analysis/linuxcnc-wasm");
assert.deepEqual(runtime.bindings.map(({ axis }) => axis), ["X", "Y", "Z", "A", "C"]);
const driven = await page.evaluate(async () => {
const { loadImportedMachine, buildImportedMachine } = await import("/src/imported-machine.ts");
const bytes = await (await fetch("/@fs/home/mes006/linuxcncWebWASM/glb/m1.glb")).arrayBuffer();
const asset = await loadImportedMachine(bytes, "m1.glb");
const assembly = buildImportedMachine(asset);
assembly.update({ joints: [12, 23, 34, 10, 40], offsets: { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: 0, y_offset: 0, z_offset: 0, tool_offset: 0 } });
const nodes = Object.fromEntries(["JT_X", "JT_Y", "JT_Z", "JT_A", "JT_C"].map((name) => { const node = assembly.machine.getObjectByName(name); return [name, { position: node?.position.toArray(), quaternion: node?.quaternion.toArray() }]; }));
assembly.dispose(); return nodes;
});
assert.ok(Math.abs(driven.JT_X.position[0] - 0.12007432234287262) < 1e-9);
assert.ok(Math.abs(driven.JT_A.quaternion[0]) > 0.01);
assert.ok(Math.abs(driven.JT_C.quaternion[2]) > 0.01);
console.log(JSON.stringify({ report_id: "glb-five-axis-analyzer-v1", status: "verified", axes: report.axes.map(({ axis, nodeName }) => ({ axis, nodeName })), suggested: report.suggestedCoordinates, kinematics: report.suggestedKinematics, bounds_mm: report.boundsMm, runtime, driven }));
} finally {
await browser.close();
}

View File

@@ -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-manager-preview]").getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-render-state"), "rendered"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
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"), "neutral-studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-exposure"), "1"); 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"), "rendered"); 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");
const selectRoot = async (index) => { const node = control("machine-tree-node").filter({ has: page.locator("span").filter({ hasText: new RegExp(`^part_${index}$`) }) }).first(); await node.dispatchEvent("click"); };
for (const [index, axis] of [[1, "X"], [2, "Y"], [3, "Z"], [5, "A"], [6, "C"]]) { await selectRoot(index); await control("machine-tree-axis-role").selectOption(axis); }

View File

@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import * as THREE from '../web/react/node_modules/three/build/three.module.js';
import { GLTFExporter } from '../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js';
globalThis.FileReader = class {
readAsArrayBuffer(blob) { blob.arrayBuffer().then((result) => { this.result = result; this.onloadend?.(); }); }
readAsDataURL(blob) { blob.arrayBuffer().then((result) => { this.result = `data:${blob.type};base64,${Buffer.from(result).toString('base64')}`; this.onloadend?.(); }); }
};
const pw = process.env.PLAYWRIGHT_MODULE ? await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href) : await import('playwright');
const browser = await (pw.default ?? pw).chromium.launch({ executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const baseline = process.env.RENDER_BASELINE === '1';
const output = new URL(`../coverage/glb-machine-manager/material-rendering/${baseline ? 'before' : 'after'}/`, import.meta.url);
await mkdir(output, { recursive: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 1100 } });
page.setDefaultTimeout(30000);
const report = { status: 'running', baseline, checks: [], errors: [] };
page.on('pageerror', (e) => report.errors.push(e.message));
const c = (id) => page.locator(`[data-axis-control="${id}"]`);
const preview = page.locator('[data-machine-manager-preview]');
async function importAndCapture(file, stem) {
await c('machine-manager-files').setInputFiles(file);
await page.waitForFunction(() => document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false' && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount === '1');
await c('machine-view-fit').click();
await page.waitForFunction(() => document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerView === 'fit' && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerRenderState === 'rendered');
await preview.screenshot({ path: fileURLToPath(new URL(`${stem}.png`, output)) });
}
try {
await page.goto(process.env.MACHINE_MANAGER_URL ?? 'http://127.0.0.1:5173/');
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
await c('machine-manager-settings').click();
await importAndCapture(fileURLToPath(new URL('../glb/m1.glb', import.meta.url)), 'm1');
report.manager = await preview.evaluate((el) => ({ lighting: el.dataset.machineManagerLighting, toneMapping: el.dataset.machineManagerToneMapping, exposure: el.dataset.machineManagerExposure, meshes: Number(el.dataset.machineManagerSceneMeshes) }));
assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5);
if (!baseline) {
assert.equal(report.manager.toneMapping, 'AgX');
assert.ok(Number(report.manager.exposure) <= 1);
// Exercise export from the actual manager: render settings must not recolor saved materials.
const downloadPromise = page.waitForEvent('download'); await c('machine-manager-export').click();
const saved = await readFile(await (await downloadPromise).path());
const original = await readFile(new URL('../glb/m1.glb', import.meta.url));
const json = (buffer) => JSON.parse(buffer.toString('utf8', 20, 20 + buffer.readUInt32LE(12)));
const materials = json(saved).materials, sourceMaterials = json(original).materials;
for (const source of sourceMaterials) {
const target = materials.find((m) => m.name === source.name); assert.ok(target, source.name);
const a = source.pbrMetallicRoughness ?? {}, b = target.pbrMetallicRoughness ?? {};
for (const key of ['metallicFactor', 'roughnessFactor']) assert.ok(Math.abs((a[key] ?? 1) - (b[key] ?? 1)) < 1e-6, `${source.name}/${key}`);
const color = a.baseColorFactor ?? [1, 1, 1, 1], savedColor = b.baseColorFactor ?? [1, 1, 1, 1];
color.forEach((value, i) => assert.ok(Math.abs(value - savedColor[i]) < 1e-6, `${source.name}/color${i}`));
if (source.normalTexture) assert.ok(target.normalTexture, `${source.name}/normal map`);
}
report.checks.push('manager export retains source PBR colors, roughness, metalness and normal maps');
}
await c('machine-document-new').click();
const fixture = new THREE.Scene();
const swatches = [0xff0000, 0x00ff00, 0x0000ff, 0xbdbdbd, 0xffffff, 0x222222];
for (let i = 0; i < swatches.length; i++) {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(.13, 48, 24), new THREE.MeshStandardMaterial({ color: swatches[i], roughness: i === 3 ? .22 : .38, metalness: i === 3 ? 1 : 0 }));
mesh.name = `material-probe-${i}`; mesh.position.set((i % 3 - 1) * .35, i < 3 ? 0 : .35, .2); fixture.add(mesh);
}
const buffer = Buffer.from(await new GLTFExporter().parseAsync(fixture, { binary: true }));
await importAndCapture({ name: 'rgb-material-probes.glb', mimeType: 'model/gltf-binary', buffer }, 'rgb-materials');
fixture.traverse((object) => { object.geometry?.dispose(); object.material?.dispose(); });
report.checks.push('real m1 and saturated RGB / white / metal / dark material probes render without browser exceptions');
if (!baseline) {
await page.keyboard.press('Escape'); await c('machine-glb-settings').click();
await c('machine-glb-file').setInputFiles(fileURLToPath(new URL('../glb/m1.glb', import.meta.url)));
await page.waitForFunction(() => document.querySelector('.xyzac-machine-view')?.dataset.machineGlb && document.querySelector('main')?.dataset.axisProgramLoading === 'false');
await page.keyboard.press('Escape');
const viewer = page.locator('.xyzac-machine-view');
assert.equal(await viewer.locator('canvas').getAttribute('data-machine-tone-mapping'), 'AgX');
await viewer.screenshot({ path: fileURLToPath(new URL('controller-m1.png', output)) });
report.checks.push('control panel uses the same AgX neutral studio for imported GLBs');
}
assert.deepEqual(report.errors, []); report.status = 'verified';
} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; }
finally { await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2)); await browser.close(); }
console.log(JSON.stringify(report));

View File

@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import * as THREE from '../web/react/node_modules/three/build/three.module.js';
const playwright = process.env.PLAYWRIGHT_MODULE
? await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href) : await import('playwright');
const browser = await (playwright.default ?? playwright).chromium.launch({
executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome', headless: true,
args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
});
const output = new URL('../coverage/glb-machine-manager/model-selection/', import.meta.url);
await mkdir(output, { recursive: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(15000);
const errors = []; page.on('pageerror', (error) => errors.push(error.message));
const report = { status: 'running', checks: [], errors };
const c = (id) => page.locator(`[data-axis-control="${id}"]`);
const preview = page.locator('[data-machine-manager-preview]');
const rows = () => page.locator('[data-machine-model-id]');
const row = (id) => page.locator(`[data-machine-model-id="${id}"]`);
const modelCount = (n) => page.waitForFunction((n) => document.querySelectorAll('[data-machine-model-id]').length === n && document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false', n);
const selected = (id) => page.waitForFunction((id) => document.querySelector(`[data-machine-model-id="${id}"] [aria-pressed="true"]`) && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerSelectedModel === id, id);
// Small independent GLBs give identical filenames but distinct import identities.
function fixture(x) {
const geometry = new THREE.BoxGeometry(.22, .22, .22).toNonIndexed();
const position = geometry.getAttribute('position');
const binary = Buffer.from(position.array.buffer);
const json = { asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }],
nodes: [{ name: 'fixture', translation: [x, 0, .3], children: [1] }, { name: 'solid', mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
materials: [{ doubleSided: true, pbrMetallicRoughness: { baseColorFactor: [.15, .6, .5, 1] } }],
accessors: [{ bufferView: 0, componentType: 5126, count: position.count, type: 'VEC3', min: [-.11, -.11, -.11], max: [.11, .11, .11] }],
bufferViews: [{ buffer: 0, byteLength: binary.length }], buffers: [{ byteLength: binary.length }] };
const raw = Buffer.from(JSON.stringify(json));
const payload = Buffer.concat([raw, Buffer.alloc((4 - raw.length % 4) % 4, 32)]);
const header = Buffer.alloc(20); header.writeUInt32LE(0x46546c67, 0); header.writeUInt32LE(2, 4);
header.writeUInt32LE(28 + payload.length + binary.length, 8); header.writeUInt32LE(payload.length, 12); header.writeUInt32LE(0x4e4f534a, 16);
const binHeader = Buffer.alloc(8); binHeader.writeUInt32LE(binary.length, 0); binHeader.writeUInt32LE(0x004e4942, 4);
geometry.dispose();
return { name: 'same-name.glb', mimeType: 'model/gltf-binary', buffer: Buffer.concat([header, payload, binHeader, binary]) };
}
try {
await page.goto(process.env.MACHINE_MANAGER_URL ?? 'http://127.0.0.1:5173/');
await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
await c('machine-manager-settings').click();
assert.equal(await c('machine-model-delete-selected').isDisabled(), true);
await c('machine-manager-files').setInputFiles([fixture(-.35), fixture(.35)]);
await modelCount(2);
const [first, second] = await rows().evaluateAll((items) => items.map((el) => el.dataset.machineModelId));
assert.notEqual(first, second); await selected(first);
await row(second).locator('[data-axis-control="machine-model-select"]').click(); await selected(second);
await c('machine-tree-search').fill('no-matching-node');
await row(first).locator('[data-axis-control="machine-model-select"]').click(); await selected(first);
assert.equal(await c('machine-tree-search').inputValue(), '');
assert.equal(await preview.getAttribute('data-machine-manager-selection-visible'), 'true');
report.checks.push('same filenames retain unique selection; model selection clears search and highlights preview');
const cameraState = JSON.parse(await preview.getAttribute('data-machine-manager-camera'));
const canvas = preview.locator('canvas'), box = await canvas.boundingBox();
const camera = new THREE.PerspectiveCamera(42, box.width / box.height, .005, 100);
camera.position.fromArray(cameraState.position); camera.up.fromArray(cameraState.up);
camera.lookAt(new THREE.Vector3(...cameraState.target)); camera.updateMatrixWorld(true);
const ndc = new THREE.Vector3(.35, 0, .3).project(camera);
const point = { x: box.x + (ndc.x + 1) * box.width / 2, y: box.y + (1 - ndc.y) * box.height / 2 };
await page.mouse.click(point.x, point.y); await selected(second);
await row(first).locator('[data-axis-control="machine-model-select"]').click(); await selected(first);
await page.mouse.move(point.x, point.y); await page.mouse.down();
await page.mouse.move(point.x + 40, point.y + 20, { steps: 4 });
await page.mouse.move(point.x, point.y, { steps: 4 }); await page.mouse.up();
await selected(first);
report.checks.push('viewport click picks correct import; orbit drag returning to start does not select');
await row(second).locator('[data-axis-control="machine-model-delete"]').click();
await modelCount(1); await selected(first);
await c('machine-manager-undo').click(); await modelCount(2); await selected(second);
await c('machine-model-delete-selected').click(); await modelCount(1); await selected(first);
await c('machine-manager-undo').click(); await modelCount(2); await selected(second);
await c('machine-manager-redo').click(); await modelCount(1); await selected(first);
report.checks.push('delete unselected/selected import by ID; undo restores and selects it; redo repairs selection');
await c('machine-manager-undo').click(); await modelCount(2);
// The second imported part was parented to the first by the assembly editor.
await row(first).locator('[data-axis-control="machine-model-select"]').click();
await c('machine-model-delete-selected').click(); await modelCount(1); await selected(second);
assert.equal(await c('machine-manager-parent').inputValue(), '');
await c('machine-manager-undo').click(); await modelCount(2); await selected(first);
await row(second).locator('[data-axis-control="machine-model-select"]').click();
assert.equal(await c('machine-manager-parent').inputValue(), first);
report.checks.push('deleting parent import preserves separately imported child; undo restores parent link');
await c('machine-model-delete-selected').click(); await modelCount(1);
await c('machine-model-delete-selected').click(); await modelCount(0);
assert.equal(await c('machine-model-delete-selected').isDisabled(), true);
assert.equal(await page.locator('.machine-preview-empty').isVisible(), true);
await c('machine-manager-undo').click(); await modelCount(1); await selected(first);
await c('machine-manager-redo').click(); await modelCount(0);
report.checks.push('last deletion clears preview; undo restores selection from empty state');
const m1 = fileURLToPath(new URL('../glb/m1.glb', import.meta.url));
const bytes = await readFile(m1);
await c('machine-manager-files').setInputFiles({ name: 'm1.glb', mimeType: 'model/gltf-binary', buffer: bytes });
await modelCount(1); const m1id = await rows().first().getAttribute('data-machine-model-id');
await selected(m1id); assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5);
await c('machine-model-delete-selected').click(); await modelCount(0);
await c('machine-manager-undo').click(); await modelCount(1); await selected(m1id);
assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5);
await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').check();
assert.equal(await c('machine-model-delete-selected').isDisabled(), true);
await c('machine-manager-tab-structure').click(); assert.equal(await c('machine-model-delete').isDisabled(), true);
await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').uncheck();
await c('machine-manager-tab-structure').click();
report.checks.push('real m1 deletion/undo preserves all five bindings; live follow locks deletion');
await page.screenshot({ path: fileURLToPath(new URL('desktop.png', output)) });
for (const [width, height] of [[1154, 520], [720, 900]]) {
await page.setViewportSize({ width, height });
await c('machine-model-select').click(); await selected(m1id);
await c('machine-model-delete').click(); await modelCount(0);
await c('machine-manager-undo').click(); await modelCount(1); await selected(m1id);
}
assert.deepEqual(errors, []); report.checks.push('selection and deletion reachable on short and narrow viewports');
report.status = 'verified';
} catch (error) {
report.status = 'failed'; report.error = String(error.stack ?? error);
await page.screenshot({ path: fileURLToPath(new URL('failure.png', output)) }).catch(() => {});
throw error;
} finally {
await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2) + '\n');
await browser.close();
}
console.log(JSON.stringify(report));

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const imported = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE ?? '/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js'));
const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({ headless: true, executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const errors = [];
page.on('pageerror', (error) => errors.push(`pageerror:${error.message}`));
page.on('console', (message) => { if (message.type() === 'error') errors.push(`console:${message.text()}`); });
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const cases = [
['xyzac-trt', 7], ['xyzbc-trt', 7], ['xyzab-tdr', 7], ['bridge-mill-5axiskins', 10], ['max5-maxkins', 7],
['hexapod-genhexkins', 10], ['pentapod-pentakins', 9], ['puma-pumakins', 8], ['puma-genserkins', 8],
['trivkins-xyzab', 7], ['trivkins-xyzcw', 7], ['trivkins-xyzya', 7], ['scorbot-kins', 7],
];
const report = { schema: 'physical-case-glb-browser.v1', status: 'running', cases: [], browser_errors: errors };
const out = new URL('../coverage/glb-machine-manager/physical-cases/', import.meta.url);
await mkdir(out, { recursive: true });
try {
await page.goto(process.env.PHYSICAL_CASE_URL ?? 'http://127.0.0.1:5174/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForFunction(() => document.querySelector('main.axis-app')?.dataset.axisStateSource === 'linuxcnc-wasm', null, { timeout: 60000 });
await control('machine-manager-settings').click();
for (const [id, minimumNodes] of cases) {
await control('machine-document-new').click();
const chooser = page.waitForEvent('filechooser');
await control('machine-manager-import').click();
const fileChooser = await chooser;
await fileChooser.setFiles({ name: `${id}.glb`, mimeType: 'model/gltf-binary', buffer: await readFile(new URL(`../web/react/public/machine-cases/physical/${id}.glb`, import.meta.url)) });
await page.waitForFunction(() => document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false', null, { timeout: 30000 });
const nodes = page.locator('[data-axis-control="machine-tree-node"]');
await page.waitForFunction((minimum) => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= minimum, minimumNodes, { timeout: 30000 });
assert.ok(await nodes.count() >= minimumNodes, id);
const preview = page.locator('[data-machine-manager-preview]');
assert.equal(await preview.getAttribute('data-machine-manager-render-state'), 'rendered', id);
await page.screenshot({ path: new URL(`${id}.png`, out).pathname, fullPage: true });
report.cases.push({ id, tree_nodes: await nodes.count(), scene_objects: await preview.getAttribute('data-machine-manager-scene-objects'), rendered: await preview.getAttribute('data-machine-manager-rendered') });
}
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', out).pathname, fullPage: true }).catch(() => {});
throw error;
} finally {
report.browser_errors = errors;
await writeFile(new URL('browser.report.json', out), `${JSON.stringify(report, null, 2)}\n`);
await browser.close();
}
console.log(JSON.stringify(report));