按关节槽位驱动 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:
63
scripts/blender_machine_template.py
Normal file
63
scripts/blender_machine_template.py
Normal 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}")
|
||||
234
scripts/generate_physical_case_glbs.mjs
Normal file
234
scripts/generate_physical_case_glbs.mjs
Normal 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) }));
|
||||
165
scripts/generate_realistic_machine_glbs.py
Normal file
165
scripts/generate_realistic_machine_glbs.py
Normal 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)
|
||||
66
scripts/test_five_axis_live_models.mjs
Normal file
66
scripts/test_five_axis_live_models.mjs
Normal 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 }));
|
||||
44
scripts/test_glb_five_axis_analyzer.mjs
Normal file
44
scripts/test_glb_five_axis_analyzer.mjs
Normal 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();
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
79
scripts/test_machine_material_rendering.mjs
Normal file
79
scripts/test_machine_material_rendering.mjs
Normal 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));
|
||||
131
scripts/test_machine_model_selection.mjs
Normal file
131
scripts/test_machine_model_selection.mjs
Normal 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));
|
||||
52
scripts/test_physical_case_glb_browser.mjs
Normal file
52
scripts/test_physical_case_glb_browser.mjs
Normal 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));
|
||||
Reference in New Issue
Block a user