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