Add AXIS preview DRO and editor parity

This commit is contained in:
2026-08-23 09:47:34 +08:00
parent a7fcec9d33
commit 26c95e1400
11 changed files with 289 additions and 37 deletions

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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。

View File

@@ -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:

View File

@@ -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']}")

View File

@@ -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()

View File

@@ -57,14 +57,16 @@ function MdiControl({ action, ready }: { action: Action; ready: boolean }) {
return <section className="mdi-control" data-axis-feature="AXIS-CTRL-03" role="tabpanel" aria-label="MDI Control"><label className="mdi-label">MDI Command<input value={command} onChange={(event) => setCommand(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") submit(); }} /></label><TextButton feature="AXIS-CTRL-03" disabled={!ready} onClick={submit}>Go</TextButton><h3>MDI History</h3><ol>{history.length ? history.map((line, index) => <li key={`${line}-${index}`}><button type="button" data-axis-feature="AXIS-CTRL-03" onClick={() => setCommand(line)}>{line}</button></li>) : <li className="empty-history">No commands</li>}</ol></section>;
}
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 <section className="dro-view" data-axis-feature="AXIS-VIEW-04" role="tabpanel" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><span>Machine</span><span>Relative</span><span>Distance to Go</span></div><div className="dro-table">{labels.slice(0, Math.max(3, axes.length)).map((label, index) => { const value = Number(axes[index] ?? 0); return <div className="dro-row" key={label}><strong>{label}</strong><output>{value.toFixed(4)}</output><output>{value.toFixed(4)}</output><output>{index < 3 ? (10 - value).toFixed(4) : "0.0000"}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>mm</b></span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
const scale = metric ? 1 : 1 / 25.4;
const format = (value: number) => (value * scale).toFixed(4);
return <section className="dro-view" data-axis-feature="AXIS-VIEW-04" role="tabpanel" aria-label="DRO"><div className="dro-mode-row"><strong>Position</strong><label>Coordinate<select data-axis-feature="AXIS-VIEW-18" value={coordType} onChange={(event) => setCoordType(event.target.value as "machine" | "relative")}><option value="machine">Machine</option><option value="relative">Relative</option></select></label><label>Display<select data-axis-feature="AXIS-VIEW-17" value={displayType} onChange={(event) => setDisplayType(event.target.value as "actual" | "commanded")}><option value="actual">Actual</option><option value="commanded">Commanded</option></select></label><label>Units<select data-axis-feature="AXIS-VIEW-02" value={metric ? "mm" : "inch"} onChange={(event) => setMetric(event.target.value === "mm")}><option value="mm">mm</option><option value="inch">inch</option></select></label></div><div className="dro-table">{labels.slice(0, Math.max(3, axes.length)).map((label, index) => { const value = Number(axes[index] ?? 0); return <div className="dro-row" key={label}><strong>{label}</strong><output>{format(value)}</output><output>{format(value)}</output><output>{index < 3 ? format(10 - value) : "0.0000"}</output></div>; })}</div><div className="dro-footer"><span>Units: <b>{metric ? "mm" : "inch"}</b></span><span>{coordType} / {displayType}</span><span>Tick: {state.tick}</span><span>Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}</span></div></section>;
}
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 <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-editor-wrap"><div className="program-gutter" aria-hidden="true">{lines.map((_, index) => <span key={index}>{index + 1}</span>)}</div><textarea id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} spellCheck={false} aria-label="G-code program" /></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!ready}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!ready}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!ready}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label></aside></section>;
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-editor-wrap"><div className="program-gutter" aria-label="G-code line numbers">{lines.map((_, index) => <span key={index} className={index + 1 === currentLine ? "current-line" : ""} aria-current={index + 1 === currentLine ? "true" : undefined}>{index + 1}</span>)}</div><textarea id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!ready}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!ready}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!ready}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
}
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<ReturnType<typeof setInterval> | 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() {
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={() => document.getElementById("axis-program")?.focus()}>Open...</MenuAction><MenuAction onClick={() => setProgramText(DEFAULT_PROGRAM)}>Reload</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={() => execute("ESTOP", { active: true })}>Toggle Emergency Stop</MenuAction><MenuAction shortcut="F2" onClick={() => execute("IO_RECOVER")}>Toggle Machine Power</MenuAction><MenuAction shortcut="R" onClick={runProgram}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={() => setLeftTab("manual")}>Manual Control</MenuAction><MenuAction onClick={() => setLeftTab("mdi")}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label="Toggle Emergency Stop [F1]" asset="tool_estop.gif" danger onClick={() => execute("ESTOP", { active: true })} pressed={state.status === "blocked"} /><IconButton feature="AXIS-MACHINE-02" label="Toggle Machine power [F2]" asset="tool_power.gif" onClick={() => execute("IO_RECOVER")} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen current file [Ctrl-R]" asset="tool_reload.gif" onClick={() => setProgramText(DEFAULT_PROGRAM)} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!ready} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!ready} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label="Pause / resume execution [P/S]" asset={state.status === "paused" ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={!ready} onClick={() => execute(state.status === "paused" ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!ready} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => setCommandStatus("Live plot cleared")} /></section>
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path.includes("minimal_xyz") || entry.path.includes("corexy")).slice(0, 8).map((entry) => <option key={entry.path} value={entry.path}>{entry.label}</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase}>Load case</TextButton><span className="case-status" role="status">{caseStatus}</span><span className="command-status" role="status">{commandStatus}</span></section>
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual and MDI controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={() => setLeftTab("manual")}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={ready} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={ready} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><ThreeMachineView axes={axes} view={view} zoom={zoom} rotateMode={rotateMode} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} /></div></div></section>
<section className="axis-metrics"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" /></label><label>Feed Override:<output>{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" onChange={(event) => execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" /></label></div></section>
<ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} onLoad={() => execute("PROGRAM_LOAD", { source: programText, path: "main.ngc" })} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} ready={ready} />
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual and MDI controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={() => setLeftTab("manual")}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={ready} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={ready} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><ThreeMachineView axes={axes} segments={trajectory} view={view} zoom={zoom} rotateMode={rotateMode} showGrid={layers.grid} showTool={layers.tool} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section>
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" /></label><label>Feed Override:<output>{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" onChange={(event) => execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" /></label></div><div className="axis-layer-controls" aria-label="Preview layers">{(["grid", "tool", "path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
<ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} onLoad={() => 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} />
<footer className="axis-info-bar" data-axis-feature="AXIS-UI-07"><span className={`task-state ${state.status}`} data-axis-feature="AXIS-SHELL-01">{taskState}</span><span className="tool-state" data-axis-feature="AXIS-UI-07">Tool {toolNumber}</span><span className="position-state" data-axis-feature="AXIS-VIEW-17">{position}</span><span className="footer-status" role="status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>
</main>;
}

View File

@@ -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<string, number>; end?: Record<string, number> };
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<HTMLDivElement>(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);

View File

@@ -25,6 +25,12 @@ export type MotionSnapshot = {
limits?: number;
};
export type TrajectorySnapshot = {
queue?: Array<{ id?: number; source_line?: number | null; start?: Record<string, number>; end?: Record<string, number> }>;
active?: { id?: number; source_line?: number | null; start?: Record<string, number>; end?: Record<string, number> } | 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<string, unknown> };
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 };

View File

@@ -56,7 +56,7 @@ function projection(result: Record<string, unknown>, 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<string, unknown>) ?? previous.task, program: (result.program as Record<string, unknown>) ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck };
}

View File

@@ -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; }