mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
viewer.html: URL input, drag-and-drop, IFC coordinate transform, camera placement
- Replace url-display span with editable input; Enter or drag-and-drop loads a new ifc:// URL by navigating to the same page with updated ?url= param - Fix syncCameraUrl: convert Three.js → IFC world coords (x,-z,y) before serialising camera= param so URLs are in Z-up IFC space - Apply IFC → Three.js transform (x,z,-y) when placing camera from URL param - Drop viewer.py: viewer is now a Forgejo static file; service.py has no /viewer or /proxy endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9e0ef5b41f
commit
81f022d4af
3 changed files with 112 additions and 271 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{"id":"ifcurl-1sh","title":"viewer.html: place camera from ifc:// URL camera= param on load","description":"When viewer.html loads with a camera= param in the ifc:// URL (9 comma-separated floats: position, direction, up in IFC world coordinates), apply the IFC→Three.js coordinate transform (Y/Z swap, one negated) and set the Three.js camera position, direction and up vector accordingly. Depends on the syncCameraUrl fix (ifcurl-j2s) so the forward and inverse transforms are consistent.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T20:05:57Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:57Z","dependencies":[{"issue_id":"ifcurl-1sh","depends_on_id":"ifcurl-j2s","type":"blocks","created_at":"2026-04-23T21:06:08Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"ifcurl-940","title":"viewer.html: URL input box and drag-and-drop target","description":"Add a text entry box and a drag-and-drop target to viewer.html so any ifc:// URL can be loaded without navigating from a Forgejo page. The input should accept a pasted ifc:// URL and immediately load it into the viewer. Drag-and-drop should accept a dropped ifc:// URL or a .ifc file URL. This makes the viewer usable as a standalone tool.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T20:05:43Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:43Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"ifcurl-cmd","title":"Apply forward IFC→Three.js transform when setting camera from URL params","description":"When the viewer page loads with a camera= param in the ifc:// URL, the 9 floats are in IFC world coordinates. Apply the IFC→Three.js transform to convert them before passing them to the Three.js camera / camera-controls. This is the forward direction of the same transform used in the inverse task.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-23T06:12:38Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:12:38Z","dependencies":[{"issue_id":"ifcurl-cmd","depends_on_id":"ifcurl-ldk","type":"blocks","created_at":"2026-04-23T07:14:10Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"ifcurl-j2s","title":"Apply inverse IFC↔Three.js transform in syncCameraUrl","description":"Using the transform found in the research task, apply its inverse to camera.position, the direction vector, and camera.up before serialising them as the ifc:// camera param in syncCameraUrl. The result should be in IFC world coordinates (Z-up, matching BCF viewpoint conventions) not Three.js scene space.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-23T06:12:37Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:12:37Z","dependencies":[{"issue_id":"ifcurl-j2s","depends_on_id":"ifcurl-ldk","type":"blocks","created_at":"2026-04-23T07:14:09Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"ifcurl-1sh","title":"viewer.html: place camera from ifc:// URL camera= param on load","description":"When viewer.html loads with a camera= param in the ifc:// URL (9 comma-separated floats: position, direction, up in IFC world coordinates), apply the IFC→Three.js coordinate transform (Y/Z swap, one negated) and set the Three.js camera position, direction and up vector accordingly. Depends on the syncCameraUrl fix (ifcurl-j2s) so the forward and inverse transforms are consistent.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T20:05:57Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:09:26Z","started_at":"2026-04-23T20:09:26Z","dependencies":[{"issue_id":"ifcurl-1sh","depends_on_id":"ifcurl-j2s","type":"blocks","created_at":"2026-04-23T21:06:08Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"ifcurl-940","title":"viewer.html: URL input box and drag-and-drop target","description":"Add a text entry box and a drag-and-drop target to viewer.html so any ifc:// URL can be loaded without navigating from a Forgejo page. The input should accept a pasted ifc:// URL and immediately load it into the viewer. Drag-and-drop should accept a dropped ifc:// URL or a .ifc file URL. This makes the viewer usable as a standalone tool.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T20:05:43Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:09:23Z","started_at":"2026-04-23T20:09:23Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"ifcurl-cmd","title":"Apply forward IFC→Three.js transform when setting camera from URL params","description":"When the viewer page loads with a camera= param in the ifc:// URL, the 9 floats are in IFC world coordinates. Apply the IFC→Three.js transform to convert them before passing them to the Three.js camera / camera-controls. This is the forward direction of the same transform used in the inverse task.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T06:12:38Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:09:25Z","started_at":"2026-04-23T20:09:25Z","dependencies":[{"issue_id":"ifcurl-cmd","depends_on_id":"ifcurl-ldk","type":"blocks","created_at":"2026-04-23T07:14:10Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"ifcurl-j2s","title":"Apply inverse IFC↔Three.js transform in syncCameraUrl","description":"Using the transform found in the research task, apply its inverse to camera.position, the direction vector, and camera.up before serialising them as the ifc:// camera param in syncCameraUrl. The result should be in IFC world coordinates (Z-up, matching BCF viewpoint conventions) not Three.js scene space.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T06:12:37Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:09:25Z","started_at":"2026-04-23T20:09:25Z","dependencies":[{"issue_id":"ifcurl-j2s","depends_on_id":"ifcurl-ldk","type":"blocks","created_at":"2026-04-23T07:14:09Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"ifcurl-ldk","title":"Find IFC↔Three.js coordinate transform in @thatopen/components source","description":"Grep ~/src/engine_components and ~/src/engine_fragment for the matrix or transform applied when an IFC model is loaded into Three.js scene space. IFC uses Z-up; Three.js uses Y-up. @thatopen/components almost certainly applies a 90° X-axis rotation (and possibly a scale) when constructing the Three.Group from IFC geometry. Record the exact transform so the inverse can be applied in syncCameraUrl.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T06:12:36Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:29Z","started_at":"2026-04-23T06:19:35Z","closed_at":"2026-04-23T20:05:29Z","close_reason":"Transform identified: IFC→Three.js is a Y/Z swap with one axis negated (Z-up to Y-up). Unblocks j2s and cmd.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||
{"id":"ifcurl-49o","title":"Deploy footer.tmpl to Forgejo custom templates directory","description":"Copy forgejo/templates/custom/footer.tmpl from the repo to /etc/forgejo/templates/custom/footer.tmpl on the Forgejo server. No Forgejo restart needed — custom templates are loaded dynamically. Verify the View in 3D button appears on an .ifc file page.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T06:12:22Z","created_by":"Bruno Postle","updated_at":"2026-04-23T19:47:16Z","closed_at":"2026-04-23T19:47:16Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-49o","depends_on_id":"ifcurl-ohq","type":"blocks","created_at":"2026-04-23T07:14:08Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"ifcurl-ohq","title":"Update footer.tmpl: point viewer button to Forgejo static file URL","description":"Change the VIEWER_BASE URL in forgejo/templates/custom/footer.tmpl from http://localhost:8000/viewer to the Forgejo static file URL determined by the research task (ifcurl-5t3). The button href should become something like /assets/viewer.html?url=ifc://... rather than the ifcurl service URL.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T06:12:21Z","created_by":"Bruno Postle","updated_at":"2026-04-23T19:47:15Z","started_at":"2026-04-23T18:48:37Z","closed_at":"2026-04-23T19:47:15Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-ohq","depends_on_id":"ifcurl-5t3","type":"blocks","created_at":"2026-04-23T07:14:07Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,13 @@
|
|||
background: rgba(28,28,30,0.85); backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
#url-display {
|
||||
flex: 1; font-size: 12px; color: #a0a0a8; font-family: monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
#url-input {
|
||||
flex: 1; 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 8px; outline: none; min-width: 0;
|
||||
}
|
||||
#url-input:focus { border-color: rgba(255,255,255,0.3); }
|
||||
#url-input::placeholder { color: #606068; }
|
||||
#status {
|
||||
position: fixed; bottom: 12px; left: 50%;
|
||||
transform: translateX(-50%); z-index: 10;
|
||||
|
|
@ -28,6 +31,14 @@
|
|||
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">
|
||||
{
|
||||
|
|
@ -43,10 +54,10 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<span id="url-display"></span>
|
||||
<input id="url-input" type="text" placeholder="Paste ifc:// URL and press Enter" spellcheck="false">
|
||||
</div>
|
||||
<div id="viewer"></div>
|
||||
<div id="status">Loading…</div>
|
||||
<div id="status"></div>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
|
|
@ -55,14 +66,69 @@
|
|||
const params = new URLSearchParams(window.location.search);
|
||||
const ifcUrl = params.get("url") ?? "";
|
||||
const statusEl = document.getElementById("status");
|
||||
const urlEl = document.getElementById("url-display");
|
||||
const urlInput = document.getElementById("url-input");
|
||||
|
||||
urlEl.textContent = ifcUrl || "No ifc:// URL provided";
|
||||
urlEl.title = ifcUrl;
|
||||
urlInput.value = ifcUrl;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rebuild the ifc:// URL with the current camera position, direction, and
|
||||
// up vector, update the toolbar display and the browser address bar.
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drag-and-drop: accept ifc:// URLs dragged onto the page.
|
||||
// Also handles viewer page URLs containing ?url=ifc://...
|
||||
// -----------------------------------------------------------------------
|
||||
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 && p.startsWith("ifc://")) { loadUrl(p); return; }
|
||||
} catch { /* not a URL */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Coordinate transforms between IFC world space (Z-up) and 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);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rebuild the ifc:// URL with the current camera viewpoint in IFC world
|
||||
// coordinates, and push it to the toolbar and browser address bar.
|
||||
// -----------------------------------------------------------------------
|
||||
function syncCameraUrl(camera) {
|
||||
const pos = camera.position;
|
||||
|
|
@ -71,10 +137,11 @@
|
|||
const up = camera.up;
|
||||
|
||||
const f = v => parseFloat(v.toFixed(4));
|
||||
const ip = toIfc(pos), id = toIfc(dir), iu = toIfc(up);
|
||||
const cameraParam = [
|
||||
f(pos.x), f(pos.y), f(pos.z),
|
||||
f(dir.x), f(dir.y), f(dir.z),
|
||||
f(up.x), f(up.y), f(up.z),
|
||||
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("?");
|
||||
|
|
@ -90,14 +157,33 @@
|
|||
}
|
||||
const newIfcUrl = base + "?" + qs.toString().replace(/%2C/g, ",");
|
||||
|
||||
urlEl.textContent = newIfcUrl;
|
||||
urlEl.title = newIfcUrl;
|
||||
urlInput.value = newIfcUrl;
|
||||
|
||||
const pageUrl = new URL(window.location.href);
|
||||
pageUrl.searchParams.set("url", newIfcUrl);
|
||||
history.replaceState(null, "", pageUrl.toString());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// If the ifc:// URL contains a camera= param, place the camera there.
|
||||
// Returns true if the camera was positioned from the URL.
|
||||
// -----------------------------------------------------------------------
|
||||
async function applyCameraParam(controls) {
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Convert an ifc:// URL to a Forgejo raw file URL so the browser fetches
|
||||
// directly from Forgejo using its existing session (no proxy needed).
|
||||
|
|
@ -132,10 +218,12 @@
|
|||
// -----------------------------------------------------------------------
|
||||
async function main() {
|
||||
if (!ifcUrl) {
|
||||
statusEl.textContent = "No ifc:// URL provided.";
|
||||
statusEl.textContent = "Paste an ifc:// URL above or drag one onto the page.";
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = "Loading…";
|
||||
|
||||
// --- World setup ---
|
||||
const components = new OBC.Components();
|
||||
const worlds = components.get(OBC.Worlds);
|
||||
|
|
@ -148,7 +236,7 @@
|
|||
world.scene.setup();
|
||||
components.init();
|
||||
|
||||
// --- FragmentsManager: must be init'd before ifcLoader.load() ---
|
||||
// --- FragmentsManager ---
|
||||
// Firefox blocks cross-origin module workers; wrap in a blob URL so the
|
||||
// worker is always same-origin regardless of where it was fetched from.
|
||||
const fragments = components.get(OBC.FragmentsManager);
|
||||
|
|
@ -160,7 +248,7 @@
|
|||
);
|
||||
fragments.init(workerUrl);
|
||||
|
||||
// --- IfcLoader: wasm config goes into setup() ---
|
||||
// --- IfcLoader ---
|
||||
const ifcLoader = components.get(OBC.IfcLoader);
|
||||
await ifcLoader.setup({
|
||||
autoSetWasm: false,
|
||||
|
|
@ -189,7 +277,8 @@
|
|||
world.scene.three.add(model.object);
|
||||
|
||||
const box = model.box;
|
||||
if (!box.isEmpty()) {
|
||||
const hasCam = await applyCameraParam(world.camera.controls);
|
||||
if (!hasCam && !box.isEmpty()) {
|
||||
await world.camera.controls.fitToBox(box, false);
|
||||
}
|
||||
model.useCamera(world.camera.three);
|
||||
|
|
|
|||
248
ifcurl/viewer.py
248
ifcurl/viewer.py
|
|
@ -1,248 +0,0 @@
|
|||
# IFC URL — web IFC viewer page
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
"""Self-contained HTML viewer page served at GET /viewer.
|
||||
|
||||
Loads @thatopen/components v3 from CDN via importmap and renders an IFC model
|
||||
in the browser using WebGL.
|
||||
|
||||
CDN notes
|
||||
---------
|
||||
- ``web-ifc`` is loaded directly from unpkg (not via esm.sh) to avoid esm.sh
|
||||
injecting a Node.js process polyfill that makes web-ifc's browser ST-build
|
||||
environment check throw "not compiled for this environment".
|
||||
- ``@thatopen/fragments`` worker is fetched and wrapped in a same-origin blob
|
||||
URL because Firefox blocks cross-origin module workers.
|
||||
- web-ifc@0.0.75 is the latest version with a ``browser`` export condition;
|
||||
0.0.76+ dropped it, causing esm.sh to bundle the Node.js build by mistake.
|
||||
- Without COOP/COEP headers the page is not cross-origin isolated, so
|
||||
``crossOriginIsolated = false`` and web-ifc uses its single-threaded WASM
|
||||
build (no SharedArrayBuffer / pthreads needed).
|
||||
"""
|
||||
|
||||
VIEWER_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; }
|
||||
#toolbar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||||
background: rgba(28,28,30,0.85); backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
#url-display {
|
||||
flex: 1; font-size: 12px; color: #a0a0a8; font-family: monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
#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; }
|
||||
</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">
|
||||
<span id="url-display"></span>
|
||||
</div>
|
||||
<div id="viewer"></div>
|
||||
<div id="status">Loading\u2026</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 urlEl = document.getElementById("url-display");
|
||||
|
||||
urlEl.textContent = ifcUrl || "No ifc:// URL provided";
|
||||
urlEl.title = ifcUrl;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rebuild the ifc:// URL with the current camera position, direction, and
|
||||
// up vector, update the toolbar display and the browser address bar.
|
||||
// -----------------------------------------------------------------------
|
||||
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 cameraParam = [
|
||||
f(pos.x), f(pos.y), f(pos.z),
|
||||
f(dir.x), f(dir.y), f(dir.z),
|
||||
f(up.x), f(up.y), f(up.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");
|
||||
} else {
|
||||
// OrthographicCamera: world-space viewport height as scale
|
||||
qs.set("scale", f(camera.top - camera.bottom).toString());
|
||||
qs.delete("fov");
|
||||
}
|
||||
const newIfcUrl = base + "?" + qs.toString().replace(/%2C/g, ",");
|
||||
|
||||
urlEl.textContent = newIfcUrl;
|
||||
urlEl.title = newIfcUrl;
|
||||
|
||||
const pageUrl = new URL(window.location.href);
|
||||
pageUrl.searchParams.set("url", newIfcUrl);
|
||||
history.replaceState(null, "", pageUrl.toString());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Convert an ifc:// URL to a same-origin /proxy?url=... request so the
|
||||
// browser never makes a cross-origin request for the IFC bytes.
|
||||
//
|
||||
// ifc://host:port/org/repo@{hash}?path=file.ifc
|
||||
// → /proxy?url=http%3A%2F%2Fhost%3Aport%2Forg%2Frepo%2Fraw%2Fcommit%2F{hash}%2Ffile.ifc
|
||||
// -----------------------------------------------------------------------
|
||||
function toProxyUrl(raw) {
|
||||
const body = raw.slice("ifc://".length);
|
||||
const atIdx = body.indexOf("@");
|
||||
const qIdx = body.indexOf("?");
|
||||
const hostRepo = body.slice(0, atIdx);
|
||||
const ref = body.slice(atIdx + 1, qIdx < 0 ? undefined : qIdx);
|
||||
const qs = new URLSearchParams(qIdx < 0 ? "" : body.slice(qIdx + 1));
|
||||
const filePath = qs.get("path") ?? "";
|
||||
|
||||
const slashIdx = hostRepo.indexOf("/");
|
||||
const host = hostRepo.slice(0, slashIdx);
|
||||
const repoPath = hostRepo.slice(slashIdx + 1);
|
||||
|
||||
let refSeg;
|
||||
if (ref.startsWith("heads/")) refSeg = "branch/" + ref.slice(6);
|
||||
else if (ref.startsWith("tags/")) refSeg = "tag/" + ref.slice(5);
|
||||
else refSeg = "commit/" + ref;
|
||||
|
||||
const protocol = host.startsWith("localhost") ? "http" : "https";
|
||||
const rawUrl = `${protocol}://${host}/${repoPath}/raw/${refSeg}/${filePath}`;
|
||||
return `/proxy?url=${encodeURIComponent(rawUrl)}`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Main
|
||||
// -----------------------------------------------------------------------
|
||||
async function main() {
|
||||
if (!ifcUrl) {
|
||||
statusEl.textContent = "No ifc:// URL provided.";
|
||||
return;
|
||||
}
|
||||
|
||||
// --- World setup ---
|
||||
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();
|
||||
|
||||
// --- FragmentsManager: must be init'd before ifcLoader.load() ---
|
||||
// Firefox blocks cross-origin module workers; wrap in a blob URL so the
|
||||
// worker is always same-origin regardless of where it was fetched from.
|
||||
const fragments = components.get(OBC.FragmentsManager);
|
||||
const workerResp = await fetch(
|
||||
"https://unpkg.com/@thatopen/fragments@3.3.1/dist/Worker/worker.mjs"
|
||||
);
|
||||
const workerUrl = URL.createObjectURL(
|
||||
new Blob([await workerResp.text()], { type: "application/javascript" })
|
||||
);
|
||||
fragments.init(workerUrl);
|
||||
|
||||
// --- IfcLoader: wasm config goes into setup() ---
|
||||
const ifcLoader = components.get(OBC.IfcLoader);
|
||||
await ifcLoader.setup({
|
||||
autoSetWasm: false,
|
||||
wasm: {
|
||||
path: "https://unpkg.com/web-ifc@0.0.75/",
|
||||
absolute: true,
|
||||
},
|
||||
});
|
||||
|
||||
// --- Fetch IFC bytes via proxy ---
|
||||
statusEl.textContent = "Fetching IFC file\u2026";
|
||||
let buffer;
|
||||
try {
|
||||
const resp = await fetch(toProxyUrl(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;
|
||||
}
|
||||
|
||||
// --- Parse and render ---
|
||||
statusEl.textContent = "Parsing IFC\u2026";
|
||||
try {
|
||||
// load() returns a FragmentsModel; model.object is its THREE.Group.
|
||||
// The modelId arg must be a string — undefined causes a worker hash error.
|
||||
const model = await ifcLoader.load(buffer, true, "model");
|
||||
world.scene.three.add(model.object);
|
||||
|
||||
// model.box (THREE.Box3) is populated from the worker's CREATE_MODEL
|
||||
// response during _setup(), so it's available before any tiles exist.
|
||||
// Fit the camera first so the LOD frustum culling generates tiles for
|
||||
// the visible region, then force-flush all pending tile requests.
|
||||
const box = model.box;
|
||||
if (!box.isEmpty()) {
|
||||
await world.camera.controls.fitToBox(box, false);
|
||||
}
|
||||
model.useCamera(world.camera.three);
|
||||
await fragments.core.update(true);
|
||||
|
||||
// Show initial camera position in URL, then keep it live.
|
||||
syncCameraUrl(world.camera.three);
|
||||
world.camera.controls.addEventListener("rest", () => {
|
||||
syncCameraUrl(world.camera.three);
|
||||
});
|
||||
|
||||
statusEl.textContent = "";
|
||||
} catch (err) {
|
||||
statusEl.textContent = `Render error: ${err.message}`;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
Loading…
Add table
Reference in a new issue