Add local Draco optimization for GLB uploads
This commit is contained in:
@@ -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"]
|
||||
|
||||
Generated
+3803
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user