ifcurl/ifcurl/diff.py
Bruno Postle 96aa1a2377 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>
2026-04-25 06:52:01 +01:00

131 lines
5.1 KiB
Python

# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IFC URL.
#
# IFC URL is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IFC URL is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
"""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"],
}