Rewrite BCF export as pure client-side, remove Python /bcf endpoint

applySelector() now returns the resolved GUIDs array in both the simple
(ThatOpen classifier → getGuid()) and complex (/select → GUIDs already
fetched) paths.  generateBcf() reads currentGuids directly and builds the
full BCF 2.1 zip in the browser, including <Selection>/<Exceptions> in
the viewpoint component list.

The Python POST /bcf endpoint, BcfRequest model, and build_bcf()/
_viewpoint_xml()/_markup_xml() functions are removed — the browser had
all the data needed to do this itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-07 07:30:33 +01:00
parent 5ea60da943
commit 7c25e0624a
4 changed files with 46 additions and 552 deletions

View file

@ -44,6 +44,7 @@ let threeCamera = null;
let activeClipper = null;
let loadedModel = null;
let rendererCanvas = null;
let currentGuids = [];
// Raw HTTP URL of the loaded IFC file, captured once at model load time.
// Used to decide whether a loadUrl call needs a full reload or an in-place update.
let modelRawUrl = null;
@ -187,9 +188,10 @@ async function applyViewChanges(newUrl) {
const hider = components.get(OBC.Hider);
await frags.resetHighlight();
await hider.set(true);
currentGuids = [];
if (newSelector) {
statusEl.textContent = "Applying selector…";
await applySelector(components, loadedModel, newSelector, newUrl, newVisibility);
currentGuids = await applySelector(components, loadedModel, newSelector, newUrl, newVisibility) ?? [];
}
await frags.core.update(true);
}
@ -653,7 +655,7 @@ async function applyFragmentStyle(components, items, color, opacity) {
}
async function applySelector(components, model, selectorStr, srcUrl, visibility = "highlight") {
if (!selectorStr) return;
if (!selectorStr) return [];
const hider = components.get(OBC.Hider);
@ -662,15 +664,22 @@ async function applySelector(components, model, selectorStr, srcUrl, visibility
const classifier = components.get(OBC.Classifier);
await classifier.byCategory();
const categoryGroups = classifier.list.get("Categories");
if (!categoryGroups) return;
if (!categoryGroups) return [];
const matchingCategories = [];
for (const [cat] of categoryGroups) {
if (userTypes.some(t => cat === t || cat.startsWith(t)))
matchingCategories.push(cat);
}
if (!matchingCategories.length) return;
if (!matchingCategories.length) return [];
const matching = await classifier.find({ Categories: matchingCategories });
if (!matching || Object.keys(matching).length === 0) return;
if (!matching || Object.keys(matching).length === 0) return [];
// Collect GUIDs before applying visual style.
const allLocalIds = Object.values(matching).flatMap(s => [...s]);
const resolvedGuids = (await Promise.all(
allLocalIds.map(async id => { const item = model.getItem(id); return item ? item.getGuid() : null; })
)).filter(Boolean);
if (visibility === "ghost") {
// Dim non-selected; selected appear normally.
const allItems = await classifier.find({ Categories: [...categoryGroups.keys()] });
@ -684,7 +693,7 @@ async function applySelector(components, model, selectorStr, srcUrl, visibility
// highlight (default): colour-overlay the selected elements, all others visible.
await applyFragmentStyle(components, matching, new THREE.Color(0xff8800), 1);
}
return;
return resolvedGuids;
}
// Complex selector: ask the service to resolve it to GUIDs.
@ -695,18 +704,18 @@ async function applySelector(components, model, selectorStr, srcUrl, visibility
if (!resp.ok) {
const detail = await resp.text().catch(() => resp.statusText);
statusEl.textContent = `Selector error: ${detail}`;
return;
return [];
}
guids = (await resp.json()).guids ?? [];
} catch (_) {
statusEl.textContent = "Complex selector requires the ifcurl service — /select not reachable";
return;
return [];
}
if (!guids.length) return;
if (!guids.length) return [];
const localIds = (await model.getLocalIdsByGuids(guids)).filter(id => id !== null);
if (!localIds.length) return;
if (!localIds.length) return [];
const matchingMap = { [model.modelId]: new Set(localIds) };
if (visibility === "ghost") {
@ -720,6 +729,7 @@ async function applySelector(components, model, selectorStr, srcUrl, visibility
} else {
await applyFragmentStyle(components, matchingMap, new THREE.Color(0xff8800), 1);
}
return guids;
}
// -----------------------------------------------------------------------
@ -810,11 +820,9 @@ async function applyCameraParam(controls, obcCamera, url) {
}
// -----------------------------------------------------------------------
// BCF 2.1 export.
// With a selector: delegates to POST /bcf so the service resolves component
// GUIDs server-side. Fails visibly if the service is unreachable — no
// silent fallback to a GUIDless file.
// Without a selector: builds the zip client-side (camera + clips only).
// BCF 2.1 export — built entirely client-side.
// GUIDs for active selectors are kept in currentGuids, populated by
// applySelector() on every model load or selector change.
// -----------------------------------------------------------------------
async function generateBcf(title, comment) {
if (threeCamera) syncCameraUrl(threeCamera);
@ -834,36 +842,6 @@ async function generateBcf(title, comment) {
const selector = qs.get("selector") || "";
const visibility = qs.get("visibility") || "highlight";
if (selector) {
let snapshotB64 = null;
if (rendererCanvas) {
try {
snapshotB64 = rendererCanvas.toDataURL("image/png").split(",")[1];
} catch (_) { /* context lost or cross-origin — skip */ }
}
try {
const resp = await fetch("/bcf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: src, title: title || "IFC View", comment, snapshot: snapshotB64 }),
});
if (!resp.ok) {
const detail = await resp.text().catch(() => resp.statusText);
statusEl.textContent = `BCF export failed: ${detail}`;
return;
}
const blob = await resp.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "view.bcf";
a.click();
URL.revokeObjectURL(a.href);
} catch (_) {
statusEl.textContent = "BCF export requires the ifcurl service — /bcf not reachable";
}
return;
}
// Capture a snapshot of the current view for the BCF zip.
let snapshotB64 = null;
if (rendererCanvas) {
@ -913,12 +891,23 @@ async function generateBcf(title, comment) {
`<ClippingPlane>${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}</ClippingPlane>`
).join("\n ")}
</ClippingPlanes>` : "";
// Reflect visibility mode: isolate → hide everything by default,
// ghost/highlight → show everything by default.
const defaultVis = visibility === "isolate" ? "false" : "true";
// Build <Components>: embed GUIDs if a selector was active.
let componentsXml;
if (currentGuids.length) {
if (visibility === "isolate") {
const exc = currentGuids.map(g => `<Component IfcGuid="${esc(g)}"/>`).join("");
componentsXml = `<Components><Visibility DefaultVisibility="false"><Exceptions>${exc}</Exceptions></Visibility></Components>`;
} else {
const sel = currentGuids.map(g => `<Component IfcGuid="${esc(g)}"/>`).join("");
componentsXml = `<Components><Selection>${sel}</Selection><Visibility DefaultVisibility="true"/></Components>`;
}
} else {
const defaultVis = visibility === "isolate" ? "false" : "true";
componentsXml = `<Components><Visibility DefaultVisibility="${defaultVis}"/></Components>`;
}
viewpointXml = `<?xml version="1.0" encoding="utf-8"?>
<VisualizationInfo Guid="${vpGuid}">
<Components><Visibility DefaultVisibility="${defaultVis}"/></Components>
${componentsXml}
${camXml}
${clipsXml}
</VisualizationInfo>`;
@ -1356,9 +1345,10 @@ async function main() {
console.warn("Highlighter unavailable:", err.message);
}
currentGuids = [];
if (selectorStr) {
statusEl.textContent = "Applying selector…";
await applySelector(components, model, selectorStr, currentIfcUrl, visibility);
currentGuids = await applySelector(components, model, selectorStr, currentIfcUrl, visibility) ?? [];
}
if (selectorStr && queryStr) {

View file

@ -17,188 +17,22 @@
# 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 3.0 REST viewpoint ↔ ifc:// URL conversion.
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
BCF zip generation has moved to the browser (viewer.js). This module
only provides the translation layer used by the BCF 3.0 REST API routes
in bcf_api.py.
"""
from __future__ import annotations
import dataclasses
import io
import re
import uuid
import xml.etree.ElementTree as ET
import zipfile
from datetime import datetime, timezone
from ifcurl.url import IfcUrl
_IFC_GUID_RE = re.compile(r"^[0-9A-Za-z_$]{22}$")
_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,
description: str = "",
has_snapshot: bool = False,
) -> 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"
if description:
ET.SubElement(topic, "Description").text = description
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 has_snapshot:
ET.SubElement(vps, "Snapshot").text = "snapshot.png"
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 = "",
description: str = "",
author: str = "anonymous",
snapshot: bytes | None = None,
) -> 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 description: Optional long description for the topic (e.g. the source ifc:// URL).
: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, description,
has_snapshot=snapshot is not None,
),
)
if vp_guid is not None:
zf.writestr(
f"{topic_guid}/viewpoint.bcfv",
_viewpoint_xml(
vp_guid, camera, fov, scale, clips or [], guids, visibility
),
)
if vp_guid is not None and snapshot is not None:
zf.writestr(f"{topic_guid}/snapshot.png", snapshot)
return buf.getvalue()
def bcf_viewpoint_to_ifc_url(base: IfcUrl, viewpoint: dict) -> str:
"""Convert a BCF 3.0 REST viewpoint dict to an ifc:// URL.

View file

@ -52,7 +52,6 @@ from platformdirs import user_cache_dir
from pydantic import BaseModel
from ifcurl.auth import get_token_for_host
from ifcurl.bcf import build_bcf
from ifcurl.bcf_api import router as bcf_router
from ifcurl.documents_api import router as documents_router
from ifcurl.git import diff_text as git_diff_text
@ -452,15 +451,6 @@ class ClashRequest(BaseModel):
token: str | None = None
class BcfRequest(BaseModel):
url: str
title: str = "IFC View"
comment: str = ""
token: str | None = None
snapshot: str | None = None
"""Base64-encoded PNG snapshot captured client-side."""
class DiffRequest(BaseModel):
base: str
"""ifc:// URL for the base (older) commit."""
@ -621,86 +611,6 @@ def preview(request: PreviewRequest) -> Response:
)
@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
_check_ifc_size(ifc_bytes)
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)
try:
if _RENDER_SOCKET:
guids = _select_via_socket(ifc_bytes, ifc_url.selector)
else:
guids = run_sandboxed(_sandboxed_select, ifc_bytes, ifc_url.selector)
except SandboxCrashError as exc:
raise HTTPException(
status_code=422, detail=f"IFC parse/select crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(
status_code=503, detail=f"Select timed out: {exc}"
) from exc
except Exception as exc:
raise HTTPException(
status_code=422, detail=f"Invalid selector: {exc}"
) from exc
snapshot_bytes: bytes | None = None
if request.snapshot:
try:
snapshot_bytes = base64.b64decode(request.snapshot)
except Exception:
pass
bcf_bytes = build_bcf(
camera=ifc_url.camera,
fov=ifc_url.fov,
scale=ifc_url.scale,
clips=ifc_url.clips or None,
guids=guids,
visibility=ifc_url.visibility,
title=request.title,
comment=request.comment,
description=request.url,
snapshot=snapshot_bytes,
)
return Response(
content=bcf_bytes,
media_type="application/octet-stream",
headers={"Content-Disposition": 'attachment; filename="view.bcf"'},
)
@app.get("/select")
def select_get(url: str, token: str | None = None) -> JSONResponse:
"""GET variant of POST /select."""

View file

@ -1,252 +1,12 @@
"""Tests for ifcurl.bcf and the /bcf service endpoint."""
"""Tests for ifcurl.bcf — BCF 3.0 viewpoint ↔ ifc:// URL conversion."""
from __future__ import annotations
import io
import zipfile
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from ifcurl.bcf import bcf_viewpoint_to_ifc_url, build_bcf
from ifcurl.service import _t2_cache, _t3_cache, app, configure_allowed_hosts
from ifcurl.bcf import bcf_viewpoint_to_ifc_url
from ifcurl.url import IfcUrl
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()
def _zip_read_bytes(data: bytes, name: str) -> bytes:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
return zf.read(name)
# ---------------------------------------------------------------------------
# build_bcf unit tests
# ---------------------------------------------------------------------------
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
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
def test_snapshot_written_to_zip_and_referenced_in_markup(self):
cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
png_bytes = b"\x89PNG\r\n\x1a\nfake"
data = build_bcf(camera=cam, fov=60.0, snapshot=png_bytes)
names = _zip_names(data)
snap_file = next((n for n in names if n.endswith("snapshot.png")), None)
assert snap_file is not None
assert _zip_read_bytes(data, snap_file) == png_bytes
markup = next(n for n in names if n.endswith("markup.bcf"))
assert "snapshot.png" in _zip_read(data, markup)
def test_no_snapshot_when_not_provided(self):
cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0)
data = build_bcf(camera=cam, fov=60.0)
names = _zip_names(data)
assert not any(n.endswith("snapshot.png") for n in names)
markup = next(n for n in names if n.endswith("markup.bcf"))
assert "snapshot.png" not in _zip_read(data, markup)
# ---------------------------------------------------------------------------
# /bcf service endpoint tests
# ---------------------------------------------------------------------------
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_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):
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, False
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
# ---------------------------------------------------------------------------
# bcf_viewpoint_to_ifc_url() unit tests
# ---------------------------------------------------------------------------
BASE = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc")