Add Phase 3b web IFC viewer: /viewer and /proxy endpoints

- GET /viewer?url=ifc://... serves a self-contained HTML page that loads
  @thatopen/components v3 from CDN and renders the IFC model in WebGL
- GET /proxy?url=<raw> proxies IFC bytes from Forgejo to the browser,
  avoiding CORS restrictions
- forgejo/templates/custom/footer.tmpl injects a "View in 3D" button on
  .ifc file view pages in Forgejo; deploy to
  /var/lib/forgejo/custom/templates/custom/footer.tmpl

CDN loading required several non-obvious fixes documented in viewer.py:
web-ifc loaded from unpkg (not esm.sh) to avoid the Node.js process polyfill
that breaks the browser ST build's environment check; fragments worker wrapped
in a blob URL for Firefox cross-origin worker compatibility; model.box used for
camera fit before tiles exist; fragments.core.update(true) to flush tile queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-22 22:04:28 +01:00
parent cf935a2a49
commit 038827fcf2
3 changed files with 303 additions and 1 deletions

View file

@ -0,0 +1,68 @@
<script>
// ifcurl — inject "View in 3D" button on .ifc file view pages.
//
// Deploy to: /etc/forgejo/templates/custom/footer.tmpl
//
// Uses the permalink href (always a full commit hash) to construct an
// unambiguous ifc:// URL regardless of branch names containing slashes.
(function () {
"use strict";
var VIEWER_BASE = "http://localhost:8000/viewer";
// -------------------------------------------------------------------------
// Find a commit-based href on this page.
// Case 1: viewing at a branch/tag — permalink button is present.
// Case 2: already viewing at a commit — current URL contains /src/commit/.
// -------------------------------------------------------------------------
function commitHref() {
var a = document.querySelector('a[href*="/src/commit/"]');
if (a) return a.getAttribute("href");
if (/\/src\/commit\//.test(window.location.pathname)) {
return window.location.pathname;
}
return null;
}
// Parse /owner/repo/src/commit/{40hex}/{treepath}
// Returns {repoPath, hash, treePath} or null.
function parseCommitHref(href) {
var m = href.match(/^\/([^/]+\/[^/]+)\/src\/commit\/([0-9a-f]{40})\/(.+)$/i);
if (!m) return null;
return { repoPath: m[1], hash: m[2], treePath: m[3] };
}
function init() {
var href = commitHref();
if (!href) return;
var info = parseCommitHref(href);
if (!info) return;
if (!info.treePath.toLowerCase().endsWith(".ifc")) return;
var host = window.location.host;
var ifcUrl = "ifc://" + host + "/" + info.repoPath
+ "@" + info.hash
+ "?path=" + encodeURIComponent(info.treePath);
var viewUrl = VIEWER_BASE + "?url=" + encodeURIComponent(ifcUrl);
// Insert button alongside the existing Raw / Permalink / History buttons.
var btnGroup = document.querySelector(".file-header-right .ui.buttons");
if (!btnGroup) return;
var btn = document.createElement("a");
btn.href = viewUrl;
btn.target = "_blank";
btn.rel = "noopener noreferrer";
btn.className = "ui mini basic button";
btn.textContent = "View in 3D";
btnGroup.appendChild(btn);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>

View file

@ -40,19 +40,22 @@ import os
import tempfile import tempfile
import threading import threading
import time import time
import urllib.error
import urllib.request
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path from pathlib import Path
import ifcopenshell import ifcopenshell
import ifcopenshell.util.selector import ifcopenshell.util.selector
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import Response from fastapi.responses import HTMLResponse, Response
from platformdirs import user_cache_dir from platformdirs import user_cache_dir
from pydantic import BaseModel from pydantic import BaseModel
from ifcurl import render as render_mod from ifcurl import render as render_mod
from ifcurl.auth import get_token_for_host from ifcurl.auth import get_token_for_host
from ifcurl.git import fetch_ifc from ifcurl.git import fetch_ifc
from ifcurl.viewer import VIEWER_HTML
from ifcurl.url import IfcUrl from ifcurl.url import IfcUrl
app = FastAPI( app = FastAPI(
@ -218,6 +221,35 @@ class PreviewRequest(BaseModel):
# Endpoints # Endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.get("/viewer")
def viewer(url: str = "") -> HTMLResponse:
"""Serve the self-contained web IFC viewer page.
The ``url`` query parameter should be an ifc:// URL. The page loads
``@thatopen/components`` from CDN and fetches the IFC file through
``/proxy`` to avoid cross-origin restrictions.
"""
return HTMLResponse(content=VIEWER_HTML)
@app.get("/proxy")
def proxy(url: str) -> Response:
"""Proxy a raw IFC file URL to the browser, avoiding CORS restrictions.
Only intended for fetching IFC bytes from the co-located Forgejo instance.
No IFC processing is performed bytes are forwarded as-is.
"""
try:
with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310
data = resp.read()
content_type = resp.headers.get_content_type() or "application/octet-stream"
except urllib.error.HTTPError as exc:
raise HTTPException(status_code=exc.code, detail=str(exc.reason)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return Response(content=data, media_type=content_type)
@app.get("/preview") @app.get("/preview")
def preview_get(url: str) -> Response: def preview_get(url: str) -> Response:
"""GET variant of POST /preview for use in HTML ``<img src>`` tags. """GET variant of POST /preview for use in HTML ``<img src>`` tags.

202
ifcurl/viewer.py Normal file
View file

@ -0,0 +1,202 @@
# 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 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;
// -----------------------------------------------------------------------
// 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);
statusEl.textContent = "";
} catch (err) {
statusEl.textContent = `Render error: ${err.message}`;
console.error(err);
}
}
main();
</script>
</body>
</html>
"""