Work towards v1.0 data model with encapsulated weak_ptr as basis for instances

This commit is contained in:
Thomas Krijnen
2026-01-04 10:40:02 +01:00
parent f09ca658f1
commit 7098beb819
210 changed files with 28269 additions and 26471 deletions
@@ -43,75 +43,7 @@ except ImportError:
T = TypeVar("T")
def set_derived_attribute(*args):
raise TypeError("Unable to set derived attribute")
def set_unsupported_attribute(*args):
raise TypeError("This is an unsupported attribute type")
# For every schema and its entities populate a list
# of functions for every entity attribute (including
# inherited attributes) to set that particular
# attribute by index.
# For example. IFC2X3.IfcWall with have a list of
# 9 methods. The first will point at
# ifcopenshell.ifcopenshell_wrapper.entity_instance.setArgumentAsString
# because the first attribute GlobalId ultimately
# is of type string.
# Previously, resolving the appropriate function was
# done for each invocation of __setitem__. Now this
# mapping is built once during initialization of the
# module.
MethodList = list[Callable[[ifcopenshell_wrapper.entity_instance, int, Any], Union[None, NoReturn]]]
"""List of setter methods for class attributes."""
_method_dict: dict[str, MethodList] = {}
"""Mapping of entity classes (e.g. 'IFC4.IfcWall') to MethodLists."""
def register_schema_attributes(schema: ifcopenshell_wrapper.schema_definition) -> None:
for decl in schema.declarations():
if hasattr(decl, "argument_types"):
fq_name = ".".join((schema.name(), decl.name()))
# get type strings as reported by IfcOpenShell C++
type_strs = decl.argument_types()
type_strs = cast(Sequence[str], type_strs)
# convert case for setter function
type_strs = [x.title().replace(" ", "") for x in type_strs]
# binary and enumeration are passed from python as string as well
type_strs = [x.replace("Binary", "String") for x in type_strs]
type_strs = [x.replace("Enumeration", "String") for x in type_strs]
# prefix to get method names
fn_names = ["setArgumentAs" + x for x in type_strs]
# resolve to actual functions in wrapper
functions = [
(
set_derived_attribute
if mname == "setArgumentAsDerived"
else (
set_unsupported_attribute
if mname == "setArgumentAsUnknown"
else getattr(ifcopenshell_wrapper.entity_instance, mname)
)
)
for mname in fn_names
]
_method_dict[fq_name] = functions
for nm in ifcopenshell_wrapper.schema_names():
schema = ifcopenshell_wrapper.schema_by_name(nm)
register_schema_attributes(schema)
class entity_instance:
class entity_instance_mixin:
"""Represents an entity (wall, slab, property, etc) of an IFC model
An IFC model consists of entities. Examples of entities include walls,
@@ -155,67 +87,33 @@ class entity_instance:
print(wall.__class__) # <class 'ifcopenshell.entity_instance'>
"""
wrapped_data: ifcopenshell_wrapper.entity_instance
method_list: Union[MethodList, None] = None
def __init__(
self,
e: Union[ifcopenshell_wrapper.entity_instance, tuple[str, str]],
file: Union[ifcopenshell.file, None] = None,
):
"""
:param e: Wrapper's ``entity_instance`` or a tuple ``(schema_identifier, ifc_class)``.
"""
# Instances of this class will be created and removed very often,
# so it's important to keep it very optimized.
if isinstance(e, tuple):
e = ifcopenshell_wrapper.new_IfcBaseClass(*e)
object.__setattr__(self, "wrapped_data", e)
# Make sure the file is not gc'ed while we have live instances
e.file = file
def __del__(self):
"""
#2471 while the precise chain of action is unclear, creating
instance references prevents file gc, even with all instance
refs deleted. This is a work-around for that.
"""
# Avoid infinite recursion if entity is failed to initialize
# and wrapped_data is unset. Hacky since we override
# both __dict__ and __dir__.
try:
wrapped_data = object.__getattribute__(self, "wrapped_data")
wrapped_data.file = None
except AttributeError:
return
@property
def file(self):
# ugh circular imports, name collisions
from . import file
return file.from_pointer(self.wrapped_data.file_pointer())
raise NotImplementedError
def __getattr__(self, name: str) -> Any:
if name in ("this", "thisown") or name.startswith("_swig_"):
return object.__getattr__(self, name)
"""
Any aggregate attributes (e.g. `SET`) are returns as Python tuples.
Inverse attributes are always returned as tuples, even it's not a set origially in IFC
Inverse attributes are returned as tuples, even it's not a set origially in IFC
(e.g. IfcFeatureElementSubtraction.VoidsElements)
(unless settings.unpack_non_aggregate_inverses is used, which is necessary for express rule execution)
"""
INVALID, FORWARD, INVERSE = range(3)
attr_cat = self.wrapped_data.get_attribute_category(name)
if attr_cat == FORWARD:
idx = self.wrapped_data.get_argument_index(name)
if _method_dict[self.is_a(True)][idx] != set_derived_attribute:
# A bit ugly, but we fall through to derived attribute handling below
return entity_instance.wrap_value(self.wrapped_data.get_argument(idx), self.wrapped_data.file)
INVALID, FORWARD, INVERSE, DERIVED = range(4)
attr_cat = self.get_attribute_category(name)
if attr_cat == INVALID:
raise AttributeError(
"entity instance of type '%s' has no attribute '%s'" % (self.is_a(True), name)
)
elif attr_cat == FORWARD:
idx = self.get_argument_index(name)
return self.get_argument(idx)
elif attr_cat == INVERSE:
vs = entity_instance.wrap_value(self.wrapped_data.get_inverse(name), self.wrapped_data.file)
vs = self.get_inverse(name)
if settings.unpack_non_aggregate_inverses:
schema_name = self.wrapped_data.is_a(True).split(".")[0]
schema_name = self.is_a(True).split(".")[0]
ent: ifcopenshell_wrapper.entity
ent = ifcopenshell_wrapper.schema_by_name(schema_name).declaration_by_name(self.is_a())
inv = next(i for i in ent.all_inverse_attributes() if i.name() == name)
@@ -225,44 +123,38 @@ class entity_instance:
else:
vs = None
return vs
elif attr_cat == DERIVED:
schema_name = self.is_a(True).split(".")[0]
try:
rules = importlib.import_module(f"ifcopenshell.express.rules.{schema_name}")
except:
import os
# derived attribute perhaps?
schema_name = self.wrapped_data.is_a(True).split(".")[0]
try:
rules = importlib.import_module(f"ifcopenshell.express.rules.{schema_name}")
except:
import os
current_dir_files = {fn.lower(): fn for fn in os.listdir(".")}
exp_filename = schema_name.lower() + ".exp"
schema_path = current_dir_files.get(exp_filename)
if schema_path is None:
raise Exception(
f"Couldn't find express file '{schema_name.lower()}.exp' in the current folder: '{os.getcwd()}'."
)
fn = schema_path[:-4] + ".py"
if not os.path.exists(fn):
subprocess.run(
[sys.executable, "-m", "ifcopenshell.express.rule_compiler", schema_path, fn], check=True
)
time.sleep(1.0)
rules = importlib.import_module(schema_name)
current_dir_files = {fn.lower(): fn for fn in os.listdir(".")}
exp_filename = schema_name.lower() + ".exp"
schema_path = current_dir_files.get(exp_filename)
if schema_path is None:
raise Exception(
f"Couldn't find express file '{schema_name.lower()}.exp' in the current folder: '{os.getcwd()}'."
)
fn = schema_path[:-4] + ".py"
if not os.path.exists(fn):
subprocess.run(
[sys.executable, "-m", "ifcopenshell.express.rule_compiler", schema_path, fn], check=True
)
time.sleep(1.0)
rules = importlib.import_module(schema_name)
def yield_supertypes():
decl = ifcopenshell_wrapper.schema_by_name(schema_name).declaration_by_name(self.is_a())
while decl:
yield decl.name()
decl = decl.supertype()
def yield_supertypes():
decl = ifcopenshell_wrapper.schema_by_name(schema_name).declaration_by_name(self.is_a())
while decl:
yield decl.name()
decl = decl.supertype()
for sty in yield_supertypes():
fn = getattr(rules, f"calc_{sty}_{name}", None)
if fn:
return fn(self)
if attr_cat != FORWARD:
raise AttributeError(
"entity instance of type '%s' has no attribute '%s'" % (self.wrapped_data.is_a(True), name)
)
for sty in yield_supertypes():
fn = getattr(rules, f"calc_{sty}_{name}", None)
if fn:
return fn(self)
@staticmethod
def walk(f: Callable[[Any], bool], g: Callable[[Any], Any], value: Any) -> Any:
@@ -295,180 +187,53 @@ class entity_instance:
"""
if isinstance(value, (tuple, list)):
return tuple(map(functools.partial(entity_instance.walk, f, g), value))
return tuple(map(functools.partial(entity_instance_mixin.walk, f, g), value))
elif f(value):
return g(value)
else:
return value
@staticmethod
def wrap_value(v, file: ifcopenshell.file):
def wrap(e: ifcopenshell_wrapper.entity_instance) -> entity_instance:
return entity_instance(e, file)
def is_instance(e: Any) -> bool:
return isinstance(e, ifcopenshell_wrapper.entity_instance)
return entity_instance.walk(is_instance, wrap, v)
@staticmethod
def unwrap_value(v):
def unwrap(e):
return e.wrapped_data
def is_instance(e):
return isinstance(e, entity_instance)
return entity_instance.walk(is_instance, unwrap, v)
def attribute_type(self, attr: Union[int, str]) -> str:
"""Return the data type of a positional attribute of the element
:param attr: The index or name of the attribute
"""
attr_idx = attr if isinstance(attr, numbers.Integral) else self.wrapped_data.get_argument_index(attr)
return self.wrapped_data.get_argument_type(attr_idx)
def attribute_name(self, attr_idx: int) -> str:
"""Return the name of a positional attribute of the element
:param attr_idx: The index of the attribute
"""
return self.wrapped_data.get_argument_name(attr_idx)
def __setattr__(self, key: str, value: Any) -> None:
index = self.wrapped_data.get_argument_index(key)
if key in ("this", "thisown") or key.startswith("_swig_"):
return object.__setattr__(self, key, value)
index = self.get_argument_index(key)
try:
self[index] = value
except IndexError as e:
# get_argument_index returns 0xFFFFFFFF if attribute is not found
if index == 0xFFFFFFFF:
raise AttributeError(
"entity instance of type '%s' has no attribute '%s'" % (self.wrapped_data.is_a(True), key)
"entity instance of type '%s' has no attribute '%s'" % (self.is_a(True), key)
)
raise e
def __getitem__(self, key: int) -> Any:
if key < 0 or key >= len(self):
raise IndexError("Attribute index {} out of range for instance of type {}".format(key, self.is_a()))
return entity_instance.wrap_value(self.wrapped_data.get_argument(key), self.wrapped_data.file)
return self.get_argument(key)
def __setitem__(self, idx: int, value: T) -> T:
if self.wrapped_data.file and self.wrapped_data.file.transaction:
self.wrapped_data.file.transaction.store_edit(self, idx, value)
if self.method_list is None:
super().__setattr__("method_list", _method_dict[self.is_a(True)])
method = self.method_list[idx]
if value is None:
if method is not set_derived_attribute:
try:
self.wrapped_data.setArgumentAsNull(idx)
except RuntimeError as e:
if e.args == ("Attribute not set",):
raise TypeError(
"attribute '%s' is not optional for entity instance of type '%s'"
% (self.wrapped_data.get_argument_name(idx), self.wrapped_data.is_a(True))
)
raise e
else:
try:
self.method_list[idx](self.wrapped_data, idx, entity_instance.unwrap_value(value))
except TypeError:
raise TypeError(
"attribute '%s' for entity '%s' is expecting value of type '%s', got '%s'."
% (
self.wrapped_data.get_argument_name(idx),
self.wrapped_data.is_a(True),
self.wrapped_data.get_argument_type(idx),
type(value).__name__,
)
)
if self.file and self.file.transaction:
self.file.transaction.store_edit(self, idx, value)
self.set_attribute_value_py(idx, value)
return value
def __len__(self):
return len(self.wrapped_data)
def __repr__(self):
return repr(self.wrapped_data)
def to_string(self, valid_spf=True) -> str:
"""Returns a string representation of the current entity instance.
Equal to str(self) when valid_spf=False. When valid_spf is True
returns a representation of the string that conforms to valid Step
Physical File notation. The difference being entity names in upper
case and string attribute values with unicode values encoded per
the specific control directives.
"""
return self.wrapped_data.to_string(valid_spf)
@overload
def is_a(self) -> str: ...
@overload
def is_a(self, ifc_class: str) -> bool: ...
@overload
def is_a(self, with_schema: bool) -> str: ...
def is_a(self, *args: Union[str, bool]) -> Union[str, bool]:
"""Return the IFC class name of an instance, or checks if an instance belongs to a class.
The check will also return true if a parent class name is provided.
:param args: If specified, is a case insensitive IFC class name to check
or if specified as a boolean then will define whether
returned IFC class name should include schema name
(e.g. "IFC4.IfcWall" if `True` and "IfcWall" if `False`).
If omitted will act as `False`.
:returns: Either the name of the class, or a boolean if it passes the check
Example:
.. code:: python
f = ifcopenshell.file()
f.create_entity('IfcPerson')
f.is_a()
>>> 'IfcPerson'
f.is_a('IfcPerson')
>>> True
"""
return self.wrapped_data.is_a(*args)
def id(self) -> int:
"""Return the STEP numerical identifier"""
return self.wrapped_data.id()
def __eq__(self, other: entity_instance) -> bool:
def __eq__(self, other: entity_instance_mixin) -> bool:
if not isinstance(self, type(other)):
return False
elif None in (self.wrapped_data.file, other.wrapped_data.file):
# when not added to a file, we can only compare attribute values
# and we need this for where rule evaluation
return self.get_info_2(recursive=True, include_identifier=False) == other.get_info_2(
recursive=True, include_identifier=False
)
else:
# Proper entity instances have a stable identity by means of the numeric
# step id. Selected type instances (such as IfcPropertySingleValue.NominalValue
# always have id=0, so we compare <type, value, file pointer>
if self.id():
return self.wrapped_data == other.wrapped_data
else:
return (self.is_a(), self[0], self.wrapped_data.file_pointer()) == (
other.is_a(),
other[0],
other.wrapped_data.file_pointer(),
)
raise NotImplementedError
def is_entity(self) -> bool:
"""Tests whether the instance is an entity type as opposed to a simple data type.
:return: True if the instance is an entity
"""
schema_name = self.wrapped_data.is_a(True).split(".")[0]
schema_name = self.is_a(True).split(".")[0]
decl = ifcopenshell_wrapper.schema_by_name(schema_name).declaration_by_name(self.is_a())
return isinstance(decl, ifcopenshell_wrapper.entity)
@@ -505,9 +270,9 @@ class entity_instance:
:return: bool: The comparison predicate applied to self and other
"""
if isinstance(other, entity_instance):
if isinstance(other, entity_instance_mixin):
a, b = map(tuple, (self, other))
if any(map(entity_instance.is_entity, (self, other))):
if any(map(entity_instance_mixin.is_entity, (self, other))):
a = (self.is_a(),) + a
b = (other.is_a(),) + b
elif self.is_entity():
@@ -540,17 +305,17 @@ class entity_instance:
# step id. Selected type instances (such as IfcPropertySingleValue.NominalValue
# always have id=0, so we hash <type, value, file pointer>
if id_ := self.id():
return hash((id_, self.wrapped_data.file_pointer()))
return hash((id_, self.file_pointer()))
else:
return hash((self.is_a(), self[0], self.wrapped_data.file_pointer()))
return hash((self.is_a(), self[0], self.file_pointer()))
def __dir__(self):
return sorted(
set(
itertools.chain(
dir(type(self)),
map(str, self.wrapped_data.get_attribute_names()),
map(str, self.wrapped_data.get_inverse_attribute_names()),
map(str, self.get_attribute_names()),
map(str, self.get_inverse_attribute_names()),
)
)
)
@@ -595,7 +360,7 @@ class entity_instance:
logging.exception("unhandled exception while getting id / type info on {}".format(self))
for i in range(len(self)):
try:
if self.wrapped_data.get_attribute_names()[i] in ignore:
if self.get_attribute_names()[i] in ignore:
continue
attr_value = self[i]
@@ -604,10 +369,10 @@ class entity_instance:
if recursive or scalar_only:
def is_instance(e):
return isinstance(e, entity_instance)
return isinstance(e, entity_instance_mixin)
def get_info_(inst):
return entity_instance.get_info(
return entity_instance_mixin.get_info(
inst,
include_identifier=include_identifier,
recursive=recursive,
@@ -619,7 +384,7 @@ class entity_instance:
to_include["v"] = False
return None
attr_value = entity_instance.walk(
attr_value = entity_instance_mixin.walk(
is_instance, get_info_ if recursive else do_ignore, attr_value
)
@@ -651,4 +416,4 @@ class entity_instance:
assert recursive
assert return_type is dict
assert len(ignore) == 0
return ifcopenshell_wrapper.get_info_cpp(self.wrapped_data, include_identifier)
return ifcopenshell_wrapper.get_info_cpp(self, include_identifier)