From 5ea60da9435e1432b9d1d2cb3a4455724a8eb4a7 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 7 Jun 2026 06:56:11 +0100 Subject: [PATCH] Fix BCF export missing snapshot.png when selector is active Capture the WebGL canvas client-side before POSTing to /bcf, send the base64 PNG as a new `snapshot` field, and embed it as snapshot.png in the zip alongside viewpoint.bcfv. Also wires up the element in markup.bcf when a snapshot is present. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 6 +++--- forgejo/custom/public/assets/viewer.js | 8 +++++++- ifcurl/bcf.py | 11 ++++++++++- ifcurl/service.py | 10 ++++++++++ tests/test_bcf.py | 24 ++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 331d027..c535955 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -9,10 +9,10 @@ {"id":"ifcurl-242","title":"connector.py: JSON-RPC connector implementing CLOUD_SYNC_PROTOCOL.md interface","description":"Implement the ifcurl connector as a JSON-RPC server per CLOUD_SYNC_PROTOCOL.md. Methods: download_models (resolve ifc:// source URLs to local file paths via working checkout), upload_model (receive a modified IFC file — the connector writes it into the working checkout, ready for the user to branch/commit/push with standard git tools). The connector is a thin JSON-RPC wrapper over checkout.py and git.py; all resolution logic stays in existing modules.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:59Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:59Z","dependencies":[{"issue_id":"ifcurl-242","depends_on_id":"ifcurl-5oo","type":"blocks","created_at":"2026-06-05T15:43:19Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-242","depends_on_id":"ifcurl-oef","type":"blocks","created_at":"2026-06-05T15:43:21Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"id":"ifcurl-oef","title":"Remote origin remapping: set working checkout origin to user local repo","description":"When a working checkout is created for an ifc:// URL and a local repo has been identified (via discovery or config), set the checkout's origin remote to the local repo path and add an upstream remote pointing to the original ifc:// source URL. This mirrors standard fork workflow: fetch from upstream, push to origin (local repo), which the user then pushes to their remote and opens a PR. If no local repo is found, origin stays as the upstream (read-only without credentials).","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:57Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:57Z","dependencies":[{"issue_id":"ifcurl-oef","depends_on_id":"ifcurl-egi","type":"blocks","created_at":"2026-06-05T15:43:18Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"ifcurl-egi","title":"Local repo discovery: scan configured paths for repos matching genesis commit","description":"When opening an ifc:// URL, scan user-configured search paths (e.g. ~/projects/) for local git repos whose genesis commit matches the project being opened. If a match is found, that local repo becomes the push target (origin) for the working checkout. Search paths are configured in ~/.config/ifcurl/config.toml under [repo_search] paths. Results are cached per genesis commit to avoid repeated scanning.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:54Z","created_by":"Bruno Postle","updated_at":"2026-06-05T22:05:37Z","started_at":"2026-06-05T22:00:19Z","closed_at":"2026-06-05T22:05:37Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-egi","depends_on_id":"ifcurl-4d4","type":"blocks","created_at":"2026-06-05T15:43:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"ifcurl-5oo","title":"checkout.py: writable working tree management on top of bare cache","description":"Add a checkout.py module that manages full (non-bare) working tree checkouts for ifc:// URLs, alongside the existing bare cache clone. The checkout is created via git clone from the bare cache (no extra network fetch). It is checked out at a specific ref as a detached HEAD, which forces the user to create a branch before committing. The checkout includes a .gitignore covering *.ifcview, *.rdbview and other viewer artefacts. The connector and local-file materialisation both build on this.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:52Z","created_by":"Bruno Postle","updated_at":"2026-06-05T22:08:23Z","started_at":"2026-06-05T22:08:23Z","dependencies":[{"issue_id":"ifcurl-5oo","depends_on_id":"ifcurl-i07","type":"blocks","created_at":"2026-06-05T15:43:13Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"id":"ifcurl-5oo","title":"checkout.py: writable working tree management on top of bare cache","description":"Add a checkout.py module that manages full (non-bare) working tree checkouts for ifc:// URLs, alongside the existing bare cache clone. The checkout is created via git clone from the bare cache (no extra network fetch). It is checked out at a specific ref as a detached HEAD, which forces the user to create a branch before committing. The checkout includes a .gitignore covering *.ifcview, *.rdbview and other viewer artefacts. The connector and local-file materialisation both build on this.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:52Z","created_by":"Bruno Postle","updated_at":"2026-06-05T22:38:17Z","started_at":"2026-06-05T22:08:23Z","closed_at":"2026-06-05T22:38:17Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-5oo","depends_on_id":"ifcurl-i07","type":"blocks","created_at":"2026-06-05T15:43:13Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"ifcurl-4d4","title":"Genesis commit identity: compute and cache root commit hash as project key","description":"To identify that https and SSH forms of the same repo, and forks of the same project, are all 'the same project', we use the genesis commit (git rev-list --max-parents=0 HEAD) as a stable project identity key. Store the genesis commit hash alongside each bare cache clone. This key is used by local repo discovery and origin remapping.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:50Z","created_by":"Bruno Postle","updated_at":"2026-06-05T17:32:58Z","started_at":"2026-06-05T17:29:24Z","closed_at":"2026-06-05T17:32:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"ifcurl-i07","title":"File materialisation layer: extract IFC blob to stable per-commit cache path","description":"When a tool needs a real file path (not bytes), the bare cache clone is not sufficient. We need a layer that writes the IFC bytes from a git blob to a stable directory keyed by commit hexsha, e.g. ~/.cache/ifcurl/\u003curl-hash\u003e/\u003ccommit-hexsha\u003e/model.ifc. Immutable refs (commit hashes) can be cached indefinitely. Mutable refs (branches, HEAD) must re-extract after each fetch. This is the foundation for all connector and checkout work.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:30Z","created_by":"Bruno Postle","updated_at":"2026-06-05T17:26:22Z","started_at":"2026-06-05T15:41:04Z","closed_at":"2026-06-05T17:26:22Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"ifcurl-8jp","title":"BCF export missing snapshot.png when selector is active","description":"When a selector is active in the viewer URL, BCF export delegates to the server-side /bcf endpoint which cannot capture the canvas. The resulting zip has viewpoint.bcfv but no snapshot.png. Fix: capture the canvas client-side before POSTing to /bcf, and include the snapshot bytes in the request so the server can embed it in the zip.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-05-17T21:58:39Z","created_by":"Bruno Postle","updated_at":"2026-05-17T21:58:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-8jp","title":"BCF export missing snapshot.png when selector is active","description":"When a selector is active in the viewer URL, BCF export delegates to the server-side /bcf endpoint which cannot capture the canvas. The resulting zip has viewpoint.bcfv but no snapshot.png. Fix: capture the canvas client-side before POSTing to /bcf, and include the snapshot bytes in the request so the server can embed it in the zip.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-17T21:58:39Z","created_by":"Bruno Postle","updated_at":"2026-06-07T05:55:02Z","started_at":"2026-06-07T05:50:19Z","closed_at":"2026-06-07T05:55:02Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-oub","title":"New Issue button produces ifc:// link that JS rewrites to empty href","description":"The viewer's 'New Issue' button pre-fills the issue body with a link in the form [label](ifc://...). The ifcurl.js linkification code then rewrites this to [label]() stripping the URL. The JS should not rewrite links that already have an explicit ifc:// href.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-05-17T21:18:25Z","created_by":"Bruno Postle","updated_at":"2026-05-17T22:59:15Z","closed_at":"2026-05-17T22:59:15Z","close_reason":"Fixed: scope linkifyBareIfcUrls and replaceIfcAnchors to .markup elements only, preventing Forgejo editor preview from being modified","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-lal","title":"Render shows multiple copies of building","description":"When rendering the brown-street model via /preview, the output image shows multiple duplicate copies of the building rather than a single model. Likely a geometry or camera issue in the render pipeline.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-17T21:18:07Z","created_by":"Bruno Postle","updated_at":"2026-05-17T23:16:36Z","started_at":"2026-05-17T23:05:27Z","closed_at":"2026-05-17T23:16:36Z","close_reason":"Excluded IfcSpace and IfcAnnotation from default render iterator, matching Bonsai/JS viewer behavior","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-ntm","title":"Avoid full model reload when only view params change (camera, clip, visibility)","description":"When editing the ifc:// URL field in the viewer, any parameter change triggers a full fetch, parse, and 3D rebuild. This is unnecessary for parameters that only affect the rendered view — camera, clipping planes, visibility mode, selector highlighting — all of which can be applied to the already-loaded model without re-fetching the IFC file from the server. A full reload should only happen when the source changes: host, repo, ref, or path.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-17T05:43:36Z","created_by":"Bruno Postle","updated_at":"2026-05-17T08:12:58Z","started_at":"2026-05-17T05:44:26Z","closed_at":"2026-05-17T08:12:58Z","close_reason":"Implemented in-place view updates via applyViewChanges(); toRawUrl comparison for source identity; cameraControls.update(0) flush before render","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -135,6 +135,6 @@ {"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-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":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","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 /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."} {"_type":"memory","key":"ifcurl-service-names-are-ifcurl-api-service-and","value":"ifcurl service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service). Install path for Python package: /opt/ifcurl/lib/python3.14/site-packages/ifcurl/. Python binary: /opt/ifcurl/bin/python3."} +{"_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/*."} {"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."} diff --git a/forgejo/custom/public/assets/viewer.js b/forgejo/custom/public/assets/viewer.js index 61451bb..b016114 100644 --- a/forgejo/custom/public/assets/viewer.js +++ b/forgejo/custom/public/assets/viewer.js @@ -835,11 +835,17 @@ async function generateBcf(title, comment) { const visibility = qs.get("visibility") || "highlight"; if (selector) { + let snapshotB64 = null; + if (rendererCanvas) { + try { + snapshotB64 = rendererCanvas.toDataURL("image/png").split(",")[1]; + } catch (_) { /* context lost or cross-origin — skip */ } + } try { const resp = await fetch("/bcf", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: src, title: title || "IFC View", comment }), + body: JSON.stringify({ url: src, title: title || "IFC View", comment, snapshot: snapshotB64 }), }); if (!resp.ok) { const detail = await resp.text().catch(() => resp.statusText); diff --git a/ifcurl/bcf.py b/ifcurl/bcf.py index 3d965d3..888cbbf 100644 --- a/ifcurl/bcf.py +++ b/ifcurl/bcf.py @@ -116,6 +116,7 @@ def _markup_xml( author: str, now: str, description: str = "", + has_snapshot: bool = False, ) -> bytes: root = ET.Element("Markup") topic = ET.SubElement( @@ -129,6 +130,8 @@ def _markup_xml( if vp_guid: vps = ET.SubElement(topic, "Viewpoints", Guid=vp_guid) ET.SubElement(vps, "Viewpoint").text = "viewpoint.bcfv" + if has_snapshot: + ET.SubElement(vps, "Snapshot").text = "snapshot.png" if comment and comment.strip(): c = ET.SubElement(root, "Comment", Guid=str(uuid.uuid4())) @@ -155,6 +158,7 @@ def build_bcf( comment: str = "", description: str = "", author: str = "anonymous", + snapshot: bytes | None = None, ) -> bytes: """Build a BCF 2.1 zip archive and return the raw bytes. @@ -179,7 +183,10 @@ def build_bcf( zf.writestr("bcf.version", _VERSION_XML) zf.writestr( f"{topic_guid}/markup.bcf", - _markup_xml(topic_guid, vp_guid, title, comment, author, now, description), + _markup_xml( + topic_guid, vp_guid, title, comment, author, now, description, + has_snapshot=snapshot is not None, + ), ) if vp_guid is not None: zf.writestr( @@ -188,6 +195,8 @@ def build_bcf( vp_guid, camera, fov, scale, clips or [], guids, visibility ), ) + if vp_guid is not None and snapshot is not None: + zf.writestr(f"{topic_guid}/snapshot.png", snapshot) return buf.getvalue() diff --git a/ifcurl/service.py b/ifcurl/service.py index 65ff129..e731893 100644 --- a/ifcurl/service.py +++ b/ifcurl/service.py @@ -457,6 +457,8 @@ class BcfRequest(BaseModel): title: str = "IFC View" comment: str = "" token: str | None = None + snapshot: str | None = None + """Base64-encoded PNG snapshot captured client-side.""" class DiffRequest(BaseModel): @@ -673,6 +675,13 @@ def bcf_export(request: BcfRequest) -> Response: status_code=422, detail=f"Invalid selector: {exc}" ) from exc + snapshot_bytes: bytes | None = None + if request.snapshot: + try: + snapshot_bytes = base64.b64decode(request.snapshot) + except Exception: + pass + bcf_bytes = build_bcf( camera=ifc_url.camera, fov=ifc_url.fov, @@ -683,6 +692,7 @@ def bcf_export(request: BcfRequest) -> Response: title=request.title, comment=request.comment, description=request.url, + snapshot=snapshot_bytes, ) return Response( content=bcf_bytes, diff --git a/tests/test_bcf.py b/tests/test_bcf.py index 19d5fc5..f994df2 100644 --- a/tests/test_bcf.py +++ b/tests/test_bcf.py @@ -50,6 +50,11 @@ def _zip_read(data: bytes, name: str) -> str: return zf.read(name).decode() +def _zip_read_bytes(data: bytes, name: str) -> bytes: + with zipfile.ZipFile(io.BytesIO(data)) as zf: + return zf.read(name) + + # --------------------------------------------------------------------------- # build_bcf unit tests # --------------------------------------------------------------------------- @@ -141,6 +146,25 @@ class TestBuildBcf: content = _zip_read(data, markup) assert "" not in content + def test_snapshot_written_to_zip_and_referenced_in_markup(self): + cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) + png_bytes = b"\x89PNG\r\n\x1a\nfake" + data = build_bcf(camera=cam, fov=60.0, snapshot=png_bytes) + names = _zip_names(data) + snap_file = next((n for n in names if n.endswith("snapshot.png")), None) + assert snap_file is not None + assert _zip_read_bytes(data, snap_file) == png_bytes + markup = next(n for n in names if n.endswith("markup.bcf")) + assert "snapshot.png" in _zip_read(data, markup) + + def test_no_snapshot_when_not_provided(self): + cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) + data = build_bcf(camera=cam, fov=60.0) + names = _zip_names(data) + assert not any(n.endswith("snapshot.png") for n in names) + markup = next(n for n in names if n.endswith("markup.bcf")) + assert "snapshot.png" not in _zip_read(data, markup) + # --------------------------------------------------------------------------- # /bcf service endpoint tests