mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
viewer: fix visibility modes, add dropdown, zoom-all, and load transition
- visibility=highlight: apply orange colour overlay to selected elements (all others visible normally) using FragmentsManager.highlight() directly — OBC.Highlighter is in @thatopen/components-front which is not installed - visibility=ghost: dim non-selected elements to 15% grey opacity - visibility=isolate: unchanged (hider.isolate) - Add 'show' dropdown (highlight/ghost/isolate) that appears when a selector is set; changing it reloads with the new visibility= param - Add 'fit' button: calls cameraControls.fitToBox(model.box, true) - Smooth load transition: capture canvas snapshot (preserveDrawingBuffer) before navigation, display it as an overlay on the new page, fade it out once the model has rendered Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c7c5d35c63
commit
be1395649c
2 changed files with 135 additions and 15 deletions
|
|
@ -43,9 +43,14 @@
|
||||||
{"id":"ifcurl-ei8","title":"Fix syncCameraUrl: convert WebGL coords to IFC world space","description":"syncCameraUrl in viewer.py currently records camera.position/direction/up directly from Three.js, which are in WebGL/Three.js coordinate space (Y-up, different scale/orientation than IFC). The ifc:// URL spec expects IFC world coordinates (Z-up, matching BCF viewpoint conventions). Need to find the IFC→Three.js transform that @thatopen/components applies when loading a model, then apply its inverse in syncCameraUrl before serialising the camera params. Repos cloned for reference: ~/src/engine_components and ~/src/engine_fragment.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-23T05:53:06Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:36Z","closed_at":"2026-04-23T06:14:36Z","close_reason":"Split into atomic tasks: ifcurl-ldk, ifcurl-j2s, ifcurl-cmd","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-ei8","title":"Fix syncCameraUrl: convert WebGL coords to IFC world space","description":"syncCameraUrl in viewer.py currently records camera.position/direction/up directly from Three.js, which are in WebGL/Three.js coordinate space (Y-up, different scale/orientation than IFC). The ifc:// URL spec expects IFC world coordinates (Z-up, matching BCF viewpoint conventions). Need to find the IFC→Three.js transform that @thatopen/components applies when loading a model, then apply its inverse in syncCameraUrl before serialising the camera params. Repos cloned for reference: ~/src/engine_components and ~/src/engine_fragment.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-23T05:53:06Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:36Z","closed_at":"2026-04-23T06:14:36Z","close_reason":"Split into atomic tasks: ifcurl-ldk, ifcurl-j2s, ifcurl-cmd","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-i1s","title":"Test Phase 3b web viewer end-to-end","description":"After deploying the footer template (ifcurl-ach), test the full flow: open an .ifc file in Forgejo at localhost:3000, click 'View in 3D', verify the viewer loads the model. CDN versions (@thatopen/components 2.4.0, three 0.160.0, web-ifc 0.0.66) may need adjustment if the API has changed.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:01Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:04Z","started_at":"2026-04-23T19:54:56Z","closed_at":"2026-04-23T20:05:04Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-49o","type":"blocks","created_at":"2026-04-23T07:14:12Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-4yu","type":"blocks","created_at":"2026-04-23T07:14:11Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ach","type":"blocks","created_at":"2026-04-23T06:48:10Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
|
{"id":"ifcurl-i1s","title":"Test Phase 3b web viewer end-to-end","description":"After deploying the footer template (ifcurl-ach), test the full flow: open an .ifc file in Forgejo at localhost:3000, click 'View in 3D', verify the viewer loads the model. CDN versions (@thatopen/components 2.4.0, three 0.160.0, web-ifc 0.0.66) may need adjustment if the API has changed.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:01Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:04Z","started_at":"2026-04-23T19:54:56Z","closed_at":"2026-04-23T20:05:04Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-49o","type":"blocks","created_at":"2026-04-23T07:14:12Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-4yu","type":"blocks","created_at":"2026-04-23T07:14:11Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ach","type":"blocks","created_at":"2026-04-23T06:48:10Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"ifcurl-ach","title":"Deploy Phase 3b footer template to Forgejo","description":"The footer.tmpl file that injects a 'View in 3D' button on .ifc file pages has been written but not yet deployed. Deploy it to /etc/forgejo/templates/custom/footer.tmpl and verify the button appears when viewing an .ifc file on localhost:3000.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:47:53Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:35Z","closed_at":"2026-04-23T06:14:35Z","close_reason":"Split into atomic tasks: ifcurl-ohq, ifcurl-49o","dependencies":[{"issue_id":"ifcurl-ach","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:40Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
{"id":"ifcurl-ach","title":"Deploy Phase 3b footer template to Forgejo","description":"The footer.tmpl file that injects a 'View in 3D' button on .ifc file pages has been written but not yet deployed. Deploy it to /etc/forgejo/templates/custom/footer.tmpl and verify the button appears when viewing an .ifc file on localhost:3000.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:47:53Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:35Z","closed_at":"2026-04-23T06:14:35Z","close_reason":"Split into atomic tasks: ifcurl-ohq, ifcurl-49o","dependencies":[{"issue_id":"ifcurl-ach","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:40Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"ifcurl-21x","title":"viewer: smooth transition when URL settings change","description":"Changing the visibility dropdown, selector, or other settings triggers a full page reload, causing the screen to go blank for several seconds while the model re-fetches and re-parses. Options: (a) CSS fade-in on load only — minimal change; (b) capture canvas snapshot before navigate and show as placeholder during reload; (c) in-place model reload without page navigation — best UX but requires refactoring main() to support hot-reload. Option (b) requires preserveDrawingBuffer:true in the WebGL renderer.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-26T07:13:24Z","created_by":"Bruno Postle","updated_at":"2026-04-26T07:17:15Z","started_at":"2026-04-26T07:15:10Z","closed_at":"2026-04-26T07:17:15Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"ifcurl-npy","title":"viewer: add zoom-all / reset-view button","description":"When the camera has panned or zoomed away from the building, there is no way to get back to a full view of the model. Add a 'zoom all' or 'reset view' button in the toolbar that calls cameraControls.fitToBox(model.box, true) to fit the entire model back in view.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-26T07:12:59Z","created_by":"Bruno Postle","updated_at":"2026-04-26T07:17:14Z","started_at":"2026-04-26T07:15:07Z","closed_at":"2026-04-26T07:17:14Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"ifcurl-8yw","title":"viewer: add visibility dropdown when selector is active","description":"When a selector is set in the viewer toolbar, show a drop-down next to the selector input to choose between highlight (ghost non-selected), ghost (alias for highlight), and isolate (hide non-selected). Currently visibility can only be set by editing the raw URL.","status":"closed","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-26T06:57:52Z","created_by":"Bruno Postle","updated_at":"2026-04-26T07:17:13Z","closed_at":"2026-04-26T07:17:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"ifcurl-olo","title":"viewer: 'View in 3D' link should open in a new tab","description":"The link to the viewer should open in a new tab (target=_blank) so the user stays on the Forgejo page. It previously opened in a new window but now opens in the same window, causing the user to navigate away from Forgejo.","status":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-26T06:24:44Z","created_by":"Bruno Postle","updated_at":"2026-04-26T06:24:44Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"ifcurl-mts","title":"viewer: selector text box too narrow","description":"The selector input field in the viewer UI is not wide enough, making it awkward to type or read selector expressions. It should be wider to accommodate typical selector strings like 'IfcWall, Name=\"Core Wall\"'.","status":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-26T06:22:33Z","created_by":"Bruno Postle","updated_at":"2026-04-26T06:22:33Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"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-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-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":"open","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-25T23:25:07Z","started_at":"2026-04-25T22:59:21Z","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":"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-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-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":"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}
|
||||||
|
|
@ -92,4 +97,4 @@
|
||||||
{"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-23T05:48:30Z","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-23T05:48:30Z","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /etc/forgejo/public/assets/ and are served at /assets/*. /var/lib/forgejo is root/forgejo-only. /etc/forgejo has conf/ public/ templates/ but only public/assets/ is used for static files."}
|
{"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,13 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
#status:empty { display: none; }
|
#status:empty { display: none; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
body { animation: fadeIn 0.2s ease; }
|
||||||
|
#snapshot-overlay {
|
||||||
|
position: fixed; inset: 0; top: var(--toolbar-h, 72px); z-index: 4;
|
||||||
|
background: #1c1c1e center/contain no-repeat;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
body.drag-over::after {
|
body.drag-over::after {
|
||||||
content: "Drop ifc:// URL or .bcf file";
|
content: "Drop ifc:// URL or .bcf file";
|
||||||
position: fixed; inset: 0; z-index: 100;
|
position: fixed; inset: 0; z-index: 100;
|
||||||
|
|
@ -142,6 +149,7 @@
|
||||||
spellcheck="false">
|
spellcheck="false">
|
||||||
<button id="clip-btn" class="tb-btn" title="Double-click model surface to add clipping plane">✂ clip</button>
|
<button id="clip-btn" class="tb-btn" title="Double-click model surface to add clipping plane">✂ clip</button>
|
||||||
<button id="clip-clear-btn" class="tb-btn" title="Remove all clipping planes" style="display:none">⊗ clips</button>
|
<button id="clip-clear-btn" class="tb-btn" title="Remove all clipping planes" style="display:none">⊗ clips</button>
|
||||||
|
<button id="fit-btn" class="tb-btn" title="Fit model in view">fit</button>
|
||||||
<button id="meta-btn" class="tb-btn" title="Model overview: types and storeys">☰</button>
|
<button id="meta-btn" class="tb-btn" title="Model overview: types and storeys">☰</button>
|
||||||
<button id="bcf-btn" class="tb-btn" title="Export current view as a BCF issue">BCF</button>
|
<button id="bcf-btn" class="tb-btn" title="Export current view as a BCF issue">BCF</button>
|
||||||
<button id="copy-url-btn" class="tb-btn" title="Copy ifc:// URL to clipboard">⎘</button>
|
<button id="copy-url-btn" class="tb-btn" title="Copy ifc:// URL to clipboard">⎘</button>
|
||||||
|
|
@ -179,6 +187,14 @@
|
||||||
title="Type-name union: IfcWall, IfcWall+IfcSlab. Property/attribute filters are applied server-side only."
|
title="Type-name union: IfcWall, IfcWall+IfcSlab. Property/attribute filters are applied server-side only."
|
||||||
spellcheck="false">
|
spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field" id="visibility-field" style="display:none">
|
||||||
|
<label>show</label>
|
||||||
|
<select id="visibility-select" class="ti" title="How to display non-selected elements">
|
||||||
|
<option value="highlight">highlight</option>
|
||||||
|
<option value="ghost">ghost</option>
|
||||||
|
<option value="isolate">isolate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 3: BCF export/import form (hidden until BCF button clicked) -->
|
<!-- Row 3: BCF export/import form (hidden until BCF button clicked) -->
|
||||||
<div id="bcf-row" class="trow" style="display:none">
|
<div id="bcf-row" class="trow" style="display:none">
|
||||||
|
|
@ -233,8 +249,10 @@
|
||||||
const refInput = document.getElementById("ref-input");
|
const refInput = document.getElementById("ref-input");
|
||||||
const pathInput = document.getElementById("path-input");
|
const pathInput = document.getElementById("path-input");
|
||||||
const selectorInput = document.getElementById("selector-input");
|
const selectorInput = document.getElementById("selector-input");
|
||||||
const copyUrlBtn = document.getElementById("copy-url-btn");
|
const copyUrlBtn = document.getElementById("copy-url-btn");
|
||||||
const newIssueBtn = document.getElementById("new-issue-btn");
|
const newIssueBtn = document.getElementById("new-issue-btn");
|
||||||
|
const visibilitySelect = document.getElementById("visibility-select");
|
||||||
|
const visibilityField = document.getElementById("visibility-field");
|
||||||
const propsPanel = document.getElementById("props-panel");
|
const propsPanel = document.getElementById("props-panel");
|
||||||
const propsType = document.getElementById("props-type");
|
const propsType = document.getElementById("props-type");
|
||||||
const propsName = document.getElementById("props-name");
|
const propsName = document.getElementById("props-name");
|
||||||
|
|
@ -251,6 +269,26 @@
|
||||||
let cameraControls = null;
|
let cameraControls = null;
|
||||||
let threeCamera = null;
|
let threeCamera = null;
|
||||||
let activeClipper = null;
|
let activeClipper = null;
|
||||||
|
let loadedModel = null;
|
||||||
|
let rendererCanvas = null;
|
||||||
|
|
||||||
|
// Show previous-view snapshot while the new model loads.
|
||||||
|
{
|
||||||
|
const snap = sessionStorage.getItem("viewerSnapshot");
|
||||||
|
if (snap && ifcUrl) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.id = "snapshot-overlay";
|
||||||
|
el.style.backgroundImage = `url("${snap}")`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function removeSnapshot() {
|
||||||
|
const el = document.getElementById("snapshot-overlay");
|
||||||
|
if (!el) return;
|
||||||
|
el.style.opacity = "0";
|
||||||
|
setTimeout(() => el.remove(), 550);
|
||||||
|
sessionStorage.removeItem("viewerSnapshot");
|
||||||
|
}
|
||||||
|
|
||||||
// Build an ifc:// URL from the structured form fields.
|
// Build an ifc:// URL from the structured form fields.
|
||||||
function buildIfcUrl() {
|
function buildIfcUrl() {
|
||||||
|
|
@ -270,6 +308,20 @@
|
||||||
}
|
}
|
||||||
urlInput.value = ifcUrl;
|
urlInput.value = ifcUrl;
|
||||||
|
|
||||||
|
// Initialise visibility dropdown from URL and show it when selector is non-empty.
|
||||||
|
{
|
||||||
|
const qIdx = ifcUrl.indexOf("?");
|
||||||
|
const visVal = qIdx < 0 ? "highlight"
|
||||||
|
: (new URLSearchParams(ifcUrl.slice(qIdx + 1)).get("visibility") ?? "highlight");
|
||||||
|
visibilitySelect.value = visVal;
|
||||||
|
}
|
||||||
|
function syncVisibilityField() {
|
||||||
|
visibilityField.style.display = selectorInput.value.trim() ? "" : "none";
|
||||||
|
}
|
||||||
|
selectorInput.addEventListener("input", syncVisibilityField);
|
||||||
|
syncVisibilityField();
|
||||||
|
visibilitySelect.addEventListener("change", loadFromFields);
|
||||||
|
|
||||||
// Show commit-pin indicator when the ref is a bare commit hash.
|
// Show commit-pin indicator when the ref is a bare commit hash.
|
||||||
const refBadge = document.getElementById("ref-badge");
|
const refBadge = document.getElementById("ref-badge");
|
||||||
const refSwitchBtn = document.getElementById("ref-switch-btn");
|
const refSwitchBtn = document.getElementById("ref-switch-btn");
|
||||||
|
|
@ -300,11 +352,19 @@
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Navigate to the viewer with a different ifc:// URL.
|
// Navigate to the viewer with a different ifc:// URL.
|
||||||
|
// Captures a canvas snapshot for the transition overlay, then fades out.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
function loadUrl(url) {
|
function loadUrl(url) {
|
||||||
|
if (rendererCanvas) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("viewerSnapshot", rendererCanvas.toDataURL("image/jpeg", 0.6));
|
||||||
|
} catch (_) { /* cross-origin or context-lost — skip */ }
|
||||||
|
}
|
||||||
|
document.body.style.transition = "opacity 0.15s";
|
||||||
|
document.body.style.opacity = "0";
|
||||||
const pageUrl = new URL(window.location.href);
|
const pageUrl = new URL(window.location.href);
|
||||||
pageUrl.searchParams.set("url", url);
|
pageUrl.searchParams.set("url", url);
|
||||||
window.location.href = pageUrl.toString();
|
setTimeout(() => { window.location.href = pageUrl.toString(); }, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
urlInput.addEventListener("keydown", e => {
|
urlInput.addEventListener("keydown", e => {
|
||||||
|
|
@ -330,6 +390,14 @@
|
||||||
}
|
}
|
||||||
structQs.delete("clip");
|
structQs.delete("clip");
|
||||||
for (const v of currentQs.getAll("clip")) structQs.append("clip", v);
|
for (const v of currentQs.getAll("clip")) structQs.append("clip", v);
|
||||||
|
// Include visibility when a selector is set; omit for the default (highlight).
|
||||||
|
if (selectorInput.value.trim()) {
|
||||||
|
const vis = visibilitySelect.value;
|
||||||
|
if (vis && vis !== "highlight") structQs.set("visibility", vis);
|
||||||
|
else structQs.delete("visibility");
|
||||||
|
} else {
|
||||||
|
structQs.delete("visibility");
|
||||||
|
}
|
||||||
const qsStr = structQs.toString()
|
const qsStr = structQs.toString()
|
||||||
.replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$");
|
.replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$");
|
||||||
loadUrl(structBase + (qsStr ? "?" + qsStr : ""));
|
loadUrl(structBase + (qsStr ? "?" + qsStr : ""));
|
||||||
|
|
@ -419,6 +487,11 @@
|
||||||
window.open(issueUrl, "_blank", "noopener");
|
window.open(issueUrl, "_blank", "noopener");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("fit-btn").addEventListener("click", () => {
|
||||||
|
if (cameraControls && loadedModel && !loadedModel.box.isEmpty())
|
||||||
|
cameraControls.fitToBox(loadedModel.box, true);
|
||||||
|
});
|
||||||
|
|
||||||
propsCloseBtn.addEventListener("click", () => { propsPanel.style.display = "none"; });
|
propsCloseBtn.addEventListener("click", () => { propsPanel.style.display = "none"; });
|
||||||
propsCopyBtn.addEventListener("click", async () => {
|
propsCopyBtn.addEventListener("click", async () => {
|
||||||
const guid = propsGuid.textContent;
|
const guid = propsGuid.textContent;
|
||||||
|
|
@ -432,7 +505,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
const allInputs = [urlInput, fovInput, repoInput, refInput, pathInput, selectorInput,
|
const allInputs = [urlInput, fovInput, repoInput, refInput, pathInput, selectorInput,
|
||||||
bcfTitleIn, bcfCommentIn];
|
visibilitySelect, bcfTitleIn, bcfCommentIn];
|
||||||
for (const el of allInputs) {
|
for (const el of allInputs) {
|
||||||
el.addEventListener("focus", onInputFocus);
|
el.addEventListener("focus", onInputFocus);
|
||||||
el.addEventListener("blur", onInputBlur);
|
el.addEventListener("blur", onInputBlur);
|
||||||
|
|
@ -614,7 +687,34 @@
|
||||||
return selectorStr.split("+").every(part => /^[Ii]fc[A-Za-z0-9]+$/.test(part.trim()));
|
return selectorStr.split("+").every(part => /^[Ii]fc[A-Za-z0-9]+$/.test(part.trim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySelector(components, model, selectorStr, srcUrl, visibility = "isolate", highlighter = null) {
|
// Returns a ModelIdMap containing all items in allItems that are NOT in toRemove.
|
||||||
|
function subtractIdMap(allItems, toRemove) {
|
||||||
|
const result = {};
|
||||||
|
for (const [modelId, allIds] of Object.entries(allItems)) {
|
||||||
|
const removeSet = new Set(toRemove[modelId] ?? []);
|
||||||
|
const remaining = [];
|
||||||
|
for (const id of allIds) {
|
||||||
|
if (!removeSet.has(id)) remaining.push(id);
|
||||||
|
}
|
||||||
|
if (remaining.length > 0) result[modelId] = remaining;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a fragment material style to the given items via FragmentsManager.
|
||||||
|
async function applyFragmentStyle(components, items, color, opacity) {
|
||||||
|
const fragments = components.get(OBC.FragmentsManager);
|
||||||
|
await fragments.resetHighlight();
|
||||||
|
if (Object.keys(items).length > 0) {
|
||||||
|
await fragments.highlight(
|
||||||
|
{ customId: "sel", color, opacity, transparent: opacity < 1 },
|
||||||
|
items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fragments.core.update(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySelector(components, model, selectorStr, srcUrl, visibility = "highlight") {
|
||||||
if (!selectorStr) return;
|
if (!selectorStr) return;
|
||||||
|
|
||||||
const hider = components.get(OBC.Hider);
|
const hider = components.get(OBC.Hider);
|
||||||
|
|
@ -633,10 +733,16 @@
|
||||||
if (!matchingCategories.length) return;
|
if (!matchingCategories.length) return;
|
||||||
const matching = await classifier.find({ Categories: matchingCategories });
|
const matching = await classifier.find({ Categories: matchingCategories });
|
||||||
if (!matching || Object.keys(matching).length === 0) return;
|
if (!matching || Object.keys(matching).length === 0) return;
|
||||||
if (visibility === "highlight" && highlighter) {
|
if (visibility === "ghost") {
|
||||||
await highlighter.highlightByID("select", matching, true, false);
|
// Dim non-selected; selected appear normally.
|
||||||
} else {
|
const allItems = await classifier.find({ Categories: [...categoryGroups.keys()] });
|
||||||
|
await applyFragmentStyle(components, subtractIdMap(allItems, matching),
|
||||||
|
new THREE.Color(0x888888), 0.15);
|
||||||
|
} else if (visibility === "isolate") {
|
||||||
await hider.isolate(matching);
|
await hider.isolate(matching);
|
||||||
|
} else {
|
||||||
|
// highlight (default): colour-overlay the selected elements, all others visible.
|
||||||
|
await applyFragmentStyle(components, matching, new THREE.Color(0xff8800), 1);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -662,10 +768,15 @@
|
||||||
const localIds = (await model.getLocalIdsByGuids(guids)).filter(id => id !== null);
|
const localIds = (await model.getLocalIdsByGuids(guids)).filter(id => id !== null);
|
||||||
if (!localIds.length) return;
|
if (!localIds.length) return;
|
||||||
|
|
||||||
if (visibility === "highlight" && highlighter) {
|
const matchingMap = { [model.modelId]: new Set(localIds) };
|
||||||
await highlighter.highlightByID("select", { [model.modelId]: localIds }, true, false);
|
if (visibility === "ghost") {
|
||||||
|
const allVisible = await hider.getVisibilityMap(true);
|
||||||
|
await applyFragmentStyle(components, subtractIdMap(allVisible, matchingMap),
|
||||||
|
new THREE.Color(0x888888), 0.15);
|
||||||
|
} else if (visibility === "isolate") {
|
||||||
|
await hider.isolate(matchingMap);
|
||||||
} else {
|
} else {
|
||||||
await hider.isolate({ [model.modelId]: localIds });
|
await applyFragmentStyle(components, matchingMap, new THREE.Color(0xff8800), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1112,8 +1223,9 @@
|
||||||
const container = document.getElementById("viewer");
|
const container = document.getElementById("viewer");
|
||||||
|
|
||||||
world.scene = new OBC.SimpleScene(components);
|
world.scene = new OBC.SimpleScene(components);
|
||||||
world.renderer = new OBC.SimpleRenderer(components, container);
|
world.renderer = new OBC.SimpleRenderer(components, container, { preserveDrawingBuffer: true });
|
||||||
world.camera = new OBC.OrthoPerspectiveCamera(components);
|
world.camera = new OBC.OrthoPerspectiveCamera(components);
|
||||||
|
rendererCanvas = world.renderer.three.domElement;
|
||||||
world.scene.setup();
|
world.scene.setup();
|
||||||
components.init();
|
components.init();
|
||||||
|
|
||||||
|
|
@ -1169,6 +1281,7 @@
|
||||||
statusEl.textContent = "Parsing IFC…";
|
statusEl.textContent = "Parsing IFC…";
|
||||||
try {
|
try {
|
||||||
const model = await ifcLoader.load(buffer, true, "model");
|
const model = await ifcLoader.load(buffer, true, "model");
|
||||||
|
loadedModel = model;
|
||||||
world.scene.three.add(model.object);
|
world.scene.three.add(model.object);
|
||||||
|
|
||||||
const hasCam = await applyCameraParam(cameraControls, threeCamera);
|
const hasCam = await applyCameraParam(cameraControls, threeCamera);
|
||||||
|
|
@ -1213,13 +1326,14 @@
|
||||||
|
|
||||||
if (selectorStr) {
|
if (selectorStr) {
|
||||||
statusEl.textContent = "Applying selector…";
|
statusEl.textContent = "Applying selector…";
|
||||||
await applySelector(components, model, selectorStr, ifcUrl, visibility, highlighter);
|
await applySelector(components, model, selectorStr, ifcUrl, visibility);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fragments.core.update(true);
|
await fragments.core.update(true);
|
||||||
|
|
||||||
syncCameraUrl(threeCamera);
|
syncCameraUrl(threeCamera);
|
||||||
cameraControls.addEventListener("rest", () => syncCameraUrl(threeCamera));
|
cameraControls.addEventListener("rest", () => syncCameraUrl(threeCamera));
|
||||||
|
removeSnapshot();
|
||||||
|
|
||||||
populateMetaPanel(components).catch(err => {
|
populateMetaPanel(components).catch(err => {
|
||||||
console.warn("Metadata panel unavailable:", err.message);
|
console.warn("Metadata panel unavailable:", err.message);
|
||||||
|
|
@ -1227,6 +1341,7 @@
|
||||||
|
|
||||||
statusEl.textContent = "";
|
statusEl.textContent = "";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
removeSnapshot();
|
||||||
statusEl.textContent = `Render error: ${err.message}`;
|
statusEl.textContent = `Render error: ${err.message}`;
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue