mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
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:
parent
cf935a2a49
commit
038827fcf2
3 changed files with 303 additions and 1 deletions
68
forgejo/templates/custom/footer.tmpl
Normal file
68
forgejo/templates/custom/footer.tmpl
Normal 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>
|
||||
|
|
@ -40,19 +40,22 @@ import os
|
|||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.selector
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from platformdirs import user_cache_dir
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ifcurl import render as render_mod
|
||||
from ifcurl.auth import get_token_for_host
|
||||
from ifcurl.git import fetch_ifc
|
||||
from ifcurl.viewer import VIEWER_HTML
|
||||
from ifcurl.url import IfcUrl
|
||||
|
||||
app = FastAPI(
|
||||
|
|
@ -218,6 +221,35 @@ class PreviewRequest(BaseModel):
|
|||
# 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")
|
||||
def preview_get(url: str) -> Response:
|
||||
"""GET variant of POST /preview for use in HTML ``<img src>`` tags.
|
||||
|
|
|
|||
202
ifcurl/viewer.py
Normal file
202
ifcurl/viewer.py
Normal 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>
|
||||
"""
|
||||
Loading…
Add table
Reference in a new issue