Add BCF export: /bcf endpoint, build_bcf(), viewer panel

- ifcurl/bcf.py: build_bcf() builds BCF 2.1 zips with perspective/
  orthographic camera, clipping planes, component selection, and
  isolate visibility mode
- ifcurl/service.py: /bcf POST endpoint parses ifc:// URL, resolves
  selector → GUIDs when present, delegates to build_bcf()
- viewer.html: BCF toolbar button + collapsible title/comment form;
  client-side zip generation via JSZip (esm.sh CDN) so the browser
  does not need service access
- tests/test_bcf.py: 21 tests covering build_bcf() unit behaviour and
  the /bcf endpoint including SSRF rejection and selector resolution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-24 09:59:22 +01:00
parent 27883e468e
commit dae1435519
5 changed files with 592 additions and 11 deletions

View file

@ -32,7 +32,7 @@
{"id":"ifcurl-cyw","title":"Phase 3c: Verify Forgejo merge-ability check works with ifcmerge","description":"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.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:22Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:27:25Z","started_at":"2026-04-24T08:19:58Z","closed_at":"2026-04-24T08:27:25Z","close_reason":"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.","dependencies":[{"issue_id":"ifcurl-cyw","depends_on_id":"ifcurl-c69","type":"blocks","created_at":"2026-04-23T06:48:29Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-c69","title":"Phase 3c: Register ifcmerge as git merge driver on Forgejo server","description":"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.","status":"closed","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:21Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:15:20Z","closed_at":"2026-04-24T08:15:20Z","close_reason":"Documented ifcmerge driver registration (gitconfig-ifcmerge), gitattributes setup, merge direction constraint, and merge-commit strategy requirement","comments":[{"id":"019dbc54-bf0a-76e2-b64d-7d1681f0c730","issue_id":"ifcurl-c69","author":"Bruno Postle","text":"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.","created_at":"2026-04-23T21:52:44Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1}
{"id":"ifcurl-8ns","title":"Future: Contribute ifc:// URL parsing to IfcOpenShell","description":"Once the Python core URL parsing and resolution logic is stable, contribute it to IfcOpenShell as a shared utility. Not a prerequisite for any phase — pursue after Phase 1 has been stable in production use for a while.","status":"open","priority":4,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:33Z","created_by":"Bruno Postle","updated_at":"2026-04-23T05:49:33Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-9yr","title":"Future: BCF export endpoint /bcf","description":"Add a /bcf endpoint to the preview service: POST /bcf with body {url: 'ifc://...'} returns a BCF zip file. Resolve the selector to a GUID set and construct a BCF viewpoint from the camera, clipping planes, and visibility state. Not planned before Phase 2 is complete.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:25Z","created_by":"Bruno Postle","updated_at":"2026-04-23T05:49:25Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-9yr","title":"Future: BCF export endpoint /bcf","description":"Add a /bcf endpoint to the preview service: POST /bcf with body {url: 'ifc://...'} returns a BCF zip file. Resolve the selector to a GUID set and construct a BCF viewpoint from the camera, clipping planes, and visibility state. Not planned before Phase 2 is complete.","status":"closed","priority":4,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:25Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:51:18Z","started_at":"2026-04-24T08:34:51Z","closed_at":"2026-04-24T08:51:18Z","close_reason":"bcf.py build_bcf() + /bcf POST endpoint + viewer BCF panel with JSZip client-side generation + 21 tests","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-23T05:48:30Z","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}

View file

@ -50,6 +50,9 @@
.tb-btn:hover { background: rgba(255,255,255,0.14); }
.tb-btn.active { color: #a8c8ff; background: rgba(80,120,255,0.2);
border-color: rgba(80,120,255,0.5); }
/* BCF panel */
#bcf-comment { flex: 3; }
#bcf-title { flex: 2; }
/* Status */
#status {
position: fixed; bottom: 12px; left: 50%;
@ -76,7 +79,8 @@
"three/addons/": "https://esm.sh/three@0.177.0/examples/jsm/",
"web-ifc": "https://unpkg.com/web-ifc@0.0.75/web-ifc-api.js",
"@thatopen/fragments": "https://esm.sh/@thatopen/fragments@3.3.1?external=three,web-ifc",
"@thatopen/components": "https://esm.sh/@thatopen/components@3.3.1?external=three,web-ifc,@thatopen/fragments"
"@thatopen/components": "https://esm.sh/@thatopen/components@3.3.1?external=three,web-ifc,@thatopen/fragments",
"jszip": "https://esm.sh/jszip@3.10.1"
}
}
</script>
@ -90,6 +94,7 @@
spellcheck="false">
<button id="clip-btn" class="tb-btn" title="Double-click model surface to add clipping plane">✂ clip</button>
<button id="clip-clear-btn" class="tb-btn" title="Remove all clipping planes" style="display:none">⊗ clips</button>
<button id="bcf-btn" class="tb-btn" title="Export current view as a BCF issue">BCF</button>
<div id="fov-wrap">
FOV <input id="fov-input" class="ti" type="number" min="10" max="120" step="1"
title="Field of view (degrees)">°
@ -118,6 +123,19 @@
placeholder="IfcWall" spellcheck="false">
</div>
</div>
<!-- Row 3: BCF export form (hidden until BCF button clicked) -->
<div id="bcf-row" class="trow" style="display:none">
<div class="field" style="flex:2">
<label>title</label>
<input id="bcf-title" class="ti" type="text" placeholder="IFC View" spellcheck="false">
</div>
<div class="field" style="flex:3">
<label>comment</label>
<input id="bcf-comment" class="ti" type="text" placeholder="Optional note" spellcheck="false">
</div>
<button id="bcf-cancel-btn" class="tb-btn"></button>
<button id="bcf-export-btn" class="tb-btn">Export .bcf</button>
</div>
</div>
<div id="viewer"></div>
<div id="status"></div>
@ -125,6 +143,7 @@
<script type="module">
import * as THREE from "three";
import * as OBC from "@thatopen/components";
import JSZip from "jszip";
import { parseIfcUrl, toRawUrl, buildIfcUrl as _buildIfcUrl } from "./viewer-url.js";
const params = new URLSearchParams(window.location.search);
@ -189,7 +208,27 @@
// Disable camera controls while any input has focus.
function onInputFocus() { if (cameraControls) cameraControls.enabled = false; }
function onInputBlur() { if (cameraControls) cameraControls.enabled = true; }
const allInputs = [urlInput, fovInput, repoInput, refInput, pathInput, selectorInput];
const bcfBtn = document.getElementById("bcf-btn");
const bcfRow = document.getElementById("bcf-row");
const bcfTitleIn = document.getElementById("bcf-title");
const bcfCommentIn = document.getElementById("bcf-comment");
const bcfCancelBtn = document.getElementById("bcf-cancel-btn");
const bcfExportBtn = document.getElementById("bcf-export-btn");
function bcfPanelOpen(open) {
bcfRow.style.display = open ? "" : "none";
bcfBtn.classList.toggle("active", open);
}
bcfBtn.addEventListener("click", () => bcfPanelOpen(bcfRow.style.display === "none"));
bcfCancelBtn.addEventListener("click", () => bcfPanelOpen(false));
bcfExportBtn.addEventListener("click", async () => {
await generateBcf(bcfTitleIn.value.trim(), bcfCommentIn.value.trim());
bcfPanelOpen(false);
});
const allInputs = [urlInput, fovInput, repoInput, refInput, pathInput, selectorInput,
bcfTitleIn, bcfCommentIn];
for (const el of allInputs) {
el.addEventListener("focus", onInputFocus);
el.addEventListener("blur", onInputBlur);
@ -406,6 +445,111 @@
return true;
}
// -----------------------------------------------------------------------
// BCF 2.1 export — build zip client-side and trigger download.
// -----------------------------------------------------------------------
async function generateBcf(title, comment) {
if (threeCamera) syncCameraUrl(threeCamera);
const src = urlInput.value.trim();
if (!src) return;
const qIdx = src.indexOf("?");
const qs = new URLSearchParams(qIdx < 0 ? "" : src.slice(qIdx + 1));
const camV = (qs.get("camera") ?? "").split(",").map(Number);
const cam = camV.length === 9 && !camV.some(isNaN) ? camV : null;
const fov = cam ? (parseFloat(qs.get("fov")) || null) : null;
const scale = cam ? (parseFloat(qs.get("scale")) || null) : null;
const clips = qs.getAll("clip")
.map(s => s.split(",").map(Number))
.filter(v => v.length === 6 && !v.some(isNaN));
const selector = qs.get("selector") || "";
const topicGuid = crypto.randomUUID();
const vpGuid = cam ? crypto.randomUUID() : null;
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
function esc(s) {
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;")
.replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function xyz(tag, a, b, c) {
return `<${tag}><X>${a}</X><Y>${b}</Y><Z>${c}</Z></${tag}>`;
}
const versionXml = `<?xml version="1.0" encoding="utf-8"?>
<Version VersionId="2.1" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/buildingSMART/BCF-XML/release_2_1/Schemas/version.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<DetailedVersion>2.1</DetailedVersion>
</Version>`;
let viewpointXml = null;
if (vpGuid && cam) {
const [px,py,pz, dx,dy,dz, ux,uy,uz] = cam;
const camXml = fov != null
? `<PerspectiveCamera>
${xyz("CameraViewPoint",px,py,pz)}
${xyz("CameraDirection",dx,dy,dz)}
${xyz("CameraUpVector",ux,uy,uz)}
<FieldOfView>${fov}</FieldOfView>
</PerspectiveCamera>`
: scale != null
? `<OrthogonalCamera>
${xyz("CameraViewPoint",px,py,pz)}
${xyz("CameraDirection",dx,dy,dz)}
${xyz("CameraUpVector",ux,uy,uz)}
<ViewToWorldScale>${scale}</ViewToWorldScale>
</OrthogonalCamera>`
: "";
const clipsXml = clips.length
? `<ClippingPlanes>
${clips.map(([cx,cy,cz,nx,ny,nz]) =>
`<ClippingPlane>${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}</ClippingPlane>`
).join("\n ")}
</ClippingPlanes>` : "";
viewpointXml = `<?xml version="1.0" encoding="utf-8"?>
<VisualizationInfo Guid="${vpGuid}">
<Components><Visibility DefaultVisibility="true"/></Components>
${camXml}
${clipsXml}
</VisualizationInfo>`;
}
const desc = selector
? `${esc(src)}\nselector: ${esc(selector)}`
: esc(src);
const vpEntry = vpGuid ? `<Viewpoints Guid="${vpGuid}"><Viewpoint>viewpoint.bcfv</Viewpoint></Viewpoints>` : "";
const cmtEntry = comment
? `<Comment Guid="${crypto.randomUUID()}">
<Date>${now}</Date><Author>anonymous</Author>
<Comment>${esc(comment)}</Comment>
${vpGuid ? `<Viewpoint Guid="${vpGuid}"/>` : ""}
</Comment>` : "";
const markupXml = `<?xml version="1.0" encoding="utf-8"?>
<Markup>
<Topic Guid="${topicGuid}" TopicType="Coordination" TopicStatus="Open">
<Title>${esc(title || "IFC View")}</Title>
<Description>${desc}</Description>
<CreationDate>${now}</CreationDate>
<CreationAuthor>anonymous</CreationAuthor>
${vpEntry}
</Topic>
${cmtEntry}
</Markup>`;
const zip = new JSZip();
zip.file("bcf.version", versionXml);
zip.folder(topicGuid).file("markup.bcf", markupXml);
if (vpGuid && viewpointXml) zip.folder(topicGuid).file("viewpoint.bcfv", viewpointXml);
const blob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "view.bcf";
a.click();
URL.revokeObjectURL(a.href);
}
// -----------------------------------------------------------------------
// Main
// -----------------------------------------------------------------------

171
ifcurl/bcf.py Normal file
View file

@ -0,0 +1,171 @@
# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IFC URL.
#
# IFC URL is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IFC URL is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
"""Build BCF 2.1 zip archives from ifc:// view state.
BCF (BIM Collaboration Format) 2.1 structure produced here::
bcf.version
<topic-guid>/
markup.bcf title, comment, viewpoint reference
viewpoint.bcfv camera, clipping planes, component selection
"""
from __future__ import annotations
import io
import uuid
import zipfile
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
_VERSION_XML = b"""\
<?xml version="1.0" encoding="utf-8"?>
<Version VersionId="2.1" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/buildingSMART/BCF-XML/release_2_1/Schemas/version.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<DetailedVersion>2.1</DetailedVersion>
</Version>"""
def _xyz(parent: ET.Element, tag: str, x: float, y: float, z: float) -> None:
el = ET.SubElement(parent, tag)
ET.SubElement(el, "X").text = str(x)
ET.SubElement(el, "Y").text = str(y)
ET.SubElement(el, "Z").text = str(z)
def _viewpoint_xml(
guid: str,
camera: tuple[float, ...],
fov: float | None,
scale: float | None,
clips: list[tuple[float, ...]],
guids: list[str] | None,
visibility: str,
) -> bytes:
root = ET.Element("VisualizationInfo", Guid=guid)
comps = ET.SubElement(root, "Components")
if guids:
if visibility == "isolate":
vis_el = ET.SubElement(comps, "Visibility", DefaultVisibility="false")
exc = ET.SubElement(vis_el, "Exceptions")
for g in guids:
ET.SubElement(exc, "Component", IfcGuid=g)
else:
sel = ET.SubElement(comps, "Selection")
for g in guids:
ET.SubElement(sel, "Component", IfcGuid=g)
ET.SubElement(comps, "Visibility", DefaultVisibility="true")
else:
ET.SubElement(comps, "Visibility", DefaultVisibility="true")
px, py, pz, dx, dy, dz, ux, uy, uz = camera
if fov is not None:
cam = ET.SubElement(root, "PerspectiveCamera")
_xyz(cam, "CameraViewPoint", px, py, pz)
_xyz(cam, "CameraDirection", dx, dy, dz)
_xyz(cam, "CameraUpVector", ux, uy, uz)
ET.SubElement(cam, "FieldOfView").text = f"{fov:.4f}"
elif scale is not None:
cam = ET.SubElement(root, "OrthogonalCamera")
_xyz(cam, "CameraViewPoint", px, py, pz)
_xyz(cam, "CameraDirection", dx, dy, dz)
_xyz(cam, "CameraUpVector", ux, uy, uz)
ET.SubElement(cam, "ViewToWorldScale").text = f"{scale:.4f}"
if clips:
cps = ET.SubElement(root, "ClippingPlanes")
for clip in clips:
cp = ET.SubElement(cps, "ClippingPlane")
_xyz(cp, "Location", clip[0], clip[1], clip[2])
_xyz(cp, "Direction", clip[3], clip[4], clip[5])
return b'<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode").encode()
def _markup_xml(
topic_guid: str,
vp_guid: str | None,
title: str,
comment: str,
author: str,
now: str,
) -> bytes:
root = ET.Element("Markup")
topic = ET.SubElement(root, "Topic",
Guid=topic_guid, TopicType="Coordination", TopicStatus="Open")
ET.SubElement(topic, "Title").text = title or "IFC View"
ET.SubElement(topic, "CreationDate").text = now
ET.SubElement(topic, "CreationAuthor").text = author
if vp_guid:
vps = ET.SubElement(topic, "Viewpoints", Guid=vp_guid)
ET.SubElement(vps, "Viewpoint").text = "viewpoint.bcfv"
if comment and comment.strip():
c = ET.SubElement(root, "Comment", Guid=str(uuid.uuid4()))
ET.SubElement(c, "Date").text = now
ET.SubElement(c, "Author").text = author
ET.SubElement(c, "Comment").text = comment
if vp_guid:
ET.SubElement(c, "Viewpoint", Guid=vp_guid)
return b'<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode").encode()
def build_bcf(
camera: tuple[float, ...] | None = None,
fov: float | None = None,
scale: float | None = None,
clips: list[tuple[float, ...]] | None = None,
guids: list[str] | None = None,
visibility: str = "highlight",
title: str = "IFC View",
comment: str = "",
author: str = "anonymous",
) -> bytes:
"""Build a BCF 2.1 zip archive and return the raw bytes.
:param camera: 9-tuple (px, py, pz, dx, dy, dz, ux, uy, uz) in IFC coords.
:param fov: Perspective field of view in degrees. Mutually exclusive with *scale*.
:param scale: Orthographic view-to-world scale. Mutually exclusive with *fov*.
:param clips: List of 6-tuples (px, py, pz, nx, ny, nz) clipping planes.
:param guids: IfcGloballyUniqueId strings for selected/visible elements.
:param visibility: ``'highlight'``, ``'ghost'``, or ``'isolate'``.
:param title: BCF topic title.
:param comment: Optional comment text added to the topic.
:param author: Author string recorded in the BCF markup.
:returns: Bytes of a valid BCF 2.1 zip archive.
"""
topic_guid = str(uuid.uuid4())
vp_guid = str(uuid.uuid4()) if camera is not None else None
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("bcf.version", _VERSION_XML)
zf.writestr(
f"{topic_guid}/markup.bcf",
_markup_xml(topic_guid, vp_guid, title, comment, author, now),
)
if vp_guid is not None:
zf.writestr(
f"{topic_guid}/viewpoint.bcfv",
_viewpoint_xml(vp_guid, camera, fov, scale, clips or [], guids, visibility),
)
return buf.getvalue()

View file

@ -54,8 +54,8 @@ from pydantic import BaseModel
from ifcurl import render as render_mod
from ifcurl.auth import get_token_for_host
from ifcurl.bcf import build_bcf
from ifcurl.git import fetch_ifc
from ifcurl.url import IfcUrl
app = FastAPI(
@ -91,6 +91,17 @@ def _is_private_ip(host: str) -> bool:
return False
def _ssrf_check(ifc_url: IfcUrl) -> None:
"""Raise HTTPException if the URL fails SSRF protection checks."""
if ifc_url.transport == "local":
raise HTTPException(status_code=403, detail="Local file transport is not permitted in service mode")
if _allowed_hosts is not None:
if ifc_url.host not in _allowed_hosts:
raise HTTPException(status_code=403, detail=f"Host {ifc_url.host!r} is not in the allowed-hosts list")
elif _is_private_ip(ifc_url.host):
raise HTTPException(status_code=403, detail="Requests to private/loopback addresses are not permitted")
# ---------------------------------------------------------------------------
# Tier 2: (commit_hexsha, path) → IFC bytes
# ---------------------------------------------------------------------------
@ -243,6 +254,13 @@ class PreviewRequest(BaseModel):
"""
class BcfRequest(BaseModel):
url: str
title: str = "IFC View"
comment: str = ""
token: str | None = None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@ -275,13 +293,7 @@ def preview(request: PreviewRequest) -> Response:
raise HTTPException(status_code=400, detail="URL has no 'path' parameter")
# --- SSRF protection ---
if ifc_url.transport == "local":
raise HTTPException(status_code=403, detail="Local file transport is not permitted in service mode")
if _allowed_hosts is not None:
if ifc_url.host not in _allowed_hosts:
raise HTTPException(status_code=403, detail=f"Host {ifc_url.host!r} is not in the allowed-hosts list")
elif _is_private_ip(ifc_url.host):
raise HTTPException(status_code=403, detail="Requests to private/loopback addresses are not permitted")
_ssrf_check(ifc_url)
# --- Tier 4 / 4m: cached PNG ---
if ifc_url.is_mutable_ref():
@ -382,3 +394,61 @@ def preview(request: PreviewRequest) -> Response:
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
@app.post("/bcf")
def bcf_export(request: BcfRequest) -> Response:
"""Generate a BCF 2.1 zip from an ifc:// URL viewpoint.
Returns ``application/octet-stream`` with a ``.bcf`` zip file containing
the camera, clipping planes, and (when a selector is present) the resolved
component GUID selection.
"""
try:
ifc_url = IfcUrl.parse(request.url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ifc_url.path is None:
raise HTTPException(status_code=400, detail="URL has no 'path' parameter")
_ssrf_check(ifc_url)
# Resolve selector → component GUIDs when present.
guids: list[str] | None = None
if ifc_url.selector:
token = request.token
if token is None and ifc_url.host:
token = get_token_for_host(ifc_url.host)
try:
hexsha, ifc_bytes = fetch_ifc(ifc_url, token=token)
except (ImportError, ValueError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
cached = _t2_get(hexsha, ifc_url.path)
ifc_bytes = cached if cached is not None else ifc_bytes
if cached is None:
_t2_put(hexsha, ifc_url.path, ifc_bytes)
model = _load_model(ifc_bytes)
try:
matched = list(ifcopenshell.util.selector.filter_elements(model, ifc_url.selector))
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid selector: {exc}") from exc
guids = [e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId]
bcf_bytes = build_bcf(
camera=ifc_url.camera,
fov=ifc_url.fov,
scale=ifc_url.scale,
clips=ifc_url.clips or None,
guids=guids,
visibility=ifc_url.visibility,
title=request.title,
comment=request.comment,
)
return Response(
content=bcf_bytes,
media_type="application/octet-stream",
headers={"Content-Disposition": 'attachment; filename="view.bcf"'},
)

196
tests/test_bcf.py Normal file
View file

@ -0,0 +1,196 @@
"""Tests for ifcurl.bcf and the /bcf service endpoint."""
from __future__ import annotations
import io
import zipfile
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from ifcurl.bcf import build_bcf
from ifcurl.service import _t2_cache, _t3_cache, app, configure_allowed_hosts
client = TestClient(app)
FAKE_HEXSHA = "b" * 40
MUTABLE_URL = "ifc://example.com/org/repo@heads/main?path=model.ifc"
CAMERA_URL = (
"ifc://example.com/org/repo@heads/main"
"?path=model.ifc&camera=1,2,3,0,0,-1,0,1,0&fov=60"
)
CLIP_URL = (
"ifc://example.com/org/repo@heads/main"
"?path=model.ifc&camera=0,0,5,0,0,-1,0,1,0&fov=60"
"&clip=0,0,2,0,0,-1"
)
@pytest.fixture(autouse=True)
def reset_service(tmp_path, monkeypatch):
monkeypatch.setattr("ifcurl.service.user_cache_dir", lambda *a, **kw: str(tmp_path))
_t2_cache.clear()
_t3_cache.clear()
configure_allowed_hosts(None)
yield
_t2_cache.clear()
_t3_cache.clear()
configure_allowed_hosts(None)
def _zip_names(data: bytes) -> set[str]:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
return set(zf.namelist())
def _zip_read(data: bytes, name: str) -> str:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
return zf.read(name).decode()
# ---------------------------------------------------------------------------
# build_bcf unit tests
# ---------------------------------------------------------------------------
class TestBuildBcf:
def test_returns_valid_zip(self):
data = build_bcf()
assert zipfile.is_zipfile(io.BytesIO(data))
def test_contains_version_file(self):
data = build_bcf()
assert "bcf.version" in _zip_names(data)
def test_contains_markup(self):
data = build_bcf(title="Test topic")
names = _zip_names(data)
markup_files = [n for n in names if n.endswith("markup.bcf")]
assert len(markup_files) == 1
content = _zip_read(data, markup_files[0])
assert "Test topic" in content
def test_no_viewpoint_without_camera(self):
data = build_bcf()
names = _zip_names(data)
assert not any(n.endswith("viewpoint.bcfv") for n in names)
def test_viewpoint_present_with_camera(self):
cam = (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
data = build_bcf(camera=cam, fov=60.0)
names = _zip_names(data)
assert any(n.endswith("viewpoint.bcfv") for n in names)
def test_perspective_camera_in_viewpoint(self):
cam = (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
data = build_bcf(camera=cam, fov=60.0)
vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
content = _zip_read(data, vp_file)
assert "PerspectiveCamera" in content
assert "<FieldOfView>60.0000</FieldOfView>" in content
def test_orthographic_camera_in_viewpoint(self):
cam = (0.0, 0.0, 10.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
data = build_bcf(camera=cam, scale=50.0)
vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
content = _zip_read(data, vp_file)
assert "OrthogonalCamera" in content
assert "ViewToWorldScale" in content
def test_clipping_planes_in_viewpoint(self):
cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
data = build_bcf(camera=cam, fov=60.0, clips=[(0.0, 0.0, 2.0, 0.0, 0.0, -1.0)])
vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
content = _zip_read(data, vp_file)
assert "ClippingPlane" in content
def test_comment_in_markup(self):
cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
data = build_bcf(camera=cam, fov=60.0, comment="Look at this crack")
markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf"))
content = _zip_read(data, markup)
assert "Look at this crack" in content
def test_guids_in_viewpoint_selection(self):
cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
data = build_bcf(camera=cam, fov=60.0, guids=["abc123", "def456"])
vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
content = _zip_read(data, vp_file)
assert 'IfcGuid="abc123"' in content
assert 'IfcGuid="def456"' in content
def test_isolate_visibility_uses_exceptions(self):
cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
data = build_bcf(camera=cam, fov=60.0, guids=["abc123"], visibility="isolate")
vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv"))
content = _zip_read(data, vp_file)
assert 'DefaultVisibility="false"' in content
assert "Exceptions" in content
# ---------------------------------------------------------------------------
# /bcf service endpoint tests
# ---------------------------------------------------------------------------
class TestBcfEndpoint:
def test_returns_zip_bytes(self):
r = client.post("/bcf", json={"url": CAMERA_URL})
assert r.status_code == 200
assert zipfile.is_zipfile(io.BytesIO(r.content))
def test_content_disposition_header(self):
r = client.post("/bcf", json={"url": CAMERA_URL})
assert "view.bcf" in r.headers["content-disposition"]
def test_contains_viewpoint_when_camera_in_url(self):
r = client.post("/bcf", json={"url": CAMERA_URL})
names = _zip_names(r.content)
assert any(n.endswith("viewpoint.bcfv") for n in names)
def test_no_viewpoint_when_no_camera(self):
r = client.post("/bcf", json={"url": MUTABLE_URL})
assert r.status_code == 200
names = _zip_names(r.content)
assert not any(n.endswith("viewpoint.bcfv") for n in names)
def test_title_and_comment_in_markup(self):
r = client.post("/bcf", json={
"url": CAMERA_URL, "title": "My issue", "comment": "Fix this"
})
markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf"))
content = _zip_read(r.content, markup)
assert "My issue" in content
assert "Fix this" in content
def test_clip_plane_in_viewpoint(self):
r = client.post("/bcf", json={"url": CLIP_URL})
vp = next(n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv"))
assert "ClippingPlane" in _zip_read(r.content, vp)
def test_ssrf_local_transport_rejected(self):
r = client.post("/bcf", json={"url": "ifc:///some/path@HEAD?path=m.ifc"})
assert r.status_code == 403
def test_ssrf_private_ip_rejected(self):
r = client.post("/bcf", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"})
assert r.status_code == 403
def test_invalid_url_returns_400(self):
r = client.post("/bcf", json={"url": "https://example.com/model.ifc"})
assert r.status_code == 400
def test_selector_resolves_guids(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
def mock_fetch(ifc_url, token=None):
return FAKE_HEXSHA, ifc_bytes
selector_url = MUTABLE_URL.replace("?path=model.ifc", "?path=model.ifc&selector=IfcWall")
with patch("ifcurl.service.fetch_ifc", mock_fetch):
r = client.post("/bcf", json={"url": selector_url})
assert r.status_code == 200
vp_files = [n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv")]
# No camera → no viewpoint even with selector
assert len(vp_files) == 0