mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
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:
parent
5ea60da943
commit
7c25e0624a
4 changed files with 46 additions and 552 deletions
|
|
@ -44,6 +44,7 @@ let threeCamera = null;
|
||||||
let activeClipper = null;
|
let activeClipper = null;
|
||||||
let loadedModel = null;
|
let loadedModel = null;
|
||||||
let rendererCanvas = null;
|
let rendererCanvas = null;
|
||||||
|
let currentGuids = [];
|
||||||
// Raw HTTP URL of the loaded IFC file, captured once at model load time.
|
// 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.
|
// Used to decide whether a loadUrl call needs a full reload or an in-place update.
|
||||||
let modelRawUrl = null;
|
let modelRawUrl = null;
|
||||||
|
|
@ -187,9 +188,10 @@ async function applyViewChanges(newUrl) {
|
||||||
const hider = components.get(OBC.Hider);
|
const hider = components.get(OBC.Hider);
|
||||||
await frags.resetHighlight();
|
await frags.resetHighlight();
|
||||||
await hider.set(true);
|
await hider.set(true);
|
||||||
|
currentGuids = [];
|
||||||
if (newSelector) {
|
if (newSelector) {
|
||||||
statusEl.textContent = "Applying selector…";
|
statusEl.textContent = "Applying selector…";
|
||||||
await applySelector(components, loadedModel, newSelector, newUrl, newVisibility);
|
currentGuids = await applySelector(components, loadedModel, newSelector, newUrl, newVisibility) ?? [];
|
||||||
}
|
}
|
||||||
await frags.core.update(true);
|
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") {
|
async function applySelector(components, model, selectorStr, srcUrl, visibility = "highlight") {
|
||||||
if (!selectorStr) return;
|
if (!selectorStr) return [];
|
||||||
|
|
||||||
const hider = components.get(OBC.Hider);
|
const hider = components.get(OBC.Hider);
|
||||||
|
|
||||||
|
|
@ -662,15 +664,22 @@ async function applySelector(components, model, selectorStr, srcUrl, visibility
|
||||||
const classifier = components.get(OBC.Classifier);
|
const classifier = components.get(OBC.Classifier);
|
||||||
await classifier.byCategory();
|
await classifier.byCategory();
|
||||||
const categoryGroups = classifier.list.get("Categories");
|
const categoryGroups = classifier.list.get("Categories");
|
||||||
if (!categoryGroups) return;
|
if (!categoryGroups) return [];
|
||||||
const matchingCategories = [];
|
const matchingCategories = [];
|
||||||
for (const [cat] of categoryGroups) {
|
for (const [cat] of categoryGroups) {
|
||||||
if (userTypes.some(t => cat === t || cat.startsWith(t)))
|
if (userTypes.some(t => cat === t || cat.startsWith(t)))
|
||||||
matchingCategories.push(cat);
|
matchingCategories.push(cat);
|
||||||
}
|
}
|
||||||
if (!matchingCategories.length) return;
|
if (!matchingCategories.length) return [];
|
||||||
const matching = await classifier.find({ Categories: matchingCategories });
|
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") {
|
if (visibility === "ghost") {
|
||||||
// Dim non-selected; selected appear normally.
|
// Dim non-selected; selected appear normally.
|
||||||
const allItems = await classifier.find({ Categories: [...categoryGroups.keys()] });
|
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.
|
// highlight (default): colour-overlay the selected elements, all others visible.
|
||||||
await applyFragmentStyle(components, matching, new THREE.Color(0xff8800), 1);
|
await applyFragmentStyle(components, matching, new THREE.Color(0xff8800), 1);
|
||||||
}
|
}
|
||||||
return;
|
return resolvedGuids;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complex selector: ask the service to resolve it to GUIDs.
|
// 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) {
|
if (!resp.ok) {
|
||||||
const detail = await resp.text().catch(() => resp.statusText);
|
const detail = await resp.text().catch(() => resp.statusText);
|
||||||
statusEl.textContent = `Selector error: ${detail}`;
|
statusEl.textContent = `Selector error: ${detail}`;
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
guids = (await resp.json()).guids ?? [];
|
guids = (await resp.json()).guids ?? [];
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
statusEl.textContent = "Complex selector requires the ifcurl service — /select not reachable";
|
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);
|
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) };
|
const matchingMap = { [model.modelId]: new Set(localIds) };
|
||||||
if (visibility === "ghost") {
|
if (visibility === "ghost") {
|
||||||
|
|
@ -720,6 +729,7 @@ async function applySelector(components, model, selectorStr, srcUrl, visibility
|
||||||
} else {
|
} else {
|
||||||
await applyFragmentStyle(components, matchingMap, new THREE.Color(0xff8800), 1);
|
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.
|
// BCF 2.1 export — built entirely client-side.
|
||||||
// With a selector: delegates to POST /bcf so the service resolves component
|
// GUIDs for active selectors are kept in currentGuids, populated by
|
||||||
// GUIDs server-side. Fails visibly if the service is unreachable — no
|
// applySelector() on every model load or selector change.
|
||||||
// silent fallback to a GUIDless file.
|
|
||||||
// Without a selector: builds the zip client-side (camera + clips only).
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
async function generateBcf(title, comment) {
|
async function generateBcf(title, comment) {
|
||||||
if (threeCamera) syncCameraUrl(threeCamera);
|
if (threeCamera) syncCameraUrl(threeCamera);
|
||||||
|
|
@ -834,36 +842,6 @@ async function generateBcf(title, comment) {
|
||||||
const selector = qs.get("selector") || "";
|
const selector = qs.get("selector") || "";
|
||||||
const visibility = qs.get("visibility") || "highlight";
|
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.
|
// Capture a snapshot of the current view for the BCF zip.
|
||||||
let snapshotB64 = null;
|
let snapshotB64 = null;
|
||||||
if (rendererCanvas) {
|
if (rendererCanvas) {
|
||||||
|
|
@ -913,12 +891,23 @@ async function generateBcf(title, comment) {
|
||||||
`<ClippingPlane>${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}</ClippingPlane>`
|
`<ClippingPlane>${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}</ClippingPlane>`
|
||||||
).join("\n ")}
|
).join("\n ")}
|
||||||
</ClippingPlanes>` : "";
|
</ClippingPlanes>` : "";
|
||||||
// Reflect visibility mode: isolate → hide everything by default,
|
// Build <Components>: embed GUIDs if a selector was active.
|
||||||
// ghost/highlight → show everything by default.
|
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";
|
const defaultVis = visibility === "isolate" ? "false" : "true";
|
||||||
|
componentsXml = `<Components><Visibility DefaultVisibility="${defaultVis}"/></Components>`;
|
||||||
|
}
|
||||||
viewpointXml = `<?xml version="1.0" encoding="utf-8"?>
|
viewpointXml = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
<VisualizationInfo Guid="${vpGuid}">
|
<VisualizationInfo Guid="${vpGuid}">
|
||||||
<Components><Visibility DefaultVisibility="${defaultVis}"/></Components>
|
${componentsXml}
|
||||||
${camXml}
|
${camXml}
|
||||||
${clipsXml}
|
${clipsXml}
|
||||||
</VisualizationInfo>`;
|
</VisualizationInfo>`;
|
||||||
|
|
@ -1356,9 +1345,10 @@ async function main() {
|
||||||
console.warn("Highlighter unavailable:", err.message);
|
console.warn("Highlighter unavailable:", err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentGuids = [];
|
||||||
if (selectorStr) {
|
if (selectorStr) {
|
||||||
statusEl.textContent = "Applying selector…";
|
statusEl.textContent = "Applying selector…";
|
||||||
await applySelector(components, model, selectorStr, currentIfcUrl, visibility);
|
currentGuids = await applySelector(components, model, selectorStr, currentIfcUrl, visibility) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectorStr && queryStr) {
|
if (selectorStr && queryStr) {
|
||||||
|
|
|
||||||
174
ifcurl/bcf.py
174
ifcurl/bcf.py
|
|
@ -17,188 +17,22 @@
|
||||||
# You should have received a copy of the GNU Lesser General Public License
|
# 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/>.
|
# 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 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
|
||||||
bcf.version
|
in bcf_api.py.
|
||||||
<topic-guid>/
|
|
||||||
markup.bcf — title, comment, viewpoint reference
|
|
||||||
viewpoint.bcfv — camera, clipping planes, component selection
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import io
|
|
||||||
import re
|
import re
|
||||||
import uuid
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
import zipfile
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from ifcurl.url import IfcUrl
|
from ifcurl.url import IfcUrl
|
||||||
|
|
||||||
_IFC_GUID_RE = re.compile(r"^[0-9A-Za-z_$]{22}$")
|
_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:
|
def bcf_viewpoint_to_ifc_url(base: IfcUrl, viewpoint: dict) -> str:
|
||||||
"""Convert a BCF 3.0 REST viewpoint dict to an ifc:// URL.
|
"""Convert a BCF 3.0 REST viewpoint dict to an ifc:// URL.
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ from platformdirs import user_cache_dir
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ifcurl.auth import get_token_for_host
|
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.bcf_api import router as bcf_router
|
||||||
from ifcurl.documents_api import router as documents_router
|
from ifcurl.documents_api import router as documents_router
|
||||||
from ifcurl.git import diff_text as git_diff_text
|
from ifcurl.git import diff_text as git_diff_text
|
||||||
|
|
@ -452,15 +451,6 @@ class ClashRequest(BaseModel):
|
||||||
token: str | None = None
|
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):
|
class DiffRequest(BaseModel):
|
||||||
base: str
|
base: str
|
||||||
"""ifc:// URL for the base (older) commit."""
|
"""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")
|
@app.get("/select")
|
||||||
def select_get(url: str, token: str | None = None) -> JSONResponse:
|
def select_get(url: str, token: str | None = None) -> JSONResponse:
|
||||||
"""GET variant of POST /select."""
|
"""GET variant of POST /select."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
|
||||||
import zipfile
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from ifcurl.bcf import bcf_viewpoint_to_ifc_url, build_bcf
|
from ifcurl.bcf import bcf_viewpoint_to_ifc_url
|
||||||
from ifcurl.service import _t2_cache, _t3_cache, app, configure_allowed_hosts
|
|
||||||
from ifcurl.url import IfcUrl
|
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 & 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")
|
BASE = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue