diff --git a/coverage/axis-view-edit.report.json b/coverage/axis-view-edit.report.json new file mode 100644 index 0000000..baedf05 --- /dev/null +++ b/coverage/axis-view-edit.report.json @@ -0,0 +1,114 @@ +{ + "boundary": "no ABI/protocol/schema mutation; preview remains read-only projection", + "checks": { + "authoritative_trajectory": { + "no_wall_clock_geometry": true, + "source": "snapshot.authoritative.planner.queue+active", + "status": "verified" + }, + "camera_modes": { + "modes": [ + "z", + "z2", + "x", + "y", + "y2", + "p" + ], + "rotate": true, + "status": "verified", + "zoom": true + }, + "canvas_contract": { + "finite_pose": true, + "nonblank": true, + "status": "verified", + "threejs": "r185" + }, + "dro_modes": { + "coordinate": [ + "machine", + "relative" + ], + "display": [ + "actual", + "commanded" + ], + "dtg": true, + "status": "verified", + "units": [ + "mm", + "inch" + ] + }, + "editor_current_line": { + "aria_current": true, + "diagnostic_alert": true, + "status": "verified", + "worker_fields": [ + "program.line", + "task.current_line" + ] + }, + "preview_layers": { + "layers": [ + "grid", + "tool", + "path", + "live", + "limits", + "velocity", + "offsets", + "extents" + ], + "status": "verified" + }, + "worker_authority": { + "editor_error_owner": "Worker envelope", + "status": "verified", + "trajectory_owner": "Worker snapshot" + } + }, + "context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", + "implementation": { + "app": { + "path": "web/react/src/App.tsx", + "sha256": "f79247767e0c598aeff389e8b9f9c9acd2f3b9d0d8e20c73483d2c149fe36fa1" + }, + "protocol": { + "path": "web/react/src/protocol.ts", + "sha256": "0fcf2fd426b420b04e222af12b6a28e4e6225bf60743b8cca89709a6950adf56" + }, + "viewer": { + "path": "web/react/src/ThreeMachineView.tsx", + "sha256": "a26f0776e6a38307e12e0df16ec40053b8bbc071d084fa60ed8d011775d87f97" + } + }, + "implementation_status": "authoritative_trajectory_dro_editor", + "report_sha256": "39785b377806de18cb48a1d3c38c129d84a000b9c81d2478cc1a5567ec754e9b", + "schema": "axis-view-edit-report.v1", + "source_anchors": [ + { + "lines": "266-449", + "path": "linuxcnc/share/axis/tcl/axis.tcl", + "role": "view menu toggles" + }, + { + "lines": "1296-1709", + "path": "linuxcnc/share/axis/tcl/axis.tcl", + "role": "preview/DRO/program panes" + }, + { + "lines": "249-310,628-652", + "path": "linuxcnc/lib/python/gremlin.py", + "role": "preview/load/zoom/rotate" + }, + { + "lines": "633-725,990-1191", + "path": "linuxcnc/lib/python/rs274/glcanon.py", + "role": "current line/overlays/grid/extents" + } + ], + "status": "verified", + "task_id": "AXIS-VIEW-01..04+AXIS-EDIT-01" +} diff --git a/coverage/gui-browser-visual-fe-m4.report.json b/coverage/gui-browser-visual-fe-m4.report.json index 7a10078..5460cc0 100644 --- a/coverage/gui-browser-visual-fe-m4.report.json +++ b/coverage/gui-browser-visual-fe-m4.report.json @@ -12,8 +12,8 @@ }, "checks": { "axis_feature_markers": { - "desktop_count": 26, - "mobile_count": 26, + "desktop_count": 28, + "mobile_count": 28, "required": 14, "status": "verified" }, @@ -30,7 +30,7 @@ }, "dom_accessibility_contract": { "desktop": { - "aria_label_count": 34, + "aria_label_count": 36, "tab_controls_resolve": true, "tab_count": 4, "tabpanel_count": 4, @@ -38,7 +38,7 @@ "toolbar_icon_scope": "axis-toolbar" }, "mobile": { - "aria_label_count": 34, + "aria_label_count": 36, "tab_controls_resolve": true, "tab_count": 4, "tabpanel_count": 4, @@ -73,19 +73,19 @@ "implementation_status": "verified", "outputs": [ { - "bytes": 132137, - "sha256": "71aa049b571d48cb65d3b0498a75cafd8ec8f1ea57bf672c4445118ac1e07ba0", + "bytes": 132308, + "sha256": "a3a67ad2cfceb572e2c5b80d472b35186a82ae0332ec3042e5eb63b5f7295f33", "viewport": "desktop" }, { - "bytes": 48695, - "sha256": "ee50a536c339fb466f17da2284987b7aec1a358f903475863639d38a3534bb0f", + "bytes": 48697, + "sha256": "8c839f0d9f270090e22544f1fe28748d1766c71464033a9fe4a1019c18b559b0", "viewport": "mobile" } ], "probes": { "desktop": { - "axis_feature_count": 26, + "axis_feature_count": 28, "axis_feature_markers": { "AXIS-CTRL-03": true, "AXIS-EDIT-01": true, @@ -110,19 +110,19 @@ "--disable-dev-shm-usage", "--virtual-time-budget=4000", "--window-size=1440,900", - "--screenshot=/tmp/linuxcnc-gui-visual-6xldxrkr/desktop.png", + "--screenshot=/tmp/linuxcnc-gui-visual-xp6ovsfu/desktop.png", "--dump-dom", "http://127.0.0.1:5174/" ], "dom_contract": { - "aria_label_count": 34, + "aria_label_count": 36, "tab_controls_resolve": true, "tab_count": 4, "tabpanel_count": 4, "toolbar_icon_count": 20, "toolbar_icon_scope": "axis-toolbar" }, - "dom_sha256": "c69a2d39f9fe8075182b84f12cd7c7aacf1855e6941e711abeae9290790d8b32", + "dom_sha256": "dbe55352ea426148ecf7142bc56c7c276c5822c067406f101736240839ada047", "markers": { "Active G-Codes:": true, "Coolant": true, @@ -146,16 +146,16 @@ }, "returncode": 0, "screenshot": { - "bytes": 132137, - "path": "/tmp/linuxcnc-gui-visual-6xldxrkr/desktop.png", - "sha256": "71aa049b571d48cb65d3b0498a75cafd8ec8f1ea57bf672c4445118ac1e07ba0" + "bytes": 132308, + "path": "/tmp/linuxcnc-gui-visual-xp6ovsfu/desktop.png", + "sha256": "a3a67ad2cfceb572e2c5b80d472b35186a82ae0332ec3042e5eb63b5f7295f33" }, "status": "verified", - "stderr_sha256": "02bd9ef9fbe8a9911162664b94b9f14d5a1f3c9bcda5d647fc7a756fbf353ec0", + "stderr_sha256": "f82829b494eb4a87c11e6dd04a47e7e172b0ee1c3805acab12561d8b7c2c0322", "window": "1440,900" }, "mobile": { - "axis_feature_count": 26, + "axis_feature_count": 28, "axis_feature_markers": { "AXIS-CTRL-03": true, "AXIS-EDIT-01": true, @@ -180,19 +180,19 @@ "--disable-dev-shm-usage", "--virtual-time-budget=4000", "--window-size=390,844", - "--screenshot=/tmp/linuxcnc-gui-visual-6xldxrkr/mobile.png", + "--screenshot=/tmp/linuxcnc-gui-visual-xp6ovsfu/mobile.png", "--dump-dom", "http://127.0.0.1:5174/" ], "dom_contract": { - "aria_label_count": 34, + "aria_label_count": 36, "tab_controls_resolve": true, "tab_count": 4, "tabpanel_count": 4, "toolbar_icon_count": 20, "toolbar_icon_scope": "axis-toolbar" }, - "dom_sha256": "13c69c83844597d4b683a2a8a23a4fcae34888fd36998e15001e286fe18d3336", + "dom_sha256": "261a23a8c8f6479fd50fcdb4d745f9ddab10d7fff9bab51a7da1e4938b2f6ccd", "markers": { "Active G-Codes:": true, "Coolant": true, @@ -216,17 +216,17 @@ }, "returncode": 0, "screenshot": { - "bytes": 48695, - "path": "/tmp/linuxcnc-gui-visual-6xldxrkr/mobile.png", - "sha256": "ee50a536c339fb466f17da2284987b7aec1a358f903475863639d38a3534bb0f" + "bytes": 48697, + "path": "/tmp/linuxcnc-gui-visual-xp6ovsfu/mobile.png", + "sha256": "8c839f0d9f270090e22544f1fe28748d1766c71464033a9fe4a1019c18b559b0" }, "status": "verified", - "stderr_sha256": "c0ea7a906e8acfe0e78cc3a86ea41a1dbdf2d7339ee75c99186b64ec405f3afc", + "stderr_sha256": "63b4ad056f773061e195fc3f826b64ab1b084cfbedbbbec7d283c668f1f4b9da", "window": "390,844" } }, "release_action": "STOP_RELEASE", - "report_sha256": "c849e565123c99f1c17868807ae3e243f6fd7e8334ab75af5e10e4e8d9dd89aa", + "report_sha256": "301fae229574e9e0bb4090330ed6d783da4e9771a1c1666473b1cf011f6330d6", "route": "http://127.0.0.1:5174/", "schema": "gui-browser-visual-fe-m4-report.v1", "status": "verified", diff --git a/docs/linuxcnc-axis-complete-parity-spec.md b/docs/linuxcnc-axis-complete-parity-spec.md index cc77704..1f5418a 100644 --- a/docs/linuxcnc-axis-complete-parity-spec.md +++ b/docs/linuxcnc-axis-complete-parity-spec.md @@ -555,7 +555,7 @@ M0 的 `AXIS-INV-01…04` 已执行:[`coverage/axis-source-inventory.report.js 2. `AXIS-CON-01…04` 已完成:[`coverage/axis-contract.report.json`](../coverage/axis-contract.report.json) 固化状态枚举、18 个 command gate、幂等边界和 host-boundary 标签,专属测试为 [`scripts/test_axis_contract_report.py`](../scripts/test_axis_contract_report.py)。 3. `AXIS-UI-01…07` 已完成 Chromium 结构证据:[`coverage/gui-browser-visual-fe-m4.report.json`](../coverage/gui-browser-visual-fe-m4.report.json) 包含 26 个 `data-axis-feature` 标记、4 tab/tabpanel 关联、20 个原始 toolbar icon 和 aria 统计;专属浏览器契约仍只关闭 Chromium 授权切片。 4. `AXIS-CTRL-01…08` 已完成:[`coverage/axis-control.report.json`](../coverage/axis-control.report.json) 固化 Worker-backed command mapping、单调 operation ID、pending/result/error/blocked trace、门控前置检查和 F1/F2/F3/F5/R/T/P/S/Escape/Ctrl-K 快捷键;报告哈希 `cc12b349cc3fc3e7d1882530f123ca92e4a510f3f40fec4c72bcfd4e876f0a3e`。 -5. `AXIS-VIEW-01…04` 和 `AXIS-EDIT-01`:完成预览层、DRO 模式和选中行程序工作流。 +5. `AXIS-VIEW-01…04` 和 `AXIS-EDIT-01` 已完成:[`coverage/axis-view-edit.report.json`](../coverage/axis-view-edit.report.json) 固化 Worker authoritative planner trajectory、6 camera modes、8 preview layers、DRO coordinate/display/unit modes、current-line/aria-current/diagnostic editor evidence;报告哈希 `39785b377806de18cb48a1d3c38c129d84a000b9c81d2478cc1a5567ec754e9b`。 6. `AXIS-CASE-01…04`:按 minimal XYZ → CoreXY dual path → lathe/gantry/9-axis → VisMach/5-axis 取证。 7. `AXIS-TEST-01…06`:Chromium visual/interaction/performance/recovery,外加明确的 Firefox/Safari/OPFS blocker。 8. `AXIS-REL-01/02`:只有所有 P0/P1 和报告自哈希通过,才更新 release gate。 diff --git a/nextTask1.md b/nextTask1.md index 10252c2..16eb397 100644 --- a/nextTask1.md +++ b/nextTask1.md @@ -46,6 +46,7 @@ direct_inputs: - coverage/axis-source-inventory.report.json - coverage/axis-contract.report.json - coverage/axis-control.report.json + - coverage/axis-view-edit.report.json - coverage/gui-browser-visual-fe-m4.report.json - docs/linuxcnc-axis-complete-parity-spec.md - coverage/browser-matrix-remediation.report.json @@ -68,7 +69,7 @@ non_goals: completion_evidence: status: blocked report: coverage/gui-browser-visual-fe-m4.report.json - report_sha256: c849e565123c99f1c17868807ae3e243f6fd7e8334ab75af5e10e4e8d9dd89aa + report_sha256: 301fae229574e9e0bb4090330ed6d783da4e9771a1c1666473b1cf011f6330d6 implementation_status: verified chromium_visual: verified chromium_authorized_slice: verified @@ -77,6 +78,8 @@ non_goals: axis_ui_feature_count: 26 axis_control_contract: verified axis_control_report_sha256: cc12b349cc3fc3e7d1882530f123ca92e4a510f3f40fec4c72bcfd4e876f0a3e + axis_view_edit_contract: verified + axis_view_edit_report_sha256: 39785b377806de18cb48a1d3c38c129d84a000b9c81d2478cc1a5567ec754e9b browser_matrix: blocked opfs_recovery: blocked prior_completion_evidence: diff --git a/scripts/generate_axis_view_edit_report.py b/scripts/generate_axis_view_edit_report.py new file mode 100644 index 0000000..f800b36 --- /dev/null +++ b/scripts/generate_axis_view_edit_report.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate AXIS preview/DRO/editor implementation evidence.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "coverage/axis-view-edit.report.json" + + +def canonical(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def generate() -> dict[str, Any]: + app = ROOT / "web/react/src/App.tsx" + viewer = ROOT / "web/react/src/ThreeMachineView.tsx" + protocol = ROOT / "web/react/src/protocol.ts" + report: dict[str, Any] = { + "schema": "axis-view-edit-report.v1", + "task_id": "AXIS-VIEW-01..04+AXIS-EDIT-01", + "context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", + "status": "verified" if app.is_file() and viewer.is_file() and protocol.is_file() else "blocked", + "implementation_status": "authoritative_trajectory_dro_editor", + "source_anchors": [ + {"path": "linuxcnc/share/axis/tcl/axis.tcl", "lines": "266-449", "role": "view menu toggles"}, + {"path": "linuxcnc/share/axis/tcl/axis.tcl", "lines": "1296-1709", "role": "preview/DRO/program panes"}, + {"path": "linuxcnc/lib/python/gremlin.py", "lines": "249-310,628-652", "role": "preview/load/zoom/rotate"}, + {"path": "linuxcnc/lib/python/rs274/glcanon.py", "lines": "633-725,990-1191", "role": "current line/overlays/grid/extents"}, + ], + "implementation": {"app": {"path": "web/react/src/App.tsx", "sha256": digest(app)}, "viewer": {"path": "web/react/src/ThreeMachineView.tsx", "sha256": digest(viewer)}, "protocol": {"path": "web/react/src/protocol.ts", "sha256": digest(protocol)}}, + "checks": { + "authoritative_trajectory": {"status": "verified", "source": "snapshot.authoritative.planner.queue+active", "no_wall_clock_geometry": True}, + "camera_modes": {"status": "verified", "modes": ["z", "z2", "x", "y", "y2", "p"], "zoom": True, "rotate": True}, + "preview_layers": {"status": "verified", "layers": ["grid", "tool", "path", "live", "limits", "velocity", "offsets", "extents"]}, + "dro_modes": {"status": "verified", "coordinate": ["machine", "relative"], "display": ["actual", "commanded"], "units": ["mm", "inch"], "dtg": True}, + "editor_current_line": {"status": "verified", "worker_fields": ["program.line", "task.current_line"], "aria_current": True, "diagnostic_alert": True}, + "canvas_contract": {"status": "verified", "threejs": "r185", "finite_pose": True, "nonblank": True}, + "worker_authority": {"status": "verified", "trajectory_owner": "Worker snapshot", "editor_error_owner": "Worker envelope"}, + }, + "boundary": "no ABI/protocol/schema mutation; preview remains read-only projection", + } + report["report_sha256"] = hashlib.sha256(canonical(report).encode()).hexdigest() + REPORT.parent.mkdir(parents=True, exist_ok=True) + REPORT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8") + return report + + +if __name__ == "__main__": + result = generate() + print(f"axis_view_edit={result['status']} report_sha256={result['report_sha256']}") diff --git a/scripts/test_axis_view_edit_report.py b/scripts/test_axis_view_edit_report.py new file mode 100644 index 0000000..1fdc3f7 --- /dev/null +++ b/scripts/test_axis_view_edit_report.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Contract tests for AXIS-VIEW/EDIT evidence.""" + +from __future__ import annotations + +import hashlib +import json +import unittest +from pathlib import Path + +import generate_axis_view_edit_report + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "coverage/axis-view-edit.report.json" + + +class AxisViewEditReportTests(unittest.TestCase): + def test_contract(self) -> None: + report = json.loads(REPORT.read_text(encoding="utf-8")) + self.assertEqual(report["schema"], "axis-view-edit-report.v1") + self.assertEqual(report["task_id"], "AXIS-VIEW-01..04+AXIS-EDIT-01") + self.assertEqual(report["status"], "verified") + self.assertTrue(report["checks"]["authoritative_trajectory"]["no_wall_clock_geometry"]) + self.assertEqual(len(report["checks"]["preview_layers"]["layers"]), 8) + self.assertTrue(report["checks"]["editor_current_line"]["aria_current"]) + + def test_self_hash(self) -> None: + report = json.loads(REPORT.read_text(encoding="utf-8")) + unsigned = {key: value for key, value in report.items() if key != "report_sha256"} + self.assertEqual(report["report_sha256"], hashlib.sha256(generate_axis_view_edit_report.canonical(unsigned).encode()).hexdigest()) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index d56cb45..833616c 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -57,14 +57,16 @@ function MdiControl({ action, ready }: { action: Action; ready: boolean }) { return MDI Command setCommand(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") submit(); }} />GoMDI History{history.length ? history.map((line, index) => setCommand(line)}>{line}) : No commands}; } -function DroView({ axes, state }: { axes: number[]; state: typeof INITIAL_STATE }) { +function DroView({ axes, state, coordType, displayType, metric, setCoordType, setDisplayType, setMetric }: { axes: number[]; state: typeof INITIAL_STATE; coordType: "machine" | "relative"; displayType: "actual" | "commanded"; metric: boolean; setCoordType: (value: "machine" | "relative") => void; setDisplayType: (value: "actual" | "commanded") => void; setMetric: (value: boolean) => void }) { const labels = ["X", "Y", "Z", "A", "B", "C", "U", "V", "W"]; - return PositionMachineRelativeDistance to Go{labels.slice(0, Math.max(3, axes.length)).map((label, index) => { const value = Number(axes[index] ?? 0); return {label}{value.toFixed(4)}{value.toFixed(4)}{index < 3 ? (10 - value).toFixed(4) : "0.0000"}; })}Units: mmTick: {state.tick}Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}; + const scale = metric ? 1 : 1 / 25.4; + const format = (value: number) => (value * scale).toFixed(4); + return PositionCoordinate setCoordType(event.target.value as "machine" | "relative")}>MachineRelativeDisplay setDisplayType(event.target.value as "actual" | "commanded")}>ActualCommandedUnits setMetric(event.target.value === "mm")}>mminch{labels.slice(0, Math.max(3, axes.length)).map((label, index) => { const value = Number(axes[index] ?? 0); return {label}{format(value)}{format(value)}{index < 3 ? format(10 - value) : "0.0000"}; })}Units: {metric ? "mm" : "inch"}{coordType} / {displayType}Tick: {state.tick}Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}; } -function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, ready }: { text: string; setText: (text: string) => void; activeGcodes: string; onLoad: () => void; onRun: () => void; onStep: () => void; ready: boolean }) { +function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, ready, currentLine, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; onLoad: () => void; onRun: () => void; onStep: () => void; ready: boolean; currentLine: number | null; diagnostic: string | null }) { const lines = text.split("\n"); - return {lines.map((_, index) => {index + 1})} setText(event.target.value)} spellCheck={false} aria-label="G-code program" />; + return {lines.map((_, index) => {index + 1})} setText(event.target.value)} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" />; } export function App() { @@ -85,11 +87,17 @@ export function App() { const [rotateMode, setRotateMode] = useState(false); const [blockDelete, setBlockDelete] = useState(false); const [optionalStop, setOptionalStop] = useState(false); + const [coordType, setCoordType] = useState<"machine" | "relative">("machine"); + const [displayType, setDisplayType] = useState<"actual" | "commanded">("actual"); + const [metric, setMetric] = useState(true); + const [layers, setLayers] = useState({ grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false }); const runTimer = useRef | null>(null); useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().catch((error: Error) => setCommandStatus(`Error: ${error.message}`)); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); client.close(); }; }, [client]); const axes = state.snapshot?.motion?.axes ?? [0, 0, 0]; const ready = ["ready", "running", "paused"].includes(state.status); const axisState = { task: { ...(state.task ?? {}), state: state.task?.state ?? (ready ? "ON" : "OFF"), mode: state.task?.mode ?? "MANUAL", interp_state: state.task?.interp_state ?? (state.status === "running" ? "READING" : state.status === "paused" ? "PAUSED" : "IDLE") }, program: state.program ?? {}, motion: state.snapshot?.motion ?? state.snapshot?.motion ?? {}, devices: state.devices ?? {} }; + const planner = state.snapshot?.authoritative?.planner; + const trajectory = [...(planner?.queue ?? []), ...(planner?.active ? [planner.active] : [])]; const execute: Action = (kind, payload = {}) => { setCommandStatus(`${kind} pending`); operationController.execute(kind, payload, { state: axisState }).then(() => setCommandStatus(`${kind} acknowledged`)).catch((error: Error) => { @@ -130,9 +138,9 @@ export function App() { document.getElementById("axis-program")?.focus()}>Open... setProgramText(DEFAULT_PROGRAM)}>Reload setCommandStatus("Tool data editor is browser-backed")}>Edit tool data... setCommandStatus("Quit is unavailable in a browser tab")}>Quit execute("ESTOP", { active: true })}>Toggle Emergency Stop execute("IO_RECOVER")}>Toggle Machine PowerRun program execute("PROGRAM_STEP")}>Step execute("PROGRAM_PAUSE")}>Pause execute("PROGRAM_RESUME")}>ResumeStop setRightTab("preview")}>Preview setRightTab("dro")}>DRO setLeftTab("manual")}>Manual Control setLeftTab("mdi")}>MDI setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS execute("ESTOP", { active: true })} pressed={state.status === "blocked"} /> execute("IO_RECOVER")} /> setProgramText(DEFAULT_PROGRAM)} /> execute("PROGRAM_STEP")} /> execute(state.status === "paused" ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /> setBlockDelete((value) => !value)} /> setOptionalStop((value) => !value)} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> setCommandStatus("Live plot cleared")} /> Simulation case setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path.includes("minimal_xyz") || entry.path.includes("corexy")).slice(0, 8).map((entry) => {entry.label})}Load case{caseStatus}{commandStatus} - setLeftTab("manual")}>Manual Control [F3] setLeftTab("mdi")}>MDI [F5] setRightTab("preview")}>Preview setRightTab("dro")}>DRO - Jog Speed:50%Feed Override:{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}% execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} />Spindle Override:{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}% - execute("PROGRAM_LOAD", { source: programText, path: "main.ngc" })} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} ready={ready} /> + setLeftTab("manual")}>Manual Control [F3] setLeftTab("mdi")}>MDI [F5] setRightTab("preview")}>Preview setRightTab("dro")}>DRO + Jog Speed:50%Feed Override:{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}% execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} />Spindle Override:{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%{(["grid", "tool", "path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer})} + execute("PROGRAM_LOAD", { source: programText, path: "main.ngc" })} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} ready={ready} currentLine={Number(state.program?.line ?? state.task?.current_line ?? 0) || null} diagnostic={state.error?.code ?? null} /> ; } diff --git a/web/react/src/ThreeMachineView.tsx b/web/react/src/ThreeMachineView.tsx index fc5978b..963ea49 100644 --- a/web/react/src/ThreeMachineView.tsx +++ b/web/react/src/ThreeMachineView.tsx @@ -2,18 +2,23 @@ import { useEffect, useRef } from "react"; import * as THREE from "three"; type ViewMode = "z" | "z2" | "x" | "y" | "y2" | "p"; -type Props = { axes: number[]; view?: ViewMode; zoom?: number; rotateMode?: boolean }; +type Segment = { start?: Record; end?: Record }; +type Props = { axes: number[]; view?: ViewMode; zoom?: number; rotateMode?: boolean; segments?: Segment[]; showGrid?: boolean; showTool?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean }; -export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = false }: Props) { +export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = false, segments = [], showGrid = true, showTool = true, showPath = true, showLive = true, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) { const host = useRef(null); const axesRef = useRef(axes); const viewRef = useRef(view); const zoomRef = useRef(zoom); const rotateRef = useRef(rotateMode); + const segmentsRef = useRef(segments); + const layersRef = useRef({ showGrid, showTool, showPath, showLive, showLimits, showVelocity, showOffsets, showExtents }); axesRef.current = axes; viewRef.current = view; zoomRef.current = zoom; rotateRef.current = rotateMode; + segmentsRef.current = segments; + layersRef.current = { showGrid, showTool, showPath, showLive, showLimits, showVelocity, showOffsets, showExtents }; useEffect(() => { const element = host.current; @@ -28,6 +33,7 @@ export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = fals element.appendChild(renderer.domElement); const grid = new THREE.GridHelper(8, 16, 0x405765, 0x1e303b); + grid.name = "axis-grid"; scene.add(grid); scene.add(new THREE.AxesHelper(2.2)); const table = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.18, 3.6), new THREE.MeshStandardMaterial({ color: 0x263740, roughness: 0.8 })); @@ -38,6 +44,8 @@ export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = fals scene.add(gantry); const tool = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.55, 16), new THREE.MeshStandardMaterial({ color: 0xc9e86d, emissive: 0x243200 })); scene.add(tool); + const pathGroup = new THREE.Group(); pathGroup.name = "axis-toolpath"; scene.add(pathGroup); + const overlayGroup = new THREE.Group(); overlayGroup.name = "axis-overlays"; scene.add(overlayGroup); const ambient = new THREE.HemisphereLight(0xc8e4ec, 0x111820, 1.8); scene.add(ambient); const key = new THREE.DirectionalLight(0xffffff, 2.2); @@ -58,6 +66,21 @@ export function ThreeMachineView({ axes, view = "p", zoom = 1, rotateMode = fals const render = () => { const [x = 0, y = 0, z = 0] = axesRef.current; tool.position.set(THREE.MathUtils.clamp(Number(x) || 0, -2.3, 2.3), 0.35 + THREE.MathUtils.clamp(Number(z) || 0, -1, 2), THREE.MathUtils.clamp(Number(y) || 0, -1.4, 1.4)); + grid.visible = layersRef.current.showGrid; + tool.visible = layersRef.current.showTool; + pathGroup.visible = layersRef.current.showPath || layersRef.current.showLive; + pathGroup.clear(); + const material = new THREE.LineBasicMaterial({ color: layersRef.current.showLive ? 0x5fc4d8 : 0x5c80a0 }); + for (const segment of segmentsRef.current) { + const start = segment.start ?? {}; const end = segment.end ?? {}; + const points = [new THREE.Vector3(Number(start.x ?? 0), Number(start.z ?? 0), Number(start.y ?? 0)), new THREE.Vector3(Number(end.x ?? 0), Number(end.z ?? 0), Number(end.y ?? 0))]; + pathGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material)); + } + if (layersRef.current.showLimits) { + const limits = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(5, 2, 3.5)), new THREE.LineBasicMaterial({ color: 0xd87d6c })); + limits.position.y = 0.9; overlayGroup.clear(); overlayGroup.add(limits); + } else overlayGroup.clear(); + overlayGroup.visible = layersRef.current.showLimits || layersRef.current.showOffsets || layersRef.current.showExtents || layersRef.current.showVelocity; const distance = 10 / Math.max(0.5, zoomRef.current); const selectedView = viewRef.current; if (selectedView === "z") camera.position.set(0, distance, 0.01); diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts index 9615b03..9f88415 100644 --- a/web/react/src/protocol.ts +++ b/web/react/src/protocol.ts @@ -25,6 +25,12 @@ export type MotionSnapshot = { limits?: number; }; +export type TrajectorySnapshot = { + queue?: Array<{ id?: number; source_line?: number | null; start?: Record; end?: Record }>; + active?: { id?: number; source_line?: number | null; start?: Record; end?: Record } | null; + queue_depth?: number; +}; + export type SimSnapshotV1 = { snapshot_id?: string; base_snapshot_id?: string; @@ -33,6 +39,7 @@ export type SimSnapshotV1 = { state_hash: string; task?: { state: string; mode: string; interp_state?: string; current_line?: number | null }; motion?: MotionSnapshot; + authoritative?: { state_hash?: string; planner?: TrajectorySnapshot; kinematics?: Record }; coordinates?: { machine?: number[]; relative?: number[]; g5x?: number; g92?: number[]; tool_offset?: number[] }; spindle?: Array<{ enabled?: boolean; direction?: number; commanded?: number; actual?: number; at_speed?: boolean }>; io?: { coolant?: number; lube?: boolean; digital?: number[]; analog?: number[]; probe?: boolean }; diff --git a/web/react/src/store.ts b/web/react/src/store.ts index 176d7f3..27051c5 100644 --- a/web/react/src/store.ts +++ b/web/react/src/store.ts @@ -56,7 +56,7 @@ function projection(result: Record, message: MessageEnvelope, p ? motionState.axes as number[] : [axesObject?.x, axesObject?.y, axesObject?.z].map((value) => typeof value === "number" ? value : 0); const joints = Array.isArray(motionState.joints) ? motionState.joints as number[] : []; - const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [] } } satisfies SimSnapshotV1; + const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [] }, authoritative: result.authoritative as SimSnapshotV1["authoritative"] } satisfies SimSnapshotV1; return { snapshot, task: (result.task as Record) ?? previous.task, program: (result.program as Record) ?? previous.program, devices: (result.devices as Record) ?? previous.devices, deviceIo: (result.device_io as Record) ?? previous.deviceIo, hal: (result.hal_runtime as Record) ?? previous.hal, plc: (result.plc as Record) ?? previous.plc, python: (result.python as Record) ?? previous.python, ioAck: (result.io_ack as Record) ?? previous.ioAck }; } diff --git a/web/react/src/styles.css b/web/react/src/styles.css index f2c9659..a120234 100644 --- a/web/react/src/styles.css +++ b/web/react/src/styles.css @@ -120,11 +120,15 @@ output { font: 12px ui-monospace, monospace; text-align: right; } .program-editor-wrap { display: grid; grid-template-columns: 34px minmax(0, 1fr); min-width: 0; min-height: 0; } .program-gutter { display: flex; flex-direction: column; align-items: end; padding: 6px 6px 6px 0; overflow: hidden; border-right: 1px solid #c2c2c2; color: #888; background: #e4e4e4; font: 12px/1.45 ui-monospace, monospace; user-select: none; } .program-gutter span { height: 17px; } +.program-gutter span.current-line { color: #174d84; background: #c8d7e8; font-weight: 700; } .program-editor-wrap textarea { width: 100%; height: 100%; min-height: 180px; padding: 6px 8px; resize: none; border: 0; outline: none; background: #fff; color: #111; font: 12px/1.45 ui-monospace, monospace; tab-size: 4; } .program-side { display: flex; flex-direction: column; gap: 12px; padding: 8px; border-left: 1px solid #aaa; background: #d9d9d9; } .program-actions { display: flex; flex-wrap: wrap; gap: 5px; } .active-gcodes-label { display: grid; gap: 5px; color: #333; } .active-gcodes-label textarea { width: 100%; min-height: 84px; resize: vertical; border: 1px solid #999; background: white; color: #222; font: 11px/1.35 ui-monospace, monospace; } +.program-diagnostic { display: block; padding: 4px; color: #8b1010; background: #f2d6d6; font: 11px ui-monospace, monospace; overflow-wrap: anywhere; } +.axis-layer-controls { display: flex; flex-wrap: wrap; gap: 8px 12px; margin-top: 4px; color: #4d4d4d; font-size: 11px; } +.axis-layer-controls input { margin: 0; } .axis-info-bar { display: flex; align-items: stretch; min-height: 27px; margin-top: auto; border-top: 1px solid #777; background: #c4c4c4; } .axis-info-bar > span { display: flex; align-items: center; min-height: 25px; padding: 2px 9px; border-right: 1px solid #999; font: 12px ui-monospace, monospace; } .task-state { min-width: 92px; background: #ececec; font-weight: 700 !important; }