Compare commits

..

No commits in common. "249d46c7c7f5e9700b65440bc3e136397daa243a" and "09b1173697d5fef525da6ea482fe9587425c289c" have entirely different histories.

33 changed files with 650 additions and 12578 deletions

View File

@ -1,14 +0,0 @@
# 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,5 +4,3 @@ node_modules
__pycache__/ __pycache__/
*.pyc *.pyc
server/data/uploads/ server/data/uploads/
server/data/shares/
server/data/models/

View File

@ -1,179 +0,0 @@
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,14 +1,11 @@
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
@ -25,28 +22,18 @@ from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
CONVERTER_NAME = "NodeDcBimConverter" CONVERTER_NAME = "NodeDcBimConverter"
CONVERTER_VERSION = "0.4.6" CONVERTER_VERSION = "0.4.0"
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"}
@ -62,26 +49,6 @@ 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"))
@ -96,219 +63,6 @@ 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]
@ -604,8 +358,7 @@ 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), " f"{labels.Length()} free shape(s), deflection={STEP_MESH_LINEAR_DEFLECTION}",
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):
@ -635,7 +388,6 @@ 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,
} }
@ -646,14 +398,11 @@ 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=STEP_MESH_LINEAR_DEFLECTION, angularTolerance=STEP_MESH_ANGULAR_DEFLECTION) assy.export(str(glb_path), tolerance=0.1, angularTolerance=0.1)
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
@ -666,33 +415,6 @@ 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}
@ -794,136 +516,6 @@ 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)
@ -955,35 +547,12 @@ 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:
result = run_step_glb_stage_with_timeout( tree, stats = convert_step_xcaf_to_glb(source_path, glb_path)
"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( print(f"[{CONVERTER_NAME}] OCCT/XCAF path failed for {source_path}: {exc}", file=sys.stderr, flush=True)
f"[{CONVERTER_NAME}] OCCT/XCAF path failed for {source_path}: {exc}; " tree, stats = convert_step_cadquery_to_glb(source_path, glb_path)
"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,
@ -999,20 +568,14 @@ 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() or upload_src_exists(manifest.get("artifactSrc"))): if status == "ready" and ready_artifact_path.exists():
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() < PROCESSING_STALE_SECONDS: if (datetime.now(timezone.utc) - updated).total_seconds() < 300:
return False return False
except Exception: except Exception:
pass pass
@ -1030,25 +593,7 @@ 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,
@ -1058,69 +603,11 @@ 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,
"attempts": attempt, "message": "Preparing XKT preview and component tree.",
"message": "Подготавливаем просмотр и дерево компонентов.",
"status": "processing", "status": "processing",
"updatedAt": now_iso(), "updatedAt": now_iso(),
}, },
@ -1131,34 +618,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")
ready_manifest = { write_json_atomic(
**base_manifest, manifest_file,
"attempts": attempt, {
"artifactSrc": artifact_src, **base_manifest,
"artifactType": artifact_type, "artifactSrc": artifact_src,
"fallbackArtifactSrc": src_from_path(glb_path), "artifactType": artifact_type,
"fallbackArtifactType": "gltf", "fallbackArtifactSrc": src_from_path(glb_path),
"componentCount": metadata.get("componentCount"), "fallbackArtifactType": "gltf",
"metadataSrc": src_from_path(metadata_path), "componentCount": metadata.get("componentCount"),
"message": "Просмотр и дерево компонентов готовы.", "metadataSrc": src_from_path(metadata_path),
"status": "ready", "message": "XKT preview and component tree are ready.",
"updatedAt": now_iso(), "status": "ready",
} "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:
failed_manifest = { write_json_atomic(
**base_manifest, manifest_file,
"attempts": attempt, {
"error": str(exc), **base_manifest,
"message": conversion_failed_message(exc), "error": str(exc),
"status": "failed", "message": "Failed to prepare XKT preview and component tree.",
"updatedAt": now_iso(), "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}: {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,23 +3,10 @@ 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:
- "${NODEDC_BIM_HOST_PORT:-8080}:8080" - "8080:8080"
volumes: volumes:
- ./:/beam - ./:/beam
@ -29,13 +16,8 @@ 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.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 852 B

View File

@ -1,29 +0,0 @@
{
"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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -4,141 +4,38 @@
<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" type="image/svg+xml" href="/assets/favicon/icon-adaptive.svg"> <link rel="icon" href="./assets/logo.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?v=3"></script> <script src="./theme.js"></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 sharedViewer = /^\/share\/[^/]+\/?$/.test(window.location.pathname || ""); const raw = localStorage.getItem(STORAGE_KEY);
let stored = null; let stored = null;
if (!sharedViewer) { try {
LEGACY_KEYS.forEach((k) => localStorage.removeItem(k)); stored = raw ? JSON.parse(raw) : null;
const raw = localStorage.getItem(STORAGE_KEY); } catch (e) {
try { stored = null;
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 s = { ...(project || {}), ...(!sharedViewer && stored ? stored : {}) }; const storedVersion = stored && stored.themeVersion;
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);
@ -152,28 +49,18 @@
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);
@ -192,21 +79,6 @@
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
} }
@ -215,11 +87,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=37"> <link rel="stylesheet" href="./dcViewer.css?v=4">
</head> </head>
<body> <body>
<header> <header>
<button class="logo-btn" id="homeButton" type="button" title="Открыть NODE.DC Hub"></button> <button class="logo-btn" id="homeButton" title="Вернуться к старту"></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>
@ -252,295 +124,77 @@
</div> </div>
</section> </section>
<aside class="side-panel" id="viewerInspector"> <aside class="side-panel">
<div class="inspector-panel-header"> <div class="panel-section">
<h3>Инспектор</h3> <h3>Инспектор</h3>
<button <div id="treeContainer" class="tree"></div>
class="inspector-collapse-btn" <div class="display-mode-panel" id="displayModeControl">
id="inspectorCollapseButton" <div class="display-mode-title">Режим отображения</div>
type="button" <div class="display-mode-buttons">
title="Свернуть инспектор" <button type="button" data-display-mode="source" class="active">Исходный</button>
aria-label="Свернуть инспектор" <button type="button" data-display-mode="white">Белый</button>
> <button type="button" data-display-mode="contrast">Контраст</button>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"> </div>
<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> </div>
</svg>
</button>
</div> </div>
<div class="inspector-panel-body"> <div class="panel-section" id="projectNameSection">
<div class="panel-section inspector-main-section"> <h3>Название проекта</h3>
<div id="treeContainer" class="tree"></div> <input type="text" id="projectNameInput" class="project-name-input" placeholder="Проект" autocomplete="off">
<div class="inspector-controls"> <div class="error-text inline-error" id="projectNameError"></div>
<div class="inspector-control-row inspector-control-row--full hidden" id="modelVersionControl"> </div>
<div class="inspector-version-heading"> <div class="panel-section">
<div class="inspector-control-label">Версия модели</div> <h3>Глобальные координаты</h3>
<div class="inspector-version-actions"> <div class="muted" style="margin-bottom:6px;">Центр выделения (мировые)</div>
<button class="inspector-icon-btn" id="modelVersionHistoryButton" type="button" title="История версий" aria-label="История версий"> <div class="transform-grid">
<svg viewBox="0 0 16 16" aria-hidden="true"> <label>X <input type="text" id="globalX"></label>
<path d="M3.2 3.4v3.3h3.3"></path> <label>Y <input type="text" id="globalY"></label>
<path d="M3.7 6.7A4.7 4.7 0 1 0 5.1 3"></path> <label>Z <input type="text" id="globalZ"></label>
<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 class="panel-section" id="projectNameSection"> </div>
<h3>Название проекта</h3> <div class="panel-section">
<input type="text" id="projectNameInput" class="project-name-input" placeholder="Проект" autocomplete="off"> <h3>Трансформации</h3>
<div class="error-text inline-error" id="projectNameError"></div> <div class="muted" style="margin-bottom:6px;">Перемещение</div>
<div class="transform-grid">
<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>
<div class="panel-section"> <div class="muted" style="margin:8px 0 6px;">Вращение (deg)</div>
<h3>Глобальные координаты</h3> <div class="transform-grid">
<div class="muted" style="margin-bottom:6px;">Центр выделения (мировые)</div> <label>X <input type="text" id="rotX"></label>
<div class="transform-grid"> <label>Y <input type="text" id="rotY"></label>
<label>X <input type="text" id="globalX"></label> <label>Z <input type="text" id="rotZ"></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="muted" style="margin:8px 0 6px;">Масштаб</div>
<h3>Трансформации</h3> <div class="transform-grid">
<div class="muted" style="margin-bottom:6px;">Перемещение</div> <label>X <input type="text" id="scaleX"></label>
<div class="transform-grid"> <label>Y <input type="text" id="scaleY"></label>
<label>X <input type="text" id="posX"></label> <label>Z <input type="text" id="scaleZ"></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 class="panel-section">
<h3>Журнал</h3>
<div id="logList" class="log"></div>
</div>
<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 inspector-collapse-btn" id="saveProjectClose" aria-label="Закрыть"> <button class="modal-close" id="saveProjectClose" aria-label="Закрыть">×</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>
<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">
@ -556,11 +210,7 @@
<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 inspector-collapse-btn" id="deleteProjectClose" aria-label="Закрыть"> <button class="modal-close" id="deleteProjectClose" aria-label="Закрыть">×</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>
<div class="input-hint">Вы уверены, что хотите удалить проект?</div> <div class="input-hint">Вы уверены, что хотите удалить проект?</div>
<div class="modal-actions"> <div class="modal-actions">
@ -569,118 +219,18 @@
</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="Настройки" aria-label="Настройки"> <button class="tb-btn" data-action="settings" title="Настройки"></button>
<svg viewBox="0 0 24 24" aria-hidden="true"> <button class="tb-btn" data-action="templates" 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> <button class="tb-btn" data-action="measure" title="Линейка">📏</button>
<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="share" title="Поделиться моделью" aria-label="Поделиться моделью"> <button class="tb-btn" data-action="add" title="Добавить файл"></button>
<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=56"></script> <script type="module" src="./dcViewer.js?v=4"></script>
</body> </body>
</html> </html>

View File

@ -126510,9 +126510,6 @@ 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() {
@ -126568,45 +126565,6 @@ 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,
@ -126873,16 +126831,6 @@ 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();
@ -126920,16 +126868,13 @@ class TransformControl {
{ // Keep gizmo screen size constant { // Keep gizmo screen size constant
let lastDist = -1; let lastDist = -1;
const setRootNodeScale = size => { const setRootNodeScale = size => {
this._baseRootScale = size; if (rootNode.scale[0] !== size) {
const styledSize = size * (this._styleScale || 1); rootNode.scale = [size, size, size];
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));
@ -127085,22 +127030,6 @@ 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.
@ -127774,7 +127703,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 });
const planeMesh = rootNode.addChild(new Mesh(rootNode, { // plane rootNode.addChild(new Mesh(rootNode, { // plane
geometry: new ReadableGeometry(rootNode, { geometry: new ReadableGeometry(rootNode, {
primitive: "triangles", primitive: "triangles",
positions: [ positions: [
@ -127808,7 +127737,7 @@ class SectionPlanesPlugin extends Plugin {
isObject: false isObject: false
})); }));
const frameMesh = rootNode.addChild(new Mesh(rootNode, { // Visible frame 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,
@ -127840,38 +127769,6 @@ 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();
@ -127884,26 +127781,7 @@ 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 = () => { };
@ -127925,10 +127803,6 @@ 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

@ -1,60 +0,0 @@
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,16 +1,6 @@
export function createSliderControl({ export function createSliderControl({ label = "", value = 0, min = 0, max = 100, step = 1, onChange }) {
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", className].filter(Boolean).join(" "); row.className = "slider-row";
const lbl = document.createElement("label"); const lbl = document.createElement("label");
lbl.textContent = label; lbl.textContent = label;
@ -24,34 +14,9 @@ export function createSliderControl({
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 range = Number(input.max) - Number(input.min); const percent = ((input.value - input.min) / (input.max - input.min)) * 100;
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", () => {
@ -64,14 +29,5 @@ export function createSliderControl({
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

@ -1,216 +0,0 @@
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,8 +1,7 @@
import { createSliderControl } from "./controls/slider.js"; import { createSliderControl } from "./controls/slider.js";
import { createColorControl } from "./controls/color.js";
const defaultSettings = { const defaultSettings = {
themeVersion: "v7-toolbar-hub-icon", themeVersion: "v3-yellow",
bgOuter: "#0f0f0f", bgOuter: "#0f0f0f",
canvasTop: "#1c1c1c", canvasTop: "#1c1c1c",
canvasBottom: "#1c1c1c", canvasBottom: "#1c1c1c",
@ -10,199 +9,53 @@ const defaultSettings = {
loadingBottom: "#1c1c1c", loadingBottom: "#1c1c1c",
accent: "#ffea00", accent: "#ffea00",
accent2: "#ffea00", accent2: "#ffea00",
templateFill: "#292929", templateFill: "#1c1c1c",
templateOutline: "#292929", templateOutline: "#233044",
templateOutlineHover: "#1c1c1c", templateOutlineHover: "rgba(255,234,0,0.7)",
projectBtnFill: "#292929", projectBtnFill: "#1c1c1c",
projectBtnBorder: "#292929", projectBtnBorder: "#233044",
projectBtnHover: "#1c1c1c", projectBtnHover: "rgba(255,234,0,0.7)",
modalBtnPrimary: "#c4ff67", modalBtnPrimary: "#ffea00",
modalBtnSecondary: "#292929", modalBtnSecondary: "#292929",
loaderColor: "#c4ff67", loaderColor: "#ffea00",
templateGlow: 80, templateGlow: 80,
toolbarBg: "#292929", toolbarBg: "#1c1c1c",
toolbarBgActive: "#c4ff67", toolbarBgActive: "#ffea00",
toolbarBorder: "#292929", toolbarBorder: "#233044",
toolbarOutline: "#292929", toolbarOutline: "#ffea00",
toolbarMinSize: 25,
toolbarMaxSize: 87,
toolbarLensCount: 5,
toolbarAutoHide: false,
modalBg: "#1c1c1c", modalBg: "#1c1c1c",
modalBgAlpha: 1, modalBgAlpha: 1,
modalFocus: "#292929", modalFocus: "#292929",
modalFocusAlpha: 0.35, modalFocusAlpha: 1,
modalBorder: "#1c1c1c", modalBorder: "#292929",
modalElement: "#262626", modalElement: "#292929",
modalElementOutline: "#262626", modalElementOutline: "#2b2b2b",
modalRadius: "14px", modalRadius: "14px",
border: "#233044", border: "#233044",
text: "#e6edf3", text: "#e6edf3",
panel: "#1c1c1c", panel: "#1c1c1c",
panel2: "#292929", panel2: "#292929",
highlight: "#c4ff67", highlight: "#ffea00",
cubeText: "#242424", cubeText: "#242424",
cubeEdges: "#e0e7ff", cubeEdges: "#e0e7ff",
plus: "#c4ff67", plus: "#ffea00",
navCubeBase: "#ffffff", navCubeBase: "#ffea00",
navCubeHover: "#ffffff", navCubeHover: "#ffffff",
navCubeText: "#242424", navCubeText: "#242424",
navCubeEdge: "#e0e7ff", navCubeEdge: "#e0e7ff",
navCubeEdgeAlpha: 0.6, navCubeEdgeAlpha: 0.6,
measureLine: "#c4ff67", measureLine: "#ffea00",
measureLabel: "#c4ff67", measureLabel: "#ffea00",
measureLabelText: "#292929", measureLabelText: "#292929",
measureDot: "#c4ff67", measureDot: "#ffea00",
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);
@ -219,26 +72,13 @@ 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);
@ -249,7 +89,6 @@ 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);
@ -269,27 +108,11 @@ 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) {
@ -298,9 +121,6 @@ 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));
@ -366,20 +186,11 @@ 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.setAttribute("aria-label", "Закрыть настройки"); closeBtn.textContent = "—";
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";
@ -391,103 +202,29 @@ 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("div"); const lbl = document.createElement("label");
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.className = "env-number-field"; input.type = "color";
input.type = "number"; input.value = isHex(state[key]) ? state[key] : "#ffffff";
input.value = value; const hex = document.createElement("input");
input.min = min; hex.type = "text";
input.step = step; hex.value = state[key];
if (max !== undefined) { input.addEventListener("change", (e) => {
input.max = max; hex.value = e.target.value;
} setVal(key, e.target.value);
input.addEventListener("input", () => { });
const nextValue = Number(input.value); hex.addEventListener("change", (e) => {
if (Number.isFinite(nextValue)) { input.value = e.target.value;
onChange?.(nextValue); setVal(key, e.target.value);
}
}); });
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("Фон");
@ -496,302 +233,157 @@ 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");
body.appendChild(font); panel.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");
addSettingsSlider({ const brightnessSlider = createSliderControl({
parent: buttons, label: "Яркость подсветки",
label: "Яркость подсветки", value: Number(state.templateGlow ?? 80),
key: "templateGlow", min: 0,
value: Number(state.templateGlow ?? 80), max: 100,
min: 0, step: 1,
max: 100, onChange: (val) => setVal("templateGlow", val),
step: 1, });
formatValue: (val) => `${Math.round(val)}%`, buttons.appendChild(brightnessSlider);
}); 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");
body.appendChild(projectButtons); panel.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");
body.appendChild(modalButtons); panel.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");
addSettingsSlider({ panel.appendChild(toolbar);
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");
addSettingsSlider({ const edgeAlphaRow = document.createElement("div");
parent: navCube, edgeAlphaRow.className = "color-row";
label: "Прозрачность рёбер", const edgeAlphaLbl = document.createElement("label");
value: Math.round(Math.max(0, Math.min(1, Number(state.navCubeEdgeAlpha ?? 0.6))) * 100), edgeAlphaLbl.textContent = "Прозрачность рёбер (0-1)";
min: 0, const edgeAlphaInput = document.createElement("input");
max: 100, edgeAlphaInput.type = "number";
step: 5, edgeAlphaInput.step = "0.05";
formatValue: (val) => `${Math.round(val)}%`, edgeAlphaInput.min = "0";
onChange: (val) => { edgeAlphaInput.max = "1";
setVal("navCubeEdgeAlpha", Math.max(0, Math.min(1, Number(val) / 100))); edgeAlphaInput.value = state.navCubeEdgeAlpha;
}, edgeAlphaInput.addEventListener("change", () => {
const v = Math.min(1, Math.max(0, parseFloat(edgeAlphaInput.value) || 0));
edgeAlphaInput.value = v;
setVal("navCubeEdgeAlpha", v);
}); });
body.appendChild(navCube); edgeAlphaRow.appendChild(edgeAlphaLbl);
edgeAlphaRow.appendChild(edgeAlphaInput);
const sectionCut = section("Разрез / сечение"); edgeAlphaRow.appendChild(document.createElement("div"));
addColor(sectionCut, "Ось X", "sectionAxisX"); navCube.appendChild(edgeAlphaRow);
addColor(sectionCut, "Ось Y", "sectionAxisY"); panel.appendChild(navCube);
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");
body.appendChild(measure); panel.appendChild(measure);
const viewport = section("Viewport"); const viewport = section("Viewport");
const addCheck = (label, checked, handler) => { const addCheck = (label, checked, handler) => {
const row = document.createElement("label"); const row = document.createElement("div");
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));
row.appendChild(text); const lbl = document.createElement("label");
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; });
body.appendChild(viewport); panel.appendChild(viewport);
const modals = section("Модальные окна"); const modals = section("Модальные окна");
addColor(modals, "Основной фон", "modalBg"); addColor(modals, "Основной фон", "modalBg");
addColor(modals, "Фокусный фон", "modalFocus"); addColor(modals, "Фокусный фон", "modalFocus");
addSettingsSlider({ const modalBgAlphaRow = createSliderControl({
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);
addSettingsSlider({ const modalFocusAlphaRow = createSliderControl({
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");
addNumber({ const radiusRow = document.createElement("div");
parent: modals, radiusRow.className = "color-row";
label: "Радиус", const radiusLbl = document.createElement("label");
value: parseInt(getComputedStyle(document.documentElement).getPropertyValue("--modal-radius")) || 14, radiusLbl.textContent = "Радиус";
min: 0, const radiusInput = document.createElement("input");
max: 48, radiusInput.type = "number";
step: 1, radiusInput.value = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--modal-radius")) || 14;
onChange: (nextValue) => { radiusInput.addEventListener("change", () => {
const val = `${Math.max(0, Math.min(48, Math.round(nextValue)))}px`; const val = `${radiusInput.value}px`;
setVal("modalRadius", val); document.documentElement.style.setProperty("--modal-radius", val);
}, setVal("modalRadius", val);
}); });
body.appendChild(modals); radiusRow.appendChild(radiusLbl);
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");
body.appendChild(cube); panel.appendChild(cube);
}; };
const show = () => { const show = () => {
render(); render();
if (panel) { if (panel) {
panel.classList.remove("hidden"); panel.classList.remove("hidden");
panel.style.display = "flex"; panel.style.display = "grid";
} }
onShow?.(); onShow?.();
}; };

View File

@ -62,42 +62,27 @@ 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.className = "templates-actions"; actions.style.display = "flex";
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.setAttribute("aria-label", "Обновить список"); refresh.textContent = "↻";
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.type = "button"; 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);
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 = "Загрузка списка проектов...";
body.appendChild(loadingEl); panel.appendChild(loadingEl);
return; return;
} }
@ -106,11 +91,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;
body.appendChild(errorEl); panel.appendChild(errorEl);
} }
body.appendChild(renderSection("Шаблоны", state.templates, "Нет шаблонов")); panel.appendChild(renderSection("Шаблоны", state.templates, "Нет шаблонов"));
body.appendChild(renderSection("Мои проекты", state.projects, "Нет сохранённых проектов")); panel.appendChild(renderSection("Мои проекты", state.projects, "Нет сохранённых проектов"));
}; };
const load = async () => { const load = async () => {
@ -141,7 +126,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 = "flex"; panel.style.display = "grid";
} }
load(); load();
onShow?.(); onShow?.();

View File

@ -1,8 +1,4 @@
const toolbarConfig = { const toolbarConfig = {
minSize: 25,
maxSize: 87,
lensCount: 5,
autoHide: false,
size: 25, size: 25,
gap: 10, gap: 10,
radius: 7, radius: 7,
@ -11,22 +7,9 @@ const toolbarConfig = {
iconSize: 13, iconSize: 13,
}; };
const numericSetting = (settings, key, fallback) => { const applyToolbarVars = () => {
const value = Number(settings?.[key]);
return Number.isFinite(value) ? value : fallback;
};
const applyToolbarVars = (settings = {}) => {
const root = document.documentElement; const root = document.documentElement;
const minSize = numericSetting(settings, "toolbarMinSize", toolbarConfig.minSize); root.style.setProperty("--tb-size", `${toolbarConfig.size}px`);
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: "v7-toolbar-hub-icon", themeVersion: "v3-yellow",
bgOuter: "#0f0f0f", bgOuter: "#0f0f0f",
canvasTop: "#1c1c1c", canvasTop: "#1c1c1c",
canvasBottom: "#1c1c1c", canvasBottom: "#1c1c1c",
@ -9,64 +9,44 @@ const PROJECT_THEME = {
loadingBottom: "#1c1c1c", loadingBottom: "#1c1c1c",
accent: "#ffea00", accent: "#ffea00",
accent2: "#ffea00", accent2: "#ffea00",
templateFill: "#292929", templateFill: "#1c1c1c",
templateOutline: "#292929", templateOutline: "#233044",
templateOutlineHover: "#1c1c1c", templateOutlineHover: "rgba(255,234,0,0.7)",
templateGlow: 80, templateGlow: 80,
toolbarBg: "#292929", toolbarBg: "#1c1c1c",
toolbarBgActive: "#c4ff67", toolbarBgActive: "#ffea00",
toolbarBorder: "#292929", toolbarBorder: "#233044",
toolbarOutline: "#292929", toolbarOutline: "#ffea00",
toolbarMinSize: 25,
toolbarMaxSize: 87,
toolbarLensCount: 5,
toolbarAutoHide: false,
modalBg: "#1c1c1c", modalBg: "#1c1c1c",
modalBgAlpha: 1, modalBgAlpha: 1,
modalFocus: "#292929", modalFocus: "#292929",
modalFocusAlpha: 0.35, modalFocusAlpha: 1,
modalBorder: "#1c1c1c", modalBorder: "#292929",
modalElement: "#262626", modalElement: "#292929",
modalElementOutline: "#262626", modalElementOutline: "#2b2b2b",
modalRadius: "14px", modalRadius: "14px",
projectBtnFill: "#292929", projectBtnFill: "#1c1c1c",
projectBtnBorder: "#292929", projectBtnBorder: "#233044",
projectBtnHover: "#1c1c1c", projectBtnHover: "rgba(255,234,0,0.7)",
modalBtnPrimary: "#c4ff67", modalBtnPrimary: "#ffea00",
modalBtnSecondary: "#292929", modalBtnSecondary: "#292929",
loaderColor: "#c4ff67",
border: "#233044", border: "#233044",
text: "#e6edf3", text: "#e6edf3",
panel: "#1c1c1c", panel: "#1c1c1c",
panel2: "#292929", panel2: "#292929",
highlight: "#c4ff67", highlight: "#ffea00",
cubeText: "#242424", cubeText: "#242424",
cubeEdges: "#e0e7ff", cubeEdges: "#e0e7ff",
plus: "#c4ff67", plus: "#ffea00",
navCubeBase: "#ffffff", navCubeBase: "#ffea00",
navCubeHover: "#ffffff", navCubeHover: "#ffffff",
navCubeText: "#242424", navCubeText: "#242424",
navCubeEdge: "#e0e7ff", navCubeEdge: "#e0e7ff",
navCubeEdgeAlpha: 0.6, navCubeEdgeAlpha: 0.6,
measureLine: "#c4ff67", measureLine: "#ffea00",
measureLabel: "#c4ff67", measureLabel: "#ffea00",
measureLabelText: "#292929", measureLabelText: "#292929",
measureDot: "#c4ff67", measureDot: "#ffea00",
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,13 +18,6 @@ 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;
@ -73,9 +66,7 @@ export class TransformGizmo {
scale: model.scale ? [...model.scale] : [1, 1, 1], scale: model.scale ? [...model.scale] : [1, 1, 1],
}, },
}); });
if (!this._isPointCloudTarget(model, objectId)) { this.scene.setObjectsHighlighted([objectId], true);
this.scene.setObjectsHighlighted([objectId], true);
}
this.target = { modelId, objectId }; this.target = { modelId, objectId };
return; return;
} }
@ -83,7 +74,7 @@ export class TransformGizmo {
// Если objectId нет, работаем всей моделью // Если objectId нет, работаем всей моделью
const ids = Object.keys(model.objects || {}); const ids = Object.keys(model.objects || {});
if (ids.length && !this._isPointCloudTarget(model)) { if (ids.length) {
this.scene.setObjectsHighlighted(ids, true); this.scene.setObjectsHighlighted(ids, true);
} }
this.onTargetChange?.({ this.onTargetChange?.({
@ -110,9 +101,7 @@ 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);
if (!this._isPointCloudTarget(model)) { this.scene.setObjectsHighlighted(ids, true);
this.scene.setObjectsHighlighted(ids, true);
}
this.target = { modelId, objectId: null }; this.target = { modelId, objectId: null };
} }
} }

View File

@ -9,17 +9,13 @@
Статика: раздаётся из `../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.
@ -28,24 +24,4 @@ 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

@ -1,100 +0,0 @@
[
{
"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,20 +1,4 @@
[ [
{
"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

@ -1,154 +0,0 @@
{
"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

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

View File

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

File diff suppressed because it is too large Load Diff