2026-04-23 20:48:36 +01:00
|
|
|
<!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; }
|
2026-04-23 22:32:09 +01:00
|
|
|
#viewer { position: fixed; inset: 0; top: var(--toolbar-h, 72px); }
|
2026-04-23 20:48:36 +01:00
|
|
|
#toolbar {
|
|
|
|
|
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
2026-04-23 22:32:09 +01:00
|
|
|
display: flex; flex-direction: column; gap: 6px;
|
|
|
|
|
padding: 8px 12px;
|
2026-04-23 20:48:36 +01:00
|
|
|
background: rgba(28,28,30,0.85); backdrop-filter: blur(8px);
|
|
|
|
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
|
|
|
|
}
|
2026-04-23 22:32:09 +01:00
|
|
|
.trow { display: flex; align-items: center; gap: 6px; }
|
|
|
|
|
/* Shared input style */
|
|
|
|
|
.ti {
|
|
|
|
|
font-size: 12px; color: #f0f0f0; font-family: monospace;
|
2026-04-23 21:15:30 +01:00
|
|
|
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12);
|
2026-04-23 22:32:09 +01:00
|
|
|
border-radius: 4px; padding: 3px 7px; outline: none; min-width: 0;
|
2026-04-23 20:48:36 +01:00
|
|
|
}
|
2026-04-23 22:32:09 +01:00
|
|
|
.ti:focus { border-color: rgba(255,255,255,0.3); }
|
|
|
|
|
.ti::placeholder { color: #505058; }
|
|
|
|
|
/* Row 1 */
|
|
|
|
|
#url-input { flex: 1; }
|
2026-04-23 22:08:53 +01:00
|
|
|
#fov-wrap {
|
|
|
|
|
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
2026-04-23 22:32:09 +01:00
|
|
|
font-size: 11px; color: #a0a0a8;
|
2026-04-23 22:08:53 +01:00
|
|
|
}
|
2026-04-23 22:32:09 +01:00
|
|
|
#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 */
|
2026-04-23 20:48:36 +01:00
|
|
|
#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; }
|
2026-04-23 21:15:30 +01:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-23 20:48:36 +01:00
|
|
|
</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">
|
2026-04-23 22:32:09 +01:00
|
|
|
<!-- 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>
|
2026-04-23 22:08:53 +01:00
|
|
|
</div>
|
2026-04-23 20:48:36 +01:00
|
|
|
</div>
|
|
|
|
|
<div id="viewer"></div>
|
2026-04-23 21:15:30 +01:00
|
|
|
<div id="status"></div>
|
2026-04-23 20:48:36 +01:00
|
|
|
|
|
|
|
|
<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");
|
2026-04-23 21:15:30 +01:00
|
|
|
const urlInput = document.getElementById("url-input");
|
2026-04-23 22:08:53 +01:00
|
|
|
const fovInput = document.getElementById("fov-input");
|
2026-04-23 21:41:40 +01:00
|
|
|
const toolbar = document.getElementById("toolbar");
|
2026-04-23 22:32:09 +01:00
|
|
|
const repoInput = document.getElementById("repo-input");
|
|
|
|
|
const refInput = document.getElementById("ref-input");
|
|
|
|
|
const pathInput = document.getElementById("path-input");
|
|
|
|
|
const selectorInput = document.getElementById("selector-input");
|
2026-04-23 21:41:40 +01:00
|
|
|
|
2026-04-23 22:32:09 +01:00
|
|
|
// Keep viewer below the full toolbar height.
|
2026-04-23 21:41:40 +01:00
|
|
|
const resizeObs = new ResizeObserver(() => {
|
|
|
|
|
document.documentElement.style.setProperty("--toolbar-h", toolbar.offsetHeight + "px");
|
|
|
|
|
});
|
|
|
|
|
resizeObs.observe(toolbar);
|
|
|
|
|
|
|
|
|
|
let cameraControls = null;
|
2026-04-23 22:08:53 +01:00
|
|
|
let threeCamera = null;
|
2026-04-23 21:15:30 +01:00
|
|
|
|
2026-04-23 22:32:09 +01:00
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2026-04-23 21:15:30 +01:00
|
|
|
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 => {
|
2026-04-23 22:08:53 +01:00
|
|
|
if (e.key === "Enter") { const v = urlInput.value.trim(); if (v) loadUrl(v); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-23 22:32:09 +01:00
|
|
|
// 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) {
|
2026-04-23 22:08:53 +01:00
|
|
|
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);
|
2026-04-23 21:15:30 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
2026-04-23 22:32:09 +01:00
|
|
|
// Drag-and-drop: accept ifc:// URLs or viewer page URLs.
|
2026-04-23 21:15:30 +01:00
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
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");
|
2026-04-23 22:08:53 +01:00
|
|
|
if (p?.startsWith("ifc://")) { loadUrl(p); return; }
|
2026-04-23 21:15:30 +01:00
|
|
|
} catch { /* not a URL */ }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-23 20:48:36 +01:00
|
|
|
|
2026-04-23 21:15:30 +01:00
|
|
|
// -----------------------------------------------------------------------
|
2026-04-23 22:32:09 +01:00
|
|
|
// Coordinate transforms: IFC (Z-up) ↔ Three.js (Y-up).
|
2026-04-23 21:15:30 +01:00
|
|
|
// 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);
|
2026-04-23 20:48:36 +01:00
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
2026-04-23 22:32:09 +01:00
|
|
|
// 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.
|
2026-04-23 20:48:36 +01:00
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
function syncCameraUrl(camera) {
|
|
|
|
|
const pos = camera.position;
|
|
|
|
|
const dir = new THREE.Vector3();
|
|
|
|
|
camera.getWorldDirection(dir);
|
|
|
|
|
const up = camera.up;
|
|
|
|
|
|
2026-04-23 22:32:09 +01:00
|
|
|
const f = v => parseFloat(v.toFixed(4));
|
2026-04-23 21:15:30 +01:00
|
|
|
const ip = toIfc(pos), id = toIfc(dir), iu = toIfc(up);
|
2026-04-23 20:48:36 +01:00
|
|
|
const cameraParam = [
|
2026-04-23 21:15:30 +01:00
|
|
|
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),
|
2026-04-23 20:48:36 +01:00
|
|
|
].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");
|
2026-04-23 22:08:53 +01:00
|
|
|
fovInput.value = Math.round(camera.fov);
|
2026-04-23 20:48:36 +01:00
|
|
|
} else {
|
|
|
|
|
qs.set("scale", f(camera.top - camera.bottom).toString());
|
|
|
|
|
qs.delete("fov");
|
2026-04-23 22:08:53 +01:00
|
|
|
fovInput.value = "";
|
2026-04-23 20:48:36 +01:00
|
|
|
}
|
|
|
|
|
const newIfcUrl = base + "?" + qs.toString().replace(/%2C/g, ",");
|
|
|
|
|
|
2026-04-23 21:15:30 +01:00
|
|
|
urlInput.value = newIfcUrl;
|
2026-04-23 20:48:36 +01:00
|
|
|
|
|
|
|
|
const pageUrl = new URL(window.location.href);
|
|
|
|
|
pageUrl.searchParams.set("url", newIfcUrl);
|
|
|
|
|
history.replaceState(null, "", pageUrl.toString());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 07:01:41 +01:00
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 21:15:30 +01:00
|
|
|
// -----------------------------------------------------------------------
|
2026-04-23 22:32:09 +01:00
|
|
|
// Place the camera from ifc:// URL camera=/fov=/scale= params.
|
2026-04-23 21:15:30 +01:00
|
|
|
// -----------------------------------------------------------------------
|
2026-04-23 22:08:53 +01:00
|
|
|
async function applyCameraParam(controls, camera) {
|
2026-04-23 21:15:30 +01:00
|
|
|
const qIdx = ifcUrl.indexOf("?");
|
|
|
|
|
if (qIdx < 0) return false;
|
|
|
|
|
const qs = new URLSearchParams(ifcUrl.slice(qIdx + 1));
|
2026-04-23 22:32:09 +01:00
|
|
|
|
2026-04-23 21:15:30 +01:00
|
|
|
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);
|
2026-04-23 22:08:53 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-04-23 21:15:30 +01:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 20:48:36 +01:00
|
|
|
// -----------------------------------------------------------------------
|
2026-04-23 22:32:09 +01:00
|
|
|
// Convert an ifc:// URL to an HTTPS raw file URL.
|
|
|
|
|
// Handles SSH user@ prefix, GitHub, GitLab, and Forgejo/Gitea.
|
2026-04-23 20:48:36 +01:00
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
function toRawUrl(raw) {
|
2026-04-23 22:08:53 +01:00
|
|
|
const body = raw.slice("ifc://".length);
|
|
|
|
|
|
2026-04-23 22:32:09 +01:00
|
|
|
const slashIdx = body.indexOf("/");
|
2026-04-23 22:08:53 +01:00
|
|
|
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);
|
|
|
|
|
|
2026-04-23 22:32:09 +01:00
|
|
|
const qIdx = pathAndQuery.indexOf("?");
|
2026-04-23 22:08:53 +01:00
|
|
|
const pathPart = qIdx < 0 ? pathAndQuery : pathAndQuery.slice(0, qIdx);
|
2026-04-23 22:32:09 +01:00
|
|
|
const qs = new URLSearchParams(qIdx < 0 ? "" : pathAndQuery.slice(qIdx + 1));
|
2026-04-23 20:48:36 +01:00
|
|
|
const filePath = qs.get("path") ?? "";
|
|
|
|
|
|
2026-04-23 22:32:09 +01:00
|
|
|
const atIdx = pathPart.lastIndexOf("@");
|
2026-04-23 22:08:53 +01:00
|
|
|
const repoPath = atIdx < 0 ? pathPart : pathPart.slice(0, atIdx);
|
|
|
|
|
const ref = atIdx < 0 ? "" : pathPart.slice(atIdx + 1);
|
2026-04-23 20:48:36 +01:00
|
|
|
|
2026-04-23 22:08:53 +01:00
|
|
|
const plainRef = ref.startsWith("heads/") ? ref.slice(6)
|
|
|
|
|
: ref.startsWith("tags/") ? ref.slice(5)
|
|
|
|
|
: ref;
|
2026-04-23 20:48:36 +01:00
|
|
|
|
2026-04-23 22:08:53 +01:00
|
|
|
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}`;
|
2026-04-23 20:48:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// Main
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
async function main() {
|
|
|
|
|
if (!ifcUrl) {
|
2026-04-23 22:32:09 +01:00
|
|
|
statusEl.textContent = "Fill in the fields above and press Enter, or paste an ifc:// URL.";
|
2026-04-23 20:48:36 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 21:15:30 +01:00
|
|
|
statusEl.textContent = "Loading…";
|
|
|
|
|
|
2026-04-23 20:48:36 +01:00
|
|
|
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();
|
2026-04-23 22:08:53 +01:00
|
|
|
|
2026-04-23 21:41:40 +01:00
|
|
|
cameraControls = world.camera.controls;
|
2026-04-23 22:08:53 +01:00
|
|
|
threeCamera = world.camera.three;
|
2026-04-23 20:48:36 +01:00
|
|
|
|
2026-04-23 22:08:53 +01:00
|
|
|
// Firefox blocks cross-origin module workers; wrap in a blob URL.
|
2026-04-23 22:32:09 +01:00
|
|
|
const fragments = components.get(OBC.FragmentsManager);
|
2026-04-23 20:48:36 +01:00
|
|
|
const workerResp = await fetch(
|
|
|
|
|
"https://unpkg.com/@thatopen/fragments@3.3.1/dist/Worker/worker.mjs"
|
|
|
|
|
);
|
2026-04-23 22:32:09 +01:00
|
|
|
fragments.init(URL.createObjectURL(
|
2026-04-23 20:48:36 +01:00
|
|
|
new Blob([await workerResp.text()], { type: "application/javascript" })
|
2026-04-23 22:32:09 +01:00
|
|
|
));
|
2026-04-23 20:48:36 +01:00
|
|
|
|
|
|
|
|
const ifcLoader = components.get(OBC.IfcLoader);
|
|
|
|
|
await ifcLoader.setup({
|
|
|
|
|
autoSetWasm: false,
|
2026-04-23 22:08:53 +01:00
|
|
|
wasm: { path: "https://unpkg.com/web-ifc@0.0.75/", absolute: true },
|
2026-04-23 20:48:36 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-04-23 22:32:09 +01:00
|
|
|
const model = await ifcLoader.load(buffer, true, "model");
|
2026-04-23 20:48:36 +01:00
|
|
|
world.scene.three.add(model.object);
|
|
|
|
|
|
2026-04-23 22:32:09 +01:00
|
|
|
const hasCam = await applyCameraParam(cameraControls, threeCamera);
|
|
|
|
|
if (!hasCam && !model.box.isEmpty()) {
|
|
|
|
|
await cameraControls.fitToBox(model.box, false);
|
2026-04-23 20:48:36 +01:00
|
|
|
}
|
2026-04-23 22:08:53 +01:00
|
|
|
model.useCamera(threeCamera);
|
2026-04-24 07:01:41 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 20:48:36 +01:00
|
|
|
await fragments.core.update(true);
|
|
|
|
|
|
2026-04-23 22:08:53 +01:00
|
|
|
syncCameraUrl(threeCamera);
|
2026-04-23 22:32:09 +01:00
|
|
|
cameraControls.addEventListener("rest", () => syncCameraUrl(threeCamera));
|
2026-04-23 20:48:36 +01:00
|
|
|
|
|
|
|
|
statusEl.textContent = "";
|
|
|
|
|
} catch (err) {
|
|
|
|
|
statusEl.textContent = `Render error: ${err.message}`;
|
|
|
|
|
console.error(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|