From 930b8b5e329276fa9d4308fd63338d59638927f7 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Wed, 29 Apr 2026 19:04:04 +0100 Subject: [PATCH] bcf: add bcf_viewpoint_to_ifc_url() and IfcUrl.to_string() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements SPECIFICATION.md §6 BCF→ifc:// direction: parse a BCF 3.0 REST viewpoint dict (camera, clips, selection/visibility) and apply it to a base IfcUrl to produce an ifc:// URL string. IfcUrl.to_string() serialises any IfcUrl back to a valid ifc:// URL, enabling round-trip parse→to_string for all URL forms. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 6 +-- ifcurl/bcf.py | 55 ++++++++++++++++++++++++ ifcurl/url.py | 38 +++++++++++++++++ tests/test_bcf.py | 100 +++++++++++++++++++++++++++++++++++++++++++- tests/test_url.py | 74 ++++++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 4 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 161f809..6a61f2d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,7 +2,7 @@ {"id":"ifcurl-p60","title":"Viewer: no element click-to-identify — can't get GlobalId or properties","description":"There is no way to click on a mesh in the viewer and see which IFC element it represents. Reviewers can see geometry but cannot reference specific elements — they can't discover a GlobalId to put in a selector URL, can't see the element name/type/Psets, and can't say 'this specific wall' in a BCF or Forgejo comment. This breaks the collaboration workflow at its foundation: every useful feedback comment needs to identify specific elements. Need: click or hover on a surface to highlight it and show a properties panel (name, type, GlobalId, key Psets). The GlobalId should be copyable into the selector field or URL. The ThatOpen components library provides the tools for this (OBC.IfcRelationsIndexer, element property queries).","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:02:33Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:23Z","started_at":"2026-04-24T11:14:57Z","closed_at":"2026-04-24T11:47:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:19Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-b1c","title":"Implement OpenCDE BCF API on Forgejo","description":"Expose a BCF 3.0 REST API implemented as Go routes inside Forgejo, making it a BCF-compatible CDE that tools like Revit, Navisworks, Solibri, and BIMcollab can connect to directly.\n\n## Architecture\n\nGo routes added to Forgejo alongside the existing patch. Purely additive — new endpoints only, no modification of existing Forgejo behaviour. Uses Forgejo's existing auth middleware so session cookies, user attribution, and per-user permissions all work correctly with no extra machinery.\n\nDirect database access via Forgejo's internal models (issues, comments, repos, users) — no round-trips through the REST API, no machine token required.\n\n## Design\n\nThe BCF API is a stateless translation layer over Forgejo issues and comments. No new storage format or schema changes to Forgejo are needed.\n\n**Storage:** ifc:// URLs in issue and comment bodies are the viewpoint format. This is already what the viewer Issue button produces. The BCF\u003c-\u003eifc:// conversion is specified in SPECIFICATION.md §6.\n\n**Mapping:**\n\n| BCF resource | Forgejo |\n|---|---|\n| Project | Repository (project_id = owner/repo) |\n| Topic | Issue |\n| Topic guid | Derived from issue ID |\n| Comment | Issue comment |\n| Viewpoint | ifc:// URL found in issue/comment body |\n| Viewpoint guid | Derived from comment ID |\n| Snapshot | ifc:// URL proxied through preview service |\n\n**Read viewpoints:** scan issue/comment bodies for ifc:// URLs, convert each to a BCF viewpoint object on the fly via the §6 mapping.\n\n**Write viewpoints:** convert incoming BCF viewpoint to an ifc:// URL, post as a new Forgejo comment.\n\n**Snapshot:** GET .../viewpoints/{guid}/snapshot proxies the stored ifc:// URL through the preview service.\n\n## Minimum viable endpoints\n\n- GET /bcf/3.0/projects\n- GET /bcf/3.0/projects/{id}\n- GET/POST /bcf/3.0/projects/{id}/topics\n- GET/PUT /bcf/3.0/projects/{id}/topics/{guid}\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/comments\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/viewpoints\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}/snapshot\n- GET /bcf/3.0/projects/{id}/extensions (static minimal response)\n\nAuthentication is handled by Forgejo's existing middleware — Bearer tokens and session cookies both work without any additional plumbing.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:55:11Z","created_by":"Bruno Postle","updated_at":"2026-04-28T23:09:46Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-b1c","title":"Implement OpenCDE BCF API on Forgejo","description":"Expose a BCF 3.0 REST API as a Python route group in the preview service, proxied at /bcf/3.0/ on the Forgejo hostname via nginx/Caddy.\n\n## Architecture\n\nPython routes added to service.py alongside the existing /preview and /bcf endpoints. No new Forgejo Go code — the service forwards the user's Authorization: Bearer header verbatim to Forgejo's REST API on localhost, so Forgejo's own auth middleware enforces per-user permissions. No service account or machine token required.\n\nBCF \u003c-\u003e ifc:// conversion stays entirely in Python: bcf.py (ifc:// -\u003e BCF, already exists) and the reverse direction implemented in ifcurl-l50.\n\n## Design\n\nThe BCF API is a stateless translation layer over Forgejo's REST API. No Forgejo recompilation, no schema changes, no new Go code.\n\n**Storage:** ifc:// URLs in issue and comment bodies are the viewpoint format. This is already what the viewer Issue button produces. The BCF\u003c-\u003eifc:// conversion is specified in SPECIFICATION.md §6.\n\n**Mapping:**\n\n| BCF resource | Forgejo |\n|---|---|\n| Project | Repository (project_id = owner/repo) |\n| Topic | Issue |\n| Topic guid | Derived from issue ID |\n| Comment | Issue comment |\n| Viewpoint | ifc:// URL found in issue/comment body |\n| Viewpoint guid | Derived from comment ID |\n| Snapshot | ifc:// URL proxied through preview service |\n\n**Auth:** BCF client sends Authorization: Bearer \u003cforgejo-token\u003e. Service forwards it to Forgejo REST API. No credential storage, no service token.\n\n**Read viewpoints:** scan issue/comment bodies for ifc:// URLs, convert each to a BCF viewpoint object on the fly via the §6 mapping.\n\n**Write viewpoints:** convert incoming BCF viewpoint to an ifc:// URL (ifcurl-l50), post as a new Forgejo comment.\n\n**Snapshot:** GET .../viewpoints/{guid}/snapshot proxies the stored ifc:// URL through the existing /preview endpoint.\n\n## Minimum viable endpoints\n\n- GET /bcf/3.0/projects\n- GET /bcf/3.0/projects/{id}\n- GET/POST /bcf/3.0/projects/{id}/topics\n- GET/PUT /bcf/3.0/projects/{id}/topics/{guid}\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/comments\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/viewpoints\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}/snapshot\n- GET /bcf/3.0/projects/{id}/extensions (static minimal response)\n\n## Prerequisites\n\n- ifcurl-lwl: Foundation API discovery so BCF clients can locate this endpoint via OpenCDE auto-discovery\n- ifcurl-l50: BCF -\u003e ifc:// URL conversion needed for POST /viewpoints","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:55:11Z","created_by":"Bruno Postle","updated_at":"2026-04-29T17:43:48Z","dependencies":[{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-l50","type":"blocks","created_at":"2026-04-29T18:44:06Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-lwl","type":"blocks","created_at":"2026-04-29T18:44:05Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"id":"ifcurl-962","title":"forgejo: replace Go markdown extension with JS, eliminate required Go compile","description":"ifc_url.go is a Goldmark AST extension that requires patching and recompiling Forgejo to work. This is painful when dev environment (Fedora) differs from deployment (Docker) — compiler flags, static linking, build tags must all match. The goal is to make the ifc:// preview feature work with zero Go compilation, via pure JS asset deployment.\n\nPlan:\n1. Add querySelectorAll('a[href^=\"ifc://\"]') scanning to ifcurl.js — replace matched anchors with figure elements (preview image + viewer link + ifc:// URL). Goldmark already renders [title](ifc://...) as \u003ca href=\"ifc://...\"\u003etitle\u003c/a\u003e without any extension, so this covers all markdown link syntax.\n2. Change the Issue button in viewer.js to emit [view](ifc://...) markdown link syntax instead of a bare URL — so that user-generated issue bodies produce linkable anchors Goldmark will render, not plain text.\n3. The existing Go extension (ifc_url.go) and its markdown.go registration can be removed on the next Forgejo upgrade; the current patched binary continues to work in the meantime.\n\nAcceptance: after this change, deploying new ifcurl features only requires copying JS assets — no Forgejo rebuild needed.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T06:58:15Z","created_by":"Bruno Postle","updated_at":"2026-04-27T07:25:09Z","started_at":"2026-04-27T06:58:48Z","closed_at":"2026-04-27T07:25:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-22n","title":"viewer: add ifc:// URL and clickable image link to embedded snapshots","description":"Forgejo commit pages and pull requests embed IFC snapshots as visual diffs. Each snapshot should have two additions: (1) the ifc:// URL that addresses this exact view at the commit hash displayed as text alongside the image, and (2) the snapshot image itself should be a clickable link that opens the viewer at this view. This makes snapshots navigable and shareable.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T05:36:16Z","created_by":"Bruno Postle","updated_at":"2026-04-27T05:59:52Z","started_at":"2026-04-27T05:59:51Z","closed_at":"2026-04-27T05:59:52Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-22n","depends_on_id":"ifcurl-5a6","type":"blocks","created_at":"2026-04-27T06:37:10Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-dkz","title":"Viewer bundle: upgrade @thatopen/components 3.3.1 → 3.4.2 to fix fast-xml-parser CVEs","description":"npm audit reports 2 critical CVEs in fast-xml-parser (a transitive dep of @thatopen/components 3.3.1). These are DoS/injection issues in XML parsing. Risk in the viewer context is low (client-side, user's own files) but should be addressed. Fix: bump @thatopen/components to 3.4.2 in forgejo/package.json, run npm install \u0026\u0026 npm run build, and commit the regenerated bundle.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T17:12:37Z","created_by":"Bruno Postle","updated_at":"2026-04-25T17:59:50Z","started_at":"2026-04-25T17:31:59Z","closed_at":"2026-04-25T17:59:50Z","close_reason":"Upgraded to @thatopen/components 3.4.2 + three 0.184.0 + web-ifc 0.0.77. Severity reduced from 2 critical to 1 high + 1 low. Remaining fast-xml-parser CVEs are upstream in @thatopen \u003e= 3.3.3 with no fix released yet.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -46,7 +46,7 @@ {"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-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-lwl","title":"Add OpenCDE Foundation API discovery endpoint","description":"Expose GET /foundation/versions on the Forgejo instance so OpenCDE clients can auto-discover which APIs the server supports. Returns JSON declaring BCF API 3.0 and Documents API with their base URLs and OAuth2 configuration.\n\n## Architecture\n\nStandalone Python daemon, proxied at /foundation/ on the Forgejo hostname. Static or config-driven JSON response — no Forgejo internals needed, no auth required (Foundation API discovery endpoint is public by spec). Likely a small route in the existing preview service.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:06Z","created_by":"Bruno Postle","updated_at":"2026-04-28T23:10:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-lwl","title":"Add OpenCDE Foundation API discovery endpoint","description":"Expose GET /foundation/versions on the Forgejo instance so OpenCDE clients can auto-discover which APIs the server supports. Returns JSON declaring BCF API 3.0 and Documents API with their base URLs and OAuth2 configuration.\n\n## Architecture\n\nStandalone Python daemon, proxied at /foundation/ on the Forgejo hostname. Static or config-driven JSON response — no Forgejo internals needed, no auth required (Foundation API discovery endpoint is public by spec). Likely a small route in the existing preview service.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:06Z","created_by":"Bruno Postle","updated_at":"2026-04-28T23:10:13Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"ifcurl-3td","title":"Add status hint when clip mode is active","description":"When the user enters clip mode the cursor changes to a crosshair but there is no other feedback explaining that a double-click is needed to place a plane. Add a short hint (e.g. in the status bar or as a tooltip) saying 'Double-click to place clip plane' while clip mode is active, and clear it when clip mode is deactivated.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-27T19:45:47Z","created_by":"Bruno Postle","updated_at":"2026-04-27T19:45:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-5u6","title":"Evaluate replacing ifc_url.go markdown extension with JS injection","description":"ifc_url.go is a Goldmark AST extension that replaces ifc:// URLs in rendered markdown with preview figure elements (img + link). This requires patching and recompiling Forgejo. A JS alternative in footer.tmpl could handle the [title](ifc://...) markdown link case by scanning document.querySelectorAll('a[href^=\"ifc://\"]') and replacing matched anchors with figure elements at runtime — no Go changes needed. The limitation: bare ifc://... text in markdown won't be auto-linked by Goldmark (unknown scheme renders as plain text), so bare URLs would need text-node scanning in JS, which is fiddly and causes a render flash. Decision: replace with JS if users can commit to always using [title](ifc://...) link syntax; keep Go extension if bare URLs in markdown are a real use case.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T06:05:32Z","created_by":"Bruno Postle","updated_at":"2026-04-27T06:40:02Z","started_at":"2026-04-27T06:39:11Z","closed_at":"2026-04-27T06:40:02Z","close_reason":"Keep Go extension. Issue button (viewer.js:261) emits bare ifc:// URLs into markdown, making bare-URL rendering essential. JS text-node scanning works but causes a render flash. A clean alternative would be registering ifc:// as a Goldmark autolink scheme so bare URLs become \u003ca\u003e elements JS can pick up — worth revisiting if upgrade burden grows, but not worth doing now.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-71f","title":"Extract footer.tmpl JS into a separate ifcurl.js asset file","description":"footer.tmpl is 200 lines of JavaScript wrapped in a single \u003cscript\u003e tag. Moving it to /var/lib/forgejo/custom/public/assets/ifcurl.js (served at /assets/ifcurl.js) would reduce footer.tmpl to a one-liner \u003cscript src='/assets/ifcurl.js'\u003e\u003c/script\u003e. Benefits: proper editor syntax highlighting and linting, no HTML-escaping concerns, cleaner separation of concerns. Two files to deploy instead of one, but that's manageable.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T06:05:05Z","created_by":"Bruno Postle","updated_at":"2026-04-27T06:36:55Z","started_at":"2026-04-27T06:33:57Z","closed_at":"2026-04-27T06:36:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -102,7 +102,7 @@ {"id":"ifcurl-s1o","title":"viewer: extract inline JS from viewer.html into viewer.js","description":"viewer.html is ~1250 lines, almost entirely JavaScript. This makes it hard to edit with proper tooling (no syntax highlighting, linting, or go-to-definition). Extract the main viewer logic into a separate viewer.js file alongside viewer-url.js. The build.js step would need to copy it to the assets directory.","status":"closed","priority":4,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T23:16:36Z","created_by":"Bruno Postle","updated_at":"2026-04-26T14:49:05Z","started_at":"2026-04-26T13:10:02Z","closed_at":"2026-04-26T14:49:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-nd1","title":"Submit provisional IANA registration for ifc:// scheme","description":"Once the Phase 1 URL spec is stable, submit a provisional IANA URI scheme registration for ifc://.\\n\\nProcess: email iana@iana.org with scheme name, syntax description, security considerations, and a link to the spec (GitHub URL suffices). First Come First Served — no committee review, typically processed within days to weeks. See RFC 7595 for requirements.\\n\\nThis secures the namespace, establishes priority, and is a prerequisite if the scheme is ever proposed to buildingSMART as an open standard.","status":"open","priority":4,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T11:17:45Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:17:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-u6r","title":"Forgejo commit page: no IFC thumbnail — git log gives no visual indication of model state","description":"In the Forgejo repository commit list, each commit shows only the commit message. When a commit modifies an IFC file, there is no thumbnail showing what the model looks like. Reviewers browsing commits have no visual cues about which commits changed significant geometry versus metadata-only changes. A Forgejo webhook or CI integration could post a rendered PNG thumbnail as a commit status/check comment when .ifc files change, using the preview service. This would make the git log visually meaningful for BIM projects.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:41Z","created_by":"Bruno Postle","updated_at":"2026-04-25T20:07:21Z","closed_at":"2026-04-25T20:07:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-l50","title":"SPECIFICATION.md section 6: BCF-to-IFC-URL direction not implemented","description":"SPECIFICATION.md §6 defines both directions of BCF ↔ IFC URL conversion. The IFC URL → BCF direction is fully implemented (build_bcf(), /bcf endpoint, viewer BCF button). The BCF → IFC URL direction (extract GUIDs from Components/Selection, construct GUID selector, convert camera and clips to URL params) has no implementation. This would be useful for round-tripping: import a BCF viewpoint and get back a shareable ifc:// URL.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:01Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:41:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-l50","title":"SPECIFICATION.md section 6: BCF-to-IFC-URL direction not implemented","description":"SPECIFICATION.md §6 defines both directions of BCF ↔ IFC URL conversion. The IFC URL → BCF direction is fully implemented (build_bcf(), /bcf endpoint, viewer BCF button). The BCF → IFC URL direction (extract GUIDs from Components/Selection, construct GUID selector, convert camera and clips to URL params) has no implementation. This would be useful for round-tripping: import a BCF viewpoint and get back a shareable ifc:// URL.","status":"in_progress","priority":4,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:01Z","created_by":"Bruno Postle","updated_at":"2026-04-29T17:45:13Z","started_at":"2026-04-29T17:45:13Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"ifcurl-8ns","title":"Future: Contribute ifc:// URL parsing to IfcOpenShell","description":"Once the Python core URL parsing and resolution logic is stable, contribute it to IfcOpenShell as a shared utility. Not a prerequisite for any phase — pursue after Phase 1 has been stable in production use for a while.","status":"open","priority":4,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:33Z","created_by":"Bruno Postle","updated_at":"2026-04-23T05:49:33Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-9yr","title":"Future: BCF export endpoint /bcf","description":"Add a /bcf endpoint to the preview service: POST /bcf with body {url: 'ifc://...'} returns a BCF zip file. Resolve the selector to a GUID set and construct a BCF viewpoint from the camera, clipping planes, and visibility state. Not planned before Phase 2 is complete.","status":"closed","priority":4,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:25Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:51:18Z","started_at":"2026-04-24T08:34:51Z","closed_at":"2026-04-24T08:51:18Z","close_reason":"bcf.py build_bcf() + /bcf POST endpoint + viewer BCF panel with JSZip client-side generation + 21 tests","dependency_count":0,"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} diff --git a/ifcurl/bcf.py b/ifcurl/bcf.py index 9bac366..4992e61 100644 --- a/ifcurl/bcf.py +++ b/ifcurl/bcf.py @@ -28,12 +28,15 @@ BCF (BIM Collaboration Format) 2.1 structure produced here:: from __future__ import annotations +import dataclasses import io import uuid import xml.etree.ElementTree as ET import zipfile from datetime import datetime, timezone +from ifcurl.url import IfcUrl + _VERSION_XML = b"""\ @@ -182,3 +185,55 @@ def build_bcf( ), ) return buf.getvalue() + + +def bcf_viewpoint_to_ifc_url(base: IfcUrl, viewpoint: dict) -> str: + """Convert a BCF 3.0 REST viewpoint dict to an ifc:// URL. + + Applies camera, clipping planes, and component selection from the BCF + viewpoint to *base*, which supplies the repo/ref/path context. + Returns the resulting ifc:// URL string. + """ + camera: tuple[float, ...] | None = None + fov: float | None = None + scale: float | None = None + + if pc := viewpoint.get("perspective_camera"): + p, d, u = pc["camera_view_point"], pc["camera_direction"], pc["camera_up_vector"] + camera = (p["x"], p["y"], p["z"], d["x"], d["y"], d["z"], u["x"], u["y"], u["z"]) + fov = float(pc["field_of_view"]) + elif oc := viewpoint.get("orthogonal_camera"): + p, d, u = oc["camera_view_point"], oc["camera_direction"], oc["camera_up_vector"] + camera = (p["x"], p["y"], p["z"], d["x"], d["y"], d["z"], u["x"], u["y"], u["z"]) + scale = float(oc["view_to_world_scale"]) + + clips: list[tuple[float, ...]] = [] + for cp in viewpoint.get("clipping_planes", []): + loc, dir_ = cp["location"], cp["direction"] + clips.append((loc["x"], loc["y"], loc["z"], dir_["x"], dir_["y"], dir_["z"])) + + components = viewpoint.get("components", {}) + vis = components.get("visibility", {}) + default_visible = vis.get("default_visibility", True) + exceptions = [e["ifc_guid"] for e in vis.get("exceptions", []) if "ifc_guid" in e] + selected = [s["ifc_guid"] for s in components.get("selection", []) if "ifc_guid" in s] + + if not default_visible: + # exceptions are the only visible elements → isolate + guids = exceptions + visibility = "isolate" + else: + guids = selected + visibility = "highlight" + + selector = "+".join(guids) if guids else None + + return dataclasses.replace( + base, + camera=camera, # type: ignore[arg-type] + fov=fov, + scale=scale, + clips=clips, # type: ignore[arg-type] + selector=selector, + visibility=visibility, + ).to_string() diff --git a/ifcurl/url.py b/ifcurl/url.py index 021560d..c98719e 100644 --- a/ifcurl/url.py +++ b/ifcurl/url.py @@ -205,3 +205,41 @@ class IfcUrl: Mutable refs (HEAD, branches) must not be cached at the rendered-PNG tier. """ return self.ref == "HEAD" or self.ref.startswith("heads/") + + def to_string(self) -> str: + """Serialize to an ifc:// URL string.""" + from urllib.parse import quote + + if self.transport == "local": + authority = "" + path = self.repo_path + elif self.transport == "ssh": + authority = f"{self.user}@{self.host}" + path = f"/{self.repo_path}" + else: # https + authority = self.host + path = f"/{self.repo_path}" + + base = f"ifc://{authority}{path}@{self.ref}" + + parts = [] + if self.path: + parts.append(f"path={quote(self.path, safe='/')}") + if self.selector: + parts.append(f"selector={quote(self.selector, safe='$')}") + if self.camera: + vals = ",".join(repr(v) for v in self.camera) + parts.append(f"camera={vals}") + if self.fov is not None: + parts.append(f"fov={self.fov!r}") + if self.scale is not None: + parts.append(f"scale={self.scale!r}") + for clip in self.clips: + vals = ",".join(repr(v) for v in clip) + parts.append(f"clip={vals}") + if self.visibility != "highlight": + parts.append(f"visibility={self.visibility}") + + if parts: + return base + "?" + "&".join(parts) + return base diff --git a/tests/test_bcf.py b/tests/test_bcf.py index 07bda0e..19d5fc5 100644 --- a/tests/test_bcf.py +++ b/tests/test_bcf.py @@ -9,8 +9,9 @@ from unittest.mock import patch import pytest from fastapi.testclient import TestClient -from ifcurl.bcf import build_bcf +from ifcurl.bcf import bcf_viewpoint_to_ifc_url, build_bcf from ifcurl.service import _t2_cache, _t3_cache, app, configure_allowed_hosts +from ifcurl.url import IfcUrl client = TestClient(app) @@ -217,3 +218,100 @@ class TestBcfEndpoint: vp_files = [n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv")] # No camera → no viewpoint even with selector assert len(vp_files) == 0 + + +# --------------------------------------------------------------------------- +# bcf_viewpoint_to_ifc_url() unit tests +# --------------------------------------------------------------------------- + +BASE = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc") + +PERSPECTIVE_VP = { + "perspective_camera": { + "camera_view_point": {"x": 1.0, "y": 2.0, "z": 3.0}, + "camera_direction": {"x": 0.0, "y": 0.0, "z": -1.0}, + "camera_up_vector": {"x": 0.0, "y": 1.0, "z": 0.0}, + "field_of_view": 60.0, + } +} + +ORTHO_VP = { + "orthogonal_camera": { + "camera_view_point": {"x": 0.0, "y": 0.0, "z": 10.0}, + "camera_direction": {"x": 0.0, "y": 0.0, "z": -1.0}, + "camera_up_vector": {"x": 0.0, "y": 1.0, "z": 0.0}, + "view_to_world_scale": 50.0, + } +} + + +class TestBcfViewpointToIfcUrl: + def test_perspective_camera(self): + result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, PERSPECTIVE_VP)) + assert result.camera == (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) + assert result.fov == 60.0 + assert result.scale is None + + def test_orthogonal_camera(self): + result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, ORTHO_VP)) + assert result.scale == 50.0 + assert result.fov is None + + def test_preserves_base_repo_context(self): + result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, PERSPECTIVE_VP)) + assert result.host == "example.com" + assert result.repo_path == "org/repo" + assert result.ref == "heads/main" + assert result.path == "model.ifc" + + def test_no_camera_gives_no_camera_params(self): + result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, {})) + assert result.camera is None + assert result.fov is None + assert result.scale is None + + def test_clipping_planes(self): + vp = { + **PERSPECTIVE_VP, + "clipping_planes": [ + { + "location": {"x": 0.0, "y": 0.0, "z": 2.0}, + "direction": {"x": 0.0, "y": 0.0, "z": -1.0}, + } + ], + } + result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, vp)) + assert result.clips == [(0.0, 0.0, 2.0, 0.0, 0.0, -1.0)] + + def test_selection_becomes_selector(self): + vp = { + **PERSPECTIVE_VP, + "components": { + "selection": [ + {"ifc_guid": "abc123"}, + {"ifc_guid": "def456"}, + ] + }, + } + result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, vp)) + assert result.selector == "abc123+def456" + assert result.visibility == "highlight" + + def test_isolate_from_default_hidden(self): + vp = { + **PERSPECTIVE_VP, + "components": { + "visibility": { + "default_visibility": False, + "exceptions": [{"ifc_guid": "abc123"}], + } + }, + } + result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, vp)) + assert result.selector == "abc123" + assert result.visibility == "isolate" + + def test_no_components_gives_no_selector(self): + result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, PERSPECTIVE_VP)) + assert result.selector is None + assert result.visibility == "highlight" diff --git a/tests/test_url.py b/tests/test_url.py index c4542ab..6a56e39 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -300,3 +300,77 @@ def test_clip_wrong_count(): def test_unknown_visibility(): with pytest.raises(ValueError, match="visibility"): IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&visibility=wireframe") + + +# --------------------------------------------------------------------------- +# to_string() +# --------------------------------------------------------------------------- + + +def test_to_string_roundtrip_simple(): + raw = "ifc://example.com/org/repo@heads/main?path=models/building.ifc" + assert IfcUrl.parse(raw).to_string() == raw + + +def test_to_string_roundtrip_ssh(): + raw = "ifc://git@example.com/org/repo@abc123?path=model.ifc" + assert IfcUrl.parse(raw).to_string() == raw + + +def test_to_string_roundtrip_local(): + raw = "ifc:///home/alice/project@HEAD?path=model.ifc" + assert IfcUrl.parse(raw).to_string() == raw + + +def test_to_string_roundtrip_perspective_camera(): + raw = ( + "ifc://example.com/o/r@heads/main" + "?path=model.ifc&camera=1.0,2.0,3.0,0.0,0.0,-1.0,0.0,1.0,0.0&fov=60.0" + ) + url = IfcUrl.parse(raw) + parsed_back = IfcUrl.parse(url.to_string()) + assert parsed_back.camera == url.camera + assert parsed_back.fov == url.fov + assert parsed_back.scale is None + + +def test_to_string_roundtrip_orthographic_with_clip(): + raw = ( + "ifc://example.com/o/r@tags/v2.0" + "?path=model.ifc&camera=0.0,0.0,8.0,0.0,0.0,-1.0,0.0,1.0,0.0" + "&scale=50.0&clip=0.0,0.0,5.0,0.0,0.0,-1.0" + ) + url = IfcUrl.parse(raw) + parsed_back = IfcUrl.parse(url.to_string()) + assert parsed_back.scale == 50.0 + assert parsed_back.clips == [(0.0, 0.0, 5.0, 0.0, 0.0, -1.0)] + + +def test_to_string_selector_encoded(): + url = IfcUrl.parse( + "ifc://example.com/o/r@HEAD?path=m.ifc&selector=IfcWall%2BIfcSlab" + ) + assert "selector=IfcWall%2BIfcSlab" in url.to_string() + + +def test_to_string_no_params(): + url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc") + s = url.to_string() + assert s.startswith("ifc://example.com/org/repo@HEAD") + assert "path=m.ifc" in s + + +def test_to_string_visibility_ghost(): + url = IfcUrl.parse( + "ifc://example.com/o/r@HEAD?path=m.ifc" + "&camera=0.0,0.0,5.0,0.0,0.0,-1.0,0.0,1.0,0.0&fov=60.0&visibility=ghost" + ) + assert "visibility=ghost" in url.to_string() + + +def test_to_string_omits_default_visibility(): + url = IfcUrl.parse( + "ifc://example.com/o/r@HEAD?path=m.ifc" + "&camera=0.0,0.0,5.0,0.0,0.0,-1.0,0.0,1.0,0.0&fov=60.0" + ) + assert "visibility" not in url.to_string()