diff --git a/forgejo/custom/public/assets/viewer.js b/forgejo/custom/public/assets/viewer.js
index b016114..dac0f83 100644
--- a/forgejo/custom/public/assets/viewer.js
+++ b/forgejo/custom/public/assets/viewer.js
@@ -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) {
`${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}`
).join("\n ")}
` : "";
- // Reflect visibility mode: isolate → hide everything by default,
- // ghost/highlight → show everything by default.
- const defaultVis = visibility === "isolate" ? "false" : "true";
+ // Build : embed GUIDs if a selector was active.
+ let componentsXml;
+ if (currentGuids.length) {
+ if (visibility === "isolate") {
+ const exc = currentGuids.map(g => ``).join("");
+ componentsXml = `${exc}`;
+ } else {
+ const sel = currentGuids.map(g => ``).join("");
+ componentsXml = `${sel}`;
+ }
+ } else {
+ const defaultVis = visibility === "isolate" ? "false" : "true";
+ componentsXml = ``;
+ }
viewpointXml = `
-
+ ${componentsXml}
${camXml}
${clipsXml}
`;
@@ -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) {
diff --git a/ifcurl/bcf.py b/ifcurl/bcf.py
index 888cbbf..1b8fbe0 100644
--- a/ifcurl/bcf.py
+++ b/ifcurl/bcf.py
@@ -17,188 +17,22 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see .
-"""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
- /
- 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"""\
-
-
- 2.1
-"""
-
-
-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'\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'\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.
diff --git a/ifcurl/service.py b/ifcurl/service.py
index e731893..c852f9b 100644
--- a/ifcurl/service.py
+++ b/ifcurl/service.py
@@ -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."""
diff --git a/tests/test_bcf.py b/tests/test_bcf.py
index f994df2..dbf3781 100644
--- a/tests/test_bcf.py
+++ b/tests/test_bcf.py
@@ -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 "60.0000" 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 "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 "" 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 & in the description element
- assert CAMERA_URL.replace("&", "&") 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")