Add BIM section plane tool

This commit is contained in:
CODEX 2026-06-18 21:53:18 +03:00
parent 4638acc04b
commit e343412468
3 changed files with 327 additions and 3 deletions

View File

@ -1911,11 +1911,102 @@ header {
background: var(--toolbar-bg-active); background: var(--toolbar-bg-active);
} }
.tb-btn.disabled,
.tb-btn:disabled {
cursor: default;
opacity: 0.45;
transform: none;
box-shadow: none;
}
body[data-viewer-mode="guest"] .bottom-toolbar, body[data-viewer-mode="guest"] .bottom-toolbar,
body[data-viewer-mode="guest"] .section-toolbar,
body[data-viewer-mode="guest"] .section-overview,
body[data-viewer-mode="guest"] .inspector-restore-tray { body[data-viewer-mode="guest"] .inspector-restore-tray {
display: none !important; display: none !important;
} }
.section-toolbar {
position: fixed;
left: calc(12px + var(--tb-size, 52px) + 10px);
bottom: 12px;
z-index: 16;
display: flex;
align-items: center;
gap: 6px;
padding: 6px;
border: 1px solid var(--toolbar-border);
border-radius: var(--tb-radius, 12px);
background: color-mix(in srgb, var(--toolbar-bg) 88%, transparent);
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px) saturate(124%);
-webkit-backdrop-filter: blur(18px) saturate(124%);
}
.section-toolbar.hidden {
display: none;
}
.section-tool-btn {
width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
border: 1px solid transparent;
border-radius: calc(var(--tb-radius, 12px) - 4px);
background: transparent;
color: var(--text);
font-size: 13px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, transform 100ms ease;
}
.section-tool-btn:hover,
.section-tool-btn.active {
border-color: var(--toolbar-outline, rgba(76, 224, 210, 0.7));
background: var(--toolbar-bg-active);
color: var(--modal-btn-primary-contrast, #15170f);
}
.section-tool-btn:active {
transform: translateY(1px);
}
.section-tool-btn svg {
display: block;
width: 21px;
height: 21px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.section-axis-btn {
letter-spacing: 0;
}
.section-overview {
position: fixed;
left: calc(12px + var(--tb-size, 52px) + 10px);
bottom: 66px;
width: 150px;
height: 150px;
z-index: 14;
display: none;
border: 1px solid var(--toolbar-border);
border-radius: 12px;
background: color-mix(in srgb, var(--toolbar-bg) 84%, transparent);
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.3);
}
body[data-section-mode="active"] .section-overview {
display: block;
}
.templates-panel { .templates-panel {
position: fixed; position: fixed;
left: var(--templates-left, 72px); left: var(--templates-left, 72px);

View File

@ -39,6 +39,9 @@ const measureButton = document.querySelector('[data-action="measure"]');
const settingsButton = document.querySelector('[data-action="settings"]'); const settingsButton = document.querySelector('[data-action="settings"]');
const templatesButton = document.querySelector('[data-action="templates"]'); const templatesButton = document.querySelector('[data-action="templates"]');
const shareButton = document.querySelector('[data-action="share"]'); const shareButton = document.querySelector('[data-action="share"]');
const sectionButton = document.querySelector('[data-action="section"]');
const sectionToolbar = document.getElementById("sectionToolbar");
const sectionOverviewCanvas = document.getElementById("sectionPlanesOverview");
const projectionToggle = document.querySelector('[data-action="projection"]'); const projectionToggle = document.querySelector('[data-action="projection"]');
const treeContainer = document.getElementById("treeContainer"); const treeContainer = document.getElementById("treeContainer");
const displayModeControl = document.getElementById("displayModeControl"); const displayModeControl = document.getElementById("displayModeControl");
@ -250,6 +253,11 @@ let measurePlugin = null;
let measureControl = null; let measureControl = null;
let measureActive = false; let measureActive = false;
let activeMeasurementId = null; let activeMeasurementId = null;
let sectionPlanesPlugin = null;
let activeSectionPlaneId = null;
let sectionPlaneCounter = 0;
let sectionModeActive = false;
let activeSectionAxis = null;
let objectContextModal = null; let objectContextModal = null;
let mathUtil = null; let mathUtil = null;
let selectionState = null; // { ids, modelId, pivotCenter, baseTransforms: Map, transform } let selectionState = null; // { ids, modelId, pivotCenter, baseTransforms: Map, transform }
@ -454,6 +462,9 @@ const applyRuntimeMode = () => {
if (dropZone) { if (dropZone) {
dropZone.style.pointerEvents = isGuestMode() ? "none" : ""; dropZone.style.pointerEvents = isGuestMode() ? "none" : "";
} }
if (sectionToolbar && isGuestMode()) {
sectionToolbar.classList.add("hidden");
}
}; };
const initializeRuntimeMode = async () => { const initializeRuntimeMode = async () => {
@ -1171,6 +1182,155 @@ const clearMeasurements = () => {
closeMeasurementModal(); closeMeasurementModal();
}; };
const getSectionPlaneIds = () => Object.keys(sectionPlanesPlugin?.sectionPlanes || {});
const getActiveSectionPlane = () => {
if (!sectionPlanesPlugin) return null;
if (activeSectionPlaneId && sectionPlanesPlugin.sectionPlanes?.[activeSectionPlaneId]) {
return sectionPlanesPlugin.sectionPlanes[activeSectionPlaneId];
}
const firstId = getSectionPlaneIds()[0] || null;
activeSectionPlaneId = firstId;
return firstId ? sectionPlanesPlugin.sectionPlanes[firstId] : null;
};
const setAllSectionPlanesActive = (active) => {
getSectionPlaneIds().forEach((id) => {
const sectionPlane = sectionPlanesPlugin?.sectionPlanes?.[id];
if (sectionPlane) {
sectionPlane.active = !!active;
}
});
};
const getSectionCenter = () => {
const sceneAABB = viewer?.scene?.aabb;
const aabb = Array.isArray(sceneAABB) || ArrayBuffer.isView(sceneAABB)
? Array.from(sceneAABB)
: null;
const center = centerFromAABB(aabb);
return center.every((value) => Number.isFinite(value)) ? center : [0, 0, 0];
};
const getCameraSectionDir = () => {
const eye = snapshotVec(viewer?.camera?.eye) || [0, 0, 1];
const look = snapshotVec(viewer?.camera?.look) || [0, 0, 0];
return normalizeVec3([
look[0] - eye[0],
look[1] - eye[1],
look[2] - eye[2],
], [0, 0, -1]);
};
const setSectionAxisActive = (axis) => {
activeSectionAxis = axis || null;
sectionToolbar?.querySelectorAll("[data-section-action]").forEach((btn) => {
btn.classList.toggle("active", !!axis && btn.dataset.sectionAction === axis);
});
};
const setSectionModeActive = (active) => {
sectionModeActive = !!active && !!sectionPlanesPlugin && getSectionPlaneIds().length > 0 && !isGuestMode();
sectionButton?.classList.toggle("active", sectionModeActive);
sectionToolbar?.classList.toggle("hidden", !sectionModeActive);
document.body.dataset.sectionMode = sectionModeActive ? "active" : "";
sectionPlanesPlugin?.setOverviewVisible?.(sectionModeActive);
if (!sectionModeActive) {
setSectionAxisActive(null);
sectionPlanesPlugin?.hideControl?.();
}
};
const updateSectionControls = () => {
const available = !!sectionPlanesPlugin && !isGuestMode() && activeModels.length > 0;
if (sectionButton) {
sectionButton.disabled = !available;
sectionButton.classList.toggle("disabled", !available);
sectionButton.classList.toggle("active", available && sectionModeActive);
}
if (!available) {
sectionToolbar?.classList.add("hidden");
document.body.dataset.sectionMode = "";
sectionPlanesPlugin?.setOverviewVisible?.(false);
}
};
const createOrUpdateSectionPlane = (axis = "camera") => {
if (!sectionPlanesPlugin) {
showError("Разрез недоступен в текущей сборке SDK");
return null;
}
if (!activeModels.length) {
showError("Сначала загрузите модель");
return null;
}
setMeasureActive(false);
const dirs = {
camera: getCameraSectionDir(),
x: [1, 0, 0],
y: [0, 1, 0],
z: [0, 0, 1],
};
const dir = dirs[axis] || dirs.camera;
const pos = getSectionCenter();
let sectionPlane = getActiveSectionPlane();
if (!sectionPlane) {
const id = `sectionPlane-${++sectionPlaneCounter}`;
sectionPlane = sectionPlanesPlugin.createSectionPlane({ id, pos, dir });
activeSectionPlaneId = sectionPlane.id || id;
} else {
sectionPlane.pos = pos;
sectionPlane.dir = dir;
sectionPlane.active = true;
activeSectionPlaneId = sectionPlane.id || activeSectionPlaneId;
}
sectionPlanesPlugin.showControl(activeSectionPlaneId);
setSectionModeActive(true);
setSectionAxisActive(axis);
setStatus("Разрез включён");
rearmCameraControl();
return sectionPlane;
};
const clearSectionPlanes = () => {
sectionPlanesPlugin?.clear?.();
activeSectionPlaneId = null;
setSectionModeActive(false);
};
const flipSectionPlanes = () => {
if (!getActiveSectionPlane()) {
createOrUpdateSectionPlane("camera");
return;
}
setAllSectionPlanesActive(true);
sectionPlanesPlugin?.flipSectionPlanes?.();
sectionPlanesPlugin?.showControl?.(activeSectionPlaneId);
setSectionModeActive(true);
setStatus("Разрез инвертирован");
};
const toggleSectionPlanes = () => {
if (sectionModeActive) {
setAllSectionPlanesActive(false);
setSectionModeActive(false);
setStatus("Разрез выключен");
return;
}
if (getSectionPlaneIds().length > 0) {
setAllSectionPlanesActive(true);
activeSectionPlaneId = activeSectionPlaneId || getSectionPlaneIds()[0];
sectionPlanesPlugin?.showControl?.(activeSectionPlaneId);
setSectionModeActive(true);
setStatus("Разрез включён");
return;
}
createOrUpdateSectionPlane("camera");
};
const rebuildNavCube = (sdk) => { const rebuildNavCube = (sdk) => {
if (!navCubeCanvas || !viewer) return; if (!navCubeCanvas || !viewer) return;
const cubeColor = cssValue("--cube-base", "#7b3fff"); const cubeColor = cssValue("--cube-base", "#7b3fff");
@ -1257,6 +1417,7 @@ const updatePanelsVisibility = () => {
projectNameInput.value = ""; projectNameInput.value = "";
} }
} }
updateSectionControls();
applyRuntimeMode(); applyRuntimeMode();
}; };
@ -1990,6 +2151,7 @@ const destroyAllModels = ({ preserveMeta = false } = {}) => {
gizmo?.clearTarget(); gizmo?.clearTarget();
treeView?.unShowNode?.(); treeView?.unShowNode?.();
clearMeasurements(); clearMeasurements();
clearSectionPlanes();
selectionState = null; selectionState = null;
lastSelection = { ids: [], modelId: null }; lastSelection = { ids: [], modelId: null };
activeTransformTarget = null; activeTransformTarget = null;
@ -2852,7 +3014,7 @@ const initViewer = async () => {
const sdk = await import(SDK_URL); const sdk = await import(SDK_URL);
sdkRef = sdk; sdkRef = sdk;
const { Viewer, NavCubePlugin, DistanceMeasurementsPlugin, DistanceMeasurementsMouseControl } = sdk; const { Viewer, NavCubePlugin, DistanceMeasurementsPlugin, DistanceMeasurementsMouseControl, SectionPlanesPlugin } = sdk;
mathUtil = sdk.math; mathUtil = sdk.math;
if (headerEl && homeButton && !homeButton.querySelector(".ue-logo")) { if (headerEl && homeButton && !homeButton.querySelector(".ue-logo")) {
const logoEl = createLogo(); const logoEl = createLogo();
@ -2931,6 +3093,22 @@ const initViewer = async () => {
} }
} }
if (SectionPlanesPlugin && sectionOverviewCanvas) {
sectionPlanesPlugin = new SectionPlanesPlugin(viewer, {
overviewCanvasId: "sectionPlanesOverview",
overviewVisible: false,
});
sectionPlanesPlugin.setOverviewVisible(false);
setSectionModeActive(false);
} else {
console.warn("SectionPlanesPlugin is not available in the loaded SDK build");
if (sectionButton) {
sectionButton.classList.add("disabled");
sectionButton.disabled = true;
sectionButton.title = "Разрез недоступен";
}
}
if (DistanceMeasurementsPlugin && DistanceMeasurementsMouseControl) { if (DistanceMeasurementsPlugin && DistanceMeasurementsMouseControl) {
measurePlugin = new DistanceMeasurementsPlugin(viewer, { measurePlugin = new DistanceMeasurementsPlugin(viewer, {
defaultAxisVisible: false, defaultAxisVisible: false,
@ -4308,6 +4486,8 @@ const loadProjectSnapshot = async (project) => {
} else if (action === "measure") { } else if (action === "measure") {
if (!measurePlugin || !measureControl) return; if (!measurePlugin || !measureControl) return;
setMeasureActive(!measureActive); setMeasureActive(!measureActive);
} else if (action === "section") {
toggleSectionPlanes();
} else if (action === "projection") { } else if (action === "projection") {
toggleProjection(); toggleProjection();
} else if (action === "share") { } else if (action === "share") {
@ -4318,6 +4498,21 @@ const loadProjectSnapshot = async (project) => {
}); });
} }
sectionToolbar?.addEventListener("click", (e) => {
if (isGuestMode()) return;
const btn = e.target.closest("[data-section-action]");
if (!btn) return;
const action = btn.dataset.sectionAction;
if (action === "camera" || action === "x" || action === "y" || action === "z") {
createOrUpdateSectionPlane(action);
} else if (action === "flip") {
flipSectionPlanes();
} else if (action === "clear") {
clearSectionPlanes();
setStatus("Разрез выключен");
}
});
function updateTransformInputs(transform) { function updateTransformInputs(transform) {
const empty = { const empty = {
position: ["", "", ""], position: ["", "", ""],

View File

@ -89,7 +89,7 @@
<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=17"> <link rel="stylesheet" href="./dcViewer.css?v=18">
</head> </head>
<body> <body>
<header> <header>
@ -107,6 +107,7 @@
<section class="viewer-wrapper"> <section class="viewer-wrapper">
<canvas id="viewerCanvas"></canvas> <canvas id="viewerCanvas"></canvas>
<canvas id="navCube"></canvas> <canvas id="navCube"></canvas>
<canvas id="sectionPlanesOverview" class="section-overview" width="150" height="150"></canvas>
<div class="drop-hint" id="dropZone"> <div class="drop-hint" id="dropZone">
<div class="drop-center"> <div class="drop-center">
<div class="drop-plus">+</div> <div class="drop-plus">+</div>
@ -292,6 +293,14 @@
<path d="m13 7 2 2"></path> <path d="m13 7 2 2"></path>
</svg> </svg>
</button> </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="projection" title="Перспектива / аксонометрия">A</button> <button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button>
<button class="tb-btn" data-action="share" title="Поделиться моделью" aria-label="Поделиться моделью"> <button class="tb-btn" data-action="share" title="Поделиться моделью" aria-label="Поделиться моделью">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
@ -308,9 +317,38 @@
</button> </button>
</div> </div>
<div id="sectionToolbar" class="section-toolbar hidden" aria-label="Управление разрезом">
<button class="section-tool-btn" data-section-action="camera" type="button" title="По камере" aria-label="По камере">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.5 12s2.75-5 7.5-5 7.5 5 7.5 5-2.75 5-7.5 5-7.5-5-7.5-5Z"></path>
<path d="M12 14.75A2.75 2.75 0 1 0 12 9.25a2.75 2.75 0 0 0 0 5.5Z"></path>
</svg>
</button>
<button class="section-tool-btn section-axis-btn" data-section-action="x" type="button" title="Ось X" aria-label="Ось X">X</button>
<button class="section-tool-btn section-axis-btn" data-section-action="y" type="button" title="Ось Y" aria-label="Ось Y">Y</button>
<button class="section-tool-btn section-axis-btn" data-section-action="z" type="button" title="Ось Z" aria-label="Ось Z">Z</button>
<button class="section-tool-btn" data-section-action="flip" type="button" title="Инвертировать" aria-label="Инвертировать">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M7.5 7.5h8a3.5 3.5 0 0 1 0 7h-7"></path>
<path d="m10.5 4.5-3 3 3 3"></path>
<path d="m13.5 19.5 3-3-3-3"></path>
</svg>
</button>
<button class="section-tool-btn" data-section-action="clear" type="button" title="Удалить разрез" aria-label="Удалить разрез">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 7h14"></path>
<path d="M9 7V5h6v2"></path>
<path d="M8 10v8"></path>
<path d="M12 10v8"></path>
<path d="M16 10v8"></path>
<path d="M7 7l1 13h8l1-13"></path>
</svg>
</button>
</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=22"></script> <script type="module" src="./dcViewer.js?v=23"></script>
</body> </body>
</html> </html>