ifcurl/forgejo/custom/public/assets/viewer.html
Bruno Postle 459047c42a viewer.html: implement selector= parameter
applySelector() classifies elements via OBC.Classifier.byCategory(), then
maps the selector type names to matching categories using prefix matching
(so IfcWall also covers IfcWallStandardCase), and calls hider.isolate() to
show only matching elements. Supports simple type names and + union syntax.

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

503 lines
20 KiB
HTML

<!DOCTYPE html>
<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; }
/* 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 to load";
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;
}
</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"
}
}
</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">
<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" spellcheck="false">
</div>
</div>
</div>
<div id="viewer"></div>
<div id="status"></div>
<script type="module">
import * as THREE from "three";
import * as OBC from "@thatopen/components";
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");
// 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;
// -----------------------------------------------------------------------
// Parse an ifc:// URL into its components.
// -----------------------------------------------------------------------
function parseIfcUrl(raw) {
if (!raw?.startsWith("ifc://")) return null;
const body = raw.slice("ifc://".length);
const slashIdx = body.indexOf("/");
const authority = slashIdx < 0 ? body : body.slice(0, slashIdx);
const pathAndQuery = slashIdx < 0 ? "" : body.slice(slashIdx + 1);
const atInAuth = authority.indexOf("@");
const userAt = atInAuth < 0 ? "" : authority.slice(0, atInAuth + 1);
const host = atInAuth < 0 ? authority : authority.slice(atInAuth + 1);
const qIdx = pathAndQuery.indexOf("?");
const pathPart = qIdx < 0 ? pathAndQuery : pathAndQuery.slice(0, qIdx);
const qs = new URLSearchParams(qIdx < 0 ? "" : pathAndQuery.slice(qIdx + 1));
const atIdx = pathPart.lastIndexOf("@");
const repoSuffix = atIdx < 0 ? pathPart : pathPart.slice(0, atIdx);
const ref = atIdx < 0 ? "" : pathPart.slice(atIdx + 1);
return {
repo: userAt + host + "/" + repoSuffix,
host, userAt, repoSuffix, ref,
path: qs.get("path") ?? "",
selector: qs.get("selector") ?? "",
camera: qs.get("camera") ?? "",
fov: qs.get("fov") ?? "",
scale: qs.get("scale") ?? "",
};
}
// -----------------------------------------------------------------------
// Build an ifc:// URL from structured fields (no camera — will fit to box).
// -----------------------------------------------------------------------
function buildIfcUrl() {
const repo = repoInput.value.trim();
const ref = refInput.value.trim();
const path = pathInput.value.trim();
const sel = selectorInput.value.trim();
if (!repo || !ref) return null;
const qs = new URLSearchParams();
if (path) qs.set("path", path);
if (sel) qs.set("selector", sel);
const qsStr = qs.toString().replace(/%2C/g, ",");
return `ifc://${repo}@${ref}${qsStr ? "?" + qsStr : ""}`;
}
// 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 allInputs = [urlInput, fovInput, repoInput, refInput, pathInput, selectorInput];
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", e => {
e.preventDefault();
document.body.classList.remove("drag-over");
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 = "";
}
const newIfcUrl = base + "?" + qs.toString().replace(/%2C/g, ",");
urlInput.value = newIfcUrl;
const pageUrl = new URL(window.location.href);
pageUrl.searchParams.set("url", newIfcUrl);
history.replaceState(null, "", pageUrl.toString());
}
// -----------------------------------------------------------------------
// 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");
if (fovStr && camera.isPerspectiveCamera) {
camera.fov = Math.max(10, Math.min(120, parseFloat(fovStr)));
camera.updateProjectionMatrix();
fovInput.value = Math.round(camera.fov);
}
return true;
}
// -----------------------------------------------------------------------
// Convert an ifc:// URL to an HTTPS raw file URL.
// Handles SSH user@ prefix, GitHub, GitLab, and Forgejo/Gitea.
// -----------------------------------------------------------------------
function toRawUrl(raw) {
const body = raw.slice("ifc://".length);
const slashIdx = body.indexOf("/");
const authority = slashIdx < 0 ? body : body.slice(0, slashIdx);
const pathAndQuery = slashIdx < 0 ? "" : body.slice(slashIdx + 1);
const atInAuth = authority.indexOf("@");
const host = atInAuth < 0 ? authority : authority.slice(atInAuth + 1);
const qIdx = pathAndQuery.indexOf("?");
const pathPart = qIdx < 0 ? pathAndQuery : pathAndQuery.slice(0, qIdx);
const qs = new URLSearchParams(qIdx < 0 ? "" : pathAndQuery.slice(qIdx + 1));
const filePath = qs.get("path") ?? "";
const atIdx = pathPart.lastIndexOf("@");
const repoPath = atIdx < 0 ? pathPart : pathPart.slice(0, atIdx);
const ref = atIdx < 0 ? "" : pathPart.slice(atIdx + 1);
const plainRef = ref.startsWith("heads/") ? ref.slice(6)
: ref.startsWith("tags/") ? ref.slice(5)
: ref;
if (host === "github.com" || host.endsWith(".github.com")) {
return `https://raw.githubusercontent.com/${repoPath}/${plainRef}/${filePath}`;
}
if (host === "gitlab.com" || host.includes("gitlab")) {
const proto = host.startsWith("localhost") ? "http" : "https";
return `${proto}://${host}/${repoPath}/-/raw/${plainRef}/${filePath}`;
}
const refSeg = ref.startsWith("heads/") ? "branch/" + ref.slice(6)
: ref.startsWith("tags/") ? "tag/" + ref.slice(5)
: "commit/" + ref;
const proto = host.startsWith("localhost") ? "http" : "https";
return `${proto}://${host}/${repoPath}/raw/${refSeg}/${filePath}`;
}
// -----------------------------------------------------------------------
// 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}`);
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 qIdxSel = ifcUrl.indexOf("?");
const selectorStr = new URLSearchParams(
qIdxSel < 0 ? "" : ifcUrl.slice(qIdxSel + 1)
).get("selector") ?? "";
if (selectorStr) {
statusEl.textContent = "Applying selector…";
await applySelector(components, selectorStr);
}
await fragments.core.update(true);
syncCameraUrl(threeCamera);
cameraControls.addEventListener("rest", () => syncCameraUrl(threeCamera));
statusEl.textContent = "";
} catch (err) {
statusEl.textContent = `Render error: ${err.message}`;
console.error(err);
}
}
main();
</script>
</body>
</html>