ifcurl/forgejo/custom/public/assets/viewer.html
Bruno Postle 7aa4ef85ab viewer: fix visibility=highlight — show all elements, highlight selection
Previously applySelector always called hider.isolate() which hid all
non-selected elements regardless of visibility mode. For highlight mode,
set up the Highlighter before applying the selector and use
highlightByID("select") instead so all elements remain visible with the
selection highlighted in orange.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 00:20:51 +01:00

1238 lines
50 KiB
HTML

<!DOCTYPE html>
<!-- Copyright 2026 The Forgejo Authors. All rights reserved. -->
<!-- SPDX-License-Identifier: MIT -->
<html lang="en">
<head>
<meta charset="utf-8">
<title>IFC Viewer</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #1c1c1e; color: #f0f0f0;
font-family: system-ui, sans-serif; overflow: hidden; }
#viewer { position: fixed; inset: 0; top: var(--toolbar-h, 72px); }
#toolbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
display: flex; flex-direction: column; gap: 6px;
padding: 8px 12px;
background: rgba(28,28,30,0.85); backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.trow { display: flex; align-items: center; gap: 6px; }
/* Shared input style */
.ti {
font-size: 12px; color: #f0f0f0; font-family: monospace;
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12);
border-radius: 4px; padding: 3px 7px; outline: none; min-width: 0;
}
.ti:focus { border-color: rgba(255,255,255,0.3); }
.ti::placeholder { color: #505058; }
/* Row 1 */
#url-input { flex: 1; }
#fov-wrap {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
font-size: 11px; color: #a0a0a8;
}
#fov-input { width: 44px; text-align: right; }
/* Row 2 structured fields */
.field { display: flex; align-items: center; gap: 4px; min-width: 0; }
.field label { font-size: 10px; color: #808088; flex-shrink: 0; }
#repo-input { flex: 3; }
#ref-input { flex: 2; }
#path-input { flex: 2; }
#selector-input { flex: 1; }
/* Toolbar buttons */
.tb-btn {
font-size: 12px; color: #c0c0c8; cursor: pointer; flex-shrink: 0;
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12);
border-radius: 4px; padding: 3px 8px; white-space: nowrap;
}
.tb-btn:hover { background: rgba(255,255,255,0.14); }
.tb-btn.active { color: #a8c8ff; background: rgba(80,120,255,0.2);
border-color: rgba(80,120,255,0.5); }
#ref-badge {
font-size: 10px; color: #f0a050; font-family: monospace;
background: rgba(255,160,80,0.12); border: 1px solid rgba(255,160,80,0.3);
border-radius: 4px; padding: 2px 6px; white-space: nowrap; flex-shrink: 0;
cursor: default;
}
/* BCF panel */
#bcf-comment { flex: 3; }
#bcf-title { flex: 2; }
/* Status */
#status {
position: fixed; bottom: 12px; left: 50%;
transform: translateX(-50%); z-index: 10;
font-size: 13px; color: #a0a0a8;
background: rgba(28,28,30,0.85);
padding: 4px 12px; border-radius: 4px;
pointer-events: none;
}
#status:empty { display: none; }
body.drag-over::after {
content: "Drop ifc:// URL or .bcf file";
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
font-size: 24px; color: #f0f0f0;
background: rgba(28,28,30,0.7);
pointer-events: none;
}
/* Model metadata panel */
#meta-panel {
position: fixed; bottom: 44px; left: 12px; z-index: 10;
background: rgba(28,28,30,0.92); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
padding: 8px 10px; min-width: 180px; max-width: 260px;
max-height: calc(100vh - 130px); overflow-y: auto;
}
#meta-panel h3 {
font-size: 10px; color: #606068;
text-transform: uppercase; letter-spacing: 0.06em;
margin-top: 10px; margin-bottom: 3px;
}
#meta-panel h3:first-child { margin-top: 0; }
.meta-row {
display: flex; align-items: baseline; gap: 4px;
margin-top: 2px; padding: 1px 3px; border-radius: 3px;
}
.meta-row.storey-row { cursor: pointer; }
.meta-row.storey-row:hover { background: rgba(255,255,255,0.07); }
.meta-row.storey-row.active { background: rgba(80,120,255,0.2);
color: #a8c8ff; }
.meta-name {
font-size: 11px; color: #d0d0d8; font-family: monospace;
flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.meta-row.storey-row.active .meta-name { color: #a8c8ff; }
.meta-count { font-size: 10px; color: #808088; flex-shrink: 0; }
/* Element properties panel */
#props-panel {
position: fixed; bottom: 44px; right: 12px; z-index: 10;
background: rgba(28,28,30,0.92); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
padding: 8px 10px; min-width: 200px; max-width: 320px;
}
#props-panel-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 4px;
}
#props-panel-header span {
font-size: 10px; color: #606068;
text-transform: uppercase; letter-spacing: 0.06em;
}
#props-panel .prop-row {
display: flex; align-items: baseline; gap: 6px; margin-top: 3px;
}
#props-panel .prop-label {
font-size: 10px; color: #808088; flex-shrink: 0; width: 46px;
}
#props-panel .prop-value {
font-size: 12px; color: #f0f0f0; font-family: monospace;
flex: 1; word-break: break-all;
}
</style>
</head>
<body>
<div id="toolbar">
<!-- Row 1: raw URL + FOV -->
<div class="trow">
<input id="url-input" class="ti" type="text"
placeholder="Paste ifc:// URL and press Enter, or fill fields below"
spellcheck="false">
<button id="clip-btn" class="tb-btn" title="Double-click model surface to add clipping plane">✂ clip</button>
<button id="clip-clear-btn" class="tb-btn" title="Remove all clipping planes" style="display:none">⊗ clips</button>
<button id="meta-btn" class="tb-btn" title="Model overview: types and storeys"></button>
<button id="bcf-btn" class="tb-btn" title="Export current view as a BCF issue">BCF</button>
<button id="copy-url-btn" class="tb-btn" title="Copy ifc:// URL to clipboard"></button>
<button id="new-issue-btn" class="tb-btn" title="Open new issue with this view URL">Issue</button>
<div id="fov-wrap">
FOV <input id="fov-input" class="ti" type="number" min="10" max="120" step="1"
title="Field of view (degrees)">°
</div>
</div>
<!-- Row 2: structured fields -->
<div class="trow">
<div class="field" style="flex:3">
<label>repo</label>
<input id="repo-input" class="ti" type="text"
placeholder="host/org/repo or git@host/org/repo" spellcheck="false">
</div>
<div class="field" style="flex:2">
<label>ref</label>
<input id="ref-input" class="ti" type="text" list="ref-list"
placeholder="heads/main or commit hash" spellcheck="false">
<button id="ref-pick-btn" class="tb-btn" title="Browse branches and tags"></button>
</div>
<datalist id="ref-list"></datalist>
<span id="ref-badge" style="display:none" title="Pinned to this commit — not tracking branch tip"></span>
<button id="ref-switch-btn" class="tb-btn" style="display:none"></button>
<div class="field" style="flex:2">
<label>path</label>
<input id="path-input" class="ti" type="text"
placeholder="model.ifc" spellcheck="false">
</div>
<div class="field" style="flex:1">
<label>selector</label>
<input id="selector-input" class="ti" type="text"
placeholder="IfcWall (viewer: type names only)"
title="Type-name union: IfcWall, IfcWall+IfcSlab. Property/attribute filters are applied server-side only."
spellcheck="false">
</div>
</div>
<!-- Row 3: BCF export/import form (hidden until BCF button clicked) -->
<div id="bcf-row" class="trow" style="display:none">
<button id="bcf-import-btn" class="tb-btn" title="Import a .bcf viewpoint to restore camera and selection">Import .bcf</button>
<input type="file" id="bcf-import-input" accept=".bcf" style="display:none">
<div class="field" style="flex:2">
<label>title</label>
<input id="bcf-title" class="ti" type="text" placeholder="IFC View" spellcheck="false">
</div>
<div class="field" style="flex:3">
<label>comment</label>
<input id="bcf-comment" class="ti" type="text" placeholder="Optional note" spellcheck="false">
</div>
<button id="bcf-cancel-btn" class="tb-btn"></button>
<button id="bcf-export-btn" class="tb-btn">Export .bcf</button>
</div>
</div>
<div id="viewer"></div>
<div id="meta-panel" style="display:none"></div>
<div id="status"></div>
<div id="props-panel" style="display:none">
<div id="props-panel-header">
<span>Element</span>
<button id="props-close-btn" class="tb-btn"></button>
</div>
<div class="prop-row">
<span class="prop-label">type</span>
<span id="props-type" class="prop-value"></span>
</div>
<div class="prop-row">
<span class="prop-label">name</span>
<span id="props-name" class="prop-value"></span>
</div>
<div class="prop-row">
<span class="prop-label">GlobalId</span>
<span id="props-guid" class="prop-value"></span>
<button id="props-copy-btn" class="tb-btn" title="Copy GlobalId to clipboard"></button>
</div>
</div>
<script type="module">
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 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;
// 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;
// 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.
// -----------------------------------------------------------------------
function loadUrl(url) {
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);
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: clear + focus the ref field so the datalist shows all refs.
const refPickBtn = document.getElementById("ref-pick-btn");
refPickBtn.addEventListener("click", () => {
refInput.value = "";
refInput.focus();
});
// Auto-navigate when user selects a datalist option (exact match).
refInput.addEventListener("input", () => {
const dl = document.getElementById("ref-list");
const known = [...dl.options].map(o => o.value);
if (known.includes(refInput.value.trim())) loadFromFields();
});
// 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 = `https://${p.host}/${p.repoSuffix}/issues/new` +
`?title=${encodeURIComponent(title)}&body=${encodeURIComponent(src)}`;
window.open(issueUrl, "_blank", "noopener");
});
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,
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()));
}
async function applySelector(components, model, selectorStr, srcUrl, visibility = "isolate", highlighter = null) {
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 === "highlight" && highlighter) {
await highlighter.highlightByID("select", matching, true, false);
} else {
await hider.isolate(matching);
}
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;
if (visibility === "highlight" && highlighter) {
await highlighter.highlightByID("select", { [model.modelId]: localIds }, true, false);
} else {
await hider.isolate({ [model.modelId]: localIds });
}
}
// -----------------------------------------------------------------------
// Place the camera from ifc:// URL camera=/fov=/scale= params.
// -----------------------------------------------------------------------
async function applyCameraParam(controls, camera) {
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 apply the view-to-world scale.
await controls.setOrthoCamera();
const s = parseFloat(scaleStr);
if (!isNaN(s)) {
camera.top = s / 2;
camera.bottom = -s / 2;
camera.updateProjectionMatrix();
}
fovInput.value = "";
} else if (fovStr && camera.isPerspectiveCamera) {
camera.fov = Math.max(10, Math.min(120, parseFloat(fovStr)));
camera.updateProjectionMatrix();
fovInput.value = Math.round(camera.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;
}
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,"&amp;").replace(/</g,"&lt;")
.replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
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></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);
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.map)) 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>");
for (const name of storeyNames) {
html.push(
`<div class="meta-row storey-row" data-storey="${name.replace(/"/g, "&quot;")}">` +
`<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.
let activeRow = null;
for (const row of metaPanel.querySelectorAll(".storey-row")) {
row.addEventListener("click", async () => {
const name = row.dataset.storey;
if (activeRow === row) {
// Toggle off: show all.
activeRow = null;
row.classList.remove("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);
world.camera = new OBC.OrthoPerspectiveCamera(components);
world.scene.setup();
components.init();
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");
world.scene.three.add(model.object);
const hasCam = await applyCameraParam(cameraControls, threeCamera);
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, highlighter);
}
await fragments.core.update(true);
syncCameraUrl(threeCamera);
cameraControls.addEventListener("rest", () => syncCameraUrl(threeCamera));
populateMetaPanel(components).catch(err => {
console.warn("Metadata panel unavailable:", err.message);
});
statusEl.textContent = "";
} catch (err) {
statusEl.textContent = `Render error: ${err.message}`;
console.error(err);
}
}
main();
</script>
</body>
</html>