mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
- git.py: unpack (repo, is_stale) tuple from _get_repo() in diff_text; was AttributeError 'tuple has no attribute commit' on every render_diff - render.py: filter removed_entities and _entity_bounds include list to IfcProduct only; non-geometric entities (IfcTaskTime etc) crash the ifcopenshell geom iterator - render.py: replace reset_camera(bounds=diff_bounds) with explicit camera positioning; pyvista's reset_camera re-expands clipping to all actors so the full scene remained visible — now camera is repositioned at the default viewing direction but at a distance sized to the diff bounds, zooming tightly to just the changed elements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
827 lines
29 KiB
Python
827 lines
29 KiB
Python
# 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/>.
|
||
|
||
"""Render an IFC model to a PNG using pyvista (off-screen).
|
||
|
||
Adapted from ifcquery/render.py (Bruno Postle, 2026). Key differences:
|
||
- Camera is set from explicit URL parameters (position, direction, up,
|
||
fov/scale) rather than named views.
|
||
- Clip planes are applied per-mesh before adding to the scene.
|
||
- Three visibility modes: highlight, ghost, isolate.
|
||
- Type entities (IfcTypeProduct) are handled via a temporary model copy,
|
||
as in ifcquery.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import multiprocessing
|
||
import os
|
||
import tempfile
|
||
|
||
# Cap parallel geometry workers per render call. In service deployments,
|
||
# N concurrent requests × cpu_count workers each can saturate the host.
|
||
# Override with IFCURL_RENDER_WORKERS (e.g. "8" for a dedicated render host).
|
||
_WORKER_COUNT: int = min(
|
||
multiprocessing.cpu_count(),
|
||
int(os.environ.get("IFCURL_RENDER_WORKERS", "4")),
|
||
)
|
||
|
||
import ifcopenshell
|
||
import ifcopenshell.geom
|
||
import ifcopenshell.guid
|
||
import ifcopenshell.util.selector
|
||
|
||
try:
|
||
import numpy as np
|
||
import pyvista as pv
|
||
|
||
# Resolve pyvista's lazy-loaded Plotter class at import time so that
|
||
# concurrent requests in a threadpool don't race on the lazy __getattr__.
|
||
_Plotter = pv.Plotter
|
||
_HAS_PYVISTA = True
|
||
except (ImportError, AttributeError):
|
||
_HAS_PYVISTA = False
|
||
|
||
try:
|
||
import io as _io
|
||
|
||
from PIL import Image as _PILImage
|
||
|
||
_HAS_PIL = True
|
||
except ImportError:
|
||
_HAS_PIL = False
|
||
|
||
# Highlight colour used in 'highlight' visibility mode (orange).
|
||
_HIGHLIGHT_COLOR = (255, 140, 0)
|
||
|
||
# Diff colours: green=added, blue=modified, red=removed, grey=unchanged ghost.
|
||
_DIFF_ADDED_COLOR = (50, 200, 50)
|
||
_DIFF_MODIFIED_COLOR = (50, 100, 200)
|
||
_DIFF_REMOVED_COLOR = (200, 50, 50)
|
||
_DIFF_GHOST_COLOR = (180, 180, 180)
|
||
_DIFF_GHOST_OPACITY = 0.08
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Camera
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _apply_camera(
|
||
plotter: pv.Plotter,
|
||
camera: tuple[float, ...],
|
||
fov: float | None,
|
||
scale: float | None,
|
||
) -> None:
|
||
"""Set the pyvista camera from ifc:// URL camera parameters.
|
||
|
||
*camera* is (px, py, pz, dx, dy, dz, ux, uy, uz) in IFC world coordinates.
|
||
Exactly one of *fov* (perspective) or *scale* (orthographic) must be given.
|
||
"""
|
||
px, py, pz, dx, dy, dz, ux, uy, uz = camera
|
||
plotter.camera.position = (px, py, pz)
|
||
plotter.camera.focal_point = (px + dx, py + dy, pz + dz)
|
||
plotter.camera.up = (ux, uy, uz)
|
||
if fov is not None:
|
||
plotter.camera.parallel_projection = False
|
||
plotter.camera.view_angle = fov
|
||
else:
|
||
plotter.camera.parallel_projection = True
|
||
plotter.camera.parallel_scale = scale
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Geometry settings (shared with ifcquery)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings:
|
||
"""Build geometry settings, excluding Clearance subcontexts."""
|
||
settings = ifcopenshell.geom.settings()
|
||
settings.set("use-world-coords", True)
|
||
|
||
clearance_ids = {
|
||
c.id()
|
||
for c in model.by_type("IfcGeometricRepresentationSubContext")
|
||
if c.ContextIdentifier == "Clearance"
|
||
}
|
||
if clearance_ids:
|
||
ctx_ids = [
|
||
c.id()
|
||
for c in model.by_type("IfcGeometricRepresentationContext")
|
||
if c.id() not in clearance_ids
|
||
]
|
||
if ctx_ids:
|
||
settings.set("context-ids", ctx_ids)
|
||
|
||
return settings
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shape rendering
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _material_color_and_opacity(mat: object) -> tuple[tuple[int, int, int], float]:
|
||
"""Return (RGB tuple, opacity) from an IfcOpenShell material."""
|
||
diffuse = np.clip(np.array(mat.diffuse.components), 0.0, 1.0)
|
||
color = tuple((diffuse * 255).astype(np.uint8))
|
||
transparency = mat.transparency if mat.transparency == mat.transparency else 0.0
|
||
opacity = float(np.clip(1.0 - transparency, 0.0, 1.0))
|
||
return color, opacity # type: ignore[return-value]
|
||
|
||
|
||
def _add_shape(
|
||
shape: object,
|
||
plotter: pv.Plotter,
|
||
selection_ids: frozenset[int] | None,
|
||
visibility: str,
|
||
clips: list[tuple[float, ...]],
|
||
diff_ids: dict[str, frozenset[int]] | None = None,
|
||
) -> None:
|
||
"""Triangulate a geometry shape, apply clips, and add it to the plotter.
|
||
|
||
Visibility modes (when *diff_ids* is None):
|
||
highlight — non-selected shown normally, selected shown in highlight colour
|
||
ghost — selected shown normally, non-selected shown as grey + translucent
|
||
isolate — only selected elements are added (non-selected are skipped)
|
||
|
||
When *diff_ids* is provided the diff colouring takes precedence:
|
||
green — step ID in diff_ids["added"]
|
||
blue — step ID in diff_ids["modified"]
|
||
red — step ID in diff_ids["removed"]
|
||
ghost — step ID in none of the above (unchanged context)
|
||
"""
|
||
geom = shape.geometry
|
||
verts = np.array(geom.verts, dtype=float).reshape(-1, 3)
|
||
if verts.size == 0:
|
||
return
|
||
|
||
raw_faces = np.array(geom.faces, dtype=int)
|
||
if raw_faces.size == 0 or raw_faces.size % 3 != 0:
|
||
return
|
||
|
||
faces = raw_faces.reshape(-1, 3)
|
||
material_ids = np.array(geom.material_ids, dtype=int)
|
||
|
||
is_selected = selection_ids is not None and shape.product.id() in selection_ids
|
||
|
||
if (
|
||
diff_ids is None
|
||
and visibility == "isolate"
|
||
and selection_ids is not None
|
||
and not is_selected
|
||
):
|
||
return
|
||
|
||
for midx, mat in enumerate(geom.materials):
|
||
tri_mask = material_ids == midx
|
||
if not np.any(tri_mask):
|
||
continue
|
||
|
||
sub_faces = faces[tri_mask]
|
||
faces_pv = np.hstack(
|
||
[np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]
|
||
).ravel()
|
||
mesh = pv.PolyData(verts, faces_pv)
|
||
|
||
# Apply clipping planes — spec: normal points toward the visible side
|
||
for px, py, pz, nx, ny, nz in clips:
|
||
mesh = mesh.clip(normal=(nx, ny, nz), origin=(px, py, pz), invert=False)
|
||
if mesh.n_points == 0:
|
||
break
|
||
if mesh.n_points == 0:
|
||
continue
|
||
|
||
# Determine colour and opacity
|
||
if diff_ids is not None:
|
||
sid = shape.product.id()
|
||
if sid in diff_ids.get("added", frozenset()):
|
||
color, opacity = _DIFF_ADDED_COLOR, 1.0
|
||
elif sid in diff_ids.get("modified", frozenset()):
|
||
color, opacity = _DIFF_MODIFIED_COLOR, 1.0
|
||
elif sid in diff_ids.get("removed", frozenset()):
|
||
color, opacity = _DIFF_REMOVED_COLOR, 1.0
|
||
else:
|
||
color, opacity = _DIFF_GHOST_COLOR, _DIFF_GHOST_OPACITY
|
||
elif selection_ids is None:
|
||
color, opacity = _material_color_and_opacity(mat)
|
||
elif visibility == "highlight":
|
||
if is_selected:
|
||
color, opacity = _HIGHLIGHT_COLOR, 1.0
|
||
else:
|
||
color, opacity = _material_color_and_opacity(mat)
|
||
elif visibility == "ghost":
|
||
if is_selected:
|
||
color, opacity = _material_color_and_opacity(mat)
|
||
else:
|
||
color, opacity = (180, 180, 180), 0.10
|
||
else: # isolate — non-selected already returned above
|
||
color, opacity = _material_color_and_opacity(mat)
|
||
|
||
plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Iterator rendering
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _plotter_screenshot(plotter: pv.Plotter) -> bytes:
|
||
"""Render *plotter* off-screen and return PNG bytes."""
|
||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png")
|
||
os.close(tmp_fd)
|
||
try:
|
||
plotter.show(screenshot=tmp_path, auto_close=True)
|
||
with open(tmp_path, "rb") as f:
|
||
return f.read()
|
||
finally:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _render_iterator(
|
||
iterator: object,
|
||
selection_ids: list[int] | None,
|
||
visibility: str,
|
||
clips: list[tuple[float, ...]],
|
||
camera: tuple[float, ...] | None,
|
||
fov: float | None,
|
||
scale: float | None,
|
||
diff_ids: dict[str, frozenset[int]] | None = None,
|
||
) -> bytes:
|
||
"""Drive a geometry iterator into a pyvista plotter and return PNG bytes."""
|
||
plotter = _Plotter(off_screen=True, window_size=(1280, 960))
|
||
plotter.background_color = "white"
|
||
|
||
frozen_ids = frozenset(selection_ids) if selection_ids else None
|
||
|
||
while True:
|
||
try:
|
||
_add_shape(
|
||
iterator.get(),
|
||
plotter,
|
||
frozen_ids,
|
||
visibility,
|
||
clips,
|
||
diff_ids=diff_ids,
|
||
)
|
||
except Exception:
|
||
pass # skip broken shapes, keep rendering the rest
|
||
if not iterator.next():
|
||
break
|
||
|
||
if camera is not None:
|
||
_apply_camera(plotter, camera, fov, scale)
|
||
else:
|
||
plotter.reset_camera()
|
||
plotter.camera.up = (0, 0, 1)
|
||
if scale is not None:
|
||
plotter.camera.parallel_projection = True
|
||
plotter.camera.parallel_scale = scale
|
||
|
||
return _plotter_screenshot(plotter)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Type entity handling (adapted from ifcquery)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _get_occurrence_class(type_entity: object) -> str:
|
||
type_class = type_entity.is_a()
|
||
if type_class.endswith("Type"):
|
||
return type_class[:-4]
|
||
return "IfcBuildingElementProxy"
|
||
|
||
|
||
def _make_type_occurrence(
|
||
model: ifcopenshell.file, type_entity: object
|
||
) -> object | None:
|
||
rep_maps = getattr(type_entity, "RepresentationMaps", None) or []
|
||
if not rep_maps:
|
||
return None
|
||
|
||
mapped_items = []
|
||
for rep_map in rep_maps:
|
||
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||
transform = model.create_entity(
|
||
"IfcCartesianTransformationOperator3D", LocalOrigin=origin
|
||
)
|
||
mapped_items.append(
|
||
model.create_entity(
|
||
"IfcMappedItem", MappingSource=rep_map, MappingTarget=transform
|
||
)
|
||
)
|
||
|
||
context = rep_maps[0].MappedRepresentation.ContextOfItems
|
||
shape_rep = model.create_entity(
|
||
"IfcShapeRepresentation",
|
||
ContextOfItems=context,
|
||
RepresentationIdentifier="Body",
|
||
RepresentationType="MappedRepresentation",
|
||
Items=mapped_items,
|
||
)
|
||
prod_def = model.create_entity(
|
||
"IfcProductDefinitionShape", Representations=[shape_rep]
|
||
)
|
||
|
||
pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||
axis2 = model.create_entity(
|
||
"IfcAxis2Placement3D",
|
||
Location=pt,
|
||
Axis=model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
|
||
RefDirection=model.create_entity(
|
||
"IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)
|
||
),
|
||
)
|
||
placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
|
||
|
||
occ_class = _get_occurrence_class(type_entity)
|
||
try:
|
||
return model.create_entity(
|
||
occ_class,
|
||
GlobalId=ifcopenshell.guid.new(),
|
||
Name=f"_type_preview_{type_entity.id()}",
|
||
ObjectPlacement=placement,
|
||
Representation=prod_def,
|
||
)
|
||
except Exception:
|
||
return model.create_entity(
|
||
"IfcBuildingElementProxy",
|
||
GlobalId=ifcopenshell.guid.new(),
|
||
Name=f"_type_preview_{type_entity.id()}",
|
||
ObjectPlacement=placement,
|
||
Representation=prod_def,
|
||
)
|
||
|
||
|
||
def _render_with_types(
|
||
model: ifcopenshell.file,
|
||
types: list,
|
||
selector_elements: list | None,
|
||
element_ids: list[int] | None,
|
||
type_highlight_ids: set[int],
|
||
visibility: str,
|
||
clips: list[tuple[float, ...]],
|
||
camera: tuple[float, ...] | None,
|
||
fov: float | None,
|
||
scale: float | None,
|
||
) -> bytes:
|
||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc")
|
||
os.close(tmp_fd)
|
||
try:
|
||
model.write(tmp_path)
|
||
tmp = ifcopenshell.open(tmp_path)
|
||
|
||
type_id_to_occ_id: dict[int, int] = {}
|
||
for t in types:
|
||
tmp_type = tmp.by_id(t.id())
|
||
occ = _make_type_occurrence(tmp, tmp_type)
|
||
if occ:
|
||
type_id_to_occ_id[t.id()] = occ.id()
|
||
|
||
if not type_id_to_occ_id:
|
||
raise ValueError("Type entities have no RepresentationMaps to render")
|
||
|
||
include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()]
|
||
if selector_elements:
|
||
include.extend(tmp.by_id(e.id()) for e in selector_elements)
|
||
|
||
settings = _build_geom_settings(tmp)
|
||
iterator = ifcopenshell.geom.iterator(
|
||
settings, tmp, _WORKER_COUNT, include=include
|
||
)
|
||
if not iterator.initialize():
|
||
raise ValueError("Type entities have no renderable geometry")
|
||
|
||
new_highlight = None
|
||
if element_ids:
|
||
new_highlight = []
|
||
for hid in element_ids:
|
||
if hid in type_highlight_ids:
|
||
mapped = type_id_to_occ_id.get(hid)
|
||
if mapped:
|
||
new_highlight.append(mapped)
|
||
else:
|
||
new_highlight.append(hid)
|
||
|
||
return _render_iterator(
|
||
iterator, new_highlight, visibility, clips, camera, fov, scale
|
||
)
|
||
finally:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def render(
|
||
model: ifcopenshell.file,
|
||
selector: str | None = None,
|
||
element_ids: list[int] | None = None,
|
||
camera: (
|
||
tuple[float, float, float, float, float, float, float, float, float] | None
|
||
) = None,
|
||
fov: float | None = None,
|
||
scale: float | None = None,
|
||
clips: list[tuple[float, float, float, float, float, float]] | None = None,
|
||
visibility: str = "highlight",
|
||
) -> bytes:
|
||
"""Render IFC model geometry to a PNG image.
|
||
|
||
:param model: The in-memory IFC model.
|
||
:param selector: IfcOpenShell selector to restrict which elements are
|
||
considered the "selection" for visibility purposes. When omitted the
|
||
whole model is rendered with no selection.
|
||
:param element_ids: Step IDs to treat as the selection (alternative to
|
||
*selector*; both may be combined).
|
||
:param camera: Nine floats (px,py,pz,dx,dy,dz,ux,uy,uz) defining the
|
||
camera in IFC world coordinates. When ``None`` the camera is fitted
|
||
to the scene automatically.
|
||
:param fov: Perspective field of view in degrees. Required when *camera*
|
||
is given and orthographic projection is not wanted.
|
||
:param scale: Orthographic view-to-world scale (BCF ViewToWorldScale).
|
||
Required when *camera* is given and perspective is not wanted.
|
||
:param clips: List of clipping planes, each ``(px,py,pz,nx,ny,nz)``.
|
||
The normal points toward the *visible* side.
|
||
:param visibility: One of ``'highlight'``, ``'ghost'``, or ``'isolate'``.
|
||
:return: PNG image as raw bytes.
|
||
:raises ImportError: If pyvista or numpy are not installed.
|
||
:raises ValueError: If the selector matches nothing or there is no
|
||
renderable geometry.
|
||
"""
|
||
if not _HAS_PYVISTA:
|
||
raise ImportError(
|
||
"pyvista and numpy are required for rendering. Install with: pip install pyvista numpy"
|
||
)
|
||
|
||
clips = clips or []
|
||
|
||
# --- Partition selector results into types and ordinary elements ---
|
||
if selector:
|
||
matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
|
||
if not matched:
|
||
raise ValueError(f"Selector {selector!r} matched no elements")
|
||
types = [e for e in matched if e.is_a("IfcTypeProduct")]
|
||
selector_elements: list | None = [
|
||
e for e in matched if not e.is_a("IfcTypeProduct")
|
||
]
|
||
else:
|
||
types = []
|
||
selector_elements = None
|
||
|
||
# --- Collect type entities from element_ids ---
|
||
type_highlight_ids: set[int] = set()
|
||
if element_ids:
|
||
for eid in element_ids:
|
||
entity = model.by_id(eid)
|
||
if entity.is_a("IfcTypeProduct"):
|
||
type_highlight_ids.add(eid)
|
||
if eid not in {t.id() for t in types}:
|
||
types.append(entity)
|
||
|
||
# Build the unified selection ID set used for visibility colouring.
|
||
selection_ids: list[int] | None = None
|
||
if selector_elements is not None or element_ids:
|
||
selection_ids = list(element_ids or [])
|
||
if selector_elements:
|
||
selection_ids.extend(e.id() for e in selector_elements)
|
||
|
||
# --- Delegate to temp-copy path when any type entities are involved ---
|
||
if types:
|
||
return _render_with_types(
|
||
model,
|
||
types,
|
||
selector_elements,
|
||
selection_ids,
|
||
type_highlight_ids,
|
||
visibility,
|
||
clips,
|
||
camera,
|
||
fov,
|
||
scale,
|
||
)
|
||
|
||
# --- Regular element rendering ---
|
||
settings = _build_geom_settings(model)
|
||
|
||
if selector_elements is not None and not selector_elements:
|
||
raise ValueError(f"Selector {selector!r} matched only type entities")
|
||
|
||
if selector_elements is not None and visibility == "isolate":
|
||
iterator = ifcopenshell.geom.iterator(
|
||
settings, model, _WORKER_COUNT, include=selector_elements
|
||
)
|
||
else:
|
||
exclude = list(model.by_type("IfcOpeningElement"))
|
||
iterator = ifcopenshell.geom.iterator(
|
||
settings,
|
||
model,
|
||
_WORKER_COUNT,
|
||
exclude=exclude if exclude else None,
|
||
)
|
||
|
||
if not iterator.initialize():
|
||
raise ValueError("No renderable geometry found")
|
||
|
||
return _render_iterator(
|
||
iterator, selection_ids, visibility, clips, camera, fov, scale
|
||
)
|
||
|
||
|
||
def _entity_bounds(model: ifcopenshell.file, entities: list) -> list[float] | None:
|
||
"""Return [xmin,xmax,ymin,ymax,zmin,zmax] for *entities* in world coords, or None."""
|
||
if not entities:
|
||
return None
|
||
products = [e for e in entities if e.is_a("IfcProduct")]
|
||
if not products:
|
||
return None
|
||
settings = _build_geom_settings(model)
|
||
it = ifcopenshell.geom.iterator(settings, model, _WORKER_COUNT, include=products)
|
||
if not it.initialize():
|
||
return None
|
||
all_verts: list[np.ndarray] = []
|
||
while True:
|
||
try:
|
||
v = np.array(it.get().geometry.verts, dtype=float).reshape(-1, 3)
|
||
if v.size > 0:
|
||
all_verts.append(v)
|
||
except Exception:
|
||
pass
|
||
if not it.next():
|
||
break
|
||
if not all_verts:
|
||
return None
|
||
v = np.vstack(all_verts)
|
||
return [
|
||
float(v[:, 0].min()),
|
||
float(v[:, 0].max()),
|
||
float(v[:, 1].min()),
|
||
float(v[:, 1].max()),
|
||
float(v[:, 2].min()),
|
||
float(v[:, 2].max()),
|
||
]
|
||
|
||
|
||
def _merge_bounds(a: list[float] | None, b: list[float] | None) -> list[float] | None:
|
||
"""Merge two [xmin,xmax,ymin,ymax,zmin,zmax] extents, either may be None."""
|
||
if a is None:
|
||
return b
|
||
if b is None:
|
||
return a
|
||
return [
|
||
min(a[0], b[0]),
|
||
max(a[1], b[1]),
|
||
min(a[2], b[2]),
|
||
max(a[3], b[3]),
|
||
min(a[4], b[4]),
|
||
max(a[5], b[5]),
|
||
]
|
||
|
||
|
||
def render_diff(
|
||
model_head: ifcopenshell.file,
|
||
model_base: ifcopenshell.file,
|
||
diff_ids: dict[str, set[int]],
|
||
camera: (
|
||
tuple[float, float, float, float, float, float, float, float, float] | None
|
||
) = None,
|
||
fov: float | None = None,
|
||
scale: float | None = None,
|
||
clips: list[tuple[float, float, float, float, float, float]] | None = None,
|
||
) -> bytes:
|
||
"""Render a two-pass IFC diff image.
|
||
|
||
Pass 1 renders the head (new) model with diff colouring:
|
||
green — added elements
|
||
blue — modified elements
|
||
ghost — unchanged context (very light grey, 8 % opacity)
|
||
|
||
Pass 2 renders only the removed elements from the base (old) model in red,
|
||
using the same camera as pass 1. The two images are then composited: any
|
||
pixel in pass 2 that is not the white background is stamped onto pass 1.
|
||
This means moved elements (classified as modified) appear once in blue at
|
||
their new position, with no ghosting artefact.
|
||
|
||
:param model_head: The newer IFC model (PR head commit).
|
||
:param model_base: The older IFC model (PR base commit).
|
||
:param diff_ids: ``{"added": set, "modified": set, "removed": set}`` of
|
||
step IDs, pre-expanded via :func:`ifcurl.diff.expand_step_ids`.
|
||
:param camera: Nine floats (px,py,pz,dx,dy,dz,ux,uy,uz). When ``None``
|
||
the camera is auto-fitted to the head model scene.
|
||
:param fov: Perspective FOV in degrees.
|
||
:param scale: Orthographic scale.
|
||
:param clips: Clipping planes.
|
||
:return: PNG image bytes.
|
||
:raises ImportError: If pyvista, numpy, or Pillow are not installed.
|
||
:raises ValueError: If the head model has no renderable geometry.
|
||
"""
|
||
if not _HAS_PYVISTA:
|
||
raise ImportError(
|
||
"pyvista and numpy are required for rendering. Install with: pip install pyvista numpy"
|
||
)
|
||
if not _HAS_PIL:
|
||
raise ImportError(
|
||
"Pillow is required for diff rendering. Install with: pip install Pillow"
|
||
)
|
||
|
||
clips = clips or []
|
||
|
||
frozen_diff = {k: frozenset(v) for k, v in diff_ids.items()}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Pre-compute removed entities (needed for bounds and pass 2)
|
||
# ------------------------------------------------------------------
|
||
removed_ids = diff_ids.get("removed", set())
|
||
removed_entities = []
|
||
for eid in removed_ids:
|
||
try:
|
||
e = model_base.by_id(eid)
|
||
if e.is_a("IfcProduct"):
|
||
removed_entities.append(e)
|
||
except Exception:
|
||
pass
|
||
|
||
# ------------------------------------------------------------------
|
||
# Pre-compute bounding box of all changed elements for camera fitting.
|
||
# added/modified bounds come from a subset iterator on the head model;
|
||
# removed bounds come from a subset iterator on the base model.
|
||
# Both are fast because they operate on small subsets.
|
||
# ------------------------------------------------------------------
|
||
if camera is None:
|
||
changed_head_ids = frozen_diff.get("added", frozenset()) | frozen_diff.get(
|
||
"modified", frozenset()
|
||
)
|
||
changed_head_entities = []
|
||
for eid in changed_head_ids:
|
||
try:
|
||
changed_head_entities.append(model_head.by_id(eid))
|
||
except Exception:
|
||
pass
|
||
diff_bounds = _merge_bounds(
|
||
_entity_bounds(model_head, changed_head_entities),
|
||
_entity_bounds(model_base, removed_entities),
|
||
)
|
||
else:
|
||
diff_bounds = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# Pass 1: head model — full scene with diff colouring
|
||
# ------------------------------------------------------------------
|
||
settings_head = _build_geom_settings(model_head)
|
||
exclude_head = list(model_head.by_type("IfcOpeningElement"))
|
||
iterator_head = ifcopenshell.geom.iterator(
|
||
settings_head,
|
||
model_head,
|
||
_WORKER_COUNT,
|
||
exclude=exclude_head if exclude_head else None,
|
||
)
|
||
if not iterator_head.initialize():
|
||
raise ValueError("Head model has no renderable geometry")
|
||
|
||
plotter1 = _Plotter(off_screen=True, window_size=(1280, 960))
|
||
plotter1.background_color = "white"
|
||
|
||
while True:
|
||
try:
|
||
_add_shape(
|
||
iterator_head.get(),
|
||
plotter1,
|
||
None,
|
||
"highlight",
|
||
clips,
|
||
diff_ids=frozen_diff,
|
||
)
|
||
except Exception:
|
||
pass
|
||
if not iterator_head.next():
|
||
break
|
||
|
||
if camera is not None:
|
||
_apply_camera(plotter1, camera, fov, scale)
|
||
elif diff_bounds is not None:
|
||
# Reset first to establish a good viewing direction, then reposition
|
||
# close to the diff area. reset_camera(bounds=...) re-expands the
|
||
# clipping range to include all actors, so it doesn't actually zoom;
|
||
# explicit positioning is required.
|
||
plotter1.reset_camera()
|
||
plotter1.camera.up = (0, 0, 1)
|
||
xmin, xmax, ymin, ymax, zmin, zmax = diff_bounds
|
||
cx, cy, cz = (xmin + xmax) / 2, (ymin + ymax) / 2, (zmin + zmax) / 2
|
||
max_dim = max(xmax - xmin, ymax - ymin, zmax - zmin, 1e-3)
|
||
cam_pos = np.array(plotter1.camera.position)
|
||
focal = np.array(plotter1.camera.focal_point)
|
||
direction = cam_pos - focal
|
||
direction /= np.linalg.norm(direction)
|
||
fov_rad = np.radians(plotter1.camera.view_angle)
|
||
dist = (max_dim / 2) / np.tan(fov_rad / 2) * 1.5
|
||
plotter1.camera.focal_point = (cx, cy, cz)
|
||
plotter1.camera.position = tuple(np.array([cx, cy, cz]) + direction * dist)
|
||
if scale is not None:
|
||
plotter1.camera.parallel_projection = True
|
||
plotter1.camera.parallel_scale = scale
|
||
else:
|
||
plotter1.reset_camera()
|
||
plotter1.camera.up = (0, 0, 1)
|
||
if scale is not None:
|
||
plotter1.camera.parallel_projection = True
|
||
plotter1.camera.parallel_scale = scale
|
||
|
||
# Capture camera state BEFORE closing plotter1 so pass 2 uses the same view.
|
||
_cam1_pos = tuple(plotter1.camera.position)
|
||
_cam1_focal = tuple(plotter1.camera.focal_point)
|
||
_cam1_up = tuple(plotter1.camera.up)
|
||
_cam1_angle = float(plotter1.camera.view_angle)
|
||
_cam1_parallel = bool(plotter1.camera.parallel_projection)
|
||
_cam1_pscale = float(plotter1.camera.parallel_scale)
|
||
|
||
pass1_png = _plotter_screenshot(plotter1)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Pass 2: base model — removed elements only, in red.
|
||
# Apply the same camera that pass 1 used.
|
||
# ------------------------------------------------------------------
|
||
if not removed_entities:
|
||
return pass1_png
|
||
|
||
settings_base = _build_geom_settings(model_base)
|
||
iterator_base = ifcopenshell.geom.iterator(
|
||
settings_base, model_base, _WORKER_COUNT, include=removed_entities
|
||
)
|
||
if not iterator_base.initialize():
|
||
return pass1_png
|
||
|
||
removed_diff = {
|
||
"added": frozenset(),
|
||
"modified": frozenset(),
|
||
"removed": frozenset(removed_ids),
|
||
}
|
||
|
||
plotter2 = _Plotter(off_screen=True, window_size=(1280, 960))
|
||
plotter2.background_color = "white"
|
||
|
||
while True:
|
||
try:
|
||
_add_shape(
|
||
iterator_base.get(),
|
||
plotter2,
|
||
None,
|
||
"highlight",
|
||
clips,
|
||
diff_ids=removed_diff,
|
||
)
|
||
except Exception:
|
||
pass
|
||
if not iterator_base.next():
|
||
break
|
||
|
||
plotter2.camera.position = _cam1_pos
|
||
plotter2.camera.focal_point = _cam1_focal
|
||
plotter2.camera.up = _cam1_up
|
||
plotter2.camera.view_angle = _cam1_angle
|
||
plotter2.camera.parallel_projection = _cam1_parallel
|
||
plotter2.camera.parallel_scale = _cam1_pscale
|
||
|
||
pass2_png = _plotter_screenshot(plotter2)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Composite: stamp pass 2 non-background pixels onto pass 1
|
||
# ------------------------------------------------------------------
|
||
return _composite_diff(pass1_png, pass2_png)
|
||
|
||
|
||
def _composite_diff(pass1_png: bytes, pass2_png: bytes) -> bytes:
|
||
"""Overlay pass 2 onto pass 1 wherever pass 2 is not white background."""
|
||
img1 = np.array(_PILImage.open(_io.BytesIO(pass1_png)).convert("RGB"))
|
||
img2 = np.array(_PILImage.open(_io.BytesIO(pass2_png)).convert("RGB"))
|
||
|
||
mask = np.any(img2 != 255, axis=2)
|
||
result = img1.copy()
|
||
result[mask] = img2[mask]
|
||
|
||
buf = _io.BytesIO()
|
||
_PILImage.fromarray(result).save(buf, format="PNG")
|
||
return buf.getvalue()
|