Complete 3axis tutorial native and Web/WASM parity tools

This commit is contained in:
2026-08-24 14:01:30 +08:00
parent 938654b19d
commit 7e90142b63
147 changed files with 17639 additions and 557 deletions

View File

@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Generate the selected-case AXIS source/control inventory for PANEL-01.
This task is intentionally read-only with respect to runtime contracts. The
report records source controls and the current React mapping; it does not
invent missing controls or promote the panel to parity.
"""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
REPORT = ROOT / "coverage/panel-01-source-control-inventory.report.json"
CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1"
CASE_PATH = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini"
AXIS_TCL = "linuxcnc/share/axis/tcl/axis.tcl"
AXIS_PY = "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py"
APP_TSX = "web/react/src/App.tsx"
def canonical(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def sha256(path: Path) -> str | None:
if not path.is_file():
return None
return hashlib.sha256(path.read_bytes()).hexdigest()
def source_record(path: str, role: str) -> dict[str, Any]:
absolute = ROOT / path
return {
"path": path,
"role": role,
"bytes": absolute.stat().st_size if absolute.is_file() else None,
"sha256": sha256(absolute),
"status": "indexed" if absolute.is_file() else "missing",
}
def slug(value: str) -> str:
value = value.replace("\\[", "").replace("\\]", "")
value = re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").upper()
return value or "UNNAMED"
def tcl_label(value: str) -> str:
match = re.search(r'"((?:\\.|[^"\\])*)"', value)
if not match:
return value.strip()
return match.group(1).replace(r"\[", "[").replace(r"\]", "]").replace(r'\"', '"').replace(r"\\", "\\")
def line_count(path: str) -> int:
return len((ROOT / path).read_text(encoding="utf-8", errors="replace").splitlines())
def source_ref(path: str, line: int, role: str, end_line: int | None = None) -> dict[str, Any]:
return {
"path": path,
"lines": f"{line}-{end_line or line}",
"line": line,
"role": role,
}
def parse_menus(lines: list[str]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
seen: dict[str, int] = {}
pattern = re.compile(r"setup_menu_accel\s+(\S+)\s+end\s+\[_\s+(.*)\]")
for number, raw in enumerate(lines, 1):
match = pattern.search(raw)
if not match:
continue
menu_path = match.group(1)
label = tcl_label(match.group(2)).replace("_", "").strip()
base = f"AXIS-MENU-{slug(menu_path.removeprefix('.menu.'))}-{slug(label)}"
seen[base] = seen.get(base, 0) + 1
control_id = base if seen[base] == 1 else f"{base}-{seen[base]}"
rows.append({
"control_id": control_id,
"category": "menu",
"menu_path": menu_path,
"label": label,
"source": source_ref(AXIS_TCL, number, "AXIS Tcl menu accelerator"),
})
return rows
def parse_toolbar(lines: list[str]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
starts = [i for i, raw in enumerate(lines) if re.match(r"(?:Button|button) \.toolbar\.", raw)]
for index, start in enumerate(starts):
end = starts[index + 1] if index + 1 < len(starts) else min(len(lines), start + 20)
block = lines[start:end]
match = re.match(r"(?:Button|button) (\.toolbar\.\S+)", lines[start])
if not match:
continue
widget = match.group(1)
command_match = re.search(r"-command\s+(?:\{\s*)?([^\n}]+)", "\n".join(block))
help_match = re.search(r"-helptext\s+\[_\s+(.*)\]", "\n".join(block))
image_match = re.search(r"-image\s+\[load_image\s+([^\]\s]+)", "\n".join(block))
rows.append({
"control_id": f"AXIS-TOOLBAR-{slug(widget.removeprefix('.toolbar.'))}",
"category": "toolbar",
"widget": widget,
"command": command_match.group(1).strip() if command_match else None,
"helptext": tcl_label(help_match.group(1)) if help_match else None,
"image": image_match.group(1) if image_match else None,
"source": source_ref(AXIS_TCL, start + 1, "AXIS Tcl toolbar", end),
})
return rows
def parse_shortcuts(axis_lines: list[str]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
seen: dict[str, int] = {}
pattern = re.compile(r"root_window\.bind(?:_class)?\((.*)")
for number, raw in enumerate(axis_lines, 1):
match = pattern.search(raw)
if not match:
continue
args = match.group(1)
quoted = re.findall(r'"([^"\\]*(?:\\.[^"\\]*)*)"', args)
if len(quoted) < 1:
continue
is_bind_class = "bind_class" in raw
if is_bind_class:
key = quoted[1] if len(quoted) > 1 else quoted[0]
command_index = 2
else:
key = quoted[0]
command_index = 1
command = quoted[command_index] if len(quoted) > command_index else None
if command is None:
tail = args.split(",")[-1].strip().rstrip(")")
command = tail or None
base = f"AXIS-SHORTCUT-{slug(key)}-{slug(command or 'UNSPECIFIED')}"
seen[base] = seen.get(base, 0) + 1
control_id = base if seen[base] == 1 else f"{base}-{seen[base]}"
rows.append({
"control_id": control_id,
"category": "shortcut",
"key": key,
"command": command or raw.split(",", 1)[-1].strip().rstrip(")"),
"source": source_ref(AXIS_PY, number, "AXIS Python keyboard binding"),
})
return rows
def parse_ini(path: str) -> dict[str, Any]:
values: dict[str, dict[str, str]] = {}
section = ""
for number, raw in enumerate((ROOT / path).read_text(encoding="utf-8").splitlines(), 1):
clean = raw.split("#", 1)[0].split(";", 1)[0].strip()
if clean.startswith("[") and clean.endswith("]"):
section = clean[1:-1].strip().upper()
values.setdefault(section, {})
continue
if section and "=" in clean:
key, value = (item.strip() for item in clean.split("=", 1))
values.setdefault(section, {})[key.upper()] = value
selected = {
section: {key: values.get(section, {}).get(key) for key in keys if key in values.get(section, {})}
for section, keys in {
"EMC": ("MACHINE", "VERSION"),
"DISPLAY": ("DISPLAY", "POSITION_OFFSET", "POSITION_FEEDBACK", "OPEN_FILE", "INTRO_GRAPHIC", "INCREMENTS"),
"HAL": ("HALFILE", "POSTGUI_HALFILE", "HALUI"),
"TRAJ": ("AXES", "COORDINATES", "LINEAR_UNITS", "POSITION_FILE"),
"KINS": ("KINEMATICS", "JOINTS"),
"EMCIO": ("TOOL_TABLE",),
}.items()
}
return {"path": path, "sha256": sha256(ROOT / path), "line_count": line_count(path), "values": selected}
def parse_react() -> dict[str, Any]:
path = ROOT / APP_TSX
text = path.read_text(encoding="utf-8", errors="replace") if path.is_file() else ""
occurrences: dict[str, list[int]] = {}
for number, line in enumerate(text.splitlines(), 1):
for match in re.finditer(r'(?:feature|data-axis-feature)="([A-Z0-9_-]+)"', line):
occurrences.setdefault(match.group(1), []).append(number)
dom_ids = sorted(set(re.findall(r'id="(axis-[a-z0-9-]+)"', text)))
labels = re.findall(r'(?:label=|>)(?:"|\{)([^"<>}{]+)', text)
return {
"path": APP_TSX,
"sha256": sha256(path),
"feature_ids": sorted(occurrences),
"feature_occurrences": {key: values for key, values in sorted(occurrences.items())},
"dom_ids": dom_ids,
"label_sample": labels[:40],
}
def manual_execution_evidence() -> dict[str, Any]:
"""Record evidence from the repository's required manual AXIS tool.
The screenshots are generated artifacts from the isolated launcher. No
native API result is inferred when the artifacts are absent.
"""
screenshots = []
for path, role in (
("coverage/panel-01-manual-axis-running.png", "manual AXIS running DRO/motion"),
("coverage/panel-01-manual-axis-complete.png", "manual AXIS M2/idle completion"),
):
absolute = ROOT / path
screenshots.append({
"path": path,
"role": role,
"bytes": absolute.stat().st_size if absolute.is_file() else None,
"sha256": sha256(absolute),
"status": "present" if absolute.is_file() else "missing",
})
status = "verified" if all(item["status"] == "present" for item in screenshots) else "blocked"
return {
"tool_path": "tool/linuxcnc-axis-manual-execution",
"launcher": "scripts/launch_axis_3dtest_manual.sh",
"status": status,
"simulation_only": True,
"window": {"class": "axis", "instance": "Axis", "title": "3dtest.ngc - AXIS 2.10.0~pre1 on 3axis-tutorial", "size": [1300, 1297]},
"state_transitions": ["ESTOP", "ESTOP_RESET/OFF", "ON", "all three joints homed", "AUTO/RUNNING", "AUTO/IDLE at M2"],
"samples": {
"initial": {"dro": [0.0, 0.0, 0.0], "interp_state": "IDLE"},
"boundary": {"code": "NOT_HOMED", "message": "Can't run a program when not homed", "status": "rejected_before_motion"},
"running": {"dro": [25.320, 50.720, 0.0], "interp_state": "RUNNING", "current_line": 6},
"completion": {"dro": [0.0, 0.0, 0.0], "interp_state": "IDLE", "highlight_line": 53, "program_end_line": 54, "program_end": "M2"},
},
"evidence": screenshots,
"boundary": "manual native GUI evidence supports source control comparison only; it is not a WASM or release-candidate oracle",
}
def panel_controls() -> list[dict[str, Any]]:
return [
{"control_id": "AXIS-PANEL-MANUAL", "category": "panel", "panel": "MANUAL", "source": [source_ref(AXIS_TCL, 820, "Manual notebook tab"), source_ref(AXIS_PY, 2574, "ensure_manual")]},
{"control_id": "AXIS-PANEL-MDI", "category": "panel", "panel": "MDI", "source": [source_ref(AXIS_TCL, 821, "MDI notebook tab"), source_ref(AXIS_PY, 2587, "ensure_mdi")]},
{"control_id": "AXIS-PANEL-AUTO", "category": "panel", "panel": "AUTO", "source": [source_ref(AXIS_TCL, 120, "Auto program menu"), source_ref(AXIS_PY, 2343, "task_run")], "note": "AXIS uses the shared program pane; it has no separate Auto notebook tab."},
{"control_id": "AXIS-PANEL-DRO", "category": "panel", "panel": "DRO", "source": [source_ref(AXIS_TCL, 1296, "DRO notebook"), source_ref(AXIS_TCL, 1331, "position label"), source_ref(AXIS_PY, 1967, "state projection") ]},
{"control_id": "AXIS-PANEL-DIAGNOSTICS", "category": "panel", "panel": "DIAGNOSTICS", "source": [source_ref(AXIS_TCL, 1356, "program text editor"), source_ref(AXIS_TCL, 1387, "active G-code display"), source_ref(AXIS_PY, 1978, "interpreter state") ]},
]
def control_contract(menus: list[dict[str, Any]], toolbar: list[dict[str, Any]], shortcuts: list[dict[str, Any]], panels: list[dict[str, Any]], react: dict[str, Any]) -> list[dict[str, Any]]:
features = set(react["feature_ids"])
toolbar_map = {
"machine_estop": "AXIS-MACHINE-01", "machine_power": "AXIS-MACHINE-02", "file_open": "AXIS-FILE-01",
"reload": "AXIS-FILE-04", "program_run": "AXIS-MACHINE-03", "program_step": "AXIS-MACHINE-05",
"program_pause": "AXIS-MACHINE-06", "program_stop": "AXIS-MACHINE-07", "program_blockdelete": "AXIS-MACHINE-09",
"program_optpause": "AXIS-MACHINE-08", "view_zoomin": "AXIS-VIEW-01", "view_zoomout": "AXIS-VIEW-01",
"view_z": "AXIS-VIEW-01", "view_z2": "AXIS-VIEW-01", "view_x": "AXIS-VIEW-01", "view_y": "AXIS-VIEW-01",
"view_y2": "AXIS-VIEW-01", "view_p": "AXIS-VIEW-01", "rotate": "AXIS-VIEW-02", "clear_plot": "AXIS-VIEW-15",
}
rows: list[dict[str, Any]] = []
for row in menus:
rows.append({**row, "react_feature_ids": ["AXIS-UI-02"] if "AXIS-UI-02" in features else [], "mapping_status": "container_only", "mapping_reason": "MenuAction has no per-action stable data-axis-feature."})
for row in toolbar:
name = row["widget"].removeprefix(".toolbar.")
mapped = toolbar_map.get(name)
rows.append({**row, "react_feature_ids": [mapped] if mapped in features else [], "mapping_status": "mapped" if mapped in features else "missing", "mapping_reason": None if mapped in features else "No React toolbar control with this source widget identity."})
command_features = {"task_stop": "AXIS-MACHINE-07", "task_run": "AXIS-MACHINE-03", "task_step": "AXIS-MACHINE-05", "task_pause": "AXIS-MACHINE-06", "task_resume": "AXIS-MACHINE-06", "open_file": "AXIS-FILE-01", "reload_file": "AXIS-FILE-04", "estop_clicked": "AXIS-MACHINE-01", "onoff_clicked": "AXIS-MACHINE-02"}
for row in shortcuts:
command = str(row["command"])
mapped = next((feature for key, feature in command_features.items() if key in command), None)
rows.append({**row, "react_feature_ids": [mapped] if mapped in features else [], "mapping_status": "mapped" if mapped in features else "missing", "mapping_reason": None if mapped in features else "Shortcut is source-defined but no matching React keyboard contract is declared."})
panel_map = {"MANUAL": ["AXIS-MANUAL-01"], "MDI": ["AXIS-CTRL-03"], "AUTO": ["AXIS-CTRL-02", "AXIS-UI-07"], "DRO": ["AXIS-VIEW-04"], "DIAGNOSTICS": ["AXIS-EDIT-01", "AXIS-UI-07"]}
for row in panels:
mapped = panel_map[row["panel"]]
rows.append({**row, "react_feature_ids": [value for value in mapped if value in features], "mapping_status": "mapped" if all(value in features for value in mapped) else "missing", "mapping_reason": None if row["panel"] != "AUTO" else "AUTO has no dedicated React tab; program controls are only an inferred shared pane."})
return rows
def generate() -> dict[str, Any]:
tcl_path = ROOT / AXIS_TCL
py_path = ROOT / AXIS_PY
tcl_lines = tcl_path.read_text(encoding="utf-8", errors="replace").splitlines() if tcl_path.is_file() else []
py_lines = py_path.read_text(encoding="utf-8", errors="replace").splitlines() if py_path.is_file() else []
menus = parse_menus(tcl_lines)
toolbar = parse_toolbar(tcl_lines)
shortcuts = parse_shortcuts(py_lines)
panels = panel_controls()
react = parse_react()
manual = manual_execution_evidence()
contract = control_contract(menus, toolbar, shortcuts, panels, react)
source = [
source_record(AXIS_TCL, "AXIS Tcl UI"),
source_record(AXIS_PY, "AXIS Python command and shortcut implementation"),
source_record(CASE_PATH, "selected 3axis-tutorial INI"),
source_record(APP_TSX, "React projection/control IDs"),
]
gaps = [row for row in contract if row["mapping_status"] != "mapped"]
duplicate_feature_ids = sorted(feature for feature, lines in react["feature_occurrences"].items() if len(lines) > 1 and feature == "AXIS-MACHINE-03")
checks = {
"source_inputs": {"status": "verified" if all(row["status"] == "indexed" for row in source) else "blocked", "count": len(source)},
"menu_inventory": {"status": "verified" if len(menus) >= 40 else "blocked", "count": len(menus), "minimum": 40},
"toolbar_inventory": {"status": "verified" if len(toolbar) >= 15 else "blocked", "count": len(toolbar), "minimum": 15},
"shortcut_inventory": {"status": "verified" if len(shortcuts) >= 20 else "blocked", "count": len(shortcuts), "minimum": 20},
"panel_inventory": {"status": "verified" if {row["panel"] for row in panels} == {"MANUAL", "MDI", "AUTO", "DRO", "DIAGNOSTICS"} else "blocked", "panels": [row["panel"] for row in panels]},
"control_ids_unique": {"status": "verified" if len({row["control_id"] for row in contract}) == len(contract) else "blocked", "count": len(contract)},
"react_feature_inventory": {"status": "verified" if react["feature_ids"] else "blocked", "count": len(react["feature_ids"])},
"web_mapping": {"status": "verified" if not gaps else "blocked", "mapped": len(contract) - len(gaps), "gaps": len(gaps)},
}
failure_samples = [
{"id": "PANEL-01-MENU-ACTION-ID", "status": "blocked", "source": AXIS_TCL, "reason": "AXIS menu inventory has per-action stable IDs, but React currently exposes only AXIS-UI-02 on the menu container."},
{"id": "PANEL-01-AUTO-TAB", "status": "blocked", "source": AXIS_TCL, "reason": "AXIS AUTO execution is source-defined, while React has no dedicated Auto tab/control identity."},
{"id": "PANEL-01-SHORTCUT-CLOSURE", "status": "blocked", "source": AXIS_PY, "reason": "Source keyboard bindings include view, spindle, coolant, jog and touch-off shortcuts that are not all declared in the React keyboard contract."},
]
if duplicate_feature_ids:
failure_samples.append({"id": "PANEL-01-REACT-ID-COLLISION", "status": "blocked", "source": APP_TSX, "reason": "AXIS-MACHINE-03 is reused for reset and program-run controls; stable IDs are not one-to-one.", "feature_ids": duplicate_feature_ids})
report: dict[str, Any] = {
"schema": "panel-01-source-control-inventory.v1",
"task_id": "PANEL-01-SOURCE-CONTROL-INVENTORY",
"context_id": CONTEXT_ID,
"case": {"path": CASE_PATH, "status": "indexed" if (ROOT / CASE_PATH).is_file() else "missing", "ini": parse_ini(CASE_PATH) if (ROOT / CASE_PATH).is_file() else None},
"status": "verified" if all(item["status"] == "verified" for key, item in checks.items() if key != "web_mapping") and checks["web_mapping"]["status"] == "verified" else "blocked",
"inventory_status": "verified" if all(item["status"] == "verified" for key, item in checks.items() if key != "web_mapping") else "blocked",
"implementation_status": "source_inventory_complete_web_control_mapping_blocked" if gaps else "source_and_web_control_inventory_complete",
"source": source,
"controls": {"menus": menus, "toolbar": toolbar, "shortcuts": shortcuts, "panels": panels, "control_id_contract": contract},
"react": react,
"manual_execution": manual,
"counts": {"menus": len(menus), "toolbar": len(toolbar), "shortcuts": len(shortcuts), "panels": len(panels), "contract": len(contract), "mapping_gaps": len(gaps), "react_feature_ids": len(react["feature_ids"])},
"checks": {**checks, "manual_axis_execution": {"status": manual["status"], "tool": manual["tool_path"], "evidence_count": len(manual["evidence"])}},
"failure_samples": failure_samples,
"boundaries": ["inventory-only; no runtime authority or protocol mutation", "selected 3axis-tutorial only", "missing web mappings remain blocked", "desktop AXIS controls are Web-equivalent workflows, not copied host windows"],
"stop_reason": "SOURCE_CONTROL_MAPPING_BLOCKED: source inventory is complete, but per-action React IDs, Auto panel identity and shortcut closure are not complete; do not promote PANEL-01 or release.",
}
report["report_sha256"] = hashlib.sha256(canonical(report).encode("utf-8")).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"panel_01={result['status']} inventory={result['inventory_status']} controls={result['counts']['contract']} gaps={result['counts']['mapping_gaps']} report_sha256={result['report_sha256']}")