Compare commits
53 Commits
09b1173697
...
249d46c7c7
| Author | SHA1 | Date |
|---|---|---|
|
|
249d46c7c7 | |
|
|
ce34ecdd4d | |
|
|
168b535953 | |
|
|
ffa60e4a22 | |
|
|
66fc4960bf | |
|
|
48400c7d8c | |
|
|
33d2f03624 | |
|
|
bf34f35d6c | |
|
|
09492a5fdc | |
|
|
f680d6bad2 | |
|
|
fcfa59ec98 | |
|
|
1a6fa9e898 | |
|
|
99ee210ffc | |
|
|
4be23070a1 | |
|
|
eee3eecde3 | |
|
|
f6ed7d18e0 | |
|
|
42629cb193 | |
|
|
ad3a4f3877 | |
|
|
3584359833 | |
|
|
778a56ea39 | |
|
|
7d65070f59 | |
|
|
b7fa6b5fbd | |
|
|
2aab1bc9b4 | |
|
|
2b89977083 | |
|
|
0556cc4ae3 | |
|
|
b4d66a92c5 | |
|
|
405113fa4f | |
|
|
d70f6e0886 | |
|
|
684f85b382 | |
|
|
c248217976 | |
|
|
5b5d8f1c3c | |
|
|
e6bb3f1761 | |
|
|
f28de867c7 | |
|
|
84b28ba12c | |
|
|
e343412468 | |
|
|
4638acc04b | |
|
|
ef5b0a42a6 | |
|
|
be2294d3fa | |
|
|
68e2160ac5 | |
|
|
9e61480ad1 | |
|
|
7e4b91b2da | |
|
|
9dadf6f838 | |
|
|
4ecebd5e79 | |
|
|
6e1f5a7f08 | |
|
|
bf397ff191 | |
|
|
ff994e705d | |
|
|
0a09296022 | |
|
|
bb30202b9b | |
|
|
43accba61c | |
|
|
6d65b2ba41 | |
|
|
1569ed9c23 | |
|
|
f40a6ce38d | |
|
|
a95a7d5c38 |
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Synology/NODE.DC production BIM Viewer environment.
|
||||||
|
# Copy to the live BIM deployment env and fill secrets from platform .env.synology.
|
||||||
|
|
||||||
|
NODEDC_BIM_HOST_PORT=18100
|
||||||
|
NODEDC_BIM_SERVICE_SLUG=bim-viewer
|
||||||
|
NODEDC_BIM_AUTH_REQUIRED=1
|
||||||
|
NODEDC_BIM_PUBLIC_URL=https://bim.nodedc.tech
|
||||||
|
NODEDC_BIM_ALLOWED_ORIGINS=https://hub.nodedc.ru,https://ops.nodedc.ru,https://bim.nodedc.tech
|
||||||
|
NODEDC_BIM_COOKIE_SECURE=1
|
||||||
|
NODEDC_BIM_COOKIE_SAMESITE=None
|
||||||
|
|
||||||
|
NODEDC_LAUNCHER_BASE_URL=https://hub.nodedc.ru
|
||||||
|
NODEDC_LAUNCHER_INTERNAL_URL=https://hub.nodedc.ru
|
||||||
|
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-token
|
||||||
|
|
@ -4,3 +4,5 @@ node_modules
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
server/data/uploads/
|
server/data/uploads/
|
||||||
|
server/data/shares/
|
||||||
|
server/data/models/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cadquery as cq
|
||||||
|
|
||||||
|
|
||||||
|
def hash_id(value: str) -> str:
|
||||||
|
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def json_safe(value: Any) -> Any:
|
||||||
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): json_safe(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [json_safe(item) for item in value]
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def node_to_metadata(node: cq.Assembly, parent_path: str = "") -> dict[str, Any]:
|
||||||
|
name = node.name or "root"
|
||||||
|
node_path = f"{parent_path}/{name}" if parent_path else name
|
||||||
|
children = [node_to_metadata(child, node_path) for child in node.children]
|
||||||
|
return {
|
||||||
|
"id": hash_id(node_path),
|
||||||
|
"name": name,
|
||||||
|
"path": node_path,
|
||||||
|
"hasShape": bool(node.obj),
|
||||||
|
"metadata": json_safe(node.metadata or {}),
|
||||||
|
"children": children,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def count_nodes(node: dict[str, Any]) -> int:
|
||||||
|
return 1 + sum(count_nodes(child) for child in node.get("children", []))
|
||||||
|
|
||||||
|
|
||||||
|
def metadata_dict(value: Any) -> dict[str, Any]:
|
||||||
|
safe_value = json_safe(value or {})
|
||||||
|
return safe_value if isinstance(safe_value, dict) else {"value": safe_value}
|
||||||
|
|
||||||
|
|
||||||
|
def get_shape_type(value: Any) -> str | None:
|
||||||
|
try:
|
||||||
|
shape_type = value.ShapeType()
|
||||||
|
return str(shape_type) if shape_type else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def collect_solids(workplane: cq.Workplane) -> list[Any]:
|
||||||
|
solids: list[Any] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
|
||||||
|
for value in workplane.vals():
|
||||||
|
candidates = []
|
||||||
|
try:
|
||||||
|
candidates = list(value.Solids())
|
||||||
|
except Exception:
|
||||||
|
candidates = []
|
||||||
|
if not candidates and get_shape_type(value) == "Solid":
|
||||||
|
candidates = [value]
|
||||||
|
for solid in candidates:
|
||||||
|
key = hash(solid)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
solids.append(solid)
|
||||||
|
|
||||||
|
return solids
|
||||||
|
|
||||||
|
|
||||||
|
def unique_assembly_name(parent: Any, requested_name: Any) -> str:
|
||||||
|
base_name = str(requested_name or "component")
|
||||||
|
if base_name not in parent.objects:
|
||||||
|
return base_name
|
||||||
|
|
||||||
|
index = 2
|
||||||
|
while True:
|
||||||
|
candidate = f"{base_name}__{index:03d}"
|
||||||
|
if candidate not in parent.objects:
|
||||||
|
return candidate
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
|
||||||
|
def import_step_with_unique_component_names(source_path: Path) -> cq.Assembly:
|
||||||
|
original_add = cq.Assembly.add
|
||||||
|
|
||||||
|
def add_with_unique_names(self: cq.Assembly, arg: Any, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
if isinstance(arg, cq.Assembly):
|
||||||
|
positional_name = args[0] if args else None
|
||||||
|
requested_name = kwargs.get("name", positional_name) or arg.name
|
||||||
|
unique_name = unique_assembly_name(self, requested_name)
|
||||||
|
|
||||||
|
if unique_name != requested_name:
|
||||||
|
arg.metadata = {
|
||||||
|
**metadata_dict(arg.metadata),
|
||||||
|
"originalName": str(requested_name),
|
||||||
|
}
|
||||||
|
if args:
|
||||||
|
args = (unique_name, *args[1:])
|
||||||
|
else:
|
||||||
|
kwargs = {**kwargs, "name": unique_name}
|
||||||
|
|
||||||
|
return original_add(self, arg, *args, **kwargs)
|
||||||
|
|
||||||
|
cq.Assembly.add = add_with_unique_names
|
||||||
|
try:
|
||||||
|
return cq.Assembly.importStep(str(source_path))
|
||||||
|
finally:
|
||||||
|
cq.Assembly.add = original_add
|
||||||
|
|
||||||
|
|
||||||
|
def import_step_assembly(source_path: Path) -> tuple[cq.Assembly, str]:
|
||||||
|
try:
|
||||||
|
return cq.Assembly.importStep(str(source_path)), "assembly"
|
||||||
|
except ValueError as exc:
|
||||||
|
error_message = str(exc)
|
||||||
|
if "Unique name is required" in error_message and "already in the assembly" in error_message:
|
||||||
|
return import_step_with_unique_component_names(source_path), "assembly-unique-names"
|
||||||
|
if "does not contain an assembly" not in error_message:
|
||||||
|
raise
|
||||||
|
|
||||||
|
step_model = cq.importers.importStep(str(source_path))
|
||||||
|
solids = collect_solids(step_model)
|
||||||
|
assembly = cq.Assembly(name=f"{source_path.stem}_root")
|
||||||
|
|
||||||
|
if len(solids) > 1:
|
||||||
|
for index, solid in enumerate(solids, start=1):
|
||||||
|
assembly.add(solid, name=f"{source_path.stem}_solid_{index:03d}")
|
||||||
|
return assembly, "split-solids"
|
||||||
|
|
||||||
|
assembly.add(step_model, name=source_path.stem)
|
||||||
|
return assembly, "single-shape"
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = path.with_name(f"{path.name}.tmp")
|
||||||
|
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
tmp_path.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
source_path = Path(sys.argv[1])
|
||||||
|
glb_path = Path(sys.argv[2])
|
||||||
|
result_path = Path(sys.argv[3])
|
||||||
|
linear_deflection = float(sys.argv[4])
|
||||||
|
angular_deflection = float(sys.argv[5])
|
||||||
|
started_at = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
assembly, import_strategy = import_step_assembly(source_path)
|
||||||
|
tree = node_to_metadata(assembly)
|
||||||
|
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
assembly.export(str(glb_path), tolerance=linear_deflection, angularTolerance=angular_deflection)
|
||||||
|
stats = {
|
||||||
|
"fallback": True,
|
||||||
|
"fileSizeMb": round(source_path.stat().st_size / 1024 / 1024, 2),
|
||||||
|
"importStrategy": import_strategy,
|
||||||
|
"cadqueryVersion": getattr(cq, "__version__", "unknown"),
|
||||||
|
"linearDeflection": linear_deflection,
|
||||||
|
"angularDeflection": angular_deflection,
|
||||||
|
"durationSeconds": round(time.monotonic() - started_at, 2),
|
||||||
|
}
|
||||||
|
write_json_atomic(result_path, {"ok": True, "tree": tree, "stats": stats})
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
write_json_atomic(result_path, {"ok": False, "error": str(exc)})
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from multiprocessing import Process
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -22,18 +25,28 @@ from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
|
||||||
|
|
||||||
|
|
||||||
CONVERTER_NAME = "NodeDcBimConverter"
|
CONVERTER_NAME = "NodeDcBimConverter"
|
||||||
CONVERTER_VERSION = "0.4.0"
|
CONVERTER_VERSION = "0.4.6"
|
||||||
UPLOADS_DIR = Path(os.environ.get("NODEDC_BIM_CONVERTER_UPLOADS_DIR", "/beam/uploads")).resolve()
|
UPLOADS_DIR = Path(os.environ.get("NODEDC_BIM_CONVERTER_UPLOADS_DIR", "/beam/uploads")).resolve()
|
||||||
POLL_INTERVAL_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_INTERVAL_SECONDS", "10"))
|
POLL_INTERVAL_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_INTERVAL_SECONDS", "10"))
|
||||||
PROCESS_ONCE = os.environ.get("NODEDC_BIM_CONVERTER_ONCE", "").lower() in {"1", "true", "yes"}
|
PROCESS_ONCE = os.environ.get("NODEDC_BIM_CONVERTER_ONCE", "").lower() in {"1", "true", "yes"}
|
||||||
REPROCESS_READY_ON_VERSION_CHANGE = os.environ.get(
|
REPROCESS_READY_ON_VERSION_CHANGE = os.environ.get(
|
||||||
"NODEDC_BIM_CONVERTER_REPROCESS_READY_ON_VERSION_CHANGE", ""
|
"NODEDC_BIM_CONVERTER_REPROCESS_READY_ON_VERSION_CHANGE", ""
|
||||||
).lower() in {"1", "true", "yes"}
|
).lower() in {"1", "true", "yes"}
|
||||||
|
REPROCESS_FAILED_ON_VERSION_CHANGE = os.environ.get(
|
||||||
|
"NODEDC_BIM_CONVERTER_REPROCESS_FAILED_ON_VERSION_CHANGE", "1"
|
||||||
|
).lower() in {"1", "true", "yes"}
|
||||||
XKT_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_XKT_ENABLED", "1").lower() not in {"0", "false", "no"}
|
XKT_ENABLED = os.environ.get("NODEDC_BIM_CONVERTER_XKT_ENABLED", "1").lower() not in {"0", "false", "no"}
|
||||||
XKT_COMMAND = os.environ.get("NODEDC_BIM_CONVERTER_XKT_COMMAND", "xeokit-convert")
|
XKT_COMMAND = os.environ.get("NODEDC_BIM_CONVERTER_XKT_COMMAND", "xeokit-convert")
|
||||||
NODE_OPTIONS = os.environ.get("NODE_OPTIONS", "--max-old-space-size=8192")
|
NODE_OPTIONS = os.environ.get("NODE_OPTIONS", "--max-old-space-size=8192")
|
||||||
STEP_MESH_LINEAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_LINEAR_DEFLECTION", "0.1"))
|
STEP_MESH_LINEAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_LINEAR_DEFLECTION", "0.1"))
|
||||||
STEP_MESH_ANGULAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_ANGULAR_DEFLECTION", "0.1"))
|
STEP_MESH_ANGULAR_DEFLECTION = float(os.environ.get("NODEDC_BIM_CONVERTER_STEP_ANGULAR_DEFLECTION", "0.1"))
|
||||||
|
PROCESSING_STALE_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_PROCESSING_STALE_SECONDS", "60"))
|
||||||
|
CONVERSION_MAX_ATTEMPTS = int(os.environ.get("NODEDC_BIM_CONVERTER_MAX_ATTEMPTS", "3"))
|
||||||
|
CONVERSION_TIMEOUT_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_TIMEOUT_SECONDS", "300"))
|
||||||
|
XCAF_TIMEOUT_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_XCAF_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS)))
|
||||||
|
CADQUERY_TIMEOUT_SECONDS = float(
|
||||||
|
os.environ.get("NODEDC_BIM_CONVERTER_CADQUERY_TIMEOUT_SECONDS", str(CONVERSION_TIMEOUT_SECONDS))
|
||||||
|
)
|
||||||
STEP_EXTENSIONS = {".step", ".stp"}
|
STEP_EXTENSIONS = {".step", ".stp"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,6 +62,26 @@ def manifest_path(source_path: Path) -> Path:
|
||||||
return source_path.with_name(f"{source_path.name}.beam.json")
|
return source_path.with_name(f"{source_path.name}.beam.json")
|
||||||
|
|
||||||
|
|
||||||
|
def asset_manifest_path(source_path: Path) -> Path | None:
|
||||||
|
version_dir = source_path.parent
|
||||||
|
asset_dir = version_dir.parent
|
||||||
|
if not version_dir.name.startswith("ver_"):
|
||||||
|
return None
|
||||||
|
if asset_dir == UPLOADS_DIR or not asset_dir.is_dir():
|
||||||
|
return None
|
||||||
|
return asset_dir / "asset.json"
|
||||||
|
|
||||||
|
|
||||||
|
def version_asset_dir(source_path: Path) -> Path | None:
|
||||||
|
version_dir = source_path.parent
|
||||||
|
asset_dir = version_dir.parent
|
||||||
|
if not version_dir.name.startswith("ver_"):
|
||||||
|
return None
|
||||||
|
if asset_dir == UPLOADS_DIR or not asset_dir.is_dir():
|
||||||
|
return None
|
||||||
|
return asset_dir
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path) -> dict[str, Any]:
|
def read_json(path: Path) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
@ -63,6 +96,219 @@ def write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
|
||||||
tmp_path.replace(path)
|
tmp_path.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def conversion_attempt_count(manifest: dict[str, Any]) -> int:
|
||||||
|
try:
|
||||||
|
return max(0, int(manifest.get("attempts") or 0))
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_asset_manifest_version(source_path: Path, version_payload: dict[str, Any]) -> None:
|
||||||
|
asset_path = asset_manifest_path(source_path)
|
||||||
|
if not asset_path:
|
||||||
|
return
|
||||||
|
asset = read_json(asset_path)
|
||||||
|
versions = asset.get("versions")
|
||||||
|
if not isinstance(versions, list):
|
||||||
|
versions = []
|
||||||
|
|
||||||
|
version_id = version_payload.get("versionId")
|
||||||
|
version_number = version_payload.get("version")
|
||||||
|
next_versions = []
|
||||||
|
replaced = False
|
||||||
|
for item in versions:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
is_match = (version_id and item.get("versionId") == version_id) or (
|
||||||
|
version_number and item.get("version") == version_number
|
||||||
|
)
|
||||||
|
if is_match:
|
||||||
|
next_versions.append({**item, **version_payload})
|
||||||
|
replaced = True
|
||||||
|
else:
|
||||||
|
next_versions.append(item)
|
||||||
|
if not replaced:
|
||||||
|
next_versions.append(version_payload)
|
||||||
|
|
||||||
|
next_versions.sort(key=lambda item: int(item.get("version") or 0))
|
||||||
|
now = now_iso()
|
||||||
|
write_json_atomic(
|
||||||
|
asset_path,
|
||||||
|
{
|
||||||
|
**asset,
|
||||||
|
"assetId": asset.get("assetId") or version_payload.get("assetId"),
|
||||||
|
"projectId": asset.get("projectId") or version_payload.get("projectId"),
|
||||||
|
"currentVersion": version_payload.get("version") or asset.get("currentVersion"),
|
||||||
|
"currentVersionId": version_payload.get("versionId") or asset.get("currentVersionId"),
|
||||||
|
"updatedAt": now,
|
||||||
|
"versions": next_versions,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def file_size_mb(path: Path) -> float:
|
||||||
|
try:
|
||||||
|
return path.stat().st_size / (1024 * 1024)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_file_sha256(path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upload_src_exists(src: Any) -> bool:
|
||||||
|
if not isinstance(src, str) or not src.startswith("uploads/"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return (UPLOADS_DIR / src.removeprefix("uploads/")).resolve().is_file()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def upload_path_from_src(src: Any) -> Path | None:
|
||||||
|
if not isinstance(src, str) or not src.startswith("uploads/"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
path = (UPLOADS_DIR / src.removeprefix("uploads/")).resolve()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
path.relative_to(UPLOADS_DIR)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return path if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def source_path_from_manifest_file(manifest_file: Path, manifest: dict[str, Any]) -> Path | None:
|
||||||
|
source_path = upload_path_from_src(manifest.get("sourceSrc") or manifest.get("downloadSrc"))
|
||||||
|
if source_path:
|
||||||
|
return source_path
|
||||||
|
source_name = manifest_file.name.removesuffix(".beam.json")
|
||||||
|
fallback_path = manifest_file.with_name(source_name)
|
||||||
|
return fallback_path if fallback_path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def link_or_copy_file(source_path: Path, target_path: Path) -> None:
|
||||||
|
if source_path.resolve() == target_path.resolve():
|
||||||
|
return
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
if target_path.exists():
|
||||||
|
target_path.unlink()
|
||||||
|
os.link(source_path, target_path)
|
||||||
|
except OSError:
|
||||||
|
shutil.copy2(source_path, target_path)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_reusable_conversion_artifacts(
|
||||||
|
source_path: Path,
|
||||||
|
reusable_conversion: dict[str, Any],
|
||||||
|
glb_path: Path,
|
||||||
|
xkt_path: Path,
|
||||||
|
metadata_path: Path,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
artifact_source_path = upload_path_from_src(reusable_conversion.get("artifactSrc"))
|
||||||
|
metadata_source_path = upload_path_from_src(reusable_conversion.get("metadataSrc"))
|
||||||
|
fallback_source_path = upload_path_from_src(reusable_conversion.get("fallbackArtifactSrc"))
|
||||||
|
|
||||||
|
artifact_type = reusable_conversion.get("artifactType") or reusable_conversion.get("targetFormat") or "xkt"
|
||||||
|
artifact_target_path = xkt_path if artifact_type == "xkt" else glb_path
|
||||||
|
|
||||||
|
if not artifact_source_path or not metadata_source_path:
|
||||||
|
raise RuntimeError("Reusable conversion is missing artifact or metadata file.")
|
||||||
|
|
||||||
|
if fallback_source_path:
|
||||||
|
link_or_copy_file(fallback_source_path, glb_path)
|
||||||
|
if artifact_source_path != fallback_source_path:
|
||||||
|
link_or_copy_file(artifact_source_path, artifact_target_path)
|
||||||
|
|
||||||
|
metadata = read_json(metadata_source_path)
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"source": src_from_path(source_path),
|
||||||
|
"artifact": src_from_path(artifact_target_path),
|
||||||
|
"artifactType": artifact_type,
|
||||||
|
"glbArtifact": src_from_path(glb_path) if glb_path.exists() else None,
|
||||||
|
"xktArtifact": src_from_path(xkt_path) if artifact_type == "xkt" and xkt_path.exists() else None,
|
||||||
|
"generatedAt": now_iso(),
|
||||||
|
"reusedFrom": {
|
||||||
|
"assetId": reusable_conversion.get("assetId"),
|
||||||
|
"projectId": reusable_conversion.get("projectId"),
|
||||||
|
"version": reusable_conversion.get("version"),
|
||||||
|
"versionId": reusable_conversion.get("versionId"),
|
||||||
|
"sourceSrc": reusable_conversion.get("sourceSrc"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
converter = metadata.get("converter")
|
||||||
|
if isinstance(converter, dict):
|
||||||
|
metadata["converter"] = {
|
||||||
|
**converter,
|
||||||
|
"name": CONVERTER_NAME,
|
||||||
|
"version": CONVERTER_VERSION,
|
||||||
|
"reusedArtifacts": True,
|
||||||
|
}
|
||||||
|
write_json_atomic(metadata_path, metadata)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"artifactSrc": src_from_path(artifact_target_path),
|
||||||
|
"artifactType": artifact_type,
|
||||||
|
"componentCount": reusable_conversion.get("componentCount") or metadata.get("componentCount"),
|
||||||
|
"fallbackArtifactSrc": src_from_path(glb_path) if glb_path.exists() else None,
|
||||||
|
"fallbackArtifactType": "gltf" if glb_path.exists() else None,
|
||||||
|
"metadataSrc": src_from_path(metadata_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_ready_conversion_with_same_hash(source_path: Path, manifest: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
sha256 = manifest.get("sha256") or calculate_file_sha256(source_path)
|
||||||
|
if not sha256:
|
||||||
|
return None
|
||||||
|
version_id = manifest.get("versionId")
|
||||||
|
version_number = manifest.get("version")
|
||||||
|
source_manifest_path = manifest_path(source_path).resolve()
|
||||||
|
current_asset_dir = version_asset_dir(source_path)
|
||||||
|
for candidate_manifest_path in sorted(UPLOADS_DIR.rglob("*.beam.json")):
|
||||||
|
if candidate_manifest_path.resolve() == source_manifest_path:
|
||||||
|
continue
|
||||||
|
candidate = read_json(candidate_manifest_path)
|
||||||
|
if candidate.get("status") != "ready":
|
||||||
|
continue
|
||||||
|
candidate_sha256 = candidate.get("sha256")
|
||||||
|
if not candidate_sha256:
|
||||||
|
candidate_source_path = source_path_from_manifest_file(candidate_manifest_path, candidate)
|
||||||
|
if candidate_source_path:
|
||||||
|
candidate_sha256 = calculate_file_sha256(candidate_source_path)
|
||||||
|
if candidate_sha256:
|
||||||
|
candidate = {**candidate, "sha256": candidate_sha256}
|
||||||
|
write_json_atomic(candidate_manifest_path, candidate)
|
||||||
|
if candidate_source_path:
|
||||||
|
update_asset_manifest_version(candidate_source_path, candidate)
|
||||||
|
if candidate_sha256 != sha256:
|
||||||
|
continue
|
||||||
|
if candidate.get("sourceFormat") != "step":
|
||||||
|
continue
|
||||||
|
if candidate.get("artifactType") != "xkt" and candidate.get("targetFormat") != "xkt":
|
||||||
|
continue
|
||||||
|
if current_asset_dir and candidate_manifest_path.parent.parent == current_asset_dir:
|
||||||
|
if candidate.get("versionId") == version_id or candidate.get("version") == version_number:
|
||||||
|
continue
|
||||||
|
if not candidate.get("artifactSrc") or not candidate.get("metadataSrc"):
|
||||||
|
continue
|
||||||
|
if not upload_src_exists(candidate.get("artifactSrc")) or not upload_src_exists(candidate.get("metadataSrc")):
|
||||||
|
continue
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def hash_id(value: str) -> str:
|
def hash_id(value: str) -> str:
|
||||||
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
|
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
@ -358,7 +604,8 @@ def convert_step_xcaf_to_glb(source_path: Path, glb_path: Path) -> tuple[dict[st
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"[{CONVERTER_NAME}] meshing {source_path.name}: "
|
f"[{CONVERTER_NAME}] meshing {source_path.name}: "
|
||||||
f"{labels.Length()} free shape(s), deflection={STEP_MESH_LINEAR_DEFLECTION}",
|
f"{labels.Length()} free shape(s), "
|
||||||
|
f"linear={STEP_MESH_LINEAR_DEFLECTION}, angular={STEP_MESH_ANGULAR_DEFLECTION}",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
for index in range(1, labels.Length() + 1):
|
for index in range(1, labels.Length() + 1):
|
||||||
|
|
@ -388,6 +635,7 @@ def convert_step_xcaf_to_glb(source_path: Path, glb_path: Path) -> tuple[dict[st
|
||||||
"gltfNodeCount": len(gltf_json.get("nodes", [])),
|
"gltfNodeCount": len(gltf_json.get("nodes", [])),
|
||||||
"gltfMeshCount": len(gltf_json.get("meshes", [])),
|
"gltfMeshCount": len(gltf_json.get("meshes", [])),
|
||||||
"renamedNodeCount": renamed_nodes,
|
"renamedNodeCount": renamed_nodes,
|
||||||
|
"fileSizeMb": round(file_size_mb(source_path), 2),
|
||||||
"linearDeflection": STEP_MESH_LINEAR_DEFLECTION,
|
"linearDeflection": STEP_MESH_LINEAR_DEFLECTION,
|
||||||
"angularDeflection": STEP_MESH_ANGULAR_DEFLECTION,
|
"angularDeflection": STEP_MESH_ANGULAR_DEFLECTION,
|
||||||
}
|
}
|
||||||
|
|
@ -398,11 +646,14 @@ def convert_step_cadquery_to_glb(source_path: Path, glb_path: Path) -> tuple[dic
|
||||||
assy, import_strategy, cadquery_version = import_step_assembly(source_path)
|
assy, import_strategy, cadquery_version = import_step_assembly(source_path)
|
||||||
tree = node_to_metadata(assy)
|
tree = node_to_metadata(assy)
|
||||||
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
assy.export(str(glb_path), tolerance=0.1, angularTolerance=0.1)
|
assy.export(str(glb_path), tolerance=STEP_MESH_LINEAR_DEFLECTION, angularTolerance=STEP_MESH_ANGULAR_DEFLECTION)
|
||||||
stats = {
|
stats = {
|
||||||
"fallback": True,
|
"fallback": True,
|
||||||
|
"fileSizeMb": round(file_size_mb(source_path), 2),
|
||||||
"importStrategy": import_strategy,
|
"importStrategy": import_strategy,
|
||||||
"cadqueryVersion": cadquery_version,
|
"cadqueryVersion": cadquery_version,
|
||||||
|
"linearDeflection": STEP_MESH_LINEAR_DEFLECTION,
|
||||||
|
"angularDeflection": STEP_MESH_ANGULAR_DEFLECTION,
|
||||||
}
|
}
|
||||||
return tree, stats
|
return tree, stats
|
||||||
|
|
||||||
|
|
@ -415,6 +666,33 @@ def read_log_tail(path: Path, max_lines: int = 30) -> str:
|
||||||
return "\n".join(lines[-max_lines:])
|
return "\n".join(lines[-max_lines:])
|
||||||
|
|
||||||
|
|
||||||
|
def stage_exit_error(stage_name: str, exitcode: int) -> str:
|
||||||
|
if exitcode < 0:
|
||||||
|
signal_number = abs(exitcode)
|
||||||
|
if signal_number == 9:
|
||||||
|
return f"{stage_name} process was killed by signal 9; likely converter memory limit was exceeded."
|
||||||
|
return f"{stage_name} process was killed by signal {signal_number}."
|
||||||
|
if exitcode == 137:
|
||||||
|
return f"{stage_name} process was killed with code 137; likely converter memory limit was exceeded."
|
||||||
|
return f"{stage_name} subprocess exited with code {exitcode}."
|
||||||
|
|
||||||
|
|
||||||
|
def is_resource_limit_error(error: Any) -> bool:
|
||||||
|
message = str(error)
|
||||||
|
return (
|
||||||
|
"signal 9" in message
|
||||||
|
or "code -9" in message
|
||||||
|
or "code 137" in message
|
||||||
|
or "memory limit was exceeded" in message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def conversion_failed_message(error: Any) -> str:
|
||||||
|
if is_resource_limit_error(error):
|
||||||
|
return "Не удалось подготовить просмотр и дерево компонентов: не хватило памяти конвертера."
|
||||||
|
return "Не удалось подготовить просмотр и дерево компонентов."
|
||||||
|
|
||||||
|
|
||||||
def convert_glb_to_xkt(glb_path: Path, xkt_path: Path) -> dict[str, Any]:
|
def convert_glb_to_xkt(glb_path: Path, xkt_path: Path) -> dict[str, Any]:
|
||||||
if not XKT_ENABLED:
|
if not XKT_ENABLED:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
@ -516,6 +794,136 @@ def build_step_metadata(
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def convert_step_xcaf_to_glb_process(source_path: str, glb_path: str, result_path: str) -> None:
|
||||||
|
try:
|
||||||
|
tree, stats = convert_step_xcaf_to_glb(Path(source_path), Path(glb_path))
|
||||||
|
write_json_atomic(Path(result_path), {"ok": True, "tree": tree, "stats": stats})
|
||||||
|
except Exception as exc:
|
||||||
|
write_json_atomic(Path(result_path), {"ok": False, "error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
def convert_step_cadquery_to_glb_process(source_path: str, glb_path: str, result_path: str) -> None:
|
||||||
|
try:
|
||||||
|
stage_path = Path(__file__).with_name("cadquery_stage.py")
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(stage_path),
|
||||||
|
source_path,
|
||||||
|
glb_path,
|
||||||
|
result_path,
|
||||||
|
str(STEP_MESH_LINEAR_DEFLECTION),
|
||||||
|
str(STEP_MESH_ANGULAR_DEFLECTION),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
result = read_json(Path(result_path))
|
||||||
|
raise RuntimeError(result.get("error") or f"CadQuery subprocess exited with code {completed.returncode}.")
|
||||||
|
except Exception as exc:
|
||||||
|
write_json_atomic(Path(result_path), {"ok": False, "error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
def run_cadquery_stage_with_timeout(result_path: Path, source_path: Path, glb_path: Path) -> dict[str, Any]:
|
||||||
|
if result_path.exists():
|
||||||
|
result_path.unlink()
|
||||||
|
|
||||||
|
stage_path = Path(__file__).with_name("cadquery_stage.py")
|
||||||
|
command = [
|
||||||
|
sys.executable,
|
||||||
|
str(stage_path),
|
||||||
|
str(source_path),
|
||||||
|
str(glb_path),
|
||||||
|
str(result_path),
|
||||||
|
str(STEP_MESH_LINEAR_DEFLECTION),
|
||||||
|
str(STEP_MESH_ANGULAR_DEFLECTION),
|
||||||
|
]
|
||||||
|
process = subprocess.Popen(command, start_new_session=True)
|
||||||
|
try:
|
||||||
|
if CADQUERY_TIMEOUT_SECONDS <= 0:
|
||||||
|
process.wait()
|
||||||
|
else:
|
||||||
|
process.wait(timeout=CADQUERY_TIMEOUT_SECONDS)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
except Exception:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=10)
|
||||||
|
raise TimeoutError(f"CadQuery timed out after {int(CADQUERY_TIMEOUT_SECONDS)} seconds.")
|
||||||
|
|
||||||
|
result = read_json(result_path)
|
||||||
|
try:
|
||||||
|
result_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise RuntimeError(result.get("error") or stage_exit_error("CadQuery", process.returncode))
|
||||||
|
if not result:
|
||||||
|
raise RuntimeError("CadQuery produced no result.")
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise RuntimeError(str(result.get("error") or "CadQuery failed."))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run_step_glb_stage_with_timeout(
|
||||||
|
stage_name: str,
|
||||||
|
timeout_seconds: float,
|
||||||
|
target: Any,
|
||||||
|
result_path: Path,
|
||||||
|
*args: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if result_path.exists():
|
||||||
|
result_path.unlink()
|
||||||
|
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
target(*args, str(result_path))
|
||||||
|
else:
|
||||||
|
process = Process(target=target, args=(*args, str(result_path)))
|
||||||
|
process.start()
|
||||||
|
process.join(timeout_seconds)
|
||||||
|
|
||||||
|
if process.is_alive():
|
||||||
|
process.terminate()
|
||||||
|
process.join(10)
|
||||||
|
if process.is_alive():
|
||||||
|
process.kill()
|
||||||
|
process.join(10)
|
||||||
|
raise TimeoutError(f"{stage_name} timed out after {int(timeout_seconds)} seconds.")
|
||||||
|
|
||||||
|
if process.exitcode != 0:
|
||||||
|
result = read_json(result_path)
|
||||||
|
error = result.get("error") if isinstance(result, dict) else None
|
||||||
|
raise RuntimeError(error or stage_exit_error(stage_name, process.exitcode))
|
||||||
|
|
||||||
|
result = read_json(result_path)
|
||||||
|
try:
|
||||||
|
result_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not result:
|
||||||
|
raise RuntimeError(f"{stage_name} produced no result.")
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise RuntimeError(str(result.get("error") or f"{stage_name} failed."))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def remove_partial_artifacts(*paths: Path) -> None:
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def convert_step_assets(source_path: Path, glb_path: Path, xkt_path: Path, metadata_path: Path) -> dict[str, Any]:
|
def convert_step_assets(source_path: Path, glb_path: Path, xkt_path: Path, metadata_path: Path) -> dict[str, Any]:
|
||||||
previous_metadata = read_json(metadata_path)
|
previous_metadata = read_json(metadata_path)
|
||||||
|
|
||||||
|
|
@ -547,12 +955,35 @@ def convert_step_assets(source_path: Path, glb_path: Path, xkt_path: Path, metad
|
||||||
)
|
)
|
||||||
|
|
||||||
import_strategy = "occt-xcaf-gltf"
|
import_strategy = "occt-xcaf-gltf"
|
||||||
|
result_path = metadata_path.with_name(f"{metadata_path.name}.stage-result.json")
|
||||||
try:
|
try:
|
||||||
tree, stats = convert_step_xcaf_to_glb(source_path, glb_path)
|
result = run_step_glb_stage_with_timeout(
|
||||||
|
"OCCT/XCAF",
|
||||||
|
XCAF_TIMEOUT_SECONDS,
|
||||||
|
convert_step_xcaf_to_glb_process,
|
||||||
|
result_path,
|
||||||
|
str(source_path),
|
||||||
|
str(glb_path),
|
||||||
|
)
|
||||||
|
tree = result["tree"]
|
||||||
|
stats = result["stats"]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[{CONVERTER_NAME}] OCCT/XCAF path failed for {source_path}: {exc}", file=sys.stderr, flush=True)
|
print(
|
||||||
tree, stats = convert_step_cadquery_to_glb(source_path, glb_path)
|
f"[{CONVERTER_NAME}] OCCT/XCAF path failed for {source_path}: {exc}; "
|
||||||
|
"falling back to CadQuery",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
remove_partial_artifacts(glb_path, xkt_path)
|
||||||
|
result = run_cadquery_stage_with_timeout(result_path, source_path, glb_path)
|
||||||
|
tree = result["tree"]
|
||||||
|
stats = result["stats"]
|
||||||
import_strategy = stats.get("importStrategy") or "cadquery"
|
import_strategy = stats.get("importStrategy") or "cadquery"
|
||||||
|
stats = {
|
||||||
|
**stats,
|
||||||
|
"fallbackFrom": "occt-xcaf-gltf",
|
||||||
|
"fallbackReason": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
return build_step_metadata(
|
return build_step_metadata(
|
||||||
source_path,
|
source_path,
|
||||||
|
|
@ -568,14 +999,20 @@ def convert_step_assets(source_path: Path, glb_path: Path, xkt_path: Path, metad
|
||||||
def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path, xkt_path: Path) -> bool:
|
def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path, xkt_path: Path) -> bool:
|
||||||
status = manifest.get("status")
|
status = manifest.get("status")
|
||||||
ready_artifact_path = xkt_path if XKT_ENABLED else glb_path
|
ready_artifact_path = xkt_path if XKT_ENABLED else glb_path
|
||||||
if status == "ready" and ready_artifact_path.exists():
|
if status == "ready" and (ready_artifact_path.exists() or upload_src_exists(manifest.get("artifactSrc"))):
|
||||||
return REPROCESS_READY_ON_VERSION_CHANGE and manifest.get("converterVersion") != CONVERTER_VERSION
|
return REPROCESS_READY_ON_VERSION_CHANGE and manifest.get("converterVersion") != CONVERTER_VERSION
|
||||||
|
if status == "failed":
|
||||||
|
return (
|
||||||
|
REPROCESS_FAILED_ON_VERSION_CHANGE
|
||||||
|
and manifest.get("converterVersion") != CONVERTER_VERSION
|
||||||
|
and source_path.exists()
|
||||||
|
)
|
||||||
if status == "processing":
|
if status == "processing":
|
||||||
updated_at = manifest.get("updatedAt")
|
updated_at = manifest.get("updatedAt")
|
||||||
if updated_at:
|
if updated_at:
|
||||||
try:
|
try:
|
||||||
updated = datetime.fromisoformat(str(updated_at).replace("Z", "+00:00"))
|
updated = datetime.fromisoformat(str(updated_at).replace("Z", "+00:00"))
|
||||||
if (datetime.now(timezone.utc) - updated).total_seconds() < 300:
|
if (datetime.now(timezone.utc) - updated).total_seconds() < PROCESSING_STALE_SECONDS:
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -593,7 +1030,25 @@ def process_one(source_path: Path) -> bool:
|
||||||
if not should_process(source_path, manifest, glb_path, xkt_path):
|
if not should_process(source_path, manifest, glb_path, xkt_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
preserved_fields = {
|
||||||
|
key: manifest.get(key)
|
||||||
|
for key in (
|
||||||
|
"assetId",
|
||||||
|
"projectId",
|
||||||
|
"version",
|
||||||
|
"versionId",
|
||||||
|
"originalFilename",
|
||||||
|
"storedFilename",
|
||||||
|
"size",
|
||||||
|
"sha256",
|
||||||
|
"uploadedAt",
|
||||||
|
"downloadSrc",
|
||||||
|
)
|
||||||
|
if manifest.get(key) is not None
|
||||||
|
}
|
||||||
|
|
||||||
base_manifest = {
|
base_manifest = {
|
||||||
|
**preserved_fields,
|
||||||
"createdAt": manifest.get("createdAt") or now_iso(),
|
"createdAt": manifest.get("createdAt") or now_iso(),
|
||||||
"componentTreeRequired": True,
|
"componentTreeRequired": True,
|
||||||
"converterName": CONVERTER_NAME,
|
"converterName": CONVERTER_NAME,
|
||||||
|
|
@ -603,11 +1058,69 @@ def process_one(source_path: Path) -> bool:
|
||||||
"targetFormat": "xkt" if XKT_ENABLED else "glb",
|
"targetFormat": "xkt" if XKT_ENABLED else "glb",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attempts = 0 if manifest.get("converterVersion") != CONVERTER_VERSION else conversion_attempt_count(manifest)
|
||||||
|
if manifest.get("status") == "processing" and attempts >= CONVERSION_MAX_ATTEMPTS:
|
||||||
|
failed_manifest = {
|
||||||
|
**base_manifest,
|
||||||
|
"attempts": attempts,
|
||||||
|
"error": f"Converter process stopped before completion after {attempts} attempt(s).",
|
||||||
|
"message": "Не удалось подготовить просмотр и дерево компонентов: превышен лимит попыток конвертации.",
|
||||||
|
"status": "failed",
|
||||||
|
"updatedAt": now_iso(),
|
||||||
|
}
|
||||||
|
write_json_atomic(manifest_file, failed_manifest)
|
||||||
|
update_asset_manifest_version(source_path, failed_manifest)
|
||||||
|
print(
|
||||||
|
f"[{CONVERTER_NAME}] failed {source_path}: max attempts reached ({attempts})",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
reusable_conversion = find_ready_conversion_with_same_hash(source_path, manifest)
|
||||||
|
if reusable_conversion:
|
||||||
|
reused_artifacts = copy_reusable_conversion_artifacts(
|
||||||
|
source_path,
|
||||||
|
reusable_conversion,
|
||||||
|
glb_path,
|
||||||
|
xkt_path,
|
||||||
|
metadata_path,
|
||||||
|
)
|
||||||
|
ready_manifest = {
|
||||||
|
**base_manifest,
|
||||||
|
"attempts": attempts,
|
||||||
|
**reused_artifacts,
|
||||||
|
"message": "Просмотр и дерево компонентов готовы.",
|
||||||
|
"reusedFromAssetId": reusable_conversion.get("assetId"),
|
||||||
|
"reusedFromProjectId": reusable_conversion.get("projectId"),
|
||||||
|
"reusedFromVersion": reusable_conversion.get("version"),
|
||||||
|
"reusedFromVersionId": reusable_conversion.get("versionId"),
|
||||||
|
"reusedFromSourceSrc": reusable_conversion.get("sourceSrc"),
|
||||||
|
"status": "ready",
|
||||||
|
"updatedAt": now_iso(),
|
||||||
|
}
|
||||||
|
write_json_atomic(manifest_file, ready_manifest)
|
||||||
|
update_asset_manifest_version(source_path, ready_manifest)
|
||||||
|
reuse_label = (
|
||||||
|
reusable_conversion.get("versionId")
|
||||||
|
or reusable_conversion.get("version")
|
||||||
|
or reusable_conversion.get("sourceSrc")
|
||||||
|
or "ready-artifact"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[{CONVERTER_NAME}] reused ready conversion {source_path.name} "
|
||||||
|
f"from {reuse_label}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
attempt = attempts + 1
|
||||||
write_json_atomic(
|
write_json_atomic(
|
||||||
manifest_file,
|
manifest_file,
|
||||||
{
|
{
|
||||||
**base_manifest,
|
**base_manifest,
|
||||||
"message": "Preparing XKT preview and component tree.",
|
"attempts": attempt,
|
||||||
|
"message": "Подготавливаем просмотр и дерево компонентов.",
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
"updatedAt": now_iso(),
|
"updatedAt": now_iso(),
|
||||||
},
|
},
|
||||||
|
|
@ -618,34 +1131,34 @@ def process_one(source_path: Path) -> bool:
|
||||||
metadata = convert_step_assets(source_path, glb_path, xkt_path, metadata_path)
|
metadata = convert_step_assets(source_path, glb_path, xkt_path, metadata_path)
|
||||||
artifact_src = metadata.get("artifact") or src_from_path(xkt_path if xkt_path.exists() else glb_path)
|
artifact_src = metadata.get("artifact") or src_from_path(xkt_path if xkt_path.exists() else glb_path)
|
||||||
artifact_type = metadata.get("artifactType") or ("xkt" if xkt_path.exists() else "gltf")
|
artifact_type = metadata.get("artifactType") or ("xkt" if xkt_path.exists() else "gltf")
|
||||||
write_json_atomic(
|
ready_manifest = {
|
||||||
manifest_file,
|
**base_manifest,
|
||||||
{
|
"attempts": attempt,
|
||||||
**base_manifest,
|
"artifactSrc": artifact_src,
|
||||||
"artifactSrc": artifact_src,
|
"artifactType": artifact_type,
|
||||||
"artifactType": artifact_type,
|
"fallbackArtifactSrc": src_from_path(glb_path),
|
||||||
"fallbackArtifactSrc": src_from_path(glb_path),
|
"fallbackArtifactType": "gltf",
|
||||||
"fallbackArtifactType": "gltf",
|
"componentCount": metadata.get("componentCount"),
|
||||||
"componentCount": metadata.get("componentCount"),
|
"metadataSrc": src_from_path(metadata_path),
|
||||||
"metadataSrc": src_from_path(metadata_path),
|
"message": "Просмотр и дерево компонентов готовы.",
|
||||||
"message": "XKT preview and component tree are ready.",
|
"status": "ready",
|
||||||
"status": "ready",
|
"updatedAt": now_iso(),
|
||||||
"updatedAt": now_iso(),
|
}
|
||||||
},
|
write_json_atomic(manifest_file, ready_manifest)
|
||||||
)
|
update_asset_manifest_version(source_path, ready_manifest)
|
||||||
print(f"[{CONVERTER_NAME}] ready {artifact_src}", flush=True)
|
print(f"[{CONVERTER_NAME}] ready {artifact_src}", flush=True)
|
||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
write_json_atomic(
|
failed_manifest = {
|
||||||
manifest_file,
|
**base_manifest,
|
||||||
{
|
"attempts": attempt,
|
||||||
**base_manifest,
|
"error": str(exc),
|
||||||
"error": str(exc),
|
"message": conversion_failed_message(exc),
|
||||||
"message": "Failed to prepare XKT preview and component tree.",
|
"status": "failed",
|
||||||
"status": "failed",
|
"updatedAt": now_iso(),
|
||||||
"updatedAt": now_iso(),
|
}
|
||||||
},
|
write_json_atomic(manifest_file, failed_manifest)
|
||||||
)
|
update_asset_manifest_version(source_path, failed_manifest)
|
||||||
print(f"[{CONVERTER_NAME}] failed {source_path}: {exc}", file=sys.stderr, flush=True)
|
print(f"[{CONVERTER_NAME}] failed {source_path}: {exc}", file=sys.stderr, flush=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,23 @@ services:
|
||||||
image: node:22-alpine
|
image: node:22-alpine
|
||||||
working_dir: /beam/server
|
working_dir: /beam/server
|
||||||
command: node index.js
|
command: node index.js
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
NODEDC_BIM_SERVICE_SLUG: "${NODEDC_BIM_SERVICE_SLUG:-bim-viewer}"
|
||||||
|
NODEDC_BIM_AUTH_REQUIRED: "${NODEDC_BIM_AUTH_REQUIRED:-0}"
|
||||||
|
NODEDC_BIM_PUBLIC_URL: "${NODEDC_BIM_PUBLIC_URL:-http://localhost:8080}"
|
||||||
|
NODEDC_BIM_DATA_ROOT: "${NODEDC_BIM_DATA_ROOT:-}"
|
||||||
|
NODEDC_BIM_SESSION_COOKIE: "${NODEDC_BIM_SESSION_COOKIE:-nodedc_bim_session}"
|
||||||
|
NODEDC_BIM_SESSION_TTL_MS: "${NODEDC_BIM_SESSION_TTL_MS:-43200000}"
|
||||||
|
NODEDC_BIM_COOKIE_SECURE: "${NODEDC_BIM_COOKIE_SECURE:-0}"
|
||||||
|
NODEDC_BIM_COOKIE_SAMESITE: "${NODEDC_BIM_COOKIE_SAMESITE:-}"
|
||||||
|
NODEDC_LAUNCHER_BASE_URL: "${NODEDC_LAUNCHER_BASE_URL:-http://localhost:5173}"
|
||||||
|
NODEDC_LAUNCHER_INTERNAL_URL: "${NODEDC_LAUNCHER_INTERNAL_URL:-http://host.docker.internal:5173}"
|
||||||
|
NODEDC_INTERNAL_ACCESS_TOKEN: "${NODEDC_INTERNAL_ACCESS_TOKEN:-}"
|
||||||
|
NODEDC_BIM_ALLOWED_ORIGINS: "${NODEDC_BIM_ALLOWED_ORIGINS:-http://localhost:5173,http://localhost:8090}"
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "${NODEDC_BIM_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/beam
|
- ./:/beam
|
||||||
|
|
||||||
|
|
@ -16,8 +29,13 @@ services:
|
||||||
image: nodedc/bim-converter:local
|
image: nodedc/bim-converter:local
|
||||||
container_name: NodeDcBimConverter
|
container_name: NodeDcBimConverter
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODEDC_BIM_CONVERTER_UPLOADS_DIR: /beam/uploads
|
NODEDC_BIM_CONVERTER_UPLOADS_DIR: /beam/uploads
|
||||||
NODEDC_BIM_CONVERTER_INTERVAL_SECONDS: "10"
|
NODEDC_BIM_CONVERTER_INTERVAL_SECONDS: "10"
|
||||||
|
NODEDC_BIM_CONVERTER_MAX_ATTEMPTS: "3"
|
||||||
|
NODEDC_BIM_CONVERTER_TIMEOUT_SECONDS: "300"
|
||||||
|
NODEDC_BIM_CONVERTER_XCAF_TIMEOUT_SECONDS: "1200"
|
||||||
|
NODEDC_BIM_CONVERTER_CADQUERY_TIMEOUT_SECONDS: "1800"
|
||||||
volumes:
|
volumes:
|
||||||
- ./server/data/uploads:/beam/uploads
|
- ./server/data/uploads:/beam/uploads
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
|
|
@ -0,0 +1,13 @@
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
.icon-fav-01 { fill: #333334; } /* Тёмный цвет для светлого таб-бара */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.icon-fav-01 { fill: #FEFEFE; } /* Светлый цвет для тёмного таб-бара */
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.icon-fav-01 { fill: #000000; } /* Тёмный цвет для светлого таб-бара (для явности) */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path class="icon-fav-01" d="M17.8738 15.9159L16.0002 18.9718L14.1267 15.9159H17.8738ZM23.6307 12.7864H8.36914L15.9999 25.2308L23.6307 12.7864Z"/>
|
||||||
|
<path class="icon-fav-01" d="M10.9793 19.4872L6.67269 11.5918H25.5344L21.2281 19.4872H25.0581L31.2614 8H0.738281L7.16244 19.4872H10.9793Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 852 B |
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"theme_color": "#eeeff4",
|
||||||
|
"background_color": "#eeeff4",
|
||||||
|
"display": "browser",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon-adaptive.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "apple-touch-icon.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "180x180"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
4223
frontend/dcViewer.js
4223
frontend/dcViewer.js
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -4,38 +4,141 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>BIM DC Viewer</title>
|
<title>BIM DC Viewer</title>
|
||||||
<link rel="icon" href="./assets/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon/icon-adaptive.svg">
|
||||||
|
<link rel="alternate icon" href="/assets/favicon/favicon.ico">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/favicon/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/assets/favicon/manifest.webmanifest.json">
|
||||||
<style id="preload-hidden">body{opacity:0;}</style>
|
<style id="preload-hidden">body{opacity:0;}</style>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap">
|
||||||
<script src="./theme.js"></script>
|
<script src="/theme.js?v=3"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (/^\/share\/[^/]+\/?$/.test(window.location.pathname || "")) {
|
||||||
|
document.documentElement.dataset.shareStartup = "pending";
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Apply persisted theme ASAP to avoid initial flash of defaults
|
// Apply persisted theme ASAP to avoid initial flash of defaults
|
||||||
(function () {
|
(function () {
|
||||||
const STORAGE_KEY = "bimdc-settings-v3";
|
const STORAGE_KEY = "bimdc-settings-v3";
|
||||||
const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"];
|
const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"];
|
||||||
try {
|
try {
|
||||||
LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
|
|
||||||
|
|
||||||
const project = window.__BIMDC_THEME__ || {};
|
const project = window.__BIMDC_THEME__ || {};
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const sharedViewer = /^\/share\/[^/]+\/?$/.test(window.location.pathname || "");
|
||||||
let stored = null;
|
let stored = null;
|
||||||
try {
|
if (!sharedViewer) {
|
||||||
stored = raw ? JSON.parse(raw) : null;
|
LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
|
||||||
} catch (e) {
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
stored = null;
|
try {
|
||||||
|
stored = raw ? JSON.parse(raw) : null;
|
||||||
|
} catch (e) {
|
||||||
|
stored = null;
|
||||||
|
}
|
||||||
|
const storedVersion = stored && stored.themeVersion;
|
||||||
|
const projectVersion = project && project.themeVersion;
|
||||||
|
if (!stored || storedVersion !== projectVersion) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
||||||
|
stored = { ...project };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const storedVersion = stored && stored.themeVersion;
|
const s = { ...(project || {}), ...(!sharedViewer && stored ? stored : {}) };
|
||||||
const projectVersion = project && project.themeVersion;
|
|
||||||
if (!stored || storedVersion !== projectVersion) {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(project));
|
|
||||||
stored = { ...project };
|
|
||||||
}
|
|
||||||
const s = { ...(project || {}), ...(stored || {}) };
|
|
||||||
if (!Object.keys(s).length) return;
|
if (!Object.keys(s).length) return;
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const set = (k, v) => v !== undefined && root.style.setProperty(k, v);
|
const set = (k, v) => v !== undefined && root.style.setProperty(k, v);
|
||||||
|
const parseColor = (value) => {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const color = value.trim();
|
||||||
|
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||||
|
if (hex) {
|
||||||
|
const raw = hex[1].length === 3
|
||||||
|
? hex[1].split("").map((c) => c + c).join("")
|
||||||
|
: hex[1];
|
||||||
|
return [
|
||||||
|
parseInt(raw.slice(0, 2), 16),
|
||||||
|
parseInt(raw.slice(2, 4), 16),
|
||||||
|
parseInt(raw.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const rgb = color.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
|
||||||
|
if (!rgb) return null;
|
||||||
|
return rgb.slice(1, 4).map((channel) => Math.max(0, Math.min(255, Number(channel))));
|
||||||
|
};
|
||||||
|
const readableTextColor = (value) => {
|
||||||
|
const rgb = parseColor(value);
|
||||||
|
if (!rgb || rgb.some((channel) => !Number.isFinite(channel))) return "#15170f";
|
||||||
|
const toLinear = (channel) => {
|
||||||
|
const value = channel / 255;
|
||||||
|
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
const lum = 0.2126 * toLinear(rgb[0]) + 0.7152 * toLinear(rgb[1]) + 0.0722 * toLinear(rgb[2]);
|
||||||
|
return (lum + 0.05) / 0.05 >= 1.05 / (lum + 0.05) ? "#15170f" : "#f5f5fa";
|
||||||
|
};
|
||||||
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||||
|
const colorParts = (value, fallback) => {
|
||||||
|
const parsed = parseColor(value);
|
||||||
|
if (parsed && parsed.every((channel) => Number.isFinite(channel))) return parsed;
|
||||||
|
const parsedFallback = parseColor(fallback);
|
||||||
|
if (parsedFallback && parsedFallback.every((channel) => Number.isFinite(channel))) return parsedFallback;
|
||||||
|
return [28, 28, 28];
|
||||||
|
};
|
||||||
|
const alphaValue = (value, fallback) => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? clamp(numeric, 0, 1) : fallback;
|
||||||
|
};
|
||||||
|
const rgba = (parts, alpha) => {
|
||||||
|
const [r, g, b] = parts.map((channel) => Math.round(clamp(channel, 0, 255)));
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1).toFixed(3)})`;
|
||||||
|
};
|
||||||
|
const applyGlassSettings = () => {
|
||||||
|
const panelColor = colorParts(s.modalBg, "#1c1c1c");
|
||||||
|
const focusColor = colorParts(s.modalFocus, "#292929");
|
||||||
|
const controlColor = colorParts(s.modalElement, s.modalFocus || "#262626");
|
||||||
|
const outlineColor = colorParts(s.modalBorder, s.modalElementOutline || "#1c1c1c");
|
||||||
|
const controlOutlineColor = colorParts(s.modalElementOutline, s.modalBorder || "#262626");
|
||||||
|
const modalBgAlpha = alphaValue(s.modalBgAlpha, 1);
|
||||||
|
const modalFocusAlpha = alphaValue(s.modalFocusAlpha, 0.35);
|
||||||
|
const panelAlpha = clamp(0.24 + modalBgAlpha * 0.48, 0.32, 0.88);
|
||||||
|
const panelStrongAlpha = clamp(panelAlpha + 0.1, 0.42, 0.92);
|
||||||
|
const panelSoftAlpha = clamp(panelAlpha - 0.16, 0.2, 0.72);
|
||||||
|
const sectionAlpha = clamp(modalFocusAlpha * 0.6, 0.04, 0.42);
|
||||||
|
const controlAlpha = clamp(0.12 + modalFocusAlpha * 0.9, 0.16, 0.66);
|
||||||
|
const controlHoverAlpha = clamp(controlAlpha + 0.08, 0.22, 0.74);
|
||||||
|
const outlineAlpha = clamp(0.055 + modalFocusAlpha * 0.18, 0.065, 0.2);
|
||||||
|
const controlOutlineAlpha = clamp(0.045 + modalFocusAlpha * 0.12, 0.055, 0.16);
|
||||||
|
|
||||||
|
const panelBg = rgba(panelColor, panelAlpha);
|
||||||
|
const panelBgStrong = rgba(panelColor, panelStrongAlpha);
|
||||||
|
const panelBgSoft = rgba(panelColor, panelSoftAlpha);
|
||||||
|
const sectionBg = rgba(focusColor, sectionAlpha);
|
||||||
|
const outline = rgba(outlineColor, outlineAlpha);
|
||||||
|
const outlineBright = rgba(outlineColor, clamp(outlineAlpha + 0.05, 0.08, 0.26));
|
||||||
|
const outlineMid = rgba(outlineColor, clamp(outlineAlpha + 0.015, 0.07, 0.22));
|
||||||
|
const outlineDim = rgba(outlineColor, clamp(outlineAlpha * 0.62, 0.04, 0.16));
|
||||||
|
const controlBg = rgba(controlColor, controlAlpha);
|
||||||
|
const controlHover = rgba(focusColor, controlHoverAlpha);
|
||||||
|
const controlShadow = rgba(controlOutlineColor, controlOutlineAlpha);
|
||||||
|
|
||||||
|
set("--nodedc-glass-panel-bg", panelBg);
|
||||||
|
set("--nodedc-glass-panel-bg-strong", panelBgStrong);
|
||||||
|
set("--nodedc-glass-panel-bg-soft", panelBgSoft);
|
||||||
|
set("--nodedc-glass-panel-surface", `linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.014) 100%), ${panelBg}`);
|
||||||
|
set("--nodedc-glass-panel-surface-strong", `linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.016) 100%), ${panelBgStrong}`);
|
||||||
|
set("--nodedc-glass-section-bg", `linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.01) 100%), ${sectionBg}`);
|
||||||
|
set("--nodedc-glass-outline", outline);
|
||||||
|
set("--nodedc-glass-outline-gradient", `linear-gradient(180deg, ${outlineBright} 0%, ${outlineMid} 56%, ${outlineDim} 100%)`);
|
||||||
|
set("--nodedc-glass-panel-shadow", `inset 0 1px 0 ${outlineDim}, 0 22px 72px rgba(0, 0, 0, 0.42)`);
|
||||||
|
set("--nodedc-glass-control-bg", `linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.018) 100%), ${controlBg}`);
|
||||||
|
set("--nodedc-glass-control-hover", `linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.026) 100%), ${controlHover}`);
|
||||||
|
set("--nodedc-glass-control-shadow", `inset 0 1px 0 ${controlShadow}`);
|
||||||
|
set("--nodedc-header-dropdown-bg", panelBgStrong);
|
||||||
|
set("--nodedc-header-dropdown-item-bg", controlBg);
|
||||||
|
set("--nodedc-header-dropdown-item-hover-bg", controlHover);
|
||||||
|
set("--nodedc-header-dropdown-item-active-bg", s.toolbarBgActive);
|
||||||
|
set("--nodedc-header-dropdown-item-active-color", s.toolbarBgActive ? readableTextColor(s.toolbarBgActive) : undefined);
|
||||||
|
};
|
||||||
set("--bg-outer", s.bgOuter);
|
set("--bg-outer", s.bgOuter);
|
||||||
set("--canvas-top", s.canvasTop);
|
set("--canvas-top", s.canvasTop);
|
||||||
set("--canvas-bottom", s.canvasBottom);
|
set("--canvas-bottom", s.canvasBottom);
|
||||||
|
|
@ -49,18 +152,28 @@
|
||||||
set("--template-glow-strength", `${s.templateGlow}%`);
|
set("--template-glow-strength", `${s.templateGlow}%`);
|
||||||
set("--toolbar-bg", s.toolbarBg);
|
set("--toolbar-bg", s.toolbarBg);
|
||||||
set("--toolbar-bg-active", s.toolbarBgActive);
|
set("--toolbar-bg-active", s.toolbarBgActive);
|
||||||
|
set("--toolbar-bg-active-contrast", s.toolbarBgActive ? readableTextColor(s.toolbarBgActive) : undefined);
|
||||||
set("--toolbar-border", s.toolbarBorder);
|
set("--toolbar-border", s.toolbarBorder);
|
||||||
set("--toolbar-outline", s.toolbarOutline);
|
set("--toolbar-outline", s.toolbarOutline);
|
||||||
|
set("--tb-size", s.toolbarMinSize !== undefined ? `${s.toolbarMinSize}px` : undefined);
|
||||||
|
set("--tb-min-size", s.toolbarMinSize !== undefined ? `${s.toolbarMinSize}px` : undefined);
|
||||||
|
set("--tb-max-size", s.toolbarMaxSize !== undefined ? `${s.toolbarMaxSize}px` : undefined);
|
||||||
|
set("--tb-lens-count", s.toolbarLensCount);
|
||||||
|
set("--tb-autohide", s.toolbarAutoHide ? "1" : "0");
|
||||||
set("--modal-bg", s.modalBg);
|
set("--modal-bg", s.modalBg);
|
||||||
|
set("--modal-bg-alpha", s.modalBgAlpha !== undefined ? `${s.modalBgAlpha * 100}%` : undefined);
|
||||||
set("--modal-focus", s.modalFocus);
|
set("--modal-focus", s.modalFocus);
|
||||||
|
set("--modal-focus-alpha", s.modalFocusAlpha !== undefined ? `${s.modalFocusAlpha * 100}%` : undefined);
|
||||||
set("--modal-border", s.modalBorder);
|
set("--modal-border", s.modalBorder);
|
||||||
set("--modal-element", s.modalElement);
|
set("--modal-element", s.modalElement);
|
||||||
set("--modal-element-outline", s.modalElementOutline);
|
set("--modal-element-outline", s.modalElementOutline);
|
||||||
set("--modal-radius", s.modalRadius);
|
set("--modal-radius", s.modalRadius);
|
||||||
|
applyGlassSettings();
|
||||||
set("--project-btn-fill", s.projectBtnFill);
|
set("--project-btn-fill", s.projectBtnFill);
|
||||||
set("--project-btn-border", s.projectBtnBorder);
|
set("--project-btn-border", s.projectBtnBorder);
|
||||||
set("--project-btn-hover", s.projectBtnHover);
|
set("--project-btn-hover", s.projectBtnHover);
|
||||||
set("--modal-btn-primary", s.modalBtnPrimary);
|
set("--modal-btn-primary", s.modalBtnPrimary);
|
||||||
|
set("--modal-btn-primary-contrast", s.modalBtnPrimary ? readableTextColor(s.modalBtnPrimary) : undefined);
|
||||||
set("--modal-btn-secondary", s.modalBtnSecondary);
|
set("--modal-btn-secondary", s.modalBtnSecondary);
|
||||||
set("--loader-color", s.loaderColor);
|
set("--loader-color", s.loaderColor);
|
||||||
set("--border", s.border);
|
set("--border", s.border);
|
||||||
|
|
@ -79,6 +192,21 @@
|
||||||
set("--measure-label", s.measureLabel);
|
set("--measure-label", s.measureLabel);
|
||||||
set("--measure-label-text", s.measureLabelText);
|
set("--measure-label-text", s.measureLabelText);
|
||||||
set("--measure-dot", s.measureDot);
|
set("--measure-dot", s.measureDot);
|
||||||
|
set("--section-axis-x", s.sectionAxisX);
|
||||||
|
set("--section-axis-y", s.sectionAxisY);
|
||||||
|
set("--section-axis-z", s.sectionAxisZ);
|
||||||
|
set("--section-gizmo-diameter", s.sectionGizmoDiameter);
|
||||||
|
set("--section-gizmo-thickness", s.sectionGizmoThickness);
|
||||||
|
set("--section-plane-fill", s.sectionPlaneFill);
|
||||||
|
set("--section-plane-alpha", s.sectionPlaneAlpha);
|
||||||
|
set("--section-plane-edge", s.sectionPlaneEdge);
|
||||||
|
set("--section-plane-edge-alpha", s.sectionPlaneEdgeAlpha);
|
||||||
|
set("--comment-target-active", s.commentTargetActiveColor);
|
||||||
|
set("--comment-target-active-alpha", s.commentTargetActiveAlpha);
|
||||||
|
set("--comment-target-active-size", s.commentTargetActiveSize !== undefined ? `${s.commentTargetActiveSize}px` : undefined);
|
||||||
|
set("--comment-target-passive", s.commentTargetPassiveColor);
|
||||||
|
set("--comment-target-passive-alpha", s.commentTargetPassiveAlpha);
|
||||||
|
set("--comment-target-passive-size", s.commentTargetPassiveSize !== undefined ? `${s.commentTargetPassiveSize}px` : undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore corrupted settings
|
// ignore corrupted settings
|
||||||
}
|
}
|
||||||
|
|
@ -87,11 +215,11 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="./dcViewer.css?v=4">
|
<link rel="stylesheet" href="/dcViewer.css?v=37">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<button class="logo-btn" id="homeButton" title="Вернуться к старту"></button>
|
<button class="logo-btn" id="homeButton" type="button" title="Открыть NODE.DC Hub"></button>
|
||||||
<div class="toolbar"></div>
|
<div class="toolbar"></div>
|
||||||
<div class="hidden-controls" style="display:none">
|
<div class="hidden-controls" style="display:none">
|
||||||
<input type="checkbox" id="toggleEdges" checked>
|
<input type="checkbox" id="toggleEdges" checked>
|
||||||
|
|
@ -124,77 +252,295 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="side-panel">
|
<aside class="side-panel" id="viewerInspector">
|
||||||
<div class="panel-section">
|
<div class="inspector-panel-header">
|
||||||
<h3>Инспектор</h3>
|
<h3>Инспектор</h3>
|
||||||
<div id="treeContainer" class="tree"></div>
|
<button
|
||||||
<div class="display-mode-panel" id="displayModeControl">
|
class="inspector-collapse-btn"
|
||||||
<div class="display-mode-title">Режим отображения</div>
|
id="inspectorCollapseButton"
|
||||||
<div class="display-mode-buttons">
|
type="button"
|
||||||
<button type="button" data-display-mode="source" class="active">Исходный</button>
|
title="Свернуть инспектор"
|
||||||
<button type="button" data-display-mode="white">Белый</button>
|
aria-label="Свернуть инспектор"
|
||||||
<button type="button" data-display-mode="contrast">Контраст</button>
|
>
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="inspector-panel-body">
|
||||||
|
<div class="panel-section inspector-main-section">
|
||||||
|
<div id="treeContainer" class="tree"></div>
|
||||||
|
<div class="inspector-controls">
|
||||||
|
<div class="inspector-control-row inspector-control-row--full hidden" id="modelVersionControl">
|
||||||
|
<div class="inspector-version-heading">
|
||||||
|
<div class="inspector-control-label">Версия модели</div>
|
||||||
|
<div class="inspector-version-actions">
|
||||||
|
<button class="inspector-icon-btn" id="modelVersionHistoryButton" type="button" title="История версий" aria-label="История версий">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M3.2 3.4v3.3h3.3"></path>
|
||||||
|
<path d="M3.7 6.7A4.7 4.7 0 1 0 5.1 3"></path>
|
||||||
|
<path d="M8 5.1v3.2l2.1 1.2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="inspector-icon-btn" id="modelVersionUploadButton" type="button" title="Добавить версию" aria-label="Добавить версию">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M8 11.4V3.6"></path>
|
||||||
|
<path d="M5.1 6.4 8 3.5l2.9 2.9"></path>
|
||||||
|
<path d="M3.2 11.8v1.4h9.6v-1.4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inspector-select-host" id="modelVersionSelect"></div>
|
||||||
|
<input id="modelVersionFileInput" class="hidden" type="file" accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl,.step,.stp">
|
||||||
|
</div>
|
||||||
|
<div class="inspector-control-row inspector-control-row--half" id="displayModeControl">
|
||||||
|
<div class="inspector-control-label">Режим отображения</div>
|
||||||
|
<div class="inspector-select-host" id="displayModeSelect"></div>
|
||||||
|
</div>
|
||||||
|
<div class="inspector-control-row inspector-control-row--half hidden" id="customDisplayColorRow">
|
||||||
|
<div class="inspector-control-label">Цвет</div>
|
||||||
|
<div class="inspector-color-host" id="customDisplayColorControl"></div>
|
||||||
|
</div>
|
||||||
|
<div class="inspector-control-row inspector-control-row--half" id="lightingModeControl">
|
||||||
|
<div class="inspector-control-label">Свет</div>
|
||||||
|
<div class="inspector-select-host" id="lightingModeSelect"></div>
|
||||||
|
</div>
|
||||||
|
<div class="inspector-control-row inspector-control-row--full inspector-control-row--slider hidden" id="customDisplaySaturationControl"></div>
|
||||||
|
<div class="inspector-control-row inspector-control-row--full" id="navigationAxisControl">
|
||||||
|
<div class="inspector-control-label">Ось навигации</div>
|
||||||
|
<div class="inspector-select-host" id="navigationAxisSelect"></div>
|
||||||
|
</div>
|
||||||
|
<div class="inspector-control-row inspector-control-row--full inspector-control-row--slider" id="zoomSpeedControl"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="panel-section" id="projectNameSection">
|
||||||
<div class="panel-section" id="projectNameSection">
|
<h3>Название проекта</h3>
|
||||||
<h3>Название проекта</h3>
|
<input type="text" id="projectNameInput" class="project-name-input" placeholder="Проект" autocomplete="off">
|
||||||
<input type="text" id="projectNameInput" class="project-name-input" placeholder="Проект" autocomplete="off">
|
<div class="error-text inline-error" id="projectNameError"></div>
|
||||||
<div class="error-text inline-error" id="projectNameError"></div>
|
|
||||||
</div>
|
|
||||||
<div class="panel-section">
|
|
||||||
<h3>Глобальные координаты</h3>
|
|
||||||
<div class="muted" style="margin-bottom:6px;">Центр выделения (мировые)</div>
|
|
||||||
<div class="transform-grid">
|
|
||||||
<label>X <input type="text" id="globalX"></label>
|
|
||||||
<label>Y <input type="text" id="globalY"></label>
|
|
||||||
<label>Z <input type="text" id="globalZ"></label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="panel-section">
|
||||||
<div class="panel-section">
|
<h3>Глобальные координаты</h3>
|
||||||
<h3>Трансформации</h3>
|
<div class="muted" style="margin-bottom:6px;">Центр выделения (мировые)</div>
|
||||||
<div class="muted" style="margin-bottom:6px;">Перемещение</div>
|
<div class="transform-grid">
|
||||||
<div class="transform-grid">
|
<label>X <input type="text" id="globalX"></label>
|
||||||
<label>X <input type="text" id="posX"></label>
|
<label>Y <input type="text" id="globalY"></label>
|
||||||
<label>Y <input type="text" id="posY"></label>
|
<label>Z <input type="text" id="globalZ"></label>
|
||||||
<label>Z <input type="text" id="posZ"></label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="muted" style="margin:8px 0 6px;">Вращение (deg)</div>
|
<div class="panel-section">
|
||||||
<div class="transform-grid">
|
<h3>Трансформации</h3>
|
||||||
<label>X <input type="text" id="rotX"></label>
|
<div class="muted" style="margin-bottom:6px;">Перемещение</div>
|
||||||
<label>Y <input type="text" id="rotY"></label>
|
<div class="transform-grid">
|
||||||
<label>Z <input type="text" id="rotZ"></label>
|
<label>X <input type="text" id="posX"></label>
|
||||||
|
<label>Y <input type="text" id="posY"></label>
|
||||||
|
<label>Z <input type="text" id="posZ"></label>
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin:8px 0 6px;">Вращение (deg)</div>
|
||||||
|
<div class="transform-grid">
|
||||||
|
<label>X <input type="text" id="rotX"></label>
|
||||||
|
<label>Y <input type="text" id="rotY"></label>
|
||||||
|
<label>Z <input type="text" id="rotZ"></label>
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="margin:8px 0 6px;">Масштаб</div>
|
||||||
|
<div class="transform-grid">
|
||||||
|
<label>X <input type="text" id="scaleX"></label>
|
||||||
|
<label>Y <input type="text" id="scaleY"></label>
|
||||||
|
<label>Z <input type="text" id="scaleZ"></label>
|
||||||
|
</div>
|
||||||
|
<small id="transformHint" class="muted">Выберите объект в инспекторе или сцене, чтобы редактировать.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="muted" style="margin:8px 0 6px;">Масштаб</div>
|
<div class="panel-section">
|
||||||
<div class="transform-grid">
|
<h3>Журнал</h3>
|
||||||
<label>X <input type="text" id="scaleX"></label>
|
<div id="logList" class="log"></div>
|
||||||
<label>Y <input type="text" id="scaleY"></label>
|
</div>
|
||||||
<label>Z <input type="text" id="scaleZ"></label>
|
<div class="panel-section" id="saveProjectSection">
|
||||||
|
<button class="secondary-btn model-settings-btn" id="saveModelSettingsButton" type="button">Сохранить настройки</button>
|
||||||
|
<button class="primary-btn" id="saveProjectButton" type="button">Сохранить проект</button>
|
||||||
|
<button class="secondary-btn danger-btn" id="deleteProjectButton" type="button">Удалить проект</button>
|
||||||
</div>
|
</div>
|
||||||
<small id="transformHint" class="muted">Выберите объект в инспекторе или сцене, чтобы редактировать.</small>
|
|
||||||
</div>
|
|
||||||
<div class="panel-section">
|
|
||||||
<h3>Журнал</h3>
|
|
||||||
<div id="logList" class="log"></div>
|
|
||||||
</div>
|
|
||||||
<div class="panel-section" id="saveProjectSection">
|
|
||||||
<button class="primary-btn" id="saveProjectButton" type="button">Сохранить проект</button>
|
|
||||||
<button class="secondary-btn danger-btn" id="deleteProjectButton" type="button">Удалить проект</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<div class="inspector-restore-tray" id="inspectorRestoreTray" hidden>
|
||||||
|
<button class="inspector-restore-button" id="inspectorRestoreButton" type="button">Инспектор</button>
|
||||||
|
</div>
|
||||||
|
<aside class="comments-panel hidden" id="commentsPanel">
|
||||||
|
<div class="comments-panel-header" id="commentsPanelHeader">
|
||||||
|
<h3>Комментарии</h3>
|
||||||
|
<button
|
||||||
|
class="inspector-collapse-btn"
|
||||||
|
id="commentsCollapseButton"
|
||||||
|
type="button"
|
||||||
|
title="Опустить комментарии"
|
||||||
|
aria-label="Опустить комментарии"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M4 6.25 8 10.25 12 6.25" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="comments-panel-body">
|
||||||
|
<div id="commentsList" class="comments-list"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="comments-restore-tray" id="commentsRestoreTray" hidden>
|
||||||
|
<button class="inspector-restore-button" id="commentsRestoreButton" type="button">Комментарии</button>
|
||||||
|
</div>
|
||||||
|
<div id="commentTargetsLayer" class="comment-targets-layer hidden"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="templatesPanel" class="templates-panel hidden"></div>
|
<div id="templatesPanel" class="templates-panel hidden"></div>
|
||||||
<div id="settingsPanel" class="settings-panel hidden"></div>
|
<div id="settingsPanel" class="settings-panel hidden"></div>
|
||||||
|
<div id="commentContextMenu" class="nodedc-dropdown-surface bim-context-menu hidden" role="menu" aria-label="Действия по модели">
|
||||||
|
<button class="nodedc-dropdown-option bim-context-menu__item" data-comment-menu-action="create" type="button" role="menuitem">
|
||||||
|
<span>Комментарий</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="measureModalBackdrop" class="modal-backdrop hidden"></div>
|
<div id="measureModalBackdrop" class="modal-backdrop hidden"></div>
|
||||||
<div id="measureModal" class="templates-panel measurement-modal hidden"></div>
|
<div id="measureModal" class="templates-panel measurement-modal hidden"></div>
|
||||||
<div id="objectModalBackdrop" class="modal-backdrop hidden"></div>
|
<div id="objectModalBackdrop" class="modal-backdrop hidden"></div>
|
||||||
<div id="objectModal" class="templates-panel measurement-modal hidden"></div>
|
<div id="objectModal" class="templates-panel measurement-modal hidden"></div>
|
||||||
|
<div id="commentModalBackdrop" class="modal-backdrop hidden"></div>
|
||||||
|
<div id="commentModal" class="comment-card-modal ops-comment-detail hidden" role="dialog" aria-modal="true" aria-label="Комментарий к модели">
|
||||||
|
<div class="comment-card-modal__header" id="commentModalHeader">
|
||||||
|
<div class="ops-detail-actions">
|
||||||
|
<button class="ops-detail-icon-btn" type="button" aria-label="Уведомления" title="Уведомления">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"></path>
|
||||||
|
<path d="M10 21h4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="ops-detail-icon-btn" id="commentModalExpandButton" type="button" aria-label="Развернуть" title="Развернуть">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M14 5h5v5M19 5l-7 7M10 19H5v-5M5 19l7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="ops-detail-icon-btn modal-close" id="commentModalClose" type="button" aria-label="Закрыть" title="Закрыть">
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-card-modal__body">
|
||||||
|
<section class="ops-detail-hero">
|
||||||
|
<div class="comment-card-modal__subtitle" id="commentModalSubtitle" hidden></div>
|
||||||
|
<input class="ops-detail-title-input" type="text" id="commentTitleInput" autocomplete="off" placeholder="Новый комментарий">
|
||||||
|
<textarea class="ops-detail-description-input" id="commentBodyInput" rows="2" placeholder="Нажмите, чтобы добавить описание"></textarea>
|
||||||
|
<div class="ops-detail-meta-row">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 12a9 9 0 1 0 3-6.7M3 4v6h6"></path>
|
||||||
|
<path d="M12 7v5l3 2"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="commentModalEditedMeta">Последнее редактирование: только что</span>
|
||||||
|
</div>
|
||||||
|
<div class="ops-detail-pills">
|
||||||
|
<button class="ops-detail-pill" id="commentAddSubitemButton" type="button">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 3 4 7l8 4 8-4-8-4ZM4 12l8 4 8-4M4 17l8 4 8-4"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Добавить подэлемент</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="commentSubitemMenu" class="nodedc-dropdown-surface bim-subitem-menu hidden" role="menu" aria-label="Тип подэлемента">
|
||||||
|
<button class="nodedc-dropdown-option" data-comment-subitem-kind="text" type="button" role="menuitem">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 6h16M4 12h10M4 18h12"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Создать текстовый блок</span>
|
||||||
|
</button>
|
||||||
|
<button class="nodedc-dropdown-option" data-comment-subitem-kind="checker" type="button" role="menuitem">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M9 11l2 2 4-5"></path>
|
||||||
|
<path d="M4 5h16M4 12h3M4 19h16"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Создать чекер</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ops-comment-subitems hidden" id="commentSubitemsSection">
|
||||||
|
<div class="ops-detail-section-title">Подэлементы</div>
|
||||||
|
<div class="ops-comment-subitems__list" id="commentSubitemsList"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="comment-attachments ops-detail-dropzone" id="commentDropzone">
|
||||||
|
<div class="ops-detail-dropzone__icon">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 16V7"></path>
|
||||||
|
<path d="m8 11 4-4 4 4"></path>
|
||||||
|
<path d="M20 16.5a4.5 4.5 0 0 0-8.7-1.6A3.5 3.5 0 1 0 6.5 19H18a2 2 0 0 0 2-2.5Z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ops-detail-dropzone__copy">
|
||||||
|
<strong>Перетащите файлы сюда или нажмите для выбора</strong>
|
||||||
|
<span>Любой формат, несколько файлов за раз, до 50 МБ на файл</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="comment-attach-button" id="commentAttachButton">Выбрать</button>
|
||||||
|
<input type="file" id="commentAttachmentInput" multiple hidden>
|
||||||
|
<div class="comment-attachment-grid" id="commentAttachmentGrid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="comment-thread ops-detail-activity">
|
||||||
|
<div class="ops-detail-section-head">
|
||||||
|
<div class="comment-thread__title">Активность</div>
|
||||||
|
<div class="ops-detail-section-actions">
|
||||||
|
<button class="ops-detail-mini-btn" type="button" aria-label="Сортировка" title="Сортировка">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 6h12M4 12h8M4 18h4M18 8v10M15 15l3 3 3-3"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="ops-detail-mini-btn" type="button" aria-label="Фильтр" title="Фильтр">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 6h16M7 12h10M10 18h4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="ops-activity-composer">
|
||||||
|
<span>Добавить комментарий</span>
|
||||||
|
<textarea id="commentReplyInput" rows="5" placeholder="Добавить комментарий"></textarea>
|
||||||
|
<div class="ops-activity-composer__toolbar">
|
||||||
|
<button type="button" class="ops-detail-mini-btn" id="commentReplyAttachButton" aria-label="Прикрепить файл" title="Прикрепить файл">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m21.4 11.6-8.5 8.5a6 6 0 0 1-8.5-8.5l8.5-8.5a4 4 0 0 1 5.7 5.7l-8.5 8.5a2 2 0 0 1-2.8-2.8l7.8-7.8"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ops-detail-mini-btn" aria-label="Реакция" title="Реакция">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z"></path>
|
||||||
|
<path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ops-activity-send" id="commentApplyInlineButton" aria-label="Отправить комментарий" title="Отправить комментарий">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 19V5"></path>
|
||||||
|
<path d="m5 12 7-7 7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="commentReplyAttachmentInput" multiple hidden>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div class="comment-attachment-grid comment-attachment-grid--reply" id="commentReplyAttachmentGrid"></div>
|
||||||
|
<div class="comment-thread__list" id="commentThreadList"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="comment-card-modal__footer">
|
||||||
|
<div class="error-text comment-card-modal__footer-error" id="commentModalError" hidden></div>
|
||||||
|
<button class="secondary-btn" id="commentCancelButton" type="button">Отменить</button>
|
||||||
|
<button class="primary-btn" id="commentApplyButton" type="button">Применить</button>
|
||||||
|
<button class="primary-btn" id="commentSaveButton" type="button">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="saveProjectBackdrop" class="modal-backdrop hidden"></div>
|
<div id="saveProjectBackdrop" class="modal-backdrop hidden"></div>
|
||||||
<div id="saveProjectModal" class="save-project-modal hidden">
|
<div id="saveProjectModal" class="save-project-modal hidden">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">Сохранить проект</div>
|
<div class="modal-title">Сохранить проект</div>
|
||||||
<button class="modal-close" id="saveProjectClose" aria-label="Закрыть">×</button>
|
<button class="modal-close inspector-collapse-btn" id="saveProjectClose" aria-label="Закрыть">
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label for="saveProjectName" class="input-label">Имя проекта</label>
|
<label for="saveProjectName" class="input-label">Имя проекта</label>
|
||||||
<input type="text" id="saveProjectName" placeholder="Новый проект" autocomplete="off">
|
<input type="text" id="saveProjectName" placeholder="Новый проект" autocomplete="off">
|
||||||
|
|
@ -210,7 +556,11 @@
|
||||||
<div id="deleteProjectModal" class="save-project-modal hidden">
|
<div id="deleteProjectModal" class="save-project-modal hidden">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">Удалить проект</div>
|
<div class="modal-title">Удалить проект</div>
|
||||||
<button class="modal-close" id="deleteProjectClose" aria-label="Закрыть">×</button>
|
<button class="modal-close inspector-collapse-btn" id="deleteProjectClose" aria-label="Закрыть">
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-hint">Вы уверены, что хотите удалить проект?</div>
|
<div class="input-hint">Вы уверены, что хотите удалить проект?</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|
@ -219,18 +569,118 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="error-text" id="deleteProjectError"></div>
|
<div class="error-text" id="deleteProjectError"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="shareModalBackdrop" class="modal-backdrop hidden"></div>
|
||||||
|
<div id="shareModal" class="save-project-modal hidden">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">Ссылка на модель</div>
|
||||||
|
<button class="modal-close inspector-collapse-btn" id="shareModalClose" aria-label="Закрыть">
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="shareLinkInput" readonly autocomplete="off">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="secondary-btn" id="shareModalCancel" type="button">Закрыть</button>
|
||||||
|
<button class="primary-btn" id="shareCopyButton" type="button" disabled>Скопировать</button>
|
||||||
|
</div>
|
||||||
|
<div class="error-text" id="shareError"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="bottomToolbar" class="bottom-toolbar">
|
<div id="bottomToolbar" class="bottom-toolbar">
|
||||||
<button class="tb-btn" data-action="settings" title="Настройки">⚙</button>
|
<button class="tb-btn" data-action="settings" title="Настройки" aria-label="Настройки">
|
||||||
<button class="tb-btn" data-action="templates" title="Примеры">📂</button>
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<button class="tb-btn" data-action="measure" title="Линейка">📏</button>
|
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"></path>
|
||||||
|
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.37a1.7 1.7 0 0 0-1 .56V20a2 2 0 0 1-4 0v-.08a1.7 1.7 0 0 0-1-.56 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.63 15a1.7 1.7 0 0 0-.56-1H4a2 2 0 0 1 0-4h.08a1.7 1.7 0 0 0 .56-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.63a1.7 1.7 0 0 0 1-.56V4a2 2 0 0 1 4 0v.08a1.7 1.7 0 0 0 1 .56 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.37 9c.18.38.38.73.56 1H20a2 2 0 0 1 0 4h-.08c-.18.27-.37.62-.52 1Z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tb-btn" data-action="templates" title="Примеры" aria-label="Примеры">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3.5 6.5h6l2 2h9v8.75a2.25 2.25 0 0 1-2.25 2.25H5.75A2.25 2.25 0 0 1 3.5 17.25V6.5Z"></path>
|
||||||
|
<path d="M3.5 10h17"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tb-btn" data-action="measure" title="Линейка" aria-label="Линейка">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4.5 15.5 15.5 4.5l4 4-11 11-4-4Z"></path>
|
||||||
|
<path d="m8 12 2 2"></path>
|
||||||
|
<path d="m10.5 9.5 1.5 1.5"></path>
|
||||||
|
<path d="m13 7 2 2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tb-btn" data-action="section" title="Разрез" aria-label="Разрез">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4.5 7.25 12 3.5l7.5 3.75L12 11 4.5 7.25Z"></path>
|
||||||
|
<path d="M4.5 16.75 12 20.5l7.5-3.75"></path>
|
||||||
|
<path d="M12 11v9.5"></path>
|
||||||
|
<path d="M6.25 4.5 17.75 19.5"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tb-btn" data-action="comments" title="Комментарии" aria-label="Комментарии">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M6.5 5.5h11a2.5 2.5 0 0 1 2.5 2.5v6.25a2.5 2.5 0 0 1-2.5 2.5H12l-4.3 3.1v-3.1H6.5A2.5 2.5 0 0 1 4 14.25V8a2.5 2.5 0 0 1 2.5-2.5Z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button>
|
<button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button>
|
||||||
<button class="tb-btn" data-action="add" title="Добавить файл">+</button>
|
<button class="tb-btn" data-action="share" title="Поделиться моделью" aria-label="Поделиться моделью">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 14.5V4.75"></path>
|
||||||
|
<path d="m8.25 8.5 3.75-3.75 3.75 3.75"></path>
|
||||||
|
<path d="M7.25 11.25H6.5a2 2 0 0 0-2 2v4.25a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-4.25a2 2 0 0 0-2-2h-.75"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="tb-btn" data-action="add" title="Добавить файл" aria-label="Добавить файл">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 5v14"></path>
|
||||||
|
<path d="M5 12h14"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sectionToolbar" class="section-toolbar hidden" aria-label="Управление разрезом">
|
||||||
|
<canvas id="sectionPlanesOverview" class="section-overview" width="80" height="80"></canvas>
|
||||||
|
<button class="section-tool-btn" data-section-action="camera" type="button" title="По камере" aria-label="По камере">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M1.7 8s2.35-4 6.3-4 6.3 4 6.3 4-2.35 4-6.3 4-6.3-4-6.3-4Z"></path>
|
||||||
|
<circle cx="8" cy="8" r="2.15"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="section-tool-btn section-axis-btn" data-section-action="x" type="button" title="Ось X" aria-label="Ось X">
|
||||||
|
<svg class="section-letter-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<text x="8" y="8" text-anchor="middle" dominant-baseline="central">X</text>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="section-tool-btn section-axis-btn" data-section-action="y" type="button" title="Ось Y" aria-label="Ось Y">
|
||||||
|
<svg class="section-letter-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<text x="8" y="8" text-anchor="middle" dominant-baseline="central">Y</text>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="section-tool-btn section-axis-btn" data-section-action="z" type="button" title="Ось Z" aria-label="Ось Z">
|
||||||
|
<svg class="section-letter-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<text x="8" y="8" text-anchor="middle" dominant-baseline="central">Z</text>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="section-tool-btn" data-section-action="flip" type="button" title="Инвертировать" aria-label="Инвертировать">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M5.5 4.7h4.4a3.05 3.05 0 0 1 0 6.1H5.6"></path>
|
||||||
|
<path d="M6.7 2.9 4.9 4.7l1.8 1.8"></path>
|
||||||
|
<path d="m9.3 13.1 1.8-1.8-1.8-1.8"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="section-tool-btn" data-section-action="clear" type="button" title="Удалить разрез" aria-label="Удалить разрез">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M3.2 4.5h9.6"></path>
|
||||||
|
<path d="M6.2 4.5V3.1h3.6v1.4"></path>
|
||||||
|
<path d="m4.4 4.5.55 8.4h6.1l.55-8.4"></path>
|
||||||
|
<path d="M6.65 6.5v4.3"></path>
|
||||||
|
<path d="M9.35 6.5v4.3"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input id="fileInput" class="hidden" type="file"
|
<input id="fileInput" class="hidden" type="file"
|
||||||
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
|
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
|
||||||
|
|
||||||
<script type="module" src="./dcViewer.js?v=4"></script>
|
<script type="module" src="/dcViewer.js?v=56"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -126510,6 +126510,9 @@ class TransformControl {
|
||||||
scale: [5, 5, 5],
|
scale: [5, 5, 5],
|
||||||
isObject: false
|
isObject: false
|
||||||
});
|
});
|
||||||
|
this._rootNode = rootNode;
|
||||||
|
this._styleScale = 1;
|
||||||
|
this._baseRootScale = 5;
|
||||||
|
|
||||||
const pos = math.vec3();
|
const pos = math.vec3();
|
||||||
this._setPosition = (function() {
|
this._setPosition = (function() {
|
||||||
|
|
@ -126565,6 +126568,45 @@ class TransformControl {
|
||||||
axisHandle: tubeGeometry(handleRadius, arrowLength, 20)
|
axisHandle: tubeGeometry(handleRadius, arrowLength, 20)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateGeometryPositions = (geometry, cfg) => {
|
||||||
|
if (geometry?.positions && cfg?.positions && geometry.positions.length === cfg.positions.length) {
|
||||||
|
geometry.positions = cfg.positions;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._guideThicknessScale = 1;
|
||||||
|
this._setGuideThickness = (scale = 1) => {
|
||||||
|
const nextScale = Math.max(0.5, Math.min(2.5, Number(scale) || 1));
|
||||||
|
if (nextScale === this._guideThicknessScale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._guideThicknessScale = nextScale;
|
||||||
|
const guideTube = tubeRadius * nextScale;
|
||||||
|
updateGeometryPositions(shapes.curve, buildTorusGeometry({
|
||||||
|
radius: arrowLength - 0.2,
|
||||||
|
tube: guideTube,
|
||||||
|
radialSegments: 64,
|
||||||
|
tubeSegments: 14,
|
||||||
|
arc: (Math.PI * 2.0) * 0.25
|
||||||
|
}));
|
||||||
|
updateGeometryPositions(shapes.hoop, buildTorusGeometry({
|
||||||
|
radius: arrowLength - 0.2,
|
||||||
|
tube: guideTube,
|
||||||
|
radialSegments: 64,
|
||||||
|
tubeSegments: 8,
|
||||||
|
arc: Math.PI * 2.0
|
||||||
|
}));
|
||||||
|
updateGeometryPositions(shapes.axis, buildCylinderGeometry({
|
||||||
|
radiusTop: guideTube,
|
||||||
|
radiusBottom: guideTube,
|
||||||
|
radialSegments: 20,
|
||||||
|
heightSegments: 1,
|
||||||
|
height: arrowLength,
|
||||||
|
openEnded: false
|
||||||
|
}));
|
||||||
|
rootNode.scene?.glRedraw?.();
|
||||||
|
};
|
||||||
|
|
||||||
const colorMaterial = (rgb) => new PhongMaterial(rootNode, {
|
const colorMaterial = (rgb) => new PhongMaterial(rootNode, {
|
||||||
diffuse: rgb,
|
diffuse: rgb,
|
||||||
emissive: rgb,
|
emissive: rgb,
|
||||||
|
|
@ -126831,6 +126873,16 @@ class TransformControl {
|
||||||
return {
|
return {
|
||||||
setPositionActive: (a) => { positionActive = a; updatePositionHandle(); },
|
setPositionActive: (a) => { positionActive = a; updatePositionHandle(); },
|
||||||
setRotationActive: (a) => { rotationActive = a; updateRotationHandle(); },
|
setRotationActive: (a) => { rotationActive = a; updateRotationHandle(); },
|
||||||
|
setColor: (color) => {
|
||||||
|
if (!color) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
material.diffuse = color;
|
||||||
|
material.emissive = color;
|
||||||
|
if (hoop.highlightMaterial) {
|
||||||
|
hoop.highlightMaterial.fillColor = color;
|
||||||
|
}
|
||||||
|
},
|
||||||
set visible(v) {
|
set visible(v) {
|
||||||
visible = v;
|
visible = v;
|
||||||
updatePositionHandle();
|
updatePositionHandle();
|
||||||
|
|
@ -126868,13 +126920,16 @@ class TransformControl {
|
||||||
{ // Keep gizmo screen size constant
|
{ // Keep gizmo screen size constant
|
||||||
let lastDist = -1;
|
let lastDist = -1;
|
||||||
const setRootNodeScale = size => {
|
const setRootNodeScale = size => {
|
||||||
if (rootNode.scale[0] !== size) {
|
this._baseRootScale = size;
|
||||||
rootNode.scale = [size, size, size];
|
const styledSize = size * (this._styleScale || 1);
|
||||||
|
if (rootNode.scale[0] !== styledSize) {
|
||||||
|
rootNode.scale = [styledSize, styledSize, styledSize];
|
||||||
if (this._handlers && this._handlers.onScreenScale) {
|
if (this._handlers && this._handlers.onScreenScale) {
|
||||||
this._handlers.onScreenScale(rootNode.scale);
|
this._handlers.onScreenScale(rootNode.scale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
this._applyRootScale = () => setRootNodeScale(this._baseRootScale || rootNode.scale[0] || 5);
|
||||||
const onSceneTick = scene.on("tick", () => {
|
const onSceneTick = scene.on("tick", () => {
|
||||||
const camera = scene.camera;
|
const camera = scene.camera;
|
||||||
const dist = Math.abs(math.distVec3(camera.eye, pos));
|
const dist = Math.abs(math.distVec3(camera.eye, pos));
|
||||||
|
|
@ -127030,6 +127085,22 @@ class TransformControl {
|
||||||
this.__destroy();
|
this.__destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setStyle(style = {}) {
|
||||||
|
if (style.gizmoDiameter !== undefined) {
|
||||||
|
this._styleScale = style.gizmoDiameter;
|
||||||
|
this._applyRootScale?.();
|
||||||
|
}
|
||||||
|
if (style.gizmoThickness !== undefined) {
|
||||||
|
this._setGuideThickness?.(style.gizmoThickness);
|
||||||
|
}
|
||||||
|
if (style.axes) {
|
||||||
|
this._displayMeshes.x?.setColor?.(style.axes.x);
|
||||||
|
this._displayMeshes.y?.setColor?.(style.axes.y);
|
||||||
|
this._displayMeshes.z?.setColor?.(style.axes.z);
|
||||||
|
}
|
||||||
|
this._rootNode?.scene?.glRedraw?.();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called to assign this Control to Handlers.
|
* Called to assign this Control to Handlers.
|
||||||
* Call with a null or undefined value to disconnect the Control from whatever Handlers it was assigned to.
|
* Call with a null or undefined value to disconnect the Control from whatever Handlers it was assigned to.
|
||||||
|
|
@ -127703,7 +127774,7 @@ class SectionPlanesPlugin extends Plugin {
|
||||||
const planeRoot = (function() {
|
const planeRoot = (function() {
|
||||||
const rootNode = new Node$1(scene, { isObject: false });
|
const rootNode = new Node$1(scene, { isObject: false });
|
||||||
|
|
||||||
rootNode.addChild(new Mesh(rootNode, { // plane
|
const planeMesh = rootNode.addChild(new Mesh(rootNode, { // plane
|
||||||
geometry: new ReadableGeometry(rootNode, {
|
geometry: new ReadableGeometry(rootNode, {
|
||||||
primitive: "triangles",
|
primitive: "triangles",
|
||||||
positions: [
|
positions: [
|
||||||
|
|
@ -127737,7 +127808,7 @@ class SectionPlanesPlugin extends Plugin {
|
||||||
isObject: false
|
isObject: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
rootNode.addChild(new Mesh(rootNode, { // Visible frame
|
const frameMesh = rootNode.addChild(new Mesh(rootNode, { // Visible frame
|
||||||
geometry: new ReadableGeometry(rootNode, buildTorusGeometry({
|
geometry: new ReadableGeometry(rootNode, buildTorusGeometry({
|
||||||
center: [0, 0, 0],
|
center: [0, 0, 0],
|
||||||
radius: 1.7,
|
radius: 1.7,
|
||||||
|
|
@ -127769,6 +127840,38 @@ class SectionPlanesPlugin extends Plugin {
|
||||||
isObject: false
|
isObject: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const setPhongMaterial = (material, color, alpha) => {
|
||||||
|
if (!material) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (color) {
|
||||||
|
material.diffuse = color;
|
||||||
|
material.emissive = color;
|
||||||
|
}
|
||||||
|
if (alpha !== undefined) {
|
||||||
|
material.alpha = alpha;
|
||||||
|
material.alphaMode = alpha < 1 ? "blend" : "opaque";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setEmphasisMaterial = (material, fillColor, fillAlpha, edgeColor, edgeAlpha) => {
|
||||||
|
if (!material) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fillColor) {
|
||||||
|
material.fillColor = fillColor;
|
||||||
|
}
|
||||||
|
if (fillAlpha !== undefined) {
|
||||||
|
material.fillAlpha = fillAlpha;
|
||||||
|
}
|
||||||
|
if (edgeColor) {
|
||||||
|
material.edgeColor = edgeColor;
|
||||||
|
}
|
||||||
|
if (edgeAlpha !== undefined) {
|
||||||
|
material.edgeAlpha = edgeAlpha;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setPosition: (function() {
|
setPosition: (function() {
|
||||||
const origin = math.vec3();
|
const origin = math.vec3();
|
||||||
|
|
@ -127781,7 +127884,26 @@ class SectionPlanesPlugin extends Plugin {
|
||||||
})(),
|
})(),
|
||||||
setQuaternion: q => { rootNode.quaternion = q; },
|
setQuaternion: q => { rootNode.quaternion = q; },
|
||||||
setScale: s => { rootNode.scale = s; },
|
setScale: s => { rootNode.scale = s; },
|
||||||
setVisible: v => { rootNode.visible = v; }
|
setVisible: v => { rootNode.visible = v; },
|
||||||
|
setStyle: (style = {}) => {
|
||||||
|
setPhongMaterial(planeMesh.material, style.planeFillColor, style.planeFillAlpha);
|
||||||
|
setEmphasisMaterial(
|
||||||
|
planeMesh.ghostMaterial,
|
||||||
|
style.planeFillColor,
|
||||||
|
style.planeFillAlpha,
|
||||||
|
style.planeEdgeColor,
|
||||||
|
style.planeEdgeAlpha
|
||||||
|
);
|
||||||
|
planeMesh.opacity = style.planeFillAlpha;
|
||||||
|
setPhongMaterial(frameMesh.material, style.planeEdgeColor, style.planeEdgeAlpha);
|
||||||
|
setEmphasisMaterial(
|
||||||
|
frameMesh.highlightMaterial,
|
||||||
|
style.planeEdgeColor,
|
||||||
|
style.planeEdgeAlpha,
|
||||||
|
style.planeEdgeColor,
|
||||||
|
style.planeEdgeAlpha
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
let unbindSectionPlane = () => { };
|
let unbindSectionPlane = () => { };
|
||||||
|
|
@ -127803,6 +127925,10 @@ class SectionPlanesPlugin extends Plugin {
|
||||||
},
|
},
|
||||||
setCulled: c => { culled = c; updateVisible(); },
|
setCulled: c => { culled = c; updateVisible(); },
|
||||||
setVisible: v => { visible = v; updateVisible(); },
|
setVisible: v => { visible = v; updateVisible(); },
|
||||||
|
setStyle: style => {
|
||||||
|
planeRoot.setStyle?.(style);
|
||||||
|
ctrl.setStyle?.(style);
|
||||||
|
},
|
||||||
_setSectionPlane: sectionPlane => {
|
_setSectionPlane: sectionPlane => {
|
||||||
unbindSectionPlane();
|
unbindSectionPlane();
|
||||||
if (sectionPlane) {
|
if (sectionPlane) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
const isHexColor = (value) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || "").trim());
|
||||||
|
|
||||||
|
export function createColorControl({
|
||||||
|
value = "#ffffff",
|
||||||
|
previewValue = value,
|
||||||
|
ariaLabel = "Выбрать цвет",
|
||||||
|
valueLabel = "Значение цвета",
|
||||||
|
onChange,
|
||||||
|
}) {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "env-color-field";
|
||||||
|
|
||||||
|
const swatch = document.createElement("label");
|
||||||
|
swatch.className = "env-color-field__swatch";
|
||||||
|
|
||||||
|
const preview = document.createElement("span");
|
||||||
|
preview.className = "env-color-field__preview";
|
||||||
|
|
||||||
|
const picker = document.createElement("input");
|
||||||
|
picker.type = "color";
|
||||||
|
picker.setAttribute("aria-label", ariaLabel);
|
||||||
|
|
||||||
|
const valueWrap = document.createElement("div");
|
||||||
|
valueWrap.className = "env-color-field__value";
|
||||||
|
|
||||||
|
const valueInput = document.createElement("input");
|
||||||
|
valueInput.type = "text";
|
||||||
|
valueInput.setAttribute("aria-label", valueLabel);
|
||||||
|
valueWrap.appendChild(valueInput);
|
||||||
|
|
||||||
|
const update = (nextValue, { emit = false } = {}) => {
|
||||||
|
const safeValue = String(nextValue ?? "");
|
||||||
|
const safePreview = isHexColor(safeValue)
|
||||||
|
? safeValue
|
||||||
|
: (isHexColor(previewValue) ? previewValue : "#000000");
|
||||||
|
|
||||||
|
valueInput.value = safeValue;
|
||||||
|
picker.value = isHexColor(safeValue) ? safeValue : safePreview;
|
||||||
|
preview.style.background = safePreview;
|
||||||
|
|
||||||
|
if (emit && typeof onChange === "function") {
|
||||||
|
onChange(safeValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
picker.addEventListener("input", () => update(picker.value, { emit: true }));
|
||||||
|
valueInput.addEventListener("change", () => update(valueInput.value, { emit: true }));
|
||||||
|
|
||||||
|
swatch.appendChild(preview);
|
||||||
|
swatch.appendChild(picker);
|
||||||
|
root.appendChild(swatch);
|
||||||
|
root.appendChild(valueWrap);
|
||||||
|
|
||||||
|
update(value);
|
||||||
|
|
||||||
|
root.setValue = (nextValue) => update(nextValue);
|
||||||
|
root.getValue = () => valueInput.value;
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
export function createSliderControl({ label = "", value = 0, min = 0, max = 100, step = 1, onChange }) {
|
export function createSliderControl({
|
||||||
|
label = "",
|
||||||
|
value = 0,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
className = "",
|
||||||
|
showValue = false,
|
||||||
|
formatValue = (val) => String(val),
|
||||||
|
onChange
|
||||||
|
}) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "slider-row";
|
row.className = ["slider-row", className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
const lbl = document.createElement("label");
|
const lbl = document.createElement("label");
|
||||||
lbl.textContent = label;
|
lbl.textContent = label;
|
||||||
|
|
@ -14,9 +24,34 @@ export function createSliderControl({ label = "", value = 0, min = 0, max = 100,
|
||||||
input.step = step;
|
input.step = step;
|
||||||
input.value = value;
|
input.value = value;
|
||||||
|
|
||||||
|
const valueEl = document.createElement("div");
|
||||||
|
valueEl.className = "slider-value";
|
||||||
|
|
||||||
|
const fillText = document.createElement("span");
|
||||||
|
fillText.className = "slider-fill-text";
|
||||||
|
fillText.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
const fillLabel = document.createElement("span");
|
||||||
|
fillLabel.textContent = label;
|
||||||
|
fillLabel.className = "slider-label";
|
||||||
|
|
||||||
|
const fillValue = document.createElement("span");
|
||||||
|
fillValue.className = "slider-value";
|
||||||
|
fillText.appendChild(fillLabel);
|
||||||
|
fillText.appendChild(fillValue);
|
||||||
|
|
||||||
const updateFill = () => {
|
const updateFill = () => {
|
||||||
const percent = ((input.value - input.min) / (input.max - input.min)) * 100;
|
const range = Number(input.max) - Number(input.min);
|
||||||
|
const percent = range
|
||||||
|
? Math.max(0, Math.min(100, ((Number(input.value) - Number(input.min)) / range) * 100))
|
||||||
|
: 0;
|
||||||
|
row.style.setProperty("--slider-percent", `${percent}%`);
|
||||||
input.style.setProperty("--slider-percent", `${percent}%`);
|
input.style.setProperty("--slider-percent", `${percent}%`);
|
||||||
|
if (showValue) {
|
||||||
|
const formattedValue = formatValue(Number(input.value));
|
||||||
|
valueEl.textContent = formattedValue;
|
||||||
|
fillValue.textContent = formattedValue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
|
|
@ -29,5 +64,14 @@ export function createSliderControl({ label = "", value = 0, min = 0, max = 100,
|
||||||
|
|
||||||
row.appendChild(lbl);
|
row.appendChild(lbl);
|
||||||
row.appendChild(input);
|
row.appendChild(input);
|
||||||
|
if (showValue) {
|
||||||
|
row.appendChild(valueEl);
|
||||||
|
row.appendChild(fillText);
|
||||||
|
}
|
||||||
|
row.setValue = (nextValue) => {
|
||||||
|
input.value = nextValue;
|
||||||
|
updateFill();
|
||||||
|
};
|
||||||
|
row.getValue = () => Number(input.value);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
const closeEventName = "nodedc-select-open";
|
||||||
|
|
||||||
|
const makeSelectId = () => `ndc-select-${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
export function createGlassSelect({
|
||||||
|
host,
|
||||||
|
value,
|
||||||
|
options = [],
|
||||||
|
ariaLabel = "Выбрать",
|
||||||
|
disabled = false,
|
||||||
|
onChange = null,
|
||||||
|
}) {
|
||||||
|
if (!host) return null;
|
||||||
|
|
||||||
|
const selectId = makeSelectId();
|
||||||
|
let currentValue = value;
|
||||||
|
let currentOptions = [...options];
|
||||||
|
let open = false;
|
||||||
|
let menu = null;
|
||||||
|
let menuRect = { top: 0, left: 0, width: 0 };
|
||||||
|
|
||||||
|
host.innerHTML = "";
|
||||||
|
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "nodedc-select";
|
||||||
|
|
||||||
|
const control = document.createElement("div");
|
||||||
|
control.className = "nodedc-select__control";
|
||||||
|
|
||||||
|
const valueEl = document.createElement("div");
|
||||||
|
valueEl.className = "nodedc-select__value";
|
||||||
|
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "nodedc-select__toggle";
|
||||||
|
toggle.setAttribute("aria-label", ariaLabel);
|
||||||
|
toggle.setAttribute("aria-haspopup", "listbox");
|
||||||
|
toggle.disabled = !!disabled;
|
||||||
|
|
||||||
|
const chevron = document.createElement("span");
|
||||||
|
chevron.className = "nodedc-select__chevron";
|
||||||
|
chevron.setAttribute("aria-hidden", "true");
|
||||||
|
toggle.appendChild(chevron);
|
||||||
|
|
||||||
|
control.appendChild(valueEl);
|
||||||
|
control.appendChild(toggle);
|
||||||
|
root.appendChild(control);
|
||||||
|
host.appendChild(root);
|
||||||
|
|
||||||
|
const selectedOption = () => (
|
||||||
|
currentOptions.find((option) => option.value === currentValue) ||
|
||||||
|
currentOptions[0] ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateValue = () => {
|
||||||
|
const selected = selectedOption();
|
||||||
|
valueEl.textContent = selected?.label || "";
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMenuRect = () => {
|
||||||
|
const rect = root.getBoundingClientRect();
|
||||||
|
const toggleWidth = 42;
|
||||||
|
const gap = 8;
|
||||||
|
const menuMaxHeight = 232;
|
||||||
|
const bottomTop = rect.bottom + 6;
|
||||||
|
const top = bottomTop + menuMaxHeight > window.innerHeight - 12
|
||||||
|
? Math.max(12, rect.top - menuMaxHeight - 6)
|
||||||
|
: bottomTop;
|
||||||
|
|
||||||
|
menuRect = {
|
||||||
|
top,
|
||||||
|
left: rect.left,
|
||||||
|
width: Math.max(180, rect.width - toggleWidth - gap),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (menu) {
|
||||||
|
menu.style.top = `${menuRect.top}px`;
|
||||||
|
menu.style.left = `${menuRect.left}px`;
|
||||||
|
menu.style.width = `${menuRect.width}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMenuOptions = () => {
|
||||||
|
if (!menu) return;
|
||||||
|
menu.innerHTML = "";
|
||||||
|
currentOptions.forEach((option) => {
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.type = "button";
|
||||||
|
item.className = "nodedc-dropdown-option nodedc-select__option";
|
||||||
|
item.dataset.value = option.value;
|
||||||
|
if (option.value === currentValue) {
|
||||||
|
item.dataset.selected = "true";
|
||||||
|
}
|
||||||
|
item.setAttribute("role", "option");
|
||||||
|
item.setAttribute("aria-selected", option.value === currentValue ? "true" : "false");
|
||||||
|
item.textContent = option.label;
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
setValue(option.value, { emit: true });
|
||||||
|
closeMenu();
|
||||||
|
});
|
||||||
|
menu.appendChild(item);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
if (toggle.disabled || open) return;
|
||||||
|
open = true;
|
||||||
|
updateValue();
|
||||||
|
updateMenuRect();
|
||||||
|
window.dispatchEvent(new CustomEvent(closeEventName, { detail: { id: selectId } }));
|
||||||
|
|
||||||
|
menu = document.createElement("div");
|
||||||
|
menu.className = "nodedc-dropdown-surface nodedc-select__menu";
|
||||||
|
menu.setAttribute("role", "listbox");
|
||||||
|
menu.style.position = "fixed";
|
||||||
|
menu.style.top = `${menuRect.top}px`;
|
||||||
|
menu.style.left = `${menuRect.left}px`;
|
||||||
|
menu.style.width = `${menuRect.width}px`;
|
||||||
|
renderMenuOptions();
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleDocumentPointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener(closeEventName, handleOtherSelectOpen);
|
||||||
|
window.addEventListener("resize", updateMenuRect);
|
||||||
|
window.addEventListener("scroll", updateMenuRect, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
if (!open) return;
|
||||||
|
open = false;
|
||||||
|
updateValue();
|
||||||
|
if (menu) {
|
||||||
|
menu.remove();
|
||||||
|
menu = null;
|
||||||
|
}
|
||||||
|
document.removeEventListener("mousedown", handleDocumentPointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener(closeEventName, handleOtherSelectOpen);
|
||||||
|
window.removeEventListener("resize", updateMenuRect);
|
||||||
|
window.removeEventListener("scroll", updateMenuRect, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentPointerDown(event) {
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
target instanceof Node &&
|
||||||
|
!root.contains(target) &&
|
||||||
|
(!menu || !menu.contains(target))
|
||||||
|
) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event) {
|
||||||
|
if (event.key === "Escape") closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOtherSelectOpen(event) {
|
||||||
|
if (event?.detail?.id !== selectId) closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(nextValue, { emit = false } = {}) {
|
||||||
|
const nextOption = currentOptions.find((option) => option.value === nextValue);
|
||||||
|
if (!nextOption) return;
|
||||||
|
const changed = currentValue !== nextValue;
|
||||||
|
currentValue = nextValue;
|
||||||
|
updateValue();
|
||||||
|
renderMenuOptions();
|
||||||
|
if (emit && changed && typeof onChange === "function") {
|
||||||
|
onChange(nextValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setOptions = (nextOptions = [], nextValue = currentValue) => {
|
||||||
|
currentOptions = [...nextOptions];
|
||||||
|
currentValue = currentOptions.some((option) => option.value === nextValue)
|
||||||
|
? nextValue
|
||||||
|
: currentOptions[0]?.value;
|
||||||
|
updateValue();
|
||||||
|
renderMenuOptions();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDisabled = (nextDisabled) => {
|
||||||
|
toggle.disabled = !!nextDisabled;
|
||||||
|
if (toggle.disabled) closeMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
closeMenu();
|
||||||
|
root.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
if (open) {
|
||||||
|
closeMenu();
|
||||||
|
} else {
|
||||||
|
openMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
valueEl.addEventListener("click", openMenu);
|
||||||
|
|
||||||
|
updateValue();
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
setValue,
|
||||||
|
setOptions,
|
||||||
|
setDisabled,
|
||||||
|
close: closeMenu,
|
||||||
|
destroy,
|
||||||
|
getValue: () => currentValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { createSliderControl } from "./controls/slider.js";
|
import { createSliderControl } from "./controls/slider.js";
|
||||||
|
import { createColorControl } from "./controls/color.js";
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
themeVersion: "v3-yellow",
|
themeVersion: "v7-toolbar-hub-icon",
|
||||||
bgOuter: "#0f0f0f",
|
bgOuter: "#0f0f0f",
|
||||||
canvasTop: "#1c1c1c",
|
canvasTop: "#1c1c1c",
|
||||||
canvasBottom: "#1c1c1c",
|
canvasBottom: "#1c1c1c",
|
||||||
|
|
@ -9,53 +10,199 @@ const defaultSettings = {
|
||||||
loadingBottom: "#1c1c1c",
|
loadingBottom: "#1c1c1c",
|
||||||
accent: "#ffea00",
|
accent: "#ffea00",
|
||||||
accent2: "#ffea00",
|
accent2: "#ffea00",
|
||||||
templateFill: "#1c1c1c",
|
templateFill: "#292929",
|
||||||
templateOutline: "#233044",
|
templateOutline: "#292929",
|
||||||
templateOutlineHover: "rgba(255,234,0,0.7)",
|
templateOutlineHover: "#1c1c1c",
|
||||||
projectBtnFill: "#1c1c1c",
|
projectBtnFill: "#292929",
|
||||||
projectBtnBorder: "#233044",
|
projectBtnBorder: "#292929",
|
||||||
projectBtnHover: "rgba(255,234,0,0.7)",
|
projectBtnHover: "#1c1c1c",
|
||||||
modalBtnPrimary: "#ffea00",
|
modalBtnPrimary: "#c4ff67",
|
||||||
modalBtnSecondary: "#292929",
|
modalBtnSecondary: "#292929",
|
||||||
loaderColor: "#ffea00",
|
loaderColor: "#c4ff67",
|
||||||
templateGlow: 80,
|
templateGlow: 80,
|
||||||
toolbarBg: "#1c1c1c",
|
toolbarBg: "#292929",
|
||||||
toolbarBgActive: "#ffea00",
|
toolbarBgActive: "#c4ff67",
|
||||||
toolbarBorder: "#233044",
|
toolbarBorder: "#292929",
|
||||||
toolbarOutline: "#ffea00",
|
toolbarOutline: "#292929",
|
||||||
|
toolbarMinSize: 25,
|
||||||
|
toolbarMaxSize: 87,
|
||||||
|
toolbarLensCount: 5,
|
||||||
|
toolbarAutoHide: false,
|
||||||
modalBg: "#1c1c1c",
|
modalBg: "#1c1c1c",
|
||||||
modalBgAlpha: 1,
|
modalBgAlpha: 1,
|
||||||
modalFocus: "#292929",
|
modalFocus: "#292929",
|
||||||
modalFocusAlpha: 1,
|
modalFocusAlpha: 0.35,
|
||||||
modalBorder: "#292929",
|
modalBorder: "#1c1c1c",
|
||||||
modalElement: "#292929",
|
modalElement: "#262626",
|
||||||
modalElementOutline: "#2b2b2b",
|
modalElementOutline: "#262626",
|
||||||
modalRadius: "14px",
|
modalRadius: "14px",
|
||||||
border: "#233044",
|
border: "#233044",
|
||||||
text: "#e6edf3",
|
text: "#e6edf3",
|
||||||
panel: "#1c1c1c",
|
panel: "#1c1c1c",
|
||||||
panel2: "#292929",
|
panel2: "#292929",
|
||||||
highlight: "#ffea00",
|
highlight: "#c4ff67",
|
||||||
cubeText: "#242424",
|
cubeText: "#242424",
|
||||||
cubeEdges: "#e0e7ff",
|
cubeEdges: "#e0e7ff",
|
||||||
plus: "#ffea00",
|
plus: "#c4ff67",
|
||||||
navCubeBase: "#ffea00",
|
navCubeBase: "#ffffff",
|
||||||
navCubeHover: "#ffffff",
|
navCubeHover: "#ffffff",
|
||||||
navCubeText: "#242424",
|
navCubeText: "#242424",
|
||||||
navCubeEdge: "#e0e7ff",
|
navCubeEdge: "#e0e7ff",
|
||||||
navCubeEdgeAlpha: 0.6,
|
navCubeEdgeAlpha: 0.6,
|
||||||
measureLine: "#ffea00",
|
measureLine: "#c4ff67",
|
||||||
measureLabel: "#ffea00",
|
measureLabel: "#c4ff67",
|
||||||
measureLabelText: "#292929",
|
measureLabelText: "#292929",
|
||||||
measureDot: "#ffea00",
|
measureDot: "#c4ff67",
|
||||||
|
sectionAxisX: "#ff2b2b",
|
||||||
|
sectionAxisY: "#35ff4f",
|
||||||
|
sectionAxisZ: "#285bff",
|
||||||
|
sectionGizmoDiameter: 1,
|
||||||
|
sectionGizmoThickness: 1,
|
||||||
|
sectionPlaneFill: "#606060",
|
||||||
|
sectionPlaneAlpha: 0.32,
|
||||||
|
sectionPlaneEdge: "#0d0d0d",
|
||||||
|
sectionPlaneEdgeAlpha: 0.8,
|
||||||
|
commentTargetActiveColor: "#c4ff67",
|
||||||
|
commentTargetActiveAlpha: 1,
|
||||||
|
commentTargetActiveSize: 30,
|
||||||
|
commentTargetPassiveColor: "#f5f5fa",
|
||||||
|
commentTargetPassiveAlpha: 0.58,
|
||||||
|
commentTargetPassiveSize: 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = "bimdc-settings-v3";
|
const STORAGE_KEY = "bimdc-settings-v3";
|
||||||
const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"];
|
const LEGACY_KEYS = ["bimdc-settings", "bimdc-settings-v2"];
|
||||||
|
const isSharedViewerPath = () => (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
/^\/share\/[^/]+\/?$/.test(window.location.pathname || "")
|
||||||
|
);
|
||||||
|
const isReadonlySettingsContext = () => {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const mode = document.body?.dataset?.viewerMode;
|
||||||
|
if (mode) return mode === "guest";
|
||||||
|
}
|
||||||
|
return isSharedViewerPath();
|
||||||
|
};
|
||||||
const projectSettings = typeof window !== "undefined" && window.__BIMDC_THEME__
|
const projectSettings = typeof window !== "undefined" && window.__BIMDC_THEME__
|
||||||
? { ...defaultSettings, ...window.__BIMDC_THEME__ }
|
? { ...defaultSettings, ...window.__BIMDC_THEME__ }
|
||||||
: { ...defaultSettings };
|
: { ...defaultSettings };
|
||||||
|
|
||||||
|
const parseColor = (value) => {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const color = value.trim();
|
||||||
|
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||||
|
if (hex) {
|
||||||
|
const raw = hex[1].length === 3
|
||||||
|
? hex[1].split("").map((c) => c + c).join("")
|
||||||
|
: hex[1];
|
||||||
|
return [
|
||||||
|
parseInt(raw.slice(0, 2), 16),
|
||||||
|
parseInt(raw.slice(2, 4), 16),
|
||||||
|
parseInt(raw.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const rgb = color.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
|
||||||
|
if (!rgb) return null;
|
||||||
|
return rgb.slice(1, 4).map((channel) => Math.max(0, Math.min(255, Number(channel))));
|
||||||
|
};
|
||||||
|
|
||||||
|
const readableTextColor = (value) => {
|
||||||
|
const rgb = parseColor(value);
|
||||||
|
if (!rgb || rgb.some((channel) => !Number.isFinite(channel))) return "#15170f";
|
||||||
|
const toLinear = (channel) => {
|
||||||
|
const value = channel / 255;
|
||||||
|
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
const lum = 0.2126 * toLinear(rgb[0]) + 0.7152 * toLinear(rgb[1]) + 0.0722 * toLinear(rgb[2]);
|
||||||
|
return (lum + 0.05) / 0.05 >= 1.05 / (lum + 0.05) ? "#15170f" : "#f5f5fa";
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||||
|
|
||||||
|
const colorParts = (value, fallback) => {
|
||||||
|
const parsed = parseColor(value);
|
||||||
|
if (parsed && parsed.every((channel) => Number.isFinite(channel))) return parsed;
|
||||||
|
const parsedFallback = parseColor(fallback);
|
||||||
|
if (parsedFallback && parsedFallback.every((channel) => Number.isFinite(channel))) return parsedFallback;
|
||||||
|
return [28, 28, 28];
|
||||||
|
};
|
||||||
|
|
||||||
|
const alphaValue = (value, fallback) => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? clamp(numeric, 0, 1) : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rgba = (parts, alpha) => {
|
||||||
|
const [r, g, b] = parts.map((channel) => Math.round(clamp(channel, 0, 255)));
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1).toFixed(3)})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyGlassSettings = (root, s) => {
|
||||||
|
const panelColor = colorParts(s.modalBg, defaultSettings.modalBg);
|
||||||
|
const focusColor = colorParts(s.modalFocus, defaultSettings.modalFocus);
|
||||||
|
const controlColor = colorParts(s.modalElement, s.modalFocus || defaultSettings.modalElement);
|
||||||
|
const outlineColor = colorParts(s.modalBorder, s.modalElementOutline || defaultSettings.modalBorder);
|
||||||
|
const controlOutlineColor = colorParts(s.modalElementOutline, s.modalBorder || defaultSettings.modalElementOutline);
|
||||||
|
const modalBgAlpha = alphaValue(s.modalBgAlpha, defaultSettings.modalBgAlpha);
|
||||||
|
const modalFocusAlpha = alphaValue(s.modalFocusAlpha, defaultSettings.modalFocusAlpha);
|
||||||
|
const panelAlpha = clamp(0.24 + modalBgAlpha * 0.48, 0.32, 0.88);
|
||||||
|
const panelStrongAlpha = clamp(panelAlpha + 0.1, 0.42, 0.92);
|
||||||
|
const panelSoftAlpha = clamp(panelAlpha - 0.16, 0.2, 0.72);
|
||||||
|
const sectionAlpha = clamp(modalFocusAlpha * 0.6, 0.04, 0.42);
|
||||||
|
const controlAlpha = clamp(0.12 + modalFocusAlpha * 0.9, 0.16, 0.66);
|
||||||
|
const controlHoverAlpha = clamp(controlAlpha + 0.08, 0.22, 0.74);
|
||||||
|
const outlineAlpha = clamp(0.055 + modalFocusAlpha * 0.18, 0.065, 0.2);
|
||||||
|
const controlOutlineAlpha = clamp(0.045 + modalFocusAlpha * 0.12, 0.055, 0.16);
|
||||||
|
|
||||||
|
const panelBg = rgba(panelColor, panelAlpha);
|
||||||
|
const panelBgStrong = rgba(panelColor, panelStrongAlpha);
|
||||||
|
const panelBgSoft = rgba(panelColor, panelSoftAlpha);
|
||||||
|
const sectionBg = rgba(focusColor, sectionAlpha);
|
||||||
|
const outline = rgba(outlineColor, outlineAlpha);
|
||||||
|
const outlineBright = rgba(outlineColor, clamp(outlineAlpha + 0.05, 0.08, 0.26));
|
||||||
|
const outlineMid = rgba(outlineColor, clamp(outlineAlpha + 0.015, 0.07, 0.22));
|
||||||
|
const outlineDim = rgba(outlineColor, clamp(outlineAlpha * 0.62, 0.04, 0.16));
|
||||||
|
const controlBg = rgba(controlColor, controlAlpha);
|
||||||
|
const controlHover = rgba(focusColor, controlHoverAlpha);
|
||||||
|
const controlShadow = rgba(controlOutlineColor, controlOutlineAlpha);
|
||||||
|
|
||||||
|
root.style.setProperty("--nodedc-glass-panel-bg", panelBg);
|
||||||
|
root.style.setProperty("--nodedc-glass-panel-bg-strong", panelBgStrong);
|
||||||
|
root.style.setProperty("--nodedc-glass-panel-bg-soft", panelBgSoft);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--nodedc-glass-panel-surface",
|
||||||
|
`linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.014) 100%), ${panelBg}`
|
||||||
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--nodedc-glass-panel-surface-strong",
|
||||||
|
`linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.016) 100%), ${panelBgStrong}`
|
||||||
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--nodedc-glass-section-bg",
|
||||||
|
`linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.01) 100%), ${sectionBg}`
|
||||||
|
);
|
||||||
|
root.style.setProperty("--nodedc-glass-outline", outline);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--nodedc-glass-outline-gradient",
|
||||||
|
`linear-gradient(180deg, ${outlineBright} 0%, ${outlineMid} 56%, ${outlineDim} 100%)`
|
||||||
|
);
|
||||||
|
root.style.setProperty("--nodedc-glass-panel-shadow", `inset 0 1px 0 ${outlineDim}, 0 22px 72px rgba(0, 0, 0, 0.42)`);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--nodedc-glass-control-bg",
|
||||||
|
`linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.018) 100%), ${controlBg}`
|
||||||
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--nodedc-glass-control-hover",
|
||||||
|
`linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.026) 100%), ${controlHover}`
|
||||||
|
);
|
||||||
|
root.style.setProperty("--nodedc-glass-control-shadow", `inset 0 1px 0 ${controlShadow}`);
|
||||||
|
root.style.setProperty("--nodedc-header-dropdown-bg", panelBgStrong);
|
||||||
|
root.style.setProperty("--nodedc-header-dropdown-item-bg", controlBg);
|
||||||
|
root.style.setProperty("--nodedc-header-dropdown-item-hover-bg", controlHover);
|
||||||
|
root.style.setProperty("--nodedc-header-dropdown-item-active-bg", s.toolbarBgActive);
|
||||||
|
root.style.setProperty("--nodedc-header-dropdown-item-active-color", readableTextColor(s.toolbarBgActive));
|
||||||
|
};
|
||||||
|
|
||||||
const applySettings = (s) => {
|
const applySettings = (s) => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty("--bg-outer", s.bgOuter);
|
root.style.setProperty("--bg-outer", s.bgOuter);
|
||||||
|
|
@ -72,13 +219,26 @@ const applySettings = (s) => {
|
||||||
root.style.setProperty("--project-btn-border", s.projectBtnBorder);
|
root.style.setProperty("--project-btn-border", s.projectBtnBorder);
|
||||||
root.style.setProperty("--project-btn-hover", s.projectBtnHover);
|
root.style.setProperty("--project-btn-hover", s.projectBtnHover);
|
||||||
root.style.setProperty("--modal-btn-primary", s.modalBtnPrimary);
|
root.style.setProperty("--modal-btn-primary", s.modalBtnPrimary);
|
||||||
|
root.style.setProperty("--modal-btn-primary-contrast", readableTextColor(s.modalBtnPrimary));
|
||||||
root.style.setProperty("--modal-btn-secondary", s.modalBtnSecondary);
|
root.style.setProperty("--modal-btn-secondary", s.modalBtnSecondary);
|
||||||
root.style.setProperty("--loader-color", s.loaderColor);
|
root.style.setProperty("--loader-color", s.loaderColor);
|
||||||
root.style.setProperty("--template-glow-strength", `${s.templateGlow}%`);
|
root.style.setProperty("--template-glow-strength", `${s.templateGlow}%`);
|
||||||
root.style.setProperty("--toolbar-bg", s.toolbarBg);
|
root.style.setProperty("--toolbar-bg", s.toolbarBg);
|
||||||
root.style.setProperty("--toolbar-bg-active", s.toolbarBgActive);
|
root.style.setProperty("--toolbar-bg-active", s.toolbarBgActive);
|
||||||
|
root.style.setProperty("--toolbar-bg-active-contrast", readableTextColor(s.toolbarBgActive));
|
||||||
root.style.setProperty("--toolbar-border", s.toolbarBorder);
|
root.style.setProperty("--toolbar-border", s.toolbarBorder);
|
||||||
root.style.setProperty("--toolbar-outline", s.toolbarOutline);
|
root.style.setProperty("--toolbar-outline", s.toolbarOutline);
|
||||||
|
const toolbarMinRaw = Number(s.toolbarMinSize ?? defaultSettings.toolbarMinSize);
|
||||||
|
const toolbarMaxRaw = Number(s.toolbarMaxSize ?? defaultSettings.toolbarMaxSize);
|
||||||
|
const toolbarLensRaw = Number(s.toolbarLensCount ?? defaultSettings.toolbarLensCount);
|
||||||
|
const toolbarMinSize = clamp(Number.isFinite(toolbarMinRaw) ? toolbarMinRaw : defaultSettings.toolbarMinSize, 18, 88);
|
||||||
|
const toolbarMaxSize = Math.max(toolbarMinSize, clamp(Number.isFinite(toolbarMaxRaw) ? toolbarMaxRaw : defaultSettings.toolbarMaxSize, 18, 96));
|
||||||
|
const toolbarLensCount = Math.max(1, Math.min(15, Math.round(Number.isFinite(toolbarLensRaw) ? toolbarLensRaw : defaultSettings.toolbarLensCount)));
|
||||||
|
root.style.setProperty("--tb-size", `${toolbarMinSize}px`);
|
||||||
|
root.style.setProperty("--tb-min-size", `${toolbarMinSize}px`);
|
||||||
|
root.style.setProperty("--tb-max-size", `${toolbarMaxSize}px`);
|
||||||
|
root.style.setProperty("--tb-lens-count", `${toolbarLensCount % 2 === 0 ? toolbarLensCount + 1 : toolbarLensCount}`);
|
||||||
|
root.style.setProperty("--tb-autohide", s.toolbarAutoHide ? "1" : "0");
|
||||||
const bgAlphaPct = `${(s.modalBgAlpha ?? 1) * 100}%`;
|
const bgAlphaPct = `${(s.modalBgAlpha ?? 1) * 100}%`;
|
||||||
const focusAlphaPct = `${(s.modalFocusAlpha ?? 1) * 100}%`;
|
const focusAlphaPct = `${(s.modalFocusAlpha ?? 1) * 100}%`;
|
||||||
root.style.setProperty("--modal-bg", s.modalBg);
|
root.style.setProperty("--modal-bg", s.modalBg);
|
||||||
|
|
@ -89,6 +249,7 @@ const applySettings = (s) => {
|
||||||
root.style.setProperty("--modal-element", s.modalElement);
|
root.style.setProperty("--modal-element", s.modalElement);
|
||||||
root.style.setProperty("--modal-element-outline", s.modalElementOutline);
|
root.style.setProperty("--modal-element-outline", s.modalElementOutline);
|
||||||
root.style.setProperty("--modal-radius", s.modalRadius);
|
root.style.setProperty("--modal-radius", s.modalRadius);
|
||||||
|
applyGlassSettings(root, s);
|
||||||
root.style.setProperty("--border", s.border);
|
root.style.setProperty("--border", s.border);
|
||||||
root.style.setProperty("--text", s.text);
|
root.style.setProperty("--text", s.text);
|
||||||
root.style.setProperty("--panel", s.panel);
|
root.style.setProperty("--panel", s.panel);
|
||||||
|
|
@ -108,11 +269,27 @@ const applySettings = (s) => {
|
||||||
root.style.setProperty("--measure-label", s.measureLabel);
|
root.style.setProperty("--measure-label", s.measureLabel);
|
||||||
root.style.setProperty("--measure-label-text", s.measureLabelText);
|
root.style.setProperty("--measure-label-text", s.measureLabelText);
|
||||||
root.style.setProperty("--measure-dot", s.measureDot);
|
root.style.setProperty("--measure-dot", s.measureDot);
|
||||||
|
root.style.setProperty("--section-axis-x", s.sectionAxisX);
|
||||||
|
root.style.setProperty("--section-axis-y", s.sectionAxisY);
|
||||||
|
root.style.setProperty("--section-axis-z", s.sectionAxisZ);
|
||||||
|
root.style.setProperty("--section-gizmo-diameter", s.sectionGizmoDiameter);
|
||||||
|
root.style.setProperty("--section-gizmo-thickness", s.sectionGizmoThickness);
|
||||||
|
root.style.setProperty("--section-plane-fill", s.sectionPlaneFill);
|
||||||
|
root.style.setProperty("--section-plane-alpha", s.sectionPlaneAlpha);
|
||||||
|
root.style.setProperty("--section-plane-edge", s.sectionPlaneEdge);
|
||||||
|
root.style.setProperty("--section-plane-edge-alpha", s.sectionPlaneEdgeAlpha);
|
||||||
|
root.style.setProperty("--comment-target-active", s.commentTargetActiveColor);
|
||||||
|
root.style.setProperty("--comment-target-active-alpha", s.commentTargetActiveAlpha);
|
||||||
|
root.style.setProperty("--comment-target-active-size", `${s.commentTargetActiveSize}px`);
|
||||||
|
root.style.setProperty("--comment-target-passive", s.commentTargetPassiveColor);
|
||||||
|
root.style.setProperty("--comment-target-passive-alpha", s.commentTargetPassiveAlpha);
|
||||||
|
root.style.setProperty("--comment-target-passive-size", `${s.commentTargetPassiveSize}px`);
|
||||||
// подсветка объектов
|
// подсветка объектов
|
||||||
root.style.setProperty("--highlight-color", s.highlight);
|
root.style.setProperty("--highlight-color", s.highlight);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSettings = (s) => {
|
const saveSettings = (s) => {
|
||||||
|
if (isReadonlySettingsContext()) return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -121,6 +298,9 @@ const saveSettings = (s) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = () => {
|
const loadSettings = () => {
|
||||||
|
if (isReadonlySettingsContext()) {
|
||||||
|
return { ...projectSettings };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// purge legacy keys so старые палитры не мешали
|
// purge legacy keys so старые палитры не мешали
|
||||||
LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
|
LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
|
||||||
|
|
@ -186,11 +366,20 @@ export function createSettingsMenu({ panel, controls = {}, onChange = null, onSh
|
||||||
header.innerHTML = `<span class="settings-title">Настройки</span>`;
|
header.innerHTML = `<span class="settings-title">Настройки</span>`;
|
||||||
const closeBtn = document.createElement("button");
|
const closeBtn = document.createElement("button");
|
||||||
closeBtn.className = "settings-close";
|
closeBtn.className = "settings-close";
|
||||||
closeBtn.textContent = "—";
|
closeBtn.setAttribute("aria-label", "Закрыть настройки");
|
||||||
|
closeBtn.innerHTML = `
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
closeBtn.addEventListener("click", hide);
|
closeBtn.addEventListener("click", hide);
|
||||||
header.appendChild(closeBtn);
|
header.appendChild(closeBtn);
|
||||||
panel.appendChild(header);
|
panel.appendChild(header);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "settings-panel-body";
|
||||||
|
panel.appendChild(body);
|
||||||
|
|
||||||
const section = (title) => {
|
const section = (title) => {
|
||||||
const box = document.createElement("div");
|
const box = document.createElement("div");
|
||||||
box.className = "settings-section";
|
box.className = "settings-section";
|
||||||
|
|
@ -202,29 +391,103 @@ export function createSettingsMenu({ panel, controls = {}, onChange = null, onSh
|
||||||
};
|
};
|
||||||
|
|
||||||
const addColor = (parent, label, key) => {
|
const addColor = (parent, label, key) => {
|
||||||
const isHex = (val) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test((val || "").trim());
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "color-row";
|
row.className = "color-row";
|
||||||
const lbl = document.createElement("label");
|
const lbl = document.createElement("div");
|
||||||
|
lbl.textContent = label;
|
||||||
|
const control = createColorControl({
|
||||||
|
value: state[key],
|
||||||
|
onChange: (nextValue) => {
|
||||||
|
setVal(key, nextValue);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
row.appendChild(lbl);
|
||||||
|
row.appendChild(control);
|
||||||
|
parent.appendChild(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSettingsSlider = ({
|
||||||
|
parent,
|
||||||
|
label,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
formatValue = (nextValue) => String(nextValue),
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const slider = createSliderControl({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
className: "env-range-control",
|
||||||
|
showValue: true,
|
||||||
|
formatValue,
|
||||||
|
onChange: (nextValue) => {
|
||||||
|
if (typeof onChange === "function") {
|
||||||
|
onChange(nextValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVal(key, nextValue);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
parent.appendChild(slider);
|
||||||
|
return slider;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNumber = ({
|
||||||
|
parent,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "color-row";
|
||||||
|
const lbl = document.createElement("div");
|
||||||
lbl.textContent = label;
|
lbl.textContent = label;
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "color";
|
input.className = "env-number-field";
|
||||||
input.value = isHex(state[key]) ? state[key] : "#ffffff";
|
input.type = "number";
|
||||||
const hex = document.createElement("input");
|
input.value = value;
|
||||||
hex.type = "text";
|
input.min = min;
|
||||||
hex.value = state[key];
|
input.step = step;
|
||||||
input.addEventListener("change", (e) => {
|
if (max !== undefined) {
|
||||||
hex.value = e.target.value;
|
input.max = max;
|
||||||
setVal(key, e.target.value);
|
}
|
||||||
});
|
input.addEventListener("input", () => {
|
||||||
hex.addEventListener("change", (e) => {
|
const nextValue = Number(input.value);
|
||||||
input.value = e.target.value;
|
if (Number.isFinite(nextValue)) {
|
||||||
setVal(key, e.target.value);
|
onChange?.(nextValue);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
row.appendChild(lbl);
|
row.appendChild(lbl);
|
||||||
row.appendChild(input);
|
row.appendChild(input);
|
||||||
row.appendChild(hex);
|
|
||||||
parent.appendChild(row);
|
parent.appendChild(row);
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addBoolean = (parent, label, key) => {
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "checkbox-row";
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.className = "checkbox-row__label";
|
||||||
|
text.textContent = label;
|
||||||
|
const chk = document.createElement("input");
|
||||||
|
chk.type = "checkbox";
|
||||||
|
chk.checked = !!state[key];
|
||||||
|
chk.addEventListener("change", () => {
|
||||||
|
setVal(key, chk.checked);
|
||||||
|
});
|
||||||
|
row.appendChild(text);
|
||||||
|
row.appendChild(chk);
|
||||||
|
parent.appendChild(row);
|
||||||
|
return chk;
|
||||||
};
|
};
|
||||||
|
|
||||||
const font = section("Фон");
|
const font = section("Фон");
|
||||||
|
|
@ -233,157 +496,302 @@ export function createSettingsMenu({ panel, controls = {}, onChange = null, onSh
|
||||||
addColor(font, "Фон холста низ", "canvasBottom");
|
addColor(font, "Фон холста низ", "canvasBottom");
|
||||||
addColor(font, "Фон загрузки верх", "loadingTop");
|
addColor(font, "Фон загрузки верх", "loadingTop");
|
||||||
addColor(font, "Фон загрузки низ", "loadingBottom");
|
addColor(font, "Фон загрузки низ", "loadingBottom");
|
||||||
panel.appendChild(font);
|
body.appendChild(font);
|
||||||
|
|
||||||
const buttons = section("Кнопки");
|
const buttons = section("Кнопки");
|
||||||
addColor(buttons, "Плюс", "plus");
|
addColor(buttons, "Плюс", "plus");
|
||||||
addColor(buttons, "Кнопки (заливка)", "templateFill");
|
addColor(buttons, "Кнопки (заливка)", "templateFill");
|
||||||
addColor(buttons, "Кнопки (бордер)", "templateOutline");
|
addColor(buttons, "Кнопки (бордер)", "templateOutline");
|
||||||
addColor(buttons, "Кнопки (outline hover)", "templateOutlineHover");
|
addColor(buttons, "Кнопки (outline hover)", "templateOutlineHover");
|
||||||
const brightnessSlider = createSliderControl({
|
addSettingsSlider({
|
||||||
label: "Яркость подсветки",
|
parent: buttons,
|
||||||
value: Number(state.templateGlow ?? 80),
|
label: "Яркость подсветки",
|
||||||
min: 0,
|
key: "templateGlow",
|
||||||
max: 100,
|
value: Number(state.templateGlow ?? 80),
|
||||||
step: 1,
|
min: 0,
|
||||||
onChange: (val) => setVal("templateGlow", val),
|
max: 100,
|
||||||
});
|
step: 1,
|
||||||
buttons.appendChild(brightnessSlider);
|
formatValue: (val) => `${Math.round(val)}%`,
|
||||||
panel.appendChild(buttons);
|
});
|
||||||
|
body.appendChild(buttons);
|
||||||
|
|
||||||
const projectButtons = section("Кнопки проектов");
|
const projectButtons = section("Кнопки проектов");
|
||||||
addColor(projectButtons, "Проекты (заливка)", "projectBtnFill");
|
addColor(projectButtons, "Проекты (заливка)", "projectBtnFill");
|
||||||
addColor(projectButtons, "Проекты (бордер)", "projectBtnBorder");
|
addColor(projectButtons, "Проекты (бордер)", "projectBtnBorder");
|
||||||
addColor(projectButtons, "Проекты (hover)", "projectBtnHover");
|
addColor(projectButtons, "Проекты (hover)", "projectBtnHover");
|
||||||
panel.appendChild(projectButtons);
|
body.appendChild(projectButtons);
|
||||||
|
|
||||||
const modalButtons = section("Кнопки модалок");
|
const modalButtons = section("Кнопки модалок");
|
||||||
addColor(modalButtons, "Модальная primary", "modalBtnPrimary");
|
addColor(modalButtons, "Модальная primary", "modalBtnPrimary");
|
||||||
addColor(modalButtons, "Модальная secondary", "modalBtnSecondary");
|
addColor(modalButtons, "Модальная secondary", "modalBtnSecondary");
|
||||||
addColor(modalButtons, "Лоудер", "loaderColor");
|
addColor(modalButtons, "Лоудер", "loaderColor");
|
||||||
panel.appendChild(modalButtons);
|
body.appendChild(modalButtons);
|
||||||
|
|
||||||
const toolbar = section("Toolbar");
|
const toolbar = section("Toolbar");
|
||||||
addColor(toolbar, "Фон", "toolbarBg");
|
addColor(toolbar, "Фон", "toolbarBg");
|
||||||
addColor(toolbar, "Актив", "toolbarBgActive");
|
addColor(toolbar, "Актив", "toolbarBgActive");
|
||||||
addColor(toolbar, "Бордер", "toolbarBorder");
|
addColor(toolbar, "Бордер", "toolbarBorder");
|
||||||
addColor(toolbar, "Outline", "toolbarOutline");
|
addColor(toolbar, "Outline", "toolbarOutline");
|
||||||
panel.appendChild(toolbar);
|
addSettingsSlider({
|
||||||
|
parent: toolbar,
|
||||||
|
label: "Минимальный размер",
|
||||||
|
value: Math.max(18, Math.min(42, Number(state.toolbarMinSize ?? defaultSettings.toolbarMinSize))),
|
||||||
|
min: 18,
|
||||||
|
max: 42,
|
||||||
|
step: 1,
|
||||||
|
formatValue: (val) => `${Math.round(val)}px`,
|
||||||
|
onChange: (val) => {
|
||||||
|
const next = Math.max(18, Math.min(42, Math.round(Number(val))));
|
||||||
|
setVal("toolbarMinSize", next);
|
||||||
|
if (Number(state.toolbarMaxSize) < next) {
|
||||||
|
setVal("toolbarMaxSize", next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: toolbar,
|
||||||
|
label: "Максимальный размер",
|
||||||
|
value: Math.max(28, Math.min(88, Number(state.toolbarMaxSize ?? defaultSettings.toolbarMaxSize))),
|
||||||
|
min: 28,
|
||||||
|
max: 88,
|
||||||
|
step: 1,
|
||||||
|
formatValue: (val) => `${Math.round(val)}px`,
|
||||||
|
onChange: (val) => {
|
||||||
|
const minSize = Math.max(18, Math.min(42, Number(state.toolbarMinSize ?? defaultSettings.toolbarMinSize)));
|
||||||
|
setVal("toolbarMaxSize", Math.max(minSize, Math.min(88, Math.round(Number(val)))));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: toolbar,
|
||||||
|
label: "Линза",
|
||||||
|
value: Math.max(1, Math.min(13, Number(state.toolbarLensCount ?? defaultSettings.toolbarLensCount))),
|
||||||
|
min: 1,
|
||||||
|
max: 13,
|
||||||
|
step: 2,
|
||||||
|
formatValue: (val) => `${Math.round(val)} иконок`,
|
||||||
|
onChange: (val) => {
|
||||||
|
const next = Math.max(1, Math.min(13, Math.round(Number(val))));
|
||||||
|
setVal("toolbarLensCount", next % 2 === 0 ? next + 1 : next);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addBoolean(toolbar, "Автоскрытие", "toolbarAutoHide");
|
||||||
|
body.appendChild(toolbar);
|
||||||
|
|
||||||
const navCube = section("NavCube");
|
const navCube = section("NavCube");
|
||||||
addColor(navCube, "Грани", "navCubeBase");
|
addColor(navCube, "Грани", "navCubeBase");
|
||||||
addColor(navCube, "Hover", "navCubeHover");
|
addColor(navCube, "Hover", "navCubeHover");
|
||||||
addColor(navCube, "Текст", "navCubeText");
|
addColor(navCube, "Текст", "navCubeText");
|
||||||
addColor(navCube, "Рёбра", "navCubeEdge");
|
addColor(navCube, "Рёбра", "navCubeEdge");
|
||||||
const edgeAlphaRow = document.createElement("div");
|
addSettingsSlider({
|
||||||
edgeAlphaRow.className = "color-row";
|
parent: navCube,
|
||||||
const edgeAlphaLbl = document.createElement("label");
|
label: "Прозрачность рёбер",
|
||||||
edgeAlphaLbl.textContent = "Прозрачность рёбер (0-1)";
|
value: Math.round(Math.max(0, Math.min(1, Number(state.navCubeEdgeAlpha ?? 0.6))) * 100),
|
||||||
const edgeAlphaInput = document.createElement("input");
|
min: 0,
|
||||||
edgeAlphaInput.type = "number";
|
max: 100,
|
||||||
edgeAlphaInput.step = "0.05";
|
step: 5,
|
||||||
edgeAlphaInput.min = "0";
|
formatValue: (val) => `${Math.round(val)}%`,
|
||||||
edgeAlphaInput.max = "1";
|
onChange: (val) => {
|
||||||
edgeAlphaInput.value = state.navCubeEdgeAlpha;
|
setVal("navCubeEdgeAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
|
||||||
edgeAlphaInput.addEventListener("change", () => {
|
},
|
||||||
const v = Math.min(1, Math.max(0, parseFloat(edgeAlphaInput.value) || 0));
|
|
||||||
edgeAlphaInput.value = v;
|
|
||||||
setVal("navCubeEdgeAlpha", v);
|
|
||||||
});
|
});
|
||||||
edgeAlphaRow.appendChild(edgeAlphaLbl);
|
body.appendChild(navCube);
|
||||||
edgeAlphaRow.appendChild(edgeAlphaInput);
|
|
||||||
edgeAlphaRow.appendChild(document.createElement("div"));
|
const sectionCut = section("Разрез / сечение");
|
||||||
navCube.appendChild(edgeAlphaRow);
|
addColor(sectionCut, "Ось X", "sectionAxisX");
|
||||||
panel.appendChild(navCube);
|
addColor(sectionCut, "Ось Y", "sectionAxisY");
|
||||||
|
addColor(sectionCut, "Ось Z", "sectionAxisZ");
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: sectionCut,
|
||||||
|
label: "Диаметр гизмо",
|
||||||
|
value: Math.round(Math.max(0.5, Math.min(1.8, Number(state.sectionGizmoDiameter ?? 1))) * 100),
|
||||||
|
min: 50,
|
||||||
|
max: 180,
|
||||||
|
step: 5,
|
||||||
|
formatValue: (val) => `${Math.round(val)}%`,
|
||||||
|
onChange: (val) => {
|
||||||
|
setVal("sectionGizmoDiameter", Math.max(0.5, Math.min(1.8, Number(val) / 100)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: sectionCut,
|
||||||
|
label: "Толщина гизмо",
|
||||||
|
value: Math.round(Math.max(0.5, Math.min(2.5, Number(state.sectionGizmoThickness ?? 1))) * 100),
|
||||||
|
min: 50,
|
||||||
|
max: 250,
|
||||||
|
step: 5,
|
||||||
|
formatValue: (val) => `${Math.round(val)}%`,
|
||||||
|
onChange: (val) => {
|
||||||
|
setVal("sectionGizmoThickness", Math.max(0.5, Math.min(2.5, Number(val) / 100)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addColor(sectionCut, "Плоскость", "sectionPlaneFill");
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: sectionCut,
|
||||||
|
label: "Прозрачность плоскости",
|
||||||
|
value: Math.round(Math.max(0, Math.min(1, Number(state.sectionPlaneAlpha ?? 0.32))) * 100),
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 5,
|
||||||
|
formatValue: (val) => `${Math.round(val)}%`,
|
||||||
|
onChange: (val) => {
|
||||||
|
setVal("sectionPlaneAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addColor(sectionCut, "Обводка", "sectionPlaneEdge");
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: sectionCut,
|
||||||
|
label: "Прозрачность обводки",
|
||||||
|
value: Math.round(Math.max(0, Math.min(1, Number(state.sectionPlaneEdgeAlpha ?? 0.8))) * 100),
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 5,
|
||||||
|
formatValue: (val) => `${Math.round(val)}%`,
|
||||||
|
onChange: (val) => {
|
||||||
|
setVal("sectionPlaneEdgeAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
body.appendChild(sectionCut);
|
||||||
|
|
||||||
|
const commentTargets = section("Таргеты комментариев");
|
||||||
|
addColor(commentTargets, "Активный: цвет", "commentTargetActiveColor");
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: commentTargets,
|
||||||
|
label: "Активный: прозрачность",
|
||||||
|
value: Math.round(Math.max(0, Math.min(1, Number(state.commentTargetActiveAlpha ?? 1))) * 100),
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 5,
|
||||||
|
formatValue: (val) => `${Math.round(val)}%`,
|
||||||
|
onChange: (val) => {
|
||||||
|
setVal("commentTargetActiveAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: commentTargets,
|
||||||
|
label: "Активный: размер",
|
||||||
|
value: Math.max(14, Math.min(64, Number(state.commentTargetActiveSize ?? 30))),
|
||||||
|
min: 14,
|
||||||
|
max: 64,
|
||||||
|
step: 1,
|
||||||
|
formatValue: (val) => `${Math.round(val)}px`,
|
||||||
|
onChange: (val) => {
|
||||||
|
setVal("commentTargetActiveSize", Math.max(14, Math.min(64, Math.round(Number(val)))));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addColor(commentTargets, "Пассивный: цвет", "commentTargetPassiveColor");
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: commentTargets,
|
||||||
|
label: "Пассивный: прозрачность",
|
||||||
|
value: Math.round(Math.max(0, Math.min(1, Number(state.commentTargetPassiveAlpha ?? 0.58))) * 100),
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 5,
|
||||||
|
formatValue: (val) => `${Math.round(val)}%`,
|
||||||
|
onChange: (val) => {
|
||||||
|
setVal("commentTargetPassiveAlpha", Math.max(0, Math.min(1, Number(val) / 100)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addSettingsSlider({
|
||||||
|
parent: commentTargets,
|
||||||
|
label: "Пассивный: размер",
|
||||||
|
value: Math.max(12, Math.min(48, Number(state.commentTargetPassiveSize ?? 22))),
|
||||||
|
min: 12,
|
||||||
|
max: 48,
|
||||||
|
step: 1,
|
||||||
|
formatValue: (val) => `${Math.round(val)}px`,
|
||||||
|
onChange: (val) => {
|
||||||
|
setVal("commentTargetPassiveSize", Math.max(12, Math.min(48, Math.round(Number(val)))));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
body.appendChild(commentTargets);
|
||||||
|
|
||||||
const measure = section("Линейка");
|
const measure = section("Линейка");
|
||||||
addColor(measure, "Линия/бордер", "measureLine");
|
addColor(measure, "Линия/бордер", "measureLine");
|
||||||
addColor(measure, "Плашка", "measureLabel");
|
addColor(measure, "Плашка", "measureLabel");
|
||||||
addColor(measure, "Текст плашки", "measureLabelText");
|
addColor(measure, "Текст плашки", "measureLabelText");
|
||||||
addColor(measure, "Кружок", "measureDot");
|
addColor(measure, "Кружок", "measureDot");
|
||||||
panel.appendChild(measure);
|
body.appendChild(measure);
|
||||||
|
|
||||||
const viewport = section("Viewport");
|
const viewport = section("Viewport");
|
||||||
const addCheck = (label, checked, handler) => {
|
const addCheck = (label, checked, handler) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("label");
|
||||||
row.className = "checkbox-row";
|
row.className = "checkbox-row";
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.className = "checkbox-row__label";
|
||||||
|
text.textContent = label;
|
||||||
const chk = document.createElement("input");
|
const chk = document.createElement("input");
|
||||||
chk.type = "checkbox";
|
chk.type = "checkbox";
|
||||||
chk.checked = checked;
|
chk.checked = checked;
|
||||||
chk.addEventListener("change", () => handler(chk.checked));
|
chk.addEventListener("change", () => handler(chk.checked));
|
||||||
const lbl = document.createElement("label");
|
row.appendChild(text);
|
||||||
lbl.textContent = label;
|
|
||||||
row.appendChild(chk);
|
row.appendChild(chk);
|
||||||
row.appendChild(lbl);
|
|
||||||
viewport.appendChild(row);
|
viewport.appendChild(row);
|
||||||
};
|
};
|
||||||
if (controls.toggleEdges) addCheck("Рёбра", controls.toggleEdges.checked, (v) => { controls.toggleEdges.checked = v; controls.toggleEdges.dispatchEvent(new Event("change")); });
|
if (controls.toggleEdges) addCheck("Рёбра", controls.toggleEdges.checked, (v) => { controls.toggleEdges.checked = v; controls.toggleEdges.dispatchEvent(new Event("change")); });
|
||||||
if (controls.toggleTransparent) addCheck("Прозрачный фон", controls.toggleTransparent.checked, (v) => { controls.toggleTransparent.checked = v; controls.toggleTransparent.dispatchEvent(new Event("change")); });
|
if (controls.toggleTransparent) addCheck("Прозрачный фон", controls.toggleTransparent.checked, (v) => { controls.toggleTransparent.checked = v; controls.toggleTransparent.dispatchEvent(new Event("change")); });
|
||||||
if (controls.addMode) addCheck("Добавлять в сцену", controls.addMode.checked, (v) => { controls.addMode.checked = v; });
|
if (controls.addMode) addCheck("Добавлять в сцену", controls.addMode.checked, (v) => { controls.addMode.checked = v; });
|
||||||
panel.appendChild(viewport);
|
body.appendChild(viewport);
|
||||||
|
|
||||||
const modals = section("Модальные окна");
|
const modals = section("Модальные окна");
|
||||||
addColor(modals, "Основной фон", "modalBg");
|
addColor(modals, "Основной фон", "modalBg");
|
||||||
addColor(modals, "Фокусный фон", "modalFocus");
|
addColor(modals, "Фокусный фон", "modalFocus");
|
||||||
const modalBgAlphaRow = createSliderControl({
|
addSettingsSlider({
|
||||||
|
parent: modals,
|
||||||
label: "Прозрачность основного фона",
|
label: "Прозрачность основного фона",
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.05,
|
step: 0.05,
|
||||||
value: parseFloat(state.modalBgAlpha ?? 1),
|
value: parseFloat(state.modalBgAlpha ?? 1),
|
||||||
|
formatValue: (val) => `${Math.round(Number(val) * 100)}%`,
|
||||||
onChange: (val) => {
|
onChange: (val) => {
|
||||||
const num = Math.max(0, Math.min(1, parseFloat(val)));
|
const num = Math.max(0, Math.min(1, parseFloat(val)));
|
||||||
setVal("modalBgAlpha", num);
|
setVal("modalBgAlpha", num);
|
||||||
document.documentElement.style.setProperty("--modal-bg-alpha", `${num * 100}%`);
|
document.documentElement.style.setProperty("--modal-bg-alpha", `${num * 100}%`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
modals.appendChild(modalBgAlphaRow);
|
|
||||||
|
|
||||||
const modalFocusAlphaRow = createSliderControl({
|
addSettingsSlider({
|
||||||
|
parent: modals,
|
||||||
label: "Прозрачность фокусного фона",
|
label: "Прозрачность фокусного фона",
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.05,
|
step: 0.05,
|
||||||
value: parseFloat(state.modalFocusAlpha ?? 1),
|
value: parseFloat(state.modalFocusAlpha ?? 1),
|
||||||
|
formatValue: (val) => `${Math.round(Number(val) * 100)}%`,
|
||||||
onChange: (val) => {
|
onChange: (val) => {
|
||||||
const num = Math.max(0, Math.min(1, parseFloat(val)));
|
const num = Math.max(0, Math.min(1, parseFloat(val)));
|
||||||
setVal("modalFocusAlpha", num);
|
setVal("modalFocusAlpha", num);
|
||||||
document.documentElement.style.setProperty("--modal-focus-alpha", `${num * 100}%`);
|
document.documentElement.style.setProperty("--modal-focus-alpha", `${num * 100}%`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
modals.appendChild(modalFocusAlphaRow);
|
|
||||||
|
|
||||||
addColor(modals, "Аутлайн", "modalBorder");
|
addColor(modals, "Аутлайн", "modalBorder");
|
||||||
addColor(modals, "Фон элемента", "modalElement");
|
addColor(modals, "Контролы", "modalElement");
|
||||||
addColor(modals, "Аутлайн элемента", "modalElementOutline");
|
addColor(modals, "Аутлайн элемента", "modalElementOutline");
|
||||||
const radiusRow = document.createElement("div");
|
addNumber({
|
||||||
radiusRow.className = "color-row";
|
parent: modals,
|
||||||
const radiusLbl = document.createElement("label");
|
label: "Радиус",
|
||||||
radiusLbl.textContent = "Радиус";
|
value: parseInt(getComputedStyle(document.documentElement).getPropertyValue("--modal-radius")) || 14,
|
||||||
const radiusInput = document.createElement("input");
|
min: 0,
|
||||||
radiusInput.type = "number";
|
max: 48,
|
||||||
radiusInput.value = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--modal-radius")) || 14;
|
step: 1,
|
||||||
radiusInput.addEventListener("change", () => {
|
onChange: (nextValue) => {
|
||||||
const val = `${radiusInput.value}px`;
|
const val = `${Math.max(0, Math.min(48, Math.round(nextValue)))}px`;
|
||||||
document.documentElement.style.setProperty("--modal-radius", val);
|
setVal("modalRadius", val);
|
||||||
setVal("modalRadius", val);
|
},
|
||||||
});
|
});
|
||||||
radiusRow.appendChild(radiusLbl);
|
body.appendChild(modals);
|
||||||
radiusRow.appendChild(radiusInput);
|
|
||||||
radiusRow.appendChild(document.createElement("div"));
|
|
||||||
modals.appendChild(radiusRow);
|
|
||||||
panel.appendChild(modals);
|
|
||||||
|
|
||||||
const cube = section("Выделение объектов");
|
const cube = section("Выделение объектов");
|
||||||
addColor(cube, "Подсветка объектов", "highlight");
|
addColor(cube, "Подсветка объектов", "highlight");
|
||||||
panel.appendChild(cube);
|
body.appendChild(cube);
|
||||||
};
|
};
|
||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
render();
|
render();
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.classList.remove("hidden");
|
panel.classList.remove("hidden");
|
||||||
panel.style.display = "grid";
|
panel.style.display = "flex";
|
||||||
}
|
}
|
||||||
onShow?.();
|
onShow?.();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -62,27 +62,42 @@ export function createTemplatesMenu({ panel, fetchProjects, openProject, onShow,
|
||||||
header.className = "templates-header";
|
header.className = "templates-header";
|
||||||
header.innerHTML = `<span class="templates-title">Проекты и шаблоны</span>`;
|
header.innerHTML = `<span class="templates-title">Проекты и шаблоны</span>`;
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.style.display = "flex";
|
actions.className = "templates-actions";
|
||||||
actions.style.gap = "6px";
|
|
||||||
const refresh = document.createElement("button");
|
const refresh = document.createElement("button");
|
||||||
refresh.className = "templates-close";
|
refresh.className = "templates-close";
|
||||||
|
refresh.type = "button";
|
||||||
refresh.title = "Обновить список";
|
refresh.title = "Обновить список";
|
||||||
refresh.textContent = "↻";
|
refresh.setAttribute("aria-label", "Обновить список");
|
||||||
|
refresh.innerHTML = `
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M12.2 5.3A4.4 4.4 0 0 0 4.7 3.4L3.6 4.5M3.7 2.1v2.4h2.4M3.8 10.7a4.4 4.4 0 0 0 7.5 1.9l1.1-1.1M12.3 13.9v-2.4H9.9" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
refresh.addEventListener("click", () => load());
|
refresh.addEventListener("click", () => load());
|
||||||
const closeBtn = document.createElement("button");
|
const closeBtn = document.createElement("button");
|
||||||
closeBtn.className = "templates-close";
|
closeBtn.className = "templates-close";
|
||||||
closeBtn.textContent = "×";
|
closeBtn.type = "button";
|
||||||
|
closeBtn.setAttribute("aria-label", "Закрыть проекты и шаблоны");
|
||||||
|
closeBtn.innerHTML = `
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
closeBtn.addEventListener("click", hide);
|
closeBtn.addEventListener("click", hide);
|
||||||
actions.appendChild(refresh);
|
actions.appendChild(refresh);
|
||||||
actions.appendChild(closeBtn);
|
actions.appendChild(closeBtn);
|
||||||
header.appendChild(actions);
|
header.appendChild(actions);
|
||||||
panel.appendChild(header);
|
panel.appendChild(header);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "templates-panel-body";
|
||||||
|
panel.appendChild(body);
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
const loadingEl = document.createElement("div");
|
const loadingEl = document.createElement("div");
|
||||||
loadingEl.className = "templates-empty";
|
loadingEl.className = "templates-empty";
|
||||||
loadingEl.textContent = "Загрузка списка проектов...";
|
loadingEl.textContent = "Загрузка списка проектов...";
|
||||||
panel.appendChild(loadingEl);
|
body.appendChild(loadingEl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,11 +106,11 @@ export function createTemplatesMenu({ panel, fetchProjects, openProject, onShow,
|
||||||
errorEl.className = "templates-empty";
|
errorEl.className = "templates-empty";
|
||||||
errorEl.style.color = "var(--error)";
|
errorEl.style.color = "var(--error)";
|
||||||
errorEl.textContent = state.error;
|
errorEl.textContent = state.error;
|
||||||
panel.appendChild(errorEl);
|
body.appendChild(errorEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
panel.appendChild(renderSection("Шаблоны", state.templates, "Нет шаблонов"));
|
body.appendChild(renderSection("Шаблоны", state.templates, "Нет шаблонов"));
|
||||||
panel.appendChild(renderSection("Мои проекты", state.projects, "Нет сохранённых проектов"));
|
body.appendChild(renderSection("Мои проекты", state.projects, "Нет сохранённых проектов"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
|
@ -126,7 +141,7 @@ export function createTemplatesMenu({ panel, fetchProjects, openProject, onShow,
|
||||||
render();
|
render();
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.classList.remove("hidden");
|
panel.classList.remove("hidden");
|
||||||
panel.style.display = "grid";
|
panel.style.display = "flex";
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
onShow?.();
|
onShow?.();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
const toolbarConfig = {
|
const toolbarConfig = {
|
||||||
|
minSize: 25,
|
||||||
|
maxSize: 87,
|
||||||
|
lensCount: 5,
|
||||||
|
autoHide: false,
|
||||||
size: 25,
|
size: 25,
|
||||||
gap: 10,
|
gap: 10,
|
||||||
radius: 7,
|
radius: 7,
|
||||||
|
|
@ -7,9 +11,22 @@ const toolbarConfig = {
|
||||||
iconSize: 13,
|
iconSize: 13,
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyToolbarVars = () => {
|
const numericSetting = (settings, key, fallback) => {
|
||||||
|
const value = Number(settings?.[key]);
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyToolbarVars = (settings = {}) => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty("--tb-size", `${toolbarConfig.size}px`);
|
const minSize = numericSetting(settings, "toolbarMinSize", toolbarConfig.minSize);
|
||||||
|
const maxSize = Math.max(minSize, numericSetting(settings, "toolbarMaxSize", toolbarConfig.maxSize));
|
||||||
|
const lensCount = Math.max(1, Math.round(numericSetting(settings, "toolbarLensCount", toolbarConfig.lensCount)));
|
||||||
|
const autoHide = settings?.toolbarAutoHide ?? toolbarConfig.autoHide;
|
||||||
|
root.style.setProperty("--tb-size", `${minSize}px`);
|
||||||
|
root.style.setProperty("--tb-min-size", `${minSize}px`);
|
||||||
|
root.style.setProperty("--tb-max-size", `${maxSize}px`);
|
||||||
|
root.style.setProperty("--tb-lens-count", `${lensCount}`);
|
||||||
|
root.style.setProperty("--tb-autohide", autoHide ? "1" : "0");
|
||||||
root.style.setProperty("--tb-gap", `${toolbarConfig.gap}px`);
|
root.style.setProperty("--tb-gap", `${toolbarConfig.gap}px`);
|
||||||
root.style.setProperty("--tb-radius", `${toolbarConfig.radius}px`);
|
root.style.setProperty("--tb-radius", `${toolbarConfig.radius}px`);
|
||||||
root.style.setProperty("--tb-pad-y", `${toolbarConfig.paddingY}px`);
|
root.style.setProperty("--tb-pad-y", `${toolbarConfig.paddingY}px`);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Базовые цвета/настройки проекта. При очистке localStorage эти значения остаются.
|
// Базовые цвета/настройки проекта. При очистке localStorage эти значения остаются.
|
||||||
// Чтобы изменить палитру по умолчанию — правь этот объект и закоммить изменения.
|
// Чтобы изменить палитру по умолчанию — правь этот объект и закоммить изменения.
|
||||||
const PROJECT_THEME = {
|
const PROJECT_THEME = {
|
||||||
themeVersion: "v3-yellow",
|
themeVersion: "v7-toolbar-hub-icon",
|
||||||
bgOuter: "#0f0f0f",
|
bgOuter: "#0f0f0f",
|
||||||
canvasTop: "#1c1c1c",
|
canvasTop: "#1c1c1c",
|
||||||
canvasBottom: "#1c1c1c",
|
canvasBottom: "#1c1c1c",
|
||||||
|
|
@ -9,44 +9,64 @@ const PROJECT_THEME = {
|
||||||
loadingBottom: "#1c1c1c",
|
loadingBottom: "#1c1c1c",
|
||||||
accent: "#ffea00",
|
accent: "#ffea00",
|
||||||
accent2: "#ffea00",
|
accent2: "#ffea00",
|
||||||
templateFill: "#1c1c1c",
|
templateFill: "#292929",
|
||||||
templateOutline: "#233044",
|
templateOutline: "#292929",
|
||||||
templateOutlineHover: "rgba(255,234,0,0.7)",
|
templateOutlineHover: "#1c1c1c",
|
||||||
templateGlow: 80,
|
templateGlow: 80,
|
||||||
toolbarBg: "#1c1c1c",
|
toolbarBg: "#292929",
|
||||||
toolbarBgActive: "#ffea00",
|
toolbarBgActive: "#c4ff67",
|
||||||
toolbarBorder: "#233044",
|
toolbarBorder: "#292929",
|
||||||
toolbarOutline: "#ffea00",
|
toolbarOutline: "#292929",
|
||||||
|
toolbarMinSize: 25,
|
||||||
|
toolbarMaxSize: 87,
|
||||||
|
toolbarLensCount: 5,
|
||||||
|
toolbarAutoHide: false,
|
||||||
modalBg: "#1c1c1c",
|
modalBg: "#1c1c1c",
|
||||||
modalBgAlpha: 1,
|
modalBgAlpha: 1,
|
||||||
modalFocus: "#292929",
|
modalFocus: "#292929",
|
||||||
modalFocusAlpha: 1,
|
modalFocusAlpha: 0.35,
|
||||||
modalBorder: "#292929",
|
modalBorder: "#1c1c1c",
|
||||||
modalElement: "#292929",
|
modalElement: "#262626",
|
||||||
modalElementOutline: "#2b2b2b",
|
modalElementOutline: "#262626",
|
||||||
modalRadius: "14px",
|
modalRadius: "14px",
|
||||||
projectBtnFill: "#1c1c1c",
|
projectBtnFill: "#292929",
|
||||||
projectBtnBorder: "#233044",
|
projectBtnBorder: "#292929",
|
||||||
projectBtnHover: "rgba(255,234,0,0.7)",
|
projectBtnHover: "#1c1c1c",
|
||||||
modalBtnPrimary: "#ffea00",
|
modalBtnPrimary: "#c4ff67",
|
||||||
modalBtnSecondary: "#292929",
|
modalBtnSecondary: "#292929",
|
||||||
|
loaderColor: "#c4ff67",
|
||||||
border: "#233044",
|
border: "#233044",
|
||||||
text: "#e6edf3",
|
text: "#e6edf3",
|
||||||
panel: "#1c1c1c",
|
panel: "#1c1c1c",
|
||||||
panel2: "#292929",
|
panel2: "#292929",
|
||||||
highlight: "#ffea00",
|
highlight: "#c4ff67",
|
||||||
cubeText: "#242424",
|
cubeText: "#242424",
|
||||||
cubeEdges: "#e0e7ff",
|
cubeEdges: "#e0e7ff",
|
||||||
plus: "#ffea00",
|
plus: "#c4ff67",
|
||||||
navCubeBase: "#ffea00",
|
navCubeBase: "#ffffff",
|
||||||
navCubeHover: "#ffffff",
|
navCubeHover: "#ffffff",
|
||||||
navCubeText: "#242424",
|
navCubeText: "#242424",
|
||||||
navCubeEdge: "#e0e7ff",
|
navCubeEdge: "#e0e7ff",
|
||||||
navCubeEdgeAlpha: 0.6,
|
navCubeEdgeAlpha: 0.6,
|
||||||
measureLine: "#ffea00",
|
measureLine: "#c4ff67",
|
||||||
measureLabel: "#ffea00",
|
measureLabel: "#c4ff67",
|
||||||
measureLabelText: "#292929",
|
measureLabelText: "#292929",
|
||||||
measureDot: "#ffea00",
|
measureDot: "#c4ff67",
|
||||||
|
sectionAxisX: "#ff2b2b",
|
||||||
|
sectionAxisY: "#35ff4f",
|
||||||
|
sectionAxisZ: "#285bff",
|
||||||
|
sectionGizmoDiameter: 1,
|
||||||
|
sectionGizmoThickness: 1,
|
||||||
|
sectionPlaneFill: "#606060",
|
||||||
|
sectionPlaneAlpha: 0.32,
|
||||||
|
sectionPlaneEdge: "#0d0d0d",
|
||||||
|
sectionPlaneEdgeAlpha: 0.8,
|
||||||
|
commentTargetActiveColor: "#c4ff67",
|
||||||
|
commentTargetActiveAlpha: 1,
|
||||||
|
commentTargetActiveSize: 30,
|
||||||
|
commentTargetPassiveColor: "#f5f5fa",
|
||||||
|
commentTargetPassiveAlpha: 0.58,
|
||||||
|
commentTargetPassiveSize: 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ export class TransformGizmo {
|
||||||
this._bindTree();
|
this._bindTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isPointCloudTarget(model, objectId = null) {
|
||||||
|
const entity = objectId ? this.scene.objects[objectId] : null;
|
||||||
|
const firstObject = entity || Object.values(model?.objects || {})[0];
|
||||||
|
const firstMesh = firstObject?.meshes?.[0] || model?.meshes?.[0];
|
||||||
|
return firstMesh?._geometry?._state?.primitiveName === "points";
|
||||||
|
}
|
||||||
|
|
||||||
setMode(next) {
|
setMode(next) {
|
||||||
if (!["translate", "rotate", "scale"].includes(next)) return;
|
if (!["translate", "rotate", "scale"].includes(next)) return;
|
||||||
this.mode = next;
|
this.mode = next;
|
||||||
|
|
@ -66,7 +73,9 @@ export class TransformGizmo {
|
||||||
scale: model.scale ? [...model.scale] : [1, 1, 1],
|
scale: model.scale ? [...model.scale] : [1, 1, 1],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.scene.setObjectsHighlighted([objectId], true);
|
if (!this._isPointCloudTarget(model, objectId)) {
|
||||||
|
this.scene.setObjectsHighlighted([objectId], true);
|
||||||
|
}
|
||||||
this.target = { modelId, objectId };
|
this.target = { modelId, objectId };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +83,7 @@ export class TransformGizmo {
|
||||||
|
|
||||||
// Если objectId нет, работаем всей моделью
|
// Если objectId нет, работаем всей моделью
|
||||||
const ids = Object.keys(model.objects || {});
|
const ids = Object.keys(model.objects || {});
|
||||||
if (ids.length) {
|
if (ids.length && !this._isPointCloudTarget(model)) {
|
||||||
this.scene.setObjectsHighlighted(ids, true);
|
this.scene.setObjectsHighlighted(ids, true);
|
||||||
}
|
}
|
||||||
this.onTargetChange?.({
|
this.onTargetChange?.({
|
||||||
|
|
@ -101,7 +110,9 @@ export class TransformGizmo {
|
||||||
if (model) {
|
if (model) {
|
||||||
const ids = Object.keys(model.objects || {});
|
const ids = Object.keys(model.objects || {});
|
||||||
this.scene.setObjectsHighlighted(this.scene.highlightedObjectIds || [], false);
|
this.scene.setObjectsHighlighted(this.scene.highlightedObjectIds || [], false);
|
||||||
this.scene.setObjectsHighlighted(ids, true);
|
if (!this._isPointCloudTarget(model)) {
|
||||||
|
this.scene.setObjectsHighlighted(ids, true);
|
||||||
|
}
|
||||||
this.target = { modelId, objectId: null };
|
this.target = { modelId, objectId: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,17 @@
|
||||||
|
|
||||||
Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`.
|
Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`.
|
||||||
|
|
||||||
Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект). При старте создаются при необходимости.
|
Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект).
|
||||||
|
Upload-артефакты лежат в `server/data/uploads`, публичные share-ссылки — в `server/data/shares/index.json`,
|
||||||
|
а пользовательские ссылки/ref-count моделей — в `server/data/models/registry.json`. При старте директории и индексы создаются при необходимости.
|
||||||
|
|
||||||
API:
|
API:
|
||||||
- `GET /api/projects[?type=project|template]` — укороченный список из `index.json`.
|
- `GET /api/projects[?type=project|template]` — укороченный список из `index.json`.
|
||||||
- `GET /api/projects/:id` — полный JSON проекта.
|
- `GET /api/projects/:id` — полный JSON проекта.
|
||||||
- `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс.
|
- `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс.
|
||||||
- `POST /api/uploads?filename=<name>&projectId=<id>` — сохранить оригинальный файл модели в `server/data/uploads`.
|
- `POST /api/uploads?filename=<name>&projectId=<id>` — сохранить оригинальный файл модели в `server/data/uploads`.
|
||||||
|
- `DELETE /api/uploads/asset` — снять ссылку текущего пользователя на модель и физически удалить asset только если больше нет user/share refs.
|
||||||
|
- `DELETE /api/uploads/version` — удалить версию, если на неё нет чужих refs или публичных share-ссылок.
|
||||||
- `GET /api/conversions/status?src=<uploads/...>` — получить статус подготовки viewer-артефакта.
|
- `GET /api/conversions/status?src=<uploads/...>` — получить статус подготовки viewer-артефакта.
|
||||||
Для `.step/.stp` ответ помечается `conversion.status=conversion_required`: оригинал уже доступен для скачивания,
|
Для `.step/.stp` ответ помечается `conversion.status=conversion_required`: оригинал уже доступен для скачивания,
|
||||||
а preview должен появиться после работы `NodeDcBimConverter`, который готовит GLB и metadata/tree.
|
а preview должен появиться после работы `NodeDcBimConverter`, который готовит GLB и metadata/tree.
|
||||||
|
|
@ -24,4 +28,24 @@ API:
|
||||||
- `docker compose -f docker-compose.beam.yml up ndc-beam-viewer`
|
- `docker compose -f docker-compose.beam.yml up ndc-beam-viewer`
|
||||||
- `docker compose -f docker-compose.beam.yml up nodedc-bim-converter`
|
- `docker compose -f docker-compose.beam.yml up nodedc-bim-converter`
|
||||||
|
|
||||||
|
Synology Beam compose:
|
||||||
|
- скопировать `.env.synology.example` в `.env` рядом с `docker-compose.beam.yml`;
|
||||||
|
- заполнить `NODEDC_INTERNAL_ACCESS_TOKEN` тем же значением, что и в platform `.env.synology`;
|
||||||
|
- запустить `docker compose -f docker-compose.beam.yml up -d ndc-beam-viewer nodedc-bim-converter`.
|
||||||
|
|
||||||
Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга.
|
Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга.
|
||||||
|
|
||||||
|
NODE.DC auth/share:
|
||||||
|
- `NODEDC_BIM_AUTH_REQUIRED=1` включает вход через Launcher handoff.
|
||||||
|
- `NODEDC_BIM_SERVICE_SLUG=bim-viewer` должен совпадать со slug сервиса в Launcher.
|
||||||
|
- `NODEDC_INTERNAL_ACCESS_TOKEN` должен совпадать с Launcher internal token.
|
||||||
|
- `NODEDC_LAUNCHER_BASE_URL` — публичный Launcher URL для редиректа на login/launch.
|
||||||
|
- `NODEDC_LAUNCHER_INTERNAL_URL` — внутренний URL Launcher для `/api/internal/handoff/consume`.
|
||||||
|
- `NODEDC_BIM_PUBLIC_URL` — публичный URL viewer-а для share-ссылок, на проде `https://bim.nodedc.tech`.
|
||||||
|
- `NODEDC_BIM_HOST_PORT` — host-port для `docker-compose.beam.yml`; на Synology BIM публикуется как `18100:8080`.
|
||||||
|
- `NODEDC_BIM_COOKIE_SAMESITE=None` и `NODEDC_BIM_COOKIE_SECURE=1` нужны для BIM в iframe на `ops.nodedc.ru`.
|
||||||
|
- `NODEDC_BIM_DATA_ROOT` — опциональный корень файлового storage вместо `server/data`.
|
||||||
|
- `POST /api/shares` создаёт публичную read-only ссылку на upload artifact; `/share/<token>` открывает viewer в guest mode.
|
||||||
|
- `GET /api/models` возвращает личные активные модели текущего пользователя.
|
||||||
|
- `POST /api/models/refs` сохраняет существующий upload artifact в личные модели текущего пользователя.
|
||||||
|
- `DELETE /api/models/refs` снимает личную ссылку и удаляет физические файлы только при отсутствии других refs/share.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "cmt_WugIB9w6U8jhk1qOHew",
|
||||||
|
"sourceKey": "nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2:legacy_572b02d5d4c05ce512d6",
|
||||||
|
"sourceSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.STEP",
|
||||||
|
"projectId": "nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2",
|
||||||
|
"assetId": "legacy_572b02d5d4c05ce512d6",
|
||||||
|
"modelId": "model",
|
||||||
|
"objectId": "407",
|
||||||
|
"title": "2",
|
||||||
|
"body": "2",
|
||||||
|
"subitems": [],
|
||||||
|
"worldPos": [
|
||||||
|
-0.016629892462037388,
|
||||||
|
0.03852343379522116,
|
||||||
|
-0.518901357060331
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-20T16:16:16.607Z",
|
||||||
|
"updatedAt": "2026-06-20T16:16:16.607Z",
|
||||||
|
"createdBy": {
|
||||||
|
"id": "local-dev",
|
||||||
|
"email": null,
|
||||||
|
"name": "Local BIM user",
|
||||||
|
"subject": null,
|
||||||
|
"authentikUserId": null
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "msg_d5ioR6S1xqv2lg",
|
||||||
|
"body": "2",
|
||||||
|
"attachments": [],
|
||||||
|
"createdAt": "2026-06-20T16:16:16.607Z",
|
||||||
|
"updatedAt": "2026-06-20T16:16:16.607Z",
|
||||||
|
"createdBy": {
|
||||||
|
"id": "local-dev",
|
||||||
|
"email": null,
|
||||||
|
"name": "Local BIM user",
|
||||||
|
"subject": null,
|
||||||
|
"authentikUserId": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmt_y_t1tTNgNa1SJA43-No",
|
||||||
|
"sourceKey": "nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2:legacy_572b02d5d4c05ce512d6",
|
||||||
|
"sourceSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.STEP",
|
||||||
|
"projectId": "nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2",
|
||||||
|
"assetId": "legacy_572b02d5d4c05ce512d6",
|
||||||
|
"modelId": "model",
|
||||||
|
"objectId": "401",
|
||||||
|
"title": "1",
|
||||||
|
"body": "1",
|
||||||
|
"subitems": [],
|
||||||
|
"worldPos": [
|
||||||
|
-0.0010763776897612098,
|
||||||
|
-0.00014665442142014137,
|
||||||
|
-0.00001253560767433548
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-20T16:15:03.218Z",
|
||||||
|
"updatedAt": "2026-06-20T16:47:23.780Z",
|
||||||
|
"createdBy": {
|
||||||
|
"id": "local-dev",
|
||||||
|
"email": null,
|
||||||
|
"name": "Local BIM user",
|
||||||
|
"subject": null,
|
||||||
|
"authentikUserId": null
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "msg_qaFqDoGumOeJ1g",
|
||||||
|
"body": "1",
|
||||||
|
"attachments": [],
|
||||||
|
"createdAt": "2026-06-20T16:15:03.218Z",
|
||||||
|
"updatedAt": "2026-06-20T16:15:03.218Z",
|
||||||
|
"createdBy": {
|
||||||
|
"id": "local-dev",
|
||||||
|
"email": null,
|
||||||
|
"name": "Local BIM user",
|
||||||
|
"subject": null,
|
||||||
|
"authentikUserId": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg_aAr0GFHSrUjeQw",
|
||||||
|
"body": "пиевив",
|
||||||
|
"attachments": [],
|
||||||
|
"createdAt": "2026-06-20T16:47:23.780Z",
|
||||||
|
"updatedAt": "2026-06-20T16:47:23.780Z",
|
||||||
|
"createdBy": {
|
||||||
|
"id": "local-dev",
|
||||||
|
"email": null,
|
||||||
|
"name": "Local BIM user",
|
||||||
|
"subject": null,
|
||||||
|
"authentikUserId": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -1,4 +1,20 @@
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
"id": "proj_0a284bb5-1c82-48b1-a636-e204578a570a",
|
||||||
|
"name": "DRONE",
|
||||||
|
"type": "project",
|
||||||
|
"ownerId": null,
|
||||||
|
"createdAt": "2026-06-05T13:33:23.556Z",
|
||||||
|
"updatedAt": "2026-06-20T20:33:51.505Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "proj_0c1b26f3-020c-4ee9-926c-fedf08003304",
|
||||||
|
"name": "TrackEnsemble_6074_v11",
|
||||||
|
"type": "project",
|
||||||
|
"ownerId": null,
|
||||||
|
"createdAt": "2026-06-04T19:14:21.726Z",
|
||||||
|
"updatedAt": "2026-06-04T19:14:21.726Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "proj_e5355c03-983b-4e2f-800c-f57638e9732f",
|
"id": "proj_e5355c03-983b-4e2f-800c-f57638e9732f",
|
||||||
"name": "СКОЛКОВО - LOFT КВАРТАЛ",
|
"name": "СКОЛКОВО - LOFT КВАРТАЛ",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
{
|
||||||
|
"id": "proj_0a284bb5-1c82-48b1-a636-e204578a570a",
|
||||||
|
"name": "DRONE",
|
||||||
|
"type": "project",
|
||||||
|
"createdAt": "2026-06-05T13:33:23.556Z",
|
||||||
|
"updatedAt": "2026-06-20T20:33:51.505Z",
|
||||||
|
"ownerId": null,
|
||||||
|
"shares": [],
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "model",
|
||||||
|
"type": "xkt",
|
||||||
|
"meta": {
|
||||||
|
"label": "Final Assembly.STEP",
|
||||||
|
"metadataPath": null,
|
||||||
|
"settingsSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.STEP",
|
||||||
|
"sourceSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.STEP",
|
||||||
|
"artifactSrc": "uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.xkt"
|
||||||
|
},
|
||||||
|
"visible": true,
|
||||||
|
"src": "http://localhost:8080/uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/Final_Assembly.xkt?v=2026-06-04T18%3A09%3A13.204700%2B00%3A00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewerState": {
|
||||||
|
"camera": {
|
||||||
|
"eye": [
|
||||||
|
-0.3470755265527011,
|
||||||
|
0.20168643298944608,
|
||||||
|
0.34824520869461856
|
||||||
|
],
|
||||||
|
"look": [
|
||||||
|
-0.01809624637167012,
|
||||||
|
-0.014447461123117594,
|
||||||
|
-0.15640939974292462
|
||||||
|
],
|
||||||
|
"up": [
|
||||||
|
0.18441935979386925,
|
||||||
|
0.941253080967919,
|
||||||
|
-0.28289951803002283
|
||||||
|
],
|
||||||
|
"projection": "perspective"
|
||||||
|
},
|
||||||
|
"transparent": true,
|
||||||
|
"edges": true,
|
||||||
|
"addMode": true,
|
||||||
|
"displayMode": "white",
|
||||||
|
"lightingMode": "standard",
|
||||||
|
"navigationAxis": "auto",
|
||||||
|
"zoomSpeed": 52,
|
||||||
|
"selection": {
|
||||||
|
"ids": [],
|
||||||
|
"modelId": null
|
||||||
|
},
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "model",
|
||||||
|
"visible": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transforms": {
|
||||||
|
"model": {
|
||||||
|
"__model__": {
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"design": {
|
||||||
|
"themeVersion": "v5-drone-lime",
|
||||||
|
"bgOuter": "#0f0f0f",
|
||||||
|
"canvasTop": "#1c1c1c",
|
||||||
|
"canvasBottom": "#1c1c1c",
|
||||||
|
"loadingTop": "#1c1c1c",
|
||||||
|
"loadingBottom": "#1c1c1c",
|
||||||
|
"accent": "#ffea00",
|
||||||
|
"accent2": "#ffea00",
|
||||||
|
"templateFill": "#292929",
|
||||||
|
"templateOutline": "#292929",
|
||||||
|
"templateOutlineHover": "#1c1c1c",
|
||||||
|
"projectBtnFill": "#292929",
|
||||||
|
"projectBtnBorder": "#292929",
|
||||||
|
"projectBtnHover": "#1c1c1c",
|
||||||
|
"modalBtnPrimary": "#c4ff67",
|
||||||
|
"modalBtnSecondary": "#292929",
|
||||||
|
"loaderColor": "#c4ff67",
|
||||||
|
"templateGlow": 80,
|
||||||
|
"toolbarBg": "#292929",
|
||||||
|
"toolbarBgActive": "#c4ff67",
|
||||||
|
"toolbarBorder": "#292929",
|
||||||
|
"toolbarOutline": "#292929",
|
||||||
|
"toolbarMinSize": 24,
|
||||||
|
"toolbarMaxSize": 88,
|
||||||
|
"toolbarLensCount": 5,
|
||||||
|
"toolbarAutoHide": false,
|
||||||
|
"modalBg": "#1c1c1c",
|
||||||
|
"modalBgAlpha": 1,
|
||||||
|
"modalFocus": "#292929",
|
||||||
|
"modalFocusAlpha": 0.35,
|
||||||
|
"modalBorder": "#1c1c1c",
|
||||||
|
"modalElement": "#262626",
|
||||||
|
"modalElementOutline": "#262626",
|
||||||
|
"modalRadius": "14px",
|
||||||
|
"border": "#233044",
|
||||||
|
"text": "#e6edf3",
|
||||||
|
"panel": "#1c1c1c",
|
||||||
|
"panel2": "#292929",
|
||||||
|
"highlight": "#c4ff67",
|
||||||
|
"cubeText": "#242424",
|
||||||
|
"cubeEdges": "#e0e7ff",
|
||||||
|
"plus": "#c4ff67",
|
||||||
|
"navCubeBase": "#ffffff",
|
||||||
|
"navCubeHover": "#ffffff",
|
||||||
|
"navCubeText": "#242424",
|
||||||
|
"navCubeEdge": "#e0e7ff",
|
||||||
|
"navCubeEdgeAlpha": 0.6,
|
||||||
|
"measureLine": "#c4ff67",
|
||||||
|
"measureLabel": "#c4ff67",
|
||||||
|
"measureLabelText": "#292929",
|
||||||
|
"measureDot": "#c4ff67",
|
||||||
|
"sectionAxisX": "#ff2b2b",
|
||||||
|
"sectionAxisY": "#35ff4f",
|
||||||
|
"sectionAxisZ": "#285bff",
|
||||||
|
"sectionGizmoDiameter": 1,
|
||||||
|
"sectionGizmoThickness": 1,
|
||||||
|
"sectionPlaneFill": "#606060",
|
||||||
|
"sectionPlaneAlpha": 0.32,
|
||||||
|
"sectionPlaneEdge": "#0d0d0d",
|
||||||
|
"sectionPlaneEdgeAlpha": 0.8,
|
||||||
|
"commentTargetActiveColor": "#c4ff67",
|
||||||
|
"commentTargetActiveAlpha": 1,
|
||||||
|
"commentTargetActiveSize": 45,
|
||||||
|
"commentTargetPassiveColor": "#262626",
|
||||||
|
"commentTargetPassiveAlpha": 0.58,
|
||||||
|
"commentTargetPassiveSize": 28
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"description": "",
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
{
|
||||||
|
"id": "proj_0c1b26f3-020c-4ee9-926c-fedf08003304",
|
||||||
|
"name": "TrackEnsemble_6074_v11",
|
||||||
|
"type": "project",
|
||||||
|
"createdAt": "2026-06-04T19:14:21.726Z",
|
||||||
|
"updatedAt": "2026-06-04T19:14:21.726Z",
|
||||||
|
"ownerId": null,
|
||||||
|
"shares": [],
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "model",
|
||||||
|
"type": "xkt",
|
||||||
|
"meta": {
|
||||||
|
"label": "TV_ensemble complet.STEP",
|
||||||
|
"metadataPath": null
|
||||||
|
},
|
||||||
|
"visible": true,
|
||||||
|
"src": "http://localhost:8080/uploads/nodedc_c2f0a08f-3f98-446a-94e9-c7b91ee95611_32e82d59-f15f-49b1-ba36-de2f8cc9ebc2/TV_ensemble_complet.xkt?v=2026-06-04T18%3A34%3A12.613772%2B00%3A00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewerState": {
|
||||||
|
"camera": {
|
||||||
|
"eye": [
|
||||||
|
0.5620614003131926,
|
||||||
|
0.636732837776035,
|
||||||
|
0.5089379140596013
|
||||||
|
],
|
||||||
|
"look": [
|
||||||
|
0.26556218907312157,
|
||||||
|
0.09433375758008311,
|
||||||
|
0.05622267468342307
|
||||||
|
],
|
||||||
|
"up": [
|
||||||
|
-0.28340934910241067,
|
||||||
|
-0.5184532182367775,
|
||||||
|
0.806774690568114
|
||||||
|
],
|
||||||
|
"projection": "perspective"
|
||||||
|
},
|
||||||
|
"transparent": true,
|
||||||
|
"edges": true,
|
||||||
|
"addMode": true,
|
||||||
|
"displayMode": "white",
|
||||||
|
"lightingMode": "technical",
|
||||||
|
"navigationAxis": "z-up",
|
||||||
|
"selection": {
|
||||||
|
"ids": [
|
||||||
|
"TV_tcapot"
|
||||||
|
],
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -802,62 +802,56 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"design": {
|
"design": {
|
||||||
"themeVersion": "v3-yellow",
|
"themeVersion": "v5-drone-lime",
|
||||||
"bgOuter": "#121319",
|
"bgOuter": "#0f0f0f",
|
||||||
"canvasTop": "#121319",
|
"canvasTop": "#1c1c1c",
|
||||||
"canvasBottom": "#121319",
|
"canvasBottom": "#1c1c1c",
|
||||||
"loadingTop": "#121319",
|
"loadingTop": "#1c1c1c",
|
||||||
"loadingBottom": "#121319",
|
"loadingBottom": "#1c1c1c",
|
||||||
"accent": "#ffea00",
|
"accent": "#ffea00",
|
||||||
"accent2": "#ffea00",
|
"accent2": "#ffea00",
|
||||||
"templateFill": "#1f1f1f",
|
"templateFill": "#292929",
|
||||||
"templateOutline": "#1c1c1c",
|
"templateOutline": "#292929",
|
||||||
"templateOutlineHover": "#2e2e2e",
|
"templateOutlineHover": "#1c1c1c",
|
||||||
"projectBtnFill": "#1c1c1c",
|
"projectBtnFill": "#292929",
|
||||||
"projectBtnBorder": "#1c1c1c",
|
"projectBtnBorder": "#292929",
|
||||||
"projectBtnHover": "#292929",
|
"projectBtnHover": "#1c1c1c",
|
||||||
"modalBtnPrimary": "#befd02",
|
"modalBtnPrimary": "#c4ff67",
|
||||||
"modalBtnSecondary": "#292929",
|
"modalBtnSecondary": "#292929",
|
||||||
"loaderColor": "#befd02",
|
"loaderColor": "#c4ff67",
|
||||||
"templateGlow": 39,
|
"templateGlow": 80,
|
||||||
"toolbarBg": "#1c1c1c",
|
"toolbarBg": "#292929",
|
||||||
"toolbarBgActive": "#befd02",
|
"toolbarBgActive": "#c4ff67",
|
||||||
"toolbarBorder": "#233044",
|
"toolbarBorder": "#292929",
|
||||||
"toolbarOutline": "#befd02",
|
"toolbarOutline": "#292929",
|
||||||
"modalBg": "#1a1a1a",
|
"modalBg": "#1c1c1c",
|
||||||
"modalBgAlpha": 0.95,
|
"modalBgAlpha": 1,
|
||||||
"modalFocus": "#141414",
|
"modalFocus": "#292929",
|
||||||
"modalFocusAlpha": 0.55,
|
"modalFocusAlpha": 0.35,
|
||||||
"modalBorder": "#141414",
|
"modalBorder": "#1c1c1c",
|
||||||
"modalElement": "#141414",
|
"modalElement": "#262626",
|
||||||
"modalElementOutline": "#141414",
|
"modalElementOutline": "#262626",
|
||||||
"modalRadius": "14px",
|
"modalRadius": "14px",
|
||||||
"border": "#233044",
|
"border": "#233044",
|
||||||
"text": "#e6edf3",
|
"text": "#e6edf3",
|
||||||
"panel": "#1c1c1c",
|
"panel": "#1c1c1c",
|
||||||
"panel2": "#292929",
|
"panel2": "#292929",
|
||||||
"highlight": "#befd02",
|
"highlight": "#c4ff67",
|
||||||
"cubeText": "#242424",
|
"cubeText": "#242424",
|
||||||
"cubeEdges": "#e0e7ff",
|
"cubeEdges": "#e0e7ff",
|
||||||
"plus": "#befd02",
|
"plus": "#c4ff67",
|
||||||
"navCubeBase": "#ffffff",
|
"navCubeBase": "#ffffff",
|
||||||
"navCubeHover": "#ffffff",
|
"navCubeHover": "#ffffff",
|
||||||
"navCubeText": "#242424",
|
"navCubeText": "#242424",
|
||||||
"navCubeEdge": "#e0e7ff",
|
"navCubeEdge": "#e0e7ff",
|
||||||
"navCubeEdgeAlpha": 0.6,
|
"navCubeEdgeAlpha": 0.6,
|
||||||
"measureLine": "#befd02",
|
"measureLine": "#c4ff67",
|
||||||
"measureLabel": "#befd02",
|
"measureLabel": "#c4ff67",
|
||||||
"measureLabelText": "#292929",
|
"measureLabelText": "#292929",
|
||||||
"measureDot": "#befd02",
|
"measureDot": "#c4ff67"
|
||||||
"clipPerspectiveNear": 0.01,
|
|
||||||
"clipPerspectiveFar": 100000,
|
|
||||||
"clipOrthoNear": 0.01,
|
|
||||||
"clipOrthoFar": 100000,
|
|
||||||
"clipPerspective": 4000,
|
|
||||||
"clipOrtho": 5000
|
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "",
|
"description": "",
|
||||||
"tags": []
|
"tags": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"design": {
|
"design": {
|
||||||
"themeVersion": "v3-yellow",
|
"themeVersion": "v5-drone-lime",
|
||||||
"bgOuter": "#0f0f0f",
|
"bgOuter": "#0f0f0f",
|
||||||
"canvasTop": "#1c1c1c",
|
"canvasTop": "#1c1c1c",
|
||||||
"canvasBottom": "#1c1c1c",
|
"canvasBottom": "#1c1c1c",
|
||||||
|
|
@ -81,40 +81,48 @@
|
||||||
"loadingBottom": "#1c1c1c",
|
"loadingBottom": "#1c1c1c",
|
||||||
"accent": "#ffea00",
|
"accent": "#ffea00",
|
||||||
"accent2": "#ffea00",
|
"accent2": "#ffea00",
|
||||||
"templateFill": "#1c1c1c",
|
"templateFill": "#292929",
|
||||||
"templateOutline": "#233044",
|
"templateOutline": "#292929",
|
||||||
"templateOutlineHover": "rgba(255,234,0,0.7)",
|
"templateOutlineHover": "#1c1c1c",
|
||||||
|
"projectBtnFill": "#292929",
|
||||||
|
"projectBtnBorder": "#292929",
|
||||||
|
"projectBtnHover": "#1c1c1c",
|
||||||
|
"modalBtnPrimary": "#c4ff67",
|
||||||
|
"modalBtnSecondary": "#292929",
|
||||||
|
"loaderColor": "#c4ff67",
|
||||||
"templateGlow": 80,
|
"templateGlow": 80,
|
||||||
"toolbarBg": "#1c1c1c",
|
"toolbarBg": "#292929",
|
||||||
"toolbarBgActive": "#ffea00",
|
"toolbarBgActive": "#c4ff67",
|
||||||
"toolbarBorder": "#233044",
|
"toolbarBorder": "#292929",
|
||||||
"toolbarOutline": "#ffea00",
|
"toolbarOutline": "#292929",
|
||||||
"modalBg": "#1c1c1c",
|
"modalBg": "#1c1c1c",
|
||||||
|
"modalBgAlpha": 1,
|
||||||
"modalFocus": "#292929",
|
"modalFocus": "#292929",
|
||||||
"modalBorder": "#292929",
|
"modalFocusAlpha": 0.35,
|
||||||
"modalElement": "#292929",
|
"modalBorder": "#1c1c1c",
|
||||||
"modalElementOutline": "#2b2b2b",
|
"modalElement": "#262626",
|
||||||
|
"modalElementOutline": "#262626",
|
||||||
"modalRadius": "14px",
|
"modalRadius": "14px",
|
||||||
"border": "#233044",
|
"border": "#233044",
|
||||||
"text": "#e6edf3",
|
"text": "#e6edf3",
|
||||||
"panel": "#1c1c1c",
|
"panel": "#1c1c1c",
|
||||||
"panel2": "#292929",
|
"panel2": "#292929",
|
||||||
"highlight": "#ffea00",
|
"highlight": "#c4ff67",
|
||||||
"cubeText": "#242424",
|
"cubeText": "#242424",
|
||||||
"cubeEdges": "#e0e7ff",
|
"cubeEdges": "#e0e7ff",
|
||||||
"plus": "#ffea00",
|
"plus": "#c4ff67",
|
||||||
"navCubeBase": "#ffea00",
|
"navCubeBase": "#ffffff",
|
||||||
"navCubeHover": "#ffffff",
|
"navCubeHover": "#ffffff",
|
||||||
"navCubeText": "#242424",
|
"navCubeText": "#242424",
|
||||||
"navCubeEdge": "#e0e7ff",
|
"navCubeEdge": "#e0e7ff",
|
||||||
"navCubeEdgeAlpha": 0.6,
|
"navCubeEdgeAlpha": 0.6,
|
||||||
"measureLine": "#ffea00",
|
"measureLine": "#c4ff67",
|
||||||
"measureLabel": "#ffea00",
|
"measureLabel": "#c4ff67",
|
||||||
"measureLabelText": "#292929",
|
"measureLabelText": "#292929",
|
||||||
"measureDot": "#ffea00"
|
"measureDot": "#c4ff67"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "",
|
"description": "",
|
||||||
"tags": []
|
"tags": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"design": {
|
"design": {
|
||||||
"themeVersion": "v3-yellow",
|
"themeVersion": "v5-drone-lime",
|
||||||
"bgOuter": "#0f0f0f",
|
"bgOuter": "#0f0f0f",
|
||||||
"canvasTop": "#1c1c1c",
|
"canvasTop": "#1c1c1c",
|
||||||
"canvasBottom": "#1c1c1c",
|
"canvasBottom": "#1c1c1c",
|
||||||
|
|
@ -100,48 +100,48 @@
|
||||||
"loadingBottom": "#1c1c1c",
|
"loadingBottom": "#1c1c1c",
|
||||||
"accent": "#ffea00",
|
"accent": "#ffea00",
|
||||||
"accent2": "#ffea00",
|
"accent2": "#ffea00",
|
||||||
"templateFill": "#1c1c1c",
|
"templateFill": "#292929",
|
||||||
"templateOutline": "#233044",
|
"templateOutline": "#292929",
|
||||||
"templateOutlineHover": "rgba(255,234,0,0.7)",
|
"templateOutlineHover": "#1c1c1c",
|
||||||
"projectBtnFill": "#1c1c1c",
|
"projectBtnFill": "#292929",
|
||||||
"projectBtnBorder": "#1c1c1c",
|
"projectBtnBorder": "#292929",
|
||||||
"projectBtnHover": "#292929",
|
"projectBtnHover": "#1c1c1c",
|
||||||
"modalBtnPrimary": "#befd02",
|
"modalBtnPrimary": "#c4ff67",
|
||||||
"modalBtnSecondary": "#292929",
|
"modalBtnSecondary": "#292929",
|
||||||
"loaderColor": "#befd02",
|
"loaderColor": "#c4ff67",
|
||||||
"templateGlow": 80,
|
"templateGlow": 80,
|
||||||
"toolbarBg": "#1c1c1c",
|
"toolbarBg": "#292929",
|
||||||
"toolbarBgActive": "#ffea00",
|
"toolbarBgActive": "#c4ff67",
|
||||||
"toolbarBorder": "#233044",
|
"toolbarBorder": "#292929",
|
||||||
"toolbarOutline": "#ffea00",
|
"toolbarOutline": "#292929",
|
||||||
"modalBg": "#1c1c1c",
|
"modalBg": "#1c1c1c",
|
||||||
"modalBgAlpha": 0.95,
|
"modalBgAlpha": 1,
|
||||||
"modalFocus": "#292929",
|
"modalFocus": "#292929",
|
||||||
"modalFocusAlpha": 0.55,
|
"modalFocusAlpha": 0.35,
|
||||||
"modalBorder": "#292929",
|
"modalBorder": "#1c1c1c",
|
||||||
"modalElement": "#292929",
|
"modalElement": "#262626",
|
||||||
"modalElementOutline": "#2b2b2b",
|
"modalElementOutline": "#262626",
|
||||||
"modalRadius": "14px",
|
"modalRadius": "14px",
|
||||||
"border": "#233044",
|
"border": "#233044",
|
||||||
"text": "#e6edf3",
|
"text": "#e6edf3",
|
||||||
"panel": "#1c1c1c",
|
"panel": "#1c1c1c",
|
||||||
"panel2": "#292929",
|
"panel2": "#292929",
|
||||||
"highlight": "#ffea00",
|
"highlight": "#c4ff67",
|
||||||
"cubeText": "#242424",
|
"cubeText": "#242424",
|
||||||
"cubeEdges": "#e0e7ff",
|
"cubeEdges": "#e0e7ff",
|
||||||
"plus": "#ffea00",
|
"plus": "#c4ff67",
|
||||||
"navCubeBase": "#ffea00",
|
"navCubeBase": "#ffffff",
|
||||||
"navCubeHover": "#ffffff",
|
"navCubeHover": "#ffffff",
|
||||||
"navCubeText": "#242424",
|
"navCubeText": "#242424",
|
||||||
"navCubeEdge": "#e0e7ff",
|
"navCubeEdge": "#e0e7ff",
|
||||||
"navCubeEdgeAlpha": 0.6,
|
"navCubeEdgeAlpha": 0.6,
|
||||||
"measureLine": "#ffea00",
|
"measureLine": "#c4ff67",
|
||||||
"measureLabel": "#ffea00",
|
"measureLabel": "#c4ff67",
|
||||||
"measureLabelText": "#292929",
|
"measureLabelText": "#292929",
|
||||||
"measureDot": "#ffea00"
|
"measureDot": "#c4ff67"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"description": "",
|
"description": "",
|
||||||
"tags": []
|
"tags": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3019
server/index.js
3019
server/index.js
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue