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 @@
-
-
@@ -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) {