Compare commits

...

53 Commits

Author SHA1 Message Date
CODEX 249d46c7c7 Sync BIM login state and stabilize zoom 2026-06-23 19:28:47 +03:00
CODEX ce34ecdd4d Promote BIM share on Launcher login event 2026-06-23 18:57:36 +03:00
CODEX 168b535953 Sync BIM controls with live Launcher session 2026-06-23 18:32:04 +03:00
CODEX ffa60e4a22 Validate BIM sessions against Launcher 2026-06-23 16:19:47 +03:00
CODEX 66fc4960bf Sync BIM session logout with Hub 2026-06-23 14:48:37 +03:00
CODEX 48400c7d8c Fix authenticated BIM share promotion 2026-06-23 14:08:12 +03:00
CODEX 33d2f03624 Promote shared BIM links through launcher 2026-06-23 11:54:31 +03:00
CODEX bf34f35d6c Add BIM model version controls 2026-06-23 11:32:49 +03:00
CODEX 09492a5fdc Promote authenticated BIM share viewers 2026-06-23 00:16:06 +03:00
CODEX f680d6bad2 Polish BIM shared viewer controls 2026-06-22 23:05:15 +03:00
CODEX fcfa59ec98 Update BIM toolbar fixture settings 2026-06-22 21:10:43 +03:00
CODEX 1a6fa9e898 Fix BIM share asset paths 2026-06-22 20:19:05 +03:00
CODEX 99ee210ffc Configure BIM Synology deployment 2026-06-22 13:09:23 +03:00
CODEX 4be23070a1 Refine BIM comment modal constraints 2026-06-20 23:34:41 +03:00
CODEX eee3eecde3 Layer BIM toolbar above workspace windows 2026-06-20 23:15:08 +03:00
CODEX f6ed7d18e0 Refine BIM toolbar dock magnification 2026-06-20 23:06:11 +03:00
CODEX 42629cb193 Update BIM local project data fixtures 2026-06-20 21:56:16 +03:00
CODEX ad3a4f3877 Refine BIM glass theming and panel scroll layout 2026-06-20 21:55:23 +03:00
CODEX 3584359833 Apply BIM glass window material 2026-06-20 21:17:16 +03:00
CODEX 778a56ea39 Refine BIM comment modal glass and resize 2026-06-20 20:04:59 +03:00
CODEX 7d65070f59 Refine BIM comment panel interactions 2026-06-20 19:40:32 +03:00
CODEX b7fa6b5fbd Detect non JSON BIM API responses 2026-06-20 19:08:22 +03:00
CODEX 2aab1bc9b4 Show BIM comment API failures 2026-06-20 18:58:39 +03:00
CODEX 2b89977083 Fix BIM comment save controls 2026-06-20 18:43:49 +03:00
CODEX 0556cc4ae3 Match BIM comment dropdown styling 2026-06-20 18:27:01 +03:00
CODEX b4d66a92c5 Fix BIM comment save flow 2026-06-20 18:13:44 +03:00
CODEX 405113fa4f Refine BIM comment modal controls 2026-06-20 17:40:15 +03:00
CODEX d70f6e0886 Align BIM comments with OPS detail card 2026-06-20 17:21:07 +03:00
CODEX 684f85b382 Add BIM model comments 2026-06-20 16:58:24 +03:00
CODEX c248217976 feat(converter): add cadquery step fallback 2026-06-20 12:56:30 +03:00
CODEX 5b5d8f1c3c Polish BIM share modal controls 2026-06-18 22:45:33 +03:00
CODEX e6bb3f1761 Normalize section popup button icons 2026-06-18 22:29:28 +03:00
CODEX f28de867c7 Tune section toolbar icons and gizmo thickness 2026-06-18 22:21:14 +03:00
CODEX 84b28ba12c Refine BIM section controls and styling 2026-06-18 22:09:55 +03:00
CODEX e343412468 Add BIM section plane tool 2026-06-18 21:53:18 +03:00
CODEX 4638acc04b Integrate BIM viewer with NodeDC auth and sharing 2026-06-18 21:42:54 +03:00
CODEX ef5b0a42a6 feat: standardize Beam viewer environment settings 2026-06-05 17:04:11 +03:00
CODEX be2294d3fa feat: align Beam settings controls 2026-06-05 15:35:54 +03:00
CODEX 68e2160ac5 feat: add collapsible Beam inspector 2026-06-05 15:09:29 +03:00
CODEX 9e61480ad1 style: refine Beam tree visibility toggles 2026-06-05 13:23:54 +03:00
CODEX 7e4b91b2da chore: standardize Beam viewer design settings 2026-06-05 12:47:01 +03:00
CODEX 9dadf6f838 beam: remove clay color controls 2026-06-05 11:09:30 +03:00
CODEX 4ecebd5e79 beam: stabilize startup model loading 2026-06-05 01:58:06 +03:00
CODEX 6e1f5a7f08 beam: add clay color control 2026-06-05 00:47:33 +03:00
CODEX bf397ff191 beam: restore point cloud source colors 2026-06-05 00:22:28 +03:00
CODEX ff994e705d beam: avoid point cloud highlight masking 2026-06-05 00:03:31 +03:00
CODEX 0a09296022 beam: preserve point cloud source colors 2026-06-04 23:52:58 +03:00
CODEX bb30202b9b beam: restore camera panning controls 2026-06-04 23:43:57 +03:00
CODEX 43accba61c beam: align zoom slider with engine controls 2026-06-04 23:33:28 +03:00
CODEX 6d65b2ba41 beam: add zoom speed control 2026-06-04 23:22:00 +03:00
CODEX 1569ed9c23 beam: tune camera zoom precision 2026-06-04 23:10:26 +03:00
CODEX f40a6ce38d beam: lower navigation cube 2026-06-04 23:01:32 +03:00
CODEX a95a7d5c38 beam: add per-model viewer settings 2026-06-04 22:36:15 +03:00
33 changed files with 12584 additions and 656 deletions

14
.env.synology.example Normal file
View File

@ -0,0 +1,14 @@
# Synology/NODE.DC production BIM Viewer environment.
# Copy to the live BIM deployment env and fill secrets from platform .env.synology.
NODEDC_BIM_HOST_PORT=18100
NODEDC_BIM_SERVICE_SLUG=bim-viewer
NODEDC_BIM_AUTH_REQUIRED=1
NODEDC_BIM_PUBLIC_URL=https://bim.nodedc.tech
NODEDC_BIM_ALLOWED_ORIGINS=https://hub.nodedc.ru,https://ops.nodedc.ru,https://bim.nodedc.tech
NODEDC_BIM_COOKIE_SECURE=1
NODEDC_BIM_COOKIE_SAMESITE=None
NODEDC_LAUNCHER_BASE_URL=https://hub.nodedc.ru
NODEDC_LAUNCHER_INTERNAL_URL=https://hub.nodedc.ru
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-token

2
.gitignore vendored
View File

@ -4,3 +4,5 @@ node_modules
__pycache__/ __pycache__/
*.pyc *.pyc
server/data/uploads/ server/data/uploads/
server/data/shares/
server/data/models/

179
converter/cadquery_stage.py Normal file
View File

@ -0,0 +1,179 @@
import hashlib
import json
import sys
import time
from pathlib import Path
from typing import Any
import cadquery as cq
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(key): json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [json_safe(item) for item in value]
return str(value)
def node_to_metadata(node: cq.Assembly, 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: cq.Workplane) -> 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.Assembly:
original_add = cq.Assembly.add
def add_with_unique_names(self: cq.Assembly, arg: Any, *args: Any, **kwargs: Any) -> Any:
if isinstance(arg, cq.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.Assembly.add = add_with_unique_names
try:
return cq.Assembly.importStep(str(source_path))
finally:
cq.Assembly.add = original_add
def import_step_assembly(source_path: Path) -> tuple[cq.Assembly, str]:
try:
return cq.Assembly.importStep(str(source_path)), "assembly"
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), "assembly-unique-names"
if "does not contain an assembly" not in error_message:
raise
step_model = cq.importers.importStep(str(source_path))
solids = collect_solids(step_model)
assembly = cq.Assembly(name=f"{source_path.stem}_root")
if len(solids) > 1:
for index, solid in enumerate(solids, start=1):
assembly.add(solid, name=f"{source_path.stem}_solid_{index:03d}")
return assembly, "split-solids"
assembly.add(step_model, name=source_path.stem)
return assembly, "single-shape"
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 main() -> int:
source_path = Path(sys.argv[1])
glb_path = Path(sys.argv[2])
result_path = Path(sys.argv[3])
linear_deflection = float(sys.argv[4])
angular_deflection = float(sys.argv[5])
started_at = time.monotonic()
try:
assembly, import_strategy = import_step_assembly(source_path)
tree = node_to_metadata(assembly)
glb_path.parent.mkdir(parents=True, exist_ok=True)
assembly.export(str(glb_path), tolerance=linear_deflection, angularTolerance=angular_deflection)
stats = {
"fallback": True,
"fileSizeMb": round(source_path.stat().st_size / 1024 / 1024, 2),
"importStrategy": import_strategy,
"cadqueryVersion": getattr(cq, "__version__", "unknown"),
"linearDeflection": linear_deflection,
"angularDeflection": angular_deflection,
"durationSeconds": round(time.monotonic() - started_at, 2),
}
write_json_atomic(result_path, {"ok": True, "tree": tree, "stats": stats})
return 0
except Exception as exc:
write_json_atomic(result_path, {"ok": False, "error": str(exc)})
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,11 +1,14 @@
import hashlib import hashlib
import json import json
import os import os
import signal
import shutil
import struct import struct
import subprocess import subprocess
import sys import sys
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from multiprocessing import Process
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -22,18 +25,28 @@ from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
CONVERTER_NAME = "NodeDcBimConverter" CONVERTER_NAME = "NodeDcBimConverter"
CONVERTER_VERSION = "0.4.0" CONVERTER_VERSION = "0.4.6"
UPLOADS_DIR = Path(os.environ.get("NODEDC_BIM_CONVERTER_UPLOADS_DIR", "/beam/uploads")).resolve() UPLOADS_DIR = Path(os.environ.get("NODEDC_BIM_CONVERTER_UPLOADS_DIR", "/beam/uploads")).resolve()
POLL_INTERVAL_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_INTERVAL_SECONDS", "10")) POLL_INTERVAL_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_INTERVAL_SECONDS", "10"))
PROCESS_ONCE = os.environ.get("NODEDC_BIM_CONVERTER_ONCE", "").lower() in {"1", "true", "yes"} PROCESS_ONCE = os.environ.get("NODEDC_BIM_CONVERTER_ONCE", "").lower() in {"1", "true", "yes"}
REPROCESS_READY_ON_VERSION_CHANGE = os.environ.get( REPROCESS_READY_ON_VERSION_CHANGE = os.environ.get(
"NODEDC_BIM_CONVERTER_REPROCESS_READY_ON_VERSION_CHANGE", "" "NODEDC_BIM_CONVERTER_REPROCESS_READY_ON_VERSION_CHANGE", ""
).lower() in {"1", "true", "yes"} ).lower() in {"1", "true", "yes"}
REPROCESS_FAILED_ON_VERSION_CHANGE = os.environ.get(
"NODEDC_BIM_CONVERTER_REPROCESS_FAILED_ON_VERSION_CHANGE", "1"
).lower() in {"1", "true", "yes"}
XKT_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_XKT_ENABLED", "1").lower() not in {"0", "false", "no"} XKT_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_XKT_ENABLED", "1").lower() not in {"0", "false", "no"}
XKT_COMMAND = os.environ.get("NODEDC_BIM_CONVERTER_XKT_COMMAND", "xeokit-convert") XKT_COMMAND = os.environ.get("NODEDC_BIM_CONVERTER_XKT_COMMAND", "xeokit-convert")
NODE_OPTIONS = os.environ.get("NODE_OPTIONS", "--max-old-space-size=8192") NODE_OPTIONS = os.environ.get("NODE_OPTIONS", "--max-old-space-size=8192")
STEP_MESH_LINEAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_LINEAR_DEFLECTION", "0.1")) STEP_MESH_LINEAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_LINEAR_DEFLECTION", "0.1"))
STEP_MESH_ANGULAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_ANGULAR_DEFLECTION", "0.1")) STEP_MESH_ANGULAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_ANGULAR_DEFLECTION", "0.1"))
PROCESSING_STALE_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_PROCESSING_STALE_SECONDS", "60"))
CONVERSION_MAX_ATTEMPTS = int(os.environ.get("NODEDC_BIM_CONVERTER_MAX_ATTEMPTS", "3"))
CONVERSION_TIMEOUT_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_TIMEOUT_SECONDS", "300"))
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"} STEP_EXTENSIONS = {".step", ".stp"}
@ -49,6 +62,26 @@ def manifest_path(source_path: Path) -> Path:
return source_path.with_name(f"{source_path.name}.beam.json") return source_path.with_name(f"{source_path.name}.beam.json")
def asset_manifest_path(source_path: Path) -> Path | None:
version_dir = source_path.parent
asset_dir = version_dir.parent
if not version_dir.name.startswith("ver_"):
return None
if asset_dir == UPLOADS_DIR or not asset_dir.is_dir():
return None
return asset_dir / "asset.json"
def version_asset_dir(source_path: Path) -> Path | None:
version_dir = source_path.parent
asset_dir = version_dir.parent
if not version_dir.name.startswith("ver_"):
return None
if asset_dir == UPLOADS_DIR or not asset_dir.is_dir():
return None
return asset_dir
def read_json(path: Path) -> dict[str, Any]: def read_json(path: Path) -> dict[str, Any]:
try: try:
return json.loads(path.read_text(encoding="utf-8")) return json.loads(path.read_text(encoding="utf-8"))
@ -63,6 +96,219 @@ def write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
tmp_path.replace(path) tmp_path.replace(path)
def conversion_attempt_count(manifest: dict[str, Any]) -> int:
try:
return max(0, int(manifest.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:
return
asset = read_json(asset_path)
versions = asset.get("versions")
if not isinstance(versions, list):
versions = []
version_id = version_payload.get("versionId")
version_number = version_payload.get("version")
next_versions = []
replaced = False
for item in versions:
if not isinstance(item, dict):
continue
is_match = (version_id and item.get("versionId") == version_id) or (
version_number and item.get("version") == version_number
)
if is_match:
next_versions.append({**item, **version_payload})
replaced = True
else:
next_versions.append(item)
if not replaced:
next_versions.append(version_payload)
next_versions.sort(key=lambda item: int(item.get("version") or 0))
now = now_iso()
write_json_atomic(
asset_path,
{
**asset,
"assetId": asset.get("assetId") or version_payload.get("assetId"),
"projectId": asset.get("projectId") or version_payload.get("projectId"),
"currentVersion": version_payload.get("version") or asset.get("currentVersion"),
"currentVersionId": version_payload.get("versionId") or asset.get("currentVersionId"),
"updatedAt": now,
"versions": next_versions,
},
)
def file_size_mb(path: Path) -> float:
try:
return path.stat().st_size / (1024 * 1024)
except Exception:
return 0.0
def calculate_file_sha256(path: Path) -> str | None:
try:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
except Exception:
return None
def upload_src_exists(src: Any) -> bool:
if not isinstance(src, str) or not src.startswith("uploads/"):
return False
try:
return (UPLOADS_DIR / src.removeprefix("uploads/")).resolve().is_file()
except Exception:
return False
def upload_path_from_src(src: Any) -> Path | None:
if not isinstance(src, str) or not src.startswith("uploads/"):
return None
try:
path = (UPLOADS_DIR / src.removeprefix("uploads/")).resolve()
except Exception:
return None
try:
path.relative_to(UPLOADS_DIR)
except ValueError:
return None
return path if path.is_file() else None
def source_path_from_manifest_file(manifest_file: Path, manifest: dict[str, Any]) -> Path | None:
source_path = upload_path_from_src(manifest.get("sourceSrc") or manifest.get("downloadSrc"))
if source_path:
return source_path
source_name = manifest_file.name.removesuffix(".beam.json")
fallback_path = manifest_file.with_name(source_name)
return fallback_path if fallback_path.is_file() else None
def link_or_copy_file(source_path: Path, target_path: Path) -> None:
if source_path.resolve() == target_path.resolve():
return
target_path.parent.mkdir(parents=True, exist_ok=True)
try:
if target_path.exists():
target_path.unlink()
os.link(source_path, target_path)
except OSError:
shutil.copy2(source_path, target_path)
def copy_reusable_conversion_artifacts(
source_path: Path,
reusable_conversion: dict[str, Any],
glb_path: Path,
xkt_path: Path,
metadata_path: Path,
) -> dict[str, Any]:
artifact_source_path = upload_path_from_src(reusable_conversion.get("artifactSrc"))
metadata_source_path = upload_path_from_src(reusable_conversion.get("metadataSrc"))
fallback_source_path = upload_path_from_src(reusable_conversion.get("fallbackArtifactSrc"))
artifact_type = reusable_conversion.get("artifactType") or reusable_conversion.get("targetFormat") or "xkt"
artifact_target_path = xkt_path if artifact_type == "xkt" else glb_path
if not artifact_source_path or not metadata_source_path:
raise RuntimeError("Reusable conversion is missing artifact or metadata file.")
if fallback_source_path:
link_or_copy_file(fallback_source_path, glb_path)
if artifact_source_path != fallback_source_path:
link_or_copy_file(artifact_source_path, artifact_target_path)
metadata = read_json(metadata_source_path)
metadata.update(
{
"source": src_from_path(source_path),
"artifact": src_from_path(artifact_target_path),
"artifactType": artifact_type,
"glbArtifact": src_from_path(glb_path) if glb_path.exists() else None,
"xktArtifact": src_from_path(xkt_path) if artifact_type == "xkt" and xkt_path.exists() else None,
"generatedAt": now_iso(),
"reusedFrom": {
"assetId": reusable_conversion.get("assetId"),
"projectId": reusable_conversion.get("projectId"),
"version": reusable_conversion.get("version"),
"versionId": reusable_conversion.get("versionId"),
"sourceSrc": reusable_conversion.get("sourceSrc"),
},
}
)
converter = metadata.get("converter")
if isinstance(converter, dict):
metadata["converter"] = {
**converter,
"name": CONVERTER_NAME,
"version": CONVERTER_VERSION,
"reusedArtifacts": True,
}
write_json_atomic(metadata_path, metadata)
return {
"artifactSrc": src_from_path(artifact_target_path),
"artifactType": artifact_type,
"componentCount": reusable_conversion.get("componentCount") or metadata.get("componentCount"),
"fallbackArtifactSrc": src_from_path(glb_path) if glb_path.exists() else None,
"fallbackArtifactType": "gltf" if glb_path.exists() else None,
"metadataSrc": src_from_path(metadata_path),
}
def find_ready_conversion_with_same_hash(source_path: Path, manifest: dict[str, Any]) -> dict[str, Any] | None:
sha256 = manifest.get("sha256") or calculate_file_sha256(source_path)
if not sha256:
return None
version_id = manifest.get("versionId")
version_number = manifest.get("version")
source_manifest_path = manifest_path(source_path).resolve()
current_asset_dir = version_asset_dir(source_path)
for candidate_manifest_path in sorted(UPLOADS_DIR.rglob("*.beam.json")):
if candidate_manifest_path.resolve() == source_manifest_path:
continue
candidate = read_json(candidate_manifest_path)
if candidate.get("status") != "ready":
continue
candidate_sha256 = candidate.get("sha256")
if not candidate_sha256:
candidate_source_path = source_path_from_manifest_file(candidate_manifest_path, candidate)
if candidate_source_path:
candidate_sha256 = calculate_file_sha256(candidate_source_path)
if candidate_sha256:
candidate = {**candidate, "sha256": candidate_sha256}
write_json_atomic(candidate_manifest_path, candidate)
if candidate_source_path:
update_asset_manifest_version(candidate_source_path, candidate)
if candidate_sha256 != sha256:
continue
if candidate.get("sourceFormat") != "step":
continue
if candidate.get("artifactType") != "xkt" and candidate.get("targetFormat") != "xkt":
continue
if current_asset_dir and candidate_manifest_path.parent.parent == current_asset_dir:
if candidate.get("versionId") == version_id or candidate.get("version") == version_number:
continue
if not candidate.get("artifactSrc") or not candidate.get("metadataSrc"):
continue
if not upload_src_exists(candidate.get("artifactSrc")) or not upload_src_exists(candidate.get("metadataSrc")):
continue
return candidate
return None
def hash_id(value: str) -> str: def hash_id(value: str) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16] return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
@ -358,7 +604,8 @@ def convert_step_xcaf_to_glb(source_path: Path, glb_path: Path) -> tuple[dict[st
print( print(
f"[{CONVERTER_NAME}] meshing {source_path.name}: " f"[{CONVERTER_NAME}] meshing {source_path.name}: "
f"{labels.Length()} free shape(s), deflection={STEP_MESH_LINEAR_DEFLECTION}", f"{labels.Length()} free shape(s), "
f"linear={STEP_MESH_LINEAR_DEFLECTION}, angular={STEP_MESH_ANGULAR_DEFLECTION}",
flush=True, flush=True,
) )
for index in range(1, labels.Length() + 1): for index in range(1, labels.Length() + 1):
@ -388,6 +635,7 @@ def convert_step_xcaf_to_glb(source_path: Path, glb_path: Path) -> tuple[dict[st
"gltfNodeCount": len(gltf_json.get("nodes", [])), "gltfNodeCount": len(gltf_json.get("nodes", [])),
"gltfMeshCount": len(gltf_json.get("meshes", [])), "gltfMeshCount": len(gltf_json.get("meshes", [])),
"renamedNodeCount": renamed_nodes, "renamedNodeCount": renamed_nodes,
"fileSizeMb": round(file_size_mb(source_path), 2),
"linearDeflection": STEP_MESH_LINEAR_DEFLECTION, "linearDeflection": STEP_MESH_LINEAR_DEFLECTION,
"angularDeflection": STEP_MESH_ANGULAR_DEFLECTION, "angularDeflection": STEP_MESH_ANGULAR_DEFLECTION,
} }
@ -398,11 +646,14 @@ def convert_step_cadquery_to_glb(source_path: Path, glb_path: Path) -> tuple[dic
assy, import_strategy, cadquery_version = import_step_assembly(source_path) assy, import_strategy, cadquery_version = import_step_assembly(source_path)
tree = node_to_metadata(assy) tree = node_to_metadata(assy)
glb_path.parent.mkdir(parents=True, exist_ok=True) glb_path.parent.mkdir(parents=True, exist_ok=True)
assy.export(str(glb_path), tolerance=0.1, angularTolerance=0.1) assy.export(str(glb_path), tolerance=STEP_MESH_LINEAR_DEFLECTION, angularTolerance=STEP_MESH_ANGULAR_DEFLECTION)
stats = { stats = {
"fallback": True, "fallback": True,
"fileSizeMb": round(file_size_mb(source_path), 2),
"importStrategy": import_strategy, "importStrategy": import_strategy,
"cadqueryVersion": cadquery_version, "cadqueryVersion": cadquery_version,
"linearDeflection": STEP_MESH_LINEAR_DEFLECTION,
"angularDeflection": STEP_MESH_ANGULAR_DEFLECTION,
} }
return tree, stats return tree, stats
@ -415,6 +666,33 @@ def read_log_tail(path: Path, max_lines: int = 30) -> str:
return "\n".join(lines[-max_lines:]) return "\n".join(lines[-max_lines:])
def stage_exit_error(stage_name: str, exitcode: int) -> str:
if exitcode < 0:
signal_number = abs(exitcode)
if signal_number == 9:
return f"{stage_name} process was killed by signal 9; likely converter memory limit was exceeded."
return f"{stage_name} process was killed by signal {signal_number}."
if exitcode == 137:
return f"{stage_name} process was killed with code 137; likely converter memory limit was exceeded."
return f"{stage_name} subprocess exited with code {exitcode}."
def is_resource_limit_error(error: Any) -> bool:
message = str(error)
return (
"signal 9" in message
or "code -9" in message
or "code 137" in message
or "memory limit was exceeded" in message
)
def conversion_failed_message(error: Any) -> str:
if is_resource_limit_error(error):
return "Не удалось подготовить просмотр и дерево компонентов: не хватило памяти конвертера."
return "Не удалось подготовить просмотр и дерево компонентов."
def convert_glb_to_xkt(glb_path: Path, xkt_path: Path) -> dict[str, Any]: def convert_glb_to_xkt(glb_path: Path, xkt_path: Path) -> dict[str, Any]:
if not XKT_ENABLED: if not XKT_ENABLED:
return {"enabled": False} return {"enabled": False}
@ -516,6 +794,136 @@ def build_step_metadata(
return metadata return metadata
def convert_step_xcaf_to_glb_process(source_path: str, glb_path: str, result_path: str) -> None:
try:
tree, stats = convert_step_xcaf_to_glb(Path(source_path), Path(glb_path))
write_json_atomic(Path(result_path), {"ok": True, "tree": tree, "stats": stats})
except Exception as exc:
write_json_atomic(Path(result_path), {"ok": False, "error": str(exc)})
def convert_step_cadquery_to_glb_process(source_path: str, glb_path: str, result_path: str) -> None:
try:
stage_path = Path(__file__).with_name("cadquery_stage.py")
completed = subprocess.run(
[
sys.executable,
str(stage_path),
source_path,
glb_path,
result_path,
str(STEP_MESH_LINEAR_DEFLECTION),
str(STEP_MESH_ANGULAR_DEFLECTION),
],
check=False,
)
if completed.returncode != 0:
result = read_json(Path(result_path))
raise RuntimeError(result.get("error") or f"CadQuery subprocess exited with code {completed.returncode}.")
except Exception as exc:
write_json_atomic(Path(result_path), {"ok": False, "error": str(exc)})
def run_cadquery_stage_with_timeout(result_path: Path, source_path: Path, glb_path: Path) -> dict[str, Any]:
if result_path.exists():
result_path.unlink()
stage_path = Path(__file__).with_name("cadquery_stage.py")
command = [
sys.executable,
str(stage_path),
str(source_path),
str(glb_path),
str(result_path),
str(STEP_MESH_LINEAR_DEFLECTION),
str(STEP_MESH_ANGULAR_DEFLECTION),
]
process = subprocess.Popen(command, start_new_session=True)
try:
if CADQUERY_TIMEOUT_SECONDS <= 0:
process.wait()
else:
process.wait(timeout=CADQUERY_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGTERM)
except Exception:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except Exception:
process.kill()
process.wait(timeout=10)
raise TimeoutError(f"CadQuery timed out after {int(CADQUERY_TIMEOUT_SECONDS)} seconds.")
result = read_json(result_path)
try:
result_path.unlink()
except Exception:
pass
if process.returncode != 0:
raise RuntimeError(result.get("error") or stage_exit_error("CadQuery", process.returncode))
if not result:
raise RuntimeError("CadQuery produced no result.")
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "CadQuery failed."))
return result
def run_step_glb_stage_with_timeout(
stage_name: str,
timeout_seconds: float,
target: Any,
result_path: Path,
*args: str,
) -> dict[str, Any]:
if result_path.exists():
result_path.unlink()
if timeout_seconds <= 0:
target(*args, str(result_path))
else:
process = Process(target=target, args=(*args, str(result_path)))
process.start()
process.join(timeout_seconds)
if process.is_alive():
process.terminate()
process.join(10)
if process.is_alive():
process.kill()
process.join(10)
raise TimeoutError(f"{stage_name} timed out after {int(timeout_seconds)} seconds.")
if process.exitcode != 0:
result = read_json(result_path)
error = result.get("error") if isinstance(result, dict) else None
raise RuntimeError(error or stage_exit_error(stage_name, process.exitcode))
result = read_json(result_path)
try:
result_path.unlink()
except Exception:
pass
if not result:
raise RuntimeError(f"{stage_name} produced no result.")
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or f"{stage_name} failed."))
return result
def remove_partial_artifacts(*paths: Path) -> None:
for path in paths:
try:
if path.exists():
path.unlink()
except Exception:
pass
def convert_step_assets(source_path: Path, glb_path: Path, xkt_path: Path, metadata_path: Path) -> dict[str, Any]: def convert_step_assets(source_path: Path, glb_path: Path, xkt_path: Path, metadata_path: Path) -> dict[str, Any]:
previous_metadata = read_json(metadata_path) previous_metadata = read_json(metadata_path)
@ -547,12 +955,35 @@ def convert_step_assets(source_path: Path, glb_path: Path, xkt_path: Path, metad
) )
import_strategy = "occt-xcaf-gltf" import_strategy = "occt-xcaf-gltf"
result_path = metadata_path.with_name(f"{metadata_path.name}.stage-result.json")
try: try:
tree, stats = convert_step_xcaf_to_glb(source_path, glb_path) result = run_step_glb_stage_with_timeout(
"OCCT/XCAF",
XCAF_TIMEOUT_SECONDS,
convert_step_xcaf_to_glb_process,
result_path,
str(source_path),
str(glb_path),
)
tree = result["tree"]
stats = result["stats"]
except Exception as exc: except Exception as exc:
print(f"[{CONVERTER_NAME}] OCCT/XCAF path failed for {source_path}: {exc}", file=sys.stderr, flush=True) print(
tree, stats = convert_step_cadquery_to_glb(source_path, glb_path) f"[{CONVERTER_NAME}] OCCT/XCAF path failed for {source_path}: {exc}; "
"falling back to CadQuery",
file=sys.stderr,
flush=True,
)
remove_partial_artifacts(glb_path, xkt_path)
result = run_cadquery_stage_with_timeout(result_path, source_path, glb_path)
tree = result["tree"]
stats = result["stats"]
import_strategy = stats.get("importStrategy") or "cadquery" import_strategy = stats.get("importStrategy") or "cadquery"
stats = {
**stats,
"fallbackFrom": "occt-xcaf-gltf",
"fallbackReason": str(exc),
}
return build_step_metadata( return build_step_metadata(
source_path, source_path,
@ -568,14 +999,20 @@ def convert_step_assets(source_path: Path, glb_path: Path, xkt_path: Path, metad
def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path, xkt_path: Path) -> bool: def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path, xkt_path: Path) -> bool:
status = manifest.get("status") status = manifest.get("status")
ready_artifact_path = xkt_path if XKT_ENABLED else glb_path ready_artifact_path = xkt_path if XKT_ENABLED else glb_path
if status == "ready" and ready_artifact_path.exists(): if status == "ready" and (ready_artifact_path.exists() or upload_src_exists(manifest.get("artifactSrc"))):
return REPROCESS_READY_ON_VERSION_CHANGE and manifest.get("converterVersion") != CONVERTER_VERSION return REPROCESS_READY_ON_VERSION_CHANGE and manifest.get("converterVersion") != CONVERTER_VERSION
if status == "failed":
return (
REPROCESS_FAILED_ON_VERSION_CHANGE
and manifest.get("converterVersion") != CONVERTER_VERSION
and source_path.exists()
)
if status == "processing": if status == "processing":
updated_at = manifest.get("updatedAt") updated_at = manifest.get("updatedAt")
if updated_at: if updated_at:
try: try:
updated = datetime.fromisoformat(str(updated_at).replace("Z", "+00:00")) updated = datetime.fromisoformat(str(updated_at).replace("Z", "+00:00"))
if (datetime.now(timezone.utc) - updated).total_seconds() < 300: if (datetime.now(timezone.utc) - updated).total_seconds() < PROCESSING_STALE_SECONDS:
return False return False
except Exception: except Exception:
pass pass
@ -593,7 +1030,25 @@ def process_one(source_path: Path) -> bool:
if not should_process(source_path, manifest, glb_path, xkt_path): if not should_process(source_path, manifest, glb_path, xkt_path):
return False return False
preserved_fields = {
key: manifest.get(key)
for key in (
"assetId",
"projectId",
"version",
"versionId",
"originalFilename",
"storedFilename",
"size",
"sha256",
"uploadedAt",
"downloadSrc",
)
if manifest.get(key) is not None
}
base_manifest = { base_manifest = {
**preserved_fields,
"createdAt": manifest.get("createdAt") or now_iso(), "createdAt": manifest.get("createdAt") or now_iso(),
"componentTreeRequired": True, "componentTreeRequired": True,
"converterName": CONVERTER_NAME, "converterName": CONVERTER_NAME,
@ -603,11 +1058,69 @@ def process_one(source_path: Path) -> bool:
"targetFormat": "xkt" if XKT_ENABLED else "glb", "targetFormat": "xkt" if XKT_ENABLED else "glb",
} }
attempts = 0 if manifest.get("converterVersion") != CONVERTER_VERSION else conversion_attempt_count(manifest)
if manifest.get("status") == "processing" and attempts >= CONVERSION_MAX_ATTEMPTS:
failed_manifest = {
**base_manifest,
"attempts": attempts,
"error": f"Converter process stopped before completion after {attempts} attempt(s).",
"message": "Не удалось подготовить просмотр и дерево компонентов: превышен лимит попыток конвертации.",
"status": "failed",
"updatedAt": now_iso(),
}
write_json_atomic(manifest_file, failed_manifest)
update_asset_manifest_version(source_path, failed_manifest)
print(
f"[{CONVERTER_NAME}] failed {source_path}: max attempts reached ({attempts})",
file=sys.stderr,
flush=True,
)
return False
reusable_conversion = find_ready_conversion_with_same_hash(source_path, manifest)
if reusable_conversion:
reused_artifacts = copy_reusable_conversion_artifacts(
source_path,
reusable_conversion,
glb_path,
xkt_path,
metadata_path,
)
ready_manifest = {
**base_manifest,
"attempts": attempts,
**reused_artifacts,
"message": "Просмотр и дерево компонентов готовы.",
"reusedFromAssetId": reusable_conversion.get("assetId"),
"reusedFromProjectId": reusable_conversion.get("projectId"),
"reusedFromVersion": reusable_conversion.get("version"),
"reusedFromVersionId": reusable_conversion.get("versionId"),
"reusedFromSourceSrc": reusable_conversion.get("sourceSrc"),
"status": "ready",
"updatedAt": now_iso(),
}
write_json_atomic(manifest_file, ready_manifest)
update_asset_manifest_version(source_path, ready_manifest)
reuse_label = (
reusable_conversion.get("versionId")
or reusable_conversion.get("version")
or reusable_conversion.get("sourceSrc")
or "ready-artifact"
)
print(
f"[{CONVERTER_NAME}] reused ready conversion {source_path.name} "
f"from {reuse_label}",
flush=True,
)
return True
attempt = attempts + 1
write_json_atomic( write_json_atomic(
manifest_file, manifest_file,
{ {
**base_manifest, **base_manifest,
"message": "Preparing XKT preview and component tree.", "attempts": attempt,
"message": "Подготавливаем просмотр и дерево компонентов.",
"status": "processing", "status": "processing",
"updatedAt": now_iso(), "updatedAt": now_iso(),
}, },
@ -618,34 +1131,34 @@ def process_one(source_path: Path) -> bool:
metadata = convert_step_assets(source_path, glb_path, xkt_path, metadata_path) metadata = convert_step_assets(source_path, glb_path, xkt_path, metadata_path)
artifact_src = metadata.get("artifact") or src_from_path(xkt_path if xkt_path.exists() else glb_path) artifact_src = metadata.get("artifact") or src_from_path(xkt_path if xkt_path.exists() else glb_path)
artifact_type = metadata.get("artifactType") or ("xkt" if xkt_path.exists() else "gltf") artifact_type = metadata.get("artifactType") or ("xkt" if xkt_path.exists() else "gltf")
write_json_atomic( ready_manifest = {
manifest_file, **base_manifest,
{ "attempts": attempt,
**base_manifest, "artifactSrc": artifact_src,
"artifactSrc": artifact_src, "artifactType": artifact_type,
"artifactType": artifact_type, "fallbackArtifactSrc": src_from_path(glb_path),
"fallbackArtifactSrc": src_from_path(glb_path), "fallbackArtifactType": "gltf",
"fallbackArtifactType": "gltf", "componentCount": metadata.get("componentCount"),
"componentCount": metadata.get("componentCount"), "metadataSrc": src_from_path(metadata_path),
"metadataSrc": src_from_path(metadata_path), "message": "Просмотр и дерево компонентов готовы.",
"message": "XKT preview and component tree are ready.", "status": "ready",
"status": "ready", "updatedAt": now_iso(),
"updatedAt": now_iso(), }
}, write_json_atomic(manifest_file, ready_manifest)
) update_asset_manifest_version(source_path, ready_manifest)
print(f"[{CONVERTER_NAME}] ready {artifact_src}", flush=True) print(f"[{CONVERTER_NAME}] ready {artifact_src}", flush=True)
return True return True
except Exception as exc: except Exception as exc:
write_json_atomic( failed_manifest = {
manifest_file, **base_manifest,
{ "attempts": attempt,
**base_manifest, "error": str(exc),
"error": str(exc), "message": conversion_failed_message(exc),
"message": "Failed to prepare XKT preview and component tree.", "status": "failed",
"status": "failed", "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}] failed {source_path}: {exc}", file=sys.stderr, flush=True) print(f"[{CONVERTER_NAME}] failed {source_path}: {exc}", file=sys.stderr, flush=True)
return False return False

View File

@ -3,10 +3,23 @@ services:
image: node:22-alpine image: node:22-alpine
working_dir: /beam/server working_dir: /beam/server
command: node index.js command: node index.js
restart: unless-stopped
environment: environment:
PORT: "8080" PORT: "8080"
NODEDC_BIM_SERVICE_SLUG: "${NODEDC_BIM_SERVICE_SLUG:-bim-viewer}"
NODEDC_BIM_AUTH_REQUIRED: "${NODEDC_BIM_AUTH_REQUIRED:-0}"
NODEDC_BIM_PUBLIC_URL: "${NODEDC_BIM_PUBLIC_URL:-http://localhost:8080}"
NODEDC_BIM_DATA_ROOT: "${NODEDC_BIM_DATA_ROOT:-}"
NODEDC_BIM_SESSION_COOKIE: "${NODEDC_BIM_SESSION_COOKIE:-nodedc_bim_session}"
NODEDC_BIM_SESSION_TTL_MS: "${NODEDC_BIM_SESSION_TTL_MS:-43200000}"
NODEDC_BIM_COOKIE_SECURE: "${NODEDC_BIM_COOKIE_SECURE:-0}"
NODEDC_BIM_COOKIE_SAMESITE: "${NODEDC_BIM_COOKIE_SAMESITE:-}"
NODEDC_LAUNCHER_BASE_URL: "${NODEDC_LAUNCHER_BASE_URL:-http://localhost:5173}"
NODEDC_LAUNCHER_INTERNAL_URL: "${NODEDC_LAUNCHER_INTERNAL_URL:-http://host.docker.internal:5173}"
NODEDC_INTERNAL_ACCESS_TOKEN: "${NODEDC_INTERNAL_ACCESS_TOKEN:-}"
NODEDC_BIM_ALLOWED_ORIGINS: "${NODEDC_BIM_ALLOWED_ORIGINS:-http://localhost:5173,http://localhost:8090}"
ports: ports:
- "8080:8080" - "${NODEDC_BIM_HOST_PORT:-8080}:8080"
volumes: volumes:
- ./:/beam - ./:/beam
@ -16,8 +29,13 @@ services:
image: nodedc/bim-converter:local image: nodedc/bim-converter:local
container_name: NodeDcBimConverter container_name: NodeDcBimConverter
platform: linux/amd64 platform: linux/amd64
restart: unless-stopped
environment: environment:
NODEDC_BIM_CONVERTER_UPLOADS_DIR: /beam/uploads NODEDC_BIM_CONVERTER_UPLOADS_DIR: /beam/uploads
NODEDC_BIM_CONVERTER_INTERVAL_SECONDS: "10" NODEDC_BIM_CONVERTER_INTERVAL_SECONDS: "10"
NODEDC_BIM_CONVERTER_MAX_ATTEMPTS: "3"
NODEDC_BIM_CONVERTER_TIMEOUT_SECONDS: "300"
NODEDC_BIM_CONVERTER_XCAF_TIMEOUT_SECONDS: "1200"
NODEDC_BIM_CONVERTER_CADQUERY_TIMEOUT_SECONDS: "1800"
volumes: volumes:
- ./server/data/uploads:/beam/uploads - ./server/data/uploads:/beam/uploads

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,13 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.icon-fav-01 { fill: #333334; } /* Тёмный цвет для светлого таб-бара */
@media (prefers-color-scheme: dark) {
.icon-fav-01 { fill: #FEFEFE; } /* Светлый цвет для тёмного таб-бара */
}
@media (prefers-color-scheme: light) {
.icon-fav-01 { fill: #000000; } /* Тёмный цвет для светлого таб-бара (для явности) */
}
</style>
<path class="icon-fav-01" d="M17.8738 15.9159L16.0002 18.9718L14.1267 15.9159H17.8738ZM23.6307 12.7864H8.36914L15.9999 25.2308L23.6307 12.7864Z"/>
<path class="icon-fav-01" d="M10.9793 19.4872L6.67269 11.5918H25.5344L21.2281 19.4872H25.0581L31.2614 8H0.738281L7.16244 19.4872H10.9793Z"/>
</svg>

After

Width:  |  Height:  |  Size: 852 B

View File

@ -0,0 +1,29 @@
{
"theme_color": "#eeeff4",
"background_color": "#eeeff4",
"display": "browser",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "icon-adaptive.svg",
"type": "image/svg+xml",
"sizes": "any"
},
{
"src": "icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "icon-512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "apple-touch-icon.png",
"type": "image/png",
"sizes": "180x180"
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -4,38 +4,141 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>BIM DC Viewer</title> <title>BIM DC Viewer</title>
<link rel="icon" href="./assets/logo.svg"> <link rel="icon" type="image/svg+xml" href="/assets/favicon/icon-adaptive.svg">
<link rel="alternate icon" href="/assets/favicon/favicon.ico">
<link rel="apple-touch-icon" href="/assets/favicon/apple-touch-icon.png">
<link rel="manifest" href="/assets/favicon/manifest.webmanifest.json">
<style id="preload-hidden">body{opacity:0;}</style> <style id="preload-hidden">body{opacity:0;}</style>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap">
<script src="./theme.js"></script> <script src="/theme.js?v=3"></script>
<script>
(function () {
if (/^\/share\/[^/]+\/?$/.test(window.location.pathname || "")) {
document.documentElement.dataset.shareStartup = "pending";
}
}());
</script>
<script> <script>
// Apply persisted theme ASAP to avoid initial flash of defaults // Apply persisted theme ASAP to avoid initial flash of defaults
(function () { (function () {
const STORAGE_KEY = "bimdc-settings-v3"; const STORAGE_KEY = "bimdc-settings-v3";
const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"]; const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"];
try { try {
LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
const project = window.__BIMDC_THEME__ || {}; const project = window.__BIMDC_THEME__ || {};
const raw = localStorage.getItem(STORAGE_KEY); const sharedViewer = /^\/share\/[^/]+\/?$/.test(window.location.pathname || "");
let stored = null; let stored = null;
try { if (!sharedViewer) {
stored = raw ? JSON.parse(raw) : null; LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
} catch (e) { const raw = localStorage.getItem(STORAGE_KEY);
stored = null; try {
stored = raw ? JSON.parse(raw) : null;
} catch (e) {
stored = null;
}
const storedVersion = stored && stored.themeVersion;
const projectVersion = project && project.themeVersion;
if (!stored || storedVersion !== projectVersion) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
stored = { ...project };
}
} }
const storedVersion = stored && stored.themeVersion; const s = { ...(project || {}), ...(!sharedViewer && stored ? stored : {}) };
const projectVersion = project && project.themeVersion;
if (!stored || storedVersion !== projectVersion) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
stored = { ...project };
}
const s = { ...(project || {}), ...(stored || {}) };
if (!Object.keys(s).length) return; if (!Object.keys(s).length) return;
const root = document.documentElement; const root = document.documentElement;
const set = (k, v) => v !== undefined && root.style.setProperty(k, v); const set = (k, v) => v !== undefined && root.style.setProperty(k, v);
const parseColor = (value) => {
if (typeof value !== "string") return null;
const color = value.trim();
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hex) {
const raw = hex[1].length === 3
? hex[1].split("").map((c) => c + c).join("")
: hex[1];
return [
parseInt(raw.slice(0, 2), 16),
parseInt(raw.slice(2, 4), 16),
parseInt(raw.slice(4, 6), 16),
];
}
const rgb = color.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
if (!rgb) return null;
return rgb.slice(1, 4).map((channel) => Math.max(0, Math.min(255, Number(channel))));
};
const readableTextColor = (value) => {
const rgb = parseColor(value);
if (!rgb || rgb.some((channel) => !Number.isFinite(channel))) return "#15170f";
const toLinear = (channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
const lum = 0.2126 * toLinear(rgb[0]) + 0.7152 * toLinear(rgb[1]) + 0.0722 * toLinear(rgb[2]);
return (lum + 0.05) / 0.05 >= 1.05 / (lum + 0.05) ? "#15170f" : "#f5f5fa";
};
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const colorParts = (value, fallback) => {
const parsed = parseColor(value);
if (parsed && parsed.every((channel) => Number.isFinite(channel))) return parsed;
const parsedFallback = parseColor(fallback);
if (parsedFallback && parsedFallback.every((channel) => Number.isFinite(channel))) return parsedFallback;
return [28, 28, 28];
};
const alphaValue = (value, fallback) => {
const numeric = Number(value);
return Number.isFinite(numeric) ? clamp(numeric, 0, 1) : fallback;
};
const rgba = (parts, alpha) => {
const [r, g, b] = parts.map((channel) => Math.round(clamp(channel, 0, 255)));
return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1).toFixed(3)})`;
};
const applyGlassSettings = () => {
const panelColor = colorParts(s.modalBg, "#1c1c1c");
const focusColor = colorParts(s.modalFocus, "#292929");
const controlColor = colorParts(s.modalElement, s.modalFocus || "#262626");
const outlineColor = colorParts(s.modalBorder, s.modalElementOutline || "#1c1c1c");
const controlOutlineColor = colorParts(s.modalElementOutline, s.modalBorder || "#262626");
const modalBgAlpha = alphaValue(s.modalBgAlpha, 1);
const modalFocusAlpha = alphaValue(s.modalFocusAlpha, 0.35);
const panelAlpha = clamp(0.24 + modalBgAlpha * 0.48, 0.32, 0.88);
const panelStrongAlpha = clamp(panelAlpha + 0.1, 0.42, 0.92);
const panelSoftAlpha = clamp(panelAlpha - 0.16, 0.2, 0.72);
const sectionAlpha = clamp(modalFocusAlpha * 0.6, 0.04, 0.42);
const controlAlpha = clamp(0.12 + modalFocusAlpha * 0.9, 0.16, 0.66);
const controlHoverAlpha = clamp(controlAlpha + 0.08, 0.22, 0.74);
const outlineAlpha = clamp(0.055 + modalFocusAlpha * 0.18, 0.065, 0.2);
const controlOutlineAlpha = clamp(0.045 + modalFocusAlpha * 0.12, 0.055, 0.16);
const panelBg = rgba(panelColor, panelAlpha);
const panelBgStrong = rgba(panelColor, panelStrongAlpha);
const panelBgSoft = rgba(panelColor, panelSoftAlpha);
const sectionBg = rgba(focusColor, sectionAlpha);
const outline = rgba(outlineColor, outlineAlpha);
const outlineBright = rgba(outlineColor, clamp(outlineAlpha + 0.05, 0.08, 0.26));
const outlineMid = rgba(outlineColor, clamp(outlineAlpha + 0.015, 0.07, 0.22));
const outlineDim = rgba(outlineColor, clamp(outlineAlpha * 0.62, 0.04, 0.16));
const controlBg = rgba(controlColor, controlAlpha);
const controlHover = rgba(focusColor, controlHoverAlpha);
const controlShadow = rgba(controlOutlineColor, controlOutlineAlpha);
set("--nodedc-glass-panel-bg", panelBg);
set("--nodedc-glass-panel-bg-strong", panelBgStrong);
set("--nodedc-glass-panel-bg-soft", panelBgSoft);
set("--nodedc-glass-panel-surface", `linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.014) 100%), ${panelBg}`);
set("--nodedc-glass-panel-surface-strong", `linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.016) 100%), ${panelBgStrong}`);
set("--nodedc-glass-section-bg", `linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.01) 100%), ${sectionBg}`);
set("--nodedc-glass-outline", outline);
set("--nodedc-glass-outline-gradient", `linear-gradient(180deg, ${outlineBright} 0%, ${outlineMid} 56%, ${outlineDim} 100%)`);
set("--nodedc-glass-panel-shadow", `inset 0 1px 0 ${outlineDim}, 0 22px 72px rgba(0, 0, 0, 0.42)`);
set("--nodedc-glass-control-bg", `linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.018) 100%), ${controlBg}`);
set("--nodedc-glass-control-hover", `linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.026) 100%), ${controlHover}`);
set("--nodedc-glass-control-shadow", `inset 0 1px 0 ${controlShadow}`);
set("--nodedc-header-dropdown-bg", panelBgStrong);
set("--nodedc-header-dropdown-item-bg", controlBg);
set("--nodedc-header-dropdown-item-hover-bg", controlHover);
set("--nodedc-header-dropdown-item-active-bg", s.toolbarBgActive);
set("--nodedc-header-dropdown-item-active-color", s.toolbarBgActive ? readableTextColor(s.toolbarBgActive) : undefined);
};
set("--bg-outer", s.bgOuter); set("--bg-outer", s.bgOuter);
set("--canvas-top", s.canvasTop); set("--canvas-top", s.canvasTop);
set("--canvas-bottom", s.canvasBottom); set("--canvas-bottom", s.canvasBottom);
@ -49,18 +152,28 @@
set("--template-glow-strength", `${s.templateGlow}%`); set("--template-glow-strength", `${s.templateGlow}%`);
set("--toolbar-bg", s.toolbarBg); set("--toolbar-bg", s.toolbarBg);
set("--toolbar-bg-active", s.toolbarBgActive); set("--toolbar-bg-active", s.toolbarBgActive);
set("--toolbar-bg-active-contrast", s.toolbarBgActive ? readableTextColor(s.toolbarBgActive) : undefined);
set("--toolbar-border", s.toolbarBorder); set("--toolbar-border", s.toolbarBorder);
set("--toolbar-outline", s.toolbarOutline); set("--toolbar-outline", s.toolbarOutline);
set("--tb-size", s.toolbarMinSize !== undefined ? `${s.toolbarMinSize}px` : undefined);
set("--tb-min-size", s.toolbarMinSize !== undefined ? `${s.toolbarMinSize}px` : undefined);
set("--tb-max-size", s.toolbarMaxSize !== undefined ? `${s.toolbarMaxSize}px` : undefined);
set("--tb-lens-count", s.toolbarLensCount);
set("--tb-autohide", s.toolbarAutoHide ? "1" : "0");
set("--modal-bg", s.modalBg); set("--modal-bg", s.modalBg);
set("--modal-bg-alpha", s.modalBgAlpha !== undefined ? `${s.modalBgAlpha * 100}%` : undefined);
set("--modal-focus", s.modalFocus); set("--modal-focus", s.modalFocus);
set("--modal-focus-alpha", s.modalFocusAlpha !== undefined ? `${s.modalFocusAlpha * 100}%` : undefined);
set("--modal-border", s.modalBorder); set("--modal-border", s.modalBorder);
set("--modal-element", s.modalElement); set("--modal-element", s.modalElement);
set("--modal-element-outline", s.modalElementOutline); set("--modal-element-outline", s.modalElementOutline);
set("--modal-radius", s.modalRadius); set("--modal-radius", s.modalRadius);
applyGlassSettings();
set("--project-btn-fill", s.projectBtnFill); set("--project-btn-fill", s.projectBtnFill);
set("--project-btn-border", s.projectBtnBorder); set("--project-btn-border", s.projectBtnBorder);
set("--project-btn-hover", s.projectBtnHover); set("--project-btn-hover", s.projectBtnHover);
set("--modal-btn-primary", s.modalBtnPrimary); set("--modal-btn-primary", s.modalBtnPrimary);
set("--modal-btn-primary-contrast", s.modalBtnPrimary ? readableTextColor(s.modalBtnPrimary) : undefined);
set("--modal-btn-secondary", s.modalBtnSecondary); set("--modal-btn-secondary", s.modalBtnSecondary);
set("--loader-color", s.loaderColor); set("--loader-color", s.loaderColor);
set("--border", s.border); set("--border", s.border);
@ -79,6 +192,21 @@
set("--measure-label", s.measureLabel); set("--measure-label", s.measureLabel);
set("--measure-label-text", s.measureLabelText); set("--measure-label-text", s.measureLabelText);
set("--measure-dot", s.measureDot); set("--measure-dot", s.measureDot);
set("--section-axis-x", s.sectionAxisX);
set("--section-axis-y", s.sectionAxisY);
set("--section-axis-z", s.sectionAxisZ);
set("--section-gizmo-diameter", s.sectionGizmoDiameter);
set("--section-gizmo-thickness", s.sectionGizmoThickness);
set("--section-plane-fill", s.sectionPlaneFill);
set("--section-plane-alpha", s.sectionPlaneAlpha);
set("--section-plane-edge", s.sectionPlaneEdge);
set("--section-plane-edge-alpha", s.sectionPlaneEdgeAlpha);
set("--comment-target-active", s.commentTargetActiveColor);
set("--comment-target-active-alpha", s.commentTargetActiveAlpha);
set("--comment-target-active-size", s.commentTargetActiveSize !== undefined ? `${s.commentTargetActiveSize}px` : undefined);
set("--comment-target-passive", s.commentTargetPassiveColor);
set("--comment-target-passive-alpha", s.commentTargetPassiveAlpha);
set("--comment-target-passive-size", s.commentTargetPassiveSize !== undefined ? `${s.commentTargetPassiveSize}px` : undefined);
} catch (e) { } catch (e) {
// ignore corrupted settings // ignore corrupted settings
} }
@ -87,11 +215,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./dcViewer.css?v=4"> <link rel="stylesheet" href="/dcViewer.css?v=37">
</head> </head>
<body> <body>
<header> <header>
<button class="logo-btn" id="homeButton" title="Вернуться к старту"></button> <button class="logo-btn" id="homeButton" type="button" title="Открыть NODE.DC Hub"></button>
<div class="toolbar"></div> <div class="toolbar"></div>
<div class="hidden-controls" style="display:none"> <div class="hidden-controls" style="display:none">
<input type="checkbox" id="toggleEdges" checked> <input type="checkbox" id="toggleEdges" checked>
@ -124,77 +252,295 @@
</div> </div>
</section> </section>
<aside class="side-panel"> <aside class="side-panel" id="viewerInspector">
<div class="panel-section"> <div class="inspector-panel-header">
<h3>Инспектор</h3> <h3>Инспектор</h3>
<div id="treeContainer" class="tree"></div> <button
<div class="display-mode-panel" id="displayModeControl"> class="inspector-collapse-btn"
<div class="display-mode-title">Режим отображения</div> id="inspectorCollapseButton"
<div class="display-mode-buttons"> type="button"
<button type="button" data-display-mode="source" class="active">Исходный</button> title="Свернуть инспектор"
<button type="button" data-display-mode="white">Белый</button> aria-label="Свернуть инспектор"
<button type="button" data-display-mode="contrast">Контраст</button> >
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
</button>
</div>
<div class="inspector-panel-body">
<div class="panel-section inspector-main-section">
<div id="treeContainer" class="tree"></div>
<div class="inspector-controls">
<div class="inspector-control-row inspector-control-row--full hidden" id="modelVersionControl">
<div class="inspector-version-heading">
<div class="inspector-control-label">Версия модели</div>
<div class="inspector-version-actions">
<button class="inspector-icon-btn" id="modelVersionHistoryButton" type="button" title="История версий" aria-label="История версий">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.2 3.4v3.3h3.3"></path>
<path d="M3.7 6.7A4.7 4.7 0 1 0 5.1 3"></path>
<path d="M8 5.1v3.2l2.1 1.2"></path>
</svg>
</button>
<button class="inspector-icon-btn" id="modelVersionUploadButton" type="button" title="Добавить версию" aria-label="Добавить версию">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 11.4V3.6"></path>
<path d="M5.1 6.4 8 3.5l2.9 2.9"></path>
<path d="M3.2 11.8v1.4h9.6v-1.4"></path>
</svg>
</button>
</div>
</div>
<div class="inspector-select-host" id="modelVersionSelect"></div>
<input id="modelVersionFileInput" class="hidden" type="file" accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl,.step,.stp">
</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>
</div>
<div class="inspector-control-row inspector-control-row--half hidden" id="customDisplayColorRow">
<div class="inspector-control-label">Цвет</div>
<div class="inspector-color-host" id="customDisplayColorControl"></div>
</div>
<div class="inspector-control-row inspector-control-row--half" id="lightingModeControl">
<div class="inspector-control-label">Свет</div>
<div class="inspector-select-host" id="lightingModeSelect"></div>
</div>
<div class="inspector-control-row inspector-control-row--full inspector-control-row--slider hidden" id="customDisplaySaturationControl"></div>
<div class="inspector-control-row inspector-control-row--full" id="navigationAxisControl">
<div class="inspector-control-label">Ось навигации</div>
<div class="inspector-select-host" id="navigationAxisSelect"></div>
</div>
<div class="inspector-control-row inspector-control-row--full inspector-control-row--slider" id="zoomSpeedControl"></div>
</div> </div>
</div> </div>
</div> <div class="panel-section" id="projectNameSection">
<div class="panel-section" id="projectNameSection"> <h3>Название проекта</h3>
<h3>Название проекта</h3> <input type="text" id="projectNameInput" class="project-name-input" placeholder="Проект" autocomplete="off">
<input type="text" id="projectNameInput" class="project-name-input" placeholder="Проект" autocomplete="off"> <div class="error-text inline-error" id="projectNameError"></div>
<div class="error-text inline-error" id="projectNameError"></div>
</div>
<div class="panel-section">
<h3>Глобальные координаты</h3>
<div class="muted" style="margin-bottom:6px;">Центр выделения (мировые)</div>
<div class="transform-grid">
<label>X <input type="text" id="globalX"></label>
<label>Y <input type="text" id="globalY"></label>
<label>Z <input type="text" id="globalZ"></label>
</div> </div>
</div> <div class="panel-section">
<div class="panel-section"> <h3>Глобальные координаты</h3>
<h3>Трансформации</h3> <div class="muted" style="margin-bottom:6px;">Центр выделения (мировые)</div>
<div class="muted" style="margin-bottom:6px;">Перемещение</div> <div class="transform-grid">
<div class="transform-grid"> <label>X <input type="text" id="globalX"></label>
<label>X <input type="text" id="posX"></label> <label>Y <input type="text" id="globalY"></label>
<label>Y <input type="text" id="posY"></label> <label>Z <input type="text" id="globalZ"></label>
<label>Z <input type="text" id="posZ"></label> </div>
</div> </div>
<div class="muted" style="margin:8px 0 6px;">Вращение (deg)</div> <div class="panel-section">
<div class="transform-grid"> <h3>Трансформации</h3>
<label>X <input type="text" id="rotX"></label> <div class="muted" style="margin-bottom:6px;">Перемещение</div>
<label>Y <input type="text" id="rotY"></label> <div class="transform-grid">
<label>Z <input type="text" id="rotZ"></label> <label>X <input type="text" id="posX"></label>
<label>Y <input type="text" id="posY"></label>
<label>Z <input type="text" id="posZ"></label>
</div>
<div class="muted" style="margin:8px 0 6px;">Вращение (deg)</div>
<div class="transform-grid">
<label>X <input type="text" id="rotX"></label>
<label>Y <input type="text" id="rotY"></label>
<label>Z <input type="text" id="rotZ"></label>
</div>
<div class="muted" style="margin:8px 0 6px;">Масштаб</div>
<div class="transform-grid">
<label>X <input type="text" id="scaleX"></label>
<label>Y <input type="text" id="scaleY"></label>
<label>Z <input type="text" id="scaleZ"></label>
</div>
<small id="transformHint" class="muted">Выберите объект в инспекторе или сцене, чтобы редактировать.</small>
</div> </div>
<div class="muted" style="margin:8px 0 6px;">Масштаб</div> <div class="panel-section">
<div class="transform-grid"> <h3>Журнал</h3>
<label>X <input type="text" id="scaleX"></label> <div id="logList" class="log"></div>
<label>Y <input type="text" id="scaleY"></label> </div>
<label>Z <input type="text" id="scaleZ"></label> <div class="panel-section" id="saveProjectSection">
<button class="secondary-btn model-settings-btn" id="saveModelSettingsButton" type="button">Сохранить настройки</button>
<button class="primary-btn" id="saveProjectButton" type="button">Сохранить проект</button>
<button class="secondary-btn danger-btn" id="deleteProjectButton" type="button">Удалить проект</button>
</div> </div>
<small id="transformHint" class="muted">Выберите объект в инспекторе или сцене, чтобы редактировать.</small>
</div>
<div class="panel-section">
<h3>Журнал</h3>
<div id="logList" class="log"></div>
</div>
<div class="panel-section" id="saveProjectSection">
<button class="primary-btn" id="saveProjectButton" type="button">Сохранить проект</button>
<button class="secondary-btn danger-btn" id="deleteProjectButton" type="button">Удалить проект</button>
</div> </div>
</aside> </aside>
<div class="inspector-restore-tray" id="inspectorRestoreTray" hidden>
<button class="inspector-restore-button" id="inspectorRestoreButton" type="button">Инспектор</button>
</div>
<aside class="comments-panel hidden" id="commentsPanel">
<div class="comments-panel-header" id="commentsPanelHeader">
<h3>Комментарии</h3>
<button
class="inspector-collapse-btn"
id="commentsCollapseButton"
type="button"
title="Опустить комментарии"
aria-label="Опустить комментарии"
>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4 6.25 8 10.25 12 6.25" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
<div class="comments-panel-body">
<div id="commentsList" class="comments-list"></div>
</div>
</aside>
<div class="comments-restore-tray" id="commentsRestoreTray" hidden>
<button class="inspector-restore-button" id="commentsRestoreButton" type="button">Комментарии</button>
</div>
<div id="commentTargetsLayer" class="comment-targets-layer hidden"></div>
</main> </main>
<div id="templatesPanel" class="templates-panel hidden"></div> <div id="templatesPanel" class="templates-panel hidden"></div>
<div id="settingsPanel" class="settings-panel hidden"></div> <div id="settingsPanel" class="settings-panel hidden"></div>
<div id="commentContextMenu" class="nodedc-dropdown-surface bim-context-menu hidden" role="menu" aria-label="Действия по модели">
<button class="nodedc-dropdown-option bim-context-menu__item" data-comment-menu-action="create" type="button" role="menuitem">
<span>Комментарий</span>
</button>
</div>
<div id="measureModalBackdrop" class="modal-backdrop hidden"></div> <div id="measureModalBackdrop" class="modal-backdrop hidden"></div>
<div id="measureModal" class="templates-panel measurement-modal hidden"></div> <div id="measureModal" class="templates-panel measurement-modal hidden"></div>
<div id="objectModalBackdrop" class="modal-backdrop hidden"></div> <div id="objectModalBackdrop" class="modal-backdrop hidden"></div>
<div id="objectModal" class="templates-panel measurement-modal hidden"></div> <div id="objectModal" class="templates-panel measurement-modal hidden"></div>
<div id="commentModalBackdrop" class="modal-backdrop hidden"></div>
<div id="commentModal" class="comment-card-modal ops-comment-detail hidden" role="dialog" aria-modal="true" aria-label="Комментарий к модели">
<div class="comment-card-modal__header" id="commentModalHeader">
<div class="ops-detail-actions">
<button class="ops-detail-icon-btn" type="button" aria-label="Уведомления" title="Уведомления">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"></path>
<path d="M10 21h4"></path>
</svg>
</button>
<button class="ops-detail-icon-btn" id="commentModalExpandButton" type="button" aria-label="Развернуть" title="Развернуть">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 5h5v5M19 5l-7 7M10 19H5v-5M5 19l7-7"></path>
</svg>
</button>
<button class="ops-detail-icon-btn modal-close" id="commentModalClose" type="button" aria-label="Закрыть" title="Закрыть">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
</button>
</div>
</div>
<div class="comment-card-modal__body">
<section class="ops-detail-hero">
<div class="comment-card-modal__subtitle" id="commentModalSubtitle" hidden></div>
<input class="ops-detail-title-input" type="text" id="commentTitleInput" autocomplete="off" placeholder="Новый комментарий">
<textarea class="ops-detail-description-input" id="commentBodyInput" rows="2" placeholder="Нажмите, чтобы добавить описание"></textarea>
<div class="ops-detail-meta-row">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7M3 4v6h6"></path>
<path d="M12 7v5l3 2"></path>
</svg>
<span id="commentModalEditedMeta">Последнее редактирование: только что</span>
</div>
<div class="ops-detail-pills">
<button class="ops-detail-pill" id="commentAddSubitemButton" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3 4 7l8 4 8-4-8-4ZM4 12l8 4 8-4M4 17l8 4 8-4"></path>
</svg>
<span>Добавить подэлемент</span>
</button>
</div>
<div id="commentSubitemMenu" class="nodedc-dropdown-surface bim-subitem-menu hidden" role="menu" aria-label="Тип подэлемента">
<button class="nodedc-dropdown-option" data-comment-subitem-kind="text" type="button" role="menuitem">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16M4 12h10M4 18h12"></path>
</svg>
<span>Создать текстовый блок</span>
</button>
<button class="nodedc-dropdown-option" data-comment-subitem-kind="checker" type="button" role="menuitem">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 11l2 2 4-5"></path>
<path d="M4 5h16M4 12h3M4 19h16"></path>
</svg>
<span>Создать чекер</span>
</button>
</div>
</section>
<section class="ops-comment-subitems hidden" id="commentSubitemsSection">
<div class="ops-detail-section-title">Подэлементы</div>
<div class="ops-comment-subitems__list" id="commentSubitemsList"></div>
</section>
<section class="comment-attachments ops-detail-dropzone" id="commentDropzone">
<div class="ops-detail-dropzone__icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 16V7"></path>
<path d="m8 11 4-4 4 4"></path>
<path d="M20 16.5a4.5 4.5 0 0 0-8.7-1.6A3.5 3.5 0 1 0 6.5 19H18a2 2 0 0 0 2-2.5Z"></path>
</svg>
</div>
<div class="ops-detail-dropzone__copy">
<strong>Перетащите файлы сюда или нажмите для выбора</strong>
<span>Любой формат, несколько файлов за раз, до 50 МБ на файл</span>
</div>
<button type="button" class="comment-attach-button" id="commentAttachButton">Выбрать</button>
<input type="file" id="commentAttachmentInput" multiple hidden>
<div class="comment-attachment-grid" id="commentAttachmentGrid"></div>
</section>
<section class="comment-thread ops-detail-activity">
<div class="ops-detail-section-head">
<div class="comment-thread__title">Активность</div>
<div class="ops-detail-section-actions">
<button class="ops-detail-mini-btn" type="button" aria-label="Сортировка" title="Сортировка">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h12M4 12h8M4 18h4M18 8v10M15 15l3 3 3-3"></path>
</svg>
</button>
<button class="ops-detail-mini-btn" type="button" aria-label="Фильтр" title="Фильтр">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4"></path>
</svg>
</button>
</div>
</div>
<label class="ops-activity-composer">
<span>Добавить комментарий</span>
<textarea id="commentReplyInput" rows="5" placeholder="Добавить комментарий"></textarea>
<div class="ops-activity-composer__toolbar">
<button type="button" class="ops-detail-mini-btn" id="commentReplyAttachButton" aria-label="Прикрепить файл" title="Прикрепить файл">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m21.4 11.6-8.5 8.5a6 6 0 0 1-8.5-8.5l8.5-8.5a4 4 0 0 1 5.7 5.7l-8.5 8.5a2 2 0 0 1-2.8-2.8l7.8-7.8"></path>
</svg>
</button>
<button type="button" class="ops-detail-mini-btn" aria-label="Реакция" title="Реакция">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z"></path>
<path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"></path>
</svg>
</button>
<button type="button" class="ops-activity-send" id="commentApplyInlineButton" aria-label="Отправить комментарий" title="Отправить комментарий">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 19V5"></path>
<path d="m5 12 7-7 7 7"></path>
</svg>
</button>
<input type="file" id="commentReplyAttachmentInput" multiple hidden>
</div>
</label>
<div class="comment-attachment-grid comment-attachment-grid--reply" id="commentReplyAttachmentGrid"></div>
<div class="comment-thread__list" id="commentThreadList"></div>
</section>
</div>
<div class="comment-card-modal__footer">
<div class="error-text comment-card-modal__footer-error" id="commentModalError" hidden></div>
<button class="secondary-btn" id="commentCancelButton" type="button">Отменить</button>
<button class="primary-btn" id="commentApplyButton" type="button">Применить</button>
<button class="primary-btn" id="commentSaveButton" type="button">Сохранить</button>
</div>
</div>
<div id="saveProjectBackdrop" class="modal-backdrop hidden"></div> <div id="saveProjectBackdrop" class="modal-backdrop hidden"></div>
<div id="saveProjectModal" class="save-project-modal hidden"> <div id="saveProjectModal" class="save-project-modal hidden">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title">Сохранить проект</div> <div class="modal-title">Сохранить проект</div>
<button class="modal-close" id="saveProjectClose" aria-label="Закрыть">×</button> <button class="modal-close inspector-collapse-btn" id="saveProjectClose" aria-label="Закрыть">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
</button>
</div> </div>
<label for="saveProjectName" class="input-label">Имя проекта</label> <label for="saveProjectName" class="input-label">Имя проекта</label>
<input type="text" id="saveProjectName" placeholder="Новый проект" autocomplete="off"> <input type="text" id="saveProjectName" placeholder="Новый проект" autocomplete="off">
@ -210,7 +556,11 @@
<div id="deleteProjectModal" class="save-project-modal hidden"> <div id="deleteProjectModal" class="save-project-modal hidden">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title">Удалить проект</div> <div class="modal-title">Удалить проект</div>
<button class="modal-close" id="deleteProjectClose" aria-label="Закрыть">×</button> <button class="modal-close inspector-collapse-btn" id="deleteProjectClose" aria-label="Закрыть">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
</button>
</div> </div>
<div class="input-hint">Вы уверены, что хотите удалить проект?</div> <div class="input-hint">Вы уверены, что хотите удалить проект?</div>
<div class="modal-actions"> <div class="modal-actions">
@ -219,18 +569,118 @@
</div> </div>
<div class="error-text" id="deleteProjectError"></div> <div class="error-text" id="deleteProjectError"></div>
</div> </div>
<div id="shareModalBackdrop" class="modal-backdrop hidden"></div>
<div id="shareModal" class="save-project-modal hidden">
<div class="modal-header">
<div class="modal-title">Ссылка на модель</div>
<button class="modal-close inspector-collapse-btn" id="shareModalClose" aria-label="Закрыть">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
</button>
</div>
<input type="text" id="shareLinkInput" readonly autocomplete="off">
<div class="modal-actions">
<button class="secondary-btn" id="shareModalCancel" type="button">Закрыть</button>
<button class="primary-btn" id="shareCopyButton" type="button" disabled>Скопировать</button>
</div>
<div class="error-text" id="shareError"></div>
</div>
<div id="bottomToolbar" class="bottom-toolbar"> <div id="bottomToolbar" class="bottom-toolbar">
<button class="tb-btn" data-action="settings" title="Настройки"></button> <button class="tb-btn" data-action="settings" title="Настройки" aria-label="Настройки">
<button class="tb-btn" data-action="templates" title="Примеры">📂</button> <svg viewBox="0 0 24 24" aria-hidden="true">
<button class="tb-btn" data-action="measure" title="Линейка">📏</button> <path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"></path>
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.37a1.7 1.7 0 0 0-1 .56V20a2 2 0 0 1-4 0v-.08a1.7 1.7 0 0 0-1-.56 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.63 15a1.7 1.7 0 0 0-.56-1H4a2 2 0 0 1 0-4h.08a1.7 1.7 0 0 0 .56-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.63a1.7 1.7 0 0 0 1-.56V4a2 2 0 0 1 4 0v.08a1.7 1.7 0 0 0 1 .56 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.37 9c.18.38.38.73.56 1H20a2 2 0 0 1 0 4h-.08c-.18.27-.37.62-.52 1Z"></path>
</svg>
</button>
<button class="tb-btn" data-action="templates" title="Примеры" aria-label="Примеры">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3.5 6.5h6l2 2h9v8.75a2.25 2.25 0 0 1-2.25 2.25H5.75A2.25 2.25 0 0 1 3.5 17.25V6.5Z"></path>
<path d="M3.5 10h17"></path>
</svg>
</button>
<button class="tb-btn" data-action="measure" title="Линейка" aria-label="Линейка">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.5 15.5 15.5 4.5l4 4-11 11-4-4Z"></path>
<path d="m8 12 2 2"></path>
<path d="m10.5 9.5 1.5 1.5"></path>
<path d="m13 7 2 2"></path>
</svg>
</button>
<button class="tb-btn" data-action="section" title="Разрез" aria-label="Разрез">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.5 7.25 12 3.5l7.5 3.75L12 11 4.5 7.25Z"></path>
<path d="M4.5 16.75 12 20.5l7.5-3.75"></path>
<path d="M12 11v9.5"></path>
<path d="M6.25 4.5 17.75 19.5"></path>
</svg>
</button>
<button class="tb-btn" data-action="comments" title="Комментарии" aria-label="Комментарии">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6.5 5.5h11a2.5 2.5 0 0 1 2.5 2.5v6.25a2.5 2.5 0 0 1-2.5 2.5H12l-4.3 3.1v-3.1H6.5A2.5 2.5 0 0 1 4 14.25V8a2.5 2.5 0 0 1 2.5-2.5Z"></path>
</svg>
</button>
<button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button> <button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button>
<button class="tb-btn" data-action="add" title="Добавить файл"></button> <button class="tb-btn" data-action="share" title="Поделиться моделью" aria-label="Поделиться моделью">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 14.5V4.75"></path>
<path d="m8.25 8.5 3.75-3.75 3.75 3.75"></path>
<path d="M7.25 11.25H6.5a2 2 0 0 0-2 2v4.25a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-4.25a2 2 0 0 0-2-2h-.75"></path>
</svg>
</button>
<button class="tb-btn" data-action="add" title="Добавить файл" aria-label="Добавить файл">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
</button>
</div>
<div id="sectionToolbar" class="section-toolbar hidden" aria-label="Управление разрезом">
<canvas id="sectionPlanesOverview" class="section-overview" width="80" height="80"></canvas>
<button class="section-tool-btn" data-section-action="camera" type="button" title="По камере" aria-label="По камере">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M1.7 8s2.35-4 6.3-4 6.3 4 6.3 4-2.35 4-6.3 4-6.3-4-6.3-4Z"></path>
<circle cx="8" cy="8" r="2.15"></circle>
</svg>
</button>
<button class="section-tool-btn section-axis-btn" data-section-action="x" type="button" title="Ось X" aria-label="Ось X">
<svg class="section-letter-icon" viewBox="0 0 16 16" aria-hidden="true">
<text x="8" y="8" text-anchor="middle" dominant-baseline="central">X</text>
</svg>
</button>
<button class="section-tool-btn section-axis-btn" data-section-action="y" type="button" title="Ось Y" aria-label="Ось Y">
<svg class="section-letter-icon" viewBox="0 0 16 16" aria-hidden="true">
<text x="8" y="8" text-anchor="middle" dominant-baseline="central">Y</text>
</svg>
</button>
<button class="section-tool-btn section-axis-btn" data-section-action="z" type="button" title="Ось Z" aria-label="Ось Z">
<svg class="section-letter-icon" viewBox="0 0 16 16" aria-hidden="true">
<text x="8" y="8" text-anchor="middle" dominant-baseline="central">Z</text>
</svg>
</button>
<button class="section-tool-btn" data-section-action="flip" type="button" title="Инвертировать" aria-label="Инвертировать">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M5.5 4.7h4.4a3.05 3.05 0 0 1 0 6.1H5.6"></path>
<path d="M6.7 2.9 4.9 4.7l1.8 1.8"></path>
<path d="m9.3 13.1 1.8-1.8-1.8-1.8"></path>
</svg>
</button>
<button class="section-tool-btn" data-section-action="clear" type="button" title="Удалить разрез" aria-label="Удалить разрез">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.2 4.5h9.6"></path>
<path d="M6.2 4.5V3.1h3.6v1.4"></path>
<path d="m4.4 4.5.55 8.4h6.1l.55-8.4"></path>
<path d="M6.65 6.5v4.3"></path>
<path d="M9.35 6.5v4.3"></path>
</svg>
</button>
</div> </div>
<input id="fileInput" class="hidden" type="file" <input id="fileInput" class="hidden" type="file"
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl"> accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
<script type="module" src="./dcViewer.js?v=4"></script> <script type="module" src="/dcViewer.js?v=56"></script>
</body> </body>
</html> </html>

View File

@ -126510,6 +126510,9 @@ class TransformControl {
scale: [5, 5, 5], scale: [5, 5, 5],
isObject: false isObject: false
}); });
this._rootNode = rootNode;
this._styleScale = 1;
this._baseRootScale = 5;
const pos = math.vec3(); const pos = math.vec3();
this._setPosition = (function() { this._setPosition = (function() {
@ -126565,6 +126568,45 @@ class TransformControl {
axisHandle: tubeGeometry(handleRadius, arrowLength, 20) axisHandle: tubeGeometry(handleRadius, arrowLength, 20)
}; };
const updateGeometryPositions = (geometry, cfg) => {
if (geometry?.positions && cfg?.positions && geometry.positions.length === cfg.positions.length) {
geometry.positions = cfg.positions;
}
};
this._guideThicknessScale = 1;
this._setGuideThickness = (scale = 1) => {
const nextScale = Math.max(0.5, Math.min(2.5, Number(scale) || 1));
if (nextScale === this._guideThicknessScale) {
return;
}
this._guideThicknessScale = nextScale;
const guideTube = tubeRadius * nextScale;
updateGeometryPositions(shapes.curve, buildTorusGeometry({
radius: arrowLength - 0.2,
tube: guideTube,
radialSegments: 64,
tubeSegments: 14,
arc: (Math.PI * 2.0) * 0.25
}));
updateGeometryPositions(shapes.hoop, buildTorusGeometry({
radius: arrowLength - 0.2,
tube: guideTube,
radialSegments: 64,
tubeSegments: 8,
arc: Math.PI * 2.0
}));
updateGeometryPositions(shapes.axis, buildCylinderGeometry({
radiusTop: guideTube,
radiusBottom: guideTube,
radialSegments: 20,
heightSegments: 1,
height: arrowLength,
openEnded: false
}));
rootNode.scene?.glRedraw?.();
};
const colorMaterial = (rgb) => new PhongMaterial(rootNode, { const colorMaterial = (rgb) => new PhongMaterial(rootNode, {
diffuse: rgb, diffuse: rgb,
emissive: rgb, emissive: rgb,
@ -126831,6 +126873,16 @@ class TransformControl {
return { return {
setPositionActive: (a) => { positionActive = a; updatePositionHandle(); }, setPositionActive: (a) => { positionActive = a; updatePositionHandle(); },
setRotationActive: (a) => { rotationActive = a; updateRotationHandle(); }, setRotationActive: (a) => { rotationActive = a; updateRotationHandle(); },
setColor: (color) => {
if (!color) {
return;
}
material.diffuse = color;
material.emissive = color;
if (hoop.highlightMaterial) {
hoop.highlightMaterial.fillColor = color;
}
},
set visible(v) { set visible(v) {
visible = v; visible = v;
updatePositionHandle(); updatePositionHandle();
@ -126868,13 +126920,16 @@ class TransformControl {
{ // Keep gizmo screen size constant { // Keep gizmo screen size constant
let lastDist = -1; let lastDist = -1;
const setRootNodeScale = size => { const setRootNodeScale = size => {
if (rootNode.scale[0] !== size) { this._baseRootScale = size;
rootNode.scale = [size, size, size]; const styledSize = size * (this._styleScale || 1);
if (rootNode.scale[0] !== styledSize) {
rootNode.scale = [styledSize, styledSize, styledSize];
if (this._handlers && this._handlers.onScreenScale) { if (this._handlers && this._handlers.onScreenScale) {
this._handlers.onScreenScale(rootNode.scale); this._handlers.onScreenScale(rootNode.scale);
} }
} }
}; };
this._applyRootScale = () => setRootNodeScale(this._baseRootScale || rootNode.scale[0] || 5);
const onSceneTick = scene.on("tick", () => { const onSceneTick = scene.on("tick", () => {
const camera = scene.camera; const camera = scene.camera;
const dist = Math.abs(math.distVec3(camera.eye, pos)); const dist = Math.abs(math.distVec3(camera.eye, pos));
@ -127030,6 +127085,22 @@ class TransformControl {
this.__destroy(); this.__destroy();
} }
setStyle(style = {}) {
if (style.gizmoDiameter !== undefined) {
this._styleScale = style.gizmoDiameter;
this._applyRootScale?.();
}
if (style.gizmoThickness !== undefined) {
this._setGuideThickness?.(style.gizmoThickness);
}
if (style.axes) {
this._displayMeshes.x?.setColor?.(style.axes.x);
this._displayMeshes.y?.setColor?.(style.axes.y);
this._displayMeshes.z?.setColor?.(style.axes.z);
}
this._rootNode?.scene?.glRedraw?.();
}
/** /**
* Called to assign this Control to Handlers. * Called to assign this Control to Handlers.
* Call with a null or undefined value to disconnect the Control from whatever Handlers it was assigned to. * Call with a null or undefined value to disconnect the Control from whatever Handlers it was assigned to.
@ -127703,7 +127774,7 @@ class SectionPlanesPlugin extends Plugin {
const planeRoot = (function() { const planeRoot = (function() {
const rootNode = new Node$1(scene, { isObject: false }); const rootNode = new Node$1(scene, { isObject: false });
rootNode.addChild(new Mesh(rootNode, { // plane const planeMesh = rootNode.addChild(new Mesh(rootNode, { // plane
geometry: new ReadableGeometry(rootNode, { geometry: new ReadableGeometry(rootNode, {
primitive: "triangles", primitive: "triangles",
positions: [ positions: [
@ -127737,7 +127808,7 @@ class SectionPlanesPlugin extends Plugin {
isObject: false isObject: false
})); }));
rootNode.addChild(new Mesh(rootNode, { // Visible frame const frameMesh = rootNode.addChild(new Mesh(rootNode, { // Visible frame
geometry: new ReadableGeometry(rootNode, buildTorusGeometry({ geometry: new ReadableGeometry(rootNode, buildTorusGeometry({
center: [0, 0, 0], center: [0, 0, 0],
radius: 1.7, radius: 1.7,
@ -127769,6 +127840,38 @@ class SectionPlanesPlugin extends Plugin {
isObject: false isObject: false
})); }));
const setPhongMaterial = (material, color, alpha) => {
if (!material) {
return;
}
if (color) {
material.diffuse = color;
material.emissive = color;
}
if (alpha !== undefined) {
material.alpha = alpha;
material.alphaMode = alpha < 1 ? "blend" : "opaque";
}
};
const setEmphasisMaterial = (material, fillColor, fillAlpha, edgeColor, edgeAlpha) => {
if (!material) {
return;
}
if (fillColor) {
material.fillColor = fillColor;
}
if (fillAlpha !== undefined) {
material.fillAlpha = fillAlpha;
}
if (edgeColor) {
material.edgeColor = edgeColor;
}
if (edgeAlpha !== undefined) {
material.edgeAlpha = edgeAlpha;
}
};
return { return {
setPosition: (function() { setPosition: (function() {
const origin = math.vec3(); const origin = math.vec3();
@ -127781,7 +127884,26 @@ class SectionPlanesPlugin extends Plugin {
})(), })(),
setQuaternion: q => { rootNode.quaternion = q; }, setQuaternion: q => { rootNode.quaternion = q; },
setScale: s => { rootNode.scale = s; }, setScale: s => { rootNode.scale = s; },
setVisible: v => { rootNode.visible = v; } setVisible: v => { rootNode.visible = v; },
setStyle: (style = {}) => {
setPhongMaterial(planeMesh.material, style.planeFillColor, style.planeFillAlpha);
setEmphasisMaterial(
planeMesh.ghostMaterial,
style.planeFillColor,
style.planeFillAlpha,
style.planeEdgeColor,
style.planeEdgeAlpha
);
planeMesh.opacity = style.planeFillAlpha;
setPhongMaterial(frameMesh.material, style.planeEdgeColor, style.planeEdgeAlpha);
setEmphasisMaterial(
frameMesh.highlightMaterial,
style.planeEdgeColor,
style.planeEdgeAlpha,
style.planeEdgeColor,
style.planeEdgeAlpha
);
}
}; };
})(); })();
let unbindSectionPlane = () => { }; let unbindSectionPlane = () => { };
@ -127803,6 +127925,10 @@ class SectionPlanesPlugin extends Plugin {
}, },
setCulled: c => { culled = c; updateVisible(); }, setCulled: c => { culled = c; updateVisible(); },
setVisible: v => { visible = v; updateVisible(); }, setVisible: v => { visible = v; updateVisible(); },
setStyle: style => {
planeRoot.setStyle?.(style);
ctrl.setStyle?.(style);
},
_setSectionPlane: sectionPlane => { _setSectionPlane: sectionPlane => {
unbindSectionPlane(); unbindSectionPlane();
if (sectionPlane) { if (sectionPlane) {

View File

@ -0,0 +1,60 @@
const isHexColor = (value) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || "").trim());
export function createColorControl({
value = "#ffffff",
previewValue = value,
ariaLabel = "Выбрать цвет",
valueLabel = "Значение цвета",
onChange,
}) {
const root = document.createElement("div");
root.className = "env-color-field";
const swatch = document.createElement("label");
swatch.className = "env-color-field__swatch";
const preview = document.createElement("span");
preview.className = "env-color-field__preview";
const picker = document.createElement("input");
picker.type = "color";
picker.setAttribute("aria-label", ariaLabel);
const valueWrap = document.createElement("div");
valueWrap.className = "env-color-field__value";
const valueInput = document.createElement("input");
valueInput.type = "text";
valueInput.setAttribute("aria-label", valueLabel);
valueWrap.appendChild(valueInput);
const update = (nextValue, { emit = false } = {}) => {
const safeValue = String(nextValue ?? "");
const safePreview = isHexColor(safeValue)
? safeValue
: (isHexColor(previewValue) ? previewValue : "#000000");
valueInput.value = safeValue;
picker.value = isHexColor(safeValue) ? safeValue : safePreview;
preview.style.background = safePreview;
if (emit && typeof onChange === "function") {
onChange(safeValue);
}
};
picker.addEventListener("input", () => update(picker.value, { emit: true }));
valueInput.addEventListener("change", () => update(valueInput.value, { emit: true }));
swatch.appendChild(preview);
swatch.appendChild(picker);
root.appendChild(swatch);
root.appendChild(valueWrap);
update(value);
root.setValue = (nextValue) => update(nextValue);
root.getValue = () => valueInput.value;
return root;
}

View File

@ -1,6 +1,16 @@
export function createSliderControl({ label = "", value = 0, min = 0, max = 100, step = 1, onChange }) { export function createSliderControl({
label = "",
value = 0,
min = 0,
max = 100,
step = 1,
className = "",
showValue = false,
formatValue = (val) => String(val),
onChange
}) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "slider-row"; row.className = ["slider-row", className].filter(Boolean).join(" ");
const lbl = document.createElement("label"); const lbl = document.createElement("label");
lbl.textContent = label; lbl.textContent = label;
@ -14,9 +24,34 @@ export function createSliderControl({ label = "", value = 0, min = 0, max = 100,
input.step = step; input.step = step;
input.value = value; input.value = value;
const valueEl = document.createElement("div");
valueEl.className = "slider-value";
const fillText = document.createElement("span");
fillText.className = "slider-fill-text";
fillText.setAttribute("aria-hidden", "true");
const fillLabel = document.createElement("span");
fillLabel.textContent = label;
fillLabel.className = "slider-label";
const fillValue = document.createElement("span");
fillValue.className = "slider-value";
fillText.appendChild(fillLabel);
fillText.appendChild(fillValue);
const updateFill = () => { const updateFill = () => {
const percent = ((input.value - input.min) / (input.max - input.min)) * 100; const range = Number(input.max) - Number(input.min);
const percent = range
? Math.max(0, Math.min(100, ((Number(input.value) - Number(input.min)) / range) * 100))
: 0;
row.style.setProperty("--slider-percent", `${percent}%`);
input.style.setProperty("--slider-percent", `${percent}%`); input.style.setProperty("--slider-percent", `${percent}%`);
if (showValue) {
const formattedValue = formatValue(Number(input.value));
valueEl.textContent = formattedValue;
fillValue.textContent = formattedValue;
}
}; };
input.addEventListener("input", () => { input.addEventListener("input", () => {
@ -29,5 +64,14 @@ export function createSliderControl({ label = "", value = 0, min = 0, max = 100,
row.appendChild(lbl); row.appendChild(lbl);
row.appendChild(input); row.appendChild(input);
if (showValue) {
row.appendChild(valueEl);
row.appendChild(fillText);
}
row.setValue = (nextValue) => {
input.value = nextValue;
updateFill();
};
row.getValue = () => Number(input.value);
return row; return row;
} }

View File

@ -0,0 +1,216 @@
const closeEventName = "nodedc-select-open";
const makeSelectId = () => `ndc-select-${Math.random().toString(16).slice(2)}`;
export function createGlassSelect({
host,
value,
options = [],
ariaLabel = "Выбрать",
disabled = false,
onChange = null,
}) {
if (!host) return null;
const selectId = makeSelectId();
let currentValue = value;
let currentOptions = [...options];
let open = false;
let menu = null;
let menuRect = { top: 0, left: 0, width: 0 };
host.innerHTML = "";
const root = document.createElement("div");
root.className = "nodedc-select";
const control = document.createElement("div");
control.className = "nodedc-select__control";
const valueEl = document.createElement("div");
valueEl.className = "nodedc-select__value";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "nodedc-select__toggle";
toggle.setAttribute("aria-label", ariaLabel);
toggle.setAttribute("aria-haspopup", "listbox");
toggle.disabled = !!disabled;
const chevron = document.createElement("span");
chevron.className = "nodedc-select__chevron";
chevron.setAttribute("aria-hidden", "true");
toggle.appendChild(chevron);
control.appendChild(valueEl);
control.appendChild(toggle);
root.appendChild(control);
host.appendChild(root);
const selectedOption = () => (
currentOptions.find((option) => option.value === currentValue) ||
currentOptions[0] ||
null
);
const updateValue = () => {
const selected = selectedOption();
valueEl.textContent = selected?.label || "";
toggle.setAttribute("aria-expanded", open ? "true" : "false");
};
const updateMenuRect = () => {
const rect = root.getBoundingClientRect();
const toggleWidth = 42;
const gap = 8;
const menuMaxHeight = 232;
const bottomTop = rect.bottom + 6;
const top = bottomTop + menuMaxHeight > window.innerHeight - 12
? Math.max(12, rect.top - menuMaxHeight - 6)
: bottomTop;
menuRect = {
top,
left: rect.left,
width: Math.max(180, rect.width - toggleWidth - gap),
};
if (menu) {
menu.style.top = `${menuRect.top}px`;
menu.style.left = `${menuRect.left}px`;
menu.style.width = `${menuRect.width}px`;
}
};
const renderMenuOptions = () => {
if (!menu) return;
menu.innerHTML = "";
currentOptions.forEach((option) => {
const item = document.createElement("button");
item.type = "button";
item.className = "nodedc-dropdown-option nodedc-select__option";
item.dataset.value = option.value;
if (option.value === currentValue) {
item.dataset.selected = "true";
}
item.setAttribute("role", "option");
item.setAttribute("aria-selected", option.value === currentValue ? "true" : "false");
item.textContent = option.label;
item.addEventListener("click", () => {
setValue(option.value, { emit: true });
closeMenu();
});
menu.appendChild(item);
});
};
const openMenu = () => {
if (toggle.disabled || open) return;
open = true;
updateValue();
updateMenuRect();
window.dispatchEvent(new CustomEvent(closeEventName, { detail: { id: selectId } }));
menu = document.createElement("div");
menu.className = "nodedc-dropdown-surface nodedc-select__menu";
menu.setAttribute("role", "listbox");
menu.style.position = "fixed";
menu.style.top = `${menuRect.top}px`;
menu.style.left = `${menuRect.left}px`;
menu.style.width = `${menuRect.width}px`;
renderMenuOptions();
document.body.appendChild(menu);
document.addEventListener("mousedown", handleDocumentPointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener(closeEventName, handleOtherSelectOpen);
window.addEventListener("resize", updateMenuRect);
window.addEventListener("scroll", updateMenuRect, true);
};
function closeMenu() {
if (!open) return;
open = false;
updateValue();
if (menu) {
menu.remove();
menu = null;
}
document.removeEventListener("mousedown", handleDocumentPointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener(closeEventName, handleOtherSelectOpen);
window.removeEventListener("resize", updateMenuRect);
window.removeEventListener("scroll", updateMenuRect, true);
}
function handleDocumentPointerDown(event) {
const target = event.target;
if (
target instanceof Node &&
!root.contains(target) &&
(!menu || !menu.contains(target))
) {
closeMenu();
}
}
function handleKeyDown(event) {
if (event.key === "Escape") closeMenu();
}
function handleOtherSelectOpen(event) {
if (event?.detail?.id !== selectId) closeMenu();
}
function setValue(nextValue, { emit = false } = {}) {
const nextOption = currentOptions.find((option) => option.value === nextValue);
if (!nextOption) return;
const changed = currentValue !== nextValue;
currentValue = nextValue;
updateValue();
renderMenuOptions();
if (emit && changed && typeof onChange === "function") {
onChange(nextValue);
}
}
const setOptions = (nextOptions = [], nextValue = currentValue) => {
currentOptions = [...nextOptions];
currentValue = currentOptions.some((option) => option.value === nextValue)
? nextValue
: currentOptions[0]?.value;
updateValue();
renderMenuOptions();
};
const setDisabled = (nextDisabled) => {
toggle.disabled = !!nextDisabled;
if (toggle.disabled) closeMenu();
};
const destroy = () => {
closeMenu();
root.remove();
};
toggle.addEventListener("click", () => {
if (open) {
closeMenu();
} else {
openMenu();
}
});
valueEl.addEventListener("click", openMenu);
updateValue();
return {
root,
setValue,
setOptions,
setDisabled,
close: closeMenu,
destroy,
getValue: () => currentValue,
};
}

View File

@ -3,7 +3,7 @@ export function createLogo({
aspect = 4, // соотношение width / height контейнера aspect = 4, // соотношение width / height контейнера
offsetY = 0, offsetY = 0,
paddingX = 0, paddingX = 0,
imageUrl = "./assets/logo.svg", imageUrl = "/assets/logo.svg",
} = {}) { } = {}) {
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "ue-logo"; wrapper.className = "ue-logo";

View File

@ -1,7 +1,8 @@
import { createSliderControl } from "./controls/slider.js"; import { createSliderControl } from "./controls/slider.js";
import { createColorControl } from "./controls/color.js";
const defaultSettings = { const defaultSettings = {
themeVersion: "v3-yellow", themeVersion: "v7-toolbar-hub-icon",
bgOuter: "#0f0f0f", bgOuter: "#0f0f0f",
canvasTop: "#1c1c1c", canvasTop: "#1c1c1c",
canvasBottom: "#1c1c1c", canvasBottom: "#1c1c1c",
@ -9,53 +10,199 @@ const defaultSettings = {
loadingBottom: "#1c1c1c", loadingBottom: "#1c1c1c",
accent: "#ffea00", accent: "#ffea00",
accent2: "#ffea00", accent2: "#ffea00",
templateFill: "#1c1c1c", templateFill: "#292929",
templateOutline: "#233044", templateOutline: "#292929",
templateOutlineHover: "rgba(255,234,0,0.7)", templateOutlineHover: "#1c1c1c",
projectBtnFill: "#1c1c1c", projectBtnFill: "#292929",
projectBtnBorder: "#233044", projectBtnBorder: "#292929",
projectBtnHover: "rgba(255,234,0,0.7)", projectBtnHover: "#1c1c1c",
modalBtnPrimary: "#ffea00", modalBtnPrimary: "#c4ff67",
modalBtnSecondary: "#292929", modalBtnSecondary: "#292929",
loaderColor: "#ffea00", loaderColor: "#c4ff67",
templateGlow: 80, templateGlow: 80,
toolbarBg: "#1c1c1c", toolbarBg: "#292929",
toolbarBgActive: "#ffea00", toolbarBgActive: "#c4ff67",
toolbarBorder: "#233044", toolbarBorder: "#292929",
toolbarOutline: "#ffea00", toolbarOutline: "#292929",
toolbarMinSize: 25,
toolbarMaxSize: 87,
toolbarLensCount: 5,
toolbarAutoHide: false,
modalBg: "#1c1c1c", modalBg: "#1c1c1c",
modalBgAlpha: 1, modalBgAlpha: 1,
modalFocus: "#292929", modalFocus: "#292929",
modalFocusAlpha: 1, modalFocusAlpha: 0.35,
modalBorder: "#292929", modalBorder: "#1c1c1c",
modalElement: "#292929", modalElement: "#262626",
modalElementOutline: "#2b2b2b", modalElementOutline: "#262626",
modalRadius: "14px", modalRadius: "14px",
border: "#233044", border: "#233044",
text: "#e6edf3", text: "#e6edf3",
panel: "#1c1c1c", panel: "#1c1c1c",
panel2: "#292929", panel2: "#292929",
highlight: "#ffea00", highlight: "#c4ff67",
cubeText: "#242424", cubeText: "#242424",
cubeEdges: "#e0e7ff", cubeEdges: "#e0e7ff",
plus: "#ffea00", plus: "#c4ff67",
navCubeBase: "#ffea00", navCubeBase: "#ffffff",
navCubeHover: "#ffffff", navCubeHover: "#ffffff",
navCubeText: "#242424", navCubeText: "#242424",
navCubeEdge: "#e0e7ff", navCubeEdge: "#e0e7ff",
navCubeEdgeAlpha: 0.6, navCubeEdgeAlpha: 0.6,
measureLine: "#ffea00", measureLine: "#c4ff67",
measureLabel: "#ffea00", measureLabel: "#c4ff67",
measureLabelText: "#292929", measureLabelText: "#292929",
measureDot: "#ffea00", measureDot: "#c4ff67",
sectionAxisX: "#ff2b2b",
sectionAxisY: "#35ff4f",
sectionAxisZ: "#285bff",
sectionGizmoDiameter: 1,
sectionGizmoThickness: 1,
sectionPlaneFill: "#606060",
sectionPlaneAlpha: 0.32,
sectionPlaneEdge: "#0d0d0d",
sectionPlaneEdgeAlpha: 0.8,
commentTargetActiveColor: "#c4ff67",
commentTargetActiveAlpha: 1,
commentTargetActiveSize: 30,
commentTargetPassiveColor: "#f5f5fa",
commentTargetPassiveAlpha: 0.58,
commentTargetPassiveSize: 22,
}; };
const STORAGE_KEY = "bimdc-settings-v3"; const STORAGE_KEY = "bimdc-settings-v3";
const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"]; const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"];
const isSharedViewerPath = () => (
typeof window !== "undefined" &&
/^\/share\/[^/]+\/?$/.test(window.location.pathname || "")
);
const isReadonlySettingsContext = () => {
if (typeof document !== "undefined") {
const mode = document.body?.dataset?.viewerMode;
if (mode) return mode === "guest";
}
return isSharedViewerPath();
};
const projectSettings = typeof window !== "undefined" && window.__BIMDC_THEME__ const projectSettings = typeof window !== "undefined" && window.__BIMDC_THEME__
? { ...defaultSettings, ...window.__BIMDC_THEME__ } ? { ...defaultSettings, ...window.__BIMDC_THEME__ }
: { ...defaultSettings }; : { ...defaultSettings };
const parseColor = (value) => {
if (typeof value !== "string") return null;
const color = value.trim();
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hex) {
const raw = hex[1].length === 3
? hex[1].split("").map((c) => c + c).join("")
: hex[1];
return [
parseInt(raw.slice(0, 2), 16),
parseInt(raw.slice(2, 4), 16),
parseInt(raw.slice(4, 6), 16),
];
}
const rgb = color.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
if (!rgb) return null;
return rgb.slice(1, 4).map((channel) => Math.max(0, Math.min(255, Number(channel))));
};
const readableTextColor = (value) => {
const rgb = parseColor(value);
if (!rgb || rgb.some((channel) => !Number.isFinite(channel))) return "#15170f";
const toLinear = (channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
const lum = 0.2126 * toLinear(rgb[0]) + 0.7152 * toLinear(rgb[1]) + 0.0722 * toLinear(rgb[2]);
return (lum + 0.05) / 0.05 >= 1.05 / (lum + 0.05) ? "#15170f" : "#f5f5fa";
};
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const colorParts = (value, fallback) => {
const parsed = parseColor(value);
if (parsed && parsed.every((channel) => Number.isFinite(channel))) return parsed;
const parsedFallback = parseColor(fallback);
if (parsedFallback && parsedFallback.every((channel) => Number.isFinite(channel))) return parsedFallback;
return [28, 28, 28];
};
const alphaValue = (value, fallback) => {
const numeric = Number(value);
return Number.isFinite(numeric) ? clamp(numeric, 0, 1) : fallback;
};
const rgba = (parts, alpha) => {
const [r, g, b] = parts.map((channel) => Math.round(clamp(channel, 0, 255)));
return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1).toFixed(3)})`;
};
const applyGlassSettings = (root, s) => {
const panelColor = colorParts(s.modalBg, defaultSettings.modalBg);
const focusColor = colorParts(s.modalFocus, defaultSettings.modalFocus);
const controlColor = colorParts(s.modalElement, s.modalFocus || defaultSettings.modalElement);
const outlineColor = colorParts(s.modalBorder, s.modalElementOutline || defaultSettings.modalBorder);
const controlOutlineColor = colorParts(s.modalElementOutline, s.modalBorder || defaultSettings.modalElementOutline);
const modalBgAlpha = alphaValue(s.modalBgAlpha, defaultSettings.modalBgAlpha);
const modalFocusAlpha = alphaValue(s.modalFocusAlpha, defaultSettings.modalFocusAlpha);
const panelAlpha = clamp(0.24 + modalBgAlpha * 0.48, 0.32, 0.88);
const panelStrongAlpha = clamp(panelAlpha + 0.1, 0.42, 0.92);
const panelSoftAlpha = clamp(panelAlpha - 0.16, 0.2, 0.72);
const sectionAlpha = clamp(modalFocusAlpha * 0.6, 0.04, 0.42);
const controlAlpha = clamp(0.12 + modalFocusAlpha * 0.9, 0.16, 0.66);
const controlHoverAlpha = clamp(controlAlpha + 0.08, 0.22, 0.74);
const outlineAlpha = clamp(0.055 + modalFocusAlpha * 0.18, 0.065, 0.2);
const controlOutlineAlpha = clamp(0.045 + modalFocusAlpha * 0.12, 0.055, 0.16);
const panelBg = rgba(panelColor, panelAlpha);
const panelBgStrong = rgba(panelColor, panelStrongAlpha);
const panelBgSoft = rgba(panelColor, panelSoftAlpha);
const sectionBg = rgba(focusColor, sectionAlpha);
const outline = rgba(outlineColor, outlineAlpha);
const outlineBright = rgba(outlineColor, clamp(outlineAlpha + 0.05, 0.08, 0.26));
const outlineMid = rgba(outlineColor, clamp(outlineAlpha + 0.015, 0.07, 0.22));
const outlineDim = rgba(outlineColor, clamp(outlineAlpha * 0.62, 0.04, 0.16));
const controlBg = rgba(controlColor, controlAlpha);
const controlHover = rgba(focusColor, controlHoverAlpha);
const controlShadow = rgba(controlOutlineColor, controlOutlineAlpha);
root.style.setProperty("--nodedc-glass-panel-bg", panelBg);
root.style.setProperty("--nodedc-glass-panel-bg-strong", panelBgStrong);
root.style.setProperty("--nodedc-glass-panel-bg-soft", panelBgSoft);
root.style.setProperty(
"--nodedc-glass-panel-surface",
`linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.014) 100%), ${panelBg}`
);
root.style.setProperty(
"--nodedc-glass-panel-surface-strong",
`linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.016) 100%), ${panelBgStrong}`
);
root.style.setProperty(
"--nodedc-glass-section-bg",
`linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.01) 100%), ${sectionBg}`
);
root.style.setProperty("--nodedc-glass-outline", outline);
root.style.setProperty(
"--nodedc-glass-outline-gradient",
`linear-gradient(180deg, ${outlineBright} 0%, ${outlineMid} 56%, ${outlineDim} 100%)`
);
root.style.setProperty("--nodedc-glass-panel-shadow", `inset 0 1px 0 ${outlineDim}, 0 22px 72px rgba(0, 0, 0, 0.42)`);
root.style.setProperty(
"--nodedc-glass-control-bg",
`linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.018) 100%), ${controlBg}`
);
root.style.setProperty(
"--nodedc-glass-control-hover",
`linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.026) 100%), ${controlHover}`
);
root.style.setProperty("--nodedc-glass-control-shadow", `inset 0 1px 0 ${controlShadow}`);
root.style.setProperty("--nodedc-header-dropdown-bg", panelBgStrong);
root.style.setProperty("--nodedc-header-dropdown-item-bg", controlBg);
root.style.setProperty("--nodedc-header-dropdown-item-hover-bg", controlHover);
root.style.setProperty("--nodedc-header-dropdown-item-active-bg", s.toolbarBgActive);
root.style.setProperty("--nodedc-header-dropdown-item-active-color", readableTextColor(s.toolbarBgActive));
};
const applySettings = (s) => { const applySettings = (s) => {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty("--bg-outer", s.bgOuter); root.style.setProperty("--bg-outer", s.bgOuter);
@ -72,13 +219,26 @@ const applySettings = (s) => {
root.style.setProperty("--project-btn-border", s.projectBtnBorder); root.style.setProperty("--project-btn-border", s.projectBtnBorder);
root.style.setProperty("--project-btn-hover", s.projectBtnHover); root.style.setProperty("--project-btn-hover", s.projectBtnHover);
root.style.setProperty("--modal-btn-primary", s.modalBtnPrimary); root.style.setProperty("--modal-btn-primary", s.modalBtnPrimary);
root.style.setProperty("--modal-btn-primary-contrast", readableTextColor(s.modalBtnPrimary));
root.style.setProperty("--modal-btn-secondary", s.modalBtnSecondary); root.style.setProperty("--modal-btn-secondary", s.modalBtnSecondary);
root.style.setProperty("--loader-color", s.loaderColor); root.style.setProperty("--loader-color", s.loaderColor);
root.style.setProperty("--template-glow-strength", `${s.templateGlow}%`); root.style.setProperty("--template-glow-strength", `${s.templateGlow}%`);
root.style.setProperty("--toolbar-bg", s.toolbarBg); root.style.setProperty("--toolbar-bg", s.toolbarBg);
root.style.setProperty("--toolbar-bg-active", s.toolbarBgActive); root.style.setProperty("--toolbar-bg-active", s.toolbarBgActive);
root.style.setProperty("--toolbar-bg-active-contrast", readableTextColor(s.toolbarBgActive));
root.style.setProperty("--toolbar-border", s.toolbarBorder); root.style.setProperty("--toolbar-border", s.toolbarBorder);
root.style.setProperty("--toolbar-outline", s.toolbarOutline); root.style.setProperty("--toolbar-outline", s.toolbarOutline);
const toolbarMinRaw = Number(s.toolbarMinSize ?? defaultSettings.toolbarMinSize);
const toolbarMaxRaw = Number(s.toolbarMaxSize ?? defaultSettings.toolbarMaxSize);
const toolbarLensRaw = Number(s.toolbarLensCount ?? defaultSettings.toolbarLensCount);
const toolbarMinSize = clamp(Number.isFinite(toolbarMinRaw) ? toolbarMinRaw : defaultSettings.toolbarMinSize, 18, 88);
const toolbarMaxSize = Math.max(toolbarMinSize, clamp(Number.isFinite(toolbarMaxRaw) ? toolbarMaxRaw : defaultSettings.toolbarMaxSize, 18, 96));
const toolbarLensCount = Math.max(1, Math.min(15, Math.round(Number.isFinite(toolbarLensRaw) ? toolbarLensRaw : defaultSettings.toolbarLensCount)));
root.style.setProperty("--tb-size", `${toolbarMinSize}px`);
root.style.setProperty("--tb-min-size", `${toolbarMinSize}px`);
root.style.setProperty("--tb-max-size", `${toolbarMaxSize}px`);
root.style.setProperty("--tb-lens-count", `${toolbarLensCount % 2 === 0 ? toolbarLensCount + 1 : toolbarLensCount}`);
root.style.setProperty("--tb-autohide", s.toolbarAutoHide ? "1" : "0");
const bgAlphaPct = `${(s.modalBgAlpha ?? 1) * 100}%`; const bgAlphaPct = `${(s.modalBgAlpha ?? 1) * 100}%`;
const focusAlphaPct = `${(s.modalFocusAlpha ?? 1) * 100}%`; const focusAlphaPct = `${(s.modalFocusAlpha ?? 1) * 100}%`;
root.style.setProperty("--modal-bg", s.modalBg); root.style.setProperty("--modal-bg", s.modalBg);
@ -89,6 +249,7 @@ const applySettings = (s) => {
root.style.setProperty("--modal-element", s.modalElement); root.style.setProperty("--modal-element", s.modalElement);
root.style.setProperty("--modal-element-outline", s.modalElementOutline); root.style.setProperty("--modal-element-outline", s.modalElementOutline);
root.style.setProperty("--modal-radius", s.modalRadius); root.style.setProperty("--modal-radius", s.modalRadius);
applyGlassSettings(root, s);
root.style.setProperty("--border", s.border); root.style.setProperty("--border", s.border);
root.style.setProperty("--text", s.text); root.style.setProperty("--text", s.text);
root.style.setProperty("--panel", s.panel); root.style.setProperty("--panel", s.panel);
@ -108,11 +269,27 @@ const applySettings = (s) => {
root.style.setProperty("--measure-label", s.measureLabel); root.style.setProperty("--measure-label", s.measureLabel);
root.style.setProperty("--measure-label-text", s.measureLabelText); root.style.setProperty("--measure-label-text", s.measureLabelText);
root.style.setProperty("--measure-dot", s.measureDot); root.style.setProperty("--measure-dot", s.measureDot);
root.style.setProperty("--section-axis-x", s.sectionAxisX);
root.style.setProperty("--section-axis-y", s.sectionAxisY);
root.style.setProperty("--section-axis-z", s.sectionAxisZ);
root.style.setProperty("--section-gizmo-diameter", s.sectionGizmoDiameter);
root.style.setProperty("--section-gizmo-thickness", s.sectionGizmoThickness);
root.style.setProperty("--section-plane-fill", s.sectionPlaneFill);
root.style.setProperty("--section-plane-alpha", s.sectionPlaneAlpha);
root.style.setProperty("--section-plane-edge", s.sectionPlaneEdge);
root.style.setProperty("--section-plane-edge-alpha", s.sectionPlaneEdgeAlpha);
root.style.setProperty("--comment-target-active", s.commentTargetActiveColor);
root.style.setProperty("--comment-target-active-alpha", s.commentTargetActiveAlpha);
root.style.setProperty("--comment-target-active-size", `${s.commentTargetActiveSize}px`);
root.style.setProperty("--comment-target-passive", s.commentTargetPassiveColor);
root.style.setProperty("--comment-target-passive-alpha", s.commentTargetPassiveAlpha);
root.style.setProperty("--comment-target-passive-size", `${s.commentTargetPassiveSize}px`);
// подсветка объектов // подсветка объектов
root.style.setProperty("--highlight-color", s.highlight); root.style.setProperty("--highlight-color", s.highlight);
}; };
const saveSettings = (s) => { const saveSettings = (s) => {
if (isReadonlySettingsContext()) return;
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch (e) { } catch (e) {
@ -121,6 +298,9 @@ const saveSettings = (s) => {
}; };
const loadSettings = () => { const loadSettings = () => {
if (isReadonlySettingsContext()) {
return { ...projectSettings };
}
try { try {
// purge legacy keys so старые палитры не мешали // purge legacy keys so старые палитры не мешали
LEGACY_KEYS.forEach((k) => localStorage.removeItem(k)); LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
@ -186,11 +366,20 @@ export function createSettingsMenu({ panel, controls = {}, onChange = null, onSh
header.innerHTML = `<span class="settings-title">Настройки</span>`; header.innerHTML = `<span class="settings-title">Настройки</span>`;
const closeBtn = document.createElement("button"); const closeBtn = document.createElement("button");
closeBtn.className = "settings-close"; closeBtn.className = "settings-close";
closeBtn.textContent = "—"; closeBtn.setAttribute("aria-label", "Закрыть настройки");
closeBtn.innerHTML = `
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
`;
closeBtn.addEventListener("click", hide); closeBtn.addEventListener("click", hide);
header.appendChild(closeBtn); header.appendChild(closeBtn);
panel.appendChild(header); panel.appendChild(header);
const body = document.createElement("div");
body.className = "settings-panel-body";
panel.appendChild(body);
const section = (title) => { const section = (title) => {
const box = document.createElement("div"); const box = document.createElement("div");
box.className = "settings-section"; box.className = "settings-section";
@ -202,29 +391,103 @@ export function createSettingsMenu({ panel, controls = {}, onChange = null, onSh
}; };
const addColor = (parent, label, key) => { const addColor = (parent, label, key) => {
const isHex = (val) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test((val || "").trim());
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "color-row"; row.className = "color-row";
const lbl = document.createElement("label"); const lbl = document.createElement("div");
lbl.textContent = label;
const control = createColorControl({
value: state[key],
onChange: (nextValue) => {
setVal(key, nextValue);
},
});
row.appendChild(lbl);
row.appendChild(control);
parent.appendChild(row);
};
const addSettingsSlider = ({
parent,
label,
key,
value,
min = 0,
max = 100,
step = 1,
formatValue = (nextValue) => String(nextValue),
onChange,
}) => {
const slider = createSliderControl({
label,
value,
min,
max,
step,
className: "env-range-control",
showValue: true,
formatValue,
onChange: (nextValue) => {
if (typeof onChange === "function") {
onChange(nextValue);
return;
}
setVal(key, nextValue);
},
});
parent.appendChild(slider);
return slider;
};
const addNumber = ({
parent,
label,
value,
min = 0,
max,
step = 1,
onChange,
}) => {
const row = document.createElement("div");
row.className = "color-row";
const lbl = document.createElement("div");
lbl.textContent = label; lbl.textContent = label;
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "color"; input.className = "env-number-field";
input.value = isHex(state[key]) ? state[key] : "#ffffff"; input.type = "number";
const hex = document.createElement("input"); input.value = value;
hex.type = "text"; input.min = min;
hex.value = state[key]; input.step = step;
input.addEventListener("change", (e) => { if (max !== undefined) {
hex.value = e.target.value; input.max = max;
setVal(key, e.target.value); }
}); input.addEventListener("input", () => {
hex.addEventListener("change", (e) => { const nextValue = Number(input.value);
input.value = e.target.value; if (Number.isFinite(nextValue)) {
setVal(key, e.target.value); onChange?.(nextValue);
}
}); });
row.appendChild(lbl); row.appendChild(lbl);
row.appendChild(input); row.appendChild(input);
row.appendChild(hex);
parent.appendChild(row); parent.appendChild(row);
return input;
};
const addBoolean = (parent, label, key) => {
const row = document.createElement("label");
row.className = "checkbox-row";
const text = document.createElement("span");
text.className = "checkbox-row__label";
text.textContent = label;
const chk = document.createElement("input");
chk.type = "checkbox";
chk.checked = !!state[key];
chk.addEventListener("change", () => {
setVal(key, chk.checked);
});
row.appendChild(text);
row.appendChild(chk);
parent.appendChild(row);
return chk;
}; };
const font = section("Фон"); const font = section("Фон");
@ -233,157 +496,302 @@ export function createSettingsMenu({ panel, controls = {}, onChange = null, onSh
addColor(font, "Фон холста низ", "canvasBottom"); addColor(font, "Фон холста низ", "canvasBottom");
addColor(font, "Фон загрузки верх", "loadingTop"); addColor(font, "Фон загрузки верх", "loadingTop");
addColor(font, "Фон загрузки низ", "loadingBottom"); addColor(font, "Фон загрузки низ", "loadingBottom");
panel.appendChild(font); body.appendChild(font);
const buttons = section("Кнопки"); const buttons = section("Кнопки");
addColor(buttons, "Плюс", "plus"); addColor(buttons, "Плюс", "plus");
addColor(buttons, "Кнопки (заливка)", "templateFill"); addColor(buttons, "Кнопки (заливка)", "templateFill");
addColor(buttons, "Кнопки (бордер)", "templateOutline"); addColor(buttons, "Кнопки (бордер)", "templateOutline");
addColor(buttons, "Кнопки (outline hover)", "templateOutlineHover"); addColor(buttons, "Кнопки (outline hover)", "templateOutlineHover");
const brightnessSlider = createSliderControl({ addSettingsSlider({
label: "Яркость подсветки", parent: buttons,
value: Number(state.templateGlow ?? 80), label: "Яркость подсветки",
min: 0, key: "templateGlow",
max: 100, value: Number(state.templateGlow ?? 80),
step: 1, min: 0,
onChange: (val) => setVal("templateGlow", val), max: 100,
}); step: 1,
buttons.appendChild(brightnessSlider); formatValue: (val) => `${Math.round(val)}%`,
panel.appendChild(buttons); });
body.appendChild(buttons);
const projectButtons = section("Кнопки проектов"); const projectButtons = section("Кнопки проектов");
addColor(projectButtons, "Проекты (заливка)", "projectBtnFill"); addColor(projectButtons, "Проекты (заливка)", "projectBtnFill");
addColor(projectButtons, "Проекты (бордер)", "projectBtnBorder"); addColor(projectButtons, "Проекты (бордер)", "projectBtnBorder");
addColor(projectButtons, "Проекты (hover)", "projectBtnHover"); addColor(projectButtons, "Проекты (hover)", "projectBtnHover");
panel.appendChild(projectButtons); body.appendChild(projectButtons);
const modalButtons = section("Кнопки модалок"); const modalButtons = section("Кнопки модалок");
addColor(modalButtons, "Модальная primary", "modalBtnPrimary"); addColor(modalButtons, "Модальная primary", "modalBtnPrimary");
addColor(modalButtons, "Модальная secondary", "modalBtnSecondary"); addColor(modalButtons, "Модальная secondary", "modalBtnSecondary");
addColor(modalButtons, "Лоудер", "loaderColor"); addColor(modalButtons, "Лоудер", "loaderColor");
panel.appendChild(modalButtons); body.appendChild(modalButtons);
const toolbar = section("Toolbar"); const toolbar = section("Toolbar");
addColor(toolbar, "Фон", "toolbarBg"); addColor(toolbar, "Фон", "toolbarBg");
addColor(toolbar, "Актив", "toolbarBgActive"); addColor(toolbar, "Актив", "toolbarBgActive");
addColor(toolbar, "Бордер", "toolbarBorder"); addColor(toolbar, "Бордер", "toolbarBorder");
addColor(toolbar, "Outline", "toolbarOutline"); addColor(toolbar, "Outline", "toolbarOutline");
panel.appendChild(toolbar); addSettingsSlider({
parent: toolbar,
label: "Минимальный размер",
value: Math.max(18, Math.min(42, Number(state.toolbarMinSize ?? defaultSettings.toolbarMinSize))),
min: 18,
max: 42,
step: 1,
formatValue: (val) => `${Math.round(val)}px`,
onChange: (val) => {
const next = Math.max(18, Math.min(42, Math.round(Number(val))));
setVal("toolbarMinSize", next);
if (Number(state.toolbarMaxSize) < next) {
setVal("toolbarMaxSize", next);
}
},
});
addSettingsSlider({
parent: toolbar,
label: "Максимальный размер",
value: Math.max(28, Math.min(88, Number(state.toolbarMaxSize ?? defaultSettings.toolbarMaxSize))),
min: 28,
max: 88,
step: 1,
formatValue: (val) => `${Math.round(val)}px`,
onChange: (val) => {
const minSize = Math.max(18, Math.min(42, Number(state.toolbarMinSize ?? defaultSettings.toolbarMinSize)));
setVal("toolbarMaxSize", Math.max(minSize, Math.min(88, Math.round(Number(val)))));
},
});
addSettingsSlider({
parent: toolbar,
label: "Линза",
value: Math.max(1, Math.min(13, Number(state.toolbarLensCount ?? defaultSettings.toolbarLensCount))),
min: 1,
max: 13,
step: 2,
formatValue: (val) => `${Math.round(val)} иконок`,
onChange: (val) => {
const next = Math.max(1, Math.min(13, Math.round(Number(val))));
setVal("toolbarLensCount", next % 2 === 0 ? next + 1 : next);
},
});
addBoolean(toolbar, "Автоскрытие", "toolbarAutoHide");
body.appendChild(toolbar);
const navCube = section("NavCube"); const navCube = section("NavCube");
addColor(navCube, "Грани", "navCubeBase"); addColor(navCube, "Грани", "navCubeBase");
addColor(navCube, "Hover", "navCubeHover"); addColor(navCube, "Hover", "navCubeHover");
addColor(navCube, "Текст", "navCubeText"); addColor(navCube, "Текст", "navCubeText");
addColor(navCube, "Рёбра", "navCubeEdge"); addColor(navCube, "Рёбра", "navCubeEdge");
const edgeAlphaRow = document.createElement("div"); addSettingsSlider({
edgeAlphaRow.className = "color-row"; parent: navCube,
const edgeAlphaLbl = document.createElement("label"); label: "Прозрачность рёбер",
edgeAlphaLbl.textContent = "Прозрачность рёбер (0-1)"; value: Math.round(Math.max(0, Math.min(1, Number(state.navCubeEdgeAlpha ?? 0.6))) * 100),
const edgeAlphaInput = document.createElement("input"); min: 0,
edgeAlphaInput.type = "number"; max: 100,
edgeAlphaInput.step = "0.05"; step: 5,
edgeAlphaInput.min = "0"; formatValue: (val) => `${Math.round(val)}%`,
edgeAlphaInput.max = "1"; onChange: (val) => {
edgeAlphaInput.value = state.navCubeEdgeAlpha; setVal("navCubeEdgeAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
edgeAlphaInput.addEventListener("change", () => { },
const v = Math.min(1, Math.max(0, parseFloat(edgeAlphaInput.value) || 0));
edgeAlphaInput.value = v;
setVal("navCubeEdgeAlpha", v);
}); });
edgeAlphaRow.appendChild(edgeAlphaLbl); body.appendChild(navCube);
edgeAlphaRow.appendChild(edgeAlphaInput);
edgeAlphaRow.appendChild(document.createElement("div")); const sectionCut = section("Разрез / сечение");
navCube.appendChild(edgeAlphaRow); addColor(sectionCut, "Ось X", "sectionAxisX");
panel.appendChild(navCube); addColor(sectionCut, "Ось Y", "sectionAxisY");
addColor(sectionCut, "Ось Z", "sectionAxisZ");
addSettingsSlider({
parent: sectionCut,
label: "Диаметр гизмо",
value: Math.round(Math.max(0.5, Math.min(1.8, Number(state.sectionGizmoDiameter ?? 1))) * 100),
min: 50,
max: 180,
step: 5,
formatValue: (val) => `${Math.round(val)}%`,
onChange: (val) => {
setVal("sectionGizmoDiameter", Math.max(0.5, Math.min(1.8, Number(val) / 100)));
},
});
addSettingsSlider({
parent: sectionCut,
label: "Толщина гизмо",
value: Math.round(Math.max(0.5, Math.min(2.5, Number(state.sectionGizmoThickness ?? 1))) * 100),
min: 50,
max: 250,
step: 5,
formatValue: (val) => `${Math.round(val)}%`,
onChange: (val) => {
setVal("sectionGizmoThickness", Math.max(0.5, Math.min(2.5, Number(val) / 100)));
},
});
addColor(sectionCut, "Плоскость", "sectionPlaneFill");
addSettingsSlider({
parent: sectionCut,
label: "Прозрачность плоскости",
value: Math.round(Math.max(0, Math.min(1, Number(state.sectionPlaneAlpha ?? 0.32))) * 100),
min: 0,
max: 100,
step: 5,
formatValue: (val) => `${Math.round(val)}%`,
onChange: (val) => {
setVal("sectionPlaneAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
},
});
addColor(sectionCut, "Обводка", "sectionPlaneEdge");
addSettingsSlider({
parent: sectionCut,
label: "Прозрачность обводки",
value: Math.round(Math.max(0, Math.min(1, Number(state.sectionPlaneEdgeAlpha ?? 0.8))) * 100),
min: 0,
max: 100,
step: 5,
formatValue: (val) => `${Math.round(val)}%`,
onChange: (val) => {
setVal("sectionPlaneEdgeAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
},
});
body.appendChild(sectionCut);
const commentTargets = section("Таргеты комментариев");
addColor(commentTargets, "Активный: цвет", "commentTargetActiveColor");
addSettingsSlider({
parent: commentTargets,
label: "Активный: прозрачность",
value: Math.round(Math.max(0, Math.min(1, Number(state.commentTargetActiveAlpha ?? 1))) * 100),
min: 0,
max: 100,
step: 5,
formatValue: (val) => `${Math.round(val)}%`,
onChange: (val) => {
setVal("commentTargetActiveAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
},
});
addSettingsSlider({
parent: commentTargets,
label: "Активный: размер",
value: Math.max(14, Math.min(64, Number(state.commentTargetActiveSize ?? 30))),
min: 14,
max: 64,
step: 1,
formatValue: (val) => `${Math.round(val)}px`,
onChange: (val) => {
setVal("commentTargetActiveSize", Math.max(14, Math.min(64, Math.round(Number(val)))));
},
});
addColor(commentTargets, "Пассивный: цвет", "commentTargetPassiveColor");
addSettingsSlider({
parent: commentTargets,
label: "Пассивный: прозрачность",
value: Math.round(Math.max(0, Math.min(1, Number(state.commentTargetPassiveAlpha ?? 0.58))) * 100),
min: 0,
max: 100,
step: 5,
formatValue: (val) => `${Math.round(val)}%`,
onChange: (val) => {
setVal("commentTargetPassiveAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
},
});
addSettingsSlider({
parent: commentTargets,
label: "Пассивный: размер",
value: Math.max(12, Math.min(48, Number(state.commentTargetPassiveSize ?? 22))),
min: 12,
max: 48,
step: 1,
formatValue: (val) => `${Math.round(val)}px`,
onChange: (val) => {
setVal("commentTargetPassiveSize", Math.max(12, Math.min(48, Math.round(Number(val)))));
},
});
body.appendChild(commentTargets);
const measure = section("Линейка"); const measure = section("Линейка");
addColor(measure, "Линия/бордер", "measureLine"); addColor(measure, "Линия/бордер", "measureLine");
addColor(measure, "Плашка", "measureLabel"); addColor(measure, "Плашка", "measureLabel");
addColor(measure, "Текст плашки", "measureLabelText"); addColor(measure, "Текст плашки", "measureLabelText");
addColor(measure, "Кружок", "measureDot"); addColor(measure, "Кружок", "measureDot");
panel.appendChild(measure); body.appendChild(measure);
const viewport = section("Viewport"); const viewport = section("Viewport");
const addCheck = (label, checked, handler) => { const addCheck = (label, checked, handler) => {
const row = document.createElement("div"); const row = document.createElement("label");
row.className = "checkbox-row"; row.className = "checkbox-row";
const text = document.createElement("span");
text.className = "checkbox-row__label";
text.textContent = label;
const chk = document.createElement("input"); const chk = document.createElement("input");
chk.type = "checkbox"; chk.type = "checkbox";
chk.checked = checked; chk.checked = checked;
chk.addEventListener("change", () => handler(chk.checked)); chk.addEventListener("change", () => handler(chk.checked));
const lbl = document.createElement("label"); row.appendChild(text);
lbl.textContent = label;
row.appendChild(chk); row.appendChild(chk);
row.appendChild(lbl);
viewport.appendChild(row); viewport.appendChild(row);
}; };
if (controls.toggleEdges) addCheck("Рёбра", controls.toggleEdges.checked, (v) => { controls.toggleEdges.checked = v; controls.toggleEdges.dispatchEvent(new Event("change")); }); if (controls.toggleEdges) addCheck("Рёбра", controls.toggleEdges.checked, (v) => { controls.toggleEdges.checked = v; controls.toggleEdges.dispatchEvent(new Event("change")); });
if (controls.toggleTransparent) addCheck("Прозрачный фон", controls.toggleTransparent.checked, (v) => { controls.toggleTransparent.checked = v; controls.toggleTransparent.dispatchEvent(new Event("change")); }); if (controls.toggleTransparent) addCheck("Прозрачный фон", controls.toggleTransparent.checked, (v) => { controls.toggleTransparent.checked = v; controls.toggleTransparent.dispatchEvent(new Event("change")); });
if (controls.addMode) addCheck("Добавлять в сцену", controls.addMode.checked, (v) => { controls.addMode.checked = v; }); if (controls.addMode) addCheck("Добавлять в сцену", controls.addMode.checked, (v) => { controls.addMode.checked = v; });
panel.appendChild(viewport); body.appendChild(viewport);
const modals = section("Модальные окна"); const modals = section("Модальные окна");
addColor(modals, "Основной фон", "modalBg"); addColor(modals, "Основной фон", "modalBg");
addColor(modals, "Фокусный фон", "modalFocus"); addColor(modals, "Фокусный фон", "modalFocus");
const modalBgAlphaRow = createSliderControl({ addSettingsSlider({
parent: modals,
label: "Прозрачность основного фона", label: "Прозрачность основного фона",
min: 0, min: 0,
max: 1, max: 1,
step: 0.05, step: 0.05,
value: parseFloat(state.modalBgAlpha ?? 1), value: parseFloat(state.modalBgAlpha ?? 1),
formatValue: (val) => `${Math.round(Number(val) * 100)}%`,
onChange: (val) => { onChange: (val) => {
const num = Math.max(0, Math.min(1, parseFloat(val))); const num = Math.max(0, Math.min(1, parseFloat(val)));
setVal("modalBgAlpha", num); setVal("modalBgAlpha", num);
document.documentElement.style.setProperty("--modal-bg-alpha", `${num * 100}%`); document.documentElement.style.setProperty("--modal-bg-alpha", `${num * 100}%`);
}, },
}); });
modals.appendChild(modalBgAlphaRow);
const modalFocusAlphaRow = createSliderControl({ addSettingsSlider({
parent: modals,
label: "Прозрачность фокусного фона", label: "Прозрачность фокусного фона",
min: 0, min: 0,
max: 1, max: 1,
step: 0.05, step: 0.05,
value: parseFloat(state.modalFocusAlpha ?? 1), value: parseFloat(state.modalFocusAlpha ?? 1),
formatValue: (val) => `${Math.round(Number(val) * 100)}%`,
onChange: (val) => { onChange: (val) => {
const num = Math.max(0, Math.min(1, parseFloat(val))); const num = Math.max(0, Math.min(1, parseFloat(val)));
setVal("modalFocusAlpha", num); setVal("modalFocusAlpha", num);
document.documentElement.style.setProperty("--modal-focus-alpha", `${num * 100}%`); document.documentElement.style.setProperty("--modal-focus-alpha", `${num * 100}%`);
}, },
}); });
modals.appendChild(modalFocusAlphaRow);
addColor(modals, "Аутлайн", "modalBorder"); addColor(modals, "Аутлайн", "modalBorder");
addColor(modals, "Фон элемента", "modalElement"); addColor(modals, "Контролы", "modalElement");
addColor(modals, "Аутлайн элемента", "modalElementOutline"); addColor(modals, "Аутлайн элемента", "modalElementOutline");
const radiusRow = document.createElement("div"); addNumber({
radiusRow.className = "color-row"; parent: modals,
const radiusLbl = document.createElement("label"); label: "Радиус",
radiusLbl.textContent = "Радиус"; value: parseInt(getComputedStyle(document.documentElement).getPropertyValue("--modal-radius")) || 14,
const radiusInput = document.createElement("input"); min: 0,
radiusInput.type = "number"; max: 48,
radiusInput.value = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--modal-radius")) || 14; step: 1,
radiusInput.addEventListener("change", () => { onChange: (nextValue) => {
const val = `${radiusInput.value}px`; const val = `${Math.max(0, Math.min(48, Math.round(nextValue)))}px`;
document.documentElement.style.setProperty("--modal-radius", val); setVal("modalRadius", val);
setVal("modalRadius", val); },
}); });
radiusRow.appendChild(radiusLbl); body.appendChild(modals);
radiusRow.appendChild(radiusInput);
radiusRow.appendChild(document.createElement("div"));
modals.appendChild(radiusRow);
panel.appendChild(modals);
const cube = section("Выделение объектов"); const cube = section("Выделение объектов");
addColor(cube, "Подсветка объектов", "highlight"); addColor(cube, "Подсветка объектов", "highlight");
panel.appendChild(cube); body.appendChild(cube);
}; };
const show = () => { const show = () => {
render(); render();
if (panel) { if (panel) {
panel.classList.remove("hidden"); panel.classList.remove("hidden");
panel.style.display = "grid"; panel.style.display = "flex";
} }
onShow?.(); onShow?.();
}; };

View File

@ -62,27 +62,42 @@ export function createTemplatesMenu({ panel, fetchProjects, openProject, onShow,
header.className = "templates-header"; header.className = "templates-header";
header.innerHTML = `<span class="templates-title">Проекты и шаблоны</span>`; header.innerHTML = `<span class="templates-title">Проекты и шаблоны</span>`;
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.style.display = "flex"; actions.className = "templates-actions";
actions.style.gap = "6px";
const refresh = document.createElement("button"); const refresh = document.createElement("button");
refresh.className = "templates-close"; refresh.className = "templates-close";
refresh.type = "button";
refresh.title = "Обновить список"; refresh.title = "Обновить список";
refresh.textContent = "↻"; refresh.setAttribute("aria-label", "Обновить список");
refresh.innerHTML = `
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M12.2 5.3A4.4 4.4 0 0 0 4.7 3.4L3.6 4.5M3.7 2.1v2.4h2.4M3.8 10.7a4.4 4.4 0 0 0 7.5 1.9l1.1-1.1M12.3 13.9v-2.4H9.9" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
`;
refresh.addEventListener("click", () => load()); refresh.addEventListener("click", () => load());
const closeBtn = document.createElement("button"); const closeBtn = document.createElement("button");
closeBtn.className = "templates-close"; closeBtn.className = "templates-close";
closeBtn.textContent = "×"; closeBtn.type = "button";
closeBtn.setAttribute("aria-label", "Закрыть проекты и шаблоны");
closeBtn.innerHTML = `
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
`;
closeBtn.addEventListener("click", hide); closeBtn.addEventListener("click", hide);
actions.appendChild(refresh); actions.appendChild(refresh);
actions.appendChild(closeBtn); actions.appendChild(closeBtn);
header.appendChild(actions); header.appendChild(actions);
panel.appendChild(header); panel.appendChild(header);
const body = document.createElement("div");
body.className = "templates-panel-body";
panel.appendChild(body);
if (state.loading) { if (state.loading) {
const loadingEl = document.createElement("div"); const loadingEl = document.createElement("div");
loadingEl.className = "templates-empty"; loadingEl.className = "templates-empty";
loadingEl.textContent = "Загрузка списка проектов..."; loadingEl.textContent = "Загрузка списка проектов...";
panel.appendChild(loadingEl); body.appendChild(loadingEl);
return; return;
} }
@ -91,11 +106,11 @@ export function createTemplatesMenu({ panel, fetchProjects, openProject, onShow,
errorEl.className = "templates-empty"; errorEl.className = "templates-empty";
errorEl.style.color = "var(--error)"; errorEl.style.color = "var(--error)";
errorEl.textContent = state.error; errorEl.textContent = state.error;
panel.appendChild(errorEl); body.appendChild(errorEl);
} }
panel.appendChild(renderSection("Шаблоны", state.templates, "Нет шаблонов")); body.appendChild(renderSection("Шаблоны", state.templates, "Нет шаблонов"));
panel.appendChild(renderSection("Мои проекты", state.projects, "Нет сохранённых проектов")); body.appendChild(renderSection("Мои проекты", state.projects, "Нет сохранённых проектов"));
}; };
const load = async () => { const load = async () => {
@ -126,7 +141,7 @@ export function createTemplatesMenu({ panel, fetchProjects, openProject, onShow,
render(); render();
if (panel) { if (panel) {
panel.classList.remove("hidden"); panel.classList.remove("hidden");
panel.style.display = "grid"; panel.style.display = "flex";
} }
load(); load();
onShow?.(); onShow?.();

View File

@ -1,4 +1,8 @@
const toolbarConfig = { const toolbarConfig = {
minSize: 25,
maxSize: 87,
lensCount: 5,
autoHide: false,
size: 25, size: 25,
gap: 10, gap: 10,
radius: 7, radius: 7,
@ -7,9 +11,22 @@ const toolbarConfig = {
iconSize: 13, iconSize: 13,
}; };
const applyToolbarVars = () => { const numericSetting = (settings, key, fallback) => {
const value = Number(settings?.[key]);
return Number.isFinite(value) ? value : fallback;
};
const applyToolbarVars = (settings = {}) => {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty("--tb-size", `${toolbarConfig.size}px`); const minSize = numericSetting(settings, "toolbarMinSize", toolbarConfig.minSize);
const maxSize = Math.max(minSize, numericSetting(settings, "toolbarMaxSize", toolbarConfig.maxSize));
const lensCount = Math.max(1, Math.round(numericSetting(settings, "toolbarLensCount", toolbarConfig.lensCount)));
const autoHide = settings?.toolbarAutoHide ?? toolbarConfig.autoHide;
root.style.setProperty("--tb-size", `${minSize}px`);
root.style.setProperty("--tb-min-size", `${minSize}px`);
root.style.setProperty("--tb-max-size", `${maxSize}px`);
root.style.setProperty("--tb-lens-count", `${lensCount}`);
root.style.setProperty("--tb-autohide", autoHide ? "1" : "0");
root.style.setProperty("--tb-gap", `${toolbarConfig.gap}px`); root.style.setProperty("--tb-gap", `${toolbarConfig.gap}px`);
root.style.setProperty("--tb-radius", `${toolbarConfig.radius}px`); root.style.setProperty("--tb-radius", `${toolbarConfig.radius}px`);
root.style.setProperty("--tb-pad-y", `${toolbarConfig.paddingY}px`); root.style.setProperty("--tb-pad-y", `${toolbarConfig.paddingY}px`);

View File

@ -1,7 +1,7 @@
// Базовые цвета/настройки проекта. При очистке localStorage эти значения остаются. // Базовые цвета/настройки проекта. При очистке localStorage эти значения остаются.
// Чтобы изменить палитру по умолчанию — правь этот объект и закоммить изменения. // Чтобы изменить палитру по умолчанию — правь этот объект и закоммить изменения.
const PROJECT_THEME = { const PROJECT_THEME = {
themeVersion: "v3-yellow", themeVersion: "v7-toolbar-hub-icon",
bgOuter: "#0f0f0f", bgOuter: "#0f0f0f",
canvasTop: "#1c1c1c", canvasTop: "#1c1c1c",
canvasBottom: "#1c1c1c", canvasBottom: "#1c1c1c",
@ -9,44 +9,64 @@ const PROJECT_THEME = {
loadingBottom: "#1c1c1c", loadingBottom: "#1c1c1c",
accent: "#ffea00", accent: "#ffea00",
accent2: "#ffea00", accent2: "#ffea00",
templateFill: "#1c1c1c", templateFill: "#292929",
templateOutline: "#233044", templateOutline: "#292929",
templateOutlineHover: "rgba(255,234,0,0.7)", templateOutlineHover: "#1c1c1c",
templateGlow: 80, templateGlow: 80,
toolbarBg: "#1c1c1c", toolbarBg: "#292929",
toolbarBgActive: "#ffea00", toolbarBgActive: "#c4ff67",
toolbarBorder: "#233044", toolbarBorder: "#292929",
toolbarOutline: "#ffea00", toolbarOutline: "#292929",
toolbarMinSize: 25,
toolbarMaxSize: 87,
toolbarLensCount: 5,
toolbarAutoHide: false,
modalBg: "#1c1c1c", modalBg: "#1c1c1c",
modalBgAlpha: 1, modalBgAlpha: 1,
modalFocus: "#292929", modalFocus: "#292929",
modalFocusAlpha: 1, modalFocusAlpha: 0.35,
modalBorder: "#292929", modalBorder: "#1c1c1c",
modalElement: "#292929", modalElement: "#262626",
modalElementOutline: "#2b2b2b", modalElementOutline: "#262626",
modalRadius: "14px", modalRadius: "14px",
projectBtnFill: "#1c1c1c", projectBtnFill: "#292929",
projectBtnBorder: "#233044", projectBtnBorder: "#292929",
projectBtnHover: "rgba(255,234,0,0.7)", projectBtnHover: "#1c1c1c",
modalBtnPrimary: "#ffea00", modalBtnPrimary: "#c4ff67",
modalBtnSecondary: "#292929", modalBtnSecondary: "#292929",
loaderColor: "#c4ff67",
border: "#233044", border: "#233044",
text: "#e6edf3", text: "#e6edf3",
panel: "#1c1c1c", panel: "#1c1c1c",
panel2: "#292929", panel2: "#292929",
highlight: "#ffea00", highlight: "#c4ff67",
cubeText: "#242424", cubeText: "#242424",
cubeEdges: "#e0e7ff", cubeEdges: "#e0e7ff",
plus: "#ffea00", plus: "#c4ff67",
navCubeBase: "#ffea00", navCubeBase: "#ffffff",
navCubeHover: "#ffffff", navCubeHover: "#ffffff",
navCubeText: "#242424", navCubeText: "#242424",
navCubeEdge: "#e0e7ff", navCubeEdge: "#e0e7ff",
navCubeEdgeAlpha: 0.6, navCubeEdgeAlpha: 0.6,
measureLine: "#ffea00", measureLine: "#c4ff67",
measureLabel: "#ffea00", measureLabel: "#c4ff67",
measureLabelText: "#292929", measureLabelText: "#292929",
measureDot: "#ffea00", measureDot: "#c4ff67",
sectionAxisX: "#ff2b2b",
sectionAxisY: "#35ff4f",
sectionAxisZ: "#285bff",
sectionGizmoDiameter: 1,
sectionGizmoThickness: 1,
sectionPlaneFill: "#606060",
sectionPlaneAlpha: 0.32,
sectionPlaneEdge: "#0d0d0d",
sectionPlaneEdgeAlpha: 0.8,
commentTargetActiveColor: "#c4ff67",
commentTargetActiveAlpha: 1,
commentTargetActiveSize: 30,
commentTargetPassiveColor: "#f5f5fa",
commentTargetPassiveAlpha: 0.58,
commentTargetPassiveSize: 22,
}; };
if (typeof window !== "undefined") { if (typeof window !== "undefined") {

View File

@ -18,6 +18,13 @@ export class TransformGizmo {
this._bindTree(); this._bindTree();
} }
_isPointCloudTarget(model, objectId = null) {
const entity = objectId ? this.scene.objects[objectId] : null;
const firstObject = entity || Object.values(model?.objects || {})[0];
const firstMesh = firstObject?.meshes?.[0] || model?.meshes?.[0];
return firstMesh?._geometry?._state?.primitiveName === "points";
}
setMode(next) { setMode(next) {
if (!["translate", "rotate", "scale"].includes(next)) return; if (!["translate", "rotate", "scale"].includes(next)) return;
this.mode = next; this.mode = next;
@ -66,7 +73,9 @@ export class TransformGizmo {
scale: model.scale ? [...model.scale] : [1, 1, 1], scale: model.scale ? [...model.scale] : [1, 1, 1],
}, },
}); });
this.scene.setObjectsHighlighted([objectId], true); if (!this._isPointCloudTarget(model, objectId)) {
this.scene.setObjectsHighlighted([objectId], true);
}
this.target = { modelId, objectId }; this.target = { modelId, objectId };
return; return;
} }
@ -74,7 +83,7 @@ export class TransformGizmo {
// Если objectId нет, работаем всей моделью // Если objectId нет, работаем всей моделью
const ids = Object.keys(model.objects || {}); const ids = Object.keys(model.objects || {});
if (ids.length) { if (ids.length && !this._isPointCloudTarget(model)) {
this.scene.setObjectsHighlighted(ids, true); this.scene.setObjectsHighlighted(ids, true);
} }
this.onTargetChange?.({ this.onTargetChange?.({
@ -101,7 +110,9 @@ export class TransformGizmo {
if (model) { if (model) {
const ids = Object.keys(model.objects || {}); const ids = Object.keys(model.objects || {});
this.scene.setObjectsHighlighted(this.scene.highlightedObjectIds || [], false); this.scene.setObjectsHighlighted(this.scene.highlightedObjectIds || [], false);
this.scene.setObjectsHighlighted(ids, true); if (!this._isPointCloudTarget(model)) {
this.scene.setObjectsHighlighted(ids, true);
}
this.target = { modelId, objectId: null }; this.target = { modelId, objectId: null };
} }
} }

View File

@ -9,13 +9,17 @@
Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`. Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`.
Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект). При старте создаются при необходимости. Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект).
Upload-артефакты лежат в `server/data/uploads`, публичные share-ссылки — в `server/data/shares/index.json`,
а пользовательские ссылки/ref-count моделей — в `server/data/models/registry.json`. При старте директории и индексы создаются при необходимости.
API: API:
- `GET /api/projects[?type=project|template]` — укороченный список из `index.json`. - `GET /api/projects[?type=project|template]` — укороченный список из `index.json`.
- `GET /api/projects/:id` — полный JSON проекта. - `GET /api/projects/:id` — полный JSON проекта.
- `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс. - `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс.
- `POST /api/uploads?filename=<name>&projectId=<id>` — сохранить оригинальный файл модели в `server/data/uploads`. - `POST /api/uploads?filename=<name>&projectId=<id>` — сохранить оригинальный файл модели в `server/data/uploads`.
- `DELETE /api/uploads/asset` — снять ссылку текущего пользователя на модель и физически удалить asset только если больше нет user/share refs.
- `DELETE /api/uploads/version` — удалить версию, если на неё нет чужих refs или публичных share-ссылок.
- `GET /api/conversions/status?src=<uploads/...>` — получить статус подготовки viewer-артефакта. - `GET /api/conversions/status?src=<uploads/...>` — получить статус подготовки viewer-артефакта.
Для `.step/.stp` ответ помечается `conversion.status=conversion_required`: оригинал уже доступен для скачивания, Для `.step/.stp` ответ помечается `conversion.status=conversion_required`: оригинал уже доступен для скачивания,
а preview должен появиться после работы `NodeDcBimConverter`, который готовит GLB и metadata/tree. а preview должен появиться после работы `NodeDcBimConverter`, который готовит GLB и metadata/tree.
@ -24,4 +28,24 @@ API:
- `docker compose -f docker-compose.beam.yml up ndc-beam-viewer` - `docker compose -f docker-compose.beam.yml up ndc-beam-viewer`
- `docker compose -f docker-compose.beam.yml up nodedc-bim-converter` - `docker compose -f docker-compose.beam.yml up nodedc-bim-converter`
Synology Beam compose:
- скопировать `.env.synology.example` в `.env` рядом с `docker-compose.beam.yml`;
- заполнить `NODEDC_INTERNAL_ACCESS_TOKEN` тем же значением, что и в platform `.env.synology`;
- запустить `docker compose -f docker-compose.beam.yml up -d ndc-beam-viewer nodedc-bim-converter`.
Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга. Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга.
NODE.DC auth/share:
- `NODEDC_BIM_AUTH_REQUIRED=1` включает вход через Launcher handoff.
- `NODEDC_BIM_SERVICE_SLUG=bim-viewer` должен совпадать со slug сервиса в Launcher.
- `NODEDC_INTERNAL_ACCESS_TOKEN` должен совпадать с Launcher internal token.
- `NODEDC_LAUNCHER_BASE_URL` — публичный Launcher URL для редиректа на login/launch.
- `NODEDC_LAUNCHER_INTERNAL_URL` — внутренний URL Launcher для `/api/internal/handoff/consume`.
- `NODEDC_BIM_PUBLIC_URL` — публичный URL viewer-а для share-ссылок, на проде `https://bim.nodedc.tech`.
- `NODEDC_BIM_HOST_PORT` — host-port для `docker-compose.beam.yml`; на Synology BIM публикуется как `18100:8080`.
- `NODEDC_BIM_COOKIE_SAMESITE=None` и `NODEDC_BIM_COOKIE_SECURE=1` нужны для BIM в iframe на `ops.nodedc.ru`.
- `NODEDC_BIM_DATA_ROOT` — опциональный корень файлового storage вместо `server/data`.
- `POST /api/shares` создаёт публичную read-only ссылку на upload artifact; `/share/<token>` открывает viewer в guest mode.
- `GET /api/models` возвращает личные активные модели текущего пользователя.
- `POST /api/models/refs` сохраняет существующий upload artifact в личные модели текущего пользователя.
- `DELETE /api/models/refs` снимает личную ссылку и удаляет физические файлы только при отсутствии других refs/share.

View File

@ -0,0 +1,100 @@
[
{
"id": "cmt_WugIB9w6U8jhk1qOHew",
"sourceKey": "nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2:legacy_572b02d5d4c05ce512d6",
"sourceSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.STEP",
"projectId": "nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2",
"assetId": "legacy_572b02d5d4c05ce512d6",
"modelId": "model",
"objectId": "407",
"title": "2",
"body": "2",
"subitems": [],
"worldPos": [
-0.016629892462037388,
0.03852343379522116,
-0.518901357060331
],
"createdAt": "2026-06-20T16:16:16.607Z",
"updatedAt": "2026-06-20T16:16:16.607Z",
"createdBy": {
"id": "local-dev",
"email": null,
"name": "Local BIM user",
"subject": null,
"authentikUserId": null
},
"messages": [
{
"id": "msg_d5ioR6S1xqv2lg",
"body": "2",
"attachments": [],
"createdAt": "2026-06-20T16:16:16.607Z",
"updatedAt": "2026-06-20T16:16:16.607Z",
"createdBy": {
"id": "local-dev",
"email": null,
"name": "Local BIM user",
"subject": null,
"authentikUserId": null
}
}
]
},
{
"id": "cmt_y_t1tTNgNa1SJA43-No",
"sourceKey": "nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2:legacy_572b02d5d4c05ce512d6",
"sourceSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.STEP",
"projectId": "nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2",
"assetId": "legacy_572b02d5d4c05ce512d6",
"modelId": "model",
"objectId": "401",
"title": "1",
"body": "1",
"subitems": [],
"worldPos": [
-0.0010763776897612098,
-0.00014665442142014137,
-0.00001253560767433548
],
"createdAt": "2026-06-20T16:15:03.218Z",
"updatedAt": "2026-06-20T16:47:23.780Z",
"createdBy": {
"id": "local-dev",
"email": null,
"name": "Local BIM user",
"subject": null,
"authentikUserId": null
},
"messages": [
{
"id": "msg_qaFqDoGumOeJ1g",
"body": "1",
"attachments": [],
"createdAt": "2026-06-20T16:15:03.218Z",
"updatedAt": "2026-06-20T16:15:03.218Z",
"createdBy": {
"id": "local-dev",
"email": null,
"name": "Local BIM user",
"subject": null,
"authentikUserId": null
}
},
{
"id": "msg_aAr0GFHSrUjeQw",
"body": "пиевив",
"attachments": [],
"createdAt": "2026-06-20T16:47:23.780Z",
"updatedAt": "2026-06-20T16:47:23.780Z",
"createdBy": {
"id": "local-dev",
"email": null,
"name": "Local BIM user",
"subject": null,
"authentikUserId": null
}
}
]
}
]

View File

@ -1,4 +1,20 @@
[ [
{
"id": "proj_0a284bb5-1c82-48b1-a636-e204578a570a",
"name": "DRONE",
"type": "project",
"ownerId": null,
"createdAt": "2026-06-05T13:33:23.556Z",
"updatedAt": "2026-06-20T20:33:51.505Z"
},
{
"id": "proj_0c1b26f3-020c-4ee9-926c-fedf08003304",
"name": "TrackEnsemble_6074_v11",
"type": "project",
"ownerId": null,
"createdAt": "2026-06-04T19:14:21.726Z",
"updatedAt": "2026-06-04T19:14:21.726Z"
},
{ {
"id": "proj_e5355c03-983b-4e2f-800c-f57638e9732f", "id": "proj_e5355c03-983b-4e2f-800c-f57638e9732f",
"name": "СКОЛКОВО - LOFT КВАРТАЛ", "name": "СКОЛКОВО - LOFT КВАРТАЛ",

View File

@ -0,0 +1,154 @@
{
"id": "proj_0a284bb5-1c82-48b1-a636-e204578a570a",
"name": "DRONE",
"type": "project",
"createdAt": "2026-06-05T13:33:23.556Z",
"updatedAt": "2026-06-20T20:33:51.505Z",
"ownerId": null,
"shares": [],
"models": [
{
"id": "model",
"type": "xkt",
"meta": {
"label": "Final Assembly.STEP",
"metadataPath": null,
"settingsSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.STEP",
"sourceSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.STEP",
"artifactSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.xkt"
},
"visible": true,
"src": "http://localhost:8080/uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.xkt?v=2026-06-04T18%3A09%3A13.204700%2B00%3A00"
}
],
"viewerState": {
"camera": {
"eye": [
-0.3470755265527011,
0.20168643298944608,
0.34824520869461856
],
"look": [
-0.01809624637167012,
-0.014447461123117594,
-0.15640939974292462
],
"up": [
0.18441935979386925,
0.941253080967919,
-0.28289951803002283
],
"projection": "perspective"
},
"transparent": true,
"edges": true,
"addMode": true,
"displayMode": "white",
"lightingMode": "standard",
"navigationAxis": "auto",
"zoomSpeed": 52,
"selection": {
"ids": [],
"modelId": null
},
"models": [
{
"id": "model",
"visible": true
}
],
"transforms": {
"model": {
"__model__": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
}
}
},
"design": {
"themeVersion": "v5-drone-lime",
"bgOuter": "#0f0f0f",
"canvasTop": "#1c1c1c",
"canvasBottom": "#1c1c1c",
"loadingTop": "#1c1c1c",
"loadingBottom": "#1c1c1c",
"accent": "#ffea00",
"accent2": "#ffea00",
"templateFill": "#292929",
"templateOutline": "#292929",
"templateOutlineHover": "#1c1c1c",
"projectBtnFill": "#292929",
"projectBtnBorder": "#292929",
"projectBtnHover": "#1c1c1c",
"modalBtnPrimary": "#c4ff67",
"modalBtnSecondary": "#292929",
"loaderColor": "#c4ff67",
"templateGlow": 80,
"toolbarBg": "#292929",
"toolbarBgActive": "#c4ff67",
"toolbarBorder": "#292929",
"toolbarOutline": "#292929",
"toolbarMinSize": 24,
"toolbarMaxSize": 88,
"toolbarLensCount": 5,
"toolbarAutoHide": false,
"modalBg": "#1c1c1c",
"modalBgAlpha": 1,
"modalFocus": "#292929",
"modalFocusAlpha": 0.35,
"modalBorder": "#1c1c1c",
"modalElement": "#262626",
"modalElementOutline": "#262626",
"modalRadius": "14px",
"border": "#233044",
"text": "#e6edf3",
"panel": "#1c1c1c",
"panel2": "#292929",
"highlight": "#c4ff67",
"cubeText": "#242424",
"cubeEdges": "#e0e7ff",
"plus": "#c4ff67",
"navCubeBase": "#ffffff",
"navCubeHover": "#ffffff",
"navCubeText": "#242424",
"navCubeEdge": "#e0e7ff",
"navCubeEdgeAlpha": 0.6,
"measureLine": "#c4ff67",
"measureLabel": "#c4ff67",
"measureLabelText": "#292929",
"measureDot": "#c4ff67",
"sectionAxisX": "#ff2b2b",
"sectionAxisY": "#35ff4f",
"sectionAxisZ": "#285bff",
"sectionGizmoDiameter": 1,
"sectionGizmoThickness": 1,
"sectionPlaneFill": "#606060",
"sectionPlaneAlpha": 0.32,
"sectionPlaneEdge": "#0d0d0d",
"sectionPlaneEdgeAlpha": 0.8,
"commentTargetActiveColor": "#c4ff67",
"commentTargetActiveAlpha": 1,
"commentTargetActiveSize": 45,
"commentTargetPassiveColor": "#262626",
"commentTargetPassiveAlpha": 0.58,
"commentTargetPassiveSize": 28
},
"meta": {
"description": "",
"tags": []
}
}

View File

@ -0,0 +1,133 @@
{
"id": "proj_0c1b26f3-020c-4ee9-926c-fedf08003304",
"name": "TrackEnsemble_6074_v11",
"type": "project",
"createdAt": "2026-06-04T19:14:21.726Z",
"updatedAt": "2026-06-04T19:14:21.726Z",
"ownerId": null,
"shares": [],
"models": [
{
"id": "model",
"type": "xkt",
"meta": {
"label": "TV_ensemble complet.STEP",
"metadataPath": null
},
"visible": true,
"src": "http://localhost:8080/uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/TV_ensemble_complet.xkt?v=2026-06-04T18%3A34%3A12.613772%2B00%3A00"
}
],
"viewerState": {
"camera": {
"eye": [
0.5620614003131926,
0.636732837776035,
0.5089379140596013
],
"look": [
0.26556218907312157,
0.09433375758008311,
0.05622267468342307
],
"up": [
-0.28340934910241067,
-0.5184532182367775,
0.806774690568114
],
"projection": "perspective"
},
"transparent": true,
"edges": true,
"addMode": true,
"displayMode": "white",
"lightingMode": "technical",
"navigationAxis": "z-up",
"selection": {
"ids": [
"TV_t󫣠capot"
],
"modelId": "model"
},
"models": [
{
"id": "model",
"visible": true
}
],
"transforms": {
"model": {
"__model__": {
"position": [
0,
0,
0
],
"rotation": [
0,
0,
0
],
"scale": [
1,
1,
1
]
}
}
}
},
"design": {
"themeVersion": "v5-drone-lime",
"bgOuter": "#0f0f0f",
"canvasTop": "#1c1c1c",
"canvasBottom": "#1c1c1c",
"loadingTop": "#1c1c1c",
"loadingBottom": "#1c1c1c",
"accent": "#ffea00",
"accent2": "#ffea00",
"templateFill": "#292929",
"templateOutline": "#292929",
"templateOutlineHover": "#1c1c1c",
"projectBtnFill": "#292929",
"projectBtnBorder": "#292929",
"projectBtnHover": "#1c1c1c",
"modalBtnPrimary": "#c4ff67",
"modalBtnSecondary": "#292929",
"loaderColor": "#c4ff67",
"templateGlow": 80,
"toolbarBg": "#292929",
"toolbarBgActive": "#c4ff67",
"toolbarBorder": "#292929",
"toolbarOutline": "#292929",
"modalBg": "#1c1c1c",
"modalBgAlpha": 1,
"modalFocus": "#292929",
"modalFocusAlpha": 0.35,
"modalBorder": "#1c1c1c",
"modalElement": "#262626",
"modalElementOutline": "#262626",
"modalRadius": "14px",
"border": "#233044",
"text": "#e6edf3",
"panel": "#1c1c1c",
"panel2": "#292929",
"highlight": "#c4ff67",
"cubeText": "#242424",
"cubeEdges": "#e0e7ff",
"plus": "#c4ff67",
"navCubeBase": "#ffffff",
"navCubeHover": "#ffffff",
"navCubeText": "#242424",
"navCubeEdge": "#e0e7ff",
"navCubeEdgeAlpha": 0.6,
"measureLine": "#c4ff67",
"measureLabel": "#c4ff67",
"measureLabelText": "#292929",
"measureDot": "#c4ff67"
},
"meta": {
"description": "",
"tags": []
}
}

View File

@ -802,62 +802,56 @@
} }
}, },
"design": { "design": {
"themeVersion": "v3-yellow", "themeVersion": "v5-drone-lime",
"bgOuter": "#121319", "bgOuter": "#0f0f0f",
"canvasTop": "#121319", "canvasTop": "#1c1c1c",
"canvasBottom": "#121319", "canvasBottom": "#1c1c1c",
"loadingTop": "#121319", "loadingTop": "#1c1c1c",
"loadingBottom": "#121319", "loadingBottom": "#1c1c1c",
"accent": "#ffea00", "accent": "#ffea00",
"accent2": "#ffea00", "accent2": "#ffea00",
"templateFill": "#1f1f1f", "templateFill": "#292929",
"templateOutline": "#1c1c1c", "templateOutline": "#292929",
"templateOutlineHover": "#2e2e2e", "templateOutlineHover": "#1c1c1c",
"projectBtnFill": "#1c1c1c", "projectBtnFill": "#292929",
"projectBtnBorder": "#1c1c1c", "projectBtnBorder": "#292929",
"projectBtnHover": "#292929", "projectBtnHover": "#1c1c1c",
"modalBtnPrimary": "#befd02", "modalBtnPrimary": "#c4ff67",
"modalBtnSecondary": "#292929", "modalBtnSecondary": "#292929",
"loaderColor": "#befd02", "loaderColor": "#c4ff67",
"templateGlow": 39, "templateGlow": 80,
"toolbarBg": "#1c1c1c", "toolbarBg": "#292929",
"toolbarBgActive": "#befd02", "toolbarBgActive": "#c4ff67",
"toolbarBorder": "#233044", "toolbarBorder": "#292929",
"toolbarOutline": "#befd02", "toolbarOutline": "#292929",
"modalBg": "#1a1a1a", "modalBg": "#1c1c1c",
"modalBgAlpha": 0.95, "modalBgAlpha": 1,
"modalFocus": "#141414", "modalFocus": "#292929",
"modalFocusAlpha": 0.55, "modalFocusAlpha": 0.35,
"modalBorder": "#141414", "modalBorder": "#1c1c1c",
"modalElement": "#141414", "modalElement": "#262626",
"modalElementOutline": "#141414", "modalElementOutline": "#262626",
"modalRadius": "14px", "modalRadius": "14px",
"border": "#233044", "border": "#233044",
"text": "#e6edf3", "text": "#e6edf3",
"panel": "#1c1c1c", "panel": "#1c1c1c",
"panel2": "#292929", "panel2": "#292929",
"highlight": "#befd02", "highlight": "#c4ff67",
"cubeText": "#242424", "cubeText": "#242424",
"cubeEdges": "#e0e7ff", "cubeEdges": "#e0e7ff",
"plus": "#befd02", "plus": "#c4ff67",
"navCubeBase": "#ffffff", "navCubeBase": "#ffffff",
"navCubeHover": "#ffffff", "navCubeHover": "#ffffff",
"navCubeText": "#242424", "navCubeText": "#242424",
"navCubeEdge": "#e0e7ff", "navCubeEdge": "#e0e7ff",
"navCubeEdgeAlpha": 0.6, "navCubeEdgeAlpha": 0.6,
"measureLine": "#befd02", "measureLine": "#c4ff67",
"measureLabel": "#befd02", "measureLabel": "#c4ff67",
"measureLabelText": "#292929", "measureLabelText": "#292929",
"measureDot": "#befd02", "measureDot": "#c4ff67"
"clipPerspectiveNear": 0.01,
"clipPerspectiveFar": 100000,
"clipOrthoNear": 0.01,
"clipOrthoFar": 100000,
"clipPerspective": 4000,
"clipOrtho": 5000
}, },
"meta": { "meta": {
"description": "", "description": "",
"tags": [] "tags": []
} }
} }

View File

@ -73,7 +73,7 @@
} }
}, },
"design": { "design": {
"themeVersion": "v3-yellow", "themeVersion": "v5-drone-lime",
"bgOuter": "#0f0f0f", "bgOuter": "#0f0f0f",
"canvasTop": "#1c1c1c", "canvasTop": "#1c1c1c",
"canvasBottom": "#1c1c1c", "canvasBottom": "#1c1c1c",
@ -81,40 +81,48 @@
"loadingBottom": "#1c1c1c", "loadingBottom": "#1c1c1c",
"accent": "#ffea00", "accent": "#ffea00",
"accent2": "#ffea00", "accent2": "#ffea00",
"templateFill": "#1c1c1c", "templateFill": "#292929",
"templateOutline": "#233044", "templateOutline": "#292929",
"templateOutlineHover": "rgba(255,234,0,0.7)", "templateOutlineHover": "#1c1c1c",
"projectBtnFill": "#292929",
"projectBtnBorder": "#292929",
"projectBtnHover": "#1c1c1c",
"modalBtnPrimary": "#c4ff67",
"modalBtnSecondary": "#292929",
"loaderColor": "#c4ff67",
"templateGlow": 80, "templateGlow": 80,
"toolbarBg": "#1c1c1c", "toolbarBg": "#292929",
"toolbarBgActive": "#ffea00", "toolbarBgActive": "#c4ff67",
"toolbarBorder": "#233044", "toolbarBorder": "#292929",
"toolbarOutline": "#ffea00", "toolbarOutline": "#292929",
"modalBg": "#1c1c1c", "modalBg": "#1c1c1c",
"modalBgAlpha": 1,
"modalFocus": "#292929", "modalFocus": "#292929",
"modalBorder": "#292929", "modalFocusAlpha": 0.35,
"modalElement": "#292929", "modalBorder": "#1c1c1c",
"modalElementOutline": "#2b2b2b", "modalElement": "#262626",
"modalElementOutline": "#262626",
"modalRadius": "14px", "modalRadius": "14px",
"border": "#233044", "border": "#233044",
"text": "#e6edf3", "text": "#e6edf3",
"panel": "#1c1c1c", "panel": "#1c1c1c",
"panel2": "#292929", "panel2": "#292929",
"highlight": "#ffea00", "highlight": "#c4ff67",
"cubeText": "#242424", "cubeText": "#242424",
"cubeEdges": "#e0e7ff", "cubeEdges": "#e0e7ff",
"plus": "#ffea00", "plus": "#c4ff67",
"navCubeBase": "#ffea00", "navCubeBase": "#ffffff",
"navCubeHover": "#ffffff", "navCubeHover": "#ffffff",
"navCubeText": "#242424", "navCubeText": "#242424",
"navCubeEdge": "#e0e7ff", "navCubeEdge": "#e0e7ff",
"navCubeEdgeAlpha": 0.6, "navCubeEdgeAlpha": 0.6,
"measureLine": "#ffea00", "measureLine": "#c4ff67",
"measureLabel": "#ffea00", "measureLabel": "#c4ff67",
"measureLabelText": "#292929", "measureLabelText": "#292929",
"measureDot": "#ffea00" "measureDot": "#c4ff67"
}, },
"meta": { "meta": {
"description": "", "description": "",
"tags": [] "tags": []
} }
} }

View File

@ -92,7 +92,7 @@
} }
}, },
"design": { "design": {
"themeVersion": "v3-yellow", "themeVersion": "v5-drone-lime",
"bgOuter": "#0f0f0f", "bgOuter": "#0f0f0f",
"canvasTop": "#1c1c1c", "canvasTop": "#1c1c1c",
"canvasBottom": "#1c1c1c", "canvasBottom": "#1c1c1c",
@ -100,48 +100,48 @@
"loadingBottom": "#1c1c1c", "loadingBottom": "#1c1c1c",
"accent": "#ffea00", "accent": "#ffea00",
"accent2": "#ffea00", "accent2": "#ffea00",
"templateFill": "#1c1c1c", "templateFill": "#292929",
"templateOutline": "#233044", "templateOutline": "#292929",
"templateOutlineHover": "rgba(255,234,0,0.7)", "templateOutlineHover": "#1c1c1c",
"projectBtnFill": "#1c1c1c", "projectBtnFill": "#292929",
"projectBtnBorder": "#1c1c1c", "projectBtnBorder": "#292929",
"projectBtnHover": "#292929", "projectBtnHover": "#1c1c1c",
"modalBtnPrimary": "#befd02", "modalBtnPrimary": "#c4ff67",
"modalBtnSecondary": "#292929", "modalBtnSecondary": "#292929",
"loaderColor": "#befd02", "loaderColor": "#c4ff67",
"templateGlow": 80, "templateGlow": 80,
"toolbarBg": "#1c1c1c", "toolbarBg": "#292929",
"toolbarBgActive": "#ffea00", "toolbarBgActive": "#c4ff67",
"toolbarBorder": "#233044", "toolbarBorder": "#292929",
"toolbarOutline": "#ffea00", "toolbarOutline": "#292929",
"modalBg": "#1c1c1c", "modalBg": "#1c1c1c",
"modalBgAlpha": 0.95, "modalBgAlpha": 1,
"modalFocus": "#292929", "modalFocus": "#292929",
"modalFocusAlpha": 0.55, "modalFocusAlpha": 0.35,
"modalBorder": "#292929", "modalBorder": "#1c1c1c",
"modalElement": "#292929", "modalElement": "#262626",
"modalElementOutline": "#2b2b2b", "modalElementOutline": "#262626",
"modalRadius": "14px", "modalRadius": "14px",
"border": "#233044", "border": "#233044",
"text": "#e6edf3", "text": "#e6edf3",
"panel": "#1c1c1c", "panel": "#1c1c1c",
"panel2": "#292929", "panel2": "#292929",
"highlight": "#ffea00", "highlight": "#c4ff67",
"cubeText": "#242424", "cubeText": "#242424",
"cubeEdges": "#e0e7ff", "cubeEdges": "#e0e7ff",
"plus": "#ffea00", "plus": "#c4ff67",
"navCubeBase": "#ffea00", "navCubeBase": "#ffffff",
"navCubeHover": "#ffffff", "navCubeHover": "#ffffff",
"navCubeText": "#242424", "navCubeText": "#242424",
"navCubeEdge": "#e0e7ff", "navCubeEdge": "#e0e7ff",
"navCubeEdgeAlpha": 0.6, "navCubeEdgeAlpha": 0.6,
"measureLine": "#ffea00", "measureLine": "#c4ff67",
"measureLabel": "#ffea00", "measureLabel": "#c4ff67",
"measureLabelText": "#292929", "measureLabelText": "#292929",
"measureDot": "#ffea00" "measureDot": "#c4ff67"
}, },
"meta": { "meta": {
"description": "", "description": "",
"tags": [] "tags": []
} }
} }

File diff suppressed because it is too large Load Diff