NODEDC_BIM_VIEWER/converter/worker.py

261 lines
8.0 KiB
Python

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