Add dual canvas XYZBC and refined machine visualization

This commit is contained in:
2026-09-10 05:47:14 +08:00
parent d90933b980
commit 17887c199a
15 changed files with 935 additions and 92 deletions

View File

@@ -0,0 +1,234 @@
# LinuxCNC Web/WASM 双 Canvas 预览设计与实施方案
**文档状态**:实施设计 v1.0
**适用案例**:`linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini`(同样适用于 `xyzbc-trt.ini`)
**关联入口**:[`run-5axis-rtcp.sh`](../run-5axis-rtcp.sh)
**现有前端**:[`web/react/src/App.tsx`](../web/react/src/App.tsx)、[`XyzacToolPreview.tsx`](../web/react/src/XyzacToolPreview.tsx)、[`XyzacMachineView.tsx`](../web/react/src/XyzacMachineView.tsx)
**协议类型**:[`web/react/src/protocol.ts`](../web/react/src/protocol.ts)、[`xyzac-visual-adapter.ts`](../web/react/src/xyzac-visual-adapter.ts)
## 1. 背景与目标
当前 AXIS 风格面板的 Preview 区域需要同时表达两种不同的信息:
1. 刀具在工件坐标中的 canonical 程序轨迹,以及执行过程中已经走过和正在执行的实时刀具轨迹。
2. 由五个关节(table-x、saddle-y、spindle-z、tilt-a、rotate-c)驱动的完整机床三维模型运动。
本方案将 Preview 明确拆为两个相互独立的 `<canvas>`:
* **Canvas A:Toolpath/Simulation**。只绘制程序预览段、快速移动、圆弧/螺旋插补、实时执行轨迹、当前段和刀尖;不绘制机床结构。
* **Canvas B:Machine 3D**。只绘制 `machine_model` 结构、关节层级、刀具、转台、限位盒和当前姿态;可选显示从同一快照投影的当前刀具路径,但默认关闭以避免与 Canvas A 重复。
两个 canvas 不共享 WebGL 场景、相机、动画循环或可变对象。它们共享同一个经过校验的 `XyzacVisualState`,该状态由 LinuxCNC WASM 在 Dedicated Worker 中生成。React 不解析 G-code、不计算 RTCP、不根据墙钟推导姿态。
目标是:在运行、暂停、单步、急停、回零、M428/M429/M430 切换、清除可视轨迹和 Worker 恢复后,两个画面都能用相同的 `simulation_tick`、`state_hash` 和姿态数据重现同一时刻;任一画面缺数据时显示 pending/error,而不是使用本地猜测值。
## 2. 范围与非目标
### 2.1 范围
* XYZAC/XYZBC TRT 案例的 exact-path 启动、配置资源闭包和 RTCP remap。
* canonical 程序轨迹生成(直线、快速、圆弧、螺旋),每段的源行、采样和运动类型。
* 固定 logical tick 的实时运动样本、活动段进度、姿态和五个 joint 值。
* Vismach 模型身份、组件层级、来源文件哈希和关节到模型节点的映射。
* 两个 canvas 的尺寸、生命周期、ResizeObserver、帧丢弃/补快照、相机控制和清理。
* native/WASM/Worker 对照、浏览器验收和恢复测试。
### 2.2 非目标
* 在浏览器中复刻 AXIS 的 Tk 绘图实现或像素级截图。
* 用 JS preview 引擎替代 LinuxCNC RS274/TP/kinematics。
* 通过宿主 LinuxCNC GUI、NML socket、shell 或真实硬件取得位置。
* 在两个 canvas 之间复制一份可写的运动状态。
## 3. `run-5axis-rtcp.sh` 执行链
脚本是 native oracle 和案例资源定义的入口;Web/WASM 必须保留同样的路径和语义。
```text
run-5axis-rtcp.sh [xyzac-trt.ini|xyzbc-trt.ini]
-> 解析 REPO_ROOT/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting
-> 选择 demo/*.ngc 和 remap_subs/helix_{ac,bc}.ngc
-> 校验 INI、demo、subroutine、M428 和 scripts/linuxcnc
-> cd linuxcnc
-> scripts/rip-environment linuxcnc -r <case>.ini
-> [DISPLAY]/[RS274NGC]/[KINS]/[HAL]/[TRAJ]/[EMCIO] 初始化
-> milltask 解析 G-code,TP 生成 canonical motion
-> xyzac-trt-kins 做 forward/inverse kinematics 和 RTCP
-> motion/servo-thread 每个周期更新 joints、pose、反馈和 HAL
-> xyzac-trt-gui.py(native vismach)读取五个 joint 与 offset 绘制机床
```
`xyzac-trt.ini` 的关键输入必须进入 Web 资源图:`OPEN_FILE`、`SUBROUTINE_PATH`、`PARAMETER_FILE`、三个 `REMAP`、`KINEMATICS=xyzac-trt-kins sparm=identityfirst`、`JOINTS=5`、`POSTGUI_HALFILE`、`TOOL_TABLE`、`SERVO_PERIOD=1000000` 和 `TASK CYCLE_TIME=0.010`。`xyzac-trt_cmds.hal` 中五个 `joint.*.pos-fb` 到 `xyzac-trt-gui.*` 的 net 是 Canvas B 的关节数据来源;`motion.switchkins-type` 由 HAL analog-out 驱动,不能由 UI 直接改写。
demo 中的 `helix_ac` 先执行 M429(identity),设置 G54,再执行 M428(XYZAC),改变 A/C 后进行 G2 螺旋,最后恢复 M429 并回到起点。Web runner 必须记录每个 M428/M429/M430 的命令、HAL 反馈和确认 tick,以便两个 canvas 在模式切换处建立姿态屏障。
## 4. 总体架构
```text
React App
├─ Panel snapshot reducer / projectXyzacVisual()
├─ Canvas A: ToolpathCanvas (独立 renderer/scene/RAF)
└─ Canvas B: MachineCanvas (独立 renderer/scene/RAF)
│ 只读 XyzacVisualState
▼
WorkerClient -> Dedicated sim worker
├─ ProtocolGateway / schema validator
├─ LinuxCNC WASM task + RS274 + TP + motion + kinematics
├─ HAL/IO/Python virtual runtime
├─ Snapshot publisher / trajectory stream / backpressure
└─ checkpoint / recovery / event ledger
```
Worker 每个 logical tick 依次执行 interpreter、TP、motion、kinematics、HAL/IO,再生成 snapshot。`StatePublisher` 可发送完整 `STATE_SNAPSHOT` 或 motion delta;React reducer 只接受 tick 不倒退且 schema/hash 合法的数据。Canvas A/B 在自己的 RAF 中读取同一个不可变快照引用:渲染时间可以不同,数据时间必须由 `simulation_tick` 标识。
## 5. 双 Canvas 设计
### 5.1 Canvas A:刀具预览和实时仿真路径
* 场景节点:`origin`、`grid`、`canonicalPath`、`rapidPath`、`liveHistory`、`activeLivePath`、`tool`、可选 `limits/offsets/extents`。
* canonical 段来自 `visual.toolpath`,保留 `id`、`source_line`、`kind`、`motion_type`、`start`、`end`、`samples`、圆心/法向/圈数和 `samples_source`。
* 实时轨迹来自 `visual.motion_trace`;当前刀具位置使用 `visual.pose`,当前段和进度使用 `active_segment_id/active_progress`。
* 使用正交投影显示 XY/XZ/YZ 和透视投影;相机状态只属于 Canvas A。暂停时保持最后权威点,清除命令只清除 live history,不删除 canonical 预览。
* 轨迹颜色约定:canonical 完成段浅灰,rapid 虚线红,实时历史橙色,当前段/刀尖青色,错误或越限红色。颜色是显示约定,不改变数据语义。
### 5.2 Canvas B:机床三维模型运动
* 场景节点必须按 `XYZAC_MACHINE_GROUPS` 建立:`xyzac-machine`、`table-x`、`saddle-y`、`spindle-z`、`tilt-a`、`rotate-c`、`tool-offset`。
* 模型描述来自 `visual.machine_model`:`id`、`source_path`、`source_hash`、`components`、`joints`、`coordinate_system`。模型缺失或哈希不匹配时显示结构化错误并保留轴姿态 HUD。
* 关节变换顺序固定为 table-x -> saddle-y -> spindle-z -> tilt-a -> rotate-c -> tool-offset;方向和单位从模型 manifest 定义,禁止组件自行猜测。
* 使用 `visual.joints` 驱动机械层级,使用 `visual.pose` 驱动刀尖/姿态 HUD;二者必须在同一 tick 通过一致性检查。
* 默认不绘制 Canvas A 的完整路径;`showPath` 仅作为明确的诊断层,且数据仍来自同一 snapshot。
### 5.3 生命周期与性能
每个组件在 mount 时创建自己的 renderer、scene、camera、controls 和 RAF,在 unmount 时停止 RAF、解除 ResizeObserver、释放 geometry/material/texture/render target 并移除 canvas。`devicePixelRatio` 上限为 2;尺寸小于 280x260 时保持稳定最小绘制区域。
Worker 流可以高于显示刷新率。Canvas 只消费最新可绘制快照,中间 motion chunk 可丢弃;发现 tick gap 时标记 `data-visual-gap` 并请求 `SNAPSHOT_REQUEST`。最终 pose、state hash、报警和事件不可丢失。渲染异常只影响对应 canvas,必须通过诊断事件上报且不能停止仿真。
## 6. 数据契约
### 6.1 权威快照
两个 canvas 的唯一输入是 `panel_snapshot.visual` 与配套的 `kinematics/motion`:
```ts
type DualCanvasSnapshot = {
schema: "axis-panel-snapshot.v1";
context_id: string;
session_id: string;
simulation_tick: number;
state_hash: string;
visual: {
schema: "axis-visual.v1";
source: "wasm_canonical_motion";
pose: Record<"x"|"y"|"z"|"a"|"c", number>;
joints: number[]; // exactly 5: mm, mm, mm, deg, deg
toolpath?: ToolpathSegment[]; // omitted in motion_delta
motion_trace?: MotionSample[];
active_segment_id?: number | null;
active_progress?: number; // [0, 1]
pose_epoch?: number;
live_path_epoch?: number;
machine_model?: MachineModel;
};
kinematics: {
schema: "wasm-xyzac-kinematics.v1";
active_type: 0|1|2;
pose: Record<string, number>;
joints: number[];
offsets: { x_rot_point: number; y_offset: number; z_offset: number; tool_offset: number };
state_hash: string;
wasm_state_hash: string;
};
};
```
`ToolpathSegment` 的数值单位是 mm/deg,坐标顺序为 XYZAC;`MotionSample` 必须含 `tick`、五关节、pose、活动段和进度。`machine_model.source` 必须为 `linuxcnc-wasm-worker`,模型 `source_hash` 用于恢复和 native 对账。`projectXyzacVisual()` 负责默认值、有限值和长度校验;校验失败时 `authoritative=false`。
### 6.2 消息时序与一致性
```text
HELLO -> CAPABILITIES -> SESSION_ATTACH -> SESSION_READY
PROGRAM_LOAD -> ACK -> STATE_SNAPSHOT(toolpath, model, tick T)
PROGRAM_RUN -> ACK
每个 tick: WASM step -> motion delta/event -> STATE_SNAPSHOT/CHUNK
PROGRAM_PAUSE/ABORT/HOME/M428... -> ACK -> RESULT + full snapshot
WORKER_RESTART -> RECOVER -> SNAPSHOT_REQUEST -> SESSION_READY
```
`seq` 在 session 内递增,副作用命令带 `operation_id`。Canvas 只接受 `simulation_tick >= lastTick` 的数据;`pose_epoch` 变化清空插值队列但不清理 canonical path,`live_path_epoch` 变化清理实时历史并建立新轨迹段。`state_hash` 不一致触发诊断和完整快照请求。
## 7. 精美机床模型与刀具实施
`xyzac-machine-model.ts` 以原生 `xyzac-trt-gui.py` 的层级和轴方向为基准,使用圆角铸件、喷涂/钢件/硬质合金材质、环境反射、软阴影和工作平面构成完整模型。模型包含床身、底座前门、排屑盘、立柱侧筋、Z 导轨和丝杠、主轴箱、冷却喷嘴、工作灯、X/Y 导轨、A 轴支架、C 轴转台、T 型槽、沉头螺栓、转台刻度和 AXIS 铭牌。
刀具采用带夹头螺母、磨削刀柄、四条螺旋刃槽和平底刃口的 Ø6 mm 硬质合金立铣刀示意模型。直径和刃形是展示参数,目前不自动映射刀具表中的刀号/直径。
机床属于固定主轴、升降工作台结构,刀尖按原生 Vismach 基准位于 `Z = 200 - tool_offset` mm;X/Y/Z/A/C 相对运动由工作台关节完成,因此机床世界坐标中的刀尖不能直接等同于轨迹画布的 `visual.pose`。夹头保持在主轴上,刀柄随刀长偏置调整伸出量,刀尖仍落在原生基准位置。A 轴中心跟随 `y_offset/z_offset`,C 轴仅旋转台面,上游传动箱不随 C 轴转动。
`tool` 控制器提供刀具特写;重置按模型边界和 canvas 比例恢复整机取景。相机拖动不改变任何关节或 WASM 快照。geometry/material 缓存在模型创建阶段,运行只更新节点变换,卸载时统一释放。
机床 canvas 使用 Three.js `OrbitControls` 的标准鼠标映射:左键拖动旋转模型,中键拖动缩放,右键拖动平移,滚轮缩放;触摸单指旋转、双指缩放/平移。按钮控制与鼠标控制共用同一相机和 target,互不创建第二套视图状态。
已完成验证:`npm run build`;`test_xyzac_threejs.mjs` 验证轴向、转台层级、0/15/30/-5 mm 刀长的刀尖基准和资源释放;`scripts/test_xyzac_machine_model_browser.mjs` 在 Chromium 1600/1024/768/390 px 验证两个 canvas、八个相机操作、取景、WASM 运行和停止后的画面稳定性。运行浏览器测试需将 `PLAYWRIGHT_MODULE` 指向本机 Playwright 的 `index.mjs`;截图和 JSON 报告默认保存到 `/tmp/xyzac-machine-model/`。
## 8. 实施步骤
1. **契约冻结**:确认 `axis-panel-snapshot.v1`、`axis-visual.v1`、模型 manifest、单位、节点顺序、tick/hash 规则,并为字段增加运行时校验。
2. **Worker 资源闭包**:实现 `run-5axis-rtcp.sh` 对应的 exact resolver;加载 INI、HAL、XML、TBL、VAR、NGC、remap 和模型来源,输出 source hash 和缺失边。
3. **WASM 轨迹/姿态发布**:在固定 tick 中生成 canonical segments、motion trace、XYZAC kinematics、machine_model;支持 full snapshot 与 motion delta,保留 M428/M429/M430 的验证结果。
4. **Canvas A 重构**:将 `XyzacToolPreview` 的 DOM host 改为显式 `<canvas>`,封装 `ToolpathRenderer`,实现路径层、实时层、插值队列、gap 恢复和视觉清除。
5. **Canvas B 重构**:将 `XyzacMachineView` 的 DOM host 改为独立 `<canvas>`,封装 `MachineRenderer`,按 manifest 创建层级、驱动五关节和 tool offset,并实现模型缺失/哈希错误状态。
6. **App 接线**:Preview split 保持两个独立 pane;通过同一个 store snapshot 调用两个 renderer。禁止组件之间传递可写场景对象;保留控制器、DRO、图层开关和无障碍标签。
7. **性能与恢复**:加入 transferable trajectory buffer、RAF 限频、ResizeObserver、WebGL context lost 恢复、Worker 重启恢复和 OPFS checkpoint 对账。
8. **测试与发布门禁**:先跑 Worker/source parity,再跑 React、canvas 像素和浏览器矩阵;所有阻断项写入 report,不以 mock 结果替代 WASM 证据。
## 9. 任务分配
| 任务 | 交付物 | 负责人角色 | 依赖 |
|---|---|---|---|
| DUAL-01 契约/manifest | schema、校验器、模型节点和单位表 | 协议与数据负责人 | 无 |
| DUAL-02 exact runner | 配置资源图、启动日志、source hash | LinuxCNC/WASM 负责人 | DUAL-01 |
| DUAL-03 canonical preview | segment、圆弧/螺旋采样、source line | TP/解释器负责人 | DUAL-02 |
| DUAL-04 live snapshot | fixed tick、motion trace、pose/joints、epoch | motion/kinematics 负责人 | DUAL-03 |
| DUAL-05 ToolpathCanvas | Canvas A renderer、图层和 gap 恢复 | 前端可视化负责人 | DUAL-01、04 |
| DUAL-06 MachineCanvas | Canvas B 模型层级、关节变换、模型校验 | Three.js/模型负责人 | DUAL-01、04 |
| DUAL-07 App/UX | 双 pane 接线、控制器、无障碍、响应式 | React 负责人 | DUAL-05、06 |
| DUAL-08 验收/回归 | native/WASM diff、浏览器截图、恢复报告 | QA/发布负责人 | 全部 |
任务状态使用 `planned -> implementing -> verified`;任一任务发现缺少 WASM ABI、oracle 或模型资源时标记 `blocked`,不得用 JS 近似结果升级状态。
## 10. 验收标准
### 10.1 功能验收
* 加载 `xyzac-trt.ini` 后,Canvas A 的 canonical segment 数、源行、螺旋半径/圈数与 Worker golden 一致;Canvas B 显示 `xyzac-trt` 模型和五个关节。
* 执行 demo 时,两个 canvas 的 `data-xyzac-tick` 和 `state_hash` 始终相同;A 的刀尖与 `visual.pose` 相同,B 的节点姿态与 `visual.joints` 相同。
* M428/M429/M430 切换在相同 tick 产生姿态屏障;不允许出现旧关节队列回放或刀具跳回旧 pose。
* Pause/Resume/Step/Abort/Home、feed hold、limit 和 ESTOP 后,实时轨迹和机床姿态均符合 LinuxCNC 状态;`VISUAL_CLEAR` 只清除实时层。
* Worker 重启后恢复到最近 checkpoint,canonical path、model hash、active segment、pose、joints 和 epoch 可重建,无重复 IO/tool/remap 副作用。
### 10.2 自动化与浏览器验收
* `web/worker/test_xyzac_exact_preview.mjs`、`test_xyzac_machine_sequence.mjs`、`test_xyzac_live_path_lifecycle.mjs` 及 source/native parity 报告通过。
* React 测试断言两个 `<canvas>` 节点数量为 2、不同 DOM 节点/renderer、相同 tick/hash 输入;卸载后无 RAF、ResizeObserver 或 WebGL 资源泄漏。
* Playwright 在 Chromium、Firefox、Safari(可用环境)验证桌面和窄屏布局;canvas 像素非空、模型和轨迹不重叠、相机控制有效、context lost 可恢复。
* 性能目标:正常程序执行时主线程渲染 >= 30 FPS;Worker 流发生背压时 UI 不阻塞,最终快照延迟 < 200 ms;单帧内存和轨迹长度受 `CAPABILITIES.limits` 限制。
## 11. 风险与缓解
| 风险 | 影响 | 缓解/阻断条件 |
|---|---|---|
| Canvas A/B 读取不同快照 | 视觉分裂、错误姿态 | 单 store snapshot 引用、tick/hash 断言;不一致即请求 full snapshot |
| 用墙钟插值改变结果 | 与 LinuxCNC 不一致 | 插值仅用于显示,权威位置来自 logical tick;禁止 wall-clock 驱动 WASM |
| 轨迹 chunk 丢失 | 实时线断裂 | gap 标记 + SNAPSHOT_REQUEST;最终 pose/hash 不可丢 |
| M428/M429 后旧队列回放 | 刀具跳变 | `pose_epoch` 屏障、清空 playback queue、记录首个 divergent tick |
| 模型来源或轴方向错误 | 机床镜像/错位 | manifest source hash、固定节点顺序、native screenshot 对照 |
| WebGL context lost/设备限制 | 单 canvas 黑屏 | 独立 renderer 重建;仿真继续运行;报告 `visual_renderer_error` |
| 大型程序导致复制和 GC | 卡顿/内存峰值 | transferable buffer、分段缓存、限长 live history、按需加载 canonical path |
| Worker 恢复重复副作用 | 状态损坏 | operation ledger、checkpoint tick/hash、结果重查后才重试 |
| 浏览器/ABI 缺失 | 无法宣称 parity | `CAPABILITIES` 明确 exclusions;保持 blocked 并附证据 |
## 12. 交付物与回滚
交付必须包含本设计文档、协议/schema 变更、Canvas A/B 组件、模型 manifest、Worker/source parity 报告、浏览器截图和性能报告。发布包保留上一版本 renderer 和协议 schema;若任一 canvas 出现不可恢复错误,可降级为静态快照显示,但不得降级为本地 JS 运动 authority。回滚后仍应能读取旧 checkpoint,并在恢复报告中记录版本、context、source hash、tick 和 state hash。

View File

@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { mkdir, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const output = process.env.MACHINE_ARTIFACT_DIR ?? "/tmp/xyzac-machine-model";
await mkdir(output, { recursive: true });
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({ viewport: { width: 1600, height: 1100 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("dialog", (dialog) => dialog.accept());
const button = (id) => page.locator(`[data-axis-control="${id}"]`);
const machine = page.locator(".xyzac-machine-view");
const canvas = machine.locator("canvas[data-engine]");
const pixels = () => canvas.evaluate((canvas) => {
const gl = canvas.getContext("webgl2");
const bytes = new Uint8Array(canvas.width * canvas.height * 4);
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, bytes);
let hash = 2166136261, lit = 0, minX = canvas.width, maxX = 0, minY = canvas.height, maxY = 0;
const colors = new Set();
for (let i = 0; i < bytes.length; i += 16) {
hash = Math.imul(hash ^ bytes[i], 16777619);
colors.add(`${bytes[i]},${bytes[i + 1]},${bytes[i + 2]}`);
if (bytes[i] + bytes[i + 1] + bytes[i + 2] > 330) {
lit++; const x = (i / 4) % canvas.width, y = Math.floor(i / 4 / canvas.width);
minX = Math.min(x, minX); maxX = Math.max(x, maxX); minY = Math.min(y, minY); maxY = Math.max(y, maxY);
}
}
return { hash: hash >>> 0, colors: colors.size, litRatio: lit / (bytes.length / 16), width: canvas.width, height: canvas.height, bounds: [minX, minY, maxX, maxY] };
});
const settled = () => page.waitForTimeout(400);
const report = { viewports: [], controls: [], motion: null };
try {
await page.goto(process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5174/");
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramLoading === "false");
await machine.scrollIntoViewIfNeeded(); await settled();
assert.equal(await page.locator(".axis-preview-split canvas[data-engine]").count(), 2);
const initial = await pixels();
const toolBefore = await page.locator(".xyzac-tool-preview canvas[data-engine]").evaluate((canvas) => canvas.toDataURL());
assert.equal(await machine.getAttribute("data-xyzac-mouse-controls"), "left-rotate-middle-zoom-right-pan-wheel-zoom");
const box = await canvas.boundingBox();
assert.ok(box, "machine canvas must have a screen box");
const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
const mouseHash = async () => (await pixels()).hash;
const beforeMouseRotate = await mouseHash();
await page.mouse.move(center.x, center.y); await page.mouse.down({ button: "left" });
await page.mouse.move(center.x + 120, center.y - 55, { steps: 8 }); await page.mouse.up({ button: "left" }); await settled();
assert.notEqual(await mouseHash(), beforeMouseRotate, "left drag must rotate the model");
const beforeMousePan = await mouseHash();
await page.mouse.move(center.x, center.y); await page.mouse.down({ button: "right" });
await page.mouse.move(center.x + 55, center.y + 25, { steps: 8 }); await page.mouse.up({ button: "right" }); await settled();
assert.notEqual(await mouseHash(), beforeMousePan, "right drag must pan the model");
const beforeWheel = await mouseHash();
await page.mouse.move(center.x, center.y); await page.mouse.wheel(0, -420); await settled();
assert.notEqual(await mouseHash(), beforeWheel, "wheel must zoom the model");
for (const action of ["zoomIn", "zoomOut", "panLeft", "panRight", "panUp", "panDown", "rotateLeft", "rotateRight", "rotateUp", "rotateDown", "tool", "reset"]) {
const before = await pixels();
await button(`machine-${action}`).click(); await settled();
const after = await pixels(); assert.notEqual(after.hash, before.hash, action);
report.controls.push(action);
if (action === "tool") await machine.screenshot({ path: `${output}/tool-detail.png` });
}
assert.equal(await page.locator(".xyzac-tool-preview canvas[data-engine]").evaluate((canvas) => canvas.toDataURL()), toolBefore, "machine controls must leave the toolpath camera alone");
assert.equal((await pixels()).hash, initial.hash, "reset should recover the fitted camera");
for (const width of [1600, 1024, 768, 390]) {
await page.setViewportSize({ width, height: width > 900 ? 1100 : 844 });
await machine.scrollIntoViewIfNeeded(); await settled();
const sample = await pixels();
assert.ok(sample.height <= 680, "canvas height must not grow with its drawing buffer");
assert.ok(sample.colors > 100 && sample.litRatio > .04, JSON.stringify(sample));
assert.ok(sample.bounds[0] > 0 && sample.bounds[1] > 0 && sample.bounds[2] < sample.width - 1 && sample.bounds[3] < sample.height - 1, `clipped model: ${JSON.stringify(sample)}`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
const overlap = await machine.evaluate((node) => {
const a = node.querySelector(".xyzac-machine-controller").getBoundingClientRect();
const b = node.querySelector(".xyzac-machine-hud").getBoundingClientRect();
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
});
assert.equal(overlap, false, "controller and HUD must not overlap");
await machine.screenshot({ path: `${output}/machine-${width}.png` });
report.viewports.push({ viewport: width, ...sample });
}
await page.setViewportSize({ width: 1600, height: 1100 });
await button("toolbar-power").click();
await button("manual-home-all").click();
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisRunEnabled === "true");
await machine.scrollIntoViewIfNeeded(); await settled();
const jointsBefore = await machine.getAttribute("data-xyzac-joints");
const beforeMotion = await pixels();
await button("toolbar-run").click();
await page.waitForFunction((joints) => document.querySelector(".xyzac-machine-view")?.dataset.xyzacJoints !== joints, jointsBefore);
await machine.scrollIntoViewIfNeeded(); await settled();
const moving = await pixels(); assert.notEqual(moving.hash, beforeMotion.hash);
report.motion = { before: jointsBefore, after: await machine.getAttribute("data-xyzac-joints"), renderedTick: await machine.getAttribute("data-xyzac-rendered-tick") };
await page.locator(".axis-preview-split").screenshot({ path: `${output}/running-dual-preview.png` });
await button("toolbar-stop").click(); await settled();
await machine.scrollIntoViewIfNeeded(); await settled();
const stopped = await pixels(); await settled(); assert.equal((await pixels()).hash, stopped.hash, "stopped machine must not animate autonomously");
assert.deepEqual(errors, []);
await writeFile(`${output}/report.json`, JSON.stringify({ status: "verified", ...report, errors }, null, 2));
console.log(JSON.stringify({ status: "verified", output, ...report }));
} finally { await browser.close(); }

File diff suppressed because one or more lines are too long

View File

@@ -1,115 +1,198 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { axisIcon } from "./sourceAssets";
import { buildXyzacMachine } from "./xyzac-machine-model";
import { projectXyzacVisual, type XyzacVisualSource, type XyzacVisualState } from "./xyzac-visual-adapter";
type Point = Record<string, number>;
type Segment = { id?: number; active?: boolean; motion_type?: number; turn?: number; center?: Point; normal?: Point; samples?: Point[]; start?: Point; end?: Point };
type Segment = { id?: number; active?: boolean; motion_type?: number; samples?: Point[]; start?: Point; end?: Point };
type Props = { visual: XyzacVisualState; toolpath?: Segment[]; view?: "z" | "z2" | "x" | "y" | "y2" | "p"; zoom?: number; rotateMode?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean };
type Controller = Record<string, () => void>;
type Host = HTMLDivElement & { xyzacMachineController?: Controller };
const toView = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * .01, Number(point.z ?? 0) * .01, Number(point.y ?? 0) * .01);
const DEG = Math.PI / 180;
const MM = 0.01;
function material(color: number) { return new THREE.MeshStandardMaterial({ color, roughness: .78, metalness: .12 }); }
function sourcePosition(x: number, y: number, z: number) { return new THREE.Vector3(x * MM, z * MM, y * MM); }
function sourceBox(width: number, depth: number, height: number, color: number) { return new THREE.Mesh(new THREE.BoxGeometry(width * MM, height * MM, depth * MM), material(color)); }
function sourceCylinderZ(radius: number, height: number, color: number) { return new THREE.Mesh(new THREE.CylinderGeometry(radius * MM, radius * MM, height * MM, 32), material(color)); }
function translate(name: string, object: THREE.Object3D, x: number, y: number, z: number) { object.name = name; object.position.copy(sourcePosition(x, y, z)); return object; }
export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotateMode = false, showPath = true, showLive = true, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
const host = useRef<HTMLDivElement>(null);
export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotateMode = false, showPath = false, showLive = false, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
const host = useRef<Host>(null);
const visualRef = useRef(visual); visualRef.current = visual;
const toolpathRef = useRef(toolpath); toolpathRef.current = toolpath;
const viewRef = useRef(view); viewRef.current = view;
const zoomRef = useRef(zoom); zoomRef.current = zoom;
const rotateRef = useRef(rotateMode); rotateRef.current = rotateMode;
const layersRef = useRef({ showPath, showLive, showLimits, showVelocity, showOffsets, showExtents });
layersRef.current = { showPath, showLive, showLimits, showVelocity, showOffsets, showExtents };
const settings = useRef({ toolpath, view, zoom, rotateMode, showPath, showLive, showLimits, showOffsets, showExtents });
settings.current = { toolpath, view, zoom, rotateMode, showPath, showLive, showLimits, showOffsets, showExtents };
useEffect(() => {
const element = host.current;
if (!element) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x081017);
scene.add(new THREE.HemisphereLight(0xe6f2ff, 0x202830, 1.65));
const key = new THREE.DirectionalLight(0xffffff, 2.1); key.position.set(5, 8, 10); scene.add(key);
const camera = new THREE.PerspectiveCamera(38, 1, .1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
element.appendChild(renderer.domElement);
const machine = new THREE.Group(); machine.name = "xyzac-machine"; scene.add(machine);
machine.add(translate("base", sourceBox(240, 260, 150, 0x43616b), 0, 30, -175));
machine.add(translate("column", sourceBox(100, 100, 510, 0x3b7a72), 0, 150, 5));
const head = new THREE.Group(); head.name = "head"; head.position.copy(sourcePosition(0, 0, 200)); machine.add(head);
head.add(translate("head-body", sourceBox(60, 60, 135, 0x277c83), 0, 0, 87.5));
head.add(translate("motor", sourceCylinderZ(30, 65, 0x277c83), 0, 200, 165));
const spindle = new THREE.Group(); spindle.name = "spindle"; spindle.position.copy(sourcePosition(0, 0, 20)); head.add(spindle);
spindle.add(translate("spindle-housing", sourceCylinderZ(20, 135, 0x31888d), 0, 0, 67.5));
spindle.add(translate("spindle-nose", sourceCylinderZ(10, 20, 0x4da0a0), 0, 0, 10));
const tool = new THREE.Group(); tool.name = "tool-offset"; spindle.add(tool);
tool.add(translate("tool-holder", sourceCylinderZ(6, 70, 0x9da7a8), 0, 0, 35));
tool.add(translate("tool", sourceCylinderZ(3, 70, 0xd85f4a), 0, 0, 105));
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x252b2c);
const studio = new RoomEnvironment();
const pmrem = new THREE.PMREMGenerator(renderer);
const environment = pmrem.fromScene(studio, .04);
scene.environment = environment.texture; scene.environmentIntensity = .65;
studio.dispose(); pmrem.dispose();
scene.add(new THREE.HemisphereLight(0xf4f8ff, 0x4b5450, 1.2));
const key = new THREE.DirectionalLight(0xfff4df, 2.7); key.position.set(-4, 9, -6);
key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.bias = -.0002; key.shadow.normalBias = .012;
Object.assign(key.shadow.camera, { left: -6, right: 6, top: 7, bottom: -6, near: .1, far: 30 }); scene.add(key);
const rim = new THREE.DirectionalLight(0xd5f0ff, 1.7); rim.position.set(5, 5, 5); scene.add(rim);
const fill = new THREE.DirectionalLight(0xffffff, .7); fill.position.set(4, 2, -7); scene.add(fill);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(18, 18), new THREE.ShadowMaterial({ opacity: .24 }));
floor.rotation.x = -Math.PI / 2; floor.position.y = -2.64; floor.receiveShadow = true; scene.add(floor);
const assembly = buildXyzacMachine(); scene.add(assembly.machine);
assembly.update(visualRef.current);
const nameplateCanvas = document.createElement("canvas"); nameplateCanvas.width = 512; nameplateCanvas.height = 128;
const label = nameplateCanvas.getContext("2d");
if (label) {
label.fillStyle = "#176b67"; label.fillRect(0, 0, 512, 128);
label.fillStyle = "#f0f4f3"; label.font = "bold 68px sans-serif"; label.fillText("AXIS", 18, 82);
label.font = "24px sans-serif"; label.fillText("XYZAC / TRT", 235, 76);
}
const nameplateTexture = new THREE.CanvasTexture(nameplateCanvas); nameplateTexture.colorSpace = THREE.SRGBColorSpace;
const nameplate = new THREE.Mesh(new THREE.PlaneGeometry(.84, .21), new THREE.MeshBasicMaterial({ map: nameplateTexture }));
nameplate.position.set(-.57, -1.48, -1.046); nameplate.rotation.y = Math.PI; assembly.machine.add(nameplate);
const camera = new THREE.PerspectiveCamera(36, 1, .02, 150);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = .12;
controls.enableRotate = true;
controls.screenSpacePanning = true; controls.minDistance = .25; controls.maxDistance = 65;
// Keep the standard Three.js mouse contract: left-drag orbits, middle
// drag zooms, right-drag pans, and the wheel zooms. The machine canvas
// must remain directly navigable without first toggling a mode button.
controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY;
controls.mouseButtons.RIGHT = THREE.MOUSE.PAN;
controls.touches.ONE = THREE.TOUCH.ROTATE;
controls.touches.TWO = THREE.TOUCH.DOLLY_PAN;
let frame = 0, lastPreset = "", lastGeometry: Segment[] | null = null, lastPathKey = "";
const initialBounds = new THREE.Box3().setFromObject(assembly.machine);
const modelCenter = initialBounds.getCenter(new THREE.Vector3());
const presetCamera = () => {
const { view: selected, zoom: scale } = settings.current;
const direction = selected === "z" ? new THREE.Vector3(0, 1, -.001)
: selected === "z2" ? new THREE.Vector3(.001, 1, 0)
: selected === "x" ? new THREE.Vector3(1, 0, 0)
: selected === "y" ? new THREE.Vector3(0, 0, 1)
: selected === "y2" ? new THREE.Vector3(0, 0, -1) : new THREE.Vector3(.95, .6, -1.5);
// Clear residual damping so a previous drag cannot move the fitted view.
controls.enableDamping = false; controls.update(); controls.enableDamping = true;
direction.normalize();
camera.position.copy(modelCenter).add(direction); camera.lookAt(modelCenter); camera.updateMatrix();
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0);
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1);
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
let distance = 0;
// Fit all eight bounding-box corners in camera space, including depth.
for (const x of [initialBounds.min.x, initialBounds.max.x]) for (const y of [initialBounds.min.y, initialBounds.max.y]) for (const z of [initialBounds.min.z, initialBounds.max.z]) {
const point = new THREE.Vector3(x, y, z).sub(modelCenter);
distance = Math.max(distance, point.dot(direction) + Math.max(Math.abs(point.dot(right)) / (tanV * camera.aspect), Math.abs(point.dot(up)) / tanV));
}
controls.target.copy(modelCenter); camera.position.copy(modelCenter).addScaledVector(direction, distance * 1.12 / Math.max(.5, scale));
camera.lookAt(controls.target); controls.update();
};
const resize = () => {
const width = Math.max(element.clientWidth, 1), height = Math.max(element.clientHeight, 1);
camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false);
lastPreset = "";
};
const observer = new ResizeObserver(resize); observer.observe(element); resize();
const pan = (x: number, y: number) => {
const amount = camera.position.distanceTo(controls.target) * .035;
camera.updateMatrix();
const offset = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0).multiplyScalar(x * amount)
.addScaledVector(new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1), y * amount);
camera.position.add(offset); controls.target.add(offset); controls.update();
};
const zoomCamera = (factor: number) => {
const offset = camera.position.clone().sub(controls.target);
offset.setLength(THREE.MathUtils.clamp(offset.length() * factor, controls.minDistance, controls.maxDistance));
camera.position.copy(controls.target).add(offset); controls.update();
};
element.xyzacMachineController = {
zoomIn: () => zoomCamera(.8), zoomOut: () => zoomCamera(1.25),
panLeft: () => pan(-1, 0), panRight: () => pan(1, 0), panUp: () => pan(0, 1), panDown: () => pan(0, -1),
rotateLeft: () => { controls.rotateLeft(Math.PI / 12); controls.update(); },
rotateRight: () => { controls.rotateLeft(-Math.PI / 12); controls.update(); },
rotateUp: () => { controls.rotateUp(Math.PI / 12); controls.update(); },
rotateDown: () => { controls.rotateUp(-Math.PI / 12); controls.update(); },
reset: () => { lastPreset = ""; },
tool: () => {
const target = assembly.toolTip.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0, .2, 0));
controls.target.copy(target); camera.position.copy(target).add(new THREE.Vector3(.95, .55, -1.5)); controls.update();
},
};
const zslide = new THREE.Group(); zslide.name = "spindle-z"; machine.add(zslide);
const saddle = new THREE.Group(); saddle.name = "saddle-y"; zslide.add(saddle);
saddle.add(translate("saddle-body", sourceBox(150, 106, 32, 0x8b9697), 0, 0, -89));
saddle.add(translate("z-slide", sourceBox(100, 220, 75, 0xb8bdbe), 0, 10, -142.5));
const tableX = new THREE.Group(); tableX.name = "table-x"; saddle.add(tableX);
tableX.add(translate("table-bed", sourceBox(300, 100, 17, 0x686f72), 0, 0, -60.5));
tableX.add(translate("ways", sourceBox(300, 80, 6, 0x50575a), 0, 0, -72));
const tilt = new THREE.Group(); tilt.name = "tilt-a"; tableX.add(tilt);
const yOffset = 20; const zOffset = 10;
tilt.position.copy(sourcePosition(0, yOffset, zOffset));
tilt.rotation.x = 0;
const rotate = new THREE.Group(); rotate.name = "rotate-c"; tilt.add(rotate);
rotate.position.copy(sourcePosition(0, -yOffset, -zOffset));
rotate.add(translate("trunnion", sourceBox(145, 80, 35, 0xb17540), 0, 0, -30));
rotate.add(translate("table", sourceCylinderZ(50, 18, 0xb17540), 0, 0, -9));
rotate.rotation.y = 0;
const yoke = new THREE.Group(); yoke.name = "yoke"; tilt.add(yoke);
yoke.add(translate("yoke-plate", sourceBox(130, 80, 10, 0xb17540), 0, 0, -30));
const brackets = new THREE.Group(); brackets.name = "brackets"; tableX.add(brackets);
brackets.position.copy(sourcePosition(0, yOffset, zOffset));
brackets.add(translate("bracket-left", sourceBox(10, 80, 50, 0x9b6b3b), -72, 0, -25));
brackets.add(translate("bracket-right", sourceBox(10, 80, 50, 0x9b6b3b), 72, 0, -25));
const pathGroup = new THREE.Group(); pathGroup.name = "xyzac-toolpath"; scene.add(pathGroup);
const liveGroup = new THREE.Group(); liveGroup.name = "xyzac-live-toolpath"; scene.add(liveGroup);
const overlayGroup = new THREE.Group(); overlayGroup.name = "xyzac-overlays"; scene.add(overlayGroup);
const resize = () => { const width = Math.max(element.clientWidth, 280); const height = Math.max(element.clientHeight, 260); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); };
const observer = new ResizeObserver(resize); observer.observe(element); resize();
const toView = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * MM, Number(point.z ?? 0) * MM, Number(point.y ?? 0) * MM);
let frame = 0;
const pathMaterials = [0xdce7e8, 0xff9a55, 0x5c9d9a].map((color) => new THREE.LineBasicMaterial({ color }));
const clearLines = (group: THREE.Group) => { group.children.forEach((child) => (child as THREE.Line).geometry.dispose()); group.clear(); };
const limits = new THREE.Box3Helper(new THREE.Box3(), 0xdd7566); scene.add(limits);
const offsets = new THREE.AxesHelper(.8); scene.add(offsets);
const extents = new THREE.Box3Helper(initialBounds, 0xe6c56b); scene.add(extents);
const render = () => {
const state = visualRef.current; const [tableXJoint, saddleYJoint, spindleZJoint, tiltAJoint, rotateCJoint] = state.joints;
tableX.position.x = -tableXJoint * MM; saddle.position.z = -saddleYJoint * MM; zslide.position.y = (200 - spindleZJoint) * MM;
tilt.rotation.x = -tiltAJoint * DEG; rotate.rotation.y = -rotateCJoint * DEG; tool.position.y = -state.offsets.tool_offset * MM;
pathGroup.clear(); liveGroup.clear();
const completed = new THREE.LineBasicMaterial({ color: 0xdce7e8 }); const active = new THREE.LineBasicMaterial({ color: 0xff9a55 }); const rapid = new THREE.LineBasicMaterial({ color: 0x5c9d9a });
for (const raw of (toolpathRef.current && toolpathRef.current.length > 0 ? toolpathRef.current : state.toolpath) as Segment[]) {
const start = raw.start ?? {}; const end = raw.end ?? {};
const points = Array.isArray(raw.samples) && raw.samples.length > 1 ? raw.samples.map(toView) : [toView(start), toView(end)];
const group = raw.active || raw.id === state.activeSegmentId ? liveGroup : pathGroup; const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), raw.active || raw.id === state.activeSegmentId ? active : raw.motion_type === 0 ? rapid : completed); group.add(line);
const state = visualRef.current, config = settings.current;
assembly.update(state);
const paths = (config.toolpath ?? state.toolpath) as Segment[];
const pathKey = `${config.showPath}:${config.showLive}:${state.activeSegmentId}`;
if (paths !== lastGeometry || pathKey !== lastPathKey) {
clearLines(pathGroup); clearLines(liveGroup);
if (config.showPath || config.showLive) for (const raw of paths) {
const live = raw.active || raw.id === state.activeSegmentId;
if (live ? !config.showLive : !config.showPath) continue;
const points = raw.samples?.length ? raw.samples.map(toView) : [toView(raw.start ?? {}), toView(raw.end ?? {})];
(live ? liveGroup : pathGroup).add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), pathMaterials[live ? 1 : raw.motion_type === 0 ? 2 : 0]));
}
const layer = layersRef.current;
pathGroup.visible = layer.showPath; liveGroup.visible = layer.showLive;
overlayGroup.clear();
if (layer.showLimits) { const limits = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(4, 2, 3)), new THREE.LineBasicMaterial({ color: 0xdd7566 })); limits.position.set(0, .8, 0); overlayGroup.add(limits); }
if (layer.showOffsets) { const offset = new THREE.AxesHelper(1.8); offset.name = "xyzac-offset-axes"; offset.position.set(state.offsets.x_rot_point * MM, state.offsets.z_offset * MM, state.offsets.y_offset * MM); overlayGroup.add(offset); }
overlayGroup.visible = layer.showLimits || layer.showOffsets || layer.showExtents || layer.showVelocity;
const distance = 9 / Math.max(.5, zoomRef.current); const selected = viewRef.current;
if (selected === "z") camera.position.set(0, distance, .01); else if (selected === "z2") camera.position.set(distance * .3, distance * .9, distance * .3); else if (selected === "x") camera.position.set(distance, 2, .01); else if (selected === "y") camera.position.set(.01, 2, distance); else if (selected === "y2") camera.position.set(.01, 2, -distance); else camera.position.set(6 / Math.max(.5, zoomRef.current), 5 / Math.max(.5, zoomRef.current), 7 / Math.max(.5, zoomRef.current));
camera.lookAt(0, 0, 0); scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0; renderer.render(scene, camera); frame = requestAnimationFrame(render);
lastGeometry = paths; lastPathKey = pathKey;
}
limits.visible = config.showLimits && Boolean(state.machineLimits?.length === 3);
if (limits.visible) {
const [x, y, z] = state.machineLimits!;
limits.box.setFromPoints([toView({ x: x[0], y: y[0], z: z[0] }), toView({ x: x[1], y: y[1], z: z[1] })]);
}
offsets.visible = config.showOffsets;
offsets.position.set(0, state.offsets.z_offset * .01, state.offsets.y_offset * .01);
extents.visible = config.showExtents;
const preset = `${config.view}:${config.zoom}`;
if (preset !== lastPreset) { presetCamera(); lastPreset = preset; }
controls.update(); renderer.render(scene, camera);
element.dataset.xyzacRenderedTick = String(state.tick);
element.dataset.xyzacToolTip = JSON.stringify(assembly.toolTip.getWorldPosition(new THREE.Vector3()).toArray());
frame = requestAnimationFrame(render);
};
render();
return () => { cancelAnimationFrame(frame); observer.disconnect(); renderer.dispose(); element.replaceChildren(); };
return () => {
cancelAnimationFrame(frame); observer.disconnect(); controls.dispose(); delete element.xyzacMachineController;
clearLines(pathGroup); clearLines(liveGroup); pathMaterials.forEach((material) => material.dispose());
for (const helper of [limits, offsets, extents]) helper.dispose();
assembly.dispose(); nameplateTexture.dispose(); nameplate.geometry.dispose(); nameplate.material.dispose();
floor.geometry.dispose(); floor.material.dispose(); key.shadow.map?.dispose(); environment.dispose();
renderer.dispose(); renderer.domElement.remove();
};
}, []);
const pose = visual.pose;
return <div className="three-viewer xyzac-machine-view" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label="XYZAC TRT Vismach machine"><pre className="axis-preview-hud" aria-label="XYZAC authoritative visual state">{visual.authoritative ? `XYZAC TRT\nX ${Number(pose.x).toFixed(3)} Y ${Number(pose.y).toFixed(3)} Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)} C ${Number(pose.c).toFixed(3)}\nJ ${visual.joints.map((value) => Number(value).toFixed(2)).join(" ")}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "XYZAC TRT\nWASM state pending"}</pre></div>;
const model = visual.machineModel;
const controllerButton = (action: string, label: string, asset?: string, glyph?: string) => <button type="button" data-axis-control={`machine-${action}`} data-xyzac-machine-controller={action} title={label} aria-label={label} onClick={() => host.current?.xyzacMachineController?.[action]?.()}>{asset ? <img src={axisIcon(asset)} alt="" /> : glyph}</button>;
return <div className="three-viewer xyzac-machine-view" ref={host} data-xyzac-preview="machine" data-xyzac-model-source="linuxcnc-wasm" data-xyzac-mouse-controls="left-rotate-middle-zoom-right-pan-wheel-zoom" data-xyzac-model-id={model?.id ?? ""} data-xyzac-model-source-path={model?.source_path ?? ""} data-xyzac-model-source-hash={model?.source_hash ?? ""} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label="XYZAC TRT Vismach machine model">
<div className="xyzac-view-controller xyzac-machine-controller" role="toolbar" aria-label="Machine 3D view controller">
{controllerButton("zoomIn", "Zoom in", "tool_zoomin.gif")}{controllerButton("zoomOut", "Zoom out", "tool_zoomout.gif")}
{controllerButton("panLeft", "Pan left", undefined, "\u2190")}{controllerButton("panRight", "Pan right", undefined, "\u2192")}
{controllerButton("panUp", "Pan up", undefined, "\u2191")}{controllerButton("panDown", "Pan down", undefined, "\u2193")}
{controllerButton("rotateLeft", "Rotate left", undefined, "\u21ba")}{controllerButton("rotateRight", "Rotate right", undefined, "\u21bb")}
{controllerButton("rotateUp", "Rotate upward", undefined, "\u2b06")}{controllerButton("rotateDown", "Rotate downward", undefined, "\u2b07")}
{controllerButton("reset", "Reset machine view", undefined, "\u21ba")}{controllerButton("tool", "Tool close-up", "tool_axis_z.gif")}
</div>
<pre className="axis-preview-hud xyzac-machine-hud" aria-label="XYZAC authoritative visual state">{visual.authoritative ? `XYZAC TRT\n${visual.joints.map((value, index) => `${["X", "Y", "Z", "A", "C"][index]} ${value.toFixed(3)}`).join("\n")}${showVelocity ? `\nV ${Number(visual.velocityMmMin ?? 0).toFixed(1)}` : ""}` : "XYZAC TRT\nWASM state pending"}</pre>
</div>;
}
export function XyzacMachineViewFromSnapshot({ snapshot, toolpath, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
return <XyzacMachineView visual={projectXyzacVisual(snapshot)} toolpath={toolpath} {...props} />;
export function XyzacMachineViewFromSnapshot({ snapshot, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
return <XyzacMachineView visual={projectXyzacVisual(snapshot)} {...props} />;
}

View File

@@ -159,6 +159,7 @@ export type PanelSnapshotV1 = {
active_segment_id?: number | null;
active_progress?: number;
motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>;
machine_model?: XyzacMachineModel;
source: "wasm_canonical_motion";
};
authoritative: Record<string, unknown>;
@@ -169,6 +170,17 @@ export type PanelSnapshotV1 = {
io?: Record<string, unknown>;
};
export type XyzacMachineModel = {
schema: "linuxcnc-vismach-model.v1";
id: string;
source: "linuxcnc-wasm-worker";
source_path: string;
source_hash: string | null;
coordinate_system: "XYZAC";
joints: string[];
components: string[];
};
export type Capabilities = {
context_id?: string;
abi?: Record<string, unknown>;

View File

@@ -171,6 +171,9 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.empty-history { color: var(--muted); }
.axis-preview { display: flex; min-height: 0; flex: 1; padding: 4px; overflow: hidden; background: #000; }
.axis-preview-split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; width: 100%; min-width: 0; min-height: 0; }
.axis-preview-pane { display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.axis-preview-pane > .three-viewer { min-width: 0; min-height: 0; }
.three-viewer { position: relative; width: 100%; height: 100%; min-height: 0; flex: 1; overflow: hidden; border: 1px solid #303030; background: #000; }
.three-viewer canvas { display: block; width: 100%; height: 100%; }
.axis-preview-hud { position: absolute; z-index: 1; top: 11px; left: 34px; margin: 0; color: #f2f2f2; font: 12px/1.45 ui-monospace, monospace; white-space: pre; pointer-events: none; text-shadow: 0 1px #000; }
@@ -178,6 +181,10 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.xyzac-view-controller button { width: 27px; height: 25px; padding: 0; border: 1px solid #93a1a8; background: #e6ebed; color: #182126; font: 700 14px/1 ui-monospace, monospace; cursor: pointer; }
.xyzac-view-controller button:hover, .xyzac-view-controller button:focus-visible { background: #fff; outline: 2px solid #e7b84b; outline-offset: 1px; }
.xyzac-controller-divider { width: 100%; height: 1px; grid-column: 1 / -1; background: #68747c; }
.xyzac-machine-hud { top: 12px; left: 12px; padding: 5px 7px; background: #252b2cde; font-size: 11px; line-height: 1.5; }
.xyzac-machine-controller { top: auto; bottom: 10px; right: 10px; grid-template-columns: repeat(4, 30px); background: #252b2cef; border-color: #6d7b7b; }
.xyzac-machine-controller button { display: grid; place-items: center; width: 30px; height: 29px; font-size: 18px; }
.xyzac-machine-controller img { width: 18px; height: 18px; object-fit: contain; }
.dro-view { background: #ececec; }
.dro-mode-row, .dro-column-headings, .dro-row { display: grid; grid-template-columns: 42px repeat(3, minmax(100px, 1fr)); align-items: center; gap: 9px; }
.dro-mode-row { padding: 5px 6px; border-bottom: 1px solid #888; color: #555; font-size: 11px; }
@@ -250,7 +257,8 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.xyzac-control-band { grid-template-columns: 1fr; }
.axis-top-pane { grid-template-columns: 1fr; height: auto; min-height: 0; flex: 0 0 auto; }
.axis-notebook { min-height: 330px; }
.axis-preview { min-height: 320px; }
.axis-preview { min-height: 732px; }
.axis-preview-split { grid-template-columns: 1fr; grid-template-rows: repeat(2, 360px); align-self: start; }
.axis-program-pane { flex: 0 0 auto; height: 740px; min-height: 740px; grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) 140px; }
.program-main-layout { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); grid-column: 1; grid-row: 1; overflow: hidden; }
.program-editor-wrap { grid-column: 1; grid-row: 1; }

View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const app = await readFile(new URL("./App.tsx", import.meta.url), "utf8");
const tool = await readFile(new URL("./XyzacToolPreview.tsx", import.meta.url), "utf8");
const machine = await readFile(new URL("./XyzacMachineView.tsx", import.meta.url), "utf8");
const css = await readFile(new URL("./styles.css", import.meta.url), "utf8");
assert.match(app, /axis-preview-split/);
assert.match(app, /XyzacToolPreviewFromSnapshot snapshot=\{state\.snapshot\}/);
assert.match(app, /XyzacMachineViewFromSnapshot snapshot=\{state\.snapshot\}/);
assert.match(app, /showPath=\{false\} showLive=\{false\}/);
assert.match(tool, /new THREE\.WebGLRenderer/);
assert.match(tool, /data-xyzac-preview="toolpath"/);
assert.match(machine, /new THREE\.WebGLRenderer/);
assert.match(machine, /data-xyzac-preview="machine"/);
assert.match(machine, /data-xyzac-model-source-hash/);
for (const token of ["OrbitControls", "enableRotate", "mouseButtons.LEFT = THREE.MOUSE.ROTATE", "mouseButtons.MIDDLE = THREE.MOUSE.DOLLY", "mouseButtons.RIGHT = THREE.MOUSE.PAN", "touches.ONE = THREE.TOUCH.ROTATE", "touches.TWO = THREE.TOUCH.DOLLY_PAN", "xyzacMachineController", "zoomIn", "zoomOut", "panLeft", "panRight", "panUp", "panDown", "rotateLeft", "rotateRight", "rotateUp", "rotateDown", "Reset machine view"]) assert.match(machine, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(css, /\.axis-preview-split\s*\{/);
console.log(JSON.stringify({ schema: "xyzac-dual-canvas-contract.v1", status: "verified", shared_snapshot: true, independent_renderers: 2, machine_path_layers: "disabled" }));

View File

@@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { projectXyzacVisual, XYZAC_MACHINE_GROUPS } from "./xyzac-visual-adapter.ts";
import * as THREE from "three";
import { buildXyzacMachine } from "./xyzac-machine-model.ts";
const source = await readFile(new URL("./XyzacMachineView.tsx", import.meta.url), "utf8");
const source = await readFile(new URL("./XyzacMachineView.tsx", import.meta.url), "utf8") + await readFile(new URL("./xyzac-machine-model.ts", import.meta.url), "utf8");
const adapter = await readFile(new URL("./xyzac-visual-adapter.ts", import.meta.url), "utf8");
const snapshot = {
schema: "axis-panel-snapshot.v1",
@@ -25,7 +27,7 @@ const snapshot = {
state_hash: "fnv1a64:kinematics",
wasm_state_hash: "fnv1a64:wasm-kinematics",
},
visual: { active_segment_id: 7, active_progress: 0.5, pose: { x: 12, y: -5, z: 20, a: 30, c: 45 }, joints: [12, -5, 20, 30, 45], toolpath: [{ id: 7, active: true, start: { x: 0, y: 0, z: 0 }, end: { x: 12, y: -5, z: 20 }, motion_type: 1 }] },
visual: { active_segment_id: 7, active_progress: 0.5, pose: { x: 12, y: -5, z: 20, a: 30, c: 45 }, joints: [12, -5, 20, 30, 45], toolpath: [{ id: 7, active: true, start: { x: 0, y: 0, z: 0 }, end: { x: 12, y: -5, z: 20 }, motion_type: 1 }], machine_model: { schema: "linuxcnc-vismach-model.v1", id: "xyzac-trt", source: "linuxcnc-wasm-worker", source_path: "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py", source_hash: "fnv1a64:model", coordinate_system: "XYZAC", joints: ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"], components: ["base", "column"] } },
};
const visual = projectXyzacVisual(snapshot);
assert.equal(visual.schema, "xyzac-visual-state.v1");
@@ -37,8 +39,48 @@ assert.deepEqual(visual.offsets, snapshot.kinematics.offsets);
assert.equal(visual.activeSegmentId, 7);
assert.equal(visual.activeProgress, 0.5);
assert.equal(visual.source, "linuxcnc-wasm-worker");
assert.equal(visual.machineModel?.id, "xyzac-trt");
assert.equal(visual.machineModel?.source_hash, "fnv1a64:model");
assert.deepEqual(XYZAC_MACHINE_GROUPS, ["xyzac-machine", "table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "xyzac-toolpath", "xyzac-live-toolpath"]);
for (const token of ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "tableX.position.x = -tableXJoint * MM", "saddle.position.z = -saddleYJoint * MM", "zslide.position.y = (200 - spindleZJoint) * MM", "tilt.rotation.x = -tiltAJoint * DEG", "rotate.rotation.y = -rotateCJoint * DEG", "tool.position.y = -state.offsets.tool_offset * MM", "data-xyzac-tick", "data-xyzac-state-hash", "data-xyzac-active-segment", "data-xyzac-joints", "data-xyzac-offsets"]) assert.ok(source.includes(token), token);
for (const forbidden of [/accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/, /HAL_SET/, /KINEMATICS_SELECT/]) assert.equal(forbidden.test(source), false, String(forbidden));
assert.ok(adapter.includes("source: \"linuxcnc-wasm-worker\""));
const model = buildXyzacMachine();
const original = structuredClone(visual);
const zero = { joints: [0, 0, 0, 0, 0], offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 0 } };
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-7, `${actual} != ${expected}`);
const position = (node) => node.getWorldPosition(new THREE.Vector3());
model.update(zero);
close(position(model.toolTip).y, 2);
close(position(model.workOrigin).y, 2);
for (const length of [0, 15, 30, -5]) {
model.update({ ...zero, offsets: { ...zero.offsets, tool_offset: length } });
close(position(model.toolTip).y, (200 - length) * .01);
const cutter = model.machine.getObjectByName("four-flute-end-mill");
const cutterBounds = new THREE.Box3().setFromObject(cutter);
close(cutterBounds.min.y, position(model.toolTip).y);
close(cutterBounds.max.x - cutterBounds.min.x, .06);
close(position(model.machine.getObjectByName("collet-nut")).y, 2.095);
}
model.update({ ...zero, joints: [12, -5, 20, 0, 0] });
close(position(model.workOrigin).x, -.12);
close(position(model.workOrigin).y, 1.8);
close(position(model.workOrigin).z, .05);
model.update({ ...zero, joints: [0, 0, 0, 90, 45] });
close(position(model.workOrigin).y, 1.9);
close(position(model.workOrigin).z, .3);
assert.equal(model.joints.rotate.parent.name, "c-rotary-base");
assert.equal(model.machine.getObjectByName("c-drive-housing").parent, model.joints.rotate.parent);
model.update(visual);
assert.deepEqual(visual, original, "rendering must not change the WASM snapshot");
const geometrySet = new Set();
model.machine.traverse((node) => { if (node.isMesh) geometrySet.add(node.geometry); });
for (let tick = 0; tick < 100; tick++) model.update(visual);
const afterGeometrySet = new Set();
model.machine.traverse((node) => { if (node.isMesh) afterGeometrySet.add(node.geometry); });
assert.deepEqual(afterGeometrySet, geometrySet, "motion must reuse model geometry");
const disposed = new Set();
geometrySet.forEach((geometry) => geometry.addEventListener("dispose", () => disposed.add(geometry)));
model.dispose();
assert.equal(disposed.size, geometrySet.size);
console.log(JSON.stringify({ schema: "xyzac-threejs-rtcp-10.v1", status: "verified", tick: visual.tick, state_hash: visual.stateHash, groups: XYZAC_MACHINE_GROUPS, active_segment: visual.activeSegmentId, direction: { table_x: -1, saddle_y: -1, spindle_z: -1, tilt_a: "x+", rotate_c: "z+", tool_offset: "z-" }, clear_visual_nonmutating: true, source: visual.source }));

View File

@@ -0,0 +1,234 @@
import * as THREE from "three";
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
import type { XyzacVisualState } from "./xyzac-visual-adapter";
const MM = .01;
const DEG = Math.PI / 180;
// Source coordinates remain millimetres, Z up. The existing viewer uses Y up.
export function buildXyzacMachine() {
const geometries = new Map<string, THREE.BufferGeometry>();
const materials = {
enamel: new THREE.MeshStandardMaterial({ color: 0xd9e1e1, roughness: .32, metalness: .25 }),
teal: new THREE.MeshStandardMaterial({ color: 0x167f79, roughness: .3, metalness: .3 }),
dark: new THREE.MeshStandardMaterial({ color: 0x303638, roughness: .42, metalness: .35 }),
steel: new THREE.MeshStandardMaterial({ color: 0xb2bdc4, roughness: .27, metalness: .85 }),
polished: new THREE.MeshStandardMaterial({ color: 0xe2e8ec, roughness: .19, metalness: .9 }),
recess: new THREE.MeshStandardMaterial({ color: 0x111718, roughness: .66, metalness: .2 }),
rubber: new THREE.MeshStandardMaterial({ color: 0x222729, roughness: .88 }),
carbide: new THREE.MeshStandardMaterial({ color: 0xb9aa79, roughness: .29, metalness: .8 }),
warning: new THREE.MeshStandardMaterial({ color: 0xe7bd48, roughness: .48, metalness: .12 }),
lamp: new THREE.MeshStandardMaterial({ color: 0xe2f8ff, emissive: 0xd4f2ff, emissiveIntensity: 1.5 }),
};
type Finish = keyof typeof materials;
const geometry = (key: string, create: () => THREE.BufferGeometry) => {
if (!geometries.has(key)) geometries.set(key, create());
return geometries.get(key)!;
};
const group = (parent: THREE.Object3D | null, name: string, x = 0, y = 0, z = 0) => {
const node = new THREE.Group(); node.name = name; node.position.set(x * MM, z * MM, y * MM);
parent?.add(node); return node;
};
const mesh = (parent: THREE.Object3D, name: string, shape: THREE.BufferGeometry, finish: Finish, x: number, y: number, z: number) => {
const node = new THREE.Mesh(shape, materials[finish]); node.name = name;
node.position.set(x * MM, z * MM, y * MM); node.castShadow = true; node.receiveShadow = true;
parent.add(node); return node;
};
const box = (parent: THREE.Object3D, name: string, x: number, y: number, z: number, w: number, d: number, h: number, finish: Finish, bevel = 1) =>
mesh(parent, name, geometry(`box:${w}:${d}:${h}:${bevel}`, () => bevel
? new RoundedBoxGeometry(w * MM, h * MM, d * MM, 2, Math.min(bevel, w / 3, d / 3, h / 3) * MM)
: new THREE.BoxGeometry(w * MM, h * MM, d * MM)), finish, x, y, z);
const cylinder = (parent: THREE.Object3D, name: string, x: number, y: number, z: number, r: number, h: number, finish: Finish, axis: "x" | "y" | "z" = "z", sides = 48) => {
const node = mesh(parent, name, geometry(`cylinder:${r}:${h}:${sides}`, () => new THREE.CylinderGeometry(r * MM, r * MM, h * MM, sides)), finish, x, y, z);
if (axis === "x") node.rotation.z = Math.PI / 2;
if (axis === "y") node.rotation.x = Math.PI / 2;
return node;
};
const bolt = (parent: THREE.Object3D, x: number, y: number, z: number, axis: "x" | "y" | "z" = "z") => {
cylinder(parent, "socket-head-bolt", x, y, z, 2.3, 1.5, "polished", axis, 12);
cylinder(parent, "hex-socket", x + (axis === "x" ? .8 : 0), y - (axis === "y" ? .8 : 0), z + (axis === "z" ? .8 : 0), 1.05, .16, "recess", axis, 6);
};
const lathe = (parent: THREE.Object3D, name: string, profile: number[][], finish: Finish) =>
mesh(parent, name, geometry(name, () => new THREE.LatheGeometry(profile.map(([r, z]) => new THREE.Vector2(r * MM, z * MM)), 64)), finish, 0, 0, 0);
const machine = group(null, "xyzac-machine");
for (const x of [-95, 95]) for (const y of [-72, 135]) {
cylinder(machine, "levelling-pad", x, y, -259, 17, 9, "rubber");
cylinder(machine, "levelling-stud", x, y, -249, 8, 15, "steel");
}
box(machine, "base", 0, 30, -178, 240, 260, 146, "enamel", 9);
box(machine, "base-plinth", 0, 30, -239, 242, 262, 20, "dark", 4);
box(machine, "base-front-panel", 0, -101, -177, 205, 4, 96, "teal", 4);
box(machine, "service-door", 68, -104, -177, 48, 2, 71, "dark", 2);
box(machine, "door-handle", 53, -108, -177, 3, 6, 23, "steel");
for (let i = 0; i < 6; i++) box(machine, "cabinet-vent", -55, -104, -192 + i * 7, 63, 1, 2, "recess", .2);
for (const x of [-91, 91]) for (const z of [-217, -135]) bolt(machine, x, -105, z, "y");
box(machine, "chip-tray", 0, 18, -101, 252, 244, 9, "steel", 3);
for (const x of [-123, 123]) box(machine, "tray-side-lip", x, 18, -93, 5, 244, 16, "dark");
box(machine, "tray-front-lip", 0, -102, -93, 246, 5, 16, "dark");
box(machine, "column", 0, 150, 5, 100, 100, 510, "enamel", 5);
for (const x of [-51, 51]) {
box(machine, "column-side-insert", x, 155, 20, 3, 62, 413, "teal");
box(machine, "column-rib", x, 190, -120, 10, 16, 244, "dark", 2);
}
for (const x of [-33, 33]) {
box(machine, "z-linear-rail", x, 98, 2, 11, 7, 365, "steel");
for (let z = -155; z < 185; z += 45) bolt(machine, x, 93, z, "y");
}
cylinder(machine, "z-leadscrew", 0, 87, -25, 6, 330, "polished");
for (let z = -185; z < 141; z += 7) cylinder(machine, "leadscrew-thread", 0, 87, z, 6.6, 1.5, "steel");
box(machine, "z-servo", 0, -105, -169, 49, 21, 50, "dark", 3);
box(machine, "z-servo-cover", 0, -117, -169, 40, 3, 38, "teal", 2);
const head = group(machine, "head", 0, 0, 200);
// Native head spans Y=-30..240; this bridge physically connects the spindle to the column.
box(head, "head-body", 0, 105, 97.5, 70, 270, 75, "enamel", 5);
box(head, "head-top-cover", 0, 107, 137, 72, 269, 9, "teal", 3);
box(head, "spindle-front-panel", 0, -31, 96, 57, 3, 54, "teal", 2);
for (const x of [-23, 23]) for (const z of [75, 119]) bolt(head, x, -34, z, "y");
for (let i = 0; i < 6; i++) box(head, "head-cooling-vent", 36, 70 + i * 11, 103, 1, 6, 29, "recess", .2);
cylinder(head, "motor", 0, 200, 168, 29, 61, "dark");
cylinder(head, "motor-cap", 0, 200, 200, 30, 7, "teal");
for (let i = 0; i < 16; i++) {
const angle = i * Math.PI / 8;
const fin = box(head, "motor-cooling-fin", 30 * Math.cos(angle), 200 + 30 * Math.sin(angle), 170, 3, 4, 48, "steel", .5);
fin.rotation.y = -angle;
}
box(head, "motor-terminal-box", 32, 200, 168, 16, 32, 24, "dark", 2);
const spindle = group(head, "spindle", 0, 0, 20);
lathe(spindle, "spindle-housing", [[0,20],[20,20],[22,23],[22,38],[20,40],[20,115],[0,115]], "steel");
lathe(spindle, "spindle-nose", [[0,0],[10,0],[15,5],[16,17],[20,20],[0,20]], "polished");
cylinder(spindle, "spindle-seal", 0, 0, 3, 11.5, 2, "dark");
for (const x of [-26, 26]) {
const nozzle = cylinder(head, "coolant-nozzle", x, 0, 45, 2.6, 33, "teal");
nozzle.rotation.z = x < 0 ? .4 : -.4;
const tip = cylinder(head, "coolant-nozzle-tip", x - Math.sign(x) * 18 * Math.sin(.4), 0, 45 - 18 * Math.cos(.4), 2.2, 5, "steel");
tip.rotation.z = nozzle.rotation.z;
}
box(head, "work-light", 0, 50, 59, 40, 9, 3, "lamp");
// Gauge face Z=220, source tooltip Z=200-tool_offset. Keep the cutter tip at that datum.
lathe(head, "collet-holder", [[0,8],[5.5,8],[6.5,9],[6.5,14],[8,16],[8,20],[0,20]], "dark");
cylinder(head, "collet-nut", 0, 0, 9.5, 6.9, 5, "steel", "z", 12);
const tool = group(head, "tool-offset");
const toolTip = group(tool, "tool-tip");
const flutes = mesh(tool, "four-flute-end-mill", createFlutedCutter(), "carbide", 0, 0, 0);
geometries.set("flutes", flutes.geometry);
const shank = cylinder(tool, "ground-tool-shank", 0, 0, 8, 3, 1, "polished");
const zslide = group(machine, "spindle-z");
box(zslide, "knee-casting", 0, 10, -142.5, 100, 220, 75, "enamel", 5);
box(zslide, "knee-front-cover", 0, -102, -143, 85, 5, 56, "teal", 3);
for (const x of [-34, 34]) bolt(zslide, x, -106, -144, "y");
for (const x of [-33, 33]) box(zslide, "y-guideway", x, 8, -103, 14, 181, 6, "steel");
const saddle = group(zslide, "saddle-y");
box(saddle, "saddle-body", 0, 0, -89, 150, 106, 32, "dark", 3);
for (const y of [-32, 32]) box(saddle, "x-guideway", 0, y, -73, 148, 12, 5, "polished");
const tableX = group(saddle, "table-x");
box(tableX, "ways", 0, 0, -72, 300, 80, 6, "dark");
box(tableX, "table-bed", 0, 0, -60.5, 300, 100, 17, "steel", 1.5);
for (const y of [-36, -12, 12, 36]) box(tableX, "table-t-slot", 0, y, -51.94, 297, 3.2, .35, "recess", .1);
box(tableX, "table-front-scale", 0, -50.6, -59, 276, 1, 8, "dark", .1);
for (let x = -130; x <= 130; x += 10) box(tableX, "linear-scale-mark", x, -51.2, -59, .5, .2, x % 50 === 0 ? 6 : 3, "polished", 0);
const brackets = group(tableX, "brackets");
box(brackets, "trunnion-mount", 0, 0, -46, 154, 80, 12, "dark", 2);
for (const x of [-72, 72]) {
box(brackets, "trunnion-upright", x, 0, -23, 12, 68, 47, "enamel", 3);
cylinder(brackets, "bearing-housing", x, 0, 0, 19, 16, "enamel", "x");
cylinder(brackets, "bearing-seal", x < 0 ? -82 : 82, 0, 0, 12, 3, "dark", "x");
for (const y of [-27, 27]) bolt(brackets, x, y, -38);
}
box(brackets, "a-axis-servo", -94, 0, 0, 23, 48, 46, "teal", 4);
cylinder(brackets, "a-axis-end-cap", -107, 0, 0, 16, 3, "dark", "x");
const tilt = group(tableX, "tilt-a");
box(tilt, "yoke-plate", 0, 0, -30, 130, 80, 10, "teal", 2);
for (const x of [-60, 60]) {
box(tilt, "yoke-side-plate", x, 0, -15, 10, 80, 34, "teal", 2);
cylinder(tilt, "a-trunnion-shaft", x, 0, 0, 10, 22, "steel", "x");
}
const rotaryBase = group(tilt, "c-rotary-base");
box(rotaryBase, "c-drive-housing", 0, 0, -24, 104, 100, 12, "dark", 3);
cylinder(rotaryBase, "c-bearing-ring", 0, 0, -17, 49, 5, "teal");
const rotate = group(rotaryBase, "rotate-c");
cylinder(rotate, "table", 0, 0, -9, 50, 18, "steel");
cylinder(rotate, "rotary-face", 0, 0, -.7, 48.7, 1.4, "polished");
cylinder(rotate, "centre-bore", 0, 0, .1, 5, .25, "recess");
for (let i = 0; i < 8; i++) {
const angle = i * Math.PI / 4;
cylinder(rotate, "fixture-hole", 35 * Math.cos(angle), 35 * Math.sin(angle), .1, 2, .3, "recess");
}
for (let i = 0; i < 4; i++) {
const slot = box(rotate, "radial-t-slot", 0, 0, .14, 3, 42, .3, "recess", .1);
slot.position.x = Math.sin(i * Math.PI / 2) * .28;
slot.position.z = Math.cos(i * Math.PI / 2) * .28;
slot.rotation.y = i * Math.PI / 2;
}
for (let i = 0; i < 72; i++) {
const a = i * Math.PI / 36;
const mark = box(rotate, "rotary-scale-mark", 49.7 * Math.sin(a), 49.7 * Math.cos(a), -6, .55, .3, i % 6 === 0 ? 5 : 2.5, "recess", 0);
mark.rotation.y = a;
}
box(rotate, "c-index-marker", 48, 0, 1, 5, 6, 2, "warning", .4);
const workOrigin = group(rotate, "work-origin");
let toolLength = NaN;
const update = (state: Pick<XyzacVisualState, "joints" | "offsets">) => {
const [tableXJoint, saddleYJoint, spindleZJoint, tiltAJoint, rotateCJoint] = state.joints;
tableX.position.x = -tableXJoint * MM;
saddle.position.z = -saddleYJoint * MM;
zslide.position.y = (200 - spindleZJoint) * MM;
tilt.rotation.x = -tiltAJoint * DEG;
rotate.rotation.y = -rotateCJoint * DEG;
tool.position.y = -state.offsets.tool_offset * MM;
tilt.position.set(0, state.offsets.z_offset * MM, state.offsets.y_offset * MM);
brackets.position.copy(tilt.position);
rotaryBase.position.copy(tilt.position).negate();
if (toolLength !== state.offsets.tool_offset) {
toolLength = state.offsets.tool_offset;
const reach = Math.max(.5, 10 + toolLength);
const cuttingLength = Math.min(18, reach * .75);
flutes.scale.y = cuttingLength / 8;
shank.scale.y = Math.max(.2, reach - cuttingLength);
shank.position.y = (cuttingLength + (reach - cuttingLength) / 2) * MM;
}
};
const dispose = () => {
for (const value of geometries.values()) value.dispose();
for (const value of Object.values(materials)) value.dispose();
};
return { machine, update, dispose, toolTip, workOrigin, joints: { tableX, saddle, zslide, tilt, rotate }, tool };
}
// Four helical reliefs with narrow cutting lands, closed end faces, and a flat cutter tip.
function createFlutedCutter() {
const radial = 96, axial = 32;
const positions: number[] = [], indices: number[] = [];
for (let row = 0; row <= axial; row++) {
const t = row / axial;
for (let col = 0; col <= radial; col++) {
const angle = col / radial * Math.PI * 2;
const phase = (angle * 4 - t * Math.PI * 2 + Math.PI * 4) % (Math.PI * 2);
const relief = Math.pow(Math.max(0, Math.sin(phase / 2)), 2);
const radius = (3 - 1.35 * relief) * .01;
positions.push(radius * Math.cos(angle), t * .08, radius * Math.sin(angle));
if (row < axial && col < radial) {
const a = row * (radial + 1) + col, b = a + radial + 1;
indices.push(a, b, a + 1, a + 1, b, b + 1);
}
}
}
const bottom = positions.length / 3; positions.push(0, 0, 0);
const top = positions.length / 3; positions.push(0, .08, 0);
for (let col = 0; col < radial; col++) {
indices.push(bottom, col, col + 1);
const a = axial * (radial + 1) + col;
indices.push(top, a + 1, a);
}
const result = new THREE.BufferGeometry();
result.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
result.setIndex(indices); result.computeVertexNormals(); return result;
}

View File

@@ -1,4 +1,4 @@
import type { XyzacKinematicsSnapshot } from "./protocol";
import type { XyzacKinematicsSnapshot, XyzacMachineModel } from "./protocol";
export type XyzacVisualState = {
schema: "xyzac-visual-state.v1";
@@ -18,6 +18,7 @@ export type XyzacVisualState = {
velocityMmMin?: number;
activeSegmentId: number | null;
toolpath: Array<Record<string, unknown>>;
machineModel: XyzacMachineModel | null;
source: "linuxcnc-wasm-worker";
};
@@ -29,7 +30,7 @@ export type XyzacVisualSource = {
state_hash?: string;
kinematics?: XyzacKinematicsSnapshot;
motion?: { velocity_mm_min?: number };
visual?: { active_segment_id?: number | null; active_progress?: number; pose_epoch?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
visual?: { active_segment_id?: number | null; active_progress?: number; pose_epoch?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>>; machine_model?: XyzacMachineModel };
};
export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefined): XyzacVisualState {
@@ -63,6 +64,7 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
velocityMmMin: (snapshot?.visual as { velocity_mm_min?: number })?.velocity_mm_min ?? snapshot?.motion?.velocity_mm_min,
activeSegmentId: snapshot?.visual?.active_segment_id ?? null,
toolpath: snapshot?.visual?.toolpath ?? [],
machineModel: snapshot?.visual?.machine_model ?? null,
source: "linuxcnc-wasm-worker",
};
}

View File

@@ -39,6 +39,7 @@ function linuxcncAssets(): Plugin {
["linuxcnc/linuxcnc.gif", "linuxcnc/linuxcnc.gif"],
["linuxcnc/share/axis/images", "linuxcnc/share/axis/images"],
["linuxcnc/lib/hallib", "linuxcnc/lib/hallib"],
["linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py", "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"],
["linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images", "linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images"],
];
for (const [from, to] of copies) cpSync(resolve(repoRoot, from), resolve(output, to), { recursive: true, dereference: false });

View File

@@ -168,6 +168,7 @@ export class SimSession {
this.previewState = null;
this.livePathEpoch = 0;
this.workspaceFiles = {};
this.visualModelSourceHash = null;
call(core, "sim_protocol_reset");
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
}
@@ -460,6 +461,23 @@ export class SimSession {
? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === 5 ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0])
: authoritative.kinematics.joints;
const preview = this.previewState?.status === "verified" ? this.previewState : null;
// Keep the Vismach model identity in the same authoritative visual
// envelope as the canonical toolpath and live motion trace. The source
// hash comes from the exact resource resolved during WASM startup, so a
// renderer can audit that its geometry matches the loaded LinuxCNC case.
const modelResource = this.configurationState?.exact_resources?.find?.((row) => row.role === "vismach_source");
const modelSourcePath = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
const modelSourceText = this.workspaceFiles?.[modelSourcePath];
const machineModel = this.xyzacProgram ? {
schema: "linuxcnc-vismach-model.v1",
id: "xyzac-trt",
source: "linuxcnc-wasm-worker",
source_path: modelResource?.path ?? modelSourcePath,
source_hash: modelResource?.data_hash ?? this.visualModelSourceHash ?? (typeof modelSourceText === "string" ? fnv1a64(modelSourceText) : null),
coordinate_system: "XYZAC",
joints: ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"],
components: ["base", "column", "head", "spindle", "tool", "table", "saddle", "zslide"],
} : null;
const visual = {
schema: "axis-visual.v1",
pose: { ...position },
@@ -477,6 +495,7 @@ export class SimSession {
velocity_mm_min: motion.velocity_mm_min ?? 0,
machine_limits: this.wasmMotion.axisLimits?.() ?? null,
coordinate_offset: program?.coordinate_context?.offsets?.[(program?.coordinate_context?.system ?? 1) - 1]?.slice(0, 3) ?? null,
...(machineModel ? { machine_model: machineModel } : {}),
};
const axisCoordinates = [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0];
const coordinateContext = program?.coordinate_context;
@@ -1567,6 +1586,8 @@ export class SimSession {
const configuration = input.payload?.configuration ?? null;
if (configuration) {
this.workspaceFiles = { ...(configuration.files ?? configuration.workspace ?? configuration.workspace_files ?? {}) };
const visualSourcePath = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
this.visualModelSourceHash = typeof this.workspaceFiles[visualSourcePath] === "string" ? fnv1a64(this.workspaceFiles[visualSourcePath]) : null;
const startupWorkspace = configuration.workspace ?? configuration.files ?? configuration.workspace_files;
if (startupWorkspace) {
const startup = new ConfigurationStartupStateMachine({

View File

@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
import { fnv1a64 } from "./replay.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const sourcePath = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
const sourceHash = fnv1a64(bundle.files[sourcePath]);
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "xyzac-visual-data-bridge" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
const loaded = send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.status, "verified");
const panel = loaded.payload.panel_snapshot;
assert.equal(panel.visual.source, "wasm_canonical_motion");
assert.equal(panel.visual.machine_model.schema, "linuxcnc-vismach-model.v1");
assert.equal(panel.visual.machine_model.source, "linuxcnc-wasm-worker");
assert.equal(panel.visual.machine_model.source_path, sourcePath);
assert.equal(panel.visual.machine_model.source_hash, sourceHash);
assert.deepEqual(panel.visual.machine_model.joints, ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"]);
assert.equal(panel.visual.machine_model.coordinate_system, "XYZAC");
assert.ok(panel.visual.toolpath === undefined || Array.isArray(panel.visual.toolpath), "toolpath is optional on compact startup snapshots");
const loadedProgram = send("PROGRAM_LOAD", {
source: "G21\nG90\nG1 X1 Y2 Z3 A4 C5 F100\nM2\n",
path: "bridge.ngc",
});
const visual = loadedProgram.payload.panel_snapshot.visual;
assert.equal(visual.machine_model.source_hash, sourceHash);
assert.ok(Array.isArray(visual.toolpath) && visual.toolpath.length > 0);
assert.equal(visual.source, "wasm_canonical_motion");
assert.match(loadedProgram.payload.panel_snapshot.state_hash, /^fnv1a64:/);
console.log(JSON.stringify({
schema: "xyzac-visual-data-bridge.v1",
status: "verified",
source: sourcePath,
source_hash: sourceHash,
toolpath_segments: visual.toolpath.length,
shared_snapshot_fields: ["visual.machine_model", "visual.toolpath", "visual.motion_trace", "visual.pose", "visual.joints"],
}));

View File

@@ -214,6 +214,18 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
queue.push(candidate);
}
}
// The XYZAC Vismach executable is the authoritative geometry contract for
// the Web renderer. It is not referenced as a normal INI resource, so
// resolve it explicitly and carry it through the Worker configuration for
// source hashing and model provenance.
if (catalog.path === "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini") {
const visualSource = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
if (!(visualSource in files)) {
const content = await fetchText(fetchImpl, baseUrl, visualSource);
if (content === null) missing.push(visualSource);
else { files[visualSource] = content; fetched.push(visualSource); }
}
}
const fixture = RUNTIME_FIXTURES[catalog.path];
if (fixture) {
if (!inManifest(fixture.generated_hal)) missing.push(fixture.generated_hal);

12
xyzbc-trt.desktop Normal file
View File

@@ -0,0 +1,12 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=LinuxCNC AXIS - XYZBC TRT
Comment=Launch the LinuxCNC XYZBC table-rotary-tilting RTCP simulation
Exec=/home/mes006/linuxcncWebWASM/run-5axis-rtcp.sh xyzbc-trt.ini
Path=/home/mes006/linuxcncWebWASM/linuxcnc
Terminal=true
StartupNotify=true
StartupWMClass=axis
Icon=/home/mes006/linuxcncWebWASM/linuxcnc/share/axis/images/axis-24x24.png
Categories=Development;Engineering;