viewer: fix five P3 bugs (types, clip, ref nav, ortho, BCF snapshot)

- Types panel: fix count by iterating groupData directly (groupData.map
  was undefined — FragmentIdMap is the groupData value, not a wrapper)
- Clip planes: register OBC.Raycasters for the world before setupClipper
  so clipper.create(world) can raycast against the model surface
- Ref navigation: ▾ button now navigates when input already has a value;
  add change listener alongside input for cross-browser datalist coverage
- Orthographic projection: replace missing controls.setOrthoCamera() with
  world.camera.projection.set("Orthographic") + threeOrtho scale fix;
  refresh threeCamera reference after projection switch
- BCF export: capture rendererCanvas snapshot and include snapshot.png in
  the BCF zip with a <Snapshot> reference in markup.bcf

Closes: ifcurl-07d, ifcurl-1pa, ifcurl-66h, ifcurl-xy9, ifcurl-uo9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-26 14:07:52 +01:00
parent 63eaa63611
commit 3dd804ffc7
2 changed files with 54 additions and 21 deletions

View file

@ -52,13 +52,13 @@
{"id":"ifcurl-irp","title":"viewer: no render_diff preview shown on Forgejo commit pages","description":"When viewing a commit in Forgejo that contains .ifc file changes (e.g. /bruno/simple-ifc/commit/61e64e05d783e621d77869feadfc218a0858c499), no 3D diff preview image is injected. The footer.tmpl is supposed to detect .ifc diffs on commit pages and inject an img tag pointing to /render_diff. Either the detection is not firing or the render_diff request is failing.","status":"closed","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T22:17:59Z","created_by":"Bruno Postle","updated_at":"2026-04-25T22:57:57Z","closed_at":"2026-04-25T22:57:57Z","close_reason":"Fixed by render_diff fixes in commit 837a9d1","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-3sz","title":"preview: visibility=highlight renders non-selected elements as opaque grey instead of transparent grey","description":"In the rendered PNG, visibility=highlight shows selected elements normally but non-selected elements appear as opaque grey rather than transparent grey (semi-transparent). The highlight mode should make non-selected elements translucent so the selected elements are visually emphasised against a faded context.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T22:15:42Z","created_by":"Bruno Postle","updated_at":"2026-04-25T23:03:49Z","started_at":"2026-04-25T22:59:19Z","closed_at":"2026-04-25T23:03:49Z","close_reason":"Not a bug — opaque grey is the model's actual IFC material colors displayed normally in highlight mode, which is correct per spec","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-d7m","title":"viewer: visibility=highlight renders as isolate (non-selected elements should be transparent grey)","description":"In the viewer, visibility=highlight and visibility=isolate produce identical results — only selected elements are shown. The correct behaviour for highlight is to show selected elements normally and render all other elements as transparent grey. Isolate should hide non-selected elements entirely. The preview service already handles both modes correctly.","notes":"First fix attempt (commit 7aa4ef8) used highlightByID('select', ...) but selected elements still hidden — highlightByID may not prevent hider from hiding them, or the Highlighter 'select' style still isolates. Needs further investigation into OBC Highlighter vs Hider interaction.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T22:15:41Z","created_by":"Bruno Postle","updated_at":"2026-04-26T07:17:13Z","started_at":"2026-04-25T22:59:21Z","closed_at":"2026-04-26T07:17:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-xy9","title":"viewer: orthographic projection not implemented (works in preview)","description":"The viewer ignores the 'scale' parameter that selects orthographic projection. The preview service renders orthographic views correctly, but the 3D viewer in the browser always shows perspective. The viewer needs to switch to an orthographic camera when the IFC URL contains 'scale=' instead of 'fov='.","status":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T22:15:38Z","created_by":"Bruno Postle","updated_at":"2026-04-25T22:15:38Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-xy9","title":"viewer: orthographic projection not implemented (works in preview)","description":"The viewer ignores the 'scale' parameter that selects orthographic projection. The preview service renders orthographic views correctly, but the 3D viewer in the browser always shows perspective. The viewer needs to switch to an orthographic camera when the IFC URL contains 'scale=' instead of 'fov='.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T22:15:38Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:51:01Z","started_at":"2026-04-26T08:51:01Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-a9x","title":"viewer: typing in selector box does not filter or apply selector","description":"Typing an IfcOpenShell selector expression (e.g. 'IfcWall') into the selector input box has no effect. The selector should be applied to the model, updating the view to show only matching elements.","status":"closed","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:17Z","created_by":"Bruno Postle","updated_at":"2026-04-25T22:36:10Z","closed_at":"2026-04-25T22:36:10Z","close_reason":"selector box now working in viewer","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-66h","title":"viewer: browse branches/tags button does not navigate to selected ref","description":"The browse branches and tags button does not appear to do anything when clicked. The ref input box has a datalist populated with heads/main but selecting a branch/tag and clicking browse does not navigate to that ref.","status":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:16Z","created_by":"Bruno Postle","updated_at":"2026-04-25T21:37:16Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-66h","title":"viewer: browse branches/tags button does not navigate to selected ref","description":"The browse branches and tags button does not appear to do anything when clicked. The ref input box has a datalist populated with heads/main but selecting a branch/tag and clicking browse does not navigate to that ref.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:16Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:51:00Z","started_at":"2026-04-26T08:51:00Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-njq","title":"viewer: 'open new issue' link uses https:// but Forgejo may be on http://","description":"The 'open new issue' link hardcodes https:// scheme, but the Forgejo instance may be served over http:// (e.g. on localhost for development). Should use the same scheme as the current page or derive it from the Forgejo base URL.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:14Z","created_by":"Bruno Postle","updated_at":"2026-04-26T07:43:42Z","started_at":"2026-04-26T07:35:13Z","closed_at":"2026-04-26T07:43:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-uo9","title":"viewer: BCF export does not include preview PNG","description":"BCF export produces a valid BCF file but the viewpoint snapshot (preview PNG) is missing. BCF spec requires a snapshot image in each viewpoint. The ifcurl render service should be called to generate this.","status":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:13Z","created_by":"Bruno Postle","updated_at":"2026-04-25T21:37:13Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-07d","title":"viewer: model overview only shows storeys, other element types not listed","description":"The model overview panel lists storeys which can be toggled, but no other element types appear. Expected to see walls, slabs, columns etc. The tree/overview is incomplete or only partially populated.","status":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:12Z","created_by":"Bruno Postle","updated_at":"2026-04-25T21:37:12Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-1pa","title":"viewer: clip planes button only changes cursor, does not add clip plane","description":"Clicking the clip button changes the cursor to a crosshair but does not actually add a clipping plane to the model. The clip plane interaction is not implemented or not wired up.","status":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:10Z","created_by":"Bruno Postle","updated_at":"2026-04-25T21:37:10Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-uo9","title":"viewer: BCF export does not include preview PNG","description":"BCF export produces a valid BCF file but the viewpoint snapshot (preview PNG) is missing. BCF spec requires a snapshot image in each viewpoint. The ifcurl render service should be called to generate this.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:13Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:51:02Z","started_at":"2026-04-26T08:51:02Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-07d","title":"viewer: model overview only shows storeys, other element types not listed","description":"The model overview panel lists storeys which can be toggled, but no other element types appear. Expected to see walls, slabs, columns etc. The tree/overview is incomplete or only partially populated.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:12Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:50:34Z","started_at":"2026-04-26T08:50:34Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-1pa","title":"viewer: clip planes button only changes cursor, does not add clip plane","description":"Clicking the clip button changes the cursor to a crosshair but does not actually add a clipping plane to the model. The clip plane interaction is not implemented or not wired up.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:10Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:50:58Z","started_at":"2026-04-26T08:50:58Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-2m9","title":"viewer: move 'copy IFC URL' button to immediately after URL text box","description":"The copy-to-clipboard button for the IFC URL should be placed directly adjacent to the URL text input, not elsewhere in the UI. Currently the layout separates them, which is confusing.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:36:47Z","created_by":"Bruno Postle","updated_at":"2026-04-26T07:43:43Z","started_at":"2026-04-26T07:35:13Z","closed_at":"2026-04-26T07:43:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-6ys","title":"Research alternative URL schemes before ifc:// is committed to","description":"The ifc:// scheme name is not yet set in stone. It's worth researching whether a different scheme would be more appropriate or less likely to conflict with existing tooling.\\n\\nLook at how other tools handle similar problems:\\n- pip uses git+https:// and git+ssh:// (prefix on top of existing schemes)\\n- Are there existing ifc:// registrations or tools that would conflict?\\n- What scheme patterns are used by other domain-specific version-controlled asset tools?\\n- Alternatives to consider: ifcgit://, git+ifc://, ifc+git://, or a vendor-prefixed form\\n- Check IANA registered schemes and common-use unregistered schemes for conflicts\\n\\nGoal: make a confident decision to keep ifc:// or switch, before the scheme is baked into any external integrations.","notes":"Research complete. Summary:\n\n- ifc is NOT registered with IANA. bcf, bim also unregistered. Clean namespace.\n- No existing tool or buildingSMART standard uses ifc:// — no conflict risk.\n- BCF (bcf://) would be confusing — BCF is a live buildingSMART standard, don't use it.\n- pip-style compound schemes (ifc+git://, ifc+https://) look appealing but hurt more than they help: they fragment the transport-inference design, improve nothing for OS handlers or terminal clickability, and are harder to explain.\n- Forgejo: CUSTOM_URL_SCHEMES defaults to allowing all schemes — ifc:// links render fine out of the box.\n- GitHub/terminals: bare custom URLs are never auto-linked regardless of scheme choice. Explicitly formatted markdown links work fine.\n\nDecision: keep ifc://\n\nAction to add to roadmap: submit a provisional IANA registration (First Come First Served, ~1 hour, just an email to iana@iana.org with scheme name, syntax summary, security notes, and a spec URL). Do this when Phase 1 spec stabilises.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T09:08:13Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:17:44Z","started_at":"2026-04-25T11:04:50Z","closed_at":"2026-04-25T11:17:44Z","close_reason":"Keep ifc://. Namespace is clean (no IANA registration, no prior art). Compound schemes (ifc+git://) would break transport-inference design with no practical benefit. Action item: provisional IANA registration when Phase 1 spec stabilises.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-gy8","title":"Viewer: 'View in 3D' uses commit hash but no UI affordance to switch to branch tip","description":"The 'View in 3D' button in footer.tmpl always generates an ifc:// URL with a commit hash (@abc123...). This is correct for reproducibility, but users who want to track 'the latest version of main' must manually change the URL from @\u003chash\u003e to @heads/main. There is no indication in the viewer that the model is pinned to a specific commit. Add a visual indicator (e.g. 'commit abc1234 · switch to heads/main') in the viewer toolbar when the ref is a commit hash, offering a one-click switch to the branch tip. The branch name could be inferred from Forgejo's API given the commit hash.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:58Z","created_by":"Bruno Postle","updated_at":"2026-04-25T18:12:57Z","started_at":"2026-04-25T18:02:24Z","closed_at":"2026-04-25T18:12:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -412,19 +412,24 @@
el.addEventListener("keydown", e => { if (e.key === "Enter") loadFromFields(); });
}
// ▾ button: clear + focus the ref field so the datalist shows all refs.
// ▾ button: if the ref field already has a value, navigate; otherwise clear
// + focus so the datalist shows all refs.
const refPickBtn = document.getElementById("ref-pick-btn");
refPickBtn.addEventListener("click", () => {
if (refInput.value.trim()) { loadFromFields(); return; }
refInput.value = "";
refInput.focus();
});
// Auto-navigate when user selects a datalist option (exact match).
refInput.addEventListener("input", () => {
// Listen to both input and change events for cross-browser coverage.
function onRefInputChange() {
const dl = document.getElementById("ref-list");
const known = [...dl.options].map(o => o.value);
if (known.includes(refInput.value.trim())) loadFromFields();
});
}
refInput.addEventListener("input", onRefInputChange);
refInput.addEventListener("change", onRefInputChange);
// Disable camera controls while any input has focus.
function onInputFocus() { if (cameraControls) cameraControls.enabled = false; }
@ -786,8 +791,9 @@
// -----------------------------------------------------------------------
// Place the camera from ifc:// URL camera=/fov=/scale= params.
// obcCamera is the OBC OrthoPerspectiveCamera instance (world.camera).
// -----------------------------------------------------------------------
async function applyCameraParam(controls, camera) {
async function applyCameraParam(controls, obcCamera) {
const qIdx = ifcUrl.indexOf("?");
if (qIdx < 0) return false;
const qs = new URLSearchParams(ifcUrl.slice(qIdx + 1));
@ -805,19 +811,28 @@
const fovStr = qs.get("fov");
const scaleStr = qs.get("scale");
if (scaleStr) {
// Switch to orthographic projection and apply the view-to-world scale.
await controls.setOrthoCamera();
// Switch to orthographic projection and scale to match the URL's
// ViewToWorldScale (height of view in world units).
await obcCamera.projection.set("Orthographic");
const s = parseFloat(scaleStr);
if (!isNaN(s)) {
camera.top = s / 2;
camera.bottom = -s / 2;
camera.updateProjectionMatrix();
const ortho = obcCamera.threeOrtho;
const currentHeight = ortho.top - ortho.bottom;
if (currentHeight > 0) {
const ratio = s / currentHeight;
ortho.top *= ratio;
ortho.bottom *= ratio;
ortho.left *= ratio;
ortho.right *= ratio;
ortho.updateProjectionMatrix();
}
}
fovInput.value = "";
} else if (fovStr && camera.isPerspectiveCamera) {
camera.fov = Math.max(10, Math.min(120, parseFloat(fovStr)));
camera.updateProjectionMatrix();
fovInput.value = Math.round(camera.fov);
} else if (fovStr) {
const persp = obcCamera.threePersp;
persp.fov = Math.max(10, Math.min(120, parseFloat(fovStr)));
persp.updateProjectionMatrix();
fovInput.value = Math.round(persp.fov);
}
return true;
}
@ -871,6 +886,14 @@
return;
}
// Capture a snapshot of the current view for the BCF zip.
let snapshotB64 = null;
if (rendererCanvas) {
try {
snapshotB64 = rendererCanvas.toDataURL("image/png").split(",")[1];
} catch (_) { /* context lost or cross-origin — skip */ }
}
const topicGuid = crypto.randomUUID();
const vpGuid = cam ? crypto.randomUUID() : null;
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@ -926,7 +949,11 @@
const desc = selector
? `${esc(src)}\nselector: ${esc(selector)}`
: esc(src);
const vpEntry = vpGuid ? `<Viewpoints Guid="${vpGuid}"><Viewpoint>viewpoint.bcfv</Viewpoint></Viewpoints>` : "";
const vpEntry = vpGuid
? `<Viewpoints Guid="${vpGuid}"><Viewpoint>viewpoint.bcfv</Viewpoint>` +
(snapshotB64 ? `<Snapshot>snapshot.png</Snapshot>` : "") +
`</Viewpoints>`
: "";
const cmtEntry = comment
? `<Comment Guid="${crypto.randomUUID()}">
<Date>${now}</Date><Author>anonymous</Author>
@ -950,6 +977,7 @@
zip.file("bcf.version", versionXml);
zip.folder(topicGuid).file("markup.bcf", markupXml);
if (vpGuid && viewpointXml) zip.folder(topicGuid).file("viewpoint.bcfv", viewpointXml);
if (snapshotB64) zip.folder(topicGuid).file("snapshot.png", snapshotB64, { base64: true });
const blob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
@ -1083,7 +1111,7 @@
if (categories) {
for (const [name, groupData] of categories) {
let count = 0;
for (const ids of Object.values(groupData.map)) count += ids.length;
for (const ids of Object.values(groupData)) count += [...ids].length;
if (count > 0) types.push({ name, count });
}
types.sort((a, b) => b.count - a.count);
@ -1240,6 +1268,10 @@
world.scene.setup();
components.init();
// Register a raycaster for this world so OBC.Clipper can raycast
// against the model surface when creating interactive clip planes.
components.get(OBC.Raycasters).get(world);
cameraControls = world.camera.controls;
threeCamera = world.camera.three;
@ -1295,7 +1327,8 @@
loadedModel = model;
world.scene.three.add(model.object);
const hasCam = await applyCameraParam(cameraControls, threeCamera);
const hasCam = await applyCameraParam(cameraControls, world.camera);
threeCamera = world.camera.three; // refresh: may now be ortho
if (!hasCam && !model.box.isEmpty()) {
await cameraControls.fitToBox(model.box, false);
}