Add GLB XKT pipeline and LOD controls

This commit is contained in:
CODEX 2026-06-25 16:24:48 +03:00
parent 761cb56fac
commit abf899fefd
7 changed files with 1144 additions and 37 deletions

View File

@ -25,7 +25,7 @@ from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
CONVERTER_NAME = "NodeDcBimConverter" 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() 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"}
@ -37,6 +37,11 @@ REPROCESS_FAILED_ON_VERSION_CHANGE = os.environ.get(
).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_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") 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 { GLB_DRACO_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_GLB_DRACO_ENABLED", "1").lower() not in {
"0", "0",
"false", "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") 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") 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_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"))
PROCESSING_STALE_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_PROCESSING_STALE_SECONDS", "60")) 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( GLB_DRACO_TIMEOUT_SECONDS = float(
os.environ.get("NODEDC_BIM_CONVERTER_GLB_DRACO_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS)) 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))) XCAF_TIMEOUT_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_XCAF_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS)))
CADQUERY_TIMEOUT_SECONDS = float( CADQUERY_TIMEOUT_SECONDS = float(
os.environ.get("NODEDC_BIM_CONVERTER_CADQUERY_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS)) os.environ.get("NODEDC_BIM_CONVERTER_CADQUERY_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS))
) )
STEP_EXTENSIONS = {".step", ".stp"} STEP_EXTENSIONS = {".step", ".stp"}
GLB_EXTENSIONS = {".glb"} 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: def now_iso() -> str:
@ -123,6 +162,26 @@ def draco_optimization_attempt_count(manifest: dict[str, Any]) -> int:
return 0 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: def update_asset_manifest_version(source_path: Path, version_payload: dict[str, Any]) -> None:
asset_path = asset_manifest_path(source_path) asset_path = asset_manifest_path(source_path)
if not asset_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) print(f"[{CONVERTER_NAME}] XKT export {glb_path.name}", flush=True)
with log_path.open("w", encoding="utf-8", errors="replace") as log_file: with log_path.open("w", encoding="utf-8", errors="replace") as log_file:
result = subprocess.run( try:
command, result = subprocess.run(
cwd=str(glb_path.parent), command,
env=env, cwd=str(glb_path.parent),
stdout=log_file, env=env,
stderr=subprocess.STDOUT, stdout=log_file,
text=True, stderr=subprocess.STDOUT,
check=False, 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: if result.returncode != 0:
tail = read_log_tail(log_path) 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") 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: 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: if not GLB_DRACO_ENABLED or not source_path.exists() or not manifest:
return False return False
@ -1331,18 +1421,32 @@ def optimize_glb_one(source_path: Path) -> bool:
source_size = source_path.stat().st_size source_size = source_path.stat().st_size
artifact_size = artifact_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 = { ready_manifest = {
**base_manifest, **base_manifest,
"artifactSha256": calculate_file_sha256(artifact_path), "artifactSha256": calculate_file_sha256(artifact_path),
"artifactSrc": src_from_path(artifact_path), "artifactSrc": preferred_artifact_src,
"artifactType": "gltf", "artifactType": preferred_artifact_type,
"dracoArtifactSrc": draco_artifact_src,
"dracoArtifactType": "gltf",
"fallbackArtifactSrc": source_src, "fallbackArtifactSrc": source_src,
"fallbackArtifactType": "gltf", "fallbackArtifactType": "gltf",
"message": "Model is ready. GLB geometry optimized with Draco.", "message": "Model is ready. GLB geometry optimized with Draco.",
"targetFormat": "gltf", "targetFormat": "xkt" if preserve_xkt_artifact else "gltf",
"dracoOptimization": { "dracoOptimization": {
"status": "ready", "status": "ready",
"attempts": attempt, "attempts": attempt,
"artifactSrc": draco_artifact_src,
"artifactType": "gltf",
"converterName": CONVERTER_NAME, "converterName": CONVERTER_NAME,
"converterVersion": CONVERTER_VERSION, "converterVersion": CONVERTER_VERSION,
"command": command, "command": command,
@ -1358,7 +1462,7 @@ def optimize_glb_one(source_path: Path) -> bool:
write_json_atomic(manifest_file, ready_manifest) write_json_atomic(manifest_file, ready_manifest)
update_asset_manifest_version(source_path, ready_manifest) update_asset_manifest_version(source_path, ready_manifest)
print( 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)", f"({source_size} -> {artifact_size} bytes)",
flush=True, flush=True,
) )
@ -1389,6 +1493,445 @@ def optimize_glb_one(source_path: Path) -> bool:
return False 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: def scan_once() -> int:
if not UPLOADS_DIR.exists(): if not UPLOADS_DIR.exists():
print(f"[{CONVERTER_NAME}] uploads dir does not exist: {UPLOADS_DIR}", flush=True) 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: if suffix in STEP_EXTENSIONS:
did_process = process_one(source_path) did_process = process_one(source_path)
elif suffix in GLB_EXTENSIONS: 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: if did_process:
processed += 1 processed += 1
return processed return processed

View File

@ -1242,6 +1242,32 @@ html.is-embedded-viewer #navCube {
stroke-linejoin: round; 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 { .inspector-color-host {
min-width: 0; min-width: 0;
} }
@ -3242,7 +3268,9 @@ html[data-share-startup] body:not([data-viewer-mode="standard"]) #projectNameSec
body[data-viewer-mode="guest"] #saveProjectSection, body[data-viewer-mode="guest"] #saveProjectSection,
html[data-share-startup] body:not([data-viewer-mode="standard"]) #saveProjectSection, html[data-share-startup] body:not([data-viewer-mode="standard"]) #saveProjectSection,
body[data-viewer-mode="guest"] #modelVersionUploadButton, 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; display: none !important;
} }

View File

@ -123,6 +123,9 @@ const modelVersionSelectHost = document.getElementById("modelVersionSelect");
const modelVersionHistoryButton = document.getElementById("modelVersionHistoryButton"); const modelVersionHistoryButton = document.getElementById("modelVersionHistoryButton");
const modelVersionUploadButton = document.getElementById("modelVersionUploadButton"); const modelVersionUploadButton = document.getElementById("modelVersionUploadButton");
const modelVersionFileInput = document.getElementById("modelVersionFileInput"); 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 navigationAxisControl = document.getElementById("navigationAxisControl");
const navigationAxisSelectHost = document.getElementById("navigationAxisSelect"); const navigationAxisSelectHost = document.getElementById("navigationAxisSelect");
const zoomSpeedControl = document.getElementById("zoomSpeedControl"); const zoomSpeedControl = document.getElementById("zoomSpeedControl");
@ -200,6 +203,7 @@ const AUTH_SESSION_API = "/api/auth/session";
const SHARES_API = "/api/shares"; const SHARES_API = "/api/shares";
const COMMENTS_API = "/api/comments"; const COMMENTS_API = "/api/comments";
const UPLOAD_VERSIONS_API = "/api/uploads/versions"; const UPLOAD_VERSIONS_API = "/api/uploads/versions";
const UPLOAD_LODS_API = "/api/uploads/lods";
const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest"; const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest";
const SHARE_PROMOTION_TIMEOUT_MS = 4500; const SHARE_PROMOTION_TIMEOUT_MS = 4500;
const SHARE_PROMOTION_RETRY_MS = 15000; const SHARE_PROMOTION_RETRY_MS = 15000;
@ -317,6 +321,9 @@ let startupSharePayload = null;
let modelVersionHistory = null; let modelVersionHistory = null;
let modelVersionHistoryLoading = false; let modelVersionHistoryLoading = false;
let modelVersionUploading = false; let modelVersionUploading = false;
let modelLodLoading = false;
let modelLodGenerating = false;
let modelLodPollTimer = null;
let versionHistoryModal = null; let versionHistoryModal = null;
let loadModelAsyncRef = null; let loadModelAsyncRef = null;
let versionModelCounter = 0; let versionModelCounter = 0;
@ -344,6 +351,7 @@ let customDisplayColorControl = null;
let customDisplaySaturationSlider = null; let customDisplaySaturationSlider = null;
let lightingModeSelect = null; let lightingModeSelect = null;
let modelVersionSelect = null; let modelVersionSelect = null;
let modelLodSelect = null;
let navigationAxisSelect = null; let navigationAxisSelect = null;
let zoomSpeedSlider = null; let zoomSpeedSlider = null;
let defaultCameraWorldAxis = null; let defaultCameraWorldAxis = null;
@ -2277,7 +2285,8 @@ const getShareableModelPayload = () => {
if (!entry) { if (!entry) {
return null; 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); const settingsSrc = getModelSettingsSrc(entry);
if (!src) { if (!src) {
return null; return null;
@ -2287,9 +2296,10 @@ const getShareableModelPayload = () => {
assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null, assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null,
projectId: entry.meta?.projectId || entry.meta?.conversion?.projectId || versionIdentity.projectId || null, projectId: entry.meta?.projectId || entry.meta?.conversion?.projectId || versionIdentity.projectId || null,
name: entry.name || entry.meta?.label || src.split("/").pop() || "model", name: entry.name || entry.meta?.label || src.split("/").pop() || "model",
selectedLod: getCurrentLodIdForEntry(entry) || null,
settingsSrc: settingsSrc || src, settingsSrc: settingsSrc || src,
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, versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null,
versions: Array.isArray(entry.meta?.versions) ? entry.meta.versions : [], versions: Array.isArray(entry.meta?.versions) ? entry.meta.versions : [],
}; };
@ -3483,6 +3493,7 @@ const updatePanelsVisibility = () => {
if (saveModelSettingsButton) { if (saveModelSettingsButton) {
saveModelSettingsButton.disabled = isGuestMode() || !hasModels || !hasModelSettingsTarget(); saveModelSettingsButton.disabled = isGuestMode() || !hasModels || !hasModelSettingsTarget();
} }
updateModelLodControl();
if (deleteProjectButton) { if (deleteProjectButton) {
deleteProjectButton.disabled = isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId); deleteProjectButton.disabled = isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId);
} }
@ -4626,7 +4637,7 @@ const formatFetchFailureMessage = (url, res, text, data) => {
const formatApiNonJsonMessage = (url, res, text) => { const formatApiNonJsonMessage = (url, res, text) => {
const status = res?.status ? `HTTP ${res.status}` : "HTTP"; const status = res?.status ? `HTTP ${res.status}` : "HTTP";
if (/^\s*<!doctype html/i.test(text || "")) { if (/^\s*<!doctype html/i.test(text || "")) {
return `BIM API на этом адресе вернул HTML вместо JSON (${status}). На localhost:8080 сейчас нет backend /api/comments.`; return `BIM API на этом адресе вернул HTML вместо JSON (${status}). Открой viewer через backend-профиль npm run serve, не через статический http-server.`;
} }
return `BIM API вернул не-JSON ответ (${status}).`; return `BIM API вернул не-JSON ответ (${status}).`;
}; };
@ -4760,6 +4771,252 @@ const getVersionViewerType = (version) => {
return normalizeType(version?.type, src) || normalizeType(guessTypeFromName(src || version?.originalFilename || "")); 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 getCurrentVersionIdForEntry = (entry = getPrimaryModelEntry()) => {
const meta = entry?.meta || {}; const meta = entry?.meta || {};
return meta.versionId || meta.conversion?.versionId || modelVersionHistory?.currentVersionId || null; return meta.versionId || meta.conversion?.versionId || modelVersionHistory?.currentVersionId || null;
@ -4898,6 +5155,8 @@ const openModelVersion = async (version) => {
sourceSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc, sourceSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc, settingsSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc, artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc,
artifactType: viewerType,
xktConversion: version.xktConversion || null,
viewerSettings: null, viewerSettings: null,
}; };
try { try {
@ -4905,6 +5164,14 @@ const openModelVersion = async (version) => {
if (settings?.viewerSettings) { if (settings?.viewerSettings) {
meta.viewerSettings = 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 previousModelId = previousEntry?.model?.id || null;
const nextModelId = makeVersionModelId(version); const nextModelId = makeVersionModelId(version);
if (previousModelId && transformStore[previousModelId]) { if (previousModelId && transformStore[previousModelId]) {
@ -4926,6 +5193,7 @@ const openModelVersion = async (version) => {
versions: modelVersionHistory?.versions || meta.versions || [], versions: modelVersionHistory?.versions || meta.versions || [],
}; };
updateModelVersionControl(); updateModelVersionControl();
updateModelLodControl();
setStatus(`Открыта версия v${version.version || ""}`.trim()); setStatus(`Открыта версия v${version.version || ""}`.trim());
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -5282,9 +5550,10 @@ const buildProjectSnapshot = async (name) => {
for (const entry of activeModels) { for (const entry of activeModels) {
const model = entry.model; 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 src = typeof entry.src === "string" ? entry.src : "";
const meta = entry.meta || {}; 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 versionIdentity = getModelVersionIdentity(entry);
const baseModel = { const baseModel = {
id: model.id, id: model.id,
@ -5299,7 +5568,12 @@ const buildProjectSnapshot = async (name) => {
metadataPath: meta.metadataPath || null, metadataPath: meta.metadataPath || null,
settingsSrc: getModelSettingsSrc(entry) || null, settingsSrc: getModelSettingsSrc(entry) || null,
sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || 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 visible: model.visible !== false
}; };
@ -5308,12 +5582,12 @@ const buildProjectSnapshot = async (name) => {
throw new Error("Не удалось определить тип модели для сохранения."); throw new Error("Не удалось определить тип модели для сохранения.");
} }
if (isRemoteSrc(src)) { if (isRemoteSrc(preferredSrc)) {
models.push({ ...baseModel, src }); models.push({ ...baseModel, src: preferredSrc });
continue; continue;
} }
const cacheKey = src || `${model.id}-${modelType}`; const cacheKey = preferredSrc || src || `${model.id}-${modelType}`;
if (uploadCache.has(cacheKey)) { if (uploadCache.has(cacheKey)) {
models.push({ ...baseModel, src: uploadCache.get(cacheKey) }); models.push({ ...baseModel, src: uploadCache.get(cacheKey) });
continue; continue;
@ -5429,7 +5703,22 @@ const buildModelViewerSettings = () => ({
customDisplaySaturation: activeCustomDisplaySaturation, customDisplaySaturation: activeCustomDisplaySaturation,
lightingMode: activeLightingMode, lightingMode: activeLightingMode,
navigationAxis: activeNavigationAxis, 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() : {} design: settingsMenu?.getState ? settingsMenu.getState() : {}
}); });
@ -5496,6 +5785,8 @@ const fetchModelSettings = async (src) => {
reportViewerEvent("model-settings-response", { reportViewerEvent("model-settings-response", {
src: data?.sourceSrc || normalized, src: data?.sourceSrc || normalized,
hasViewerSettings: !!data?.viewerSettings, hasViewerSettings: !!data?.viewerSettings,
lodCount: Array.isArray(data?.lods) ? data.lods.length : 0,
lodStatus: data?.lodGeneration?.status || null,
}); });
return data; return data;
}; };
@ -5530,8 +5821,12 @@ const saveCurrentModelSettings = async () => {
...(entry.meta || {}), ...(entry.meta || {}),
settingsSrc: saved?.sourceSrc || settingsSrc, settingsSrc: saved?.sourceSrc || settingsSrc,
sourceSrc: saved?.sourceSrc || entry.meta?.sourceSrc || null, 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 viewerSettings: saved?.viewerSettings || payload.viewerSettings
}; };
updateModelLodControl();
setStatus("Настройки модели сохранены"); setStatus("Настройки модели сохранены");
log(`Настройки модели сохранены: ${entry.name || entry.model?.id || settingsSrc}`, false); log(`Настройки модели сохранены: ${entry.name || entry.model?.id || settingsSrc}`, false);
} catch (err) { } catch (err) {
@ -6222,6 +6517,7 @@ const initViewer = async () => {
updateNavCubeVisibility(); updateNavCubeVisibility();
updatePanelsVisibility(); updatePanelsVisibility();
refreshModelVersionHistory({ silent: true }).catch(() => {}); refreshModelVersionHistory({ silent: true }).catch(() => {});
refreshModelLods({ silent: true }).catch(() => {});
// если есть сохранённые трансформы для модели — применим // если есть сохранённые трансформы для модели — применим
applyStoredTransformsToModel(id); applyStoredTransformsToModel(id);
@ -6338,6 +6634,7 @@ const initViewer = async () => {
assetId: sharedViewer?.assetId || null, assetId: sharedViewer?.assetId || null,
projectId: sharedViewer?.projectId || null, projectId: sharedViewer?.projectId || null,
versionId: sharedViewer?.versionId || null, versionId: sharedViewer?.versionId || null,
selectedLod: sharedViewer?.selectedLod || null,
versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [], versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [],
shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(), shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(),
viewerSettings: null, viewerSettings: null,
@ -6350,7 +6647,6 @@ const initViewer = async () => {
settingsSrc, settingsSrc,
hasViewerSettings: !!modelSettings, hasViewerSettings: !!modelSettings,
}); });
if (!modelSettings) return;
const normalizedSettingsSrc = normalizeModelSettingsSrc(settingsSrc) || startupMeta.settingsSrc; const normalizedSettingsSrc = normalizeModelSettingsSrc(settingsSrc) || startupMeta.settingsSrc;
startupMeta.settingsSrc = normalizedSettingsSrc; startupMeta.settingsSrc = normalizedSettingsSrc;
startupMeta.viewerSettings = modelSettings; startupMeta.viewerSettings = modelSettings;
@ -6360,10 +6656,17 @@ const initViewer = async () => {
...(entry.meta || {}), ...(entry.meta || {}),
settingsSrc: normalizedSettingsSrc, settingsSrc: normalizedSettingsSrc,
sourceSrc: 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, viewerSettings: modelSettings,
}; };
updateModelLodControl();
} }
if (!modelSettings) return;
applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: true }); applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: true });
applyQueryViewerOverrides(); applyQueryViewerOverrides();
}; };
@ -6734,6 +7037,19 @@ const loadProjectSnapshot = async (project) => {
}); });
updateModelVersionControl(); 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) { if (displayModeControl && displayModeSelectHost) {
displayModeSelect = createGlassSelect({ displayModeSelect = createGlassSelect({
host: displayModeSelectHost, host: displayModeSelectHost,
@ -6841,6 +7157,9 @@ const loadProjectSnapshot = async (project) => {
event.target.value = ""; event.target.value = "";
if (file) await uploadModelVersion(file); if (file) await uploadModelVersion(file);
}); });
modelLodGenerateButton?.addEventListener("click", () => {
generateModelLods();
});
registerFloatingWindowElement("inspector", sidePanelEl); registerFloatingWindowElement("inspector", sidePanelEl);
registerFloatingWindowElement("comments", commentsPanel); registerFloatingWindowElement("comments", commentsPanel);
registerFloatingWindowElement("settings", settingsPanel); registerFloatingWindowElement("settings", settingsPanel);

View File

@ -231,7 +231,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/dcViewer.css?v=40"> <link rel="stylesheet" href="/dcViewer.css?v=41">
</head> </head>
<body> <body>
<header> <header>
@ -310,6 +310,11 @@
<div class="inspector-select-host" id="modelVersionSelect"></div> <div class="inspector-select-host" id="modelVersionSelect"></div>
<input id="modelVersionFileInput" class="hidden" type="file" accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl,.step,.stp"> <input id="modelVersionFileInput" class="hidden" type="file" accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl,.step,.stp">
</div> </div>
<div class="inspector-control-row inspector-control-row--full hidden" id="modelLodControl">
<div class="inspector-control-label">Уровни LOD</div>
<button class="primary-btn model-lod-generate-btn" id="modelLodGenerateButton" type="button">Сгенерировать LOD</button>
<div class="inspector-select-host hidden" id="modelLodSelect"></div>
</div>
<div class="inspector-control-row inspector-control-row--half" id="displayModeControl"> <div class="inspector-control-row inspector-control-row--half" id="displayModeControl">
<div class="inspector-control-label">Режим отображения</div> <div class="inspector-control-label">Режим отображения</div>
<div class="inspector-select-host" id="displayModeSelect"></div> <div class="inspector-select-host" id="displayModeSelect"></div>
@ -697,6 +702,6 @@
<input id="fileInput" class="hidden" type="file" <input id="fileInput" class="hidden" type="file"
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl"> accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
<script type="module" src="/dcViewer.js?v=61"></script> <script type="module" src="/dcViewer.js?v=63"></script>
</body> </body>
</html> </html>

View File

@ -14,7 +14,10 @@
"test": "echo \"Error: no test specified\" && exit 1", "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", "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", "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" "changelog": "auto-changelog --commit-limit false --package --template changelog-template.hbs"
}, },
"repository": { "repository": {

View File

@ -3,9 +3,10 @@
Лёгкий файловый backend без внешних зависимостей. Лёгкий файловый 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` - Ручной выбор порта: `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`. Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`.

View File

@ -96,6 +96,8 @@ const CONVERTIBLE_MODEL_FORMATS = new Map([
[".stp", "step"] [".stp", "step"]
]); ]);
const GLB_DRACO_OPTIMIZABLE_FORMATS = new Set(["glb"]); 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(); const nowIso = () => new Date().toISOString();
@ -1281,6 +1283,37 @@ const upsertAssetVersion = async (assetDir, versionRecord) => {
return nextManifest; 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 = [ const STATIC_ROOTS = [
{prefix: "/uploads/", dir: UPLOADS_DIR}, {prefix: "/uploads/", dir: UPLOADS_DIR},
{prefix: "/data/", dir: DATA_ROOT}, {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 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) => { const safeShareToken = (token) => {
if (typeof token !== "string") { if (typeof token !== "string") {
return null; return null;
@ -1685,8 +1748,13 @@ const publicModelVersionRecord = (req, versionRecord) => {
projectId: stringOrNull(versionRecord?.projectId), projectId: stringOrNull(versionRecord?.projectId),
conversion, conversion,
downloadUrl: downloadSrc ? publicUrlForUploadSrc(req, downloadSrc) : "", 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"), originalFilename: stringOrNull(versionRecord?.originalFilename) || (downloadSrc ? path.basename(downloadSrc) : "model"),
previewAvailable, previewAvailable,
selectedLod: stringOrNull(versionRecord?.viewerSettings?.viewerState?.lod?.selectedId || versionRecord?.selectedLod),
sha256: stringOrNull(versionRecord?.sha256), sha256: stringOrNull(versionRecord?.sha256),
size: Number(versionRecord?.size) || 0, size: Number(versionRecord?.size) || 0,
sourceSrc: sourceSrc || null, sourceSrc: sourceSrc || null,
@ -1697,6 +1765,9 @@ const publicModelVersionRecord = (req, versionRecord) => {
uploadedAt: stringOrNull(versionRecord?.uploadedAt || versionRecord?.updatedAt), uploadedAt: stringOrNull(versionRecord?.uploadedAt || versionRecord?.updatedAt),
version: Number(versionRecord?.version) || null, version: Number(versionRecord?.version) || null,
versionId: stringOrNull(versionRecord?.versionId), versionId: stringOrNull(versionRecord?.versionId),
xktConversion: versionRecord?.xktConversion && typeof versionRecord.xktConversion === "object"
? versionRecord.xktConversion
: null,
viewerUrl viewerUrl
}; };
}; };
@ -1912,6 +1983,7 @@ const handleCreateShare = async (req, res) => {
assetId: identity.assetId, assetId: identity.assetId,
projectId: identity.projectId, projectId: identity.projectId,
versionId: typeof payload?.versionId === "string" ? payload.versionId : null, versionId: typeof payload?.versionId === "string" ? payload.versionId : null,
selectedLod: stringOrNull(payload?.selectedLod),
createdAt: now, createdAt: now,
createdBy: session?.user ? { createdBy: session?.user ? {
id: session.user.id || null, id: session.user.id || null,
@ -2000,6 +2072,7 @@ const handleGetShare = async (req, res, token) => {
assetId: share.assetId || null, assetId: share.assetId || null,
projectId: share.projectId || null, projectId: share.projectId || null,
versionId: share.versionId || versionHistory?.currentVersionId || null, versionId: share.versionId || versionHistory?.currentVersionId || null,
selectedLod: share.selectedLod || null,
versions: versionHistory?.versions || [] versions: versionHistory?.versions || []
}, },
share: { share: {
@ -2508,7 +2581,11 @@ const findManifestForModelSrc = async (src) => {
manifest.sourceSrc, manifest.sourceSrc,
manifest.artifactSrc, manifest.artifactSrc,
manifest.fallbackArtifactSrc, 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); ].map(normalizeUploadSrcValue).filter(Boolean);
if (relatedSrcs.includes(sourceSrc)) { if (relatedSrcs.includes(sourceSrc)) {
return { return {
@ -2597,6 +2674,13 @@ const handleRawUpload = async (req, res, url) => {
const rawSourceFormat = path.extname(safeName).replace(/^\./, "").toLowerCase(); const rawSourceFormat = path.extname(safeName).replace(/^\./, "").toLowerCase();
const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase()); const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase());
const modelSourceFormat = sourceFormat || rawSourceFormat || "file"; 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) const dracoOptimization = !sourceFormat && GLB_DRACO_OPTIMIZABLE_FORMATS.has(modelSourceFormat)
? { ? {
status: "queued", status: "queued",
@ -2630,9 +2714,12 @@ const handleRawUpload = async (req, res, url) => {
downloadSrc: src, downloadSrc: src,
sourceFormat: modelSourceFormat, sourceFormat: modelSourceFormat,
status: sourceFormat ? "conversion_required" : "ready", status: sourceFormat ? "conversion_required" : "ready",
targetFormat: sourceFormat ? "xkt" : (dracoOptimization ? "gltf" : null), targetFormat: sourceFormat ? "xkt" : (xktConversion ? "xkt" : (dracoOptimization ? "gltf" : null)),
uploadedAt, 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} : {}), ...(dracoOptimization ? {dracoOptimization} : {}),
...(sourceDedup || {}) ...(sourceDedup || {})
}; };
@ -3075,7 +3162,19 @@ const handleConversionStatus = async (res, searchParams) => {
sendJSON(res, 200, manifest); 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")); const manifestRef = await findManifestForModelSrc(searchParams.get("src"));
if (!manifestRef) { if (!manifestRef) {
sendText(res, 400, "Invalid model path"); sendText(res, 400, "Invalid model path");
@ -3087,10 +3186,18 @@ const handleGetModelSettings = async (res, searchParams) => {
} }
const manifest = await readJSONFile(manifestRef.manifestPath); const manifest = await readJSONFile(manifestRef.manifestPath);
const lodPayload = buildModelLodPayload(req, manifestRef, manifest || {});
sendJSON(res, 200, { sendJSON(res, 200, {
sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc, sourceSrc: manifest?.sourceSrc || manifestRef.sourceSrc,
artifactSrc: manifest?.artifactSrc || null, artifactSrc: manifest?.artifactSrc || null,
artifactType: lodPayload.artifactType,
fallbackArtifactSrc: manifest?.fallbackArtifactSrc || null, 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 viewerSettings: manifest?.viewerSettings || null
}); });
}; };
@ -3140,6 +3247,9 @@ const handlePutModelSettings = async (req, res, searchParams) => {
sourceSrc: nextManifest.sourceSrc, sourceSrc: nextManifest.sourceSrc,
artifactSrc: nextManifest.artifactSrc || null, artifactSrc: nextManifest.artifactSrc || null,
fallbackArtifactSrc: nextManifest.fallbackArtifactSrc || 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 viewerSettings: nextManifest.viewerSettings
}); });
} catch (err) { } 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 buildProjectPayload = (body) => {
const now = new Date().toISOString(); const now = new Date().toISOString();
const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`; 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); 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") { if (req.method === "GET" && url.pathname === "/api/conversions/status") {
return handleConversionStatus(res, url.searchParams); return handleConversionStatus(res, url.searchParams);
} }
if (req.method === "GET" && url.pathname === "/api/model-settings") { 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") { if (req.method === "PUT" && url.pathname === "/api/model-settings") {