Fix 7 issues from deep review

- pyproject.toml: add bcf to [service] dependencies (ifcurl-9rk)
- README.md: replace bogus GET /viewer row with POST /bcf (ifcurl-ttb)
- viewer-url.js: fix GitLab host detection (includes → startsWith) (ifcurl-3n4)
- git.py: call _evict_if_needed() after fetch, not only after clone (ifcurl-t1k)
- bcf.py: add description param to build_bcf(); markup.bcf now includes
  <Description> when provided (ifcurl-7wg)
- service.py: pass request URL as description to build_bcf() (ifcurl-7wg)
- viewer.html: BCF export reads visibility= from URL and sets
  DefaultVisibility accordingly (ifcurl-sto)
- viewer.html: applyCameraParam now switches to orthographic projection
  and applies scale when scale= is present in URL (ifcurl-4l7)
- tests: 3 new tests for description field; update JS tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-24 11:57:06 +01:00
parent 75aefee276
commit 01de9fdcac
9 changed files with 63 additions and 22 deletions

View file

@ -1,12 +1,12 @@
{"id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"open","priority":1,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T09:04:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:19Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"open","priority":1,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T09:04:31Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-9rk","title":"pyproject.toml: bcf package not listed as optional service dependency","description":"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.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:19Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:41:19Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-9rk","title":"pyproject.toml: bcf package not listed as optional service dependency","description":"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.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:19Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:27Z","started_at":"2026-04-24T10:50:11Z","closed_at":"2026-04-24T10:56:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-czd","title":"GET /preview has no token parameter — private repos inaccessible","description":"POST /preview accepts a token field in the JSON body for private repository authentication. GET /preview (used by Forgejo \u003cimg src=\u003e 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.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:17Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:17Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-czd","title":"GET /preview has no token parameter — private repos inaccessible","description":"POST /preview accepts a token field in the JSON body for private repository authentication. GET /preview (used by Forgejo \u003cimg src=\u003e 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.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:17Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:17Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-3n4","title":"viewer-url.js: toRawUrl GitLab host detection too broad","description":"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.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:01Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-3n4","title":"viewer-url.js: toRawUrl GitLab host detection too broad","description":"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.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:01Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:28Z","started_at":"2026-04-24T10:50:14Z","closed_at":"2026-04-24T10:56:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-7wg","title":"service.py: /bcf endpoint doesn't include source URL in BCF description","description":"The viewer's client-side BCF generation records the source ifc:// URL as a \u003cDescription\u003e 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.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:17Z","created_by":"Bruno Postle","updated_at":"2026-04-24T09:05:17Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-7wg","title":"service.py: /bcf endpoint doesn't include source URL in BCF description","description":"The viewer's client-side BCF generation records the source ifc:// URL as a \u003cDescription\u003e 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.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:17Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:17Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-sto","title":"viewer.html: BCF export ignores visibility mode from URL","description":"The viewer's client-side generateBcf() always writes '\u003cVisibility DefaultVisibility=\"true\"/\u003e' 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.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:14Z","created_by":"Bruno Postle","updated_at":"2026-04-24T09:05:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-sto","title":"viewer.html: BCF export ignores visibility mode from URL","description":"The viewer's client-side generateBcf() always writes '\u003cVisibility DefaultVisibility=\"true\"/\u003e' 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.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:14Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:18Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-t1k","title":"git.py: _evict_if_needed not called after fetch, only after clone","description":"_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.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:03Z","created_by":"Bruno Postle","updated_at":"2026-04-24T09:05:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-t1k","title":"git.py: _evict_if_needed not called after fetch, only after clone","description":"_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.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:03Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:28Z","started_at":"2026-04-24T10:50:16Z","closed_at":"2026-04-24T10:56:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-ttb","title":"README.md: documents GET /viewer endpoint that doesn't exist","description":"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.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:00Z","created_by":"Bruno Postle","updated_at":"2026-04-24T09:05:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-ttb","title":"README.md: documents GET /viewer endpoint that doesn't exist","description":"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.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:00Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:27Z","started_at":"2026-04-24T10:50:13Z","closed_at":"2026-04-24T10:56:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-2v9","title":"Security audit: preview endpoint SSRF risk","description":"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.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T07:09:33Z","created_by":"Bruno Postle","updated_at":"2026-04-24T07:21:07Z","started_at":"2026-04-24T07:12:29Z","closed_at":"2026-04-24T07:21:07Z","close_reason":"Implemented: reject local transport (403), --allowed-hosts allowlist for serve command, and private-IP literal blocking as defense-in-depth","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-2v9","title":"Security audit: preview endpoint SSRF risk","description":"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.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T07:09:33Z","created_by":"Bruno Postle","updated_at":"2026-04-24T07:21:07Z","started_at":"2026-04-24T07:12:29Z","closed_at":"2026-04-24T07:21:07Z","close_reason":"Implemented: reject local transport (403), --allowed-hosts allowlist for serve command, and private-IP literal blocking as defense-in-depth","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-bab","title":"Implement clip= parameter in viewer.html","description":"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.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T05:45:58Z","created_by":"Bruno Postle","updated_at":"2026-04-24T06:09:31Z","started_at":"2026-04-24T06:08:44Z","closed_at":"2026-04-24T06:09:31Z","close_reason":"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.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-bab","title":"Implement clip= parameter in viewer.html","description":"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.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T05:45:58Z","created_by":"Bruno Postle","updated_at":"2026-04-24T06:09:31Z","started_at":"2026-04-24T06:08:44Z","closed_at":"2026-04-24T06:09:31Z","close_reason":"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.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-yju","title":"viewer.html: structured form UI for ifc:// URL components","description":"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.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T21:07:11Z","created_by":"Bruno Postle","updated_at":"2026-04-23T21:32:17Z","started_at":"2026-04-23T21:12:42Z","closed_at":"2026-04-23T21:32:17Z","close_reason":"Structured form UI implemented with repo/ref/path/selector fields; crash recovered, changes committed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-yju","title":"viewer.html: structured form UI for ifc:// URL components","description":"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.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T21:07:11Z","created_by":"Bruno Postle","updated_at":"2026-04-23T21:32:17Z","started_at":"2026-04-23T21:12:42Z","closed_at":"2026-04-23T21:32:17Z","close_reason":"Structured form UI implemented with repo/ref/path/selector fields; crash recovered, changes committed.","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -137,8 +137,8 @@ non-private remote hosts.
| Endpoint | Description | | Endpoint | Description |
|---|---| |---|---|
| `POST /preview` | Render an ifc:// URL to PNG | | `POST /preview` | Render an ifc:// URL to PNG |
| `GET /preview?url=ifc://…` | Same, via query string | | `GET /preview?url=ifc://…` | Same, via query string (used by Forgejo `<img>` tags) |
| `GET /viewer?url=ifc://…` | Redirect to the Forgejo browser viewer | | `POST /bcf` | Generate a BCF 2.1 zip from an ifc:// URL viewpoint |
### Caching ### Caching

View file

@ -69,7 +69,7 @@ export function toRawUrl(raw) {
if (host === "github.com" || host.endsWith(".github.com")) { if (host === "github.com" || host.endsWith(".github.com")) {
return `https://raw.githubusercontent.com/${repoPath}/${plainRef}/${filePath}`; return `https://raw.githubusercontent.com/${repoPath}/${plainRef}/${filePath}`;
} }
if (host === "gitlab.com" || host.includes("gitlab")) { if (host === "gitlab.com" || host.startsWith("gitlab.")) {
const proto = host.startsWith("localhost") ? "http" : "https"; const proto = host.startsWith("localhost") ? "http" : "https";
return `${proto}://${host}/${repoPath}/-/raw/${plainRef}/${filePath}`; return `${proto}://${host}/${repoPath}/-/raw/${plainRef}/${filePath}`;
} }

View file

@ -437,7 +437,18 @@
await controls.setLookAt(pos.x, pos.y, pos.z, target.x, target.y, target.z, false); await controls.setLookAt(pos.x, pos.y, pos.z, target.x, target.y, target.z, false);
const fovStr = qs.get("fov"); const fovStr = qs.get("fov");
if (fovStr && camera.isPerspectiveCamera) { const scaleStr = qs.get("scale");
if (scaleStr) {
// Switch to orthographic projection and apply the view-to-world scale.
await controls.setOrthoCamera();
const s = parseFloat(scaleStr);
if (!isNaN(s)) {
camera.top = s / 2;
camera.bottom = -s / 2;
camera.updateProjectionMatrix();
}
fovInput.value = "";
} else if (fovStr && camera.isPerspectiveCamera) {
camera.fov = Math.max(10, Math.min(120, parseFloat(fovStr))); camera.fov = Math.max(10, Math.min(120, parseFloat(fovStr)));
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
fovInput.value = Math.round(camera.fov); fovInput.value = Math.round(camera.fov);
@ -464,6 +475,7 @@
.map(s => s.split(",").map(Number)) .map(s => s.split(",").map(Number))
.filter(v => v.length === 6 && !v.some(isNaN)); .filter(v => v.length === 6 && !v.some(isNaN));
const selector = qs.get("selector") || ""; const selector = qs.get("selector") || "";
const visibility = qs.get("visibility") || "highlight";
const topicGuid = crypto.randomUUID(); const topicGuid = crypto.randomUUID();
const vpGuid = cam ? crypto.randomUUID() : null; const vpGuid = cam ? crypto.randomUUID() : null;
@ -506,9 +518,12 @@
`<ClippingPlane>${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}</ClippingPlane>` `<ClippingPlane>${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}</ClippingPlane>`
).join("\n ")} ).join("\n ")}
</ClippingPlanes>` : ""; </ClippingPlanes>` : "";
// Reflect visibility mode: isolate → hide everything by default,
// ghost/highlight → show everything by default.
const defaultVis = visibility === "isolate" ? "false" : "true";
viewpointXml = `<?xml version="1.0" encoding="utf-8"?> viewpointXml = `<?xml version="1.0" encoding="utf-8"?>
<VisualizationInfo Guid="${vpGuid}"> <VisualizationInfo Guid="${vpGuid}">
<Components><Visibility DefaultVisibility="true"/></Components> <Components><Visibility DefaultVisibility="${defaultVis}"/></Components>
${camXml} ${camXml}
${clipsXml} ${clipsXml}
</VisualizationInfo>`; </VisualizationInfo>`;

View file

@ -106,11 +106,14 @@ def _markup_xml(
comment: str, comment: str,
author: str, author: str,
now: str, now: str,
description: str = "",
) -> bytes: ) -> bytes:
root = ET.Element("Markup") root = ET.Element("Markup")
topic = ET.SubElement(root, "Topic", topic = ET.SubElement(root, "Topic",
Guid=topic_guid, TopicType="Coordination", TopicStatus="Open") Guid=topic_guid, TopicType="Coordination", TopicStatus="Open")
ET.SubElement(topic, "Title").text = title or "IFC View" ET.SubElement(topic, "Title").text = title or "IFC View"
if description:
ET.SubElement(topic, "Description").text = description
ET.SubElement(topic, "CreationDate").text = now ET.SubElement(topic, "CreationDate").text = now
ET.SubElement(topic, "CreationAuthor").text = author ET.SubElement(topic, "CreationAuthor").text = author
if vp_guid: if vp_guid:
@ -137,6 +140,7 @@ def build_bcf(
visibility: str = "highlight", visibility: str = "highlight",
title: str = "IFC View", title: str = "IFC View",
comment: str = "", comment: str = "",
description: str = "",
author: str = "anonymous", author: str = "anonymous",
) -> bytes: ) -> bytes:
"""Build a BCF 2.1 zip archive and return the raw bytes. """Build a BCF 2.1 zip archive and return the raw bytes.
@ -149,6 +153,7 @@ def build_bcf(
:param visibility: ``'highlight'``, ``'ghost'``, or ``'isolate'``. :param visibility: ``'highlight'``, ``'ghost'``, or ``'isolate'``.
:param title: BCF topic title. :param title: BCF topic title.
:param comment: Optional comment text added to the topic. :param comment: Optional comment text added to the topic.
:param description: Optional long description for the topic (e.g. the source ifc:// URL).
:param author: Author string recorded in the BCF markup. :param author: Author string recorded in the BCF markup.
:returns: Bytes of a valid BCF 2.1 zip archive. :returns: Bytes of a valid BCF 2.1 zip archive.
""" """
@ -161,7 +166,7 @@ def build_bcf(
zf.writestr("bcf.version", _VERSION_XML) zf.writestr("bcf.version", _VERSION_XML)
zf.writestr( zf.writestr(
f"{topic_guid}/markup.bcf", f"{topic_guid}/markup.bcf",
_markup_xml(topic_guid, vp_guid, title, comment, author, now), _markup_xml(topic_guid, vp_guid, title, comment, author, now, description),
) )
if vp_guid is not None: if vp_guid is not None:
zf.writestr( zf.writestr(

View file

@ -266,6 +266,7 @@ def _open_remote(remote_url: str, is_mutable: bool, token: str | None = None) ->
repo.git.fetch("origin") repo.git.fetch("origin")
except git.exc.GitCommandError: except git.exc.GitCommandError:
pass # offline — use cached data pass # offline — use cached data
_evict_if_needed()
_touch_cache(cache_dir) _touch_cache(cache_dir)
return repo return repo

View file

@ -446,6 +446,7 @@ def bcf_export(request: BcfRequest) -> Response:
visibility=ifc_url.visibility, visibility=ifc_url.visibility,
title=request.title, title=request.title,
comment=request.comment, comment=request.comment,
description=request.url,
) )
return Response( return Response(
content=bcf_bytes, content=bcf_bytes,

View file

@ -21,7 +21,7 @@ dependencies = ["ifcopenshell", "gitpython", "platformdirs"]
[project.optional-dependencies] [project.optional-dependencies]
render = ["pyvista", "numpy"] render = ["pyvista", "numpy"]
service = ["pyvista", "numpy", "fastapi", "uvicorn[standard]"] service = ["pyvista", "numpy", "fastapi", "uvicorn[standard]", "bcf"]
[project.scripts] [project.scripts]
ifcurl = "ifcurl.__main__:main" ifcurl = "ifcurl.__main__:main"

View file

@ -128,6 +128,18 @@ class TestBuildBcf:
assert 'DefaultVisibility="false"' in content assert 'DefaultVisibility="false"' in content
assert "Exceptions" in content assert "Exceptions" in content
def test_description_in_markup(self):
data = build_bcf(description="ifc://example.com/org/repo@heads/main?path=m.ifc")
markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf"))
content = _zip_read(data, markup)
assert "<Description>ifc://example.com" in content
def test_no_description_element_when_empty(self):
data = build_bcf()
markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf"))
content = _zip_read(data, markup)
assert "<Description>" not in content
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /bcf service endpoint tests # /bcf service endpoint tests
@ -164,6 +176,13 @@ class TestBcfEndpoint:
assert "My issue" in content assert "My issue" in content
assert "Fix this" in content assert "Fix this" in content
def test_source_url_in_description(self):
r = client.post("/bcf", json={"url": CAMERA_URL})
markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf"))
content = _zip_read(r.content, markup)
# & is XML-escaped to &amp; in the description element
assert CAMERA_URL.replace("&", "&amp;") in content
def test_clip_plane_in_viewpoint(self): def test_clip_plane_in_viewpoint(self):
r = client.post("/bcf", json={"url": CLIP_URL}) r = client.post("/bcf", json={"url": CLIP_URL})
vp = next(n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv")) vp = next(n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv"))