ifcurl/ifcurl/render.py
Bruno Postle 837a9d10c8 render: fix render_diff — tuple unpack, IfcProduct filter, diff-area zoom
- 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>
2026-04-25 23:54:35 +01:00

827 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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()