diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index b6fda46..c8c8dba 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -1,6 +1,7 @@
-{"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":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T20:42:24Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:42:24Z","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.","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:42:22Z","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":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T20:42:09Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:44:21Z","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":"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-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}
{"id":"ifcurl-1sh","title":"viewer.html: place camera from ifc:// URL camera= param on load","description":"When viewer.html loads with a camera= param in the ifc:// URL (9 comma-separated floats: position, direction, up in IFC world coordinates), apply the IFC→Three.js coordinate transform (Y/Z swap, one negated) and set the Three.js camera position, direction and up vector accordingly. Depends on the syncCameraUrl fix (ifcurl-j2s) so the forward and inverse transforms are consistent.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T20:05:57Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:45:10Z","started_at":"2026-04-23T20:09:26Z","closed_at":"2026-04-23T20:45:10Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-1sh","depends_on_id":"ifcurl-j2s","type":"blocks","created_at":"2026-04-23T21:06:08Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-940","title":"viewer.html: URL input box and drag-and-drop target","description":"Add a text entry box and a drag-and-drop target to viewer.html so any ifc:// URL can be loaded without navigating from a Forgejo page. The input should accept a pasted ifc:// URL and immediately load it into the viewer. Drag-and-drop should accept a dropped ifc:// URL or a .ifc file URL. This makes the viewer usable as a standalone tool.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T20:05:43Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:45:08Z","started_at":"2026-04-23T20:09:23Z","closed_at":"2026-04-23T20:45:08Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-cmd","title":"Apply forward IFC→Three.js transform when setting camera from URL params","description":"When the viewer page loads with a camera= param in the ifc:// URL, the 9 floats are in IFC world coordinates. Apply the IFC→Three.js transform to convert them before passing them to the Three.js camera / camera-controls. This is the forward direction of the same transform used in the inverse task.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T06:12:38Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:45:09Z","started_at":"2026-04-23T20:09:25Z","closed_at":"2026-04-23T20:45:09Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-cmd","depends_on_id":"ifcurl-ldk","type":"blocks","created_at":"2026-04-23T07:14:10Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
diff --git a/forgejo/custom/public/assets/viewer.html b/forgejo/custom/public/assets/viewer.html
index a3d0052..4979529 100644
--- a/forgejo/custom/public/assets/viewer.html
+++ b/forgejo/custom/public/assets/viewer.html
@@ -22,6 +22,16 @@
}
#url-input:focus { border-color: rgba(255,255,255,0.3); }
#url-input::placeholder { color: #606068; }
+ #fov-wrap {
+ display: flex; align-items: center; gap: 4px; flex-shrink: 0;
+ font-size: 11px; color: #a0a0a8; white-space: nowrap;
+ }
+ #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); }
#status {
position: fixed; bottom: 12px; left: 50%;
transform: translateX(-50%); z-index: 10;
@@ -55,6 +65,9 @@
@@ -67,30 +80,21 @@
const ifcUrl = params.get("url") ?? "";
const statusEl = document.getElementById("status");
const urlInput = document.getElementById("url-input");
+ const fovInput = document.getElementById("fov-input");
const toolbar = document.getElementById("toolbar");
- // Keep viewer below the toolbar so the toolbar never overlaps the canvas.
+ // Keep viewer below the toolbar so the canvas never overlaps the input.
const resizeObs = new ResizeObserver(() => {
document.documentElement.style.setProperty("--toolbar-h", toolbar.offsetHeight + "px");
});
resizeObs.observe(toolbar);
- // Module-level reference so event handlers can reach camera controls.
+ // Module-level references set once the world is initialised.
let cameraControls = null;
+ let threeCamera = null;
urlInput.value = ifcUrl;
- // Disable camera controls while the input has focus so keystrokes are not
- // swallowed by the camera-controls library.
- urlInput.addEventListener("focus", () => {
- if (cameraControls) cameraControls.enabled = false;
- });
- urlInput.addEventListener("blur", () => {
- if (cameraControls) cameraControls.enabled = true;
- });
- urlInput.addEventListener("pointerdown", e => e.stopPropagation());
- urlInput.addEventListener("mousedown", e => e.stopPropagation());
-
// -----------------------------------------------------------------------
// Navigate to the viewer with a different ifc:// URL.
// -----------------------------------------------------------------------
@@ -101,10 +105,26 @@
}
urlInput.addEventListener("keydown", e => {
- if (e.key === "Enter") {
- const v = urlInput.value.trim();
- if (v) loadUrl(v);
- }
+ 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]) {
+ el.addEventListener("focus", onInputFocus);
+ el.addEventListener("blur", onInputBlur);
+ el.addEventListener("pointerdown", e => e.stopPropagation());
+ el.addEventListener("mousedown", e => e.stopPropagation());
+ }
+
+ fovInput.addEventListener("change", () => {
+ if (!threeCamera?.isPerspectiveCamera) return;
+ const v = parseFloat(fovInput.value);
+ if (isNaN(v)) return;
+ threeCamera.fov = Math.max(10, Math.min(120, v));
+ threeCamera.updateProjectionMatrix();
+ syncCameraUrl(threeCamera);
});
// -----------------------------------------------------------------------
@@ -133,14 +153,14 @@
try {
const u = new URL(t, window.location.href);
const p = u.searchParams.get("url");
- if (p && p.startsWith("ifc://")) { loadUrl(p); return; }
+ if (p?.startsWith("ifc://")) { loadUrl(p); return; }
} catch { /* not a URL */ }
}
}
});
// -----------------------------------------------------------------------
- // Coordinate transforms between IFC world space (Z-up) and Three.js (Y-up).
+ // Coordinate transforms: IFC world space (Z-up) ↔ Three.js (Y-up).
// IFC → Three.js: (x, y, z) → (x, z, -y)
// Three.js → IFC: (x, y, z) → (x, -z, y)
// -----------------------------------------------------------------------
@@ -148,8 +168,8 @@
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.
+ // Rebuild the ifc:// URL with the current camera viewpoint (IFC coords)
+ // and push to the toolbar and browser address bar.
// -----------------------------------------------------------------------
function syncCameraUrl(camera) {
const pos = camera.position;
@@ -172,9 +192,11 @@
if (camera.isPerspectiveCamera) {
qs.set("fov", f(camera.fov).toString());
qs.delete("scale");
+ fovInput.value = Math.round(camera.fov);
} else {
qs.set("scale", f(camera.top - camera.bottom).toString());
qs.delete("fov");
+ fovInput.value = "";
}
const newIfcUrl = base + "?" + qs.toString().replace(/%2C/g, ",");
@@ -186,10 +208,10 @@
}
// -----------------------------------------------------------------------
- // If the ifc:// URL contains a camera= param, place the camera there.
- // Returns true if the camera was positioned from the URL.
+ // Place the camera from the ifc:// URL's camera= / fov= / scale= params.
+ // Returns true if a camera param was found and applied.
// -----------------------------------------------------------------------
- async function applyCameraParam(controls) {
+ async function applyCameraParam(controls, camera) {
const qIdx = ifcUrl.indexOf("?");
if (qIdx < 0) return false;
const qs = new URLSearchParams(ifcUrl.slice(qIdx + 1));
@@ -202,36 +224,68 @@
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);
+
+ const fovStr = qs.get("fov");
+ if (fovStr && camera.isPerspectiveCamera) {
+ camera.fov = Math.max(10, Math.min(120, parseFloat(fovStr)));
+ camera.updateProjectionMatrix();
+ fovInput.value = Math.round(camera.fov);
+ }
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).
+ // Convert an ifc:// URL to an HTTPS raw file URL for browser fetching.
//
- // ifc://host:port/org/repo@{hash}?path=file.ifc
- // → http://host:port/org/repo/raw/commit/{hash}/file.ifc
+ // 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
// -----------------------------------------------------------------------
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 body = raw.slice("ifc://".length);
+
+ // Split authority (host) from path+query at the first "/"
+ 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 pathPart = qIdx < 0 ? pathAndQuery : pathAndQuery.slice(0, qIdx);
+ const qs = new URLSearchParams(qIdx < 0 ? "" : pathAndQuery.slice(qIdx + 1));
const filePath = qs.get("path") ?? "";
- const slashIdx = hostRepo.indexOf("/");
- const host = hostRepo.slice(0, slashIdx);
- const repoPath = hostRepo.slice(slashIdx + 1);
+ // Split repoPath from ref at the last "@" in the path portion
+ const atIdx = pathPart.lastIndexOf("@");
+ const repoPath = atIdx < 0 ? pathPart : pathPart.slice(0, atIdx);
+ const ref = atIdx < 0 ? "" : pathPart.slice(atIdx + 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;
+ // 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;
- const protocol = host.startsWith("localhost") ? "http" : "https";
- return `${protocol}://${host}/${repoPath}/raw/${refSeg}/${filePath}`;
+ 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;
+ const proto = host.startsWith("localhost") ? "http" : "https";
+ return `${proto}://${host}/${repoPath}/raw/${refSeg}/${filePath}`;
}
// -----------------------------------------------------------------------
@@ -256,11 +310,12 @@
world.camera = new OBC.OrthoPerspectiveCamera(components);
world.scene.setup();
components.init();
+
cameraControls = world.camera.controls;
+ threeCamera = world.camera.three;
// --- 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.
+ // Firefox blocks cross-origin module workers; wrap in a blob URL.
const fragments = components.get(OBC.FragmentsManager);
const workerResp = await fetch(
"https://unpkg.com/@thatopen/fragments@3.3.1/dist/Worker/worker.mjs"
@@ -274,13 +329,10 @@
const ifcLoader = components.get(OBC.IfcLoader);
await ifcLoader.setup({
autoSetWasm: false,
- wasm: {
- path: "https://unpkg.com/web-ifc@0.0.75/",
- absolute: true,
- },
+ wasm: { path: "https://unpkg.com/web-ifc@0.0.75/", absolute: true },
});
- // --- Fetch IFC bytes directly from Forgejo ---
+ // --- Fetch IFC bytes ---
statusEl.textContent = "Fetching IFC file…";
let buffer;
try {
@@ -298,18 +350,16 @@
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);
+ const box = model.box;
+ const hasCam = await applyCameraParam(world.camera.controls, threeCamera);
if (!hasCam && !box.isEmpty()) {
await world.camera.controls.fitToBox(box, false);
}
- model.useCamera(world.camera.three);
+ model.useCamera(threeCamera);
await fragments.core.update(true);
- syncCameraUrl(world.camera.three);
- world.camera.controls.addEventListener("rest", () => {
- syncCameraUrl(world.camera.three);
- });
+ syncCameraUrl(threeCamera);
+ world.camera.controls.addEventListener("rest", () => syncCameraUrl(threeCamera));
statusEl.textContent = "";
} catch (err) {