mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
diff rendering: two-pass IFC diff with green/blue/red colouring
Implements the ifcgit/Bonsai diff algorithm for the preview service: diff.py: step_ids_from_diff() parses git diff text (regex on +#NNN= / -#NNN= lines) to classify step IDs as added/modified/removed. expand_step_ids() walks IfcShapeRepresentation, IfcObjectPlacement, IfcPropertySet, and IfcTypeProduct relationships to promote changed sub-entities to the parent IfcProduct that is visually affected. render.py: render_diff() does two passes over the same camera. Pass 1 renders the head model with green=added, blue=modified, ghost=unchanged. Pass 2 renders only the removed elements from the base model in red, using the camera auto-fitted to pass 1. PIL composites the passes: wherever pass 2 is not white background it stamps onto pass 1. Moved elements (modified) appear once in blue with no doubling artefact. git.py: diff_text() runs git diff against the cached bare repo clone and returns the raw text for diff.py to parse. service.py: GET+POST /render_diff endpoint. Fetches both commits, gets diff text, runs the full pipeline sandboxed. Caches on disk when both refs are immutable. Pillow added to render/service extras. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
275e74cce8
commit
96aa1a2377
6 changed files with 670 additions and 17 deletions
131
ifcurl/diff.py
Normal file
131
ifcurl/diff.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
"""IFC diff utilities: extract changed step IDs from a git diff and expand
|
||||||
|
them to the IfcProduct entities that are visually affected.
|
||||||
|
|
||||||
|
Algorithm adapted from Bonsai / ifcgit (IfcOpenShell project, GPL-3.0).
|
||||||
|
|
||||||
|
Step ID extraction
|
||||||
|
------------------
|
||||||
|
IFC files are stored in STEP format where every entity has a stable numeric
|
||||||
|
ID: ``#123 = IfcWall(...)``. A plain text diff of two versions of the same
|
||||||
|
file therefore shows which IDs were added (``+#NNN=``) and which were deleted
|
||||||
|
(``-#NNN=``). An ID appearing in both sets means the entity was *modified*
|
||||||
|
in-place; IDs only in the added set are truly *new*; IDs only in the deleted
|
||||||
|
set were *removed*.
|
||||||
|
|
||||||
|
Entity expansion
|
||||||
|
----------------
|
||||||
|
Many IFC entities (IfcShapeRepresentation, IfcObjectPlacement, IfcPropertySet,
|
||||||
|
…) don't have their own visible geometry — they're owned by an IfcProduct.
|
||||||
|
When such an entity changes, we walk up the IFC graph to find all IfcProducts
|
||||||
|
that are visually affected, and promote them to *modified*. This ensures that
|
||||||
|
a wall whose shape was edited is coloured blue even though the IfcWall entity's
|
||||||
|
own step ID didn't change in the diff.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import ifcopenshell
|
||||||
|
|
||||||
|
DiffIds = dict[str, set[int]]
|
||||||
|
|
||||||
|
|
||||||
|
def step_ids_from_diff(diff_text: str) -> DiffIds:
|
||||||
|
"""Parse a git diff of an IFC file and return classified step ID sets.
|
||||||
|
|
||||||
|
:param diff_text: Output of ``git diff hash_a hash_b path/to/file.ifc``.
|
||||||
|
:returns: ``{"added": set, "modified": set, "removed": set}`` of integer
|
||||||
|
step IDs. *modified* IDs appear in both the inserted and deleted
|
||||||
|
halves of the diff.
|
||||||
|
"""
|
||||||
|
inserted: set[int] = set()
|
||||||
|
deleted: set[int] = set()
|
||||||
|
for line in diff_text.splitlines():
|
||||||
|
m = re.match(r"^\+#(\d+)=", line)
|
||||||
|
if m:
|
||||||
|
inserted.add(int(m.group(1)))
|
||||||
|
continue
|
||||||
|
m = re.match(r"^-#(\d+)=", line)
|
||||||
|
if m:
|
||||||
|
deleted.add(int(m.group(1)))
|
||||||
|
modified = inserted & deleted
|
||||||
|
return {
|
||||||
|
"added": inserted - modified,
|
||||||
|
"modified": modified,
|
||||||
|
"removed": deleted - modified,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_step_ids(model: "ifcopenshell.file", step_ids: DiffIds) -> DiffIds:
|
||||||
|
"""Propagate changed step IDs to the IfcProduct entities that own them.
|
||||||
|
|
||||||
|
:param model: The *head* (newer) model, used to walk IFC relationships.
|
||||||
|
:param step_ids: Raw diff IDs from :func:`step_ids_from_diff`.
|
||||||
|
:returns: A new dict with the same *added* and *removed* sets, but with
|
||||||
|
*modified* augmented by any IfcProducts whose dependent entities changed.
|
||||||
|
"""
|
||||||
|
extra_modified: set[int] = set()
|
||||||
|
|
||||||
|
def collect(entity: object, depth: int = 0) -> None:
|
||||||
|
if depth > 2:
|
||||||
|
return
|
||||||
|
if entity.is_a("IfcProduct"):
|
||||||
|
extra_modified.add(entity.id())
|
||||||
|
elif entity.is_a("IfcProductDefinitionShape"):
|
||||||
|
for product in entity.ShapeOfProduct:
|
||||||
|
extra_modified.add(product.id())
|
||||||
|
elif entity.is_a("IfcObjectPlacement"):
|
||||||
|
for product in entity.PlacesObject:
|
||||||
|
extra_modified.add(product.id())
|
||||||
|
elif entity.is_a("IfcTypeProduct"):
|
||||||
|
for rel in entity.Types:
|
||||||
|
for obj in rel.RelatedObjects:
|
||||||
|
extra_modified.add(obj.id())
|
||||||
|
elif entity.is_a("IfcShapeRepresentation"):
|
||||||
|
for prod_rep in entity.OfProductRepresentation:
|
||||||
|
for product in prod_rep.ShapeOfProduct:
|
||||||
|
extra_modified.add(product.id())
|
||||||
|
elif entity.is_a("IfcRepresentationItem"):
|
||||||
|
for ref in model.get_inverse(entity):
|
||||||
|
if ref.is_a("IfcShapeRepresentation"):
|
||||||
|
collect(ref, depth + 1)
|
||||||
|
elif entity.is_a("IfcPropertySet"):
|
||||||
|
for rel in entity.DefinesOccurrence:
|
||||||
|
for obj in rel.RelatedObjects:
|
||||||
|
extra_modified.add(obj.id())
|
||||||
|
elif entity.is_a("IfcProperty"):
|
||||||
|
for pset in entity.PartOfPset:
|
||||||
|
collect(pset, depth + 1)
|
||||||
|
|
||||||
|
for step_id in step_ids["modified"] | step_ids["added"]:
|
||||||
|
try:
|
||||||
|
collect(model.by_id(step_id))
|
||||||
|
except Exception:
|
||||||
|
pass # entity absent from this model version
|
||||||
|
|
||||||
|
return {
|
||||||
|
"added": step_ids["added"],
|
||||||
|
"modified": (step_ids["modified"] | extra_modified) - step_ids["added"],
|
||||||
|
"removed": step_ids["removed"],
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,43 @@ def fetch_ifc(ifc_url: IfcUrl, token: str | None = None) -> tuple[str, bytes]:
|
||||||
return _read_commit_blob(repo, ifc_url.git_ref(), ifc_url.path)
|
return _read_commit_blob(repo, ifc_url.git_ref(), ifc_url.path)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_text(base_url: IfcUrl, head_url: IfcUrl, token: str | None = None) -> str:
|
||||||
|
"""Return the raw ``git diff`` text between *base_url* and *head_url*.
|
||||||
|
|
||||||
|
Both URLs must refer to the same IFC file path in the same repository.
|
||||||
|
The diff is produced against the cached bare clone, so no additional
|
||||||
|
network fetch is performed beyond what :func:`fetch_ifc` already did.
|
||||||
|
|
||||||
|
:param base_url: The older (base) ref — typically the PR base commit.
|
||||||
|
:param head_url: The newer (head) ref — typically the PR head commit.
|
||||||
|
:param token: Optional HTTPS token for authentication.
|
||||||
|
:raises ImportError: If GitPython is not installed.
|
||||||
|
:raises ValueError: If the paths differ, a ref is not found, or the diff
|
||||||
|
cannot be produced.
|
||||||
|
"""
|
||||||
|
if not _HAS_GITPYTHON:
|
||||||
|
raise ImportError("GitPython is not installed. Install with: pip install gitpython")
|
||||||
|
if base_url.path is None or head_url.path is None:
|
||||||
|
raise ValueError("Both URLs must have a 'path' parameter")
|
||||||
|
if base_url.path != head_url.path:
|
||||||
|
raise ValueError(
|
||||||
|
f"base and head URLs must refer to the same IFC path "
|
||||||
|
f"(got {base_url.path!r} vs {head_url.path!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
repo = _get_repo(base_url, token=token)
|
||||||
|
try:
|
||||||
|
base_hexsha = repo.commit(base_url.git_ref()).hexsha
|
||||||
|
head_hexsha = repo.commit(head_url.git_ref()).hexsha
|
||||||
|
except (git.exc.BadName, git.exc.BadObject) as exc:
|
||||||
|
raise ValueError(f"Ref not found: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
return repo.git.diff(base_hexsha, head_hexsha, "--", base_url.path)
|
||||||
|
except git.exc.GitCommandError as exc:
|
||||||
|
raise ValueError(f"git diff failed: {exc.stderr.strip()}") from exc
|
||||||
|
|
||||||
|
|
||||||
def fetch_ifc_bytes(ifc_url: IfcUrl, token: str | None = None) -> bytes:
|
def fetch_ifc_bytes(ifc_url: IfcUrl, token: str | None = None) -> bytes:
|
||||||
"""Return the raw bytes of the IFC file addressed by *ifc_url*.
|
"""Return the raw bytes of the IFC file addressed by *ifc_url*.
|
||||||
|
|
||||||
|
|
|
||||||
219
ifcurl/render.py
219
ifcurl/render.py
|
|
@ -57,9 +57,23 @@ try:
|
||||||
except (ImportError, AttributeError):
|
except (ImportError, AttributeError):
|
||||||
_HAS_PYVISTA = False
|
_HAS_PYVISTA = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
import io as _io
|
||||||
|
_HAS_PIL = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_PIL = False
|
||||||
|
|
||||||
# Highlight colour used in 'highlight' visibility mode (orange).
|
# Highlight colour used in 'highlight' visibility mode (orange).
|
||||||
_HIGHLIGHT_COLOR = (255, 140, 0)
|
_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
|
# Camera
|
||||||
|
|
@ -130,13 +144,20 @@ def _add_shape(
|
||||||
selection_ids: frozenset[int] | None,
|
selection_ids: frozenset[int] | None,
|
||||||
visibility: str,
|
visibility: str,
|
||||||
clips: list[tuple[float, ...]],
|
clips: list[tuple[float, ...]],
|
||||||
|
diff_ids: dict[str, frozenset[int]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Triangulate a geometry shape, apply clips, and add it to the plotter.
|
"""Triangulate a geometry shape, apply clips, and add it to the plotter.
|
||||||
|
|
||||||
Visibility modes:
|
Visibility modes (when *diff_ids* is None):
|
||||||
highlight — non-selected shown normally, selected shown in highlight colour
|
highlight — non-selected shown normally, selected shown in highlight colour
|
||||||
ghost — selected shown normally, non-selected shown as grey + translucent
|
ghost — selected shown normally, non-selected shown as grey + translucent
|
||||||
isolate — only selected elements are added (non-selected are skipped)
|
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
|
geom = shape.geometry
|
||||||
verts = np.array(geom.verts, dtype=float).reshape(-1, 3)
|
verts = np.array(geom.verts, dtype=float).reshape(-1, 3)
|
||||||
|
|
@ -152,7 +173,7 @@ def _add_shape(
|
||||||
|
|
||||||
is_selected = selection_ids is not None and shape.product.id() in selection_ids
|
is_selected = selection_ids is not None and shape.product.id() in selection_ids
|
||||||
|
|
||||||
if visibility == "isolate" and selection_ids is not None and not is_selected:
|
if diff_ids is None and visibility == "isolate" and selection_ids is not None and not is_selected:
|
||||||
return
|
return
|
||||||
|
|
||||||
for midx, mat in enumerate(geom.materials):
|
for midx, mat in enumerate(geom.materials):
|
||||||
|
|
@ -173,7 +194,17 @@ def _add_shape(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Determine colour and opacity
|
# Determine colour and opacity
|
||||||
if selection_ids is None:
|
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)
|
color, opacity = _material_color_and_opacity(mat)
|
||||||
elif visibility == "highlight":
|
elif visibility == "highlight":
|
||||||
if is_selected:
|
if is_selected:
|
||||||
|
|
@ -196,6 +227,21 @@ def _add_shape(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
def _render_iterator(
|
||||||
iterator: object,
|
iterator: object,
|
||||||
selection_ids: list[int] | None,
|
selection_ids: list[int] | None,
|
||||||
|
|
@ -204,6 +250,7 @@ def _render_iterator(
|
||||||
camera: tuple[float, ...] | None,
|
camera: tuple[float, ...] | None,
|
||||||
fov: float | None,
|
fov: float | None,
|
||||||
scale: float | None,
|
scale: float | None,
|
||||||
|
diff_ids: dict[str, frozenset[int]] | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Drive a geometry iterator into a pyvista plotter and return PNG bytes."""
|
"""Drive a geometry iterator into a pyvista plotter and return PNG bytes."""
|
||||||
plotter = _Plotter(off_screen=True, window_size=(1280, 960))
|
plotter = _Plotter(off_screen=True, window_size=(1280, 960))
|
||||||
|
|
@ -213,7 +260,7 @@ def _render_iterator(
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
_add_shape(iterator.get(), plotter, frozen_ids, visibility, clips)
|
_add_shape(iterator.get(), plotter, frozen_ids, visibility, clips, diff_ids=diff_ids)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # skip broken shapes, keep rendering the rest
|
pass # skip broken shapes, keep rendering the rest
|
||||||
if not iterator.next():
|
if not iterator.next():
|
||||||
|
|
@ -228,17 +275,7 @@ def _render_iterator(
|
||||||
plotter.camera.parallel_projection = True
|
plotter.camera.parallel_projection = True
|
||||||
plotter.camera.parallel_scale = scale
|
plotter.camera.parallel_scale = scale
|
||||||
|
|
||||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png")
|
return _plotter_screenshot(plotter)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -457,3 +494,155 @@ def render(
|
||||||
raise ValueError("No renderable geometry found")
|
raise ValueError("No renderable geometry found")
|
||||||
|
|
||||||
return _render_iterator(iterator, selection_ids, visibility, clips, camera, fov, scale)
|
return _render_iterator(iterator, selection_ids, visibility, clips, camera, fov, scale)
|
||||||
|
|
||||||
|
|
||||||
|
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()}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 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)
|
||||||
|
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.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
removed_ids = diff_ids.get("removed", set())
|
||||||
|
if not removed_ids:
|
||||||
|
return pass1_png
|
||||||
|
|
||||||
|
removed_entities = []
|
||||||
|
for eid in removed_ids:
|
||||||
|
try:
|
||||||
|
removed_entities.append(model_base.by_id(eid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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()
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ 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 import build_bcf
|
||||||
|
from ifcurl.git import diff_text as git_diff_text
|
||||||
from ifcurl.git import fetch_ifc
|
from ifcurl.git import fetch_ifc
|
||||||
from ifcurl.sandbox import SandboxCrashError, SandboxTimeoutError, run_sandboxed
|
from ifcurl.sandbox import SandboxCrashError, SandboxTimeoutError, run_sandboxed
|
||||||
from ifcurl.url import IfcUrl
|
from ifcurl.url import IfcUrl
|
||||||
|
|
@ -122,6 +123,37 @@ def _sandboxed_select(ifc_bytes: bytes, selector: str) -> list[str]:
|
||||||
return [e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId]
|
return [e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId]
|
||||||
|
|
||||||
|
|
||||||
|
def _sandboxed_diff(
|
||||||
|
head_bytes: bytes,
|
||||||
|
base_bytes: bytes,
|
||||||
|
raw_diff_text: str,
|
||||||
|
camera: tuple | None,
|
||||||
|
fov: float | None,
|
||||||
|
scale: float | None,
|
||||||
|
clips: list,
|
||||||
|
) -> bytes:
|
||||||
|
"""Parse both models, expand diff IDs, and render the two-pass diff image."""
|
||||||
|
import ifcopenshell
|
||||||
|
from ifcurl import render as render_mod
|
||||||
|
from ifcurl.diff import expand_step_ids, step_ids_from_diff
|
||||||
|
|
||||||
|
model_head = ifcopenshell.file.from_string(head_bytes.decode())
|
||||||
|
model_base = ifcopenshell.file.from_string(base_bytes.decode())
|
||||||
|
|
||||||
|
raw_ids = step_ids_from_diff(raw_diff_text)
|
||||||
|
diff_ids = expand_step_ids(model_head, raw_ids)
|
||||||
|
|
||||||
|
return render_mod.render_diff(
|
||||||
|
model_head=model_head,
|
||||||
|
model_base=model_base,
|
||||||
|
diff_ids=diff_ids,
|
||||||
|
camera=camera,
|
||||||
|
fov=fov,
|
||||||
|
scale=scale,
|
||||||
|
clips=clips or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="ifcurl preview service",
|
title="ifcurl preview service",
|
||||||
description="Renders ifc:// URLs to PNG images for embedding in Gitea and other consumers.",
|
description="Renders ifc:// URLs to PNG images for embedding in Gitea and other consumers.",
|
||||||
|
|
@ -294,6 +326,15 @@ class BcfRequest(BaseModel):
|
||||||
token: str | None = None
|
token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiffRequest(BaseModel):
|
||||||
|
base: str
|
||||||
|
"""ifc:// URL for the base (older) commit."""
|
||||||
|
head: str
|
||||||
|
"""ifc:// URL for the head (newer) commit."""
|
||||||
|
token: str | None = None
|
||||||
|
"""Optional bearer token for git authentication."""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -484,3 +525,126 @@ def bcf_export(request: BcfRequest) -> Response:
|
||||||
media_type="application/octet-stream",
|
media_type="application/octet-stream",
|
||||||
headers={"Content-Disposition": 'attachment; filename="view.bcf"'},
|
headers={"Content-Disposition": 'attachment; filename="view.bcf"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/render_diff")
|
||||||
|
def render_diff_get(base: str, head: str, token: str | None = None) -> Response:
|
||||||
|
"""GET variant of POST /render_diff for use in HTML ``<img src>`` tags.
|
||||||
|
|
||||||
|
*base* and *head* are ifc:// URLs (URL-encoded) for the base and head
|
||||||
|
commits of the same IFC file. The optional *token* is a bearer token
|
||||||
|
for private-repository access.
|
||||||
|
"""
|
||||||
|
return render_diff(DiffRequest(base=base, head=head, token=token))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/render_diff")
|
||||||
|
def render_diff(request: DiffRequest) -> Response:
|
||||||
|
"""Render a two-pass IFC diff image (green=added, blue=modified, red=removed).
|
||||||
|
|
||||||
|
Both URLs must refer to the same IFC file path in the same repository.
|
||||||
|
The result is cached on disk when both refs are immutable (commit hashes).
|
||||||
|
Returns ``image/png``.
|
||||||
|
"""
|
||||||
|
# --- Parse ---
|
||||||
|
try:
|
||||||
|
base_url = IfcUrl.parse(request.base)
|
||||||
|
head_url = IfcUrl.parse(request.head)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
for label, ifc_url in (("base", base_url), ("head", head_url)):
|
||||||
|
if ifc_url.path is None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{label} URL has no 'path' parameter")
|
||||||
|
|
||||||
|
if base_url.path != head_url.path:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"base and head must refer to the same IFC path ({base_url.path!r} vs {head_url.path!r})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- SSRF protection ---
|
||||||
|
_ssrf_check(base_url)
|
||||||
|
_ssrf_check(head_url)
|
||||||
|
|
||||||
|
# --- Tier 4: cached diff PNG (immutable refs only) ---
|
||||||
|
both_immutable = not base_url.is_mutable_ref() and not head_url.is_mutable_ref()
|
||||||
|
cache_key = f"diff:{request.base}:{request.head}"
|
||||||
|
if both_immutable:
|
||||||
|
cached_png = _t4_get(cache_key)
|
||||||
|
if cached_png is not None:
|
||||||
|
return Response(
|
||||||
|
content=cached_png,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Resolve authentication token ---
|
||||||
|
token = request.token
|
||||||
|
if token is None and base_url.host:
|
||||||
|
token = get_token_for_host(base_url.host)
|
||||||
|
|
||||||
|
# --- Fetch IFC bytes for both commits ---
|
||||||
|
try:
|
||||||
|
base_hexsha, base_bytes = fetch_ifc(base_url, token=token)
|
||||||
|
head_hexsha, head_bytes = fetch_ifc(head_url, token=token)
|
||||||
|
except ImportError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
# --- Tier 2: populate byte cache ---
|
||||||
|
for hexsha, path, ifc_bytes in (
|
||||||
|
(base_hexsha, base_url.path, base_bytes),
|
||||||
|
(head_hexsha, head_url.path, head_bytes),
|
||||||
|
):
|
||||||
|
cached = _t2_get(hexsha, path)
|
||||||
|
if cached is not None:
|
||||||
|
if hexsha == base_hexsha:
|
||||||
|
base_bytes = cached
|
||||||
|
else:
|
||||||
|
head_bytes = cached
|
||||||
|
else:
|
||||||
|
_t2_put(hexsha, path, ifc_bytes)
|
||||||
|
|
||||||
|
# --- Get git diff text ---
|
||||||
|
try:
|
||||||
|
raw_diff = git_diff_text(base_url, head_url, token=token)
|
||||||
|
except (ImportError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Could not diff: {exc}") from exc
|
||||||
|
|
||||||
|
# --- Sandboxed parse + expand + render ---
|
||||||
|
try:
|
||||||
|
png_bytes = run_sandboxed(
|
||||||
|
_sandboxed_diff,
|
||||||
|
head_bytes,
|
||||||
|
base_bytes,
|
||||||
|
raw_diff,
|
||||||
|
head_url.camera,
|
||||||
|
head_url.fov,
|
||||||
|
head_url.scale,
|
||||||
|
head_url.clips or [],
|
||||||
|
)
|
||||||
|
except SandboxCrashError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=f"IFC diff render crashed: {exc}") from exc
|
||||||
|
except SandboxTimeoutError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Diff render timed out: {exc}") from exc
|
||||||
|
except ImportError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
# --- Tier 4: cache and return ---
|
||||||
|
if both_immutable:
|
||||||
|
_t4_put(cache_key, png_bytes)
|
||||||
|
return Response(
|
||||||
|
content=png_bytes,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=png_bytes,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ classifiers = [
|
||||||
dependencies = ["ifcopenshell", "gitpython", "platformdirs"]
|
dependencies = ["ifcopenshell", "gitpython", "platformdirs"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
render = ["pyvista", "numpy"]
|
render = ["pyvista", "numpy", "Pillow"]
|
||||||
service = ["pyvista", "numpy", "fastapi", "uvicorn[standard]", "bcf"]
|
service = ["pyvista", "numpy", "Pillow", "fastapi", "uvicorn[standard]", "bcf"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ifcurl = "ifcurl.__main__:main"
|
ifcurl = "ifcurl.__main__:main"
|
||||||
|
|
|
||||||
132
tests/test_diff.py
Normal file
132
tests/test_diff.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""Tests for ifcurl.diff — step ID extraction and entity expansion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ifcurl.diff import expand_step_ids, step_ids_from_diff
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# step_ids_from_diff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SAMPLE_DIFF = """\
|
||||||
|
diff --git a/model.ifc b/model.ifc
|
||||||
|
--- a/model.ifc
|
||||||
|
+++ b/model.ifc
|
||||||
|
@@ -1,5 +1,5 @@
|
||||||
|
#1=IFCPROJECT('abc');
|
||||||
|
-#10=IFCWALL('old_wall');
|
||||||
|
-#11=IFCWALLSTANDARDCASE('deleted_wall');
|
||||||
|
+#10=IFCWALL('new_wall');
|
||||||
|
+#99=IFCWALLSTANDARDCASE('added_wall');
|
||||||
|
#20=IFCSLAB('slab');
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestStepIdsFromDiff:
|
||||||
|
def test_modified_id_in_both_sets(self):
|
||||||
|
ids = step_ids_from_diff(SAMPLE_DIFF)
|
||||||
|
assert 10 in ids["modified"]
|
||||||
|
|
||||||
|
def test_added_id_only_in_inserted(self):
|
||||||
|
ids = step_ids_from_diff(SAMPLE_DIFF)
|
||||||
|
assert 99 in ids["added"]
|
||||||
|
|
||||||
|
def test_removed_id_only_in_deleted(self):
|
||||||
|
ids = step_ids_from_diff(SAMPLE_DIFF)
|
||||||
|
assert 11 in ids["removed"]
|
||||||
|
|
||||||
|
def test_unchanged_id_absent(self):
|
||||||
|
ids = step_ids_from_diff(SAMPLE_DIFF)
|
||||||
|
assert 1 not in ids["added"]
|
||||||
|
assert 1 not in ids["modified"]
|
||||||
|
assert 1 not in ids["removed"]
|
||||||
|
assert 20 not in ids["added"]
|
||||||
|
assert 20 not in ids["modified"]
|
||||||
|
assert 20 not in ids["removed"]
|
||||||
|
|
||||||
|
def test_modified_not_in_added_or_removed(self):
|
||||||
|
ids = step_ids_from_diff(SAMPLE_DIFF)
|
||||||
|
assert 10 not in ids["added"]
|
||||||
|
assert 10 not in ids["removed"]
|
||||||
|
|
||||||
|
def test_empty_diff(self):
|
||||||
|
ids = step_ids_from_diff("")
|
||||||
|
assert ids == {"added": set(), "modified": set(), "removed": set()}
|
||||||
|
|
||||||
|
def test_context_lines_ignored(self):
|
||||||
|
diff = " #5=IFCSITE('site');\n"
|
||||||
|
ids = step_ids_from_diff(diff)
|
||||||
|
assert 5 not in ids["added"] and 5 not in ids["removed"] and 5 not in ids["modified"]
|
||||||
|
|
||||||
|
def test_git_header_lines_ignored(self):
|
||||||
|
diff = "--- a/model.ifc\n+++ b/model.ifc\n"
|
||||||
|
ids = step_ids_from_diff(diff)
|
||||||
|
assert ids == {"added": set(), "modified": set(), "removed": set()}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# expand_step_ids — requires a real ifcopenshell model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandStepIds:
|
||||||
|
def test_product_promoted_to_modified(self, model_with_geometry):
|
||||||
|
"""An IfcProduct step ID in 'modified' stays in modified after expansion."""
|
||||||
|
# Find a real product step ID in the model
|
||||||
|
products = list(model_with_geometry.by_type("IfcProduct"))
|
||||||
|
assert products, "fixture must contain at least one IfcProduct"
|
||||||
|
pid = products[0].id()
|
||||||
|
|
||||||
|
raw = {"added": set(), "modified": {pid}, "removed": set()}
|
||||||
|
expanded = expand_step_ids(model_with_geometry, raw)
|
||||||
|
|
||||||
|
assert pid in expanded["modified"]
|
||||||
|
|
||||||
|
def test_added_ids_not_moved_to_modified(self, model_with_geometry):
|
||||||
|
"""IDs in 'added' must not be demoted into 'modified' by expansion."""
|
||||||
|
products = list(model_with_geometry.by_type("IfcProduct"))
|
||||||
|
assert products
|
||||||
|
pid = products[0].id()
|
||||||
|
|
||||||
|
raw = {"added": {pid}, "modified": set(), "removed": set()}
|
||||||
|
expanded = expand_step_ids(model_with_geometry, raw)
|
||||||
|
|
||||||
|
assert pid in expanded["added"]
|
||||||
|
assert pid not in expanded["modified"]
|
||||||
|
|
||||||
|
def test_removed_ids_unchanged(self, model_with_geometry):
|
||||||
|
"""The 'removed' set is never altered by expansion."""
|
||||||
|
raw = {"added": set(), "modified": set(), "removed": {9999}}
|
||||||
|
expanded = expand_step_ids(model_with_geometry, raw)
|
||||||
|
assert expanded["removed"] == {9999}
|
||||||
|
|
||||||
|
def test_nonexistent_id_silently_skipped(self, model_with_geometry):
|
||||||
|
"""Step IDs absent from the model don't cause errors; they pass through unchanged."""
|
||||||
|
raw = {"added": set(), "modified": {99999999}, "removed": set()}
|
||||||
|
expanded = expand_step_ids(model_with_geometry, raw)
|
||||||
|
# The ID passes through unmodified — expand adds no extra products for it
|
||||||
|
assert expanded["modified"] == {99999999}
|
||||||
|
|
||||||
|
def test_shape_representation_expands_to_product(self, model_with_geometry):
|
||||||
|
"""A changed IfcShapeRepresentation expands to its parent IfcProduct."""
|
||||||
|
reps = list(model_with_geometry.by_type("IfcShapeRepresentation"))
|
||||||
|
if not reps:
|
||||||
|
pytest.skip("no IfcShapeRepresentation in fixture")
|
||||||
|
|
||||||
|
rep = reps[0]
|
||||||
|
products_via_rep = set()
|
||||||
|
for prod_rep in rep.OfProductRepresentation:
|
||||||
|
for product in prod_rep.ShapeOfProduct:
|
||||||
|
products_via_rep.add(product.id())
|
||||||
|
|
||||||
|
if not products_via_rep:
|
||||||
|
pytest.skip("representation not linked to any product in fixture")
|
||||||
|
|
||||||
|
raw = {"added": set(), "modified": {rep.id()}, "removed": set()}
|
||||||
|
expanded = expand_step_ids(model_with_geometry, raw)
|
||||||
|
|
||||||
|
for pid in products_via_rep:
|
||||||
|
assert pid in expanded["modified"]
|
||||||
Loading…
Add table
Reference in a new issue