mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
1161 lines
42 KiB
JavaScript
1161 lines
42 KiB
JavaScript
|
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
import { THREE, OBC, JSZip } from "/assets/viewer-deps.js";
|
||
|
|
import { parseIfcUrl, toRawUrl, buildIfcUrl as _buildIfcUrl } from "./viewer-url.js";
|
||
|
|
|
||
|
|
const params = new URLSearchParams(window.location.search);
|
||
|
|
const ifcUrl = params.get("url") ?? "";
|
||
|
|
const statusEl = document.getElementById("status");
|
||
|
|
const urlInput = document.getElementById("url-input");
|
||
|
|
const fovInput = document.getElementById("fov-input");
|
||
|
|
const toolbar = document.getElementById("toolbar");
|
||
|
|
const repoInput = document.getElementById("repo-input");
|
||
|
|
const refInput = document.getElementById("ref-input");
|
||
|
|
const pathInput = document.getElementById("path-input");
|
||
|
|
const selectorInput = document.getElementById("selector-input");
|
||
|
|
const copyUrlBtn = document.getElementById("copy-url-btn");
|
||
|
|
const newIssueBtn = document.getElementById("new-issue-btn");
|
||
|
|
const visibilitySelect = document.getElementById("visibility-select");
|
||
|
|
const visibilityField = document.getElementById("visibility-field");
|
||
|
|
const propsPanel = document.getElementById("props-panel");
|
||
|
|
const propsType = document.getElementById("props-type");
|
||
|
|
const propsName = document.getElementById("props-name");
|
||
|
|
const propsGuid = document.getElementById("props-guid");
|
||
|
|
const propsCopyBtn = document.getElementById("props-copy-btn");
|
||
|
|
const propsCloseBtn = document.getElementById("props-close-btn");
|
||
|
|
|
||
|
|
// Keep viewer below the full toolbar height.
|
||
|
|
const resizeObs = new ResizeObserver(() => {
|
||
|
|
document.documentElement.style.setProperty("--toolbar-h", toolbar.offsetHeight + "px");
|
||
|
|
});
|
||
|
|
resizeObs.observe(toolbar);
|
||
|
|
|
||
|
|
let cameraControls = null;
|
||
|
|
let threeCamera = null;
|
||
|
|
let activeClipper = null;
|
||
|
|
let loadedModel = null;
|
||
|
|
let rendererCanvas = null;
|
||
|
|
|
||
|
|
// Show previous-view snapshot while the new model loads.
|
||
|
|
{
|
||
|
|
const snap = sessionStorage.getItem("viewerSnapshot");
|
||
|
|
if (snap && ifcUrl) {
|
||
|
|
const el = document.createElement("div");
|
||
|
|
el.id = "snapshot-overlay";
|
||
|
|
el.style.backgroundImage = `url("${snap}")`;
|
||
|
|
document.body.appendChild(el);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function removeSnapshot() {
|
||
|
|
const el = document.getElementById("snapshot-overlay");
|
||
|
|
if (!el) return;
|
||
|
|
el.style.opacity = "0";
|
||
|
|
setTimeout(() => el.remove(), 550);
|
||
|
|
sessionStorage.removeItem("viewerSnapshot");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build an ifc:// URL from the structured form fields.
|
||
|
|
function buildIfcUrl() {
|
||
|
|
return _buildIfcUrl(
|
||
|
|
repoInput.value.trim(), refInput.value.trim(),
|
||
|
|
pathInput.value.trim(), selectorInput.value.trim()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Populate structured fields from the current ifcUrl.
|
||
|
|
const parsed = parseIfcUrl(ifcUrl);
|
||
|
|
if (parsed) {
|
||
|
|
repoInput.value = parsed.repo;
|
||
|
|
refInput.value = parsed.ref;
|
||
|
|
pathInput.value = parsed.path;
|
||
|
|
selectorInput.value = parsed.selector;
|
||
|
|
}
|
||
|
|
urlInput.value = ifcUrl;
|
||
|
|
|
||
|
|
// Initialise visibility dropdown from URL and show it when selector is non-empty.
|
||
|
|
{
|
||
|
|
const qIdx = ifcUrl.indexOf("?");
|
||
|
|
const visVal = qIdx < 0 ? "highlight"
|
||
|
|
: (new URLSearchParams(ifcUrl.slice(qIdx + 1)).get("visibility") ?? "highlight");
|
||
|
|
visibilitySelect.value = visVal;
|
||
|
|
}
|
||
|
|
function syncVisibilityField() {
|
||
|
|
visibilityField.style.display = selectorInput.value.trim() ? "" : "none";
|
||
|
|
}
|
||
|
|
selectorInput.addEventListener("input", syncVisibilityField);
|
||
|
|
syncVisibilityField();
|
||
|
|
visibilitySelect.addEventListener("change", loadFromFields);
|
||
|
|
|
||
|
|
// Show commit-pin indicator when the ref is a bare commit hash.
|
||
|
|
const refBadge = document.getElementById("ref-badge");
|
||
|
|
const refSwitchBtn = document.getElementById("ref-switch-btn");
|
||
|
|
if (parsed && isCommitHash(parsed.ref)) {
|
||
|
|
refBadge.textContent = parsed.ref.slice(0, 7);
|
||
|
|
refBadge.style.display = "";
|
||
|
|
if (parsed.host && parsed.repoSuffix) {
|
||
|
|
fetchBranchForCommit(parsed.host, parsed.repoSuffix, parsed.ref)
|
||
|
|
.then(branchName => {
|
||
|
|
const targetRef = `heads/${branchName}`;
|
||
|
|
refSwitchBtn.textContent = `→ ${targetRef}`;
|
||
|
|
refSwitchBtn.style.display = "";
|
||
|
|
refSwitchBtn.addEventListener("click", () => {
|
||
|
|
const qI = ifcUrl.indexOf("?");
|
||
|
|
const base = qI < 0 ? ifcUrl : ifcUrl.slice(0, qI);
|
||
|
|
const tail = qI < 0 ? "" : ifcUrl.slice(qI);
|
||
|
|
loadUrl(base.slice(0, base.lastIndexOf("@") + 1) + targetRef + tail);
|
||
|
|
});
|
||
|
|
})
|
||
|
|
.catch(() => { /* silent — badge alone is sufficient */ });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Populate ref datalist in background (non-blocking).
|
||
|
|
if (parsed?.host && parsed?.repoSuffix) {
|
||
|
|
populateRefList(parsed.host, parsed.repoSuffix).catch(() => { /* silent */ });
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Navigate to the viewer with a different ifc:// URL.
|
||
|
|
// Captures a canvas snapshot for the transition overlay, then fades out.
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
function loadUrl(url) {
|
||
|
|
if (rendererCanvas) {
|
||
|
|
try {
|
||
|
|
const snap = rendererCanvas.toDataURL("image/jpeg", 0.6);
|
||
|
|
sessionStorage.setItem("viewerSnapshot", snap);
|
||
|
|
// Cover the 3D view with the snapshot NOW, before the page unloads,
|
||
|
|
// so the browser's navigation blank is hidden behind the static image.
|
||
|
|
const cover = document.createElement("div");
|
||
|
|
cover.style.cssText = "position:fixed;inset:0;z-index:9999;" +
|
||
|
|
"background:#1c1c1e center/contain no-repeat";
|
||
|
|
cover.style.backgroundImage = `url("${snap}")`;
|
||
|
|
document.body.appendChild(cover);
|
||
|
|
} catch (_) { /* context-lost or cross-origin — skip */ }
|
||
|
|
}
|
||
|
|
const pageUrl = new URL(window.location.href);
|
||
|
|
pageUrl.searchParams.set("url", url);
|
||
|
|
window.location.href = pageUrl.toString();
|
||
|
|
}
|
||
|
|
|
||
|
|
urlInput.addEventListener("keydown", e => {
|
||
|
|
if (e.key === "Enter") { const v = urlInput.value.trim(); if (v) loadUrl(v); }
|
||
|
|
});
|
||
|
|
|
||
|
|
// Build URL from structured fields, preserving camera/clip/fov/scale from
|
||
|
|
// the current URL so that ref/path changes keep the viewer's camera state.
|
||
|
|
function loadFromFields() {
|
||
|
|
if (threeCamera) syncCameraUrl(threeCamera);
|
||
|
|
const structural = buildIfcUrl();
|
||
|
|
if (!structural) return;
|
||
|
|
const current = urlInput.value.trim();
|
||
|
|
const qI = current.indexOf("?");
|
||
|
|
if (qI < 0) { loadUrl(structural); return; }
|
||
|
|
const currentQs = new URLSearchParams(current.slice(qI + 1));
|
||
|
|
const qI2 = structural.indexOf("?");
|
||
|
|
const structBase = qI2 < 0 ? structural : structural.slice(0, qI2);
|
||
|
|
const structQs = new URLSearchParams(qI2 < 0 ? "" : structural.slice(qI2 + 1));
|
||
|
|
for (const key of ["camera", "fov", "scale"]) {
|
||
|
|
const v = currentQs.get(key);
|
||
|
|
if (v) structQs.set(key, v); else structQs.delete(key);
|
||
|
|
}
|
||
|
|
structQs.delete("clip");
|
||
|
|
for (const v of currentQs.getAll("clip")) structQs.append("clip", v);
|
||
|
|
// Include visibility when a selector is set; omit for the default (highlight).
|
||
|
|
if (selectorInput.value.trim()) {
|
||
|
|
const vis = visibilitySelect.value;
|
||
|
|
if (vis && vis !== "highlight") structQs.set("visibility", vis);
|
||
|
|
else structQs.delete("visibility");
|
||
|
|
} else {
|
||
|
|
structQs.delete("visibility");
|
||
|
|
}
|
||
|
|
const qsStr = structQs.toString()
|
||
|
|
.replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$");
|
||
|
|
loadUrl(structBase + (qsStr ? "?" + qsStr : ""));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Pressing Enter in any structured field rebuilds the URL and navigates.
|
||
|
|
for (const el of [repoInput, refInput, pathInput, selectorInput]) {
|
||
|
|
el.addEventListener("keydown", e => { if (e.key === "Enter") loadFromFields(); });
|
||
|
|
}
|
||
|
|
|
||
|
|
// ▾ button: if the ref field already has a value, navigate; otherwise clear
|
||
|
|
// + focus so the datalist shows all refs.
|
||
|
|
const refPickBtn = document.getElementById("ref-pick-btn");
|
||
|
|
refPickBtn.addEventListener("click", () => {
|
||
|
|
if (refInput.value.trim()) { loadFromFields(); return; }
|
||
|
|
refInput.value = "";
|
||
|
|
refInput.focus();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Auto-navigate when user selects a datalist option (exact match).
|
||
|
|
// Listen to both input and change events for cross-browser coverage.
|
||
|
|
function onRefInputChange() {
|
||
|
|
const dl = document.getElementById("ref-list");
|
||
|
|
const known = [...dl.options].map(o => o.value);
|
||
|
|
if (known.includes(refInput.value.trim())) loadFromFields();
|
||
|
|
}
|
||
|
|
refInput.addEventListener("input", onRefInputChange);
|
||
|
|
refInput.addEventListener("change", onRefInputChange);
|
||
|
|
|
||
|
|
// Disable camera controls while any input has focus.
|
||
|
|
function onInputFocus() { if (cameraControls) cameraControls.enabled = false; }
|
||
|
|
function onInputBlur() { if (cameraControls) cameraControls.enabled = true; }
|
||
|
|
const metaBtn = document.getElementById("meta-btn");
|
||
|
|
const metaPanel = document.getElementById("meta-panel");
|
||
|
|
metaBtn.addEventListener("click", () => {
|
||
|
|
const open = metaPanel.style.display === "none";
|
||
|
|
metaPanel.style.display = open ? "" : "none";
|
||
|
|
metaBtn.classList.toggle("active", open);
|
||
|
|
});
|
||
|
|
|
||
|
|
const bcfBtn = document.getElementById("bcf-btn");
|
||
|
|
const bcfRow = document.getElementById("bcf-row");
|
||
|
|
const bcfTitleIn = document.getElementById("bcf-title");
|
||
|
|
const bcfCommentIn = document.getElementById("bcf-comment");
|
||
|
|
const bcfCancelBtn = document.getElementById("bcf-cancel-btn");
|
||
|
|
const bcfExportBtn = document.getElementById("bcf-export-btn");
|
||
|
|
const bcfImportBtn = document.getElementById("bcf-import-btn");
|
||
|
|
const bcfImportInput = document.getElementById("bcf-import-input");
|
||
|
|
|
||
|
|
function bcfPanelOpen(open) {
|
||
|
|
bcfRow.style.display = open ? "" : "none";
|
||
|
|
bcfBtn.classList.toggle("active", open);
|
||
|
|
}
|
||
|
|
|
||
|
|
bcfBtn.addEventListener("click", () => bcfPanelOpen(bcfRow.style.display === "none"));
|
||
|
|
bcfCancelBtn.addEventListener("click", () => bcfPanelOpen(false));
|
||
|
|
bcfExportBtn.addEventListener("click", async () => {
|
||
|
|
await generateBcf(bcfTitleIn.value.trim(), bcfCommentIn.value.trim());
|
||
|
|
bcfPanelOpen(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
bcfImportBtn.addEventListener("click", () => bcfImportInput.click());
|
||
|
|
bcfImportInput.addEventListener("change", async () => {
|
||
|
|
const file = bcfImportInput.files[0];
|
||
|
|
if (file) await importBcf(file);
|
||
|
|
bcfImportInput.value = "";
|
||
|
|
});
|
||
|
|
|
||
|
|
copyUrlBtn.addEventListener("click", async () => {
|
||
|
|
if (threeCamera) syncCameraUrl(threeCamera);
|
||
|
|
const url = urlInput.value.trim();
|
||
|
|
if (!url) return;
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.writeText(url);
|
||
|
|
const saved = copyUrlBtn.textContent;
|
||
|
|
copyUrlBtn.textContent = "✓";
|
||
|
|
setTimeout(() => { copyUrlBtn.textContent = saved; }, 1500);
|
||
|
|
} catch {
|
||
|
|
urlInput.select();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
newIssueBtn.addEventListener("click", () => {
|
||
|
|
if (threeCamera) syncCameraUrl(threeCamera);
|
||
|
|
const src = urlInput.value.trim();
|
||
|
|
if (!src) return;
|
||
|
|
const p = parseIfcUrl(src);
|
||
|
|
if (!p || !p.host || !p.repoSuffix) return;
|
||
|
|
const title = p.path ? `IFC view: ${p.path}` : "IFC view";
|
||
|
|
const issueUrl = `${window.location.origin}/${p.repoSuffix}/issues/new` +
|
||
|
|
`?title=${encodeURIComponent(title)}&body=${encodeURIComponent(src)}`;
|
||
|
|
window.open(issueUrl, "_blank", "noopener");
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById("fit-btn").addEventListener("click", () => {
|
||
|
|
if (cameraControls && loadedModel && !loadedModel.box.isEmpty())
|
||
|
|
cameraControls.fitToBox(loadedModel.box, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
propsCloseBtn.addEventListener("click", () => { propsPanel.style.display = "none"; });
|
||
|
|
propsCopyBtn.addEventListener("click", async () => {
|
||
|
|
const guid = propsGuid.textContent;
|
||
|
|
if (!guid) return;
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.writeText(guid);
|
||
|
|
const saved = propsCopyBtn.textContent;
|
||
|
|
propsCopyBtn.textContent = "✓";
|
||
|
|
setTimeout(() => { propsCopyBtn.textContent = saved; }, 1500);
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
});
|
||
|
|
|
||
|
|
const allInputs = [urlInput, fovInput, repoInput, refInput, pathInput, selectorInput,
|
||
|
|
visibilitySelect, bcfTitleIn, bcfCommentIn];
|
||
|
|
for (const el of allInputs) {
|
||
|
|
el.addEventListener("focus", onInputFocus);
|
||
|
|
el.addEventListener("blur", onInputBlur);
|
||
|
|
el.addEventListener("pointerdown", e => e.stopPropagation());
|
||
|
|
el.addEventListener("mousedown", e => e.stopPropagation());
|
||
|
|
}
|
||
|
|
refPickBtn.addEventListener("pointerdown", e => e.stopPropagation());
|
||
|
|
refPickBtn.addEventListener("mousedown", e => e.stopPropagation());
|
||
|
|
|
||
|
|
fovInput.addEventListener("change", () => {
|
||
|
|
if (!threeCamera?.isPerspectiveCamera) return;
|
||
|
|
const v = parseFloat(fovInput.value);
|
||
|
|
if (isNaN(v)) return;
|
||
|
|
threeCamera.fov = Math.max(10, Math.min(120, v));
|
||
|
|
threeCamera.updateProjectionMatrix();
|
||
|
|
syncCameraUrl(threeCamera);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Drag-and-drop: accept ifc:// URLs or viewer page URLs.
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
document.body.addEventListener("dragover", e => {
|
||
|
|
e.preventDefault();
|
||
|
|
document.body.classList.add("drag-over");
|
||
|
|
});
|
||
|
|
document.body.addEventListener("dragleave", e => {
|
||
|
|
if (!e.relatedTarget) document.body.classList.remove("drag-over");
|
||
|
|
});
|
||
|
|
document.body.addEventListener("drop", async e => {
|
||
|
|
e.preventDefault();
|
||
|
|
document.body.classList.remove("drag-over");
|
||
|
|
// Check for .bcf file drops first.
|
||
|
|
for (const file of (e.dataTransfer.files || [])) {
|
||
|
|
if (file.name.toLowerCase().endsWith(".bcf")) {
|
||
|
|
await importBcf(file);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const candidates = [
|
||
|
|
e.dataTransfer.getData("text/uri-list"),
|
||
|
|
e.dataTransfer.getData("text/plain"),
|
||
|
|
];
|
||
|
|
for (const blob of candidates) {
|
||
|
|
for (const line of (blob || "").split(/\r?\n/)) {
|
||
|
|
const t = line.trim();
|
||
|
|
if (!t || t.startsWith("#")) continue;
|
||
|
|
if (t.startsWith("ifc://")) { loadUrl(t); return; }
|
||
|
|
try {
|
||
|
|
const u = new URL(t, window.location.href);
|
||
|
|
const p = u.searchParams.get("url");
|
||
|
|
if (p?.startsWith("ifc://")) { loadUrl(p); return; }
|
||
|
|
} catch { /* not a URL */ }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Coordinate transforms: IFC (Z-up) ↔ Three.js (Y-up).
|
||
|
|
// IFC → Three.js: (x, y, z) → (x, z, -y)
|
||
|
|
// Three.js → IFC: (x, y, z) → (x, -z, y)
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
const toIfc = v => ({ x: v.x, y: -v.z, z: v.y });
|
||
|
|
const toThree = (x, y, z) => new THREE.Vector3(x, z, -y);
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Sync camera viewpoint (in IFC coords) into the URL and toolbar row 1.
|
||
|
|
// The structured fields in row 2 are not affected by camera movement.
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
function syncCameraUrl(camera) {
|
||
|
|
const pos = camera.position;
|
||
|
|
const dir = new THREE.Vector3();
|
||
|
|
camera.getWorldDirection(dir);
|
||
|
|
const up = camera.up;
|
||
|
|
|
||
|
|
const f = v => parseFloat(v.toFixed(4));
|
||
|
|
const ip = toIfc(pos), id = toIfc(dir), iu = toIfc(up);
|
||
|
|
const cameraParam = [
|
||
|
|
f(ip.x), f(ip.y), f(ip.z),
|
||
|
|
f(id.x), f(id.y), f(id.z),
|
||
|
|
f(iu.x), f(iu.y), f(iu.z),
|
||
|
|
].join(",");
|
||
|
|
|
||
|
|
const qIdx = ifcUrl.indexOf("?");
|
||
|
|
const base = qIdx < 0 ? ifcUrl : ifcUrl.slice(0, qIdx);
|
||
|
|
const qs = new URLSearchParams(qIdx < 0 ? "" : ifcUrl.slice(qIdx + 1));
|
||
|
|
qs.set("camera", cameraParam);
|
||
|
|
if (camera.isPerspectiveCamera) {
|
||
|
|
qs.set("fov", f(camera.fov).toString());
|
||
|
|
qs.delete("scale");
|
||
|
|
fovInput.value = Math.round(camera.fov);
|
||
|
|
} else {
|
||
|
|
qs.set("scale", f(camera.top - camera.bottom).toString());
|
||
|
|
qs.delete("fov");
|
||
|
|
fovInput.value = "";
|
||
|
|
}
|
||
|
|
// Replace original clip= params with current interactive planes.
|
||
|
|
qs.delete("clip");
|
||
|
|
if (activeClipper) {
|
||
|
|
for (const [, plane] of activeClipper.list) {
|
||
|
|
const n3 = plane.three.normal;
|
||
|
|
const p3 = n3.clone().multiplyScalar(-plane.three.constant);
|
||
|
|
const ni = toIfc(n3), pi = toIfc(p3);
|
||
|
|
qs.append("clip", [f(pi.x),f(pi.y),f(pi.z),f(ni.x),f(ni.y),f(ni.z)].join(","));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const newIfcUrl = base + "?" + qs.toString()
|
||
|
|
.replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$");
|
||
|
|
|
||
|
|
urlInput.value = newIfcUrl;
|
||
|
|
|
||
|
|
const pageUrl = new URL(window.location.href);
|
||
|
|
pageUrl.searchParams.set("url", newIfcUrl);
|
||
|
|
history.replaceState(null, "", pageUrl.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Set up OBC.Clipper for interactive clipping planes.
|
||
|
|
// Seeds initial planes from the URL, wires toolbar buttons, and syncs
|
||
|
|
// plane state back to the URL on create/delete/drag.
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
function setupClipper(components, world, clips) {
|
||
|
|
const clipper = components.get(OBC.Clipper);
|
||
|
|
clipper.setup();
|
||
|
|
activeClipper = clipper;
|
||
|
|
|
||
|
|
const clipBtn = document.getElementById("clip-btn");
|
||
|
|
const clipClearBtn = document.getElementById("clip-clear-btn");
|
||
|
|
const container = document.getElementById("viewer");
|
||
|
|
|
||
|
|
function updateClearBtn() {
|
||
|
|
clipClearBtn.style.display = clipper.list.size > 0 ? "" : "none";
|
||
|
|
}
|
||
|
|
|
||
|
|
// Seed planes from URL as interactive planes.
|
||
|
|
for (const [px, py, pz, nx, ny, nz] of clips) {
|
||
|
|
clipper.createFromNormalAndCoplanarPoint(
|
||
|
|
world, toThree(nx, ny, nz).normalize(), toThree(px, py, pz)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
updateClearBtn();
|
||
|
|
|
||
|
|
// Double-click on model surface adds a plane when clip mode is active.
|
||
|
|
let clipMode = false;
|
||
|
|
clipBtn.addEventListener("click", () => {
|
||
|
|
clipMode = !clipMode;
|
||
|
|
clipBtn.classList.toggle("active", clipMode);
|
||
|
|
container.style.cursor = clipMode ? "crosshair" : "";
|
||
|
|
});
|
||
|
|
container.addEventListener("dblclick", async () => {
|
||
|
|
if (!clipMode) return;
|
||
|
|
await clipper.create(world);
|
||
|
|
syncCameraUrl(threeCamera);
|
||
|
|
updateClearBtn();
|
||
|
|
});
|
||
|
|
|
||
|
|
clipClearBtn.addEventListener("click", () => {
|
||
|
|
clipper.deleteAll();
|
||
|
|
syncCameraUrl(threeCamera);
|
||
|
|
updateClearBtn();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Sync URL whenever a plane is dragged.
|
||
|
|
clipper.onAfterDrag.add(() => syncCameraUrl(threeCamera));
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Apply selector= by isolating matching IFC elements.
|
||
|
|
//
|
||
|
|
// Simple selectors (IFC type names, "+" union): handled client-side.
|
||
|
|
// "IfcWall" "IfcWall+IfcSlab"
|
||
|
|
// Subtypes matched by prefix: IfcWall matches IfcWallStandardCase.
|
||
|
|
//
|
||
|
|
// Complex selectors (property filters, name, GlobalId, pset): resolved
|
||
|
|
// server-side via GET /select, then applied by GUID in the viewer.
|
||
|
|
// Requires the ifcurl service to be running.
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
function isSimpleTypeSelector(selectorStr) {
|
||
|
|
return selectorStr.split("+").every(part => /^[Ii]fc[A-Za-z0-9]+$/.test(part.trim()));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Returns a ModelIdMap containing all items in allItems that are NOT in toRemove.
|
||
|
|
function subtractIdMap(allItems, toRemove) {
|
||
|
|
const result = {};
|
||
|
|
for (const [modelId, allIds] of Object.entries(allItems)) {
|
||
|
|
const removeSet = new Set(toRemove[modelId] ?? []);
|
||
|
|
const remaining = [];
|
||
|
|
for (const id of allIds) {
|
||
|
|
if (!removeSet.has(id)) remaining.push(id);
|
||
|
|
}
|
||
|
|
if (remaining.length > 0) result[modelId] = remaining;
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply a fragment material style to the given items via FragmentsManager.
|
||
|
|
async function applyFragmentStyle(components, items, color, opacity) {
|
||
|
|
const fragments = components.get(OBC.FragmentsManager);
|
||
|
|
await fragments.resetHighlight();
|
||
|
|
if (Object.keys(items).length > 0) {
|
||
|
|
await fragments.highlight(
|
||
|
|
{ customId: "sel", color, opacity, transparent: opacity < 1 },
|
||
|
|
items
|
||
|
|
);
|
||
|
|
}
|
||
|
|
await fragments.core.update(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function applySelector(components, model, selectorStr, srcUrl, visibility = "highlight") {
|
||
|
|
if (!selectorStr) return;
|
||
|
|
|
||
|
|
const hider = components.get(OBC.Hider);
|
||
|
|
|
||
|
|
if (isSimpleTypeSelector(selectorStr)) {
|
||
|
|
const userTypes = selectorStr.split("+").map(p => p.trim().toUpperCase());
|
||
|
|
const classifier = components.get(OBC.Classifier);
|
||
|
|
await classifier.byCategory();
|
||
|
|
const categoryGroups = classifier.list.get("Categories");
|
||
|
|
if (!categoryGroups) return;
|
||
|
|
const matchingCategories = [];
|
||
|
|
for (const [cat] of categoryGroups) {
|
||
|
|
if (userTypes.some(t => cat === t || cat.startsWith(t)))
|
||
|
|
matchingCategories.push(cat);
|
||
|
|
}
|
||
|
|
if (!matchingCategories.length) return;
|
||
|
|
const matching = await classifier.find({ Categories: matchingCategories });
|
||
|
|
if (!matching || Object.keys(matching).length === 0) return;
|
||
|
|
if (visibility === "ghost") {
|
||
|
|
// Dim non-selected; selected appear normally.
|
||
|
|
const allItems = await classifier.find({ Categories: [...categoryGroups.keys()] });
|
||
|
|
await applyFragmentStyle(components, subtractIdMap(allItems, matching),
|
||
|
|
new THREE.Color(0x888888), 0.15);
|
||
|
|
} else if (visibility === "isolate") {
|
||
|
|
await hider.isolate(matching);
|
||
|
|
} else {
|
||
|
|
// highlight (default): colour-overlay the selected elements, all others visible.
|
||
|
|
await applyFragmentStyle(components, matching, new THREE.Color(0xff8800), 1);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Complex selector: ask the service to resolve it to GUIDs.
|
||
|
|
statusEl.textContent = "Resolving selector…";
|
||
|
|
let guids;
|
||
|
|
try {
|
||
|
|
const resp = await fetch(`/select?url=${encodeURIComponent(srcUrl)}`);
|
||
|
|
if (!resp.ok) {
|
||
|
|
const detail = await resp.text().catch(() => resp.statusText);
|
||
|
|
statusEl.textContent = `Selector error: ${detail}`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
guids = (await resp.json()).guids ?? [];
|
||
|
|
} catch (_) {
|
||
|
|
statusEl.textContent = "Complex selector requires the ifcurl service — /select not reachable";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!guids.length) return;
|
||
|
|
|
||
|
|
const localIds = (await model.getLocalIdsByGuids(guids)).filter(id => id !== null);
|
||
|
|
if (!localIds.length) return;
|
||
|
|
|
||
|
|
const matchingMap = { [model.modelId]: new Set(localIds) };
|
||
|
|
if (visibility === "ghost") {
|
||
|
|
const allVisible = await hider.getVisibilityMap(true);
|
||
|
|
await applyFragmentStyle(components, subtractIdMap(allVisible, matchingMap),
|
||
|
|
new THREE.Color(0x888888), 0.15);
|
||
|
|
} else if (visibility === "isolate") {
|
||
|
|
await hider.isolate(matchingMap);
|
||
|
|
} else {
|
||
|
|
await applyFragmentStyle(components, matchingMap, new THREE.Color(0xff8800), 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Place the camera from ifc:// URL camera=/fov=/scale= params.
|
||
|
|
// obcCamera is the OBC OrthoPerspectiveCamera instance (world.camera).
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
async function applyCameraParam(controls, obcCamera) {
|
||
|
|
const qIdx = ifcUrl.indexOf("?");
|
||
|
|
if (qIdx < 0) return false;
|
||
|
|
const qs = new URLSearchParams(ifcUrl.slice(qIdx + 1));
|
||
|
|
|
||
|
|
const camStr = qs.get("camera");
|
||
|
|
if (!camStr) return false;
|
||
|
|
const v = camStr.split(",").map(Number);
|
||
|
|
if (v.length < 9 || v.some(isNaN)) return false;
|
||
|
|
|
||
|
|
const pos = toThree(v[0], v[1], v[2]);
|
||
|
|
const dir = toThree(v[3], v[4], v[5]);
|
||
|
|
const target = pos.clone().add(dir);
|
||
|
|
await controls.setLookAt(pos.x, pos.y, pos.z, target.x, target.y, target.z, false);
|
||
|
|
|
||
|
|
const fovStr = qs.get("fov");
|
||
|
|
const scaleStr = qs.get("scale");
|
||
|
|
if (scaleStr) {
|
||
|
|
// Switch to orthographic projection and scale to match the URL's
|
||
|
|
// ViewToWorldScale (height of view in world units).
|
||
|
|
await obcCamera.projection.set("Orthographic");
|
||
|
|
const s = parseFloat(scaleStr);
|
||
|
|
if (!isNaN(s)) {
|
||
|
|
const ortho = obcCamera.threeOrtho;
|
||
|
|
const currentHeight = ortho.top - ortho.bottom;
|
||
|
|
if (currentHeight > 0) {
|
||
|
|
const ratio = s / currentHeight;
|
||
|
|
ortho.top *= ratio;
|
||
|
|
ortho.bottom *= ratio;
|
||
|
|
ortho.left *= ratio;
|
||
|
|
ortho.right *= ratio;
|
||
|
|
ortho.updateProjectionMatrix();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
fovInput.value = "";
|
||
|
|
} else if (fovStr) {
|
||
|
|
const persp = obcCamera.threePersp;
|
||
|
|
persp.fov = Math.max(10, Math.min(120, parseFloat(fovStr)));
|
||
|
|
persp.updateProjectionMatrix();
|
||
|
|
fovInput.value = Math.round(persp.fov);
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// BCF 2.1 export.
|
||
|
|
// With a selector: delegates to POST /bcf so the service resolves component
|
||
|
|
// GUIDs server-side. Fails visibly if the service is unreachable — no
|
||
|
|
// silent fallback to a GUIDless file.
|
||
|
|
// Without a selector: builds the zip client-side (camera + clips only).
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
async function generateBcf(title, comment) {
|
||
|
|
if (threeCamera) syncCameraUrl(threeCamera);
|
||
|
|
const src = urlInput.value.trim();
|
||
|
|
if (!src) return;
|
||
|
|
|
||
|
|
const qIdx = src.indexOf("?");
|
||
|
|
const qs = new URLSearchParams(qIdx < 0 ? "" : src.slice(qIdx + 1));
|
||
|
|
|
||
|
|
const camV = (qs.get("camera") ?? "").split(",").map(Number);
|
||
|
|
const cam = camV.length === 9 && !camV.some(isNaN) ? camV : null;
|
||
|
|
const fov = cam ? (parseFloat(qs.get("fov")) || null) : null;
|
||
|
|
const scale = cam ? (parseFloat(qs.get("scale")) || null) : null;
|
||
|
|
const clips = qs.getAll("clip")
|
||
|
|
.map(s => s.split(",").map(Number))
|
||
|
|
.filter(v => v.length === 6 && !v.some(isNaN));
|
||
|
|
const selector = qs.get("selector") || "";
|
||
|
|
const visibility = qs.get("visibility") || "highlight";
|
||
|
|
|
||
|
|
if (selector) {
|
||
|
|
try {
|
||
|
|
const resp = await fetch("/bcf", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ url: src, title: title || "IFC View", comment }),
|
||
|
|
});
|
||
|
|
if (!resp.ok) {
|
||
|
|
const detail = await resp.text().catch(() => resp.statusText);
|
||
|
|
statusEl.textContent = `BCF export failed: ${detail}`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const blob = await resp.blob();
|
||
|
|
const a = document.createElement("a");
|
||
|
|
a.href = URL.createObjectURL(blob);
|
||
|
|
a.download = "view.bcf";
|
||
|
|
a.click();
|
||
|
|
URL.revokeObjectURL(a.href);
|
||
|
|
} catch (_) {
|
||
|
|
statusEl.textContent = "BCF export requires the ifcurl service — /bcf not reachable";
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Capture a snapshot of the current view for the BCF zip.
|
||
|
|
let snapshotB64 = null;
|
||
|
|
if (rendererCanvas) {
|
||
|
|
try {
|
||
|
|
snapshotB64 = rendererCanvas.toDataURL("image/png").split(",")[1];
|
||
|
|
} catch (_) { /* context lost or cross-origin — skip */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
const topicGuid = crypto.randomUUID();
|
||
|
|
const vpGuid = cam ? crypto.randomUUID() : null;
|
||
|
|
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||
|
|
|
||
|
|
function esc(s) {
|
||
|
|
return String(s).replace(/&/g,"&").replace(/</g,"<")
|
||
|
|
.replace(/>/g,">").replace(/"/g,""");
|
||
|
|
}
|
||
|
|
function xyz(tag, a, b, c) {
|
||
|
|
return `<${tag}><X>${a}</X><Y>${b}</Y><Z>${c}</Z></${tag}>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const versionXml = `<?xml version="1.0" encoding="utf-8"?>
|
||
|
|
<Version VersionId="2.1" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/buildingSMART/BCF-XML/release_2_1/Schemas/version.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||
|
|
<DetailedVersion>2.1</DetailedVersion>
|
||
|
|
</Version>`;
|
||
|
|
|
||
|
|
let viewpointXml = null;
|
||
|
|
if (vpGuid && cam) {
|
||
|
|
const [px,py,pz, dx,dy,dz, ux,uy,uz] = cam;
|
||
|
|
const camXml = fov != null
|
||
|
|
? `<PerspectiveCamera>
|
||
|
|
${xyz("CameraViewPoint",px,py,pz)}
|
||
|
|
${xyz("CameraDirection",dx,dy,dz)}
|
||
|
|
${xyz("CameraUpVector",ux,uy,uz)}
|
||
|
|
<FieldOfView>${fov}</FieldOfView>
|
||
|
|
</PerspectiveCamera>`
|
||
|
|
: scale != null
|
||
|
|
? `<OrthogonalCamera>
|
||
|
|
${xyz("CameraViewPoint",px,py,pz)}
|
||
|
|
${xyz("CameraDirection",dx,dy,dz)}
|
||
|
|
${xyz("CameraUpVector",ux,uy,uz)}
|
||
|
|
<ViewToWorldScale>${scale}</ViewToWorldScale>
|
||
|
|
</OrthogonalCamera>`
|
||
|
|
: "";
|
||
|
|
const clipsXml = clips.length
|
||
|
|
? `<ClippingPlanes>
|
||
|
|
${clips.map(([cx,cy,cz,nx,ny,nz]) =>
|
||
|
|
`<ClippingPlane>${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}</ClippingPlane>`
|
||
|
|
).join("\n ")}
|
||
|
|
</ClippingPlanes>` : "";
|
||
|
|
// Reflect visibility mode: isolate → hide everything by default,
|
||
|
|
// ghost/highlight → show everything by default.
|
||
|
|
const defaultVis = visibility === "isolate" ? "false" : "true";
|
||
|
|
viewpointXml = `<?xml version="1.0" encoding="utf-8"?>
|
||
|
|
<VisualizationInfo Guid="${vpGuid}">
|
||
|
|
<Components><Visibility DefaultVisibility="${defaultVis}"/></Components>
|
||
|
|
${camXml}
|
||
|
|
${clipsXml}
|
||
|
|
</VisualizationInfo>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const desc = selector
|
||
|
|
? `${esc(src)}\nselector: ${esc(selector)}`
|
||
|
|
: esc(src);
|
||
|
|
const vpEntry = vpGuid
|
||
|
|
? `<Viewpoints Guid="${vpGuid}"><Viewpoint>viewpoint.bcfv</Viewpoint>` +
|
||
|
|
(snapshotB64 ? `<Snapshot>snapshot.png</Snapshot>` : "") +
|
||
|
|
`</Viewpoints>`
|
||
|
|
: "";
|
||
|
|
const cmtEntry = comment
|
||
|
|
? `<Comment Guid="${crypto.randomUUID()}">
|
||
|
|
<Date>${now}</Date><Author>anonymous</Author>
|
||
|
|
<Comment>${esc(comment)}</Comment>
|
||
|
|
${vpGuid ? `<Viewpoint Guid="${vpGuid}"/>` : ""}
|
||
|
|
</Comment>` : "";
|
||
|
|
|
||
|
|
const markupXml = `<?xml version="1.0" encoding="utf-8"?>
|
||
|
|
<Markup>
|
||
|
|
<Topic Guid="${topicGuid}" TopicType="Coordination" TopicStatus="Open">
|
||
|
|
<Title>${esc(title || "IFC View")}</Title>
|
||
|
|
<Description>${desc}</Description>
|
||
|
|
<CreationDate>${now}</CreationDate>
|
||
|
|
<CreationAuthor>anonymous</CreationAuthor>
|
||
|
|
${vpEntry}
|
||
|
|
</Topic>
|
||
|
|
${cmtEntry}
|
||
|
|
</Markup>`;
|
||
|
|
|
||
|
|
const zip = new JSZip();
|
||
|
|
zip.file("bcf.version", versionXml);
|
||
|
|
zip.folder(topicGuid).file("markup.bcf", markupXml);
|
||
|
|
if (vpGuid && viewpointXml) zip.folder(topicGuid).file("viewpoint.bcfv", viewpointXml);
|
||
|
|
if (snapshotB64) zip.folder(topicGuid).file("snapshot.png", snapshotB64, { base64: true });
|
||
|
|
|
||
|
|
const blob = await zip.generateAsync({ type: "blob" });
|
||
|
|
const a = document.createElement("a");
|
||
|
|
a.href = URL.createObjectURL(blob);
|
||
|
|
a.download = "view.bcf";
|
||
|
|
a.click();
|
||
|
|
URL.revokeObjectURL(a.href);
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// BCF 2.1 import — read a .bcf zip, extract the first viewpoint, apply
|
||
|
|
// camera/clips/GUIDs to the current ifc:// URL and reload the viewer.
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
async function importBcf(file) {
|
||
|
|
const src = urlInput.value.trim();
|
||
|
|
if (!src) {
|
||
|
|
statusEl.textContent = "Load a model first, then import a BCF viewpoint";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
statusEl.textContent = "Importing BCF…";
|
||
|
|
try {
|
||
|
|
const zip = await JSZip.loadAsync(file);
|
||
|
|
|
||
|
|
// Find the first viewpoint.bcfv entry.
|
||
|
|
const vpPath = Object.keys(zip.files).find(p => p.endsWith("viewpoint.bcfv"));
|
||
|
|
if (!vpPath) {
|
||
|
|
statusEl.textContent = "BCF: no viewpoint found in file";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const vpXml = await zip.file(vpPath).async("text");
|
||
|
|
const doc = new DOMParser().parseFromString(vpXml, "text/xml");
|
||
|
|
|
||
|
|
const getXYZ = (parent, tag) => {
|
||
|
|
const el = parent.querySelector(tag);
|
||
|
|
if (!el) return null;
|
||
|
|
const v = ["X", "Y", "Z"].map(c => parseFloat(el.querySelector(c)?.textContent));
|
||
|
|
return v.some(isNaN) ? null : v;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Camera
|
||
|
|
let camera = null, fov = null, scale = null;
|
||
|
|
const perspCam = doc.querySelector("PerspectiveCamera");
|
||
|
|
const orthoCam = doc.querySelector("OrthogonalCamera");
|
||
|
|
const camEl = perspCam || orthoCam;
|
||
|
|
if (camEl) {
|
||
|
|
const pos = getXYZ(camEl, "CameraViewPoint");
|
||
|
|
const dir = getXYZ(camEl, "CameraDirection");
|
||
|
|
const up = getXYZ(camEl, "CameraUpVector");
|
||
|
|
if (pos && dir && up) {
|
||
|
|
camera = [...pos, ...dir, ...up];
|
||
|
|
if (perspCam) {
|
||
|
|
fov = parseFloat(perspCam.querySelector("FieldOfView")?.textContent);
|
||
|
|
if (isNaN(fov)) fov = null;
|
||
|
|
} else {
|
||
|
|
scale = parseFloat(orthoCam.querySelector("ViewToWorldScale")?.textContent);
|
||
|
|
if (isNaN(scale)) scale = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clipping planes
|
||
|
|
const clips = [];
|
||
|
|
for (const cp of doc.querySelectorAll("ClippingPlane")) {
|
||
|
|
const loc = getXYZ(cp, "Location");
|
||
|
|
const dir = getXYZ(cp, "Direction");
|
||
|
|
if (loc && dir) clips.push([...loc, ...dir]);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Component GUIDs from Selection
|
||
|
|
const guids = Array.from(doc.querySelectorAll("Components Selection Component"))
|
||
|
|
.map(el => el.getAttribute("IfcGuid"))
|
||
|
|
.filter(Boolean);
|
||
|
|
|
||
|
|
// Build updated URL: keep repo/ref/path, replace viewpoint params.
|
||
|
|
const qIdx = src.indexOf("?");
|
||
|
|
const base = qIdx < 0 ? src : src.slice(0, qIdx);
|
||
|
|
const qs = new URLSearchParams(qIdx < 0 ? "" : src.slice(qIdx + 1));
|
||
|
|
|
||
|
|
qs.delete("camera"); qs.delete("fov"); qs.delete("scale"); qs.delete("clip");
|
||
|
|
qs.delete("selector"); qs.delete("visibility");
|
||
|
|
|
||
|
|
if (camera) {
|
||
|
|
const f = v => parseFloat(v.toFixed(4));
|
||
|
|
qs.set("camera", camera.map(f).join(","));
|
||
|
|
if (fov != null) qs.set("fov", fov.toString());
|
||
|
|
if (scale != null) qs.set("scale", scale.toString());
|
||
|
|
}
|
||
|
|
for (const clip of clips) {
|
||
|
|
const f = v => parseFloat(v.toFixed(4));
|
||
|
|
qs.append("clip", clip.map(f).join(","));
|
||
|
|
}
|
||
|
|
if (guids.length > 0) qs.set("selector", guids.join("+"));
|
||
|
|
|
||
|
|
const newUrl = base + "?" + qs.toString()
|
||
|
|
.replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$");
|
||
|
|
loadUrl(newUrl);
|
||
|
|
} catch (err) {
|
||
|
|
statusEl.textContent = `BCF import error: ${err.message}`;
|
||
|
|
console.error(err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Element properties panel
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
function showPropsPanel(props, webIfc) {
|
||
|
|
let typeName = String(props.type ?? "");
|
||
|
|
try {
|
||
|
|
const name = webIfc?.GetNameFromTypeCode(props.type);
|
||
|
|
if (name) typeName = name;
|
||
|
|
} catch { /* not available in this version */ }
|
||
|
|
propsType.textContent = typeName;
|
||
|
|
propsName.textContent = props.Name?.value ?? props.LongName?.value ?? "";
|
||
|
|
propsGuid.textContent = props.GlobalId?.value ?? "";
|
||
|
|
propsPanel.style.display = "";
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Model metadata panel population.
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
async function populateMetaPanel(components) {
|
||
|
|
const classifier = components.get(OBC.Classifier);
|
||
|
|
const hider = components.get(OBC.Hider);
|
||
|
|
|
||
|
|
// --- Type breakdown ---
|
||
|
|
await classifier.byCategory();
|
||
|
|
const categories = classifier.list.get("Categories");
|
||
|
|
const types = [];
|
||
|
|
if (categories) {
|
||
|
|
for (const [name, groupData] of categories) {
|
||
|
|
let count = 0;
|
||
|
|
for (const ids of Object.values(groupData)) count += [...ids].length;
|
||
|
|
if (count > 0) types.push({ name, count });
|
||
|
|
}
|
||
|
|
types.sort((a, b) => b.count - a.count);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Storey list ---
|
||
|
|
await classifier.byIfcBuildingStorey();
|
||
|
|
const storeyGroups = classifier.list.get("Storeys");
|
||
|
|
const storeyNames = storeyGroups ? [...storeyGroups.keys()] : [];
|
||
|
|
|
||
|
|
// --- Render ---
|
||
|
|
const html = [];
|
||
|
|
|
||
|
|
if (types.length) {
|
||
|
|
html.push("<h3>Types</h3>");
|
||
|
|
for (const { name, count } of types) {
|
||
|
|
html.push(
|
||
|
|
`<div class="meta-row">` +
|
||
|
|
`<span class="meta-name">${name}</span>` +
|
||
|
|
`<span class="meta-count">${count}</span>` +
|
||
|
|
`</div>`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (storeyNames.length) {
|
||
|
|
html.push("<h3>Storeys</h3>");
|
||
|
|
html.push(
|
||
|
|
`<div class="meta-row storey-row active" data-storey="">` +
|
||
|
|
`<span class="meta-name">All storeys</span>` +
|
||
|
|
`</div>`
|
||
|
|
);
|
||
|
|
for (const name of storeyNames) {
|
||
|
|
html.push(
|
||
|
|
`<div class="meta-row storey-row" data-storey="${name.replace(/"/g, """)}">` +
|
||
|
|
`<span class="meta-name">${name}</span>` +
|
||
|
|
`</div>`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!types.length && !storeyNames.length) {
|
||
|
|
html.push('<div class="meta-row"><span class="meta-name" style="color:#505058">No metadata</span></div>');
|
||
|
|
}
|
||
|
|
|
||
|
|
metaPanel.innerHTML = html.join("");
|
||
|
|
|
||
|
|
// Wire up storey click-to-isolate.
|
||
|
|
const allRow = metaPanel.querySelector(".storey-row[data-storey='']");
|
||
|
|
let activeRow = allRow;
|
||
|
|
for (const row of metaPanel.querySelectorAll(".storey-row")) {
|
||
|
|
row.addEventListener("click", async () => {
|
||
|
|
const name = row.dataset.storey;
|
||
|
|
if (!name || activeRow === row) {
|
||
|
|
// "All storeys" row or clicking the active row: show all.
|
||
|
|
activeRow?.classList.remove("active");
|
||
|
|
activeRow = allRow;
|
||
|
|
allRow?.classList.add("active");
|
||
|
|
await hider.set(true);
|
||
|
|
} else {
|
||
|
|
activeRow?.classList.remove("active");
|
||
|
|
activeRow = row;
|
||
|
|
row.classList.add("active");
|
||
|
|
const found = await classifier.find({ Storeys: [name] });
|
||
|
|
await hider.isolate(found);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Ref picker: populate <datalist id="ref-list"> with branches + tags.
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
async function populateRefList(host, repoSuffix) {
|
||
|
|
const proto = host.startsWith("localhost") ? "http" : "https";
|
||
|
|
const base = `${proto}://${host}/api/v1/repos/${repoSuffix}`;
|
||
|
|
const sig = AbortSignal.timeout(4000);
|
||
|
|
const [branchRes, tagRes] = await Promise.allSettled([
|
||
|
|
fetch(`${base}/branches?limit=50`, { signal: sig }),
|
||
|
|
fetch(`${base}/tags?limit=50`, { signal: sig }),
|
||
|
|
]);
|
||
|
|
const dl = document.getElementById("ref-list");
|
||
|
|
if (branchRes.status === "fulfilled" && branchRes.value.ok) {
|
||
|
|
const branches = await branchRes.value.json().catch(() => []);
|
||
|
|
for (const b of (branches ?? [])) {
|
||
|
|
const opt = document.createElement("option");
|
||
|
|
opt.value = `heads/${b.name}`;
|
||
|
|
dl.appendChild(opt);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (tagRes.status === "fulfilled" && tagRes.value.ok) {
|
||
|
|
const tags = await tagRes.value.json().catch(() => []);
|
||
|
|
for (const t of (tags ?? [])) {
|
||
|
|
const opt = document.createElement("option");
|
||
|
|
opt.value = `tags/${t.name}`;
|
||
|
|
dl.appendChild(opt);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Commit-pin indicator helpers.
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
function isCommitHash(ref) {
|
||
|
|
return /^[0-9a-f]{7,40}$/.test(ref);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchBranchForCommit(host, repoSuffix, hash) {
|
||
|
|
const proto = host.startsWith("localhost") ? "http" : "https";
|
||
|
|
const apiBase = `${proto}://${host}/api/v1/repos/${repoSuffix}`;
|
||
|
|
const sig = AbortSignal.timeout(3000);
|
||
|
|
const [branchRes, repoRes] = await Promise.allSettled([
|
||
|
|
fetch(`${apiBase}/branches?limit=50`, { signal: sig }),
|
||
|
|
fetch(apiBase, { signal: sig }),
|
||
|
|
]);
|
||
|
|
let defaultBranch = "main";
|
||
|
|
if (repoRes.status === "fulfilled" && repoRes.value.ok) {
|
||
|
|
const info = await repoRes.value.json().catch(() => null);
|
||
|
|
if (info?.default_branch) defaultBranch = info.default_branch;
|
||
|
|
}
|
||
|
|
if (branchRes.status === "fulfilled" && branchRes.value.ok) {
|
||
|
|
const branches = await branchRes.value.json().catch(() => []);
|
||
|
|
const h = hash.toLowerCase();
|
||
|
|
for (const b of (branches ?? [])) {
|
||
|
|
const id = (b.commit?.id ?? "").toLowerCase();
|
||
|
|
if (id.startsWith(h) || h.startsWith(id.slice(0, Math.max(h.length, 7)))) {
|
||
|
|
return b.name;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return defaultBranch;
|
||
|
|
}
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Main
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
async function main() {
|
||
|
|
if (!ifcUrl) {
|
||
|
|
statusEl.textContent = "Fill in the fields above and press Enter, or paste an ifc:// URL.";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
statusEl.textContent = "Loading…";
|
||
|
|
|
||
|
|
const components = new OBC.Components();
|
||
|
|
const worlds = components.get(OBC.Worlds);
|
||
|
|
const world = worlds.create();
|
||
|
|
const container = document.getElementById("viewer");
|
||
|
|
|
||
|
|
world.scene = new OBC.SimpleScene(components);
|
||
|
|
world.renderer = new OBC.SimpleRenderer(components, container, { preserveDrawingBuffer: true });
|
||
|
|
world.camera = new OBC.OrthoPerspectiveCamera(components);
|
||
|
|
rendererCanvas = world.renderer.three.domElement;
|
||
|
|
world.scene.setup();
|
||
|
|
components.init();
|
||
|
|
|
||
|
|
// Register a raycaster for this world so OBC.Clipper can raycast
|
||
|
|
// against the model surface when creating interactive clip planes.
|
||
|
|
components.get(OBC.Raycasters).get(world);
|
||
|
|
|
||
|
|
cameraControls = world.camera.controls;
|
||
|
|
threeCamera = world.camera.three;
|
||
|
|
|
||
|
|
// Firefox blocks cross-origin module workers; wrap in a blob URL.
|
||
|
|
const fragments = components.get(OBC.FragmentsManager);
|
||
|
|
const workerResp = await fetch("/assets/fragments-worker.js");
|
||
|
|
fragments.init(URL.createObjectURL(
|
||
|
|
new Blob([await workerResp.text()], { type: "application/javascript" })
|
||
|
|
));
|
||
|
|
|
||
|
|
const ifcLoader = components.get(OBC.IfcLoader);
|
||
|
|
await ifcLoader.setup({
|
||
|
|
autoSetWasm: false,
|
||
|
|
wasm: { path: "/assets/", absolute: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
statusEl.textContent = "Fetching IFC file…";
|
||
|
|
let buffer;
|
||
|
|
try {
|
||
|
|
const resp = await fetch(toRawUrl(ifcUrl));
|
||
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||
|
|
const contentLength = resp.headers.get("Content-Length");
|
||
|
|
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
||
|
|
const reader = resp.body?.getReader();
|
||
|
|
if (reader) {
|
||
|
|
const chunks = [];
|
||
|
|
let received = 0;
|
||
|
|
for (;;) {
|
||
|
|
const { done, value } = await reader.read();
|
||
|
|
if (done) break;
|
||
|
|
chunks.push(value);
|
||
|
|
received += value.length;
|
||
|
|
if (total > 0) {
|
||
|
|
statusEl.textContent = `Fetching… ${Math.round(received * 100 / total)}%`;
|
||
|
|
} else {
|
||
|
|
statusEl.textContent = `Fetching… ${(received / 1048576).toFixed(1)} MB`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
buffer = new Uint8Array(received);
|
||
|
|
let offset = 0;
|
||
|
|
for (const chunk of chunks) { buffer.set(chunk, offset); offset += chunk.length; }
|
||
|
|
} else {
|
||
|
|
buffer = new Uint8Array(await resp.arrayBuffer());
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
statusEl.textContent = `Fetch error: ${err.message}`;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
statusEl.textContent = "Parsing IFC…";
|
||
|
|
try {
|
||
|
|
const model = await ifcLoader.load(buffer, true, "model");
|
||
|
|
loadedModel = model;
|
||
|
|
world.scene.three.add(model.object);
|
||
|
|
|
||
|
|
const hasCam = await applyCameraParam(cameraControls, world.camera);
|
||
|
|
threeCamera = world.camera.three; // refresh: may now be ortho
|
||
|
|
if (!hasCam && !model.box.isEmpty()) {
|
||
|
|
await cameraControls.fitToBox(model.box, false);
|
||
|
|
}
|
||
|
|
model.useCamera(threeCamera);
|
||
|
|
|
||
|
|
const qIdxPost = ifcUrl.indexOf("?");
|
||
|
|
const qsPost = new URLSearchParams(
|
||
|
|
qIdxPost < 0 ? "" : ifcUrl.slice(qIdxPost + 1)
|
||
|
|
);
|
||
|
|
|
||
|
|
const clips = qsPost.getAll("clip")
|
||
|
|
.map(s => s.split(",").map(Number))
|
||
|
|
.filter(v => v.length === 6 && !v.some(isNaN));
|
||
|
|
setupClipper(components, world, clips);
|
||
|
|
|
||
|
|
const visibility = qsPost.get("visibility") ?? "highlight";
|
||
|
|
const selectorStr = qsPost.get("selector") ?? "";
|
||
|
|
|
||
|
|
// Set up Highlighter before selector so visibility=highlight can use it.
|
||
|
|
let highlighter = null;
|
||
|
|
try {
|
||
|
|
highlighter = components.get(OBC.Highlighter);
|
||
|
|
highlighter.setup({ world });
|
||
|
|
highlighter.events.select.onHighlight.add(async (fragmentIdMap) => {
|
||
|
|
for (const expressIds of Object.values(fragmentIdMap)) {
|
||
|
|
const expressId = [...expressIds][0];
|
||
|
|
if (expressId == null) continue;
|
||
|
|
const props = await model.getLocalProperties(expressId);
|
||
|
|
if (props) showPropsPanel(props, ifcLoader.webIfc);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
highlighter.events.select.onClear.add(() => {
|
||
|
|
propsPanel.style.display = "none";
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
console.warn("Highlighter unavailable:", err.message);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (selectorStr) {
|
||
|
|
statusEl.textContent = "Applying selector…";
|
||
|
|
await applySelector(components, model, selectorStr, ifcUrl, visibility);
|
||
|
|
}
|
||
|
|
|
||
|
|
await fragments.core.update(true);
|
||
|
|
|
||
|
|
syncCameraUrl(threeCamera);
|
||
|
|
cameraControls.addEventListener("rest", () => syncCameraUrl(threeCamera));
|
||
|
|
removeSnapshot();
|
||
|
|
|
||
|
|
populateMetaPanel(components).catch(err => {
|
||
|
|
console.warn("Metadata panel unavailable:", err.message);
|
||
|
|
});
|
||
|
|
|
||
|
|
statusEl.textContent = "";
|
||
|
|
} catch (err) {
|
||
|
|
removeSnapshot();
|
||
|
|
statusEl.textContent = `Render error: ${err.message}`;
|
||
|
|
console.error(err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
main();
|