import hashlib import json import os import sys import time from datetime import datetime, timezone from pathlib import Path from typing import Any import cadquery as cq CONVERTER_NAME = "NodeDcBimConverter" CONVERTER_VERSION = "0.2.0" UPLOADS_DIR = Path(os.environ.get("NODEDC_BIM_CONVERTER_UPLOADS_DIR", "/beam/uploads")).resolve() POLL_INTERVAL_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_INTERVAL_SECONDS", "10")) PROCESS_ONCE = os.environ.get("NODEDC_BIM_CONVERTER_ONCE", "").lower() in {"1", "true", "yes"} STEP_EXTENSIONS = {".step", ".stp"} def now_iso() -> str: return datetime.now(timezone.utc).isoformat() def src_from_path(path: Path) -> str: return f"uploads/{path.resolve().relative_to(UPLOADS_DIR).as_posix()}" def manifest_path(source_path: Path) -> Path: return source_path.with_name(f"{source_path.name}.beam.json") def read_json(path: Path) -> dict[str, Any]: try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return {} def write_json_atomic(path: Path, payload: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_name(f"{path.name}.tmp") tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") tmp_path.replace(path) def hash_id(value: str) -> str: return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16] def json_safe(value: Any) -> Any: if value is None or isinstance(value, (str, int, float, bool)): return value if isinstance(value, dict): return {str(k): json_safe(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [json_safe(v) for v in value] return str(value) def node_to_metadata(node: cq.Assembly, parent_path: str = "") -> dict[str, Any]: name = node.name or "root" node_path = f"{parent_path}/{name}" if parent_path else name children = [node_to_metadata(child, node_path) for child in node.children] return { "id": hash_id(node_path), "name": name, "path": node_path, "hasShape": bool(node.obj), "metadata": json_safe(node.metadata or {}), "children": children, } def count_nodes(node: dict[str, Any]) -> int: return 1 + sum(count_nodes(child) for child in node.get("children", [])) def get_shape_type(value: Any) -> str | None: try: shape_type = value.ShapeType() return str(shape_type) if shape_type else None except Exception: return None def collect_solids(workplane: cq.Workplane) -> list[Any]: solids: list[Any] = [] seen: set[int] = set() for value in workplane.vals(): candidates = [] try: candidates = list(value.Solids()) except Exception: candidates = [] if not candidates and get_shape_type(value) == "Solid": candidates = [value] for solid in candidates: key = hash(solid) if key in seen: continue seen.add(key) solids.append(solid) return solids def import_step_assembly(source_path: Path) -> tuple[cq.Assembly, str]: try: return cq.Assembly.importStep(str(source_path)), "assembly" except ValueError as exc: if "does not contain an assembly" not in str(exc): raise step_model = cq.importers.importStep(str(source_path)) solids = collect_solids(step_model) assy = cq.Assembly(name=f"{source_path.stem}_root") if len(solids) > 1: for index, solid in enumerate(solids, start=1): assy.add(solid, name=f"{source_path.stem}_solid_{index:03d}") return assy, "split-solids" assy.add(step_model, name=source_path.stem) return assy, "single-shape" def convert_step_to_glb(source_path: Path, glb_path: Path, metadata_path: Path) -> dict[str, Any]: assy, import_strategy = import_step_assembly(source_path) tree = node_to_metadata(assy) glb_path.parent.mkdir(parents=True, exist_ok=True) assy.export(str(glb_path), tolerance=0.1, angularTolerance=0.1) metadata = { "source": src_from_path(source_path), "artifact": src_from_path(glb_path), "format": "step", "targetFormat": "glb", "importStrategy": import_strategy, "componentTree": tree, "componentCount": count_nodes(tree), "generatedAt": now_iso(), "converter": { "name": CONVERTER_NAME, "version": CONVERTER_VERSION, "engine": "cadquery", "cadqueryVersion": getattr(cq, "__version__", "unknown"), }, } write_json_atomic(metadata_path, metadata) return metadata def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path) -> bool: status = manifest.get("status") if status == "ready" and glb_path.exists(): return manifest.get("converterVersion") != CONVERTER_VERSION if status == "processing": updated_at = manifest.get("updatedAt") if updated_at: try: updated = datetime.fromisoformat(str(updated_at).replace("Z", "+00:00")) if (datetime.now(timezone.utc) - updated).total_seconds() < 300: return False except Exception: pass return source_path.exists() def process_one(source_path: Path) -> bool: manifest_file = manifest_path(source_path) stem = source_path.stem glb_path = source_path.with_name(f"{stem}.glb") metadata_path = source_path.with_name(f"{stem}.metadata.json") manifest = read_json(manifest_file) if not should_process(source_path, manifest, glb_path): return False base_manifest = { "createdAt": manifest.get("createdAt") or now_iso(), "componentTreeRequired": True, "converterName": CONVERTER_NAME, "converterVersion": CONVERTER_VERSION, "sourceFormat": "step", "sourceSrc": src_from_path(source_path), "targetFormat": "glb", } write_json_atomic( manifest_file, { **base_manifest, "message": "Preparing GLB and component tree.", "status": "processing", "updatedAt": now_iso(), }, ) print(f"[{CONVERTER_NAME}] converting {source_path}", flush=True) try: metadata = convert_step_to_glb(source_path, glb_path, metadata_path) write_json_atomic( manifest_file, { **base_manifest, "artifactSrc": src_from_path(glb_path), "artifactType": "gltf", "componentCount": metadata.get("componentCount"), "metadataSrc": src_from_path(metadata_path), "message": "GLB and component tree are ready.", "status": "ready", "updatedAt": now_iso(), }, ) print(f"[{CONVERTER_NAME}] ready {glb_path}", flush=True) return True except Exception as exc: write_json_atomic( manifest_file, { **base_manifest, "error": str(exc), "message": "Failed to prepare GLB and component tree.", "status": "failed", "updatedAt": now_iso(), }, ) print(f"[{CONVERTER_NAME}] failed {source_path}: {exc}", file=sys.stderr, flush=True) return False def scan_once() -> int: if not UPLOADS_DIR.exists(): print(f"[{CONVERTER_NAME}] uploads dir does not exist: {UPLOADS_DIR}", flush=True) return 0 processed = 0 for source_path in sorted(UPLOADS_DIR.rglob("*")): if not source_path.is_file(): continue if source_path.suffix.lower() not in STEP_EXTENSIONS: continue if process_one(source_path): processed += 1 return processed def main() -> None: print(f"[{CONVERTER_NAME}] watching {UPLOADS_DIR}", flush=True) while True: scan_once() if PROCESS_ONCE: return time.sleep(POLL_INTERVAL_SECONDS) if __name__ == "__main__": main()