beam: add local converter and STEP viewer flow
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
NODEDC_BIM_CONVERTER_UPLOADS_DIR=/beam/uploads \
|
||||
NODEDC_BIM_CONVERTER_INTERVAL_SECONDS=10
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libglu1-mesa \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . ./
|
||||
|
||||
CMD ["python", "worker.py"]
|
||||
@@ -0,0 +1 @@
|
||||
cadquery==2.7.0
|
||||
@@ -0,0 +1,260 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user