// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
import { THREE, OBC, OBCF, JSZip } from "/assets/viewer-deps.js";
import { parseIfcUrl, toRawUrl, buildIfcUrl as _buildIfcUrl } from "./viewer-url.js";
import { isSimpleTypeSelector, subtractIdMap, isCommitHash } from "./viewer-util.js";
const params = new URLSearchParams(window.location.search);
let currentIfcUrl = 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 queryInput = document.getElementById("query-input");
const queryPanel = document.getElementById("query-panel");
const queryTbody = document.getElementById("query-tbody");
const queryPanelTitle = document.getElementById("query-panel-title");
const queryValHeader = document.getElementById("query-val-header-col");
const queryCloseBtn = document.getElementById("query-close-btn");
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 components = null;
let world = null;
let cameraControls = null;
let threeCamera = null;
let activeClipper = null;
let loadedModel = null;
let rendererCanvas = null;
// Raw HTTP URL of the loaded IFC file, captured once at model load time.
// Used to decide whether a loadUrl call needs a full reload or an in-place update.
let modelRawUrl = null;
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(),
queryInput.value.trim()
);
}
// Populate structured fields from the current URL.
const parsed = parseIfcUrl(currentIfcUrl);
if (parsed) {
repoInput.value = parsed.repo;
refInput.value = parsed.ref;
pathInput.value = parsed.path;
selectorInput.value = parsed.selector;
queryInput.value = parsed.query;
}
urlInput.value = currentIfcUrl;
// Initialise visibility dropdown from URL and show it when selector is non-empty.
{
const qIdx = currentIfcUrl.indexOf("?");
const visVal = qIdx < 0 ? "highlight"
: (new URLSearchParams(currentIfcUrl.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 = currentIfcUrl.indexOf("?");
const base = qI < 0 ? currentIfcUrl : currentIfcUrl.slice(0, qI);
const tail = qI < 0 ? "" : currentIfcUrl.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 */ });
}
// -----------------------------------------------------------------------
// Apply view-only param changes (camera, clip, selector, visibility, query)
// in-place without re-fetching the IFC file.
// -----------------------------------------------------------------------
async function applyViewChanges(newUrl) {
const oldQIdx = currentIfcUrl.indexOf("?");
const oldQs = new URLSearchParams(oldQIdx < 0 ? "" : currentIfcUrl.slice(oldQIdx + 1));
const newQIdx = newUrl.indexOf("?");
const newQs = new URLSearchParams(newQIdx < 0 ? "" : newUrl.slice(newQIdx + 1));
// Update currentIfcUrl and the URL bar first so any triggered syncCameraUrl
// calls use the correct base.
currentIfcUrl = newUrl;
urlInput.value = newUrl;
const pageUrl = new URL(window.location.href);
pageUrl.searchParams.set("url", newUrl);
history.replaceState(null, "", pageUrl.toString());
// Sync structured form fields.
const parsedNew = parseIfcUrl(newUrl);
if (parsedNew) {
selectorInput.value = parsedNew.selector;
queryInput.value = parsedNew.query;
}
const newVisibility = newQs.get("visibility") ?? "highlight";
visibilitySelect.value = newVisibility;
syncVisibilityField();
// Camera / projection.
const oldCamera = oldQs.get("camera") ?? "";
const newCamera = newQs.get("camera") ?? "";
const oldFov = oldQs.get("fov") ?? "";
const newFov = newQs.get("fov") ?? "";
const oldScale = oldQs.get("scale") ?? "";
const newScale = newQs.get("scale") ?? "";
if (newCamera !== oldCamera || newFov !== oldFov || newScale !== oldScale) {
await applyCameraParam(cameraControls, world.camera, newUrl);
// Flush camera-controls position into the Three.js camera matrix before rendering.
// setLookAt(false) sets the destination but doesn't write to Three.js until update().
cameraControls.update(0);
threeCamera = world.camera.three;
if (loadedModel) loadedModel.useCamera(threeCamera);
await components.get(OBC.FragmentsManager).core.update(true);
}
// Clipping planes.
const oldClips = oldQs.getAll("clip");
const newClips = newQs.getAll("clip");
if (JSON.stringify(newClips) !== JSON.stringify(oldClips) && activeClipper) {
activeClipper.deleteAll();
const planes = newClips.map(s => s.split(",").map(Number))
.filter(v => v.length === 6 && !v.some(isNaN));
for (const [px, py, pz, nx, ny, nz] of planes) {
activeClipper.createFromNormalAndCoplanarPoint(
world, toThree(nx, ny, nz).normalize(), toThree(px, py, pz)
);
}
const clipClearBtn = document.getElementById("clip-clear-btn");
clipClearBtn.style.display = activeClipper.list.size > 0 ? "" : "none";
}
// Selector / visibility.
const oldSelector = oldQs.get("selector") ?? "";
const newSelector = newQs.get("selector") ?? "";
const oldVisibility = oldQs.get("visibility") ?? "highlight";
if (newSelector !== oldSelector || newVisibility !== oldVisibility) {
const frags = components.get(OBC.FragmentsManager);
const hider = components.get(OBC.Hider);
await frags.resetHighlight();
await hider.set(true);
if (newSelector) {
statusEl.textContent = "Applying selector…";
await applySelector(components, loadedModel, newSelector, newUrl, newVisibility);
}
await frags.core.update(true);
}
// Query.
const oldQuery = oldQs.get("query") ?? "";
const newQuery = newQs.get("query") ?? "";
if (newQuery !== oldQuery) {
if (newQuery) await applyQuery(newUrl, newQuery);
else queryPanel.style.display = "none";
}
statusEl.textContent = "";
}
// -----------------------------------------------------------------------
// Navigate to the viewer with a different ifc:// URL.
// If only view params changed (camera, clip, selector, visibility, query)
// the changes are applied in-place without re-fetching the IFC model.
// A full page reload only happens when the source changes (host/repo/ref/path).
// -----------------------------------------------------------------------
function loadUrl(url) {
// In-place update when the loaded IFC file is unchanged (only view params differ).
if (modelRawUrl) {
let rawUrl;
try { rawUrl = toRawUrl(url); } catch (_) { /* malformed url — fall through */ }
if (rawUrl && rawUrl === modelRawUrl) {
applyViewChanges(url).catch(err => {
statusEl.textContent = `Update error: ${err.message}`;
console.error(err);
});
return;
}
}
// Capture a JPEG snapshot of the WebGL canvas so the overlay script on the
// new page can cover the blank canvas while the model reloads.
if (rendererCanvas) {
try {
sessionStorage.setItem("viewerSnapshot", rendererCanvas.toDataURL("image/jpeg", 0.6));
} 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, queryInput]) {
el.addEventListener("keydown", e => { if (e.key === "Enter") loadFromFields(); });
}
queryCloseBtn.addEventListener("click", () => { queryPanel.style.display = "none"; });
// ▾ button: show a custom dropdown of available refs.
const refPickBtn = document.getElementById("ref-pick-btn");
refPickBtn.addEventListener("click", () => {
const existing = document.getElementById("ref-dropdown");
if (existing) { existing.remove(); return; }
const dl = document.getElementById("ref-list");
const opts = [...dl.options].map(o => o.value);
if (!opts.length) { refInput.value = ""; refInput.focus(); return; }
const rect = refInput.getBoundingClientRect();
const ul = document.createElement("ul");
ul.id = "ref-dropdown";
ul.style.cssText = `position:fixed;top:${rect.bottom + 2}px;left:${rect.left}px;
min-width:${rect.width}px;max-height:220px;overflow-y:auto;z-index:50;
background:#2c2c2e;border:1px solid rgba(255,255,255,0.15);border-radius:4px;
list-style:none;padding:2px 0;`;
for (const val of opts) {
const li = document.createElement("li");
li.textContent = val;
li.style.cssText = "padding:4px 8px;font-size:12px;font-family:monospace;" +
"cursor:pointer;color:#f0f0f0;white-space:nowrap;";
li.addEventListener("mouseover", () => { li.style.background = "rgba(255,255,255,0.1)"; });
li.addEventListener("mouseout", () => { li.style.background = ""; });
li.addEventListener("click", () => {
refInput.value = val;
ul.remove();
loadFromFields();
});
ul.appendChild(li);
}
document.body.appendChild(ul);
const close = e => {
if (!ul.contains(e.target) && e.target !== refPickBtn) {
ul.remove();
document.removeEventListener("click", close, true);
}
};
setTimeout(() => document.addEventListener("click", close, true), 0);
});
// 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 body = `[${title}](${src})`;
const issueUrl = `${window.location.origin}/${p.repoSuffix}/issues/new` +
`?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
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,
queryInput, 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 = currentIfcUrl.indexOf("?");
const base = qIdx < 0 ? currentIfcUrl : currentIfcUrl.slice(0, qIdx);
const qs = new URLSearchParams(qIdx < 0 ? "" : currentIfcUrl.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, "$");
currentIfcUrl = newIfcUrl;
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" : "";
statusEl.textContent = clipMode ? "Double-click model surface to place clip plane" : "";
});
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.
// -----------------------------------------------------------------------
// 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 if (visibility === "clash") {
await applyFragmentStyle(components, matching, new THREE.Color(0xdc3232), 1);
} 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 if (visibility === "clash") {
await applyFragmentStyle(components, matchingMap, new THREE.Color(0xdc3232), 1);
} else {
await applyFragmentStyle(components, matchingMap, new THREE.Color(0xff8800), 1);
}
}
// -----------------------------------------------------------------------
// Fetch /query results and display them in the query panel.
// -----------------------------------------------------------------------
async function applyQuery(srcUrl, queryPath) {
statusEl.textContent = "Querying…";
let data;
try {
const resp = await fetch(`/query?url=${encodeURIComponent(srcUrl)}`);
if (!resp.ok) {
const detail = await resp.text().catch(() => resp.statusText);
statusEl.textContent = `Query error: ${detail}`;
return;
}
data = await resp.json();
} catch (_) {
statusEl.textContent = "Query requires the ifcurl service — /query not reachable";
return;
}
statusEl.textContent = "";
const entries = Object.entries(data);
if (!entries.length) return;
queryPanelTitle.textContent = queryPath;
queryValHeader.textContent = queryPath;
queryTbody.innerHTML = "";
for (const [guid, value] of entries) {
const tr = document.createElement("tr");
const tdGuid = document.createElement("td");
tdGuid.textContent = guid;
const tdVal = document.createElement("td");
tdVal.textContent = value;
tr.appendChild(tdGuid);
tr.appendChild(tdVal);
queryTbody.appendChild(tr);
}
queryPanel.style.display = "";
}
// -----------------------------------------------------------------------
// Place the camera from ifc:// URL camera=/fov=/scale= params.
// obcCamera is the OBC OrthoPerspectiveCamera instance (world.camera).
// -----------------------------------------------------------------------
async function applyCameraParam(controls, obcCamera, url) {
const qIdx = url.indexOf("?");
if (qIdx < 0) return false;
const qs = new URLSearchParams(url.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,""");
}
function xyz(tag, a, b, c) {
return `<${tag}>