Skip to content

Commit 03c6f48

Browse files
committed
Process sub-template applicableEntity when it specializes the parent anchor
1 parent fbb3682 commit 03c6f48

9 files changed

Lines changed: 509 additions & 15 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
python-version: "3.10"
2626

2727
- name: Install test dependencies
28-
run: python -m pip install "ifcopenshell[advanced]" pytest pyparsing
28+
run: python -m pip install "ifcopenshell[advanced]" pytest pyparsing rdflib tabulate xlsxwriter
2929

3030
- name: Install the checked-out MVD modules
3131
shell: bash

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ concept templates returns a tuple of `template` objects instead. Invalid XML
2828
and missing template references raise `ValueError` with the relevant document
2929
or template identifier. Recursive template branches are expanded once.
3030

31+
When a referenced template's `applicableEntity` differs from its anchor entity,
32+
the parsed tree includes an additional `EntityRule` for that type. Consecutive
33+
entity rules apply to the same IFC instance; sibling entity rules are alternative
34+
specializations. Matching roots are expanded without an extra rule. This keeps
35+
type restrictions visible to traversal, extraction and SPARQL conversion.
36+
Serialization writes these restrictions as partial-template references again.
37+
3138
### Graphviz concept templates
3239

3340
The lightweight concept graphs used by the buildingSMART IFC4.x documentation

‎graphviz.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,18 @@ def parse(
7676
root = min(graph.in_degree(), key=lambda item: item[1])[0]
7777
resolved_references = _reference_lookup(references or {})
7878

79-
def build(node: str, top_level_attribute: bool = False) -> tuple[rule, ...]:
79+
def build(node: str, top_level_attribute: bool = False, anchor: str = "") -> tuple[rule, ...]:
8080
node_type = graph.nodes[node].get("type", "EntityRule")
8181
if node_type == "Reference":
8282
try:
83-
return resolved_references[_normalise_reference(node)].rules
83+
referenced = resolved_references[_normalise_reference(node)]
8484
except KeyError as error:
8585
raise ValueError(
8686
f"Unknown Graphviz template reference: {node}"
8787
) from error
88+
if referenced.entity and referenced.entity.casefold() != anchor.casefold():
89+
return (rule("EntityRule", referenced.entity, referenced.rules),)
90+
return referenced.rules
8891

8992
if node_type == "Constraint":
9093
try:
@@ -99,6 +102,7 @@ def build(node: str, top_level_attribute: bool = False) -> tuple[rule, ...]:
99102
itertools.chain.from_iterable(
100103
build(
101104
child,
105+
anchor=node.split("_", 1)[0] if node_type == "EntityRule" else anchor,
102106
top_level_attribute=(
103107
node == root
104108
and graph.nodes[child].get("type") == "AttributeRule"

‎model.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,22 @@ def extract(self, ifc_data: ifcopenshell.entity_instance | Any) -> extracted_dat
167167
return []
168168

169169
to_combine: list[extracted_data] = []
170+
# Sibling entity rules represent alternative specializations of
171+
# this instance, just as entity rules under an attribute do.
172+
entity_alternatives = [
173+
child for child in self.nodes if child.tag == "EntityRule"
174+
]
175+
if entity_alternatives:
176+
to_combine.append(
177+
[
178+
values
179+
for child in entity_alternatives
180+
for values in child.extract(ifc_data)
181+
]
182+
)
170183
for child in self.nodes:
184+
if child.tag == "EntityRule":
185+
continue
171186
if child.tag == "Constraint":
172187
on_node = child.attribute[0][0].c.replace("'", "")
173188
if isinstance(ifc_data, ifcopenshell.entity_instance):
@@ -249,12 +264,18 @@ def extract(self, ifc_data: ifcopenshell.entity_instance | Any) -> extracted_dat
249264
def binding_for(self, target: rule) -> str | None:
250265
binding: str | None = None
251266

252-
def find(rule: rule, parent: rule | None) -> None:
267+
def find(rule: rule, parents: tuple[rule | None, ...]) -> None:
253268
nonlocal binding
254269
if rule is target:
255-
binding = rule.bind or (parent.bind if parent else None)
256-
257-
self.traverse(find)
270+
binding = rule.bind
271+
for parent in reversed(parents):
272+
if binding or parent is None:
273+
break
274+
binding = parent.bind
275+
if parent.tag != "EntityRule":
276+
break
277+
278+
self.traverse(find, with_parents=True)
258279
return binding
259280

260281

‎mvd.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import ifcopenshell
22
import ifcopenshell.geom
3+
from .model import rule
34

45
import itertools
56
import os
@@ -36,6 +37,9 @@ def extract_data(mvd_node, ifc_data):
3637
3738
3839
"""
40+
if isinstance(mvd_node, rule):
41+
return mvd_node.extract(ifc_data)
42+
3943
to_combine = []
4044
return_value = []
4145

‎parser.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def parse_template(
4242

4343
parsed_rules: list[rule] = []
4444
next_visited = visited | {template_key}
45+
entity = root.getAttribute("applicableEntity")
4546
# Only the template's own <Rules> are in scope. Nested <SubTemplates>
4647
# form a hierarchy of separate templates, so they must not contribute
4748
# rules (and therefore entity scope) to the parent template.
@@ -51,7 +52,7 @@ def parse_template(
5152
for node in _elements(rules_node):
5253
# A <References> child unions one or more <Template>
5354
# references, so a single child may expand to several rules.
54-
parsed_rules.extend(self._visit_rule(node, "", next_visited))
55+
parsed_rules.extend(self._visit_rule(node, "", next_visited, entity))
5556

5657
return template(
5758
entity=(
@@ -70,12 +71,14 @@ def _visit_rule(
7071
node: Element,
7172
prefix: str,
7273
visited: frozenset[str],
74+
anchor: str,
7375
) -> tuple[rule, ...]:
7476
attribute: Any = None
7577
bind: str | None = None
7678
optional = False
7779
target = node
7880
child_prefix = prefix
81+
tag = node.localName
7982

8083
if node.localName == "AttributeRule":
8184
attribute = node.attributes["AttributeName"].value
@@ -89,6 +92,7 @@ def _visit_rule(
8992
)
9093
elif node.localName == "EntityRule":
9194
attribute = node.attributes["EntityName"].value
95+
anchor = attribute
9296
elif node.localName == "Template":
9397
reference = node.attributes["ref"].value
9498
reference_key = reference.casefold()
@@ -103,6 +107,13 @@ def _visit_rule(
103107
if "IdPrefix" in node.attributes:
104108
child_prefix += node.attributes["IdPrefix"].value
105109
visited = visited | {reference_key}
110+
entity = target.getAttribute("applicableEntity")
111+
if entity and entity.casefold() != anchor.casefold():
112+
# A reference may specialize its anchor without following an
113+
# attribute. Keep that type restriction in the rule tree.
114+
tag = "EntityRule"
115+
attribute = entity
116+
anchor = entity
106117
elif node.localName == "Constraint":
107118
attribute = mvdxml_expression.parse(node.attributes["Expression"].value)
108119
elif node.localName in {
@@ -121,13 +132,13 @@ def _visit_rule(
121132
children = tuple(
122133
parsed
123134
for child in _elements(target)
124-
for parsed in self._visit_rule(child, child_prefix, visited)
135+
for parsed in self._visit_rule(child, child_prefix, visited, anchor)
125136
)
126137

127138
if attribute is not None:
128139
return (
129140
rule(
130-
tag=node.localName,
141+
tag=tag,
131142
attribute=attribute,
132143
nodes=children,
133144
bind=child_prefix + bind if bind else None,

‎serializer.py‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def token(value: mvdxml_expression.node | str) -> str:
5252
}
5353

5454

55-
def _rule(parent: ET.Element, value: rule) -> None:
55+
def _rule(parent: ET.Element, value: rule, referenced: list[tuple[str, template]]) -> None:
5656
attributes: dict[str, str] = {}
5757
if value.tag == "AttributeRule":
5858
attributes = {"AttributeName": str(value.attribute)}
@@ -67,12 +67,30 @@ def _rule(parent: ET.Element, value: rule) -> None:
6767

6868
element = _add(parent, value.tag, **attributes)
6969
for tag, children in groupby(value.nodes, key=lambda child: child.tag):
70+
if value.tag == "EntityRule" and tag == "EntityRule":
71+
# Consecutive entity rules are an in-memory type restriction.
72+
# Write them as partial-template references in mvdXML.
73+
container = _add(element, "References")
74+
for child in children:
75+
identifier = str(uuid.uuid4())
76+
_add(container, "Template", ref=identifier)
77+
referenced.append(
78+
(identifier, template(child.attribute, None, child.nodes))
79+
)
80+
continue
7081
container = _add(element, _CONTAINERS[tag])
7182
for child in children:
72-
_rule(container, child)
83+
_rule(container, child, referenced)
7384

7485

75-
def _template(parent: ET.Element, value: template, identifier: str) -> None:
86+
def _template(
87+
parent: ET.Element,
88+
value: template,
89+
identifier: str,
90+
referenced: list[tuple[str, template]],
91+
*,
92+
partial: bool = False,
93+
) -> None:
7694
element = _add(
7795
parent,
7896
"ConceptTemplate",
@@ -81,11 +99,12 @@ def _template(parent: ET.Element, value: template, identifier: str) -> None:
8199
status="sample",
82100
applicableSchema="IFC4",
83101
applicableEntity=value.entity,
102+
isPartial="true" if partial else None,
84103
)
85104
if value.rules:
86105
rules = _add(element, "Rules")
87106
for item in value.rules:
88-
_rule(rules, item)
107+
_rule(rules, item, referenced)
89108

90109

91110
def _key(value: template) -> str | int:
@@ -138,8 +157,15 @@ def serialize(items: Iterable[mvd_item], destination: str | Path) -> None:
138157
{"uuid": str(uuid.uuid4()), "name": "", "status": "sample"},
139158
)
140159
template_elements = _add(document, "Templates")
160+
referenced: list[tuple[str, template]] = []
141161
for key, value in templates.items():
142-
_template(template_elements, value, template_ids[key])
162+
_template(template_elements, value, template_ids[key], referenced)
163+
# Serializing a partial template can discover further specializations.
164+
index = 0
165+
while index < len(referenced):
166+
identifier, value = referenced[index]
167+
_template(template_elements, value, identifier, referenced, partial=True)
168+
index += 1
143169

144170
roots = [item for item in parsed if isinstance(item, concept_root)]
145171
if roots:

‎sparql.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,18 @@ def build(rule, parents):
365365
return_value = lambda: bld.append(INDENT + "}")
366366

367367
if rule.tag == "EntityRule":
368+
siblings = [
369+
child
370+
for child in getattr(parents[-1], "nodes", ())
371+
if child.tag == "EntityRule"
372+
]
373+
if len(siblings) > 1:
374+
bld.append(INDENT + "{")
375+
376+
def close_alternative():
377+
bld.append(INDENT + ("} UNION" if rule is not siblings[-1] else "}"))
378+
379+
return_value = close_alternative
368380
# G.nm = G.next_nm
369381

370382
if not ifcOwl.is_select(rule.attribute):

0 commit comments

Comments
 (0)