beam: produce XKT preview artifacts
This commit is contained in:
parent
3aace73a1f
commit
09b1173697
|
|
@ -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 . ./
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue