# Copyright (c) 2022-2024, Manfred Moitzi # License: MIT License from __future__ import annotations from typing import Callable, Type, Any, Sequence, Iterator import abc from . import sab, sat, const, hdr from .const import Features from .abstract import DataLoader, AbstractEntity, DataExporter from .type_hints import EncodedData from ezdxf.math import Matrix44, Vec3, NULLVEC Factory = Callable[[AbstractEntity], "AcisEntity"] ENTITY_TYPES: dict[str, Type[AcisEntity]] = {} INF = float("inf") def load(data: EncodedData) -> list[Body]: """Returns a list of :class:`Body` entities from :term:`SAT` or :term:`SAB` data. Accepts :term:`SAT` data as a single string or a sequence of strings and :term:`SAB` data as bytes or bytearray. """ if isinstance(data, (bytes, bytearray)): return SabLoader.load(data) return SatLoader.load(data) def export_sat( bodies: Sequence[Body], version: int = const.DEFAULT_SAT_VERSION ) -> list[str]: """Export one or more :class:`Body` entities as text based :term:`SAT` data. ACIS version 700 is sufficient for DXF versions R2000, R2004, R2007 and R2010, later DXF versions require :term:`SAB` data. Raises: ExportError: ACIS structures contain unsupported entities InvalidLinkStructure: corrupt link structure """ if version < const.MIN_EXPORT_VERSION: raise const.ExportError(f"invalid ACIS version: {version}") exporter = sat.SatExporter(_setup_export_header(version)) exporter.header.asm_end_marker = False for body in bodies: exporter.export(body) return exporter.dump_sat() def export_sab( bodies: Sequence[Body], version: int = const.DEFAULT_SAB_VERSION ) -> bytes: """Export one or more :class:`Body` entities as binary encoded :term:`SAB` data. ACIS version 21800 is sufficient for DXF versions R2013 and R2018, earlier DXF versions require :term:`SAT` data. Raises: ExportError: ACIS structures contain unsupported entities InvalidLinkStructure: corrupt link structure """ if version < const.MIN_EXPORT_VERSION: raise const.ExportError(f"invalid ACIS version: {version}") exporter = sab.SabExporter(_setup_export_header(version)) exporter.header.asm_end_marker = True for body in bodies: exporter.export(body) return exporter.dump_sab() def _setup_export_header(version) -> hdr.AcisHeader: if not const.is_valid_export_version(version): raise const.ExportError(f"invalid export version: {version}") header = hdr.AcisHeader() header.set_version(version) return header def register(cls): ENTITY_TYPES[cls.type] = cls return cls class NoneEntity: type: str = const.NONE_ENTITY_NAME @property def is_none(self) -> bool: return self.type == const.NONE_ENTITY_NAME NONE_REF: Any = NoneEntity() class AcisEntity(NoneEntity): """Base ACIS entity which also represents unsupported entities. Unsupported entities are entities whose internal structure are not fully known or user defined entity types. The content of these unsupported entities is not loaded and lost by exporting such entities, therefore exporting unsupported entities raises an :class:`ExportError` exception. """ type: str = "unsupported-entity" id: int = -1 attributes: AcisEntity = NONE_REF def __str__(self) -> str: return f"{self.type}({self.id})" def load(self, loader: DataLoader, entity_factory: Factory) -> None: """Load the ACIS entity content from `loader`.""" self.restore_common(loader, entity_factory) self.restore_data(loader) def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: """Load the common part of an ACIS entity.""" pass def restore_data(self, loader: DataLoader) -> None: """Load the data part of an ACIS entity.""" pass def export(self, exporter: DataExporter) -> None: """Write the ACIS entity content to `exporter`.""" self.write_common(exporter) self.write_data(exporter) def write_common(self, exporter: DataExporter) -> None: """Write the common part of the ACIS entity. It is not possible to export :class:`Body` entities including unsupported entities, doing so would cause data loss or worse data corruption! """ raise const.ExportError(f"unsupported entity type: {self.type}") def write_data(self, exporter: DataExporter) -> None: """Write the data part of the ACIS entity.""" pass def entities(self) -> Iterator[AcisEntity]: """Yield all attributes of this entity of type AcisEntity.""" for e in vars(self).values(): if isinstance(e, AcisEntity): yield e def restore_entity( expected_type: str, loader: DataLoader, entity_factory: Factory ) -> Any: raw_entity = loader.read_ptr() if raw_entity.is_null_ptr: return NONE_REF if raw_entity.name.endswith(expected_type): return entity_factory(raw_entity) else: raise const.ParsingError( f"expected entity type '{expected_type}', got '{raw_entity.name}'" ) @register class Transform(AcisEntity): type: str = "transform" matrix = Matrix44() def restore_data(self, loader: DataLoader) -> None: data = loader.read_transform() # insert values of the 4th matrix column (0, 0, 0, 1) data.insert(3, 0.0) data.insert(7, 0.0) data.insert(11, 0.0) data.append(1.0) self.matrix = Matrix44(data) def write_common(self, exporter: DataExporter) -> None: def write_double(value: float): data.append(f"{value:g}") data: list[str] = [] for row in self.matrix.rows(): write_double(row[0]) write_double(row[1]) write_double(row[2]) test_vector = Vec3(1, 0, 0) result = self.matrix.transform_direction(test_vector) # A uniform scaling in x- y- and z-axis is assumed: write_double(round(result.magnitude, 6)) # scale factor is_rotated = not result.normalize().isclose(test_vector) data.append("rotate" if is_rotated else "no_rotate") data.append("no_reflect") data.append("no_shear") exporter.write_transform(data) @register class AsmHeader(AcisEntity): type: str = "asmheader" def __init__(self, version: str = ""): self.version = version def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: self.version = loader.read_str() def write_common(self, exporter: DataExporter) -> None: exporter.write_str(self.version) class SupportsPattern(AcisEntity): pattern: Pattern = NONE_REF def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: if loader.version >= Features.PATTERN: self.pattern = restore_entity("pattern", loader, entity_factory) def write_common(self, exporter: DataExporter) -> None: exporter.write_ptr(self.pattern) @register class Body(SupportsPattern): type: str = "body" pattern: Pattern = NONE_REF lump: Lump = NONE_REF wire: Wire = NONE_REF transform: Transform = NONE_REF def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: super().restore_common(loader, entity_factory) self.lump = restore_entity("lump", loader, entity_factory) self.wire = restore_entity("wire", loader, entity_factory) self.transform = restore_entity("transform", loader, entity_factory) def write_common(self, exporter: DataExporter) -> None: super().write_common(exporter) exporter.write_ptr(self.lump) exporter.write_ptr(self.wire) exporter.write_ptr(self.transform) def append_lump(self, lump: Lump) -> None: """Append a :class:`Lump` entity as last lump.""" lump.body = self if self.lump.is_none: self.lump = lump else: current_lump = self.lump while not current_lump.next_lump.is_none: current_lump = current_lump.next_lump current_lump.next_lump = lump def lumps(self) -> list[Lump]: """Returns all linked :class:`Lump` entities as a list.""" lumps = [] current_lump = self.lump while not current_lump.is_none: lumps.append(current_lump) current_lump = current_lump.next_lump return lumps @register class Wire(SupportsPattern): # not implemented type: str = "wire" @register class Pattern(AcisEntity): # not implemented type: str = "pattern" @register class Lump(SupportsPattern): type: str = "lump" next_lump: Lump = NONE_REF shell: Shell = NONE_REF body: Body = NONE_REF def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: super().restore_common(loader, entity_factory) self.next_lump = restore_entity("lump", loader, entity_factory) self.shell = restore_entity("shell", loader, entity_factory) self.body = restore_entity("body", loader, entity_factory) def write_common(self, exporter: DataExporter) -> None: super().write_common(exporter) exporter.write_ptr(self.next_lump) exporter.write_ptr(self.shell) exporter.write_ptr(self.body) def append_shell(self, shell: Shell) -> None: """Append a :class:`Shell` entity as last shell.""" shell.lump = self if self.shell.is_none: self.shell = shell else: current_shell = self.shell while not current_shell.next_shell.is_none: current_shell = current_shell.next_shell current_shell.next_shell = shell def shells(self) -> list[Shell]: """Returns all linked :class:`Shell` entities as a list.""" shells = [] current_shell = self.shell while not current_shell.is_none: shells.append(current_shell) current_shell = current_shell.next_shell return shells @register class Shell(SupportsPattern): type: str = "shell" next_shell: Shell = NONE_REF subshell: Subshell = NONE_REF face: Face = NONE_REF wire: Wire = NONE_REF lump: Lump = NONE_REF def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: super().restore_common(loader, entity_factory) self.next_shell = restore_entity("next_shell", loader, entity_factory) self.subshell = restore_entity("subshell", loader, entity_factory) self.face = restore_entity("face", loader, entity_factory) self.wire = restore_entity("wire", loader, entity_factory) self.lump = restore_entity("lump", loader, entity_factory) def write_common(self, exporter: DataExporter) -> None: super().write_common(exporter) exporter.write_ptr(self.next_shell) exporter.write_ptr(self.subshell) exporter.write_ptr(self.face) exporter.write_ptr(self.wire) exporter.write_ptr(self.lump) def append_face(self, face: Face) -> None: """Append a :class:`Face` entity as last face.""" face.shell = self if self.face.is_none: self.face = face else: current_face = self.face while not current_face.next_face.is_none: current_face = current_face.next_face current_face.next_face = face def faces(self) -> list[Face]: """Returns all linked :class:`Face` entities as a list.""" faces = [] current_face = self.face while not current_face.is_none: faces.append(current_face) current_face = current_face.next_face return faces @register class Subshell(SupportsPattern): # not implemented type: str = "subshell" @register class Face(SupportsPattern): type: str = "face" next_face: "Face" = NONE_REF loop: Loop = NONE_REF shell: Shell = NONE_REF subshell: Subshell = NONE_REF surface: Surface = NONE_REF # sense: face normal with respect to the surface sense = False # True = reversed; False = forward double_sided = False # True = double (hollow body); False = single (solid body) containment = False # if double_sided: True = in, False = out def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: super().restore_common(loader, entity_factory) self.next_face = restore_entity("face", loader, entity_factory) self.loop = restore_entity("loop", loader, entity_factory) self.shell = restore_entity("shell", loader, entity_factory) self.subshell = restore_entity("subshell", loader, entity_factory) self.surface = restore_entity("surface", loader, entity_factory) self.sense = loader.read_bool("reversed", "forward") self.double_sided = loader.read_bool("double", "single") if self.double_sided: self.containment = loader.read_bool("in", "out") def write_common(self, exporter: DataExporter) -> None: super().write_common(exporter) exporter.write_ptr(self.next_face) exporter.write_ptr(self.loop) exporter.write_ptr(self.shell) exporter.write_ptr(self.subshell) exporter.write_ptr(self.surface) exporter.write_bool(self.sense, "reversed", "forward") exporter.write_bool(self.double_sided, "double", "single") if self.double_sided: exporter.write_bool(self.containment, "in", "out") def append_loop(self, loop: Loop) -> None: """Append a :class:`Loop` entity as last loop.""" loop.face = self if self.loop.is_none: self.loop = loop else: # order of coedges is important! (right-hand rule) current_loop = self.loop while not current_loop.next_loop.is_none: current_loop = current_loop.next_loop current_loop.next_loop = loop def loops(self) -> list[Loop]: """Returns all linked :class:`Loop` entities as a list.""" loops = [] current_loop = self.loop while not current_loop.is_none: loops.append(current_loop) current_loop = current_loop.next_loop return loops @register class Surface(SupportsPattern): type: str = "surface" u_bounds = INF, INF v_bounds = INF, INF def restore_data(self, loader: DataLoader) -> None: self.u_bounds = loader.read_interval(), loader.read_interval() self.v_bounds = loader.read_interval(), loader.read_interval() def write_data(self, exporter: DataExporter): exporter.write_interval(self.u_bounds[0]) exporter.write_interval(self.u_bounds[1]) exporter.write_interval(self.v_bounds[0]) exporter.write_interval(self.v_bounds[1]) @abc.abstractmethod def evaluate(self, u: float, v: float) -> Vec3: """Returns the spatial location at the parametric surface for the given parameters `u` and `v`. """ pass @register class Plane(Surface): type: str = "plane-surface" origin = Vec3(0, 0, 0) normal = Vec3(0, 0, 1) # pointing outside u_dir = Vec3(1, 0, 0) # unit vector! v_dir = Vec3(0, 1, 0) # unit vector! # reverse_v: # True: "reverse_v" - the normal vector does not follow the right-hand rule # False: "forward_v" - the normal vector follows right-hand rule reverse_v = False def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: super().restore_common(loader, entity_factory) self.origin = Vec3(loader.read_vec3()) self.normal = Vec3(loader.read_vec3()) self.u_dir = Vec3(loader.read_vec3()) self.reverse_v = loader.read_bool("reverse_v", "forward_v") self.update_v_dir() def write_common(self, exporter: DataExporter) -> None: super().write_common(exporter) exporter.write_loc_vec3(self.origin) exporter.write_dir_vec3(self.normal) exporter.write_dir_vec3(self.u_dir) exporter.write_bool(self.reverse_v, "reverse_v", "forward_v") # v_dir is not exported def update_v_dir(self): v_dir = self.normal.cross(self.u_dir) if self.reverse_v: v_dir = -v_dir self.v_dir = v_dir def evaluate(self, u: float, v: float) -> Vec3: return self.origin + (self.u_dir * u) + (self.v_dir * v) @register class Loop(SupportsPattern): type: str = "loop" next_loop: Loop = NONE_REF coedge: Coedge = NONE_REF face: Face = NONE_REF # parent/owner def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: super().restore_common(loader, entity_factory) self.next_loop = restore_entity("loop", loader, entity_factory) self.coedge = restore_entity("coedge", loader, entity_factory) self.face = restore_entity("face", loader, entity_factory) def write_common(self, exporter: DataExporter) -> None: super().write_common(exporter) exporter.write_ptr(self.next_loop) exporter.write_ptr(self.coedge) exporter.write_ptr(self.face) def set_coedges(self, coedges: list[Coedge], close=True) -> None: """Set all coedges of a loop at once.""" assert len(coedges) > 0 self.coedge = coedges[0] next_coedges = coedges[1:] prev_coedges = coedges[:-1] if close: next_coedges.append(coedges[0]) prev_coedges.insert(0, coedges[-1]) else: next_coedges.append(NONE_REF) prev_coedges.insert(0, NONE_REF) for coedge, next, prev in zip(coedges, next_coedges, prev_coedges): coedge.loop = self coedge.prev_coedge = prev coedge.next_coedge = next def coedges(self) -> list[Coedge]: """Returns all linked :class:`Coedge` entities as a list.""" coedges = [] current_coedge = self.coedge while not current_coedge.is_none: # open loop if none coedges.append(current_coedge) current_coedge = current_coedge.next_coedge if current_coedge is self.coedge: # circular linked list! break # closed loop return coedges @register class Coedge(SupportsPattern): type: str = "coedge" next_coedge: Coedge = NONE_REF prev_coedge: Coedge = NONE_REF # The partner_coedge points to the coedge of an adjacent face, in a # manifold body each coedge has zero (open) or one (closed) partner edge. # ACIS supports also non-manifold bodies, so there can be more than one # partner coedges which are organized in a circular linked list. partner_coedge: Coedge = NONE_REF edge: Edge = NONE_REF # sense: True = reversed; False = forward; # coedge has the same direction as the underlying edge sense: bool = True loop: Loop = NONE_REF # parent/owner unknown: int = 0 # only in SAB file!? pcurve: PCurve = NONE_REF def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: super().restore_common(loader, entity_factory) self.next_coedge = restore_entity("coedge", loader, entity_factory) self.prev_coedge = restore_entity("coedge", loader, entity_factory) self.partner_coedge = restore_entity("coedge", loader, entity_factory) self.edge = restore_entity("edge", loader, entity_factory) self.sense = loader.read_bool("reversed", "forward") self.loop = restore_entity("loop", loader, entity_factory) self.unknown = loader.read_int(skip_sat=0) self.pcurve = restore_entity("pcurve", loader, entity_factory) def write_common(self, exporter: DataExporter) -> None: super().write_common(exporter) exporter.write_ptr(self.next_coedge) exporter.write_ptr(self.prev_coedge) exporter.write_ptr(self.partner_coedge) exporter.write_ptr(self.edge) exporter.write_bool(self.sense, "reversed", "forward") exporter.write_ptr(self.loop) # TODO: write_int() ? exporter.write_int(0, skip_sat=True) exporter.write_ptr(self.pcurve) def add_partner_coedge(self, coedge: Coedge) -> None: assert coedge.partner_coedge.is_none partner_coedge = self.partner_coedge if partner_coedge.is_none: partner_coedge = self # insert new coedge as first partner coedge: self.partner_coedge = coedge coedge.partner_coedge = partner_coedge self.order_partner_coedges() def order_partner_coedges(self) -> None: # todo: the referenced faces of non-manifold coedges have to be ordered # by the right-hand rule around this edge. pass def partner_coedges(self) -> list[Coedge]: """Returns all partner coedges of this coedge without `self`.""" coedges: list[Coedge] = [] partner_coedge = self.partner_coedge if partner_coedge.is_none: return coedges while True: coedges.append(partner_coedge) partner_coedge = partner_coedge.partner_coedge if partner_coedge.is_none or partner_coedge is self: break return coedges @register class Edge(SupportsPattern): type: str = "edge" # The parent edge of the start_vertex doesn't have to be this edge! start_vertex: Vertex = NONE_REF start_param: float = 0.0 # The parent edge of the end_vertex doesn't have to be this edge! end_vertex: Vertex = NONE_REF end_param: float = 0.0 coedge: Coedge = NONE_REF curve: Curve = NONE_REF # sense: True = reversed; False = forward; # forward: edge has the same direction as the underlying curve sense: bool = False convexity: str = "unknown" def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: super().restore_common(loader, entity_factory) self.start_vertex = restore_entity("vertex", loader, entity_factory) if loader.version >= Features.TOL_MODELING: self.start_param = loader.read_double() self.end_vertex = restore_entity("vertex", loader, entity_factory) if loader.version >= Features.TOL_MODELING: self.end_param = loader.read_double() self.coedge = restore_entity("coedge", loader, entity_factory) self.curve = restore_entity("curve", loader, entity_factory) self.sense = loader.read_bool("reversed", "forward") if loader.version >= Features.TOL_MODELING: self.convexity = loader.read_str() def write_common(self, exporter: DataExporter) -> None: # write support >= version 700 only super().write_common(exporter) exporter.write_ptr(self.start_vertex) exporter.write_double(self.start_param) exporter.write_ptr(self.end_vertex) exporter.write_double(self.end_param) exporter.write_ptr(self.coedge) exporter.write_ptr(self.curve) exporter.write_bool(self.sense, "reversed", "forward") exporter.write_str(self.convexity) @register class PCurve(SupportsPattern): # not implemented type: str = "pcurve" @register class Vertex(SupportsPattern): type: str = "vertex" edge: Edge = NONE_REF ref_count: int = 0 # only in SAB files point: Point = NONE_REF def restore_common(self, loader: DataLoader, entity_factory: Factory) -> None: super().restore_common(loader, entity_factory) self.edge = restore_entity("edge", loader, entity_factory) self.ref_count = loader.read_int(skip_sat=0) self.point = restore_entity("point", loader, entity_factory) def write_common(self, exporter: DataExporter) -> None: super().write_common(exporter) exporter.write_ptr(self.edge) exporter.write_int(self.ref_count, skip_sat=True) exporter.write_ptr(self.point) @register class Curve(SupportsPattern): type: str = "curve" bounds = INF, INF def restore_data(self, loader: DataLoader) -> None: self.bounds = loader.read_interval(), loader.read_interval() def write_data(self, exporter: DataExporter) -> None: exporter.write_interval(self.bounds[0]) exporter.write_interval(self.bounds[1]) @abc.abstractmethod def evaluate(self, param: float) -> Vec3: """Returns the spatial location at the parametric curve for the given parameter. """ pass @register class StraightCurve(Curve): type: str = "straight-curve" origin = Vec3(0, 0, 0) direction = Vec3(1, 0, 0) def restore_data(self, loader: DataLoader) -> None: self.origin = Vec3(loader.read_vec3()) self.direction = Vec3(loader.read_vec3()) super().restore_data(loader) def write_data(self, exporter: DataExporter) -> None: exporter.write_loc_vec3(self.origin) exporter.write_dir_vec3(self.direction) super().write_data(exporter) def evaluate(self, param: float) -> Vec3: return self.origin + (self.direction * param) @register class Point(SupportsPattern): type: str = "point" location: Vec3 = NULLVEC def restore_data(self, loader: DataLoader) -> None: self.location = Vec3(loader.read_vec3()) def write_data(self, exporter: DataExporter) -> None: exporter.write_loc_vec3(self.location) class FileLoader(abc.ABC): records: Sequence[sat.SatEntity | sab.SabEntity] def __init__(self, version: int): self.entities: dict[int, AcisEntity] = {} self.version: int = version def entity_factory(self, raw_entity: AbstractEntity) -> AcisEntity: uid = id(raw_entity) try: return self.entities[uid] except KeyError: # create a new entity entity = ENTITY_TYPES.get(raw_entity.name, AcisEntity)() self.entities[uid] = entity return entity def bodies(self) -> list[Body]: # noinspection PyTypeChecker return [e for e in self.entities.values() if isinstance(e, Body)] def load_entities(self): entity_factory = self.entity_factory for raw_entity in self.records: entity = entity_factory(raw_entity) entity.id = raw_entity.id attributes = raw_entity.attributes if not attributes.is_null_ptr: entity.attributes = entity_factory(attributes) data_loader = self.make_data_loader(raw_entity.data) entity.load(data_loader, entity_factory) @abc.abstractmethod def make_data_loader(self, data: list[Any]) -> DataLoader: pass class SabLoader(FileLoader): def __init__(self, data: bytes | bytearray): builder = sab.parse_sab(data) super().__init__(builder.header.version) self.records = builder.entities def make_data_loader(self, data: list[Any]) -> DataLoader: return sab.SabDataLoader(data, self.version) @classmethod def load(cls, data: bytes | bytearray) -> list[Body]: loader = cls(data) loader.load_entities() return loader.bodies() class SatLoader(FileLoader): def __init__(self, data: str | Sequence[str]): builder = sat.parse_sat(data) super().__init__(builder.header.version) self.records = builder.entities def make_data_loader(self, data: list[Any]) -> DataLoader: return sat.SatDataLoader(data, self.version) @classmethod def load(cls, data: str | Sequence[str]) -> list[Body]: loader = cls(data) loader.load_entities() return loader.bodies()