mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
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:
parent
27883e468e
commit
dae1435519
5 changed files with 592 additions and 11 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,"&").replace(/</g,"<")
|
||||
.replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
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
171
ifcurl/bcf.py
Normal 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()
|
||||
|
|
@ -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
196
tests/test_bcf.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue