ifcurl/forgejo/custom/public/assets/viewer.html

303 lines
12 KiB
HTML
Raw Normal View History

<!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-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;
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">
<input id="url-input" type="text" placeholder="Paste ifc:// URL and press Enter" spellcheck="false">
</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");
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);
}
});
// -----------------------------------------------------------------------
// 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;
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");
} else {
qs.set("scale", f(camera.top - camera.bottom).toString());
qs.delete("fov");
}
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());
}
// -----------------------------------------------------------------------
// 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).
//
// ifc://host:port/org/repo@{hash}?path=file.ifc
// → http://host:port/org/repo/raw/commit/{hash}/file.ifc
// -----------------------------------------------------------------------
function toRawUrl(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";
return `${protocol}://${host}/${repoPath}/raw/${refSeg}/${filePath}`;
}
// -----------------------------------------------------------------------
// Main
// -----------------------------------------------------------------------
async function main() {
if (!ifcUrl) {
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);
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 ---
// 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 ---
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 directly from Forgejo ---
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;
}
// --- Parse and render ---
statusEl.textContent = "Parsing IFC…";
try {
const model = await ifcLoader.load(buffer, true, "model");
world.scene.three.add(model.object);
const box = model.box;
const hasCam = await applyCameraParam(world.camera.controls);
if (!hasCam && !box.isEmpty()) {
await world.camera.controls.fitToBox(box, false);
}
model.useCamera(world.camera.three);
await fragments.core.update(true);
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>