Complete 3axis tutorial native and Web/WASM parity tools
This commit is contained in:
343
scripts/generate_panel_01_source_control_inventory.py
Normal file
343
scripts/generate_panel_01_source_control_inventory.py
Normal 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']}")
|
||||
Reference in New Issue
Block a user