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:
Bruno Postle 2026-04-25 06:52:01 +01:00
parent 275e74cce8
commit 96aa1a2377
6 changed files with 670 additions and 17 deletions

131
ifcurl/diff.py Normal file
View 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"],
}

View file

@ -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)
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:
"""Return the raw bytes of the IFC file addressed by *ifc_url*.

View file

@ -57,9 +57,23 @@ try:
except (ImportError, AttributeError):
_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_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
@ -130,13 +144,20 @@ def _add_shape(
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:
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)
@ -152,7 +173,7 @@ def _add_shape(
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
for midx, mat in enumerate(geom.materials):
@ -173,7 +194,17 @@ def _add_shape(
continue
# 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)
elif visibility == "highlight":
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(
iterator: object,
selection_ids: list[int] | None,
@ -204,6 +250,7 @@ def _render_iterator(
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))
@ -213,7 +260,7 @@ def _render_iterator(
while True:
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:
pass # skip broken shapes, keep rendering the rest
if not iterator.next():
@ -228,17 +275,7 @@ def _render_iterator(
plotter.camera.parallel_projection = True
plotter.camera.parallel_scale = scale
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
return _plotter_screenshot(plotter)
# ---------------------------------------------------------------------------
@ -457,3 +494,155 @@ def render(
raise ValueError("No renderable geometry found")
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()

View file

@ -51,6 +51,7 @@ from pydantic import BaseModel
from ifcurl.auth import get_token_for_host
from ifcurl.bcf import build_bcf
from ifcurl.git import diff_text as git_diff_text
from ifcurl.git import fetch_ifc
from ifcurl.sandbox import SandboxCrashError, SandboxTimeoutError, run_sandboxed
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]
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(
title="ifcurl preview service",
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
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
# ---------------------------------------------------------------------------
@ -484,3 +525,126 @@ def bcf_export(request: BcfRequest) -> Response:
media_type="application/octet-stream",
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"},
)

View file

@ -20,8 +20,8 @@ classifiers = [
dependencies = ["ifcopenshell", "gitpython", "platformdirs"]
[project.optional-dependencies]
render = ["pyvista", "numpy"]
service = ["pyvista", "numpy", "fastapi", "uvicorn[standard]", "bcf"]
render = ["pyvista", "numpy", "Pillow"]
service = ["pyvista", "numpy", "Pillow", "fastapi", "uvicorn[standard]", "bcf"]
[project.scripts]
ifcurl = "ifcurl.__main__:main"

132
tests/test_diff.py Normal file
View 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"]