Files
FreeCADWasmDev/tools/generate_web_module_registry.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

276 lines
14 KiB
Python

#!/usr/bin/env python3
"""Generate the React/Worker module routing table from the parity matrix."""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MATRIX = ROOT / "docs/web-parity-matrix.json"
SRC = ROOT / "web/src"
OUT = SRC / "module-registry.generated.ts"
COMMAND_RE = re.compile(r'export\s+const\s+\w*COMMAND\s*=\s*["\']([^"\']+)["\']')
NATIVE_DESKTOP_MACRO_RE = re.compile(r'FC_WEB_DESKTOP_COMMAND\("([^"]+)"\s*,')
NATIVE_DESKTOP_LIST_RE = re.compile(r'(?:desktopCommands|family\s*==\s*QStringLiteral\("desktop-command"\).*?QStringList\s+\w+)\s*=\s*\{(.*?)\};', re.DOTALL)
QSTRING_RE = re.compile(r'QStringLiteral\("([^"]+)"\)')
PROBE_ROOT = ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe"
PRIMARY_WORKERS = {
"Part": "part.worker.ts",
"PartDesign": "partdesign.worker.ts",
"Sketcher": "sketcher.worker.ts",
"TechDraw": "techdraw-native.worker.ts",
"Import": "import.worker.ts",
}
COMMAND_WORKERS = {
("Part", "part.viewCommand"): "part.worker.ts",
("Part", "part.documentViewCommand"): "part-save.worker.ts",
("Part", "part.document"): "part-save.worker.ts",
("Web", "web.statefulFamilyBatch"): "stateful-family.worker.ts",
("Part", "part.saveReopen"): "part-save.worker.ts",
("TechDraw", "techdraw.nativeSaveReopen"): "techdraw-python.worker.ts",
("TechDraw", "techdraw.desktopCommand"): "techdraw-python.worker.ts",
("Web", "web.dockOverlay"): "web-dock-overlay.worker.ts",
("TechDraw", "techdraw.pythonMethod"): "techdraw-python.worker.ts",
("Spreadsheet", "spreadsheet.saveReopen"): "spreadsheet-save.worker.ts",
("PartDesign", "partdesign.pythonApi"): "partdesign-python.worker.ts",
("PartDesign", "partdesign.pythonFamilyBatch"): "partdesign-python.worker.ts",
("PartDesign", "partdesign.geometryFamily"): "partdesign.worker.ts",
("Points", "points.pythonApi"): "points.worker.ts",
("Plot", "plot.pythonApi"): "plot-python.worker.ts",
("ReverseEngineering", "reverseengineering.pythonApi"): "reverseengineering-python.worker.ts",
("ReverseEngineering", "reverseengineering.pythonFamilyBatch"): "reverseengineering-python.worker.ts",
("Robot", "robot.pythonApi"): "robot-python.worker.ts",
("Sandbox", "sandbox.pythonApi"): "sandbox-python.worker.ts",
("Show", "show.pythonApi"): "show-python.worker.ts",
("Sketcher", "sketcher.pythonApi"): "sketcher-python.worker.ts",
("Sketcher", "sketcher.pythonFamilyBatch"): "sketcher-python.worker.ts",
("Sketcher", "sketcher.pythonMethod"): "sketcher-python.worker.ts",
("Spreadsheet", "spreadsheet.pythonApi"): "spreadsheet-python.worker.ts",
("Spreadsheet", "spreadsheet.pythonFamilyBatch"): "spreadsheet-python.worker.ts",
("Start", "start.pythonApi"): "start-python.worker.ts",
("Surface", "surface.pythonApi"): "surface-python.worker.ts",
("Surface", "surface.pythonFamilyBatch"): "surface-python.worker.ts",
("TechDraw", "techdraw.pythonApi"): "techdraw-python.worker.ts",
("TechDraw", "techdraw.pythonFamilyBatch"): "techdraw-python.worker.ts",
("TemplatePyMod", "templatepymod.pythonApi"): "templatepymod-python.worker.ts",
("PartDesign", "partdesign.inspectSourceInventory"): "partdesign-inspect.worker.ts",
("PartDesign", "partdesign.saveReopen"): "partdesign.worker.ts",
("Sketcher", "sketcher.inspectSourceInventory"): "sketcher-inspect.worker.ts",
("Sketcher", "sketcher.saveReopen"): "sketcher-persistence.worker.ts",
("TechDraw", "techdraw.createRecompute"): "techdraw-python.worker.ts",
("TechDraw", "techdraw.nativeInventory"): "techdraw-inspect.worker.ts",
("Points", "points.inspectSource"): "points.worker.ts",
("Points", "points.saveReopen"): "points-persistence.worker.ts",
("Surface", "surface.createRecompute"): "surface.worker.ts",
("Surface", "surface.inspectSourceInventory"): "surface-inspect.worker.ts",
("Surface", "surface.pythonApi"): "surface-python.worker.ts",
("Surface", "surface.saveReopen"): "surface-save.worker.ts",
("Spreadsheet", "spreadsheet.createRecompute"): "spreadsheet.worker.ts",
("Spreadsheet", "spreadsheet.inspectSourceInventory"): "spreadsheet-inspect.worker.ts",
("Spreadsheet", "spreadsheet.pythonApi"): "spreadsheet-python.worker.ts",
("Spreadsheet", "spreadsheet.saveReopen"): "spreadsheet-save.worker.ts",
("Robot", "robot.createRecompute"): "robot-create.worker.ts",
("Robot", "robot.inspectSourceInventory"): "robot-inspect.worker.ts",
("Robot", "robot.saveReopen"): "robot-save.worker.ts",
("Sandbox", "sandbox.createRecompute"): "sandbox.worker.ts",
("Sandbox", "sandbox.inspectSourceInventory"): "sandbox-inspect.worker.ts",
("Sandbox", "sandbox.pythonApi"): "sandbox-python.worker.ts",
("Sandbox", "sandbox.saveReopen"): "sandbox.worker.ts",
("ReverseEngineering", "reverseengineering.createRecompute"): "reverseengineering-create.worker.ts",
("ReverseEngineering", "reverseengineering.inspectSourceInventory"): "reverseengineering-inspect.worker.ts",
("ReverseEngineering", "reverseengineering.pythonApi"): "reverseengineering-python.worker.ts",
("ReverseEngineering", "reverseengineering.saveReopen"): "reverseengineering-save.worker.ts",
("Start", "start.inspectSourceInventory"): "start-inventory.worker.ts",
("Start", "start.pythonApi"): "start-python.worker.ts",
("Start", "start.moduleImport"): "start.worker.ts",
("Start", "start.importSvg"): "start.worker.ts",
("Show", "show.inspectSourceInventory"): "show-inspect.worker.ts",
("Show", "show.pythonApi"): "show-python.worker.ts",
("Assembly", "assembly.saveReopen"): "assembly.worker.ts",
("Assembly", "assembly.nativeTypeProbe"): "assembly.worker.ts",
("BIM", "bim.createAxis"): "bim.worker.ts",
("BIM", "bim.extrusionData"): "bim.worker.ts",
("CAM", "cam.saveReopen"): "cam.worker.ts",
("CAM", "cam.pythonFamilyBatch"): "cam.worker.ts",
("Cloud", "cloud.saveReopen"): "cloud.worker.ts",
("Cloud", "cloud.pythonFamilyBatch"): "cloud.worker.ts",
("Draft", "draft.pythonFamilyBatch"): "draft.worker.ts",
("Fem", "fem.pythonFamilyBatch"): "fem.worker.ts",
("Import", "import.pythonFamilyBatch"): "import.worker.ts",
("Inspection", "inspection.pythonFamilyBatch"): "inspection.worker.ts",
("Import", "import.saveReopen"): "import.worker.ts",
("Inspection", "inspection.saveReopen"): "inspection.worker.ts",
("JtReader", "jtreader.saveReopen"): "jtreader.worker.ts",
("Material", "material.saveReopen"): "material.worker.ts",
("Material", "material.pythonApi"): "material.worker.ts",
("Material", "material.pythonFamilyBatch"): "material.worker.ts",
("Measure", "measure.pythonFamilyBatch"): "measure.worker.ts",
("Mesh", "mesh.pythonFamilyBatch"): "mesh.worker.ts",
("MeshPart", "meshpart.pythonFamilyBatch"): "meshpart.worker.ts",
("Measure", "measure.saveReopen"): "measure.worker.ts",
("Mesh", "mesh.saveReopen"): "mesh.worker.ts",
("Part", "part.saveReopen"): "part-save.worker.ts",
("Points", "points.saveReopen"): "points-persistence.worker.ts",
("ReverseEngineering", "reverseengineering.saveReopen"): "reverseengineering-save.worker.ts",
("Robot", "robot.saveReopen"): "robot-save.worker.ts",
("Sandbox", "sandbox.saveReopen"): "sandbox.worker.ts",
("Sketcher", "sketcher.saveReopen"): "sketcher-persistence.worker.ts",
("Surface", "surface.saveReopen"): "surface-save.worker.ts",
("TechDraw", "techdraw.nativeSaveReopen"): "techdraw-python.worker.ts",
("Web", "web.saveReopenBoundary"): "web-persistence.worker.ts",
}
def source_stem(module: str) -> str:
return re.sub(r"[^a-z0-9]", "", module.lower())
def module_files(module: str) -> list[str]:
stem = source_stem(module)
return sorted(
path.name
for path in SRC.glob(f"{stem}*.worker.ts")
if path.name.startswith(f"{stem}.") or path.name.startswith(f"{stem}-")
if path.name != "core.worker.ts"
)
def native_desktop_commands(module: str) -> set[str]:
commands: set[str] = set()
paths = list(PROBE_ROOT.glob(f"{module}*Probe.cpp"))
desktop = PROBE_ROOT / f"{module}DesktopCommands.cpp"
if desktop.is_file():
paths.append(desktop)
for path in dict.fromkeys(paths):
text = path.read_text(encoding="utf-8", errors="replace")
commands.update(NATIVE_DESKTOP_MACRO_RE.findall(text))
for block in NATIVE_DESKTOP_LIST_RE.findall(text):
commands.update(QSTRING_RE.findall(block))
return commands
def direct_desktop_worker(module: str) -> str | None:
for worker in module_files(module):
text = (SRC / worker).read_text(encoding="utf-8", errors="replace")
if "DesktopRequest" in text and re.search(r"is[A-Za-z0-9]+DesktopRequest", text):
return worker
return None
def module_commands(module: str) -> list[str]:
stem = source_stem(module)
commands: set[str] = set()
for path in sorted(SRC.glob(f"{stem}*-facade.ts")):
if not (path.name.startswith(f"{stem}-") or path.name == f"{stem}-facade.ts"):
continue
commands.update(COMMAND_RE.findall(path.read_text(encoding="utf-8")))
return sorted(commands)
def ts_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
rows = json.loads(MATRIX.read_text(encoding="utf-8"))["rows"]
entries: list[str] = []
generated_command_workers = dict(COMMAND_WORKERS)
for row in rows:
module = row["module"]
workers = module_files(module)
commands = module_commands(module)
desktop_worker = direct_desktop_worker(module)
if desktop_worker:
desktop_commands = native_desktop_commands(module)
commands = sorted(set(commands) | desktop_commands)
generated_command_workers.update({(module, command): desktop_worker for command in desktop_commands})
if module == "Web":
workers = sorted(set(workers + ["stateful-family.worker.ts"]))
commands = sorted(set(commands + ["web.statefulFamilyBatch"]))
primary_worker = PRIMARY_WORKERS.get(module) or (workers[0] if workers else None)
entries.append(
" { "
f"module: {ts_string(module)}, "
f"workers: {json.dumps(workers, ensure_ascii=False)}, "
f"primaryWorker: {ts_string(primary_worker) if primary_worker else 'null'}, "
f"commands: {json.dumps(commands, ensure_ascii=False)}, "
f"objectTypeCount: {row['object_type_count']}, "
f"pythonApiCount: {row['python_api_count']}, "
f"guiDependency: {str(bool(row['gui_dependency'])).lower()}, "
f"formatExtensions: {json.dumps(row['format_extensions'], ensure_ascii=False)} "
"},"
)
worker_assets = sorted({worker for row in rows for worker in module_files(row["module"])})
worker_identifiers = {worker: f"workerEntry{index}" for index, worker in enumerate(worker_assets)}
worker_imports = "\n".join(f'import {worker_identifiers[worker]} from "./{worker}?worker&url";' for worker in worker_assets)
declarations = """export const WEB_MODULE_REGISTRY_SCHEMA = "freecad-web-module-registry/v1" as const;
export interface WebModuleRoute {
readonly module: string;
readonly workers: readonly string[];
readonly primaryWorker: string | null;
readonly commands: readonly string[];
readonly objectTypeCount: number;
readonly pythonApiCount: number;
readonly guiDependency: boolean;
readonly formatExtensions: readonly string[];
}
export const WEB_MODULE_REGISTRY = [
""" + "\n".join(entries) + """
] as const satisfies readonly WebModuleRoute[];
const ROUTES: ReadonlyMap<string, WebModuleRoute> = new Map<string, WebModuleRoute>(WEB_MODULE_REGISTRY.map((route) => [route.module, route]));
export function getWebModuleRoute(module: string): WebModuleRoute | undefined {
return ROUTES.get(module);
}
export function listWebModuleCommands(module?: string): string[] {
if (module) return [...(ROUTES.get(module)?.commands ?? [])];
return WEB_MODULE_REGISTRY.flatMap((route) => route.commands);
}
"""
content = """/* Generated by tools/generate_web_module_registry.py. */
import { artifactForWorker, createDynamicModuleWorker, type WebModuleArtifact } from "./module-loader.js";
""" + worker_imports + "\n\n" + declarations + """
export function createWebModuleWorker(module: string, workerFile?: string, artifact?: WebModuleArtifact): Worker | undefined {
const route = ROUTES.get(module);
const selectedWorker = workerFile ?? route?.primaryWorker;
if (!route || !selectedWorker) return undefined;
const entryUrl = (() => { switch (`${module}:${selectedWorker}`) {
""" + "\n".join(
f' case {ts_string(row["module"] + ":" + worker)}: return new URL({worker_identifiers[worker]}, import.meta.url);'
for row in rows
for worker in module_files(row["module"])
) + """
default: return undefined;
} })();
return entryUrl ? createDynamicModuleWorker(entryUrl, artifactForWorker(artifact, selectedWorker)) : undefined;
}
export function getWebModuleWorkerForCommand(module: string, command: string): string | undefined {
const route = ROUTES.get(module);
if (!route) return undefined;
switch (`${module}:${command}`) {
""" + "\n".join(
f' case {ts_string(module + ":" + command)}: return {ts_string(worker)};'
for (module, command), worker in sorted(generated_command_workers.items())
) + """
default: return route.primaryWorker ?? undefined;
}
}
"""
changed = not OUT.is_file() or OUT.read_text(encoding="utf-8") != content
if changed:
OUT.write_text(content, encoding="utf-8")
metadata_path = SRC / "module-metadata.generated.ts"
metadata_content = "/* Generated by tools/generate_web_module_registry.py; no Worker factories. */\n" + declarations
if not metadata_path.is_file() or metadata_path.read_text(encoding="utf-8") != metadata_content:
metadata_path.write_text(metadata_content, encoding="utf-8")
print(json.dumps({"output": str(OUT.relative_to(ROOT)), "modules": len(entries), "bytes": OUT.stat().st_size, "changed": changed}, ensure_ascii=False))