From 17887c199a5721fe60a49bd3cb6bbd84faf6746f Mon Sep 17 00:00:00 2001 From: wangdequan Date: Thu, 10 Sep 2026 05:47:14 +0800 Subject: [PATCH] Add dual canvas XYZBC and refined machine visualization --- docs/linuxcnc-web-wasm-dual-canvas-design.md | 234 +++++++++++++++++ scripts/test_xyzac_machine_model_browser.mjs | 102 ++++++++ web/react/src/App.tsx | 3 +- web/react/src/XyzacMachineView.tsx | 255 ++++++++++++------- web/react/src/protocol.ts | 12 + web/react/src/styles.css | 10 +- web/react/src/test_dual_canvas_contract.mjs | 21 ++ web/react/src/test_xyzac_threejs.mjs | 46 +++- web/react/src/xyzac-machine-model.ts | 234 +++++++++++++++++ web/react/src/xyzac-visual-adapter.ts | 6 +- web/react/vite.config.ts | 1 + web/worker/sim-session.mjs | 21 ++ web/worker/test_xyzac_visual_data_bridge.mjs | 58 +++++ web/workspace/sim-case-loader.mjs | 12 + xyzbc-trt.desktop | 12 + 15 files changed, 935 insertions(+), 92 deletions(-) create mode 100644 docs/linuxcnc-web-wasm-dual-canvas-design.md create mode 100644 scripts/test_xyzac_machine_model_browser.mjs create mode 100644 web/react/src/test_dual_canvas_contract.mjs create mode 100644 web/react/src/xyzac-machine-model.ts create mode 100644 web/worker/test_xyzac_visual_data_bridge.mjs create mode 100644 xyzbc-trt.desktop diff --git a/docs/linuxcnc-web-wasm-dual-canvas-design.md b/docs/linuxcnc-web-wasm-dual-canvas-design.md new file mode 100644 index 0000000..6a14345 --- /dev/null +++ b/docs/linuxcnc-web-wasm-dual-canvas-design.md @@ -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 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 .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; + 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 改为显式 ``,封装 `ToolpathRenderer`,实现路径层、实时层、插值队列、gap 恢复和视觉清除。 +5. **Canvas B 重构**:将 `XyzacMachineView` 的 DOM host 改为独立 ``,封装 `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 测试断言两个 `` 节点数量为 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。 diff --git a/scripts/test_xyzac_machine_model_browser.mjs b/scripts/test_xyzac_machine_model_browser.mjs new file mode 100644 index 0000000..b5f0a71 --- /dev/null +++ b/scripts/test_xyzac_machine_model_browser.mjs @@ -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(); } diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index e2a0eca..c7abae9 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { INITIAL_STATE, reduceFrontendState } from "./store"; import { ReactWorkerClient } from "./worker-client"; import { XyzacToolPreviewFromSnapshot } from "./XyzacToolPreview"; +import { XyzacMachineViewFromSnapshot } from "./XyzacMachineView"; import { SIM_CASE_CATALOG } from "../../workspace/sim-case-catalog.mjs"; import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs"; import { axisIcon } from "./sourceAssets"; @@ -755,7 +756,7 @@ export function App() { -
+
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
diff --git a/web/react/src/XyzacMachineView.tsx b/web/react/src/XyzacMachineView.tsx index 9d30a3d..56f10f2 100644 --- a/web/react/src/XyzacMachineView.tsx +++ b/web/react/src/XyzacMachineView.tsx @@ -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; -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 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(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(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])); + } + lastGeometry = paths; lastPathKey = pathKey; } - 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); + 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
{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"}
; + const model = visual.machineModel; + const controllerButton = (action: string, label: string, asset?: string, glyph?: string) => ; + return
+
+ {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")} +
+
{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"}
+
; } -export function XyzacMachineViewFromSnapshot({ snapshot, toolpath, ...props }: Omit & { snapshot: XyzacVisualSource | null | undefined }) { - return ; +export function XyzacMachineViewFromSnapshot({ snapshot, ...props }: Omit & { snapshot: XyzacVisualSource | null | undefined }) { + return ; } diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts index 79db93e..f94c885 100644 --- a/web/react/src/protocol.ts +++ b/web/react/src/protocol.ts @@ -159,6 +159,7 @@ export type PanelSnapshotV1 = { active_segment_id?: number | null; active_progress?: number; motion_trace?: Array<{ tick: number; pose: Record; joints: number[]; active_segment_id: number; active_progress: number }>; + machine_model?: XyzacMachineModel; source: "wasm_canonical_motion"; }; authoritative: Record; @@ -169,6 +170,17 @@ export type PanelSnapshotV1 = { io?: Record; }; +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; diff --git a/web/react/src/styles.css b/web/react/src/styles.css index 77f82e7..1b87fb2 100644 --- a/web/react/src/styles.css +++ b/web/react/src/styles.css @@ -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; } diff --git a/web/react/src/test_dual_canvas_contract.mjs b/web/react/src/test_dual_canvas_contract.mjs new file mode 100644 index 0000000..20e13bd --- /dev/null +++ b/web/react/src/test_dual_canvas_contract.mjs @@ -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" })); diff --git a/web/react/src/test_xyzac_threejs.mjs b/web/react/src/test_xyzac_threejs.mjs index 80a9569..e03070b 100644 --- a/web/react/src/test_xyzac_threejs.mjs +++ b/web/react/src/test_xyzac_threejs.mjs @@ -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 })); diff --git a/web/react/src/xyzac-machine-model.ts b/web/react/src/xyzac-machine-model.ts new file mode 100644 index 0000000..8488164 --- /dev/null +++ b/web/react/src/xyzac-machine-model.ts @@ -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(); + 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) => { + 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; +} diff --git a/web/react/src/xyzac-visual-adapter.ts b/web/react/src/xyzac-visual-adapter.ts index 97ad010..1efc932 100644 --- a/web/react/src/xyzac-visual-adapter.ts +++ b/web/react/src/xyzac-visual-adapter.ts @@ -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>; + 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; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record; joints?: number[]; toolpath?: Array> }; + visual?: { active_segment_id?: number | null; active_progress?: number; pose_epoch?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record; joints?: number[]; toolpath?: Array>; 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", }; } diff --git a/web/react/vite.config.ts b/web/react/vite.config.ts index ca2a796..f207a54 100644 --- a/web/react/vite.config.ts +++ b/web/react/vite.config.ts @@ -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 }); diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs index ae709d9..162b9f1 100644 --- a/web/worker/sim-session.mjs +++ b/web/worker/sim-session.mjs @@ -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({ diff --git a/web/worker/test_xyzac_visual_data_bridge.mjs b/web/worker/test_xyzac_visual_data_bridge.mjs new file mode 100644 index 0000000..7a08d87 --- /dev/null +++ b/web/worker/test_xyzac_visual_data_bridge.mjs @@ -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"], +})); diff --git a/web/workspace/sim-case-loader.mjs b/web/workspace/sim-case-loader.mjs index 91a4759..1a5ac23 100644 --- a/web/workspace/sim-case-loader.mjs +++ b/web/workspace/sim-case-loader.mjs @@ -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); diff --git a/xyzbc-trt.desktop b/xyzbc-trt.desktop new file mode 100644 index 0000000..45f962b --- /dev/null +++ b/xyzbc-trt.desktop @@ -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;