render_diff 404 for branch/PR commits — git.py fix not deployed to /opt/ifcurl/

The git.py fetch-on-miss fix (commit cad1fa5) works correctly from source (confirmed by python3 test returning OK for commit 50f3571). However the installed service at /opt/ifcurl/ is still running the old code and returns 404 with 'SHA ... could not be resolved'.

Root cause: initial bare clone only fetches the default branch; PR/branch commits are absent and immutable refs skip the fetch. Fix adds a retry with +refs/*:refs/* fetch on miss.

To deploy:
  sudo cp /home/bruno/src/ifcurl/ifcurl/git.py /opt/ifcurl/lib/python3.14/site-packages/ifcurl/git.py
  sudo systemctl restart ifcurl-api.service ifcurl-render.service

Note: service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service).


ifcopenshell segfault on malformed IFC: service DoS and potential RCE risk

ifcopenshell uses C++ bindings (via web-ifc) that can segfault when given malformed or adversarially crafted IFC data. In the current service architecture this has two serious implications:

1. **DoS**: a segfault kills the uvicorn worker process. Under repeated attack a service with --workers=1 (the common deployment) goes down entirely. Even with multiple workers, each is vulnerable independently.

2. **Potential RCE**: memory-corruption bugs in C++ parsers are a well-known exploit vector. A crafted IFC file that triggers a heap overflow in ifcopenshell could yield arbitrary code execution as the service user. The service runs on the same host as Forgejo, making this a high-value target.

The two call sites at risk are:
- `_load_model()` → `ifcopenshell.file.from_string()` in service.py (runs in the uvicorn worker thread)
- `render_mod.render()` → `ifcopenshell.geom.iterator()` in render.py (already spawns worker subprocesses, so a segfault there kills a worker process but not the service)

The primary mitigation to investigate is **subprocess isolation for the parse step**: run `from_string()` in a short-lived child process with a timeout and resource limits. If the child exits with a signal (SIGSEGV, SIGABRT) or times out, return HTTP 422 to the caller without killing the service. This is the standard pattern used by document-conversion services (e.g. LibreOffice sandboxing).

Secondary mitigations:
- Apply `ulimit` / `resource.setrlimit` to cap address-space and CPU time in the child
- Consider `seccomp` syscall filtering for the parse subprocess (restrict to read-only FS access, no network, no exec)
- File-size cap on uploaded/fetched IFC bytes (tracked separately in ifcurl-fu5)
- Fuzz testing with `atheris` or AFL++ against the parse path to surface crashes before deployment

Acceptance criteria: a crafted request that would segfault the current service returns 422 and the service continues to handle subsequent requests.

Implemented subprocess isolation in ifcurl/sandbox.py. All ifcopenshell calls now run in a child process; segfault → SandboxCrashError → HTTP 422 without killing the service.


Viewer: no element click-to-identify — can't get GlobalId or properties

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).


viewer.html: scale= URLs don't load in orthographic mode

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=...&scale=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.


Add missing server-config files: gitconfig-ifcmerge and gitattributes

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.

Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.


Preview image camera is offset ~5m laterally vs JS viewer

When opening a Forgejo markdown preview image (rendered by Python service via /preview), the camera position appears ~5m to the left compared to the same URL opened in the JS viewer.

Confirmed: the model geometry IS shifted, not just a display scaling issue.

What's known:
- render.py uses IfcOpenShell with use-world-coords=True (world-space vertices)
- _apply_camera() sets PyVista camera directly in IFC coordinates (no axis conversion)
- JS viewer uses toThree() to convert IFC (Z-up) → Three.js (Y-up) coords
- Python test from source correctly fetches the file (fetch works, rendering is the issue)

Hypothesis to investigate:
- Does IfcOpenShell's geom iterator output vertices in IFC world coords (Z-up) or does it apply an axis swap?
- Does PyVista/VTK interpret the camera position in the same axis convention as the IFC geometry?
- Is plotter.show() triggering a reset_camera() that overrides the explicitly set camera?

Test URL: ifc://localhost/bruno/simple-ifc@6af61b5454ac10ee2651da2d7363f54faa4f5b70?path=_test_simple.ifc&camera=13.4983,-3.5129,4.4906,-0.8673,-0.4901,-0.0869,0,0,1&fov=60

Relevant code: ifcurl/render.py _apply_camera() line 88, _render_iterator() line 269.

Investigation so far (2026-05-16):

Root cause hypothesis: web-ifc uses COORDINATE_TO_ORIGIN=True when loading IFC for the JS viewer. This moves the model's IFC WorldCoordinateSystem to Three.js origin. IfcOpenShell with use-world-coords=True gives raw IFC world coordinates. If COORDINATE_TO_ORIGIN applies a non-identity transform to this specific model, that explains the offset.

Model coordinate facts:
- WorldCoordinateSystem: at origin (0,0,0) with standard axes
- Site/Building/Storey placements: all at origin (identity matrix)
- Model Y range (IFC): -15.9 to 4.1m, bbox center Y=-5.9m
- This ~5.9m Y-offset matches the reported ~5m lateral offset

Key findings:
- COORDINATE_TO_ORIGIN=True is set in IfcImporter.webIfcSettings (engine_fragment)
- The autoCoordinate=true in FragmentsModels does NOT center a single model — only aligns subsequent models to the first one's coordinate system
- MCP tool .mcp.json configured: ifcmcp installed at /usr/bin/ifcmcp

Next step: use ifcmcp to inspect what web-ifc actually outputs for this model's bounding box vs IfcOpenShell. Need to determine: does web-ifc COORDINATE_TO_ORIGIN shift the model's geometry even when WorldCoordinateSystem is at origin?

Fixed by adding webIfc: { COORDINATE_TO_ORIGIN: false } to ifcLoader.setup() in viewer.js. Root cause: web-ifc COORDINATE_TO_ORIGIN shifts all model geometry (by ~12m for this model's first-element position) but GetCoordinationMatrix returns identity, hiding the shift. Camera URL stores IFC world coords but model was in shifted space. Fix keeps model in IFC world coords (per spec §5) which matches IfcOpenShell's unshifted output in the Python renderer.


Implement OpenCDE BCF API on Forgejo

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.

## Architecture

Python 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.

BCF <-> ifc:// conversion stays entirely in Python: bcf.py (ifc:// -> BCF, already exists) and the reverse direction implemented in ifcurl-l50.

## Design

The BCF API is a stateless translation layer over Forgejo's REST API. No Forgejo recompilation, no schema changes, no new Go code.

**Storage:** ifc:// URLs in issue and comment bodies are the viewpoint format. This is already what the viewer Issue button produces. The BCF<->ifc:// conversion is specified in SPECIFICATION.md §6.

**Mapping:**

| BCF resource | Forgejo |
|---|---|
| Project | Repository (project_id = owner/repo) |
| Topic | Issue |
| Topic guid | Derived from issue ID |
| Comment | Issue comment |
| Viewpoint | ifc:// URL found in issue/comment body |
| Viewpoint guid | Derived from comment ID |
| Snapshot | ifc:// URL proxied through preview service |

**Auth:** BCF client sends Authorization: Bearer <forgejo-token>. Service forwards it to Forgejo REST API. No credential storage, no service token.

**Read viewpoints:** scan issue/comment bodies for ifc:// URLs, convert each to a BCF viewpoint object on the fly via the §6 mapping.

**Write viewpoints:** convert incoming BCF viewpoint to an ifc:// URL (ifcurl-l50), post as a new Forgejo comment.

**Snapshot:** GET .../viewpoints/{guid}/snapshot proxies the stored ifc:// URL through the existing /preview endpoint.

## Minimum viable endpoints

- GET /bcf/3.0/projects
- GET /bcf/3.0/projects/{id}
- GET/POST /bcf/3.0/projects/{id}/topics
- GET/PUT /bcf/3.0/projects/{id}/topics/{guid}
- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/comments
- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/viewpoints
- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}
- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}/snapshot
- GET /bcf/3.0/projects/{id}/extensions (static minimal response)

## Prerequisites

- ifcurl-lwl: Foundation API discovery so BCF clients can locate this endpoint via OpenCDE auto-discovery
- ifcurl-l50: BCF -> ifc:// URL conversion needed for POST /viewpoints


forgejo: replace Go markdown extension with JS, eliminate required Go compile

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.

Plan:
1. 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 title without any extension, so this covers all markdown link syntax.
2. 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.
3. 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.

Acceptance: after this change, deploying new ifcurl features only requires copying JS assets — no Forgejo rebuild needed.


viewer: add ifc:// URL and clickable image link to embedded snapshots

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.


Viewer bundle: upgrade @thatopen/components 3.3.1 → 3.4.2 to fix fast-xml-parser CVEs

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 && npm run build, and commit the regenerated bundle.

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 >= 3.3.3 with no fix released yet.


Split service into ifcurl-api and ifcurl-render for RCE isolation

The ifcopenshell C++ bindings are the main RCE attack surface (memory corruption on malformed IFC). The git/token code is not at risk but currently shares the same process and systemd security profile.

Split the service into two systemd units with different security profiles:

ifcurl-api (renamed from ifcurl-preview): handles HTTP, git fetch, caching, auth token lookup. Needs execve (git CLI via GitPython) and AF_INET/AF_INET6. No ifcopenshell.

ifcurl-render: handles IFC parsing and PNG rendering only. Listens on Unix socket /run/ifcurl-render/render.sock. Has SystemCallFilter ~execve ~execveat (confirmed safe: strace shows pyvista and ifcopenshell geom iterator use fork/clone only, never exec). Has RestrictAddressFamilies=AF_UNIX only. No access to /etc/ifcurl tokens. Runs as separate user ifcurl-render.

Communication: API service calls render service via httpx over the Unix socket. Both service users belong to a shared ifcurl group for socket access.

Interface (three endpoints on render service):
  POST /render        multipart(ifc, params JSON) → JSON {png: base64, guids: list|null}
  POST /select        multipart(ifc, params JSON) → JSON {guids: list}
  POST /render_diff   multipart(head_ifc, base_ifc, params JSON) → image/png binary

sandbox.py crash isolation is kept inside the render service for per-request segfault containment.

Backward compat: if IFCURL_RENDER_SOCKET is unset, API service falls back to direct in-process rendering (current sandbox.py path).

Implementation steps:
1. ifcurl/render_service.py — FastAPI app with the three endpoints wrapping sandboxed functions
2. ifcurl/service.py — delegate to render service when IFCURL_RENDER_SOCKET is set
3. ifcurl/__main__.py — add 'render-service' CLI subcommand
4. pyproject.toml — add httpx to service optional deps
5. forgejo/server-config/ifcurl-api.service (rename + update)
6. forgejo/server-config/ifcurl-render.service (new, hardened unit)
7. forgejo/README.md — update deployment docs for two-service setup
8. tests/test_render_service.py — integration tests for the render service endpoints

render service cannot exec /bin/sh even with RCE (verified by strace showing no execve during normal render); normal renders still produce correct PNG; API service correctly delegates to render service when socket is configured; fallback to in-process rendering works when socket is unset

Two-service split implemented: render_service.py with /render /select /render_diff endpoints, service.py delegates via httpx when IFCURL_RENDER_SOCKET is set, render-service CLI subcommand added, two systemd units created


gitconfig-ifcmerge: update to use ifcmerge --prioritise-local instead of swapping LOCAL/REMOTE

The current gitconfig-ifcmerge uses the old technique of swapping the order of %A and %B arguments to preserve the base branch's STEP ID space:

    driver = ifcmerge %O %A %B %L    (standard)
    driver = ifcmerge %O %B %A %L    (ours variant)

Bonsai's current ifcgit code (tool/ifcgit.py, config_ifcmerge) uses the newer --prioritise-local flag instead:

    ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED

The --prioritise-local flag tells ifcmerge to preserve LOCAL's STEP ID space without needing to swap argument order. This is cleaner, more readable, and follows the canonical ifcmerge interface.

The gitconfig-ifcmerge needs updating to:
    driver = ifcmerge --prioritise-local %O %A %B %A

(where %A is both LOCAL and the output file — git merge drivers write their result to %A)

The ifcmerge_ours variant (for rebase direction where base branch arrives as %B) would become:
    driver = ifcmerge --prioritise-local %O %B %A %B

Verify that the --prioritise-local flag exists in the version of ifcmerge being used before merging.


render_diff: auto-fit camera to changed elements, not whole model

When rendering a diff of a large building model, the auto-fit camera fits to the entire head model. A wall added to one corner of a multi-storey building would appear as a tiny green speck. The camera should instead zoom to the bounding box of just the added + modified + removed elements so the changed area fills the viewport.

Implementation sketch:
- In render_diff(), after adding all shapes to plotter1, call plotter1.reset_camera() as now, but also compute the bounds of only the diff-coloured meshes (added + modified) and re-fit to those bounds.
- For pass 2 (removed elements), the elements are already isolated in the iterator, so reset_camera() naturally fits to them. The pass 1 camera capture then needs to consider that pass 2 might be out of that frustum.
- A combined approach: compute the bounding box union of added + modified + removed element meshes, then set the camera to fit that box with a small margin. This is the camera used for both passes.
- pyvista Plotter has plotter.reset_camera(bounds=(xmin,xmax,ymin,ymax,zmin,zmax)) which can fit to an explicit box.
- The bounds can be accumulated during the _add_shape loop for diff-coloured elements.

Acceptance: a single modified wall in a large building renders with the wall clearly visible and filling most of the image.


sandbox.py: add seccomp filter to block execve and socket in render subprocess

The subprocess isolation in sandbox.py (ifcurl-vm5) prevents DoS but not RCE: if a crafted IFC file achieves code execution inside the render child, that child still has full access to execve and the network. A seccomp BPF filter applied at the start of _worker() would block the two most useful post-exploitation steps even under active code execution.

The filter should:
- Block execve/execveat (cannot exec a shell or any binary)
- Block socket() for AF_INET/AF_INET6/AF_UNIX (cannot open a reverse shell or connect to the Forgejo Unix socket)
- Allow clone/fork (ifcopenshell's geom.iterator spawns its own worker processes)
- Allow everything else needed by Python + ifcopenshell + pyvista

The python-seccomp library (pip install seccomp, wraps libseccomp) is the cleanest way to do this. The filter would be installed via resource.setrlimit companion code already present in sandbox._worker().

The main risk is over-filtering: if the seccomp policy blocks a syscall that ifcopenshell or pyvista needs, renders fail with EPERM. The policy needs to be validated against the full render path (including the geometry iterator subprocesses, which inherit the filter across fork).

Acceptance criteria:
- A render subprocess cannot exec /bin/sh even with arbitrary code execution (verified by replacing fn with a ctypes execve call — should get EPERM/killed, not a shell)
- Normal renders continue to produce correct PNG output
- The seccomp library is optional (graceful fallback if libseccomp is absent, e.g. on macOS or minimal containers)

systemd hardening accepted as sufficient; execve cannot be blocked per-subprocess without non-standard seccomp library; residual risk acceptable for threat model


ifc:// URLs in Forgejo issues and PR comments not documented as a collaboration tool

Forgejo's markdown extension renders ifc:// URLs as clickable 3D preview images in file README views, but it works equally in issue bodies, PR descriptions, and comment threads — which is where the actual collaboration happens. This is potentially the most useful feature: a reviewer can paste an ifc:// URL in a PR comment and everyone in the thread sees the 3D view inline. This is not mentioned anywhere in README.md, forgejo/README.md, or SPECIFICATION.md. Add a 'Collaboration workflow' section to the README showing: (1) navigate to view in viewer, (2) copy URL from address bar, (3) paste as [label](ifc://...) in a Forgejo comment/issue, (4) collaborators click the preview image to open the view.


Forgejo markdown previews fail silently for private repos — no auth path from preview service

The Forgejo markdown extension generates <img src='http://localhost:8000/preview?url=ifc://...'> tags. The preview service fetches the IFC file server-side using GET /preview with no authentication. For private repos, this returns HTTP 404 or 403 and the preview image shows as broken. There is no mechanism for the Forgejo markdown renderer to pass the user's session token to the preview service. Options to investigate: (1) the Forgejo integration could pass a signed request token; (2) the preview service could run as the Forgejo git user (which has read access to all repos); (3) SSH-based fetch from the service using the Forgejo server's own key. The current state is a silent failure that will confuse users.


Private Forgejo repos work in viewer via session cookie (same-origin) but this is undocumented and untested

When the viewer is served at https://forgejo.example.com/assets/viewer.html, toRawUrl() constructs a same-origin URL like https://forgejo.example.com/org/repo/raw/branch/main/model.ifc. The browser automatically attaches the Forgejo session cookie to this fetch, so private repos on the same Forgejo instance should be accessible to logged-in users without any extra configuration. This is a significant capability that is not documented or tested. Needs: (1) a test or note confirming this works, (2) documentation in forgejo/README.md explaining that private repos work automatically for logged-in users when the viewer is served from the same Forgejo instance, (3) clear note that external repos (other Forgejo, GitHub, GitLab) have no auth path from the browser viewer.


PR review: no before/after 3D render when an IFC file is modified

When a Forgejo PR modifies a .ifc file, the Files Changed tab shows raw STEP text diff — completely unreadable. Reviewers cannot see what geometry changed. The system should automatically add before/after rendered PNG images to the PR description or as a PR check comment when an .ifc file is modified. The service can render both the base-branch version and the PR-branch version using the commit hexshas already available in the PR context. This is the single most important workflow gap for IFC code review — currently 100% of the visual review burden falls on the reviewer manually opening the viewer and navigating. This supersedes and expands the existing ifcurl-prc issue.

footer.tmpl Case 3 + Nginx proxy docs implemented


BCF import in viewer: reconstruct ifc:// URL from a BCF viewpoint file

BCF is a standard exchange format for BIM issue tracking. Reviewers using other tools (Solibri, Navisworks, BIMcollab) will generate BCF files. There is currently no way to open a BCF file in the viewer to see the referenced view. Implement BCF import: accept a .bcf zip via drag-and-drop or a file picker, parse the first viewpoint's camera/clips, construct an ifc:// URL from the Components/Selection GUIDs (as a GUID-based selector), and load the view. This also completes SPECIFICATION.md §6 direction 2. The ifc:// URL base (repo, ref, path) cannot be recovered from BCF alone — the viewer should keep the current model context and only apply the viewpoint.


Viewer: no 'Create Forgejo issue' button — BCF export and Forgejo issues are disconnected

The BCF export downloads a file that the reviewer must then manually attach to a Forgejo issue. There is no direct path from 'I found a problem in the viewer' to 'this is now a Forgejo issue'. The feedback loop — reviewer flags, designer fixes, reviewer verifies — requires the issue to live in Forgejo where the code/model review happens. A 'Report issue' button in the viewer should call the Forgejo API (POST /api/v1/repos/{owner}/{repo}/issues) with the current ifc:// URL pre-filled in the issue description, so the issue body automatically renders a clickable 3D preview. The viewer already knows the repo path (from the URL) and when served by Forgejo the user is already authenticated.


Viewer: no 'Copy URL' / share button — relies on users knowing to use the address bar

The viewer continuously updates the browser address bar via history.replaceState so the current view is always shareable. But there is no visible 'Copy link to this view' button. Non-technical users (clients, project managers) will not know to copy from the address bar, especially when the URL is long and truncated. A clipboard button alongside the BCF button — or a dedicated Share toolbar item — would make the core sharing workflow discoverable. Should copy the ifc:// URL (from urlInput, which reflects current camera state) to the clipboard and briefly confirm with a visual indicator.


pyproject.toml: bcf package not listed as optional service dependency

service.py and bcf.py work correctly because the 'bcf' Python library is installed, but it is not listed in pyproject.toml's [service] or [render] optional dependencies. A fresh install with 'pip install ifcurl[service]' may not install it. Add bcf to the [service] extras to make the dependency explicit.


GET /preview has no token parameter — private repos inaccessible

POST /preview accepts a token field in the JSON body for private repository authentication. GET /preview (used by Forgejo <img src=> tags) only takes a url query parameter with no way to pass a token. The Go markdown extension generates img tags using the GET form. This means inline previews in Forgejo markdown can never authenticate to private repos — they will silently fail or return 404. Either the Go extension should use POST, or a separate signed/session-scoped token mechanism is needed for the GET path.


viewer-url.js: toRawUrl GitLab host detection too broad

toRawUrl() uses host.includes('gitlab') to detect GitLab instances, which matches any hostname containing the string 'gitlab' — e.g. 'not-actually-gitlab.example.com' would be routed to the GitLab raw URL pattern. Should use a more specific check: host === 'gitlab.com' or host.startsWith('gitlab.') to avoid false positives on unrelated hostnames.


service.py: /bcf endpoint doesn't include source URL in BCF description

The viewer's client-side BCF generation records the source ifc:// URL as a <Description> element in the markup, making the BCF traceable back to its origin. The service POST /bcf endpoint calls build_bcf() without passing any description, so service-generated BCF files contain no reference to the original URL. The URL should be included (in Description or a comment) so the BCF is self-documenting. build_bcf() needs a description parameter, or the url should be passed as comment/description automatically.


viewer.html: BCF export ignores visibility mode from URL

The viewer's client-side generateBcf() always writes '<Visibility DefaultVisibility="true"/>' and never adds Selection or Exceptions elements based on the visibility= URL parameter. A URL with visibility=isolate or visibility=ghost generates BCF with no component visibility data, so a BCF tool importing the file will show all elements with no selection. The service /bcf endpoint handles this correctly. The viewer-side BCF should at minimum write the selector string as a Description so the visibility intent is preserved.


git.py: _evict_if_needed not called after fetch, only after clone

_evict_if_needed() is called in _open_remote() only after a fresh clone (the 'if not git_dir.exists()' branch). When a mutable ref triggers a git fetch on an existing clone, the repo grows with new objects but eviction is never checked. Over time a long-running service can exceed IFCURL_CACHE_MAX_GB without triggering cleanup. Fix: call _evict_if_needed() after fetch as well as after clone.


README.md: documents GET /viewer endpoint that doesn't exist

The preview service endpoints table in README.md lists 'GET /viewer?url=ifc://… — Redirect to the Forgejo browser viewer' but no such route exists in ifcurl/service.py. The viewer is served directly by Forgejo at /assets/viewer.html. The service only has POST /preview, GET /preview, and POST /bcf. Remove the bogus row or implement the redirect.


Security audit: preview endpoint SSRF risk

The /preview endpoint accepts an ifc:// URL and calls fetch_ifc(), which clones or fetches from whatever git host is named in the URL. This is a potential SSRF vector: a caller could supply ifc://internal-host/... to trigger outbound connections to internal infrastructure. Assess: (1) should the service validate that the host is in an allowlist or is a public forge? (2) are there other parameters that could be abused? (3) is the intended deployment model (co-located with Forgejo, not exposed publicly) a sufficient mitigation, or should we add explicit controls regardless? Document the threat model and add any necessary validation.

Implemented: reject local transport (403), --allowed-hosts allowlist for serve command, and private-IP literal blocking as defense-in-depth


Implement clip= parameter in viewer.html

The ifc:// spec defines a repeatable clip= parameter (6 floats: point x,y,z + normal x,y,z in IFC world coords). The Python ifcurl renderer handles it, but viewer.html ignores it entirely — no parsing, no Three.js clipping planes, no round-trip into the URL on camera sync. Implement: parse clip= from the ifc:// URL, apply as THREE.Plane clipping planes to the renderer (with IFC→Three.js coordinate transform), and include current clip planes when rebuilding the URL.

applyClipPlanes() parses all clip= params, transforms IFC→Three.js coords via toThree(), constructs THREE.Plane objects and sets on world.renderer.three.clippingPlanes. syncCameraUrl already preserves clip= params unchanged.


viewer.html: structured form UI for ifc:// URL components

Replace the single ifc:// URL text input with individual labelled fields for each component: host/repository, ref (branch/tag/commit), path (file within repo), selector (IfcOpenShell selector), camera, fov/scale. Editing any field should update the ifc:// URL and reload the model. The raw ifc:// URL should still be visible/copyable. This makes the viewer usable without needing to hand-edit a URL string.

Structured form UI implemented with repo/ref/path/selector fields; crash recovered, changes committed.


viewer.html: FOV control and restore fov=/scale= from URL

Two gaps: (1) No UI to change the camera FOV — add a control (e.g. a small number input or slider in the toolbar) so the user can adjust perspective FOV or orthographic scale. (2) applyCameraParam reads position/direction but ignores the fov= and scale= params written by syncCameraUrl — restore these when placing camera from URL so a shared link opens at exactly the recorded viewpoint.


viewer.html: implement ifc:// selector= parameter

The ifc:// URL spec supports a selector= param using IfcOpenShell selector syntax (e.g. selector=IfcWall, selector=IfcSlab.Name='Floor'). The viewer currently ignores it. Implement selector filtering in the browser: parse the selector= value, query matching elements from the loaded FragmentsModel, and apply highlight/isolate/ghost visibility to them. @thatopen/components has Highlighter and Hider components that may be usable. The selector syntax used is the IfcOpenShell Python selector syntax — a JS port or subset may be needed.

Selector syntax docs: https://docs.ifcopenshell.org/ifcopenshell-python/selector_syntax.html — Python implementation in ifcopenshell.util.selector module. Need a JS port or subset. Syntax includes: IfcType, .Attribute='value', #GlobalId, [pset.prop], boolean operators (+ for union, , for intersection within group).

applySelector() implemented: byCategory classification + prefix subtype matching + hider.isolate(). Supports simple type names and + union. Attribute filters noted as future work.


viewer.html: generalise toRawUrl() to support GitHub, GitLab etc.

toRawUrl() currently only handles Forgejo/Gitea HTTPS URLs, mapping ifc://host/org/repo@hash?path=file to http(s)://host/org/repo/raw/commit/hash/file. This needs to: (1) detect the git host type and construct the correct raw file URL — GitHub → raw.githubusercontent.com/org/repo/hash/file; GitLab → host/org/repo/-/raw/hash/file; Forgejo/Gitea → host/org/repo/raw/commit/hash/file; (2) handle SSH ifc:// URLs (user@host form per the spec) by stripping the user@ prefix and rewriting to HTTPS for browser fetching — ifc://git@github.com/org/repo@hash becomes https://raw.githubusercontent.com/org/repo/hash/file.


viewer.html: place camera from ifc:// URL camera= param on load

When viewer.html loads with a camera= param in the ifc:// URL (9 comma-separated floats: position, direction, up in IFC world coordinates), apply the IFC→Three.js coordinate transform (Y/Z swap, one negated) and set the Three.js camera position, direction and up vector accordingly. Depends on the syncCameraUrl fix (ifcurl-j2s) so the forward and inverse transforms are consistent.


viewer.html: URL input box and drag-and-drop target

Add a text entry box and a drag-and-drop target to viewer.html so any ifc:// URL can be loaded without navigating from a Forgejo page. The input should accept a pasted ifc:// URL and immediately load it into the viewer. Drag-and-drop should accept a dropped ifc:// URL or a .ifc file URL. This makes the viewer usable as a standalone tool.


Apply forward IFC→Three.js transform when setting camera from URL params

When the viewer page loads with a camera= param in the ifc:// URL, the 9 floats are in IFC world coordinates. Apply the IFC→Three.js transform to convert them before passing them to the Three.js camera / camera-controls. This is the forward direction of the same transform used in the inverse task.


Apply inverse IFC↔Three.js transform in syncCameraUrl

Using the transform found in the research task, apply its inverse to camera.position, the direction vector, and camera.up before serialising them as the ifc:// camera param in syncCameraUrl. The result should be in IFC world coordinates (Z-up, matching BCF viewpoint conventions) not Three.js scene space.


Find IFC↔Three.js coordinate transform in @thatopen/components source

Grep ~/src/engine_components and ~/src/engine_fragment for the matrix or transform applied when an IFC model is loaded into Three.js scene space. IFC uses Z-up; Three.js uses Y-up. @thatopen/components almost certainly applies a 90° X-axis rotation (and possibly a scale) when constructing the Three.Group from IFC geometry. Record the exact transform so the inverse can be applied in syncCameraUrl.

Transform identified: IFC→Three.js is a Y/Z swap with one axis negated (Z-up to Y-up). Unblocks j2s and cmd.


Deploy footer.tmpl to Forgejo custom templates directory

Copy forgejo/templates/custom/footer.tmpl from the repo to /etc/forgejo/templates/custom/footer.tmpl on the Forgejo server. No Forgejo restart needed — custom templates are loaded dynamically. Verify the View in 3D button appears on an .ifc file page.


Update footer.tmpl: point viewer button to Forgejo static file URL

Change the VIEWER_BASE URL in forgejo/templates/custom/footer.tmpl from http://localhost:8000/viewer to the Forgejo static file URL determined by the research task (ifcurl-5t3). The button href should become something like /assets/viewer.html?url=ifc://... rather than the ifcurl service URL.


Deploy viewer.html to Forgejo server

Copy the viewer static file from the repo to the correct location on the Forgejo server so it is served at its expected URL. Document the deployment step in the repo (e.g. Makefile target or install notes).


Remove /viewer and /proxy endpoints from service.py

Once the viewer HTML is served as a Forgejo static file and fetches IFC bytes directly from Forgejo (using the browser session for auth), the /viewer and /proxy endpoints in ifcurl/service.py are defunct. Remove them and clean up the associated VIEWER_HTML constant in viewer.py.


Move viewer HTML to Forgejo custom static file

Extract the VIEWER_HTML constant from ifcurl/viewer.py and write it as a static HTML file in the repo under the Forgejo custom static directory (path determined by the research task). The file should be a standalone page — no dependency on the ifcurl service for serving.


Research Forgejo custom static file serving

Determine where Forgejo looks for custom static files and what URL they get served at. Check the Forgejo docs and source for the custom/ static directory convention. The result feeds into the viewer HTML deployment and the footer.tmpl URL update.

Forgejo serves custom static files from <CustomPath>/public/assets/ at URL /assets/*. CustomPath is /var/lib/forgejo/custom on this system. No auth required. Any file type. Place viewer.html at /var/lib/forgejo/custom/public/assets/viewer.html → served at /assets/viewer.html.


Forgejo goldmark extension: handle bare ifc:// URLs in markdown

The goldmark extension in the patched Forgejo binary (modules/markup/markdown/ifc_url.go) currently only detects ifc:// URLs when written in explicit markdown link form [Title](ifc://...). It should also auto-link bare ifc:// URLs pasted inline in markdown text, the same way goldmark auto-links http:// URLs. Extend the extension (or add a separate autolink pass) to detect raw ifc:// URL tokens and wrap them in the preview embed.


Serve viewer HTML as Forgejo static files instead of ifcurl /viewer endpoint

Currently the viewer is served from the ifcurl preview service at GET /viewer, and IFC bytes are fetched via GET /proxy to work around CORS. This has two problems: it pipes all IFC data through the ifcurl service (bandwidth/latency), and it bypasses the browser's existing Forgejo authentication (cookies/session) so private repos won't work. Better architecture: deploy the viewer HTML as a Forgejo custom static file (e.g. /assets/viewer.html or via a custom template). The viewer JS can then fetch IFC bytes directly from Forgejo's raw file URL using the browser's existing session, so auth is inherited automatically and no proxy is needed. The /viewer and /proxy endpoints in ifcurl/service.py can be removed once this is working.

Split into atomic tasks: ifcurl-5t3, ifcurl-55f, ifcurl-cuh, ifcurl-4yu


Fix syncCameraUrl: convert WebGL coords to IFC world space

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.

Split into atomic tasks: ifcurl-ldk, ifcurl-j2s, ifcurl-cmd


Test Phase 3b web viewer end-to-end

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.


Deploy Phase 3b footer template to Forgejo

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.

Split into atomic tasks: ifcurl-ohq, ifcurl-49o


Implement OpenCDE document picker (select-documents flow)

The OpenCDE Documents API defines a browser-redirect flow for document discovery. Implement POST /documents/1.0/select-documents which returns a select_documents_url. That URL serves a small HTML page: owner/repo selector then file-tree browser. When user selects an IFC file, generate the document_id and redirect to the callback_url with document_ids[] params.


Implement OpenCDE OAuth2 proxy routes

The OpenCDE Foundation API requires OAuth2 endpoints. Forgejo already has OAuth2 support — proxy through to it. Routes needed: GET /foundation/1.0/oauth2/auth_url (return Forgejo OAuth2 auth URL), POST /foundation/1.0/oauth2/token (proxy code exchange to Forgejo token endpoint), POST /foundation/1.0/oauth2/token_refresh (proxy refresh). Requires a one-time step of registering an OAuth2 app in Forgejo admin and storing client_id/client_secret as env vars.


Add GET /documents/1.0/document-metadata/{document_id}

The OpenCDE Documents API spec includes a GET endpoint for individual file metadata. Implement it: decode the document_id, fetch the file's latest info from Forgejo (name, size, last-modified), return as a metadata response.


Add BCF topic filtering query params

The BCF 3.0 REST spec defines query parameters for GET /topics (topic_status, topic_type, priority, assignee, etc.). Currently we return all topics unfiltered. Translate BCF filter params to Forgejo issues API params (state, labels, assignee) and pass them through.


Add OpenCDE Foundation API discovery endpoint

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.

## Architecture

Standalone 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.


Add status hint when clip mode is active

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.


Evaluate replacing ifc_url.go markdown extension with JS injection

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.

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 <a> elements JS can pick up — worth revisiting if upgrade burden grows, but not worth doing now.


Extract footer.tmpl JS into a separate ifcurl.js asset file

footer.tmpl is 200 lines of JavaScript wrapped in a single <script> 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 <script src='/assets/ifcurl.js'></script>. 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.


viewer: wrap ifc:// text in anchor tags for future browser URL handler

At some point we will register a browser protocol handler for ifc:// URLs so they can open desktop tools (Bonsai etc). In preparation, all ifc:// URLs generated alongside embedded snapshots should be wrapped in <a href="ifc://..."> anchor tags. Currently they will not do anything (no handler registered), but having them as links now means the UI is ready when the handler exists, and users can at least see and copy the URL.


docs: reorganise README to lead with Forgejo features

The project is now primarily a Forgejo mod that enables IFC previews in commit and PR pages. The README should lead with what this enables for users (embedded snapshots, visual diffs, clickable model links), then move into the ifc:// URL specification as the technical foundation. The current README leads with the URL spec which buries the headline feature.


viewer: storey filter has no 'show all' / reset option

Clicking a storey row in the model overview panel isolates that storey via hider.isolate(). There is no way to reset back to the full model view. Clicking the active storey row should toggle it off (show all), and/or a 'show all' button should appear at the top of the Storeys section when a storey is active. Note: the storey click handler at line ~1016 already has toggle-off logic (if activeRow === row → hider.set(true)) but it only works if you click the same row a second time — the bug is that clicking a different storey, then trying to go back, leaves no 'all storeys' option visible.


viewer: smooth transition when URL settings change

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.


viewer: add zoom-all / reset-view button

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.


viewer: add visibility dropdown when selector is active

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.


viewer: 'View in 3D' link should open in a new tab

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.

footer.tmpl already has target=_blank; fix was already in the repo, deployment pending


viewer: selector text box too narrow

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"'.


viewer: no render_diff preview shown on Forgejo commit pages

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.

Fixed by render_diff fixes in commit 837a9d1


preview: visibility=highlight renders non-selected elements as opaque grey instead of transparent grey

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.

Not a bug — opaque grey is the model's actual IFC material colors displayed normally in highlight mode, which is correct per spec


viewer: visibility=highlight renders as isolate (non-selected elements should be transparent grey)

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.

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.


viewer: orthographic projection not implemented (works in preview)

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='.


viewer: typing in selector box does not filter or apply selector

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.


viewer: browse branches/tags button does not navigate to selected ref

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.


viewer: 'open new issue' link uses https:// but Forgejo may be on http://

The 'open new issue' link hardcodes https:// scheme, but the Forgejo instance may be served over http:// (e.g. on localhost for development). Should use the same scheme as the current page or derive it from the Forgejo base URL.


viewer: BCF export does not include preview PNG

BCF export produces a valid BCF file but the viewpoint snapshot (preview PNG) is missing. BCF spec requires a snapshot image in each viewpoint. The ifcurl render service should be called to generate this.


viewer: model overview only shows storeys, other element types not listed

The model overview panel lists storeys which can be toggled, but no other element types appear. Expected to see walls, slabs, columns etc. The tree/overview is incomplete or only partially populated.


viewer: clip planes button only changes cursor, does not add clip plane

Clicking the clip button changes the cursor to a crosshair but does not actually add a clipping plane to the model. The clip plane interaction is not implemented or not wired up.


viewer: move 'copy IFC URL' button to immediately after URL text box

The copy-to-clipboard button for the IFC URL should be placed directly adjacent to the URL text input, not elsewhere in the UI. Currently the layout separates them, which is confusing.


Research alternative URL schemes before ifc:// is committed to

The ifc:// scheme name is not yet set in stone. It's worth researching whether a different scheme would be more appropriate or less likely to conflict with existing tooling.\n\nLook at how other tools handle similar problems:\n- pip uses git+https:// and git+ssh:// (prefix on top of existing schemes)\n- Are there existing ifc:// registrations or tools that would conflict?\n- What scheme patterns are used by other domain-specific version-controlled asset tools?\n- Alternatives to consider: ifcgit://, git+ifc://, ifc+git://, or a vendor-prefixed form\n- Check IANA registered schemes and common-use unregistered schemes for conflicts\n\nGoal: make a confident decision to keep ifc:// or switch, before the scheme is baked into any external integrations.

Research complete. Summary:

- ifc is NOT registered with IANA. bcf, bim also unregistered. Clean namespace.
- No existing tool or buildingSMART standard uses ifc:// — no conflict risk.
- BCF (bcf://) would be confusing — BCF is a live buildingSMART standard, don't use it.
- pip-style compound schemes (ifc+git://, ifc+https://) look appealing but hurt more than they help: they fragment the transport-inference design, improve nothing for OS handlers or terminal clickability, and are harder to explain.
- Forgejo: CUSTOM_URL_SCHEMES defaults to allowing all schemes — ifc:// links render fine out of the box.
- GitHub/terminals: bare custom URLs are never auto-linked regardless of scheme choice. Explicitly formatted markdown links work fine.

Decision: keep ifc://

Action to add to roadmap: submit a provisional IANA registration (First Come First Served, ~1 hour, just an email to iana@iana.org with scheme name, syntax summary, security notes, and a spec URL). Do this when Phase 1 spec stabilises.

Keep ifc://. Namespace is clean (no IANA registration, no prior art). Compound schemes (ifc+git://) would break transport-inference design with no practical benefit. Action item: provisional IANA registration when Phase 1 spec stabilises.


Viewer: 'View in 3D' uses commit hash but no UI affordance to switch to branch tip

The 'View in 3D' button in footer.tmpl always generates an ifc:// URL with a commit hash (@abc123...). This is correct for reproducibility, but users who want to track 'the latest version of main' must manually change the URL from @<hash> to @heads/main. There is no indication in the viewer that the model is pinned to a specific commit. Add a visual indicator (e.g. 'commit abc1234 · switch to heads/main') in the viewer toolbar when the ref is a commit hash, offering a one-click switch to the branch tip. The branch name could be inferred from Forgejo's API given the commit hash.


Viewer: no model metadata panel — disciplines, spatial structure, element counts

Beyond individual element selection, reviewers and designers need a model overview: what disciplines/types are present, how many elements, what building storeys exist, what's the spatial structure (site/building/storey/space hierarchy). A collapsible side panel showing IFC spatial tree and type breakdown would give immediate context and allow navigating by storey or discipline. Without this, reviewers must already know the model structure before opening the viewer.


Viewer selector only supports type names — property filters require server round-trip

The viewer's applySelector() only supports IFC type names and their unions ('IfcWall+IfcSlab'). Full IfcOpenShell selector syntax — filter by property value (Pset_WallCommon.FireRating=2HR), by GlobalId, by name (.Name='Core Wall'), by spatial containment — is not supported. For collaboration, reviewers need to select specific elements ('all walls with fire rating 2HR that aren't compliant') not just all walls. The service's /preview endpoint already handles full selector syntax server-side. The viewer should forward complex selectors to the service and use the rendered image, or fetch the matching GlobalIds from a new /resolve-selector service endpoint and apply them in the viewer.


footer.tmpl: 'View in 3D' button absent from file history (git log) page

The 'View in 3D' button is injected on the file view page (showing one commit of a file). It is not shown on the file history page (the list of commits that touched a file), which is where users naturally go to see how a model evolved over time. Each entry in the history list already has a commit hash in its href. Injecting a 'View in 3D' link or button alongside each history entry would let reviewers quickly compare model states across commits — which is a primary collaboration use case.


Viewer: no branch/ref switcher — can't navigate model history or compare versions

The viewer loads a single ifc:// URL and has no UI to switch to a different ref. A reviewer wanting to see 'what did this look like on main before the PR' or 'show me the v1.2 tag' must manually edit the URL. For a collaboration tool, being able to flip between 'current PR branch' and 'base branch' at the same camera view is essential for review. Consider a ref dropdown or input in the toolbar that reconstructs the URL with a different @ref, preserving camera/clip/selector state. The structured fields already expose repo/ref/path — the ref field is editable but not prominently labelled as a version picker.


Viewer depends on CDN (unpkg, esm.sh) — broken in air-gapped or restricted networks

viewer.html loads all JavaScript from public CDNs: three.js from esm.sh, @thatopen/components and fragments from esm.sh/unpkg, web-ifc WASM from unpkg, the fragment worker from unpkg, JSZip from esm.sh. In corporate BIM environments with restricted internet (common in construction/engineering), the viewer will fail to load entirely. There is no documented self-hosted path. Consider: document how to serve the dependencies from the Forgejo instance's own /assets/ directory, or provide a bundled viewer.html as a build artifact. The viewer worker fetch ('https://unpkg.com/@thatopen/fragments@3.3.1/dist/Worker/worker.mjs') is already fetched at runtime with a special workaround for Firefox — this is the most fragile CDN dependency.

viewer.html now loads all JS from /assets/ (bundled at build time via npm+esbuild). No CDN dependencies remain.


Viewer: no load progress — large IFC files appear to hang

The viewer status shows 'Fetching IFC file…' and 'Parsing IFC…' as plain text but gives no indication of how far along the download or parse is. IFC files in real projects are often 20–200MB. The fetch() call and the web-ifc WASM parser both block for seconds with no visual feedback. A progress bar or percentage indicator during fetch (using response.body ReadableStream + Content-Length header) and a spinner during parse would prevent users abandoning the page thinking it crashed. The status element exists and is already used — it just needs richer updates.


viewer.html: selector support is a subset of IfcOpenShell syntax — not documented

applySelector() in the viewer only handles type-name union syntax ('IfcWall+IfcSlab'). The comment says 'Attribute/property filters not yet supported', but this limitation is not visible to users and not documented in README.md or SPECIFICATION.md. A user pasting a URL with 'selector=IfcWall,+Pset_WallCommon.FireRating=2HR' will see all elements rather than the filtered set, with no error. Should add a user-visible note or at minimum document the viewer's selector limitations.


service.py: no rate limiting or request size cap on /preview and /bcf

The preview and BCF endpoints have no rate limiting, no cap on IFC file size fetched, and no timeout on the render operation. A single large IFC file or a flood of requests can saturate memory and CPU. Consider: max IFC blob size (reject fetch if commit object too large), render timeout via threading.Timer, and a simple in-process rate limiter or documentation that a reverse proxy (nginx) should front the service with rate limits.


ifc_url_test.go: no test for bare URL with path containing @ character

The Go transformer reassembles multi-sibling text nodes for bare ifc:// URLs. There is a test for underscores in the path (which goldmark splits on), but no test for paths or query values containing '@', which is a valid character in query strings and could appear in selector expressions. Also missing: test for a bare URL followed immediately by punctuation like a period or closing parenthesis to verify the terminator logic.


auth.py: token appears in git process arguments, visible in process list

inject_token() embeds the authentication token into the HTTPS remote URL as 'https://<token>@host/path'. This URL is passed directly to git clone/fetch as a command-line argument, which means the token is visible in the process list (ps aux) while the git command runs. On a shared server this is a security leak. The standard mitigation is to use GIT_ASKPASS or a credential helper that supplies the token via stdin/environment rather than via the URL. Should be documented as a known limitation at minimum.


render.py: concurrent requests each spawn full CPU-count process pools

render() calls ifcopenshell.geom.iterator(settings, model, multiprocessing.cpu_count(), ...). In a multi-request service scenario, N concurrent renders each spawn cpu_count worker processes, so a 16-core machine handling 4 simultaneous requests spawns 64 processes. This can saturate the machine and is effectively a DoS amplifier. Consider capping the worker count (e.g. max 2 or 4 per request), or using a shared process pool at the service level.


README.md: library usage example doesn't show temp file pattern for ifcopenshell.open()

The 'Use as a library' section shows 'model = ifcopenshell.open(...)   # write ifc_bytes to tmp file first' with a comment but no actual code. ifcopenshell.open() requires a file path, not bytes. A new user following the README will be stuck. The example should show the tempfile.mkstemp pattern used in _load_model() in service.py, or at least reference that function.


viewer.html: BCF export from viewer omits component GUIDs — undocumented

The viewer's client-side BCF export cannot include component GUIDs because it has no access to the IFC model's resolved GlobalIds. The service POST /bcf endpoint resolves the selector server-side and produces proper BCF with <Selection> or <Visibility/Exceptions> elements. This discrepancy is not documented anywhere. The viewer's BCF button should either show a note ('GUIDs not resolved — use /bcf API for full component selection') or redirect to the service endpoint when available.


No tests for ifcurl cache CLI subcommands

The 'ifcurl cache list', 'ifcurl cache prune', and 'ifcurl cache clear' subcommands in __main__.py have no automated test coverage. _repo_cache_entries(), _evict_if_needed(), and _get_max_cache_bytes() have unit tests in test_git.py, but the CLI layer (_cmd_cache) is untested. Should add tests that exercise the argparse wiring, the output formatting, and the prune logic via subprocess or by calling _cmd_cache() directly with a fake args namespace.


dev/compose.yml uses Forgejo v10, but go.patch targets v13

dev/compose.yml pulls codeberg.org/forgejo/forgejo:10 for local development. The go.patch and ifc_url.go were written and tested against the v13 branch. Running the dev environment with v10 won't have the patched binary or the correct template slots. The compose file should either use the patched v13 build, or document that the stock container is for testing the service/viewer in isolation only, not for testing the Go patch.

Updated image to v13 and added comment clarifying stock container doesn't include the IFC patch


git.py: silent failure on mutable ref fetch serves stale data without warning

In _open_remote(), when fetching for a mutable ref (branch/HEAD) fails — network error, auth failure, server down — the exception is caught and ignored ('pass # offline — use cached data'). The caller receives stale data with no indication anything went wrong. The render then proceeds, potentially showing outdated geometry. At minimum a log.warning should be emitted. Ideally the service response should include a header (e.g. X-Ifcurl-Cache: stale) so Forgejo or the browser viewer can surface a warning to the user.


License headers for Forgejo integration files

The Go patch files (ifc_url.go, ifc_url_test.go) already carry MIT headers matching Forgejo's per-file convention. The JS/HTML assets have no license header: viewer.html, viewer-url.js, footer.tmpl. Review: (1) confirm MIT is correct for files intended for Forgejo contribution (Forgejo project is GPL-3.0 but individual contributed files use MIT); (2) check compatibility with ThatOpen components licence (viewer.html loads @thatopen/components from CDN); (3) add SPDX licence headers to the three unlicensed asset files. The Python/CLI core remains LGPL-3.0-or-later as it targets IfcOpenShell contribution.

Added MIT SPDX headers to viewer.html, viewer-url.js, footer.tmpl — ThatOpen components also MIT-licensed so no compatibility issue


viewer.html: interactive tool to set clipping planes

Currently clip= planes can only be set by hand-editing the URL. Add an interactive clipping tool to the viewer: a UI control (e.g. toolbar button) that lets the user drag/place a clipping plane against the loaded model. The plane position and normal should be written back into the ifc:// URL as clip= parameters so the view is shareable. Consider using ThatOpen's clipper component (OBC.Clipper) if available, or a Three.js-based approach.

setupClipper() uses OBC.Clipper: seeds URL planes as draggable SimplePlanes, toolbar '✂ clip' toggle for double-click placement, '⊗ clips' clear button. syncCameraUrl extended to serialise current planes back to URL.


Cache size control for bare repo clones

git.py clones remote repos as bare repos under ~/.cache/ifcurl/<hash>/repo.git with no size limit or eviction policy. Large repos accumulate indefinitely. Add a configurable max cache size (e.g. via env var IFCURL_CACHE_MAX_GB or a config file) with an LRU eviction policy based on last-access time of each repo directory. Should also expose a CLI command (ifcurl cache --list, --prune, --clear) to inspect and manage the cache manually.

IFCURL_CACHE_MAX_GB env var for LRU disk eviction; IFCURL_T2_MAX/T3_MAX for in-memory limits; ifcurl cache list/prune/clear CLI; remote_url stored per clone


Assess Go test strategy for Forgejo plugin patch

The Forgejo integration is a low-maintenance source patch (footer.tmpl, viewer.html, /viewer and /proxy HTTP handlers). Evaluate whether adding Go tests is feasible without coupling to Forgejo's full test suite: can the handler logic be extracted into a small testable package, or do tests have to live inside the Forgejo tree? If the cost of keeping tests green across Forgejo upstream upgrades is too high, document the decision and close. Outcome: either a small test file or a documented rationale for no Go tests.

Go tests are feasible and low-maintenance. 7 tests in ifc_url_test.go live in the same package (markdown_test), use goldmark.New() directly (no Forgejo test infrastructure needed beyond TestMain which is already there). Only deps: setting.IfcURL.PreviewServiceURL (ours), goldmark (stable), testify. Test file copied to forgejo/modules/markup/markdown/ in ifcurl repo as patch artifact.


Add JavaScript tests for viewer.html URL logic

viewer.html contains pure-JS functions (parseIfcUrl, buildIfcUrl, toRawUrl, syncCameraUrl) with no test coverage. Add a lightweight JS test harness (e.g. a standalone test.html or Node-runnable test.js using the native test runner) that unit-tests these functions: round-trip URL parse/build, toRawUrl for each host type (GitHub, GitLab, Forgejo), and camera parameter serialisation. No browser automation needed — pure function tests.

32 Node tests in tests/test_viewer_url.mjs covering parseIfcUrl, buildIfcUrl, toRawUrl. Pure functions extracted to viewer-url.js; viewer.html imports from it. Also caught + not being decoded in URL building.


Audit and extend Python test coverage

103 tests exist across test_url.py, test_git.py, test_auth.py, test_render.py, and test_service.py. Audit what is missing: check branch/edge coverage in git.py (offline fallback, mutable ref fetch, auth injection), service.py (tier 2 cache eviction, mutable ref bypass), and the CLI entry points in __main__.py. Add tests for any significant gaps found.

Added 15 tests: SSRF protection (8), GET /preview endpoint (3), tier-2 LRU eviction (3), heads/<branch> ref form (1)


Phase 4: Bonsai IFCDOCUMENTREFERENCE federation via ifc://

When opening a model via ifc://, resolve IFCDOCUMENTREFERENCE locations using the repo and ref from the root file's URL as context. Resolve ifc:// locations in linked model references recursively. Requires the Phase 4 protocol handler (ifcurl-0oj).


Phase 4: Bonsai 'Copy view URL' button

Add a button to Bonsai that generates an ifc:// URL from the current state: git remote/ref/file path, active selection as IfcOpenShell selector, current camera as camera+fov or scale params in IFC world coordinates, active clipping planes as clip params, current visibility mode. Default to commit hash ref; secondary option for @heads/ ref.


Phase 4: Bonsai ifc:// OS protocol handler

Register ifc:// as an OS protocol handler in Bonsai on installation (Windows, macOS, Linux). On receiving a URL, resolve the git source, load the model, and apply the view state from the URL parameters (camera, selector, clipping planes, visibility mode). This is the foundation for all other Phase 4 work.


Phase 3c: Verify Forgejo merge-ability check works with ifcmerge

Investigate whether Forgejo's merge-ability check (the 'branches can be merged automatically' indicator) correctly reflects ifcmerge's ability to handle .ifc files. Determine if Forgejo runs a trial git merge or uses a heuristic. If needed, patch Forgejo to run a trial git merge --no-commit to get an accurate result.

Verified: git merge-tree --write-tree (Forgejo's code path for git ≥2.38) does invoke merge drivers. Bare repos require core.attributesFile in /etc/gitconfig — committed .gitattributes not read. Added gitattributes server-config file and updated deployment docs.


Phase 3c: Register ifcmerge as git merge driver on Forgejo server

Configure ifcmerge as the git merge driver for .ifc files on the Forgejo server. Add to /etc/gitconfig or ~forgejo/.gitconfig: [merge "ifcmerge"] name = IFC merge driver / driver = ifcmerge %O %A %B %L. Add *.ifc merge=ifcmerge to server-side gitattributes. This allows Forgejo's 'merge automatically' path to invoke ifcmerge via git when merging .ifc files.

Documented ifcmerge driver registration (gitconfig-ifcmerge), gitattributes setup, merge direction constraint, and merge-commit strategy requirement

ifcmerge needs to be installed as a pair of merge tools because the merge process is asymmetrical and we need to be able to prefer one branch over another (because step-ids from one branch get rewritten in the merged file). In practice we prefer origin and/or main/master branches. there is code in the ifcopenshell bonsai ifcgit module for reference.


Add minimal read-only OpenCDE Documents API

Expose a minimal read-only OpenCDE Documents API that makes IFC files in git repos addressable as OpenCDE documents.

## Architecture

Python routes in the preview service, proxied at /documents/ on the Forgejo hostname via nginx/Caddy. Same pattern as the BCF API. Auth forwarded verbatim (Authorization: Bearer) — Forgejo enforces per-user repo permissions. No Go code, no Forgejo recompilation.

## Design

document_id is a base64url encoding of 'owner/repo/path', making it stable and opaque. version_index is the 1-based position of the commit in the file's git history (oldest=1, newest=N), derived by querying Forgejo's commits API with the file path filter.

POST /documents/1.0/document-versions returns all versions (up to 50 commits) for each requested document_id. Download URLs point at Forgejo's existing raw file endpoint: {forgejo_host}/{owner}/{repo}/raw/commit/{sha}/{path}.

The public Forgejo base URL for download links is derived from X-Forwarded-Host/X-Forwarded-Proto headers, same as the Foundation endpoint.

This validates the ifc:// CDE extension in SPECIFICATION.md §9 (document_id= parameter) and makes Forgejo discoverable as an OpenCDE server to any client that speaks the Documents API.


viewer: extract inline JS from viewer.html into viewer.js

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.


Submit provisional IANA registration for ifc:// scheme

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.


Forgejo commit page: no IFC thumbnail — git log gives no visual indication of model state

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.


SPECIFICATION.md section 6: BCF-to-IFC-URL direction not implemented

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.


Future: Contribute ifc:// URL parsing to IfcOpenShell

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.


Future: BCF export endpoint /bcf

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.

bcf.py build_bcf() + /bcf POST endpoint + viewer BCF panel with JSZip client-side generation + 21 tests


Phase 6: Bonsai offer ifc:// URL when saving linked model reference

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).


Phase 5: IFC Viewer integration (equivalent of Phase 4)

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.


Phase 3c: 3D diff view for IFC PR merges

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.

Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D


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/*.


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.


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.