Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions src/ada/assets/ifc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"""The shipped IFC asset provider -- the only place in ``ada.assets`` allowed to know IFC.

Registers the ``asset-build-ifc`` builder from :mod:`ada.assets.builders`. This import itself must
stay CHEAP: :func:`ada.assets.builders.ensure_core_builders` imports this module from every
process that touches the asset store (Decision 1's dispatch-by-capability needs every builder
registered so it can answer "no builder for X" accurately, even on a pool that will never serve
``asset-build-ifc``). Neither ``ifcopenshell`` nor ``adacpp`` is imported at module scope here --
only inside :func:`_make_builder`, which runs once, lazily, the first time something actually asks
for this capability.

``walk`` / ``index`` / ``publish`` / ``build`` are separate submodules on purpose: a caller that
only needs the publish-time logic (a future ``asset-publish-ifc`` job handler, or a test) imports
Registers the ``asset-build-ifc`` builder (:mod:`ada.assets.builders`) and the ``ifc`` publisher
(:mod:`ada.assets.publishers`, Phase 4). This import itself must stay CHEAP:
:func:`ada.assets.builders.ensure_core_builders` AND :func:`ada.assets.publishers.
ensure_core_publishers` both import this module from every process that touches the asset store
(Decision 1's dispatch-by-capability needs every builder/publisher registered so it can answer "no
builder/publisher for X" accurately, even on a process that will never serve ``asset-build-ifc`` or
publish IFC). Neither ``ifcopenshell`` nor ``adacpp`` is imported at module scope here -- only
inside :func:`_make_builder` / :func:`_make_publisher`, which run once, lazily, the first time
something actually asks for the capability or the provider id.

``walk`` / ``index`` / ``publish`` / ``publisher`` / ``build`` are separate submodules on purpose:
a caller that only needs the publish-time logic (a job handler, or a test) imports
``ada.assets.ifc.publish`` directly without pulling in the builder's own lazy import of the native
GLB path.
"""
Expand All @@ -19,10 +21,12 @@
import importlib.util

from ada.assets.builders import register_asset_builder
from ada.assets.publishers import register_asset_publisher

__all__ = ["IFC_BUILD_CAPABILITY"]

IFC_BUILD_CAPABILITY = "asset-build-ifc"
IFC_PROVIDER_ID = "ifc"


def _available() -> bool:
Expand Down Expand Up @@ -50,9 +54,21 @@ def _make_builder():
return IfcAssetBuilder()


def _make_publisher():
from ada.assets.ifc.publisher import IfcAssetPublisher

return IfcAssetPublisher()


register_asset_builder(
IFC_BUILD_CAPABILITY,
_make_builder,
label="IFC (core)",
available=_available,
)

register_asset_publisher(
IFC_PROVIDER_ID,
_make_publisher,
label="IFC (core)",
)
31 changes: 25 additions & 6 deletions src/ada/assets/ifc/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,26 @@
sets included or not" -- an unmeasured, provider-internal choice). Widening this hash to include
property sets is additive and does not change the artefact's shape.

**Why STEP entity-instance numbers are excluded.** ``get_info`` embeds the numeric ``#id`` of
every referenced entity by default only through nested dicts (each referenced entity is itself
expanded to its own attribute dict, not left as a bare ``#123`` token), so two files that assign
different STEP line numbers to logically identical entities still hash the same -- which matters
here because ``v1`` and a re-exported ``v2`` are independently written files, not the same file
re-saved.
**Why STEP entity-instance numbers must be stripped, recursively.** ``get_info(recursive=True)``
expands every referenced entity into its own nested dict rather than leaving a bare ``#123``
token -- but EACH of those nested dicts still carries its own numeric ``"id"`` key alongside the
expanded attributes (verified against ifcopenshell's own output, not assumed), at every depth: the
product's own top-level id, its placement's id, its placement's axis' id, and so on down the whole
representation graph. Two files that assign different STEP line numbers to logically identical
entities -- the ordinary case for ``v1`` and an independently re-exported ``v2``, where adding or
removing ANY entity anywhere in the file shifts numbering for everything written after it -- would
therefore hash as "changed" everywhere, not just where content actually moved, if only the
top-level ``"id"`` were dropped. :func:`_strip_step_ids` walks the WHOLE nested structure and drops
every ``"id"`` key it finds, which is what makes the hash a statement about content rather than
about write order.
"""

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from typing import Any

import ifcopenshell

Expand All @@ -57,13 +64,25 @@ class IfcIndexEntry:
hash: str # sha256 of representation + placement + own attributes


def _strip_step_ids(node: Any) -> Any:
"""Drop every ``"id"`` key (the STEP entity-instance number) at every depth of a
``get_info(recursive=True)`` tree -- see the module docstring for why this must be recursive,
not just applied to the top-level dict."""
if isinstance(node, dict):
return {k: _strip_step_ids(v) for k, v in node.items() if k != "id"}
if isinstance(node, (list, tuple)):
return [_strip_step_ids(v) for v in node]
return node


def _product_hash(product: ifcopenshell.entity_instance) -> str:
info = product.get_info(recursive=True)
# OwnerHistory carries a per-write timestamp that moves on every re-export even when nothing
# about the product itself changed (adapy writes one per file, `store.py:157`); GlobalId is
# the entry's own key already. Both would make an untouched product hash as "changed".
info.pop("OwnerHistory", None)
info.pop("GlobalId", None)
info = _strip_step_ids(info)
blob = json.dumps(info, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
return hashlib.sha256(blob).hexdigest()

Expand Down
Loading
Loading