From 60596ef1a7de9806e8acf1f98c4c58f27dbce2f7 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 23 Apr 2026 22:32:09 +0100 Subject: [PATCH] viewer.html: structured form UI for ifc:// URL components Adds a second toolbar row with labelled fields for repo, ref, path, and selector. Editing any field rebuilds and reloads the ifc:// URL. The raw URL in row 1 remains editable and copyable. Closes ifcurl-yju. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 2 +- forgejo/custom/public/assets/viewer.html | 218 ++++++++++++++++------- 2 files changed, 153 insertions(+), 67 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c8c8dba..367b4ba 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"ifcurl-yju","title":"viewer.html: structured form UI for ifc:// URL components","description":"Replace the single ifc:// URL text input with individual labelled fields for each component: host/repository, ref (branch/tag/commit), path (file within repo), selector (IfcOpenShell selector), camera, fov/scale. Editing any field should update the ifc:// URL and reload the model. The raw ifc:// URL should still be visible/copyable. This makes the viewer usable without needing to hand-edit a URL string.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T21:07:11Z","created_by":"Bruno Postle","updated_at":"2026-04-23T21:07:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-yju","title":"viewer.html: structured form UI for ifc:// URL components","description":"Replace the single ifc:// URL text input with individual labelled fields for each component: host/repository, ref (branch/tag/commit), path (file within repo), selector (IfcOpenShell selector), camera, fov/scale. Editing any field should update the ifc:// URL and reload the model. The raw ifc:// URL should still be visible/copyable. This makes the viewer usable without needing to hand-edit a URL string.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T21:07:11Z","created_by":"Bruno Postle","updated_at":"2026-04-23T21:12:42Z","started_at":"2026-04-23T21:12:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-2zf","title":"viewer.html: FOV control and restore fov=/scale= from URL","description":"Two gaps: (1) No UI to change the camera FOV — add a control (e.g. a small number input or slider in the toolbar) so the user can adjust perspective FOV or orthographic scale. (2) applyCameraParam reads position/direction but ignores the fov= and scale= params written by syncCameraUrl — restore these when placing camera from URL so a shared link opens at exactly the recorded viewpoint.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T20:42:24Z","created_by":"Bruno Postle","updated_at":"2026-04-23T21:07:42Z","started_at":"2026-04-23T20:52:48Z","closed_at":"2026-04-23T21:07:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-yga","title":"viewer.html: implement ifc:// selector= parameter","description":"The ifc:// URL spec supports a selector= param using IfcOpenShell selector syntax (e.g. selector=IfcWall, selector=IfcSlab.Name='Floor'). The viewer currently ignores it. Implement selector filtering in the browser: parse the selector= value, query matching elements from the loaded FragmentsModel, and apply highlight/isolate/ghost visibility to them. @thatopen/components has Highlighter and Hider components that may be usable. The selector syntax used is the IfcOpenShell Python selector syntax — a JS port or subset may be needed.","notes":"Selector syntax docs: https://docs.ifcopenshell.org/ifcopenshell-python/selector_syntax.html — Python implementation in ifcopenshell.util.selector module. Need a JS port or subset. Syntax includes: IfcType, .Attribute='value', #GlobalId, [pset.prop], boolean operators (+ for union, , for intersection within group).","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T20:42:22Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:52:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-set","title":"viewer.html: generalise toRawUrl() to support GitHub, GitLab etc.","description":"toRawUrl() currently only handles Forgejo/Gitea HTTPS URLs, mapping ifc://host/org/repo@hash?path=file to http(s)://host/org/repo/raw/commit/hash/file. This needs to: (1) detect the git host type and construct the correct raw file URL — GitHub → raw.githubusercontent.com/org/repo/hash/file; GitLab → host/org/repo/-/raw/hash/file; Forgejo/Gitea → host/org/repo/raw/commit/hash/file; (2) handle SSH ifc:// URLs (user@host form per the spec) by stripping the user@ prefix and rewriting to HTTPS for browser fetching — ifc://git@github.com/org/repo@hash becomes https://raw.githubusercontent.com/org/repo/hash/file.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T20:42:09Z","created_by":"Bruno Postle","updated_at":"2026-04-23T21:07:42Z","started_at":"2026-04-23T20:52:47Z","closed_at":"2026-04-23T21:07:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/forgejo/custom/public/assets/viewer.html b/forgejo/custom/public/assets/viewer.html index 4979529..fbfc648 100644 --- a/forgejo/custom/public/assets/viewer.html +++ b/forgejo/custom/public/assets/viewer.html @@ -8,30 +8,38 @@ *, *::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; top: var(--toolbar-h, 40px); } + #viewer { position: fixed; inset: 0; top: var(--toolbar-h, 72px); } #toolbar { position: fixed; top: 0; left: 0; right: 0; z-index: 10; - display: flex; align-items: center; gap: 8px; padding: 8px 12px; + display: flex; flex-direction: column; gap: 6px; + 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; + .trow { display: flex; align-items: center; gap: 6px; } + /* Shared input style */ + .ti { + 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; + border-radius: 4px; padding: 3px 7px; outline: none; min-width: 0; } - #url-input:focus { border-color: rgba(255,255,255,0.3); } - #url-input::placeholder { color: #606068; } + .ti:focus { border-color: rgba(255,255,255,0.3); } + .ti::placeholder { color: #505058; } + /* Row 1 */ + #url-input { flex: 1; } #fov-wrap { display: flex; align-items: center; gap: 4px; flex-shrink: 0; - font-size: 11px; color: #a0a0a8; white-space: nowrap; + font-size: 11px; color: #a0a0a8; } - #fov-input { - width: 44px; font-size: 11px; 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 6px; outline: none; text-align: right; - } - #fov-input:focus { border-color: rgba(255,255,255,0.3); } + #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 */ #status { position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 10; @@ -64,9 +72,38 @@
- -
- FOV ° + +
+ +
+ FOV ° +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
@@ -82,17 +119,79 @@ const urlInput = document.getElementById("url-input"); const fovInput = document.getElementById("fov-input"); const toolbar = document.getElementById("toolbar"); + const repoInput = document.getElementById("repo-input"); + const refInput = document.getElementById("ref-input"); + const pathInput = document.getElementById("path-input"); + const selectorInput = document.getElementById("selector-input"); - // Keep viewer below the toolbar so the canvas never overlaps the input. + // Keep viewer below the full toolbar height. const resizeObs = new ResizeObserver(() => { document.documentElement.style.setProperty("--toolbar-h", toolbar.offsetHeight + "px"); }); resizeObs.observe(toolbar); - // Module-level references set once the world is initialised. let cameraControls = null; let threeCamera = null; + // ----------------------------------------------------------------------- + // 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; + } urlInput.value = ifcUrl; // ----------------------------------------------------------------------- @@ -108,10 +207,18 @@ if (e.key === "Enter") { const v = urlInput.value.trim(); if (v) loadUrl(v); } }); - // Disable camera controls while inputs have focus so keystrokes reach them. - function onInputFocus() { if (cameraControls) cameraControls.enabled = false; } - function onInputBlur() { if (cameraControls) cameraControls.enabled = true; } - for (const el of [urlInput, fovInput]) { + // 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) { el.addEventListener("focus", onInputFocus); el.addEventListener("blur", onInputBlur); el.addEventListener("pointerdown", e => e.stopPropagation()); @@ -128,8 +235,7 @@ }); // ----------------------------------------------------------------------- - // Drag-and-drop: accept ifc:// URLs dragged onto the page. - // Also handles viewer page URLs containing ?url=ifc://... + // Drag-and-drop: accept ifc:// URLs or viewer page URLs. // ----------------------------------------------------------------------- document.body.addEventListener("dragover", e => { e.preventDefault(); @@ -160,7 +266,7 @@ }); // ----------------------------------------------------------------------- - // Coordinate transforms: IFC world space (Z-up) ↔ Three.js (Y-up). + // Coordinate transforms: IFC (Z-up) ↔ Three.js (Y-up). // IFC → Three.js: (x, y, z) → (x, z, -y) // Three.js → IFC: (x, y, z) → (x, -z, y) // ----------------------------------------------------------------------- @@ -168,8 +274,8 @@ const toThree = (x, y, z) => new THREE.Vector3(x, z, -y); // ----------------------------------------------------------------------- - // Rebuild the ifc:// URL with the current camera viewpoint (IFC coords) - // and push to the toolbar and browser address bar. + // 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. // ----------------------------------------------------------------------- function syncCameraUrl(camera) { const pos = camera.position; @@ -177,7 +283,7 @@ camera.getWorldDirection(dir); const up = camera.up; - const f = v => parseFloat(v.toFixed(4)); + 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), @@ -208,13 +314,13 @@ } // ----------------------------------------------------------------------- - // Place the camera from the ifc:// URL's camera= / fov= / scale= params. - // Returns true if a camera param was found and applied. + // Place the camera from ifc:// URL camera=/fov=/scale= params. // ----------------------------------------------------------------------- async function applyCameraParam(controls, camera) { 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); @@ -235,38 +341,28 @@ } // ----------------------------------------------------------------------- - // Convert an ifc:// URL to an HTTPS raw file URL for browser fetching. - // - // Handles: - // - Optional SSH user prefix: ifc://git@host/... → strip user@ - // - GitHub: → https://raw.githubusercontent.com/org/repo/ref/path - // - GitLab: → https://host/org/repo/-/raw/ref/path - // - Forgejo/Gitea: → https://host/org/repo/raw/{commit|branch|tag}/ref/path + // Convert an ifc:// URL to an HTTPS raw file URL. + // Handles SSH user@ prefix, GitHub, GitLab, and Forgejo/Gitea. // ----------------------------------------------------------------------- function toRawUrl(raw) { const body = raw.slice("ifc://".length); - // Split authority (host) from path+query at the first "/" - const slashIdx = body.indexOf("/"); + const slashIdx = body.indexOf("/"); const authority = slashIdx < 0 ? body : body.slice(0, slashIdx); const pathAndQuery = slashIdx < 0 ? "" : body.slice(slashIdx + 1); - // Strip optional SSH user@ from authority const atInAuth = authority.indexOf("@"); const host = atInAuth < 0 ? authority : authority.slice(atInAuth + 1); - // Split query string - const qIdx = pathAndQuery.indexOf("?"); + const qIdx = pathAndQuery.indexOf("?"); const pathPart = qIdx < 0 ? pathAndQuery : pathAndQuery.slice(0, qIdx); - const qs = new URLSearchParams(qIdx < 0 ? "" : pathAndQuery.slice(qIdx + 1)); + const qs = new URLSearchParams(qIdx < 0 ? "" : pathAndQuery.slice(qIdx + 1)); const filePath = qs.get("path") ?? ""; - // Split repoPath from ref at the last "@" in the path portion - const atIdx = pathPart.lastIndexOf("@"); + const atIdx = pathPart.lastIndexOf("@"); const repoPath = atIdx < 0 ? pathPart : pathPart.slice(0, atIdx); const ref = atIdx < 0 ? "" : pathPart.slice(atIdx + 1); - // Normalise ref for hosts that don't use gitnamespace prefixes const plainRef = ref.startsWith("heads/") ? ref.slice(6) : ref.startsWith("tags/") ? ref.slice(5) : ref; @@ -274,13 +370,10 @@ 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}`; } - - // Forgejo / Gitea: map gitnamespace ref to URL segment const refSeg = ref.startsWith("heads/") ? "branch/" + ref.slice(6) : ref.startsWith("tags/") ? "tag/" + ref.slice(5) : "commit/" + ref; @@ -293,13 +386,12 @@ // ----------------------------------------------------------------------- async function main() { if (!ifcUrl) { - statusEl.textContent = "Paste an ifc:// URL above or drag one onto the page."; + statusEl.textContent = "Fill in the fields above and press Enter, or paste an ifc:// URL."; return; } statusEl.textContent = "Loading…"; - // --- World setup --- const components = new OBC.Components(); const worlds = components.get(OBC.Worlds); const world = worlds.create(); @@ -314,25 +406,21 @@ cameraControls = world.camera.controls; threeCamera = world.camera.three; - // --- FragmentsManager --- // Firefox blocks cross-origin module workers; wrap in a blob URL. - const fragments = components.get(OBC.FragmentsManager); + 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( + fragments.init(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 --- statusEl.textContent = "Fetching IFC file…"; let buffer; try { @@ -344,22 +432,20 @@ return; } - // --- Parse and render --- statusEl.textContent = "Parsing IFC…"; try { - const model = await ifcLoader.load(buffer, true, "model"); + 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, threeCamera); - if (!hasCam && !box.isEmpty()) { - await world.camera.controls.fitToBox(box, false); + const hasCam = await applyCameraParam(cameraControls, threeCamera); + if (!hasCam && !model.box.isEmpty()) { + await cameraControls.fitToBox(model.box, false); } model.useCamera(threeCamera); await fragments.core.update(true); syncCameraUrl(threeCamera); - world.camera.controls.addEventListener("rest", () => syncCameraUrl(threeCamera)); + cameraControls.addEventListener("rest", () => syncCameraUrl(threeCamera)); statusEl.textContent = ""; } catch (err) {