Add GLB XKT pipeline and LOD controls
This commit is contained in:
parent
761cb56fac
commit
abf899fefd
|
|
@ -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,6 +799,7 @@ 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:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=str(glb_path.parent),
|
||||
|
|
@ -747,8 +807,15 @@ def convert_glb_to_xkt(glb_path: Path, xkt_path: Path) -> dict[str, Any]:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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*<!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}).`;
|
||||
};
|
||||
|
|
@ -4760,6 +4771,252 @@ const getVersionViewerType = (version) => {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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 rel="stylesheet" href="/dcViewer.css?v=40">
|
||||
<link rel="stylesheet" href="/dcViewer.css?v=41">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
|
|
@ -310,6 +310,11 @@
|
|||
<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">
|
||||
</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-label">Режим отображения</div>
|
||||
<div class="inspector-select-host" id="displayModeSelect"></div>
|
||||
|
|
@ -697,6 +702,6 @@
|
|||
<input id="fileInput" class="hidden" type="file"
|
||||
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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
216
server/index.js
216
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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue