Add local Draco optimization for GLB uploads

This commit is contained in:
CODEX
2026-06-24 21:56:32 +03:00
parent 376a4237fa
commit d601058b3f
14 changed files with 4368 additions and 14 deletions
+5 -1
View File
@@ -20,9 +20,13 @@ WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN npm install -g @xeokit/xeokit-convert@1.3.2 \
COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& npm cache clean --force
ENV PATH="/app/node_modules/.bin:${PATH}"
COPY . ./
CMD ["python", "worker.py"]
+3803
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "nodedc-bim-converter-runtime",
"version": "0.1.0",
"private": true,
"description": "Pinned Node.js runtime tools for NODE.DC BIM converter",
"type": "module",
"dependencies": {
"@gltf-transform/cli": "4.4.0",
"@xeokit/xeokit-convert": "1.3.2",
"draco3d": "1.5.7"
}
}
+236 -6
View File
@@ -25,7 +25,7 @@ from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
CONVERTER_NAME = "NodeDcBimConverter"
CONVERTER_VERSION = "0.4.6"
CONVERTER_VERSION = "0.4.7"
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"}
@@ -37,17 +37,27 @@ REPROCESS_FAILED_ON_VERSION_CHANGE = os.environ.get(
).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")
GLB_DRACO_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_GLB_DRACO_ENABLED", "1").lower() not in {
"0",
"false",
"no",
}
GLTF_TRANSFORM_COMMAND = os.environ.get("NODEDC_BIM_CONVERTER_GLTF_TRANSFORM_COMMAND", "gltf-transform")
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_ANGULAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_ANGULAR_DEFLECTION", "0.1"))
PROCESSING_STALE_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_PROCESSING_STALE_SECONDS", "60"))
CONVERSION_MAX_ATTEMPTS = int(os.environ.get("NODEDC_BIM_CONVERTER_MAX_ATTEMPTS", "3"))
CONVERSION_TIMEOUT_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_TIMEOUT_SECONDS", "300"))
GLB_DRACO_TIMEOUT_SECONDS = float(
os.environ.get("NODEDC_BIM_CONVERTER_GLB_DRACO_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS))
)
XCAF_TIMEOUT_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_XCAF_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS)))
CADQUERY_TIMEOUT_SECONDS = float(
os.environ.get("NODEDC_BIM_CONVERTER_CADQUERY_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS))
)
STEP_EXTENSIONS = {".step", ".stp"}
GLB_EXTENSIONS = {".glb"}
def now_iso() -> str:
@@ -103,6 +113,16 @@ def conversion_attempt_count(manifest: dict[str, Any]) -> int:
return 0
def draco_optimization_attempt_count(manifest: dict[str, Any]) -> int:
optimization = manifest.get("dracoOptimization")
if not isinstance(optimization, dict):
return 0
try:
return max(0, int(optimization.get("attempts") or 0))
except Exception:
return 0
def update_asset_manifest_version(source_path: Path, version_payload: dict[str, Any]) -> None:
asset_path = asset_manifest_path(source_path)
if not asset_path:
@@ -138,8 +158,8 @@ def update_asset_manifest_version(source_path: Path, version_payload: dict[str,
**asset,
"assetId": asset.get("assetId") or version_payload.get("assetId"),
"projectId": asset.get("projectId") or version_payload.get("projectId"),
"currentVersion": version_payload.get("version") or asset.get("currentVersion"),
"currentVersionId": version_payload.get("versionId") or asset.get("currentVersionId"),
"currentVersion": asset.get("currentVersion") or version_payload.get("version"),
"currentVersionId": asset.get("currentVersionId") or version_payload.get("versionId"),
"updatedAt": now,
"versions": next_versions,
},
@@ -1163,6 +1183,212 @@ def process_one(source_path: Path) -> bool:
return False
def limited_process_output(value: Any, limit: int = 4000) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text if len(text) <= limit else f"{text[:limit]}..."
def glb_draco_artifact_path(source_path: Path) -> Path:
return source_path.with_name(f"{source_path.stem}.draco.glb")
def should_optimize_glb(source_path: Path, manifest: dict[str, Any], artifact_path: Path) -> bool:
if not GLB_DRACO_ENABLED or not source_path.exists() or not manifest:
return False
if source_path.name.endswith(".draco.glb"):
return False
source_format = str(manifest.get("sourceFormat") or "").replace(".", "").lower()
if source_format != "glb":
return False
if manifest.get("status") not in {None, "ready"}:
return False
optimization = manifest.get("dracoOptimization")
if not isinstance(optimization, dict):
optimization = {}
optimization_status = optimization.get("status")
optimization_version = optimization.get("converterVersion")
artifact_ready = upload_src_exists(manifest.get("artifactSrc"))
if optimization_status == "ready" and artifact_ready:
return REPROCESS_READY_ON_VERSION_CHANGE and optimization_version != CONVERTER_VERSION
if optimization_status == "failed":
attempts = draco_optimization_attempt_count(manifest)
if attempts >= CONVERSION_MAX_ATTEMPTS:
return REPROCESS_FAILED_ON_VERSION_CHANGE and optimization_version != CONVERTER_VERSION
return True
if optimization_status == "processing":
updated_at = optimization.get("updatedAt") or 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() < PROCESSING_STALE_SECONDS:
return False
except Exception:
pass
return True
def optimize_glb_one(source_path: Path) -> bool:
manifest_file = manifest_path(source_path)
if not manifest_file.exists():
return False
manifest = read_json(manifest_file)
artifact_path = glb_draco_artifact_path(source_path)
if not should_optimize_glb(source_path, manifest, artifact_path):
return False
source_src = src_from_path(source_path)
base_manifest = {
**manifest,
"createdAt": manifest.get("createdAt") or now_iso(),
"downloadSrc": manifest.get("downloadSrc") or source_src,
"sourceFormat": "glb",
"sourceSrc": manifest.get("sourceSrc") or source_src,
"status": "ready",
}
attempts = 0
optimization = manifest.get("dracoOptimization")
if isinstance(optimization, dict) and optimization.get("converterVersion") == CONVERTER_VERSION:
attempts = draco_optimization_attempt_count(manifest)
if isinstance(optimization, dict) and optimization.get("status") == "processing" and attempts >= CONVERSION_MAX_ATTEMPTS:
failed_manifest = {
**base_manifest,
"message": "Model is ready. Draco optimization did not finish; original GLB is used.",
"dracoOptimization": {
**optimization,
"status": "failed",
"attempts": attempts,
"converterName": CONVERTER_NAME,
"converterVersion": CONVERTER_VERSION,
"error": f"GLB optimization stopped before completion after {attempts} attempt(s).",
"updatedAt": now_iso(),
},
"updatedAt": now_iso(),
}
write_json_atomic(manifest_file, failed_manifest)
update_asset_manifest_version(source_path, failed_manifest)
print(
f"[{CONVERTER_NAME}] GLB Draco failed {source_path}: max attempts reached ({attempts})",
file=sys.stderr,
flush=True,
)
return False
attempt = attempts + 1
command = [GLTF_TRANSFORM_COMMAND, "draco", str(source_path), str(artifact_path.with_suffix(".tmp.glb"))]
processing_manifest = {
**base_manifest,
"message": "Model is ready. Optimizing GLB geometry with Draco.",
"dracoOptimization": {
"status": "processing",
"attempts": attempt,
"converterName": CONVERTER_NAME,
"converterVersion": CONVERTER_VERSION,
"command": command,
"updatedAt": now_iso(),
},
"updatedAt": now_iso(),
}
write_json_atomic(manifest_file, processing_manifest)
update_asset_manifest_version(source_path, processing_manifest)
tmp_path = artifact_path.with_suffix(".tmp.glb")
print(f"[{CONVERTER_NAME}] optimizing GLB with Draco {source_path}", flush=True)
try:
if tmp_path.exists():
tmp_path.unlink()
run_env = os.environ.copy()
run_env["NODE_OPTIONS"] = NODE_OPTIONS
result = subprocess.run(
command,
cwd=str(Path(__file__).resolve().parent),
env=run_env,
text=True,
capture_output=True,
timeout=GLB_DRACO_TIMEOUT_SECONDS,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
limited_process_output(result.stderr)
or limited_process_output(result.stdout)
or f"gltf-transform exited with code {result.returncode}"
)
if not tmp_path.exists() or tmp_path.stat().st_size <= 0:
raise RuntimeError("gltf-transform did not produce a Draco GLB artifact")
tmp_path.replace(artifact_path)
source_size = source_path.stat().st_size
artifact_size = artifact_path.stat().st_size
ready_manifest = {
**base_manifest,
"artifactSha256": calculate_file_sha256(artifact_path),
"artifactSrc": src_from_path(artifact_path),
"artifactType": "gltf",
"fallbackArtifactSrc": source_src,
"fallbackArtifactType": "gltf",
"message": "Model is ready. GLB geometry optimized with Draco.",
"targetFormat": "gltf",
"dracoOptimization": {
"status": "ready",
"attempts": attempt,
"converterName": CONVERTER_NAME,
"converterVersion": CONVERTER_VERSION,
"command": command,
"sourceSize": source_size,
"artifactSize": artifact_size,
"compressionRatio": round(artifact_size / source_size, 6) if source_size else None,
"stdout": limited_process_output(result.stdout),
"stderr": limited_process_output(result.stderr),
"updatedAt": now_iso(),
},
"updatedAt": now_iso(),
}
write_json_atomic(manifest_file, ready_manifest)
update_asset_manifest_version(source_path, ready_manifest)
print(
f"[{CONVERTER_NAME}] GLB Draco ready {ready_manifest['artifactSrc']} "
f"({source_size} -> {artifact_size} bytes)",
flush=True,
)
return True
except Exception as exc:
if tmp_path.exists():
try:
tmp_path.unlink()
except Exception:
pass
failed_manifest = {
**base_manifest,
"message": "Model is ready. Draco optimization failed; original GLB is used.",
"dracoOptimization": {
"status": "failed",
"attempts": attempt,
"converterName": CONVERTER_NAME,
"converterVersion": CONVERTER_VERSION,
"command": command,
"error": str(exc),
"updatedAt": now_iso(),
},
"updatedAt": now_iso(),
}
write_json_atomic(manifest_file, failed_manifest)
update_asset_manifest_version(source_path, failed_manifest)
print(f"[{CONVERTER_NAME}] GLB Draco 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)
@@ -1172,9 +1398,13 @@ def scan_once() -> int:
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):
suffix = source_path.suffix.lower()
did_process = False
if suffix in STEP_EXTENSIONS:
did_process = process_one(source_path)
elif suffix in GLB_EXTENSIONS:
did_process = optimize_glb_one(source_path)
if did_process:
processed += 1
return processed