diff --git a/ifcurl/diff.py b/ifcurl/diff.py new file mode 100644 index 0000000..a5fecef --- /dev/null +++ b/ifcurl/diff.py @@ -0,0 +1,131 @@ +# IFC URL — resolve and render ifc:// URLs +# Copyright (C) 2026 Bruno Postle +# +# 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 . + +"""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"], + } diff --git a/ifcurl/git.py b/ifcurl/git.py index bd4a1d0..ac30342 100644 --- a/ifcurl/git.py +++ b/ifcurl/git.py @@ -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*. diff --git a/ifcurl/render.py b/ifcurl/render.py index e129dd8..99a00e1 100644 --- a/ifcurl/render.py +++ b/ifcurl/render.py @@ -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() diff --git a/ifcurl/service.py b/ifcurl/service.py index dea6a5c..333ee67 100644 --- a/ifcurl/service.py +++ b/ifcurl/service.py @@ -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 ```` 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"}, + ) diff --git a/pyproject.toml b/pyproject.toml index d5baaf7..3046e61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100644 index 0000000..7f7642c --- /dev/null +++ b/tests/test_diff.py @@ -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"]