ifcurl/forgejo/custom/public/assets/viewer.html
Bruno Postle ee317bef75 footer.tmpl: View in 3D on history page; urlTerminators fix; selector docs
- footer.tmpl: refactor to inject per-commit "View in 3D" links on the file
  history page, alongside the existing file view page button
- ifc_url.go: add ')' to urlTerminators so bare ifc:// URLs followed by a
  closing parenthesis are correctly terminated
- ifc_url_test.go: add tests for @ in query value, ) terminator, trailing
  period, and SERVICE_TOKEN presence/absence in preview URL
- viewer.html: selector input placeholder and title attribute now document
  that the browser viewer only supports type-name union selectors
- README: document viewer selector limitation vs full server-side behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:45:31 +01:00

926 lines
37 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); }
/* 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;
}
/* 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>
<script type="importmap">
{
"imports": {
"three": "https://esm.sh/three@0.177.0",
"three/addons/": "https://esm.sh/three@0.177.0/examples/jsm/",
"web-ifc": "https://unpkg.com/web-ifc@0.0.75/web-ifc-api.js",
"@thatopen/fragments": "https://esm.sh/@thatopen/fragments@3.3.1?external=three,web-ifc",
"@thatopen/components": "https://esm.sh/@thatopen/components@3.3.1?external=three,web-ifc,@thatopen/fragments",
"jszip": "https://esm.sh/jszip@3.10.1"
}
}
</script>
</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="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"
placeholder="heads/main or commit hash" spellcheck="false">
</div>
<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="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 * as THREE from "three";
import * as OBC from "@thatopen/components";
import JSZip from "jszip";
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;
// -----------------------------------------------------------------------
// 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); }
});
// 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") { const u = buildIfcUrl(); if (u) loadUrl(u); }
});
}
// Disable camera controls while any input has focus.
function onInputFocus() { if (cameraControls) cameraControls.enabled = false; }
function onInputBlur() { if (cameraControls) cameraControls.enabled = true; }
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());
}
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.
// Supports simple type names and + union: "IfcWall+IfcSlab".
// Subtypes matched by prefix: IfcWall matches IfcWallStandardCase, etc.
// Attribute/property filters (.Name=, #id, [pset]) are not yet supported.
// -----------------------------------------------------------------------
async function applySelector(components, selectorStr) {
const userTypes = selectorStr
.split("+")
.map(g => g.trim().replace(/[.,\s#[].*/s, "").trim().toUpperCase())
.filter(Boolean);
if (!userTypes.length) return;
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;
const hider = components.get(OBC.Hider);
await hider.isolate(matching);
}
// -----------------------------------------------------------------------
// 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 — build zip client-side and trigger download.
// -----------------------------------------------------------------------
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";
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 = "";
}
// -----------------------------------------------------------------------
// 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(
"https://unpkg.com/@thatopen/fragments@3.3.1/dist/Worker/worker.mjs"
);
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: "https://unpkg.com/web-ifc@0.0.75/", 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 selectorStr = qsPost.get("selector") ?? "";
if (selectorStr) {
statusEl.textContent = "Applying selector…";
await applySelector(components, selectorStr);
}
await fragments.core.update(true);
// Click-to-identify: highlight element and show its properties.
try {
const 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);
}
syncCameraUrl(threeCamera);
cameraControls.addEventListener("rest", () => syncCameraUrl(threeCamera));
statusEl.textContent = "";
} catch (err) {
statusEl.textContent = `Render error: ${err.message}`;
console.error(err);
}
}
main();
</script>
</body>
</html>