From 36f528a25460282736e7f874c3aca732b626b562 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 26 Apr 2026 15:50:28 +0100 Subject: [PATCH] viewer: extract inline JS into viewer.js viewer.html was ~1250 lines of almost entirely JavaScript. The script block is now a single + diff --git a/forgejo/custom/public/assets/viewer.js b/forgejo/custom/public/assets/viewer.js new file mode 100644 index 0000000..434b365 --- /dev/null +++ b/forgejo/custom/public/assets/viewer.js @@ -0,0 +1,1160 @@ +// 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,"""); + } + function xyz(tag, a, b, c) { + return `<${tag}>${a}${b}${c}`; + } + + const versionXml = ` + + 2.1 +`; + + let viewpointXml = null; + if (vpGuid && cam) { + const [px,py,pz, dx,dy,dz, ux,uy,uz] = cam; + const camXml = fov != null + ? ` + ${xyz("CameraViewPoint",px,py,pz)} + ${xyz("CameraDirection",dx,dy,dz)} + ${xyz("CameraUpVector",ux,uy,uz)} + ${fov} + ` + : scale != null + ? ` + ${xyz("CameraViewPoint",px,py,pz)} + ${xyz("CameraDirection",dx,dy,dz)} + ${xyz("CameraUpVector",ux,uy,uz)} + ${scale} + ` + : ""; + const clipsXml = clips.length + ? ` + ${clips.map(([cx,cy,cz,nx,ny,nz]) => + `${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}` + ).join("\n ")} + ` : ""; + // Reflect visibility mode: isolate → hide everything by default, + // ghost/highlight → show everything by default. + const defaultVis = visibility === "isolate" ? "false" : "true"; + viewpointXml = ` + + + ${camXml} + ${clipsXml} +`; + } + + const desc = selector + ? `${esc(src)}\nselector: ${esc(selector)}` + : esc(src); + const vpEntry = vpGuid + ? `viewpoint.bcfv` + + (snapshotB64 ? `snapshot.png` : "") + + `` + : ""; + const cmtEntry = comment + ? ` + ${now}anonymous + ${esc(comment)} + ${vpGuid ? `` : ""} + ` : ""; + + const markupXml = ` + + + ${esc(title || "IFC View")} + ${desc} + ${now} + anonymous + ${vpEntry} + + ${cmtEntry} +`; + + 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("

Types

"); + for (const { name, count } of types) { + html.push( + `
` + + `${name}` + + `${count}` + + `
` + ); + } + } + + if (storeyNames.length) { + html.push("

Storeys

"); + html.push( + `
` + + `All storeys` + + `
` + ); + for (const name of storeyNames) { + html.push( + `
` + + `${name}` + + `
` + ); + } + } + + if (!types.length && !storeyNames.length) { + html.push('
No metadata
'); + } + + 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 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();