diff --git a/converter/worker.py b/converter/worker.py index 6b08fe0..0bad97b 100644 --- a/converter/worker.py +++ b/converter/worker.py @@ -25,7 +25,7 @@ from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool CONVERTER_NAME = "NodeDcBimConverter" -CONVERTER_VERSION = "0.4.7" +CONVERTER_VERSION = "0.4.9" 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,6 +37,11 @@ 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_XKT_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_GLB_XKT_ENABLED", "1").lower() not in { + "0", + "false", + "no", +} GLB_DRACO_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_GLB_DRACO_ENABLED", "1").lower() not in { "0", "false", @@ -44,6 +49,11 @@ GLB_DRACO_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_GLB_DRACO_ENABLED", "1" } 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") +GLB_LOD_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_GLB_LOD_ENABLED", "1").lower() not in { + "0", + "false", + "no", +} 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")) @@ -52,12 +62,41 @@ CONVERSION_TIMEOUT_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_TIMEOUT_ GLB_DRACO_TIMEOUT_SECONDS = float( os.environ.get("NODEDC_BIM_CONVERTER_GLB_DRACO_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS)) ) +GLB_LOD_TIMEOUT_SECONDS = float( + os.environ.get("NODEDC_BIM_CONVERTER_GLB_LOD_TIMEOUT_SECONDS", str(max(CONVERSION_TIMEOUT_SECONDS, 900))) +) +XKT_TIMEOUT_SECONDS = float( + os.environ.get("NODEDC_BIM_CONVERTER_XKT_TIMEOUT_SECONDS", str(max(CONVERSION_TIMEOUT_SECONDS, 900))) +) 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"} +GLB_LOD_PROFILES = [ + { + "id": "full", + "label": "Полный", + "suffix": "lod-full", + "ratio": None, + "error": None, + }, + { + "id": "desktop", + "label": "Средний", + "suffix": "lod-desktop", + "ratio": 0.35, + "error": 0.005, + }, + { + "id": "mobile", + "label": "Мобильный", + "suffix": "lod-mobile", + "ratio": 0.12, + "error": 0.02, + }, +] def now_iso() -> str: @@ -123,6 +162,26 @@ def draco_optimization_attempt_count(manifest: dict[str, Any]) -> int: return 0 +def xkt_conversion_attempt_count(manifest: dict[str, Any]) -> int: + conversion = manifest.get("xktConversion") + if not isinstance(conversion, dict): + return 0 + try: + return max(0, int(conversion.get("attempts") or 0)) + except Exception: + return 0 + + +def lod_generation_attempt_count(manifest: dict[str, Any]) -> int: + generation = manifest.get("lodGeneration") + if not isinstance(generation, dict): + return 0 + try: + return max(0, int(generation.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: @@ -740,15 +799,23 @@ def convert_glb_to_xkt(glb_path: Path, xkt_path: Path) -> dict[str, Any]: 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, - ) + try: + result = subprocess.run( + command, + cwd=str(glb_path.parent), + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + text=True, + timeout=XKT_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired as exc: + try: + tmp_xkt_path.unlink() + except Exception: + pass + raise TimeoutError(f"XKT export timed out after {int(XKT_TIMEOUT_SECONDS)} seconds.") from exc if result.returncode != 0: tail = read_log_tail(log_path) @@ -1196,6 +1263,29 @@ def glb_draco_artifact_path(source_path: Path) -> Path: return source_path.with_name(f"{source_path.stem}.draco.glb") +def glb_xkt_artifact_path(source_path: Path) -> Path: + return source_path.with_name(f"{source_path.stem}.xkt") + + +def glb_lod_artifact_path(source_path: Path, profile: dict[str, Any]) -> Path: + return source_path.with_name(f"{source_path.stem}.{profile['suffix']}.glb") + + +def lod_records_ready(lods: Any) -> bool: + if not isinstance(lods, list) or len(lods) < len(GLB_LOD_PROFILES): + return False + by_id = { + str(item.get("id")): item + for item in lods + if isinstance(item, dict) and item.get("id") + } + for profile in GLB_LOD_PROFILES: + item = by_id.get(profile["id"]) + if not item or not upload_src_exists(item.get("artifactSrc")): + return False + return True + + 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 @@ -1331,18 +1421,32 @@ def optimize_glb_one(source_path: Path) -> bool: source_size = source_path.stat().st_size artifact_size = artifact_path.stat().st_size + draco_artifact_src = src_from_path(artifact_path) + existing_artifact_src = manifest.get("artifactSrc") + existing_artifact_type = manifest.get("artifactType") or manifest.get("targetFormat") + preserve_xkt_artifact = ( + existing_artifact_type == "xkt" + and isinstance(existing_artifact_src, str) + and upload_src_exists(existing_artifact_src) + ) + preferred_artifact_src = existing_artifact_src if preserve_xkt_artifact else draco_artifact_src + preferred_artifact_type = "xkt" if preserve_xkt_artifact else "gltf" ready_manifest = { **base_manifest, "artifactSha256": calculate_file_sha256(artifact_path), - "artifactSrc": src_from_path(artifact_path), - "artifactType": "gltf", + "artifactSrc": preferred_artifact_src, + "artifactType": preferred_artifact_type, + "dracoArtifactSrc": draco_artifact_src, + "dracoArtifactType": "gltf", "fallbackArtifactSrc": source_src, "fallbackArtifactType": "gltf", "message": "Model is ready. GLB geometry optimized with Draco.", - "targetFormat": "gltf", + "targetFormat": "xkt" if preserve_xkt_artifact else "gltf", "dracoOptimization": { "status": "ready", "attempts": attempt, + "artifactSrc": draco_artifact_src, + "artifactType": "gltf", "converterName": CONVERTER_NAME, "converterVersion": CONVERTER_VERSION, "command": command, @@ -1358,7 +1462,7 @@ def optimize_glb_one(source_path: Path) -> bool: 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"[{CONVERTER_NAME}] GLB Draco ready {draco_artifact_src} " f"({source_size} -> {artifact_size} bytes)", flush=True, ) @@ -1389,6 +1493,445 @@ def optimize_glb_one(source_path: Path) -> bool: return False +def should_convert_glb_to_xkt(source_path: Path, manifest: dict[str, Any], xkt_path: Path) -> bool: + if not XKT_ENABLED or not GLB_XKT_ENABLED or not source_path.exists() or not manifest: + return False + if source_path.name.endswith(".draco.glb") or ".lod-" in source_path.name: + 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 + + conversion = manifest.get("xktConversion") + if not isinstance(conversion, dict): + conversion = {} + + conversion_status = conversion.get("status") + conversion_version = conversion.get("converterVersion") + artifact_src = manifest.get("artifactSrc") + artifact_type = manifest.get("artifactType") or manifest.get("targetFormat") + artifact_ready = artifact_type == "xkt" and isinstance(artifact_src, str) and upload_src_exists(artifact_src) + if conversion_status == "ready" and artifact_ready: + return REPROCESS_READY_ON_VERSION_CHANGE and conversion_version != CONVERTER_VERSION + if conversion_status == "ready" and xkt_path.exists() and not artifact_ready: + return True + + if conversion_status == "failed": + attempts = xkt_conversion_attempt_count(manifest) + if attempts >= CONVERSION_MAX_ATTEMPTS: + return REPROCESS_FAILED_ON_VERSION_CHANGE and conversion_version != CONVERTER_VERSION + return True + + if conversion_status == "processing": + updated_at = conversion.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 + + return True + + +def convert_glb_to_xkt_one(source_path: Path) -> bool: + manifest_file = manifest_path(source_path) + if not manifest_file.exists(): + return False + + manifest = read_json(manifest_file) + xkt_path = glb_xkt_artifact_path(source_path) + if not should_convert_glb_to_xkt(source_path, manifest, xkt_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 + conversion = manifest.get("xktConversion") + if isinstance(conversion, dict) and conversion.get("converterVersion") == CONVERTER_VERSION: + attempts = xkt_conversion_attempt_count(manifest) + if isinstance(conversion, dict) and conversion.get("status") == "processing" and attempts >= CONVERSION_MAX_ATTEMPTS: + failed_manifest = { + **base_manifest, + "message": "Model is ready. XKT conversion did not finish; GLB fallback is used.", + "xktConversion": { + **conversion, + "status": "failed", + "attempts": attempts, + "converterName": CONVERTER_NAME, + "converterVersion": CONVERTER_VERSION, + "error": f"XKT conversion 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 XKT failed {source_path}: max attempts reached ({attempts})", + file=sys.stderr, + flush=True, + ) + return False + + attempt = attempts + 1 + command = [ + XKT_COMMAND, + "-s", + str(source_path), + "-f", + "glb", + "-o", + str(xkt_path.with_name(f"{xkt_path.name}.tmp")), + "-t", + "-n", + "-e", + "0", + "-b", + ] + processing_manifest = { + **base_manifest, + "message": "Model is ready. Converting GLB to XKT viewer artifact.", + "targetFormat": "xkt", + "xktConversion": { + "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) + + print(f"[{CONVERTER_NAME}] converting GLB to XKT {source_path}", flush=True) + try: + xkt_stats = convert_glb_to_xkt(source_path, xkt_path) + source_size = source_path.stat().st_size + artifact_size = xkt_path.stat().st_size + xkt_artifact_src = src_from_path(xkt_path) + fallback_artifact_src = ( + manifest.get("dracoArtifactSrc") + or ( + manifest.get("artifactSrc") + if (manifest.get("artifactType") or manifest.get("targetFormat")) == "gltf" + else None + ) + or source_src + ) + ready_manifest = { + **base_manifest, + "artifactSha256": calculate_file_sha256(xkt_path), + "artifactSrc": xkt_artifact_src, + "artifactType": "xkt", + "fallbackArtifactSrc": fallback_artifact_src, + "fallbackArtifactType": "gltf", + "glbArtifactSrc": source_src, + "targetFormat": "xkt", + "xktArtifactSrc": xkt_artifact_src, + "xkt": xkt_stats, + "xktConversion": { + "status": "ready", + "attempts": attempt, + "artifactSrc": xkt_artifact_src, + "artifactType": "xkt", + "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, + "updatedAt": now_iso(), + }, + "message": "Model is ready. XKT viewer artifact generated.", + "updatedAt": now_iso(), + } + write_json_atomic(manifest_file, ready_manifest) + update_asset_manifest_version(source_path, ready_manifest) + print( + f"[{CONVERTER_NAME}] GLB XKT ready {xkt_artifact_src} " + f"({source_size} -> {artifact_size} bytes)", + flush=True, + ) + return True + except Exception as exc: + tmp_path = xkt_path.with_name(f"{xkt_path.name}.tmp") + if tmp_path.exists(): + try: + tmp_path.unlink() + except Exception: + pass + failed_manifest = { + **base_manifest, + "message": "Model is ready. XKT conversion failed; GLB fallback is used.", + "xktConversion": { + "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 XKT failed {source_path}: {exc}", file=sys.stderr, flush=True) + return False + + +def should_generate_glb_lods(source_path: Path, manifest: dict[str, Any]) -> bool: + if not GLB_LOD_ENABLED or not source_path.exists() or not manifest: + return False + if source_path.name.endswith(".draco.glb") or ".lod-" in source_path.name: + 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 + + generation = manifest.get("lodGeneration") + if not isinstance(generation, dict): + return False + + generation_status = generation.get("status") + generation_version = generation.get("converterVersion") + if generation_status == "ready" and lod_records_ready(manifest.get("lods")): + return REPROCESS_READY_ON_VERSION_CHANGE and generation_version != CONVERTER_VERSION + + if generation_status == "failed": + attempts = lod_generation_attempt_count(manifest) + if attempts >= CONVERSION_MAX_ATTEMPTS: + return REPROCESS_FAILED_ON_VERSION_CHANGE and generation_version != CONVERTER_VERSION + return True + + if generation_status == "processing": + updated_at = generation.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 + + return generation_status == "queued" + + +def glb_lod_command(source_path: Path, target_path: Path, profile: dict[str, Any]) -> list[str]: + if profile.get("ratio") is None: + return [GLTF_TRANSFORM_COMMAND, "draco", str(source_path), str(target_path)] + return [ + GLTF_TRANSFORM_COMMAND, + "optimize", + str(source_path), + str(target_path), + "--compress", + "draco", + "--texture-compress", + "false", + "--simplify-ratio", + str(profile["ratio"]), + "--simplify-error", + str(profile["error"]), + ] + + +def run_gltf_transform_lod(source_path: Path, target_path: Path, profile: dict[str, Any]) -> tuple[list[str], str, str]: + tmp_path = target_path.with_suffix(".tmp.glb") + if tmp_path.exists(): + tmp_path.unlink() + command = glb_lod_command(source_path, tmp_path, profile) + 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_LOD_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 LOD GLB artifact") + tmp_path.replace(target_path) + return command, limited_process_output(result.stdout), limited_process_output(result.stderr) + + +def generate_glb_lods_one(source_path: Path) -> bool: + manifest_file = manifest_path(source_path) + if not manifest_file.exists(): + return False + + manifest = read_json(manifest_file) + if not should_generate_glb_lods(source_path, manifest): + 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 + generation = manifest.get("lodGeneration") + if isinstance(generation, dict) and generation.get("converterVersion") == CONVERTER_VERSION: + attempts = lod_generation_attempt_count(manifest) + if isinstance(generation, dict) and generation.get("status") == "processing" and attempts >= CONVERSION_MAX_ATTEMPTS: + failed_manifest = { + **base_manifest, + "message": "Model is ready. LOD generation did not finish.", + "lodGeneration": { + **generation, + "status": "failed", + "attempts": attempts, + "converterName": CONVERTER_NAME, + "converterVersion": CONVERTER_VERSION, + "error": f"LOD generation 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 LOD failed {source_path}: max attempts reached ({attempts})", + file=sys.stderr, + flush=True, + ) + return False + + attempt = attempts + 1 + processing_manifest = { + **base_manifest, + "message": "Model is ready. Generating GLB LOD levels.", + "lodGeneration": { + **(generation if isinstance(generation, dict) else {}), + "status": "processing", + "attempts": attempt, + "converterName": CONVERTER_NAME, + "converterVersion": CONVERTER_VERSION, + "profiles": [ + {key: profile[key] for key in ("id", "label", "ratio", "error") if key in profile} + for profile in GLB_LOD_PROFILES + ], + "updatedAt": now_iso(), + }, + "updatedAt": now_iso(), + } + write_json_atomic(manifest_file, processing_manifest) + update_asset_manifest_version(source_path, processing_manifest) + + source_size = source_path.stat().st_size + records = [] + commands = [] + print(f"[{CONVERTER_NAME}] generating GLB LODs {source_path}", flush=True) + + try: + for profile in GLB_LOD_PROFILES: + artifact_path = glb_lod_artifact_path(source_path, profile) + command, stdout, stderr = run_gltf_transform_lod(source_path, artifact_path, profile) + commands.append({ + "id": profile["id"], + "command": command, + "stdout": stdout, + "stderr": stderr, + }) + artifact_size = artifact_path.stat().st_size + records.append({ + "id": profile["id"], + "label": profile["label"], + "artifactSrc": src_from_path(artifact_path), + "type": "gltf", + "ratio": profile.get("ratio"), + "error": profile.get("error"), + "size": artifact_size, + "sha256": calculate_file_sha256(artifact_path), + "sourceSize": source_size, + "compressionRatio": round(artifact_size / source_size, 6) if source_size else None, + "generatedAt": now_iso(), + }) + + ready_manifest = { + **base_manifest, + "lods": records, + "lodGeneration": { + "status": "ready", + "attempts": attempt, + "converterName": CONVERTER_NAME, + "converterVersion": CONVERTER_VERSION, + "commands": commands, + "sourceSize": source_size, + "updatedAt": now_iso(), + }, + "message": "Model is ready. GLB LOD levels generated.", + "updatedAt": now_iso(), + } + write_json_atomic(manifest_file, ready_manifest) + update_asset_manifest_version(source_path, ready_manifest) + print( + f"[{CONVERTER_NAME}] GLB LOD ready {source_src}: " + + ", ".join(f"{item['id']}={item['size']}" for item in records), + flush=True, + ) + return True + except Exception as exc: + for profile in GLB_LOD_PROFILES: + tmp_path = glb_lod_artifact_path(source_path, profile).with_suffix(".tmp.glb") + if tmp_path.exists(): + try: + tmp_path.unlink() + except Exception: + pass + failed_manifest = { + **base_manifest, + "message": "Model is ready. LOD generation failed.", + "lodGeneration": { + **(generation if isinstance(generation, dict) else {}), + "status": "failed", + "attempts": attempt, + "converterName": CONVERTER_NAME, + "converterVersion": CONVERTER_VERSION, + "commands": commands, + "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 LOD 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) @@ -1403,7 +1946,9 @@ def scan_once() -> int: if suffix in STEP_EXTENSIONS: did_process = process_one(source_path) elif suffix in GLB_EXTENSIONS: - did_process = optimize_glb_one(source_path) + did_process = convert_glb_to_xkt_one(source_path) + did_process = optimize_glb_one(source_path) or did_process + did_process = generate_glb_lods_one(source_path) or did_process if did_process: processed += 1 return processed diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css index db36ff6..f2a1bf4 100644 --- a/frontend/dcViewer.css +++ b/frontend/dcViewer.css @@ -1242,6 +1242,32 @@ html.is-embedded-viewer #navCube { stroke-linejoin: round; } +.model-lod-generate-btn { + width: 100%; + min-height: 42px; + background: var(--modal-btn-primary, var(--accent)); + color: var(--modal-btn-primary-contrast, #15170f); + box-shadow: 0 8px 24px color-mix(in srgb, var(--modal-btn-primary, var(--accent)) 25%, transparent); +} + +.model-lod-generate-btn.is-generating, +.model-lod-generate-btn.is-generating:disabled { + cursor: progress; + opacity: 1; + animation: model-lod-generate-pulse 2.4s ease-in-out infinite; + box-shadow: 0 8px 26px color-mix(in srgb, var(--modal-btn-primary, var(--accent)) 28%, transparent); +} + +@keyframes model-lod-generate-pulse { + 0%, + 100% { + background: var(--modal-btn-primary, var(--accent)); + } + 50% { + background: color-mix(in srgb, var(--modal-btn-primary, var(--accent)) 72%, white 28%); + } +} + .inspector-color-host { min-width: 0; } @@ -3242,7 +3268,9 @@ html[data-share-startup] body:not([data-viewer-mode="standard"]) #projectNameSec body[data-viewer-mode="guest"] #saveProjectSection, html[data-share-startup] body:not([data-viewer-mode="standard"]) #saveProjectSection, body[data-viewer-mode="guest"] #modelVersionUploadButton, -html[data-share-startup] body:not([data-viewer-mode="standard"]) #modelVersionUploadButton { +html[data-share-startup] body:not([data-viewer-mode="standard"]) #modelVersionUploadButton, +body[data-viewer-mode="guest"] #modelLodGenerateButton, +html[data-share-startup] body:not([data-viewer-mode="standard"]) #modelLodGenerateButton { display: none !important; } diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index 0018b9f..39f3e7c 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -123,6 +123,9 @@ const modelVersionSelectHost = document.getElementById("modelVersionSelect"); const modelVersionHistoryButton = document.getElementById("modelVersionHistoryButton"); const modelVersionUploadButton = document.getElementById("modelVersionUploadButton"); const modelVersionFileInput = document.getElementById("modelVersionFileInput"); +const modelLodControl = document.getElementById("modelLodControl"); +const modelLodSelectHost = document.getElementById("modelLodSelect"); +const modelLodGenerateButton = document.getElementById("modelLodGenerateButton"); const navigationAxisControl = document.getElementById("navigationAxisControl"); const navigationAxisSelectHost = document.getElementById("navigationAxisSelect"); const zoomSpeedControl = document.getElementById("zoomSpeedControl"); @@ -200,6 +203,7 @@ const AUTH_SESSION_API = "/api/auth/session"; const SHARES_API = "/api/shares"; const COMMENTS_API = "/api/comments"; const UPLOAD_VERSIONS_API = "/api/uploads/versions"; +const UPLOAD_LODS_API = "/api/uploads/lods"; const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest"; const SHARE_PROMOTION_TIMEOUT_MS = 4500; const SHARE_PROMOTION_RETRY_MS = 15000; @@ -317,6 +321,9 @@ let startupSharePayload = null; let modelVersionHistory = null; let modelVersionHistoryLoading = false; let modelVersionUploading = false; +let modelLodLoading = false; +let modelLodGenerating = false; +let modelLodPollTimer = null; let versionHistoryModal = null; let loadModelAsyncRef = null; let versionModelCounter = 0; @@ -344,6 +351,7 @@ let customDisplayColorControl = null; let customDisplaySaturationSlider = null; let lightingModeSelect = null; let modelVersionSelect = null; +let modelLodSelect = null; let navigationAxisSelect = null; let zoomSpeedSlider = null; let defaultCameraWorldAxis = null; @@ -2277,7 +2285,8 @@ const getShareableModelPayload = () => { if (!entry) { return null; } - const src = normalizeModelSettingsSrc(entry.meta?.artifactSrc || entry.src || entry.meta?.src); + const selectedLodSrc = getSelectedLodViewerSrc(entry); + const src = normalizeModelSettingsSrc(selectedLodSrc || entry.meta?.artifactSrc || entry.src || entry.meta?.src); const settingsSrc = getModelSettingsSrc(entry); if (!src) { return null; @@ -2287,9 +2296,10 @@ const getShareableModelPayload = () => { assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null, projectId: entry.meta?.projectId || entry.meta?.conversion?.projectId || versionIdentity.projectId || null, name: entry.name || entry.meta?.label || src.split("/").pop() || "model", + selectedLod: getCurrentLodIdForEntry(entry) || null, settingsSrc: settingsSrc || src, src, - type: entry.type || normalizeType(null, src), + type: normalizeType(null, src) || entry.meta?.artifactType || entry.type, versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null, versions: Array.isArray(entry.meta?.versions) ? entry.meta.versions : [], }; @@ -3483,6 +3493,7 @@ const updatePanelsVisibility = () => { if (saveModelSettingsButton) { saveModelSettingsButton.disabled = isGuestMode() || !hasModels || !hasModelSettingsTarget(); } + updateModelLodControl(); if (deleteProjectButton) { deleteProjectButton.disabled = isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId); } @@ -4626,7 +4637,7 @@ const formatFetchFailureMessage = (url, res, text, data) => { const formatApiNonJsonMessage = (url, res, text) => { const status = res?.status ? `HTTP ${res.status}` : "HTTP"; if (/^\s* { return normalizeType(version?.type, src) || normalizeType(guessTypeFromName(src || version?.originalFilename || "")); }; +const getLodKey = (lod) => lod?.id || lod?.artifactSrc || lod?.src || lod?.url || "lod"; + +const getLodViewerSrc = (lod) => normalizeVersionSource(lod?.src || lod?.url || lod?.artifactSrc || ""); + +const getLodViewerType = (lod) => { + const src = getLodViewerSrc(lod); + return normalizeType(lod?.type, src) || normalizeType(guessTypeFromName(src)) || "gltf"; +}; + +const getEntryLods = (entry = getPrimaryModelEntry()) => { + const lods = entry?.meta?.lods; + return Array.isArray(lods) ? lods : []; +}; + +const getCurrentLodIdForEntry = (entry = getPrimaryModelEntry()) => { + const meta = entry?.meta || {}; + const savedId = meta.selectedLodId || + meta.selectedLod || + meta.viewerSettings?.viewerState?.lod?.selectedId || + meta.viewerSettings?.viewerState?.lod?.id || + ""; + if (savedId) return savedId; + const entrySrc = normalizeModelSettingsSrc(entry?.src || meta.artifactSrc || ""); + const currentLod = getEntryLods(entry).find((lod) => { + const lodSrc = normalizeModelSettingsSrc(lod?.artifactSrc || lod?.src || lod?.url || ""); + return lodSrc && lodSrc === entrySrc; + }); + return currentLod?.id || getEntryLods(entry)[0]?.id || ""; +}; + +const getSelectedLodRecord = (entry = getPrimaryModelEntry()) => { + const lods = getEntryLods(entry); + const currentId = getCurrentLodIdForEntry(entry); + return lods.find((lod) => currentId && lod?.id === currentId) || null; +}; + +const getSelectedLodViewerSrc = (entry = getPrimaryModelEntry()) => { + const lod = getSelectedLodRecord(entry); + return lod ? getLodViewerSrc(lod) : ""; +}; + +const setEntryLodPayload = (entry, payload) => { + if (!entry || !payload) return; + const meta = entry.meta || {}; + entry.meta = { + ...meta, + sourceSrc: payload.sourceSrc || meta.sourceSrc || null, + settingsSrc: payload.sourceSrc || meta.settingsSrc || null, + artifactSrc: payload.artifactSrc || meta.artifactSrc || null, + artifactType: payload.artifactType || meta.artifactType || null, + xktConversion: payload.xktConversion || meta.xktConversion || null, + lodGeneration: payload.lodGeneration || null, + lods: Array.isArray(payload.lods) ? payload.lods : [], + selectedLod: payload.selectedLod || meta.selectedLod || null, + }; +}; + +const isGlbLodCandidate = (entry = getPrimaryModelEntry()) => { + if (!entry) return false; + const meta = entry.meta || {}; + const candidates = [ + meta.sourceFormat, + meta.sourceSrc, + meta.settingsSrc, + meta.artifactSrc, + entry.src, + entry.name, + ].filter(Boolean); + return candidates.some((value) => String(value).toLowerCase().includes(".glb") || String(value).toLowerCase() === "glb"); +}; + +const buildLodParams = (entry = getPrimaryModelEntry()) => { + const params = new URLSearchParams(); + const src = getModelSettingsSrc(entry) || entry?.meta?.sourceSrc || entry?.meta?.settingsSrc || ""; + if (src) params.set("src", src); + return params; +}; + +const fetchModelLods = async (entry = getPrimaryModelEntry()) => { + const params = buildLodParams(entry); + if (!params.toString()) return null; + return fetchJSON(`${UPLOAD_LODS_API}?${params.toString()}`); +}; + +const formatLodOptionLabel = (lod) => { + const label = lod?.label || lod?.id || "LOD"; + const size = formatFileSize(lod?.size); + return size ? `${label} · ${size}` : label; +}; + +const updateModelLodControl = () => { + const entry = getPrimaryModelEntry(); + const lods = getEntryLods(entry); + const generation = entry?.meta?.lodGeneration || null; + const generationStatus = generation?.status || ""; + const hasControl = !!entry && isGlbLodCandidate(entry) && (!isGuestMode() || lods.length > 0 || modelLodLoading); + modelLodControl?.classList.toggle("hidden", !hasControl); + if (!hasControl) { + modelLodGenerateButton?.classList.remove("is-generating"); + return; + } + + const hasReadyLods = lods.length > 0; + modelLodSelectHost?.classList.toggle("hidden", !hasReadyLods); + modelLodGenerateButton?.classList.toggle("hidden", hasReadyLods); + + if (modelLodGenerateButton) { + const busy = modelLodGenerating || modelLodLoading || generationStatus === "queued" || generationStatus === "processing"; + modelLodGenerateButton.classList.toggle("is-generating", busy); + modelLodGenerateButton.disabled = isGuestMode() || busy; + modelLodGenerateButton.textContent = busy + ? "Генерируем LOD..." + : generationStatus === "failed" + ? "Повторить генерацию LOD" + : "Сгенерировать LOD"; + } + + if (modelLodSelect) { + const options = hasReadyLods + ? lods.map((lod) => ({ value: getLodKey(lod), label: formatLodOptionLabel(lod) })) + : [{ value: "loading", label: modelLodLoading ? "Загружаем LOD..." : "LOD не сгенерирован" }]; + const currentValue = getCurrentLodIdForEntry(entry) || options[0]?.value; + modelLodSelect.setOptions(options, currentValue); + modelLodSelect.setDisabled(!hasReadyLods); + } +}; + +const scheduleModelLodPoll = () => { + if (modelLodPollTimer) { + window.clearTimeout(modelLodPollTimer); + } + modelLodPollTimer = window.setTimeout(() => { + modelLodPollTimer = null; + refreshModelLods({ silent: true }).catch(() => {}); + }, 2500); +}; + +const refreshModelLods = async ({ silent = false } = {}) => { + const entry = getPrimaryModelEntry(); + if (!entry || !isGlbLodCandidate(entry)) { + updateModelLodControl(); + return null; + } + modelLodLoading = true; + updateModelLodControl(); + try { + const payload = await fetchModelLods(entry); + setEntryLodPayload(entry, payload); + const status = payload?.lodGeneration?.status; + if (status === "queued" || status === "processing") { + scheduleModelLodPoll(); + } + updateModelLodControl(); + return payload; + } catch (err) { + if (!silent) console.warn("model LOD state unavailable", err); + updateModelLodControl(); + return null; + } finally { + modelLodLoading = false; + updateModelLodControl(); + } +}; + +const openModelLod = async (lod) => { + if (!loadModelAsyncRef) { + showError("Загрузчик модели еще не готов"); + return; + } + const viewerSrc = getLodViewerSrc(lod); + const viewerType = getLodViewerType(lod); + if (!viewerSrc || !viewerType) { + setStatus("Этот LOD еще не готов к просмотру"); + return; + } + const previousEntry = getPrimaryModelEntry(); + const meta = { + ...(previousEntry?.meta || {}), + artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc, + selectedLod: lod.id || null, + selectedLodId: lod.id || null, + }; + if (meta.viewerSettings?.viewerState) { + meta.viewerSettings = { + ...meta.viewerSettings, + viewerState: { + ...meta.viewerSettings.viewerState, + lod: { + selectedId: lod.id || null, + selectedSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc, + }, + }, + }; + } + try { + await loadModelAsyncRef({ + type: viewerType, + url: viewerSrc, + name: previousEntry?.name || meta.label || lod.label || "model", + replace: true, + preserveMeta: true, + id: previousEntry?.model?.id || "model", + meta, + }); + updateModelLodControl(); + setStatus(`Открыт LOD: ${lod.label || lod.id || "LOD"}`); + } catch (err) { + console.error(err); + showError(err.message || "Не удалось открыть LOD модели"); + updateModelLodControl(); + } +}; + +const generateModelLods = async () => { + const entry = getPrimaryModelEntry(); + if (!entry || isGuestMode() || modelLodGenerating) return; + const settingsSrc = getModelSettingsSrc(entry); + if (!settingsSrc) { + setStatus("LOD можно сгенерировать только для модели из BIM-хранилища"); + return; + } + modelLodGenerating = true; + updateModelLodControl(); + try { + const payload = await fetchJSON(UPLOAD_LODS_API, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + settingsSrc, + sourceSrc: entry.meta?.sourceSrc || settingsSrc, + artifactSrc: entry.meta?.artifactSrc || entry.src || null, + }), + }); + setEntryLodPayload(entry, payload); + scheduleModelLodPoll(); + setStatus("Генерация LOD поставлена в очередь"); + } catch (err) { + console.error(err); + showError(err.message || "Не удалось запустить генерацию LOD"); + } finally { + modelLodGenerating = false; + updateModelLodControl(); + } +}; + const getCurrentVersionIdForEntry = (entry = getPrimaryModelEntry()) => { const meta = entry?.meta || {}; return meta.versionId || meta.conversion?.versionId || modelVersionHistory?.currentVersionId || null; @@ -4898,6 +5155,8 @@ const openModelVersion = async (version) => { sourceSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc, settingsSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc, artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc, + artifactType: viewerType, + xktConversion: version.xktConversion || null, viewerSettings: null, }; try { @@ -4905,6 +5164,14 @@ const openModelVersion = async (version) => { if (settings?.viewerSettings) { meta.viewerSettings = settings.viewerSettings; } + if (settings) { + meta.lodGeneration = settings.lodGeneration || null; + meta.lods = Array.isArray(settings.lods) ? settings.lods : []; + meta.selectedLod = settings.selectedLod || null; + meta.artifactSrc = settings.artifactSrc || meta.artifactSrc; + meta.artifactType = normalizeType(null, settings.artifactSrc) || meta.artifactType || viewerType; + meta.xktConversion = settings.xktConversion || meta.xktConversion || null; + } const previousModelId = previousEntry?.model?.id || null; const nextModelId = makeVersionModelId(version); if (previousModelId && transformStore[previousModelId]) { @@ -4926,6 +5193,7 @@ const openModelVersion = async (version) => { versions: modelVersionHistory?.versions || meta.versions || [], }; updateModelVersionControl(); + updateModelLodControl(); setStatus(`Открыта версия v${version.version || ""}`.trim()); } catch (err) { console.error(err); @@ -5282,9 +5550,10 @@ const buildProjectSnapshot = async (name) => { for (const entry of activeModels) { const model = entry.model; - const modelType = normalizeType(entry.type, entry.src) || normalizeType(guessTypeFromName(entry.src)) || entry.type || null; const src = typeof entry.src === "string" ? entry.src : ""; const meta = entry.meta || {}; + const preferredSrc = normalizeModelSettingsSrc(getSelectedLodViewerSrc(entry) || meta.artifactSrc || src) || src; + const modelType = normalizeType(null, preferredSrc) || normalizeType(entry.type, src) || normalizeType(guessTypeFromName(src)) || entry.type || null; const versionIdentity = getModelVersionIdentity(entry); const baseModel = { id: model.id, @@ -5299,7 +5568,12 @@ const buildProjectSnapshot = async (name) => { metadataPath: meta.metadataPath || null, settingsSrc: getModelSettingsSrc(entry) || null, sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || null, - artifactSrc: meta.artifactSrc || null + artifactSrc: meta.artifactSrc || null, + artifactType: meta.artifactType || normalizeType(null, meta.artifactSrc) || null, + xktConversion: meta.xktConversion || null, + lodGeneration: meta.lodGeneration || null, + lods: Array.isArray(meta.lods) ? meta.lods : [], + selectedLod: getCurrentLodIdForEntry(entry) || meta.selectedLod || null }, visible: model.visible !== false }; @@ -5308,12 +5582,12 @@ const buildProjectSnapshot = async (name) => { throw new Error("Не удалось определить тип модели для сохранения."); } - if (isRemoteSrc(src)) { - models.push({ ...baseModel, src }); + if (isRemoteSrc(preferredSrc)) { + models.push({ ...baseModel, src: preferredSrc }); continue; } - const cacheKey = src || `${model.id}-${modelType}`; + const cacheKey = preferredSrc || src || `${model.id}-${modelType}`; if (uploadCache.has(cacheKey)) { models.push({ ...baseModel, src: uploadCache.get(cacheKey) }); continue; @@ -5429,7 +5703,22 @@ const buildModelViewerSettings = () => ({ customDisplaySaturation: activeCustomDisplaySaturation, lightingMode: activeLightingMode, navigationAxis: activeNavigationAxis, - zoomSpeed: activeZoomSpeed + zoomSpeed: activeZoomSpeed, + lod: (() => { + const entry = getPrimaryModelEntry(); + const selected = getSelectedLodRecord(entry); + const selectedSrc = getSelectedLodViewerSrc(entry); + return { + selectedId: selected?.id || getCurrentLodIdForEntry(entry) || null, + selectedSrc: normalizeModelSettingsSrc(selectedSrc) || selectedSrc || null, + levels: getEntryLods(entry).map((lod) => ({ + id: lod.id || null, + label: lod.label || null, + artifactSrc: normalizeModelSettingsSrc(lod.artifactSrc || lod.src || lod.url || "") || null, + size: Number(lod.size) || 0 + })) + }; + })() } : {}, design: settingsMenu?.getState ? settingsMenu.getState() : {} }); @@ -5496,6 +5785,8 @@ const fetchModelSettings = async (src) => { reportViewerEvent("model-settings-response", { src: data?.sourceSrc || normalized, hasViewerSettings: !!data?.viewerSettings, + lodCount: Array.isArray(data?.lods) ? data.lods.length : 0, + lodStatus: data?.lodGeneration?.status || null, }); return data; }; @@ -5530,8 +5821,12 @@ const saveCurrentModelSettings = async () => { ...(entry.meta || {}), settingsSrc: saved?.sourceSrc || settingsSrc, sourceSrc: saved?.sourceSrc || entry.meta?.sourceSrc || null, + lodGeneration: saved?.lodGeneration || entry.meta?.lodGeneration || null, + lods: Array.isArray(saved?.lods) ? saved.lods : (entry.meta?.lods || []), + selectedLod: saved?.selectedLod || entry.meta?.selectedLod || null, viewerSettings: saved?.viewerSettings || payload.viewerSettings }; + updateModelLodControl(); setStatus("Настройки модели сохранены"); log(`Настройки модели сохранены: ${entry.name || entry.model?.id || settingsSrc}`, false); } catch (err) { @@ -6222,6 +6517,7 @@ const initViewer = async () => { updateNavCubeVisibility(); updatePanelsVisibility(); refreshModelVersionHistory({ silent: true }).catch(() => {}); + refreshModelLods({ silent: true }).catch(() => {}); // если есть сохранённые трансформы для модели — применим applyStoredTransformsToModel(id); @@ -6338,6 +6634,7 @@ const initViewer = async () => { assetId: sharedViewer?.assetId || null, projectId: sharedViewer?.projectId || null, versionId: sharedViewer?.versionId || null, + selectedLod: sharedViewer?.selectedLod || null, versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [], shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(), viewerSettings: null, @@ -6350,7 +6647,6 @@ const initViewer = async () => { settingsSrc, hasViewerSettings: !!modelSettings, }); - if (!modelSettings) return; const normalizedSettingsSrc = normalizeModelSettingsSrc(settingsSrc) || startupMeta.settingsSrc; startupMeta.settingsSrc = normalizedSettingsSrc; startupMeta.viewerSettings = modelSettings; @@ -6360,10 +6656,17 @@ const initViewer = async () => { ...(entry.meta || {}), settingsSrc: normalizedSettingsSrc, sourceSrc: normalizedSettingsSrc, - artifactSrc: startupMeta.artifactSrc, + artifactSrc: settingsResponse?.artifactSrc || startupMeta.artifactSrc, + artifactType: normalizeType(null, settingsResponse?.artifactSrc || startupMeta.artifactSrc) || startupMeta.artifactType || null, + xktConversion: settingsResponse?.xktConversion || startupMeta.xktConversion || null, + lodGeneration: settingsResponse?.lodGeneration || null, + lods: Array.isArray(settingsResponse?.lods) ? settingsResponse.lods : [], + selectedLod: settingsResponse?.selectedLod || startupMeta.selectedLod || null, viewerSettings: modelSettings, }; + updateModelLodControl(); } + if (!modelSettings) return; applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: true }); applyQueryViewerOverrides(); }; @@ -6734,6 +7037,19 @@ const loadProjectSnapshot = async (project) => { }); updateModelVersionControl(); } + if (modelLodControl && modelLodSelectHost) { + modelLodSelect = createGlassSelect({ + host: modelLodSelectHost, + value: "loading", + options: [{ value: "loading", label: "LOD не сгенерирован" }], + ariaLabel: "Уровень LOD", + onChange: (value) => { + const lod = getEntryLods(getPrimaryModelEntry()).find((item) => getLodKey(item) === value); + if (lod) openModelLod(lod); + }, + }); + updateModelLodControl(); + } if (displayModeControl && displayModeSelectHost) { displayModeSelect = createGlassSelect({ host: displayModeSelectHost, @@ -6841,6 +7157,9 @@ const loadProjectSnapshot = async (project) => { event.target.value = ""; if (file) await uploadModelVersion(file); }); + modelLodGenerateButton?.addEventListener("click", () => { + generateModelLods(); + }); registerFloatingWindowElement("inspector", sidePanelEl); registerFloatingWindowElement("comments", commentsPanel); registerFloatingWindowElement("settings", settingsPanel); diff --git a/frontend/index.html b/frontend/index.html index bdef6b4..b7b9859 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -231,7 +231,7 @@ - +
@@ -310,6 +310,11 @@
+
Режим отображения
@@ -697,6 +702,6 @@ - + diff --git a/package.json b/package.json index f80cdac..3eb3c11 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "test": "echo \"Error: no test specified\" && exit 1", "build": "rollup --config rollup.config.js && rollup --config rollup.dev.config.js && copyfiles -f locales/messages.js ./dist && copyfiles -f xeokit-bim-viewer.css ./dist", "docs": "./node_modules/.bin/esdoc", - "serve": "http-server . -p 8080 ", + "serve": "npm --prefix server start", + "serve:static": "http-server . -p 8080 ", + "serve:frontend": "http-server frontend -p 9001", + "serve:beam": "docker compose -f docker-compose.beam.yml up ndc-beam-viewer nodedc-bim-converter", "changelog": "auto-changelog --commit-limit false --package --template changelog-template.hbs" }, "repository": { diff --git a/server/README.md b/server/README.md index 919a760..04ee960 100644 --- a/server/README.md +++ b/server/README.md @@ -3,9 +3,10 @@ Лёгкий файловый backend без внешних зависимостей. Быстрый старт -- Сервер: `npm --prefix server start` (PORT=8080 по умолчанию). Если порт занят, сервер попробует 8081/8082/8083 автоматически. +- Полный локальный viewer с backend/API: `npm run serve` или `npm --prefix server start` (PORT=8080 по умолчанию). Если порт занят, сервер попробует 8081/8082/8083 автоматически. - Ручной выбор порта: `PORT=8088 npm --prefix server start` -- Клиент отдельно (только статика, без API): `npx http-server frontend -p 9001` и открыть http://localhost:9001 +- Клиент отдельно (только статика, без API): `npm run serve:frontend` или `npx http-server frontend -p 9001` и открыть http://localhost:9001 +- Старый статический корень без backend оставлен как `npm run serve:static`; для проверки upload/comments/share/LOD его не использовать. Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`. diff --git a/server/index.js b/server/index.js index 9ca2b0c..764822d 100644 --- a/server/index.js +++ b/server/index.js @@ -96,6 +96,8 @@ const CONVERTIBLE_MODEL_FORMATS = new Map([ [".stp", "step"] ]); const GLB_DRACO_OPTIMIZABLE_FORMATS = new Set(["glb"]); +const GLB_XKT_CONVERTIBLE_FORMATS = new Set(["glb"]); +const GLB_LOD_SOURCE_FORMATS = new Set(["glb"]); const nowIso = () => new Date().toISOString(); @@ -1281,6 +1283,37 @@ const upsertAssetVersion = async (assetDir, versionRecord) => { return nextManifest; }; +const updateAssetVersionRecord = async (assetDir, versionRecord) => { + const existing = await readAssetManifest(assetDir); + if (!existing || !Array.isArray(existing.versions)) { + return null; + } + const now = nowIso(); + let replaced = false; + const nextVersions = existing.versions.map((version) => { + const sameId = versionRecord.versionId && version?.versionId === versionRecord.versionId; + const sameNumber = versionRecord.version && Number(version?.version) === Number(versionRecord.version); + if (!sameId && !sameNumber) { + return version; + } + replaced = true; + return {...version, ...versionRecord}; + }); + if (!replaced) { + nextVersions.push(versionRecord); + } + nextVersions.sort((first, second) => Number(first.version || 0) - Number(second.version || 0)); + const nextManifest = { + ...existing, + assetId: existing.assetId || versionRecord.assetId, + projectId: existing.projectId || versionRecord.projectId, + updatedAt: now, + versions: nextVersions + }; + await writeAssetManifest(assetDir, nextManifest); + return nextManifest; +}; + const STATIC_ROOTS = [ {prefix: "/uploads/", dir: UPLOADS_DIR}, {prefix: "/data/", dir: DATA_ROOT}, @@ -1425,6 +1458,36 @@ const guessViewerTypeFromSrc = (src, fallback = null) => { const publicUrlForUploadSrc = (req, src) => new URL(`/${src.replace(/^\/+/, "")}`, `${getRequestBaseUrl(req)}/`).toString(); +const publicLodRecords = (req, lods) => { + if (!Array.isArray(lods)) { + return []; + } + return lods + .map((lod) => { + if (!lod || typeof lod !== "object") { + return null; + } + const artifactSrc = normalizeUploadSrcValue(lod.artifactSrc || lod.src || lod.url); + if (!artifactSrc) { + return null; + } + return { + id: stringOrNull(lod.id) || "lod", + label: stringOrNull(lod.label) || stringOrNull(lod.id) || "LOD", + artifactSrc, + src: publicUrlForUploadSrc(req, artifactSrc), + url: publicUrlForUploadSrc(req, artifactSrc), + type: stringOrNull(lod.type) || guessViewerTypeFromSrc(artifactSrc, "gltf"), + ratio: Number.isFinite(Number(lod.ratio)) ? Number(lod.ratio) : null, + error: Number.isFinite(Number(lod.error)) ? Number(lod.error) : null, + size: Number(lod.size) || 0, + sha256: stringOrNull(lod.sha256), + generatedAt: stringOrNull(lod.generatedAt) + }; + }) + .filter(Boolean); +}; + const safeShareToken = (token) => { if (typeof token !== "string") { return null; @@ -1685,8 +1748,13 @@ const publicModelVersionRecord = (req, versionRecord) => { projectId: stringOrNull(versionRecord?.projectId), conversion, downloadUrl: downloadSrc ? publicUrlForUploadSrc(req, downloadSrc) : "", + lodGeneration: versionRecord?.lodGeneration && typeof versionRecord.lodGeneration === "object" + ? versionRecord.lodGeneration + : null, + lods: publicLodRecords(req, versionRecord?.lods), originalFilename: stringOrNull(versionRecord?.originalFilename) || (downloadSrc ? path.basename(downloadSrc) : "model"), previewAvailable, + selectedLod: stringOrNull(versionRecord?.viewerSettings?.viewerState?.lod?.selectedId || versionRecord?.selectedLod), sha256: stringOrNull(versionRecord?.sha256), size: Number(versionRecord?.size) || 0, sourceSrc: sourceSrc || null, @@ -1697,6 +1765,9 @@ const publicModelVersionRecord = (req, versionRecord) => { uploadedAt: stringOrNull(versionRecord?.uploadedAt || versionRecord?.updatedAt), version: Number(versionRecord?.version) || null, versionId: stringOrNull(versionRecord?.versionId), + xktConversion: versionRecord?.xktConversion && typeof versionRecord.xktConversion === "object" + ? versionRecord.xktConversion + : null, viewerUrl }; }; @@ -1912,6 +1983,7 @@ const handleCreateShare = async (req, res) => { assetId: identity.assetId, projectId: identity.projectId, versionId: typeof payload?.versionId === "string" ? payload.versionId : null, + selectedLod: stringOrNull(payload?.selectedLod), createdAt: now, createdBy: session?.user ? { id: session.user.id || null, @@ -2000,6 +2072,7 @@ const handleGetShare = async (req, res, token) => { assetId: share.assetId || null, projectId: share.projectId || null, versionId: share.versionId || versionHistory?.currentVersionId || null, + selectedLod: share.selectedLod || null, versions: versionHistory?.versions || [] }, share: { @@ -2508,7 +2581,11 @@ const findManifestForModelSrc = async (src) => { manifest.sourceSrc, manifest.artifactSrc, manifest.fallbackArtifactSrc, - manifest.metadataSrc + manifest.glbArtifactSrc, + manifest.dracoArtifactSrc, + manifest.xktArtifactSrc, + manifest.metadataSrc, + ...(Array.isArray(manifest.lods) ? manifest.lods.flatMap((lod) => [lod?.artifactSrc, lod?.src, lod?.url]) : []) ].map(normalizeUploadSrcValue).filter(Boolean); if (relatedSrcs.includes(sourceSrc)) { return { @@ -2597,6 +2674,13 @@ const handleRawUpload = async (req, res, url) => { const rawSourceFormat = path.extname(safeName).replace(/^\./, "").toLowerCase(); const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase()); const modelSourceFormat = sourceFormat || rawSourceFormat || "file"; + const xktConversion = !sourceFormat && GLB_XKT_CONVERTIBLE_FORMATS.has(modelSourceFormat) + ? { + status: "queued", + targetFormat: "xkt", + updatedAt: uploadedAt + } + : null; const dracoOptimization = !sourceFormat && GLB_DRACO_OPTIMIZABLE_FORMATS.has(modelSourceFormat) ? { status: "queued", @@ -2630,9 +2714,12 @@ const handleRawUpload = async (req, res, url) => { downloadSrc: src, sourceFormat: modelSourceFormat, status: sourceFormat ? "conversion_required" : "ready", - targetFormat: sourceFormat ? "xkt" : (dracoOptimization ? "gltf" : null), + targetFormat: sourceFormat ? "xkt" : (xktConversion ? "xkt" : (dracoOptimization ? "gltf" : null)), uploadedAt, - message: dracoOptimization ? "Model is ready. Draco optimization queued." : "Model is ready.", + message: xktConversion + ? "Model is ready. XKT conversion queued." + : (dracoOptimization ? "Model is ready. Draco optimization queued." : "Model is ready."), + ...(xktConversion ? {xktConversion} : {}), ...(dracoOptimization ? {dracoOptimization} : {}), ...(sourceDedup || {}) }; @@ -3075,7 +3162,19 @@ const handleConversionStatus = async (res, searchParams) => { sendJSON(res, 200, manifest); }; -const handleGetModelSettings = async (res, searchParams) => { +const buildModelLodPayload = (req, manifestRef, manifest = {}) => ({ + sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc, + artifactSrc: manifest?.artifactSrc || null, + artifactType: stringOrNull(manifest?.artifactType || manifest?.targetFormat) || null, + lodGeneration: manifest?.lodGeneration && typeof manifest.lodGeneration === "object" + ? manifest.lodGeneration + : null, + lods: publicLodRecords(req, manifest?.lods), + selectedLod: stringOrNull(manifest?.viewerSettings?.viewerState?.lod?.selectedId || manifest?.selectedLod), + sourceFormat: stringOrNull(manifest?.sourceFormat) || guessViewerTypeFromSrc(manifest?.sourceSrc || manifestRef.sourceSrc || "", "file") +}); + +const handleGetModelSettings = async (req, res, searchParams) => { const manifestRef = await findManifestForModelSrc(searchParams.get("src")); if (!manifestRef) { sendText(res, 400, "Invalid model path"); @@ -3087,10 +3186,18 @@ const handleGetModelSettings = async (res, searchParams) => { } const manifest = await readJSONFile(manifestRef.manifestPath); + const lodPayload = buildModelLodPayload(req, manifestRef, manifest || {}); sendJSON(res, 200, { sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc, artifactSrc: manifest?.artifactSrc || null, + artifactType: lodPayload.artifactType, fallbackArtifactSrc: manifest?.fallbackArtifactSrc || null, + xktConversion: manifest?.xktConversion && typeof manifest.xktConversion === "object" + ? manifest.xktConversion + : null, + lodGeneration: lodPayload.lodGeneration, + lods: lodPayload.lods, + selectedLod: lodPayload.selectedLod, viewerSettings: manifest?.viewerSettings || null }); }; @@ -3140,6 +3247,9 @@ const handlePutModelSettings = async (req, res, searchParams) => { sourceSrc: nextManifest.sourceSrc, artifactSrc: nextManifest.artifactSrc || null, fallbackArtifactSrc: nextManifest.fallbackArtifactSrc || null, + lodGeneration: nextManifest.lodGeneration || null, + lods: publicLodRecords(req, nextManifest.lods), + selectedLod: stringOrNull(nextManifest.viewerSettings?.viewerState?.lod?.selectedId || nextManifest.selectedLod), viewerSettings: nextManifest.viewerSettings }); } catch (err) { @@ -3148,6 +3258,94 @@ const handlePutModelSettings = async (req, res, searchParams) => { } }; +const handleGetModelLods = async (req, res, searchParams) => { + const manifestRef = await findManifestForModelSrc(searchParams.get("src")); + if (!manifestRef) { + sendText(res, 400, "Invalid model path"); + return; + } + if (!existsSync(manifestRef.modelPath)) { + sendText(res, 404, "Model file not found"); + return; + } + + const manifest = await readJSONFile(manifestRef.manifestPath) || {}; + sendJSON(res, 200, { + ok: true, + ...buildModelLodPayload(req, manifestRef, manifest) + }); +}; + +const handleGenerateModelLods = async (req, res) => { + let payload = {}; + try { + payload = await parseJSONBody(req); + } catch (err) { + sendText(res, 400, err.message || "Invalid JSON"); + return; + } + + const src = payload?.settingsSrc || payload?.sourceSrc || payload?.src || payload?.url || payload?.artifactSrc; + const manifestRef = await findManifestForModelSrc(src); + if (!manifestRef) { + sendText(res, 400, "Invalid model path"); + return; + } + if (!existsSync(manifestRef.modelPath)) { + sendText(res, 404, "Model file not found"); + return; + } + + const existing = await readJSONFile(manifestRef.manifestPath) || {}; + const sourceSrc = normalizeUploadSrcValue(existing.sourceSrc || manifestRef.sourceSrc); + const sourcePath = resolveUploadSrc(sourceSrc); + const sourceFormat = String(existing.sourceFormat || path.extname(sourcePath || "").replace(/^\./, "")).toLowerCase(); + if (!sourcePath || !GLB_LOD_SOURCE_FORMATS.has(sourceFormat)) { + sendJSON(res, 400, { + ok: false, + error: "lod_source_not_supported", + message: "LOD generation is available only for GLB models." + }); + return; + } + + const now = nowIso(); + const requestedBy = getRequestRegistryUser(req); + const currentGeneration = existing.lodGeneration && typeof existing.lodGeneration === "object" + ? existing.lodGeneration + : {}; + const nextManifest = { + ...existing, + sourceSrc, + downloadSrc: existing.downloadSrc || sourceSrc, + sourceFormat: "glb", + status: existing.status || "ready", + lodGeneration: { + ...currentGeneration, + status: "queued", + requestedAt: currentGeneration.requestedAt || now, + updatedAt: now, + requestedBy: requestedBy ? summarizeRegistryUser(requestedBy) : null + }, + updatedAt: now + }; + + try { + await writeJSONFile(manifestRef.manifestPath, nextManifest); + const identity = resolveAssetIdentityFromPayload({...nextManifest, src: sourceSrc}); + if (identity && !identity.legacy) { + await updateAssetVersionRecord(path.join(UPLOADS_DIR, identity.projectId, identity.assetId), nextManifest); + } + sendJSON(res, 202, { + ok: true, + ...buildModelLodPayload(req, manifestRef, nextManifest) + }); + } catch (err) { + console.error("[server] failed to queue model LOD generation", err); + sendText(res, 500, "Failed to queue model LOD generation"); + } +}; + const buildProjectPayload = (body) => { const now = new Date().toISOString(); const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`; @@ -3591,12 +3789,20 @@ const requestHandler = async (req, res) => { return handleGetUploadVersions(req, res, url.searchParams); } + if (req.method === "GET" && url.pathname === "/api/uploads/lods") { + return handleGetModelLods(req, res, url.searchParams); + } + + if (req.method === "POST" && url.pathname === "/api/uploads/lods") { + return handleGenerateModelLods(req, res); + } + if (req.method === "GET" && url.pathname === "/api/conversions/status") { return handleConversionStatus(res, url.searchParams); } if (req.method === "GET" && url.pathname === "/api/model-settings") { - return handleGetModelSettings(res, url.searchParams); + return handleGetModelSettings(req, res, url.searchParams); } if (req.method === "PUT" && url.pathname === "/api/model-settings") {