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 -189
View File
@@ -31,9 +31,6 @@ from typing import Any, Optional, TYPE_CHECKING, Union, overload, Literal, Typed
from collections.abc import Callable, Generator
from typing_extensions import assert_never
from . import ifcopenshell_wrapper
from .entity_instance import entity_instance
from ifcopenshell.util.mvd_info import MvdInfo, LARK_AVAILABLE
if TYPE_CHECKING:
@@ -108,7 +105,7 @@ class Transaction:
def serialise_value(self, element, value) -> Any:
return element.walk(
lambda v: isinstance(v, entity_instance),
lambda v: isinstance(v, ifcopenshell.entity_instance),
lambda v: {"id": v.id()} if v.id() else {"type": v.is_a(), "value": v.wrappedValue},
value,
)
@@ -237,28 +234,8 @@ class Transaction:
else:
assert_never(operation["action"])
file_dict: dict[int, tuple[weakref.ReferenceType[file], int]] = {}
"""Mapping of internal IfcFile pointer address to existing ``ifcopenshell.file``
and the timestamp when it was created.
Needed only to quickly access related from ``entity_instance`` it's ``file``.
"""
READ_ERROR = ifcopenshell_wrapper.file_open_status.READ_ERROR
NO_HEADER = ifcopenshell_wrapper.file_open_status.NO_HEADER
UNSUPPORTED_SCHEMA = ifcopenshell_wrapper.file_open_status.UNSUPPORTED_SCHEMA
INVALID_SYNTAX = ifcopenshell_wrapper.file_open_status.INVALID_SYNTAX
# TODO: Workaround for old builds, remove after build stabilizes.
try:
UNKNOWN = ifcopenshell_wrapper.file_open_status.UNKNOWN
except:
UNKNOWN = 5 # Workaround
import struct
def consume_buffer(val, inner):
while val:
s = struct.unpack("@q", val[:8])[0]
@@ -508,20 +485,21 @@ class file_header:
self.file = file
self.header_data = header_data
# @todo these are probably no longer necessary now as we no longer depend on decoration of file
@property
def file_description(self) -> entity_instance:
return entity_instance.wrap_value(self.header_data.file_description_py(), file=self.file)
def file_description(self) -> ifcopenshell.entity_instance:
return self.header_data.file_description_py()
@property
def file_name(self) -> entity_instance:
return entity_instance.wrap_value(self.header_data.file_name_py(), file=self.file)
def file_name(self) -> ifcopenshell.entity_instance:
return self.header_data.file_name_py()
@property
def file_schema(self) -> entity_instance:
return entity_instance.wrap_value(self.header_data.file_schema_py(), file=self.file)
def file_schema(self) -> ifcopenshell.entity_instance:
return self.header_data.file_schema_py()
class file:
class file_mixin:
"""Base class for containing IFC files.
Class has instance methods for filtering by element Id, Type, etc.
@@ -537,8 +515,7 @@ class file:
print(products[0] == model[122] == model["2XQ$n5SLP5MBLyL442paFx"]) # True
"""
wrapped_data: ifcopenshell_wrapper.file
units: dict[str, entity_instance] = {}
units: dict[str, ifcopenshell.entity_instance] = {}
history_size: int = 64
history: list[Transaction]
"""Chronological order - from oldest to newest."""
@@ -548,104 +525,13 @@ class file:
to_delete: Union[set[ifcopenshell.entity_instance], None] = None
"""Entities for batch removal."""
def __init__(
self,
f: Optional[ifcopenshell_wrapper.file] = None,
schema: Optional[ifcopenshell.util.schema.IFC_SCHEMA] = None,
schema_version: Optional[tuple[int, int, int, int]] = None,
):
"""Create a new blank IFC model
This IFC model does not have any entities in it yet. See the
``create_entity`` function for how to create new entities. All data is
stored in memory. If you wish to write the IFC model to disk, see the
``write`` function.
:param f: The underlying IfcOpenShell file object to be wrapped. This
is an internal implementation detail and should generally be left
as None by users.
:param schema: Which IFC schema to use, chosen from "IFC2X3", "IFC4",
or "IFC4X3". These refer to the ISO approved versions of IFC.
Defaults to "IFC4" if not specified, which is currently recommended
for all new projects.
:param schema_version: If you want to specify an exact version of IFC
that may not be an ISO approved version, use this argument instead
of ``schema``. IFC versions on technical.buildingsmart.org are
described using 4 integers representing the major, minor, addendum,
and corrigendum number. For example, (4, 0, 2, 1) refers to IFC4
ADD2 TC1, which is the official version approved by ISO when people
refer to "IFC4". Generally you should not use this argument unless
you are testing non-ISO IFC releases.
Example:
.. code:: python
# Create a new IFC4 model, create a wall, then save it to an IFC-SPF file.
model = ifcopenshell.file()
model.create_entity("IfcWall")
model.write("/path/to/model.ifc")
# Create a new IFC4X3 model
model = ifcopenshell.file(schema="IFC4X3")
# A poweruser testing out a particular version of IFC4X3
model = ifcopenshell.file(schema_version=(4, 3, 0, 1))
"""
if schema_version:
prefixes = ("IFC", "X", "_ADD", "_TC")
schema = "".join("".join(map(str, t)) if t[1] else "" for t in zip(prefixes, schema_version))
else:
schema = {"IFC4X3": "IFC4X3_ADD2"}.get(schema, schema)
if f is not None:
self.wrapped_data = f
if not f.good():
from . import Error, SchemaError
exc, msg = {
READ_ERROR: lambda: (IOError, "Unable to open file for reading"),
NO_HEADER: lambda: (Error, "Unable to parse IFC SPF header"),
UNSUPPORTED_SCHEMA: lambda: (
SchemaError,
"Unsupported schema: %s" % ",".join(self.header.file_schema.schema_identifiers),
),
INVALID_SYNTAX: lambda: (Error, "Syntax error during parse, check logs"),
# This is the case when passing uninitialized_tag
UNKNOWN: lambda: (None, None),
}[f.good().value()]()
if exc is not None:
raise exc(msg)
else:
args = filter(None, [schema])
args = map(ifcopenshell_wrapper.schema_by_name, args)
self.wrapped_data = ifcopenshell_wrapper.file(*args)
def post_init(self):
self.history = []
self.future = []
self.transaction: Optional[Transaction] = None
# we store a tuple of C++ file pointer address and creation time stamp so that
# when memory addresses get recycled we do not run into collisions when the
# address is used as a cache key.
file_dict[self.wrapped_data.file_pointer()] = (weakref.ref(self), time.monotonic_ns())
@property
def identifier(self) -> tuple[int, int]:
"""Pair of C++ file pointer address and creation time stamp to uniquely identify a file
over the life time of ifcopenshell module that should be mostly safe except in pathological
cases
Returns:
tuple[int, int]: Pair of C++ file pointer address and creation time stamp
"""
return (self.wrapped_data.file_pointer(), file_dict[self.wrapped_data.file_pointer()][1])
def __del__(self) -> None:
# Avoid infinite recursion if file is failed to initialize
# and wrapped_data is unset.
if "wrapped_data" not in dir(self):
return
del file_dict[self.file_pointer()]
def set_history_size(self, size: int) -> None:
self.history_size = size
while len(self.history) > self.history_size:
@@ -716,7 +602,7 @@ class file:
"""
eid = kwargs.pop("id", -1)
e = entity_instance((self.schema_identifier, type), self)
e = self.create(type)
# Create pairs of {attribute index, attribute value}.
# Keyword arguments are mapped to their corresponding
@@ -758,15 +644,6 @@ class file:
if attrs:
self.transaction = transaction
# Once the values are populated add the instance
# to the file.
self.wrapped_data.add(e.wrapped_data, eid)
# The file container now handles the lifetime of
# this instance. Tell SWIG that it is no longer
# the owner.
e.wrapped_data.this.disown()
if self.transaction:
self.transaction.store_create(e)
@@ -777,7 +654,7 @@ class file:
"""General IFC schema version: IFC2X3, IFC4, IFC4X3."""
prefixes = ("IFC", "X", "_ADD", "_TC")
reg = "".join(f"(?P<{s}>{s}\\d+)?" for s in prefixes)
match = re.match(reg, self.wrapped_data.schema)
match = re.match(reg, self.schema)
version_tuple = tuple(
map(
lambda pp: int(pp[1][len(pp[0]) :]) if pp[1] else None,
@@ -789,7 +666,7 @@ class file:
@property
def schema_identifier(self) -> str:
"""Full IFC schema version: IFC2X3_TC1, IFC4_ADD2, IFC4X3_ADD2, etc."""
return self.wrapped_data.schema
return self.schema
@property
def schema_version(self) -> tuple[int, int, int, int]:
@@ -797,7 +674,7 @@ class file:
E.g. IFC4X3_ADD2 is represented as (4, 3, 2, 0).
"""
schema = self.wrapped_data.schema
schema = self.schema
version = []
for prefix in ("IFC", "X", "_ADD", "_TC"):
number = re.search(prefix + r"(\d)", schema)
@@ -814,35 +691,16 @@ class file:
if attr[0:6] == "create":
return functools.partial(self.create_entity, attr[6:])
else:
return getattr(self.wrapped_data, attr)
return getattr(self, attr)
def __getitem__(self, key: Union[numbers.Integral, str, bytes]) -> entity_instance:
if isinstance(key, numbers.Integral):
return entity_instance(self.wrapped_data.by_id(key), self)
return self.by_id(key)
elif isinstance(key, (str, bytes)):
return entity_instance(self.wrapped_data.by_guid(str(key)), self)
return self.by_guid(str(key))
else:
raise TypeError("Indexing into file requires either an integral number or compressed guid string")
def by_id(self, id: int) -> ifcopenshell.entity_instance:
"""Return an IFC entity instance filtered by IFC ID.
:param id: STEP numerical identifier
:raises RuntimeError: If `id` is not found.
:returns: An ifcopenshell.entity_instance
"""
return self[id]
def by_guid(self, guid: str) -> ifcopenshell.entity_instance:
"""Return an IFC entity instance filtered by IFC GUID.
:param guid: GlobalId value in 22-character encoded form
:raises RuntimeError: If `guid` is not found.
:returns: An ifcopenshell.entity_instance
"""
return self[guid]
def add(self, inst: ifcopenshell.entity_instance, _id: int = None) -> ifcopenshell.entity_instance:
"""Adds an entity including any dependent entities to an IFC file.
@@ -853,9 +711,10 @@ class file:
"""
if self.transaction:
max_id = self.wrapped_data.getMaxId()
inst.wrapped_data.this.disown()
result = entity_instance(self.wrapped_data.add(inst.wrapped_data, -1 if _id is None else _id), self)
max_id = self.getMaxId()
result = self._add(inst, -1 if _id is None else _id)
if self.transaction:
added_elements = [e for e in self.traverse(result) if e.id() > max_id]
[self.transaction.store_create(e) for e in reversed(added_elements)]
@@ -874,8 +733,8 @@ class file:
:returns: A list of ifcopenshell.entity_instance objects
"""
if include_subtypes:
return [entity_instance(e, self) for e in self.wrapped_data.by_type(type)]
return [entity_instance(e, self) for e in self.wrapped_data.by_type_excl_subtypes(type)]
return self._by_type(type)
return self._by_type_excl_subtypes(type)
def traverse(
self, inst: ifcopenshell.entity_instance, max_levels: Optional[int] = None, breadth_first: bool = False
@@ -891,11 +750,11 @@ class file:
max_levels = -1
if breadth_first:
fn = self.wrapped_data.traverse_breadth_first
fn = self.traverse_breadth_first
else:
fn = self.wrapped_data.traverse
fn = self.traverse
return [entity_instance(e, self) for e in fn(inst.wrapped_data, max_levels)]
return fn(inst, max_levels)
@overload
def get_inverse(
@@ -940,11 +799,11 @@ class file:
if with_attribute_indices and not allow_duplicate:
raise ValueError("with_attribute_indices requires allow_duplicate to be True")
inverses = [entity_instance(e, self) for e in self.wrapped_data.get_inverse(inst.wrapped_data)]
inverses = [entity_instance(e, self) for e in self.get_inverse(inst.wrapped_data)]
if allow_duplicate:
if with_attribute_indices:
idxs = self.wrapped_data.get_inverse_indices(inst.wrapped_data)
idxs = self.get_inverse_indices(inst.wrapped_data)
# TODO: include in typing.
return list(zip(inverses, idxs))
else:
@@ -961,7 +820,7 @@ class file:
:param inst: The entity instance to get inverse relationships
:returns: The total number of references
"""
return self.wrapped_data.get_total_inverses(inst.wrapped_data)
return self.get_total_inverses(inst.wrapped_data)
def remove(self, inst: ifcopenshell.entity_instance) -> None:
"""Deletes an IFC object in the file.
@@ -974,22 +833,22 @@ class file:
"""
if self.transaction:
self.transaction.store_delete(inst)
return self.wrapped_data.remove(inst.wrapped_data)
return self.remove(inst.wrapped_data)
def batch(self):
"""Low-level mechanism to speed up deletion of large subgraphs"""
if self.transaction:
self.transaction.batch()
return self.wrapped_data.batch()
return self.batch()
def unbatch(self):
"""Low-level mechanism to speed up deletion of large subgraphs"""
if self.transaction:
self.transaction.unbatch()
return self.wrapped_data.unbatch()
return self.unbatch()
def __iter__(self) -> Generator[ifcopenshell.entity_instance, None, None]:
return iter(self[id] for id in self.wrapped_data.entity_names())
return iter(self[id] for id in self.entity_names())
def assign_header_from(self, other: ifcopenshell.file) -> None:
for k, vs in HEADER_FIELDS.items():
@@ -1024,7 +883,7 @@ class file:
raise NotImplementedError("Writing .ifcXML files is not supported")
if format == ".ifcZIP":
return self.write(path, ".ifc", zipped=True)
self.wrapped_data.write(str(path))
self.write(str(path))
if zipped:
unzipped_path = path.with_suffix(format)
@@ -1042,23 +901,18 @@ class file:
def from_string(s: str) -> file:
return file(ifcopenshell_wrapper.read(s))
@staticmethod
def from_pointer(address: int) -> file:
assert (f := file_dict[address][0]()) is not None
return f
def to_string(self) -> str:
return self.wrapped_data.to_string()
return self.to_string()
@property
def header(self) -> file_header:
# TODO: Workaround for old builds, remove after build stabilizes.
# TODO: No need for `wrapped_data.header` to be a method - should use `@property`?
header = self.wrapped_data.header
header = self.header
if isinstance(header, types.MethodType):
return file_header(self, self.wrapped_data.header())
return file_header(self, self.header())
else:
return self.wrapped_data.header
return self.header
@property
def storage(self) -> Optional[rocksdb_file_storage]:
@@ -1066,5 +920,5 @@ class file:
Returns:
Optional[rocksdb_file_storage]: underlying key-value store interface when opened as a RocksDB-backed file
"""
if self.wrapped_data.storage_mode() == 1:
if self.storage_mode() == 1:
return rocksdb_file_storage(self)