Compare commits
No commits in common. "3aace73a1f8259a7ca3017a1e2ef5a0b16aae56b" and "9aaf82862fdcc63a8887eee61ffc2d3aebe13a8b" have entirely different histories.
3aace73a1f
...
9aaf82862f
|
|
@ -1,6 +1,3 @@
|
|||
# .gitignore
|
||||
node_modules
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
server/data/uploads/
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
NODEDC_BIM_CONVERTER_UPLOADS_DIR=/beam/uploads \
|
||||
NODEDC_BIM_CONVERTER_INTERVAL_SECONDS=10
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libglu1-mesa \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . ./
|
||||
|
||||
CMD ["python", "worker.py"]
|
||||
|
|
@ -1 +0,0 @@
|
|||
cadquery==2.7.0
|
||||
|
|
@ -1,527 +0,0 @@
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.Message import Message_ProgressRange
|
||||
from OCP.RWGltf import RWGltf_CafWriter
|
||||
from OCP.STEPCAFControl import STEPCAFControl_Reader
|
||||
from OCP.TColStd import TColStd_IndexedDataMapOfStringString
|
||||
from OCP.TCollection import TCollection_AsciiString, TCollection_ExtendedString
|
||||
from OCP.TDF import TDF_LabelSequence
|
||||
from OCP.TDocStd import TDocStd_Document
|
||||
from OCP.XCAFApp import XCAFApp_Application
|
||||
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
|
||||
|
||||
|
||||
CONVERTER_NAME = "NodeDcBimConverter"
|
||||
CONVERTER_VERSION = "0.3.0"
|
||||
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"}
|
||||
REPROCESS_READY_ON_VERSION_CHANGE = os.environ.get(
|
||||
"NODEDC_BIM_CONVERTER_REPROCESS_READY_ON_VERSION_CHANGE", ""
|
||||
).lower() in {"1", "true", "yes"}
|
||||
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_EXTENSIONS = {".step", ".stp"}
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def src_from_path(path: Path) -> str:
|
||||
return f"uploads/{path.resolve().relative_to(UPLOADS_DIR).as_posix()}"
|
||||
|
||||
|
||||
def manifest_path(source_path: Path) -> Path:
|
||||
return source_path.with_name(f"{source_path.name}.beam.json")
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_name(f"{path.name}.tmp")
|
||||
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def hash_id(value: str) -> str:
|
||||
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def json_safe(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {str(k): json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [json_safe(v) for v in value]
|
||||
return str(value)
|
||||
|
||||
|
||||
def node_to_metadata(node: Any, parent_path: str = "") -> dict[str, Any]:
|
||||
name = node.name or "root"
|
||||
node_path = f"{parent_path}/{name}" if parent_path else name
|
||||
children = [node_to_metadata(child, node_path) for child in node.children]
|
||||
return {
|
||||
"id": hash_id(node_path),
|
||||
"name": name,
|
||||
"path": node_path,
|
||||
"hasShape": bool(node.obj),
|
||||
"metadata": json_safe(node.metadata or {}),
|
||||
"children": children,
|
||||
}
|
||||
|
||||
|
||||
def count_nodes(node: dict[str, Any]) -> int:
|
||||
return 1 + sum(count_nodes(child) for child in node.get("children", []))
|
||||
|
||||
|
||||
def metadata_dict(value: Any) -> dict[str, Any]:
|
||||
safe_value = json_safe(value or {})
|
||||
return safe_value if isinstance(safe_value, dict) else {"value": safe_value}
|
||||
|
||||
|
||||
def get_shape_type(value: Any) -> str | None:
|
||||
try:
|
||||
shape_type = value.ShapeType()
|
||||
return str(shape_type) if shape_type else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def collect_solids(workplane: Any) -> list[Any]:
|
||||
solids: list[Any] = []
|
||||
seen: set[int] = set()
|
||||
|
||||
for value in workplane.vals():
|
||||
candidates = []
|
||||
try:
|
||||
candidates = list(value.Solids())
|
||||
except Exception:
|
||||
candidates = []
|
||||
if not candidates and get_shape_type(value) == "Solid":
|
||||
candidates = [value]
|
||||
for solid in candidates:
|
||||
key = hash(solid)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
solids.append(solid)
|
||||
|
||||
return solids
|
||||
|
||||
|
||||
def unique_assembly_name(parent: Any, requested_name: Any) -> str:
|
||||
base_name = str(requested_name or "component")
|
||||
if base_name not in parent.objects:
|
||||
return base_name
|
||||
|
||||
index = 2
|
||||
while True:
|
||||
candidate = f"{base_name}__{index:03d}"
|
||||
if candidate not in parent.objects:
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
|
||||
def import_step_with_unique_component_names(source_path: Path, cq_module: Any) -> Any:
|
||||
original_add = cq_module.Assembly.add
|
||||
|
||||
def add_with_unique_names(self: Any, arg: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
if isinstance(arg, cq_module.Assembly):
|
||||
positional_name = args[0] if args else None
|
||||
requested_name = kwargs.get("name", positional_name) or arg.name
|
||||
unique_name = unique_assembly_name(self, requested_name)
|
||||
|
||||
if unique_name != requested_name:
|
||||
arg.metadata = {
|
||||
**metadata_dict(arg.metadata),
|
||||
"originalName": str(requested_name),
|
||||
}
|
||||
if args:
|
||||
args = (unique_name, *args[1:])
|
||||
else:
|
||||
kwargs = {**kwargs, "name": unique_name}
|
||||
|
||||
return original_add(self, arg, *args, **kwargs)
|
||||
|
||||
cq_module.Assembly.add = add_with_unique_names
|
||||
try:
|
||||
return cq_module.Assembly.importStep(str(source_path))
|
||||
finally:
|
||||
cq_module.Assembly.add = original_add
|
||||
|
||||
|
||||
def import_step_assembly(source_path: Path) -> tuple[Any, str, str]:
|
||||
import cadquery as cq
|
||||
|
||||
try:
|
||||
return cq.Assembly.importStep(str(source_path)), "assembly", getattr(cq, "__version__", "unknown")
|
||||
except ValueError as exc:
|
||||
error_message = str(exc)
|
||||
if "Unique name is required" in error_message and "already in the assembly" in error_message:
|
||||
return (
|
||||
import_step_with_unique_component_names(source_path, cq),
|
||||
"assembly-unique-names",
|
||||
getattr(cq, "__version__", "unknown"),
|
||||
)
|
||||
if "does not contain an assembly" not in error_message:
|
||||
raise
|
||||
|
||||
step_model = cq.importers.importStep(str(source_path))
|
||||
solids = collect_solids(step_model)
|
||||
assy = cq.Assembly(name=f"{source_path.stem}_root")
|
||||
|
||||
if len(solids) > 1:
|
||||
for index, solid in enumerate(solids, start=1):
|
||||
assy.add(solid, name=f"{source_path.stem}_solid_{index:03d}")
|
||||
return assy, "split-solids", getattr(cq, "__version__", "unknown")
|
||||
|
||||
assy.add(step_model, name=source_path.stem)
|
||||
return assy, "single-shape", getattr(cq, "__version__", "unknown")
|
||||
|
||||
|
||||
def read_glb_json(glb_path: Path) -> tuple[dict[str, Any], list[tuple[bytes, bytes]]]:
|
||||
data = glb_path.read_bytes()
|
||||
if len(data) < 20:
|
||||
raise ValueError("GLB is too small.")
|
||||
|
||||
magic, version, declared_length = struct.unpack("<4sII", data[:12])
|
||||
if magic != b"glTF" or version != 2:
|
||||
raise ValueError("Only binary glTF v2 files are supported.")
|
||||
if declared_length != len(data):
|
||||
raise ValueError("GLB declared length does not match file size.")
|
||||
|
||||
gltf_json: dict[str, Any] | None = None
|
||||
chunks: list[tuple[bytes, bytes]] = []
|
||||
offset = 12
|
||||
while offset < len(data):
|
||||
if offset + 8 > len(data):
|
||||
raise ValueError("Invalid GLB chunk header.")
|
||||
chunk_length, chunk_type = struct.unpack("<I4s", data[offset:offset + 8])
|
||||
offset += 8
|
||||
chunk_data = data[offset:offset + chunk_length]
|
||||
offset += chunk_length
|
||||
if chunk_type == b"JSON":
|
||||
gltf_json = json.loads(chunk_data.decode("utf-8"))
|
||||
else:
|
||||
chunks.append((chunk_type, chunk_data))
|
||||
|
||||
if gltf_json is None:
|
||||
raise ValueError("GLB JSON chunk is missing.")
|
||||
return gltf_json, chunks
|
||||
|
||||
|
||||
def write_glb_json(glb_path: Path, gltf_json: dict[str, Any], chunks: list[tuple[bytes, bytes]]) -> None:
|
||||
json_bytes = json.dumps(gltf_json, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
json_padding = (4 - (len(json_bytes) % 4)) % 4
|
||||
json_chunk = json_bytes + (b" " * json_padding)
|
||||
|
||||
body = struct.pack("<I4s", len(json_chunk), b"JSON") + json_chunk
|
||||
for chunk_type, chunk_data in chunks:
|
||||
chunk_padding = (4 - (len(chunk_data) % 4)) % 4
|
||||
padded_chunk = chunk_data + (b"\x00" * chunk_padding)
|
||||
body += struct.pack("<I4s", len(padded_chunk), chunk_type) + padded_chunk
|
||||
|
||||
glb_path.write_bytes(struct.pack("<4sII", b"glTF", 2, 12 + len(body)) + body)
|
||||
|
||||
|
||||
def normalize_glb_node_names(glb_path: Path) -> tuple[dict[str, Any], int]:
|
||||
gltf_json, chunks = read_glb_json(glb_path)
|
||||
nodes = gltf_json.get("nodes", [])
|
||||
meshes = gltf_json.get("meshes", [])
|
||||
renamed = 0
|
||||
|
||||
for node in nodes:
|
||||
mesh_index = node.get("mesh")
|
||||
if not isinstance(mesh_index, int) or mesh_index < 0 or mesh_index >= len(meshes):
|
||||
continue
|
||||
mesh_name = meshes[mesh_index].get("name")
|
||||
node_name = node.get("name")
|
||||
if mesh_name and (not node_name or str(node_name).startswith("NAUO")):
|
||||
node["name"] = mesh_name
|
||||
renamed += 1
|
||||
|
||||
if renamed:
|
||||
write_glb_json(glb_path, gltf_json, chunks)
|
||||
return gltf_json, renamed
|
||||
|
||||
|
||||
def gltf_node_name(gltf_json: dict[str, Any], node: dict[str, Any], index: int) -> str:
|
||||
name = node.get("name")
|
||||
if name:
|
||||
return str(name)
|
||||
meshes = gltf_json.get("meshes", [])
|
||||
mesh_index = node.get("mesh")
|
||||
if isinstance(mesh_index, int) and 0 <= mesh_index < len(meshes):
|
||||
mesh_name = meshes[mesh_index].get("name")
|
||||
if mesh_name:
|
||||
return str(mesh_name)
|
||||
return f"node_{index}"
|
||||
|
||||
|
||||
def gltf_node_to_metadata(gltf_json: dict[str, Any], node_index: int, parent_path: str = "") -> dict[str, Any]:
|
||||
nodes = gltf_json.get("nodes", [])
|
||||
node = nodes[node_index]
|
||||
name = gltf_node_name(gltf_json, node, node_index)
|
||||
node_path = f"{parent_path}/{name}#{node_index}" if parent_path else f"{name}#{node_index}"
|
||||
children = [
|
||||
gltf_node_to_metadata(gltf_json, child_index, node_path)
|
||||
for child_index in node.get("children", [])
|
||||
if isinstance(child_index, int) and 0 <= child_index < len(nodes)
|
||||
]
|
||||
return {
|
||||
"id": hash_id(node_path),
|
||||
"name": name,
|
||||
"path": node_path,
|
||||
"hasShape": isinstance(node.get("mesh"), int),
|
||||
"metadata": {},
|
||||
"children": children,
|
||||
}
|
||||
|
||||
|
||||
def gltf_component_tree(gltf_json: dict[str, Any], fallback_name: str) -> dict[str, Any]:
|
||||
nodes = gltf_json.get("nodes", [])
|
||||
scenes = gltf_json.get("scenes", [])
|
||||
scene_index = gltf_json.get("scene", 0)
|
||||
scene = scenes[scene_index] if isinstance(scene_index, int) and 0 <= scene_index < len(scenes) else {}
|
||||
root_indexes = [idx for idx in scene.get("nodes", []) if isinstance(idx, int) and 0 <= idx < len(nodes)]
|
||||
|
||||
if len(root_indexes) == 1:
|
||||
return gltf_node_to_metadata(gltf_json, root_indexes[0])
|
||||
|
||||
children = [gltf_node_to_metadata(gltf_json, idx, fallback_name) for idx in root_indexes]
|
||||
return {
|
||||
"id": hash_id(fallback_name),
|
||||
"name": fallback_name,
|
||||
"path": fallback_name,
|
||||
"hasShape": False,
|
||||
"metadata": {},
|
||||
"children": children,
|
||||
}
|
||||
|
||||
|
||||
def convert_step_xcaf_to_glb(source_path: Path, glb_path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
app = XCAFApp_Application.GetApplication_s()
|
||||
doc = TDocStd_Document(TCollection_ExtendedString("MDTV-XCAF"))
|
||||
app.NewDocument(TCollection_ExtendedString("MDTV-XCAF"), doc)
|
||||
|
||||
print(f"[{CONVERTER_NAME}] STEPCAF read {source_path.name}", flush=True)
|
||||
reader = STEPCAFControl_Reader()
|
||||
reader.SetNameMode(True)
|
||||
reader.SetColorMode(True)
|
||||
reader.SetLayerMode(True)
|
||||
read_status = reader.ReadFile(str(source_path))
|
||||
if "RetDone" not in str(read_status):
|
||||
raise ValueError(f"STEPCAF read failed: {read_status}")
|
||||
if not reader.Transfer(doc):
|
||||
raise ValueError("STEPCAF transfer failed.")
|
||||
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
labels = TDF_LabelSequence()
|
||||
shape_tool.GetFreeShapes(labels)
|
||||
if labels.Length() == 0:
|
||||
raise ValueError("STEPCAF document has no free shapes.")
|
||||
|
||||
print(
|
||||
f"[{CONVERTER_NAME}] meshing {source_path.name}: "
|
||||
f"{labels.Length()} free shape(s), deflection={STEP_MESH_LINEAR_DEFLECTION}",
|
||||
flush=True,
|
||||
)
|
||||
for index in range(1, labels.Length() + 1):
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(labels.Value(index))
|
||||
if not shape.IsNull():
|
||||
BRepMesh_IncrementalMesh(
|
||||
shape,
|
||||
STEP_MESH_LINEAR_DEFLECTION,
|
||||
False,
|
||||
STEP_MESH_ANGULAR_DEFLECTION,
|
||||
True,
|
||||
)
|
||||
|
||||
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"[{CONVERTER_NAME}] GLB export {source_path.name}", flush=True)
|
||||
writer = RWGltf_CafWriter(TCollection_AsciiString(str(glb_path)), True)
|
||||
file_info = TColStd_IndexedDataMapOfStringString()
|
||||
if not writer.Perform(doc, file_info, Message_ProgressRange()):
|
||||
raise ValueError("GLB export failed.")
|
||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
raise ValueError("GLB export produced no output.")
|
||||
|
||||
gltf_json, renamed_nodes = normalize_glb_node_names(glb_path)
|
||||
tree = gltf_component_tree(gltf_json, source_path.stem)
|
||||
stats = {
|
||||
"freeShapeCount": labels.Length(),
|
||||
"gltfNodeCount": len(gltf_json.get("nodes", [])),
|
||||
"gltfMeshCount": len(gltf_json.get("meshes", [])),
|
||||
"renamedNodeCount": renamed_nodes,
|
||||
"linearDeflection": STEP_MESH_LINEAR_DEFLECTION,
|
||||
"angularDeflection": STEP_MESH_ANGULAR_DEFLECTION,
|
||||
}
|
||||
return tree, stats
|
||||
|
||||
|
||||
def convert_step_cadquery_to_glb(source_path: Path, glb_path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
assy, import_strategy, cadquery_version = import_step_assembly(source_path)
|
||||
tree = node_to_metadata(assy)
|
||||
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
assy.export(str(glb_path), tolerance=0.1, angularTolerance=0.1)
|
||||
stats = {
|
||||
"fallback": True,
|
||||
"importStrategy": import_strategy,
|
||||
"cadqueryVersion": cadquery_version,
|
||||
}
|
||||
return tree, stats
|
||||
|
||||
|
||||
def convert_step_to_glb(source_path: Path, glb_path: Path, metadata_path: Path) -> dict[str, Any]:
|
||||
import_strategy = "occt-xcaf-gltf"
|
||||
try:
|
||||
tree, stats = convert_step_xcaf_to_glb(source_path, glb_path)
|
||||
except Exception as exc:
|
||||
print(f"[{CONVERTER_NAME}] OCCT/XCAF path failed for {source_path}: {exc}", file=sys.stderr, flush=True)
|
||||
tree, stats = convert_step_cadquery_to_glb(source_path, glb_path)
|
||||
import_strategy = stats.get("importStrategy") or "cadquery"
|
||||
|
||||
metadata = {
|
||||
"source": src_from_path(source_path),
|
||||
"artifact": src_from_path(glb_path),
|
||||
"format": "step",
|
||||
"targetFormat": "glb",
|
||||
"importStrategy": import_strategy,
|
||||
"componentTree": tree,
|
||||
"componentCount": count_nodes(tree),
|
||||
"generatedAt": now_iso(),
|
||||
"converter": {
|
||||
"name": CONVERTER_NAME,
|
||||
"version": CONVERTER_VERSION,
|
||||
"engine": "occt-xcaf" if import_strategy == "occt-xcaf-gltf" else "cadquery",
|
||||
"cadqueryVersion": stats.get("cadqueryVersion"),
|
||||
},
|
||||
"stats": stats,
|
||||
}
|
||||
write_json_atomic(metadata_path, metadata)
|
||||
return metadata
|
||||
|
||||
|
||||
def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path) -> bool:
|
||||
status = manifest.get("status")
|
||||
if status == "ready" and glb_path.exists():
|
||||
return REPROCESS_READY_ON_VERSION_CHANGE and manifest.get("converterVersion") != CONVERTER_VERSION
|
||||
if status == "processing":
|
||||
updated_at = 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() < 300:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return source_path.exists()
|
||||
|
||||
|
||||
def process_one(source_path: Path) -> bool:
|
||||
manifest_file = manifest_path(source_path)
|
||||
stem = source_path.stem
|
||||
glb_path = source_path.with_name(f"{stem}.glb")
|
||||
metadata_path = source_path.with_name(f"{stem}.metadata.json")
|
||||
manifest = read_json(manifest_file)
|
||||
|
||||
if not should_process(source_path, manifest, glb_path):
|
||||
return False
|
||||
|
||||
base_manifest = {
|
||||
"createdAt": manifest.get("createdAt") or now_iso(),
|
||||
"componentTreeRequired": True,
|
||||
"converterName": CONVERTER_NAME,
|
||||
"converterVersion": CONVERTER_VERSION,
|
||||
"sourceFormat": "step",
|
||||
"sourceSrc": src_from_path(source_path),
|
||||
"targetFormat": "glb",
|
||||
}
|
||||
|
||||
write_json_atomic(
|
||||
manifest_file,
|
||||
{
|
||||
**base_manifest,
|
||||
"message": "Preparing GLB and component tree.",
|
||||
"status": "processing",
|
||||
"updatedAt": now_iso(),
|
||||
},
|
||||
)
|
||||
print(f"[{CONVERTER_NAME}] converting {source_path}", flush=True)
|
||||
|
||||
try:
|
||||
metadata = convert_step_to_glb(source_path, glb_path, metadata_path)
|
||||
write_json_atomic(
|
||||
manifest_file,
|
||||
{
|
||||
**base_manifest,
|
||||
"artifactSrc": src_from_path(glb_path),
|
||||
"artifactType": "gltf",
|
||||
"componentCount": metadata.get("componentCount"),
|
||||
"metadataSrc": src_from_path(metadata_path),
|
||||
"message": "GLB and component tree are ready.",
|
||||
"status": "ready",
|
||||
"updatedAt": now_iso(),
|
||||
},
|
||||
)
|
||||
print(f"[{CONVERTER_NAME}] ready {glb_path}", flush=True)
|
||||
return True
|
||||
except Exception as exc:
|
||||
write_json_atomic(
|
||||
manifest_file,
|
||||
{
|
||||
**base_manifest,
|
||||
"error": str(exc),
|
||||
"message": "Failed to prepare GLB and component tree.",
|
||||
"status": "failed",
|
||||
"updatedAt": now_iso(),
|
||||
},
|
||||
)
|
||||
print(f"[{CONVERTER_NAME}] 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)
|
||||
return 0
|
||||
|
||||
processed = 0
|
||||
for source_path in sorted(UPLOADS_DIR.rglob("*")):
|
||||
if not source_path.is_file():
|
||||
continue
|
||||
if source_path.suffix.lower() not in STEP_EXTENSIONS:
|
||||
continue
|
||||
if process_one(source_path):
|
||||
processed += 1
|
||||
return processed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"[{CONVERTER_NAME}] watching {UPLOADS_DIR}", flush=True)
|
||||
while True:
|
||||
scan_once()
|
||||
if PROCESS_ONCE:
|
||||
return
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
services:
|
||||
ndc-beam-viewer:
|
||||
image: node:22-alpine
|
||||
working_dir: /beam/server
|
||||
command: node index.js
|
||||
environment:
|
||||
PORT: "8080"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./:/beam
|
||||
|
||||
nodedc-bim-converter:
|
||||
build:
|
||||
context: ./converter
|
||||
image: nodedc/bim-converter:local
|
||||
container_name: NodeDcBimConverter
|
||||
platform: linux/amd64
|
||||
environment:
|
||||
NODEDC_BIM_CONVERTER_UPLOADS_DIR: /beam/uploads
|
||||
NODEDC_BIM_CONVERTER_INTERVAL_SECONDS: "10"
|
||||
volumes:
|
||||
- ./server/data/uploads:/beam/uploads
|
||||
|
|
@ -73,21 +73,6 @@ body {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
.viewer-wrapper,
|
||||
#viewerCanvas {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#viewerCanvas:focus,
|
||||
#viewerCanvas:focus-visible,
|
||||
.viewer-wrapper:focus,
|
||||
.viewer-wrapper:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
header {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
|
|
@ -591,52 +576,6 @@ header {
|
|||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.display-mode-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.display-mode-title {
|
||||
margin-bottom: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.display-mode-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.display-mode-buttons button {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--modal-border);
|
||||
border-radius: 9px;
|
||||
background: var(--modal-focus);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
outline: none;
|
||||
padding: 8px 6px;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.display-mode-buttons button:hover,
|
||||
.display-mode-buttons button.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--modal-focus));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.display-mode-buttons button:focus,
|
||||
.display-mode-buttons button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.examples-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
|
|
|||
|
|
@ -35,8 +35,6 @@ const settingsButton = document.querySelector('[data-action="settings"]');
|
|||
const templatesButton = document.querySelector('[data-action="templates"]');
|
||||
const projectionToggle = document.querySelector('[data-action="projection"]');
|
||||
const treeContainer = document.getElementById("treeContainer");
|
||||
const displayModeControl = document.getElementById("displayModeControl");
|
||||
const displayModeButtons = document.querySelectorAll("[data-display-mode]");
|
||||
const logList = document.getElementById("logList");
|
||||
const loaderEl = document.getElementById("loader");
|
||||
const navCubeCanvas = document.getElementById("navCube");
|
||||
|
|
@ -164,7 +162,6 @@ let gizmo = null;
|
|||
const transformStore = {}; // modelId -> objectId -> {position, rotation, scale}
|
||||
const modelTypes = {}; // modelId -> normalized type
|
||||
const sceneModels = {}; // modelId -> SceneModel
|
||||
let activeDisplayMode = "source";
|
||||
let currentSelection = null; // { modelId, objectId }
|
||||
const customTrees = new Map(); // modelId -> element
|
||||
let navCubePlugin = null;
|
||||
|
|
@ -232,52 +229,6 @@ const updateProjectionButton = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const setDisplayModeButtonState = () => {
|
||||
displayModeButtons.forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.displayMode === activeDisplayMode);
|
||||
});
|
||||
};
|
||||
|
||||
const getDisplayModeColorize = (mode) => {
|
||||
switch (mode) {
|
||||
case "white":
|
||||
return [1, 1, 1];
|
||||
case "contrast":
|
||||
return [0.78, 0.82, 1];
|
||||
case "source":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const applyDisplayModeToModel = (modelId) => {
|
||||
const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
|
||||
if (!model) return;
|
||||
const colorize = getDisplayModeColorize(activeDisplayMode);
|
||||
const objects = Object.values(model.objects || {});
|
||||
const targets = objects.length ? objects : [model];
|
||||
|
||||
targets.forEach((entity) => {
|
||||
if (!entity) return;
|
||||
try {
|
||||
entity.colorize = colorize;
|
||||
entity.opacity = 1;
|
||||
if (activeDisplayMode !== "source") {
|
||||
entity.edges = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("display mode update failed", err);
|
||||
}
|
||||
});
|
||||
|
||||
viewer?.scene?.glRedraw?.();
|
||||
};
|
||||
|
||||
const applyDisplayMode = () => {
|
||||
setDisplayModeButtonState();
|
||||
Object.keys(sceneModels).forEach((modelId) => applyDisplayModeToModel(modelId));
|
||||
};
|
||||
|
||||
const toggleProjection = () => {
|
||||
if (!viewer?.camera) return;
|
||||
const cam = viewer.camera;
|
||||
|
|
@ -707,23 +658,12 @@ const applyGlobalTarget = () => {
|
|||
});
|
||||
});
|
||||
|
||||
const scheduleCameraRearm = () => {
|
||||
window.addEventListener("focus", () => {
|
||||
rearmCameraControl();
|
||||
window.setTimeout(rearmCameraControl, 60);
|
||||
window.setTimeout(rearmCameraControl, 220);
|
||||
};
|
||||
});
|
||||
|
||||
window.addEventListener("blur", scheduleCameraRearm);
|
||||
window.addEventListener("focus", scheduleCameraRearm);
|
||||
window.addEventListener("pageshow", scheduleCameraRearm);
|
||||
window.addEventListener("pointerup", scheduleCameraRearm, true);
|
||||
window.addEventListener("mouseup", scheduleCameraRearm, true);
|
||||
window.addEventListener("touchend", scheduleCameraRearm, true);
|
||||
window.addEventListener("touchcancel", scheduleCameraRearm, true);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
scheduleCameraRearm();
|
||||
}
|
||||
window.addEventListener("pointerup", () => {
|
||||
rearmCameraControl();
|
||||
});
|
||||
|
||||
const makeTranslationMat = (x, y, z) => {
|
||||
|
|
@ -1889,7 +1829,6 @@ const buildProjectSnapshot = async (name) => {
|
|||
transparent: !!toggleTransparent?.checked,
|
||||
edges: !!toggleEdges?.checked,
|
||||
addMode: !!addMode?.checked,
|
||||
displayMode: activeDisplayMode,
|
||||
selection: {
|
||||
ids: lastSelection.ids || [],
|
||||
modelId: lastSelection.modelId || null,
|
||||
|
|
@ -1950,10 +1889,6 @@ const applyViewerState = (state) => {
|
|||
if (typeof state.addMode === "boolean" && addMode) {
|
||||
addMode.checked = state.addMode;
|
||||
}
|
||||
if (typeof state.displayMode === "string") {
|
||||
activeDisplayMode = state.displayMode;
|
||||
applyDisplayMode();
|
||||
}
|
||||
if (state.transforms && typeof state.transforms === "object") {
|
||||
Object.keys(transformStore).forEach((k) => delete transformStore[k]);
|
||||
Object.assign(transformStore, safeClone(state.transforms));
|
||||
|
|
@ -2132,8 +2067,8 @@ const initViewer = async () => {
|
|||
if (mainCanvas) {
|
||||
mainCanvas.addEventListener("pointerdown", () => {
|
||||
focusCanvasAndControls();
|
||||
}, true);
|
||||
mainCanvas.addEventListener("pointerenter", scheduleCameraRearm);
|
||||
resetCameraControl();
|
||||
});
|
||||
}
|
||||
|
||||
rebuildNavCube(sdk);
|
||||
|
|
@ -2361,7 +2296,7 @@ const initViewer = async () => {
|
|||
|
||||
const loadModel = (options) => {
|
||||
try {
|
||||
const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options;
|
||||
const { type, url, name = "", replace = true, id: forcedId, meta } = options;
|
||||
const normalizedType = normalizeType(type, typeof url === "string" ? url : "");
|
||||
const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : ""));
|
||||
if (!inferredType || !acceptByType[inferredType]) {
|
||||
|
|
@ -2402,11 +2337,7 @@ const initViewer = async () => {
|
|||
blobUrls.push(blobUrl);
|
||||
srcToLoad = blobUrl;
|
||||
}
|
||||
const loadOpts = {
|
||||
id,
|
||||
src: srcToLoad,
|
||||
edges: edges ?? (inferredType === "gltf" ? false : toggleEdges.checked),
|
||||
};
|
||||
const loadOpts = { id, src: srcToLoad, edges: toggleEdges.checked };
|
||||
if (inferredType === "gltf") loadOpts.autoMetaModel = true;
|
||||
if (inferredType === "bim" || inferredType === "las") loadOpts.rotation = [-90, 0, 0];
|
||||
if (inferredType === "stl") loadOpts.smoothNormals = true;
|
||||
|
|
@ -2424,7 +2355,6 @@ const initViewer = async () => {
|
|||
setStatus(`Загружено: ${name || inferredType}`);
|
||||
dropZone.classList.add("hidden");
|
||||
setLoading(false);
|
||||
applyDisplayModeToModel(id);
|
||||
updateNavCubeVisibility();
|
||||
updatePanelsVisibility();
|
||||
rearmCameraControl();
|
||||
|
|
@ -2484,35 +2414,6 @@ const initViewer = async () => {
|
|||
model.on("error", (err) => reject(new Error(err?.message || err)));
|
||||
});
|
||||
|
||||
const loadStartupModelFromQuery = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const src = params.get("url") || params.get("src");
|
||||
if (!src) return;
|
||||
|
||||
const name = params.get("name") || src.split("/").pop() || "model";
|
||||
const type = normalizeType(params.get("type"), src) || normalizeType(guessTypeFromName(name));
|
||||
if (!type) {
|
||||
showError("Не удалось определить формат модели из URL");
|
||||
return;
|
||||
}
|
||||
if (params.has("displayMode")) {
|
||||
activeDisplayMode = params.get("displayMode") || "source";
|
||||
setDisplayModeButtonState();
|
||||
}
|
||||
|
||||
loadModel({
|
||||
type,
|
||||
url: src,
|
||||
name,
|
||||
replace: params.get("replace") !== "false",
|
||||
edges: params.has("edges") ? params.get("edges") !== "false" : undefined,
|
||||
meta: {
|
||||
label: name,
|
||||
source: "query",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const loadProjectSnapshot = async (project) => {
|
||||
if (!project) {
|
||||
throw new Error("Нет данных проекта");
|
||||
|
|
@ -2797,16 +2698,6 @@ const loadProjectSnapshot = async (project) => {
|
|||
rebuildNavCube(sdk);
|
||||
applyHighlightTheme();
|
||||
applyMeasureTheme();
|
||||
if (displayModeControl) {
|
||||
displayModeButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
activeDisplayMode = button.dataset.displayMode || "source";
|
||||
applyDisplayMode();
|
||||
focusCanvasAndControls();
|
||||
});
|
||||
});
|
||||
setDisplayModeButtonState();
|
||||
}
|
||||
loadProjectsList().catch(() => {
|
||||
templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
|
||||
});
|
||||
|
|
@ -3185,8 +3076,6 @@ const loadProjectSnapshot = async (project) => {
|
|||
onClose: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
loadStartupModelFromQuery();
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
|
|
|||
|
|
@ -87,7 +87,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=4">
|
||||
<link rel="stylesheet" href="./dcViewer.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
|
|
@ -128,14 +128,6 @@
|
|||
<div class="panel-section">
|
||||
<h3>Инспектор</h3>
|
||||
<div id="treeContainer" class="tree"></div>
|
||||
<div class="display-mode-panel" id="displayModeControl">
|
||||
<div class="display-mode-title">Режим отображения</div>
|
||||
<div class="display-mode-buttons">
|
||||
<button type="button" data-display-mode="source" class="active">Исходный</button>
|
||||
<button type="button" data-display-mode="white">Белый</button>
|
||||
<button type="button" data-display-mode="contrast">Контраст</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-section" id="projectNameSection">
|
||||
<h3>Название проекта</h3>
|
||||
|
|
@ -231,6 +223,6 @@
|
|||
<input id="fileInput" class="hidden" type="file"
|
||||
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
|
||||
|
||||
<script type="module" src="./dcViewer.js?v=4"></script>
|
||||
<script type="module" src="./dcViewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -15,13 +15,5 @@ API:
|
|||
- `GET /api/projects[?type=project|template]` — укороченный список из `index.json`.
|
||||
- `GET /api/projects/:id` — полный JSON проекта.
|
||||
- `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс.
|
||||
- `POST /api/uploads?filename=<name>&projectId=<id>` — сохранить оригинальный файл модели в `server/data/uploads`.
|
||||
- `GET /api/conversions/status?src=<uploads/...>` — получить статус подготовки viewer-артефакта.
|
||||
Для `.step/.stp` ответ помечается `conversion.status=conversion_required`: оригинал уже доступен для скачивания,
|
||||
а preview должен появиться после работы `NodeDcBimConverter`, который готовит GLB и metadata/tree.
|
||||
|
||||
Локальный Beam compose:
|
||||
- `docker compose -f docker-compose.beam.yml up ndc-beam-viewer`
|
||||
- `docker compose -f docker-compose.beam.yml up nodedc-bim-converter`
|
||||
|
||||
Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга.
|
||||
|
|
|
|||
116
server/index.js
116
server/index.js
|
|
@ -34,19 +34,10 @@ const MIME_TYPES = {
|
|||
".las": "application/octet-stream",
|
||||
".laz": "application/octet-stream",
|
||||
".bim": "application/octet-stream",
|
||||
".step": "application/octet-stream",
|
||||
".stp": "application/octet-stream",
|
||||
".wasm": "application/wasm",
|
||||
".txt": "text/plain"
|
||||
};
|
||||
|
||||
const CONVERTIBLE_MODEL_FORMATS = new Map([
|
||||
[".step", "step"],
|
||||
[".stp", "step"]
|
||||
]);
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const ensureStorage = async () => {
|
||||
await fs.mkdir(DATA_DIR, {recursive: true});
|
||||
await fs.mkdir(UPLOADS_DIR, {recursive: true});
|
||||
|
|
@ -195,54 +186,6 @@ const sanitizeFilename = (name, fallback) => {
|
|||
return safe || fallback;
|
||||
};
|
||||
|
||||
const manifestPathForSource = (sourcePath) => path.join(path.dirname(sourcePath), `${path.basename(sourcePath)}.beam.json`);
|
||||
|
||||
const srcFromUploadPath = (uploadPath) => {
|
||||
const relative = path.relative(UPLOADS_DIR, uploadPath).replace(/\\/g, "/");
|
||||
return path.join("uploads", relative).replace(/\\/g, "/");
|
||||
};
|
||||
|
||||
const resolveUploadSrc = (src) => {
|
||||
if (!src || typeof src !== "string") {
|
||||
return null;
|
||||
}
|
||||
let value = src.trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
const parsed = new URL(value);
|
||||
value = parsed.pathname;
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
value = value.replace(/^\/+/, "");
|
||||
if (!value.startsWith("uploads/")) {
|
||||
return null;
|
||||
}
|
||||
const relative = value.slice("uploads/".length);
|
||||
const resolved = path.resolve(UPLOADS_DIR, relative);
|
||||
if (!resolved.startsWith(UPLOADS_DIR)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const readJSONFile = async (filePath) => {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeJSONFile = async (filePath, payload) => {
|
||||
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
|
||||
};
|
||||
|
||||
const processUploads = async (project) => {
|
||||
if (!Array.isArray(project.models)) {
|
||||
return;
|
||||
|
|
@ -289,64 +232,9 @@ const handleRawUpload = async (req, res, url) => {
|
|||
return;
|
||||
}
|
||||
const src = path.join("uploads", uploadId, safeName).replace(/\\/g, "/");
|
||||
const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(safeName).toLowerCase());
|
||||
if (sourceFormat) {
|
||||
const conversion = {
|
||||
componentTreeRequired: true,
|
||||
message: "Original STEP uploaded. GLB/component tree is waiting for NodeDcBimConverter.",
|
||||
sourceFormat,
|
||||
sourceSrc: src,
|
||||
status: "conversion_required",
|
||||
targetFormat: "glb",
|
||||
updatedAt: nowIso()
|
||||
};
|
||||
await writeJSONFile(manifestPathForSource(targetPath), conversion);
|
||||
sendJSON(res, 201, {
|
||||
src,
|
||||
conversion
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJSON(res, 201, {src});
|
||||
};
|
||||
|
||||
const handleConversionStatus = async (res, searchParams) => {
|
||||
const sourcePath = resolveUploadSrc(searchParams.get("src"));
|
||||
if (!sourcePath) {
|
||||
sendText(res, 400, "Invalid source path");
|
||||
return;
|
||||
}
|
||||
if (!existsSync(sourcePath)) {
|
||||
sendText(res, 404, "Source file not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceFormat = CONVERTIBLE_MODEL_FORMATS.get(path.extname(sourcePath).toLowerCase());
|
||||
if (!sourceFormat) {
|
||||
sendJSON(res, 200, {
|
||||
sourceSrc: srcFromUploadPath(sourcePath),
|
||||
status: "ready"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = await readJSONFile(manifestPathForSource(sourcePath));
|
||||
if (!manifest) {
|
||||
sendJSON(res, 200, {
|
||||
componentTreeRequired: true,
|
||||
message: "GLB/component tree is waiting for NodeDcBimConverter.",
|
||||
sourceFormat,
|
||||
sourceSrc: srcFromUploadPath(sourcePath),
|
||||
status: "conversion_required",
|
||||
targetFormat: "glb",
|
||||
updatedAt: nowIso()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJSON(res, 200, manifest);
|
||||
};
|
||||
|
||||
const buildProjectPayload = (body) => {
|
||||
const now = new Date().toISOString();
|
||||
const id = `proj_${crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)}`;
|
||||
|
|
@ -630,10 +518,6 @@ const requestHandler = async (req, res) => {
|
|||
return handleRawUpload(req, res, url);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/conversions/status") {
|
||||
return handleConversionStatus(res, url.searchParams);
|
||||
}
|
||||
|
||||
await serveStatic(req, res, url);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue