beam: produce XKT preview artifacts

This commit is contained in:
CODEX 2026-06-04 21:21:58 +03:00
parent 3aace73a1f
commit 09b1173697
3 changed files with 181 additions and 25 deletions

View File

@ -9,6 +9,8 @@ RUN apt-get update \
libgl1 \ libgl1 \
libglib2.0-0 \ libglib2.0-0 \
libglu1-mesa \ libglu1-mesa \
nodejs \
npm \
libsm6 \ libsm6 \
libxext6 \ libxext6 \
libxrender1 \ libxrender1 \
@ -18,6 +20,8 @@ WORKDIR /app
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
RUN npm install -g @xeokit/xeokit-convert@1.3.2 \
&& npm cache clean --force
COPY . ./ COPY . ./

View File

@ -2,6 +2,7 @@ import hashlib
import json import json
import os import os
import struct import struct
import subprocess
import sys import sys
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
@ -21,13 +22,16 @@ from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
CONVERTER_NAME = "NodeDcBimConverter" CONVERTER_NAME = "NodeDcBimConverter"
CONVERTER_VERSION = "0.3.0" CONVERTER_VERSION = "0.4.0"
UPLOADS_DIR = Path(os.environ.get("NODEDC_BIM_CONVERTER_UPLOADS_DIR", "/beam/uploads")).resolve() 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")) 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"} PROCESS_ONCE = os.environ.get("NODEDC_BIM_CONVERTER_ONCE", "").lower() in {"1", "true", "yes"}
REPROCESS_READY_ON_VERSION_CHANGE = os.environ.get( REPROCESS_READY_ON_VERSION_CHANGE = os.environ.get(
"NODEDC_BIM_CONVERTER_REPROCESS_READY_ON_VERSION_CHANGE", "" "NODEDC_BIM_CONVERTER_REPROCESS_READY_ON_VERSION_CHANGE", ""
).lower() in {"1", "true", "yes"} ).lower() in {"1", "true", "yes"}
XKT_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_XKT_ENABLED", "1").lower() not in {"0", "false", "no"}
XKT_COMMAND = os.environ.get("NODEDC_BIM_CONVERTER_XKT_COMMAND", "xeokit-convert")
NODE_OPTIONS = os.environ.get("NODE_OPTIONS", "--max-old-space-size=8192")
STEP_MESH_LINEAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_LINEAR_DEFLECTION", "0.1")) STEP_MESH_LINEAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_LINEAR_DEFLECTION", "0.1"))
STEP_MESH_ANGULAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_ANGULAR_DEFLECTION", "0.1")) STEP_MESH_ANGULAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_ANGULAR_DEFLECTION", "0.1"))
STEP_EXTENSIONS = {".step", ".stp"} STEP_EXTENSIONS = {".step", ".stp"}
@ -257,6 +261,20 @@ def normalize_glb_node_names(glb_path: Path) -> tuple[dict[str, Any], int]:
node["name"] = mesh_name node["name"] = mesh_name
renamed += 1 renamed += 1
seen_names: dict[str, int] = {}
for index, node in enumerate(nodes):
base_name = str(node.get("name") or f"node_{index}")
occurrence = seen_names.get(base_name, 0) + 1
seen_names[base_name] = occurrence
if occurrence > 1:
extras = node.get("extras")
if not isinstance(extras, dict):
extras = {}
extras.setdefault("originalName", base_name)
node["extras"] = extras
node["name"] = f"{base_name}__{occurrence:03d}"
renamed += 1
if renamed: if renamed:
write_glb_json(glb_path, gltf_json, chunks) write_glb_json(glb_path, gltf_json, chunks)
return gltf_json, renamed return gltf_json, renamed
@ -389,20 +407,98 @@ def convert_step_cadquery_to_glb(source_path: Path, glb_path: Path) -> tuple[dic
return tree, stats return tree, stats
def convert_step_to_glb(source_path: Path, glb_path: Path, metadata_path: Path) -> dict[str, Any]: def read_log_tail(path: Path, max_lines: int = 30) -> str:
import_strategy = "occt-xcaf-gltf"
try: try:
tree, stats = convert_step_xcaf_to_glb(source_path, glb_path) lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except Exception as exc: except Exception:
print(f"[{CONVERTER_NAME}] OCCT/XCAF path failed for {source_path}: {exc}", file=sys.stderr, flush=True) return ""
tree, stats = convert_step_cadquery_to_glb(source_path, glb_path) return "\n".join(lines[-max_lines:])
import_strategy = stats.get("importStrategy") or "cadquery"
def convert_glb_to_xkt(glb_path: Path, xkt_path: Path) -> dict[str, Any]:
if not XKT_ENABLED:
return {"enabled": False}
tmp_xkt_path = xkt_path.with_name(f"{xkt_path.name}.tmp")
log_path = xkt_path.with_name(f"{xkt_path.name}.convert.log")
if tmp_xkt_path.exists():
tmp_xkt_path.unlink()
env = {**os.environ, "NODE_OPTIONS": NODE_OPTIONS}
command = [
XKT_COMMAND,
"-s",
str(glb_path),
"-f",
"glb",
"-o",
str(tmp_xkt_path),
"-t",
"-n",
"-e",
"0",
"-b",
]
print(f"[{CONVERTER_NAME}] XKT export {glb_path.name}", flush=True)
with log_path.open("w", encoding="utf-8", errors="replace") as log_file:
result = subprocess.run(
command,
cwd=str(glb_path.parent),
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
if result.returncode != 0:
tail = read_log_tail(log_path)
raise RuntimeError(f"XKT export failed with code {result.returncode}: {tail}")
if not tmp_xkt_path.exists() or tmp_xkt_path.stat().st_size == 0:
raise RuntimeError("XKT export produced no output.")
tmp_xkt_path.replace(xkt_path)
try:
log_path.unlink()
except Exception:
pass
return {
"enabled": True,
"command": XKT_COMMAND,
"nodeOptions": NODE_OPTIONS,
"edgeBuffers": False,
"textures": False,
"normals": False,
"compressedBuffers": True,
"sizeBytes": xkt_path.stat().st_size,
}
def build_step_metadata(
source_path: Path,
glb_path: Path,
xkt_path: Path,
metadata_path: Path,
*,
tree: dict[str, Any],
stats: dict[str, Any],
import_strategy: str,
) -> dict[str, Any]:
xkt_stats = convert_glb_to_xkt(glb_path, xkt_path)
has_xkt = bool(xkt_stats.get("enabled") and xkt_path.exists())
preferred_artifact_path = xkt_path if has_xkt else glb_path
preferred_artifact_type = "xkt" if has_xkt else "gltf"
metadata = { metadata = {
"source": src_from_path(source_path), "source": src_from_path(source_path),
"artifact": src_from_path(glb_path), "artifact": src_from_path(preferred_artifact_path),
"artifactType": preferred_artifact_type,
"glbArtifact": src_from_path(glb_path),
"xktArtifact": src_from_path(xkt_path) if has_xkt else None,
"format": "step", "format": "step",
"targetFormat": "glb", "targetFormat": "xkt" if has_xkt else "glb",
"importStrategy": import_strategy, "importStrategy": import_strategy,
"componentTree": tree, "componentTree": tree,
"componentCount": count_nodes(tree), "componentCount": count_nodes(tree),
@ -414,14 +510,65 @@ def convert_step_to_glb(source_path: Path, glb_path: Path, metadata_path: Path)
"cadqueryVersion": stats.get("cadqueryVersion"), "cadqueryVersion": stats.get("cadqueryVersion"),
}, },
"stats": stats, "stats": stats,
"xkt": xkt_stats,
} }
write_json_atomic(metadata_path, metadata) write_json_atomic(metadata_path, metadata)
return metadata return metadata
def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path) -> bool: def convert_step_assets(source_path: Path, glb_path: Path, xkt_path: Path, metadata_path: Path) -> dict[str, Any]:
previous_metadata = read_json(metadata_path)
if XKT_ENABLED and glb_path.exists() and previous_metadata.get("componentTree"):
print(f"[{CONVERTER_NAME}] upgrading existing GLB to XKT {glb_path.name}", flush=True)
try:
gltf_json, renamed_nodes = normalize_glb_node_names(glb_path)
except Exception as exc:
print(f"[{CONVERTER_NAME}] existing GLB normalize failed for {glb_path}: {exc}", file=sys.stderr, flush=True)
gltf_json = {}
renamed_nodes = 0
previous_stats = previous_metadata.get("stats") if isinstance(previous_metadata.get("stats"), dict) else {}
stats = {
**previous_stats,
"gltfNodeCount": len(gltf_json.get("nodes", [])) or previous_stats.get("gltfNodeCount"),
"gltfMeshCount": len(gltf_json.get("meshes", [])) or previous_stats.get("gltfMeshCount"),
"renamedNodeCount": (previous_stats.get("renamedNodeCount") or 0) + renamed_nodes,
"reusedExistingGlb": True,
}
return build_step_metadata(
source_path,
glb_path,
xkt_path,
metadata_path,
tree=previous_metadata["componentTree"],
stats=stats,
import_strategy=previous_metadata.get("importStrategy") or "existing-glb",
)
import_strategy = "occt-xcaf-gltf"
try:
tree, stats = convert_step_xcaf_to_glb(source_path, glb_path)
except Exception as exc:
print(f"[{CONVERTER_NAME}] OCCT/XCAF path failed for {source_path}: {exc}", file=sys.stderr, flush=True)
tree, stats = convert_step_cadquery_to_glb(source_path, glb_path)
import_strategy = stats.get("importStrategy") or "cadquery"
return build_step_metadata(
source_path,
glb_path,
xkt_path,
metadata_path,
tree=tree,
stats=stats,
import_strategy=import_strategy,
)
def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path, xkt_path: Path) -> bool:
status = manifest.get("status") status = manifest.get("status")
if status == "ready" and glb_path.exists(): ready_artifact_path = xkt_path if XKT_ENABLED else glb_path
if status == "ready" and ready_artifact_path.exists():
return REPROCESS_READY_ON_VERSION_CHANGE and manifest.get("converterVersion") != CONVERTER_VERSION return REPROCESS_READY_ON_VERSION_CHANGE and manifest.get("converterVersion") != CONVERTER_VERSION
if status == "processing": if status == "processing":
updated_at = manifest.get("updatedAt") updated_at = manifest.get("updatedAt")
@ -439,10 +586,11 @@ def process_one(source_path: Path) -> bool:
manifest_file = manifest_path(source_path) manifest_file = manifest_path(source_path)
stem = source_path.stem stem = source_path.stem
glb_path = source_path.with_name(f"{stem}.glb") glb_path = source_path.with_name(f"{stem}.glb")
xkt_path = source_path.with_name(f"{stem}.xkt")
metadata_path = source_path.with_name(f"{stem}.metadata.json") metadata_path = source_path.with_name(f"{stem}.metadata.json")
manifest = read_json(manifest_file) manifest = read_json(manifest_file)
if not should_process(source_path, manifest, glb_path): if not should_process(source_path, manifest, glb_path, xkt_path):
return False return False
base_manifest = { base_manifest = {
@ -452,14 +600,14 @@ def process_one(source_path: Path) -> bool:
"converterVersion": CONVERTER_VERSION, "converterVersion": CONVERTER_VERSION,
"sourceFormat": "step", "sourceFormat": "step",
"sourceSrc": src_from_path(source_path), "sourceSrc": src_from_path(source_path),
"targetFormat": "glb", "targetFormat": "xkt" if XKT_ENABLED else "glb",
} }
write_json_atomic( write_json_atomic(
manifest_file, manifest_file,
{ {
**base_manifest, **base_manifest,
"message": "Preparing GLB and component tree.", "message": "Preparing XKT preview and component tree.",
"status": "processing", "status": "processing",
"updatedAt": now_iso(), "updatedAt": now_iso(),
}, },
@ -467,21 +615,25 @@ def process_one(source_path: Path) -> bool:
print(f"[{CONVERTER_NAME}] converting {source_path}", flush=True) print(f"[{CONVERTER_NAME}] converting {source_path}", flush=True)
try: try:
metadata = convert_step_to_glb(source_path, glb_path, metadata_path) metadata = convert_step_assets(source_path, glb_path, xkt_path, metadata_path)
artifact_src = metadata.get("artifact") or src_from_path(xkt_path if xkt_path.exists() else glb_path)
artifact_type = metadata.get("artifactType") or ("xkt" if xkt_path.exists() else "gltf")
write_json_atomic( write_json_atomic(
manifest_file, manifest_file,
{ {
**base_manifest, **base_manifest,
"artifactSrc": src_from_path(glb_path), "artifactSrc": artifact_src,
"artifactType": "gltf", "artifactType": artifact_type,
"fallbackArtifactSrc": src_from_path(glb_path),
"fallbackArtifactType": "gltf",
"componentCount": metadata.get("componentCount"), "componentCount": metadata.get("componentCount"),
"metadataSrc": src_from_path(metadata_path), "metadataSrc": src_from_path(metadata_path),
"message": "GLB and component tree are ready.", "message": "XKT preview and component tree are ready.",
"status": "ready", "status": "ready",
"updatedAt": now_iso(), "updatedAt": now_iso(),
}, },
) )
print(f"[{CONVERTER_NAME}] ready {glb_path}", flush=True) print(f"[{CONVERTER_NAME}] ready {artifact_src}", flush=True)
return True return True
except Exception as exc: except Exception as exc:
write_json_atomic( write_json_atomic(
@ -489,7 +641,7 @@ def process_one(source_path: Path) -> bool:
{ {
**base_manifest, **base_manifest,
"error": str(exc), "error": str(exc),
"message": "Failed to prepare GLB and component tree.", "message": "Failed to prepare XKT preview and component tree.",
"status": "failed", "status": "failed",
"updatedAt": now_iso(), "updatedAt": now_iso(),
}, },

View File

@ -293,11 +293,11 @@ const handleRawUpload = async (req, res, url) => {
if (sourceFormat) { if (sourceFormat) {
const conversion = { const conversion = {
componentTreeRequired: true, componentTreeRequired: true,
message: "Original STEP uploaded. GLB/component tree is waiting for NodeDcBimConverter.", message: "Original STEP uploaded. XKT preview/component tree is waiting for NodeDcBimConverter.",
sourceFormat, sourceFormat,
sourceSrc: src, sourceSrc: src,
status: "conversion_required", status: "conversion_required",
targetFormat: "glb", targetFormat: "xkt",
updatedAt: nowIso() updatedAt: nowIso()
}; };
await writeJSONFile(manifestPathForSource(targetPath), conversion); await writeJSONFile(manifestPathForSource(targetPath), conversion);
@ -334,11 +334,11 @@ const handleConversionStatus = async (res, searchParams) => {
if (!manifest) { if (!manifest) {
sendJSON(res, 200, { sendJSON(res, 200, {
componentTreeRequired: true, componentTreeRequired: true,
message: "GLB/component tree is waiting for NodeDcBimConverter.", message: "XKT preview/component tree is waiting for NodeDcBimConverter.",
sourceFormat, sourceFormat,
sourceSrc: srcFromUploadPath(sourcePath), sourceSrc: srcFromUploadPath(sourcePath),
status: "conversion_required", status: "conversion_required",
targetFormat: "glb", targetFormat: "xkt",
updatedAt: nowIso() updatedAt: nowIso()
}); });
return; return;