"""build123d/OCC implementation of the runtime-neutral geometry adapter.""" from __future__ import annotations import math from typing import Any, Iterable from build123d import Axis, Compound, Edge, Face, Plane, Solid, Vector, Wire, export_step from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature def _vector(value: list[float] | tuple[float, float, float]) -> Vector: return Vector(float(value[0]), float(value[1]), float(value[2])) def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector: radius = float(edge.get("radius_mm") or (start - center).length) first = start - center second = end - center if first.length <= 1e-9 or second.length <= 1e-9: return (start + end) / 2 normal = _vector(edge.get("normal") or [0, 0, 1]) if normal.length <= 1e-9: normal = first.cross(second) if normal.length <= 1e-9: normal = Vector(0, 0, 1) normal = normal.normalized() if "clockwise" not in edge: bisector = first.normalized() + second.normalized() if bisector.length <= 1e-9: bisector = normal.cross(first) return center + bisector.normalized() * radius sweep = math.atan2(normal.dot(first.cross(second)), first.dot(second)) if bool(edge["clockwise"]): if sweep >= 0: sweep -= math.tau elif sweep <= 0: sweep += math.tau half = sweep / 2 radius_vector = first.normalized() * radius return center + radius_vector * math.cos(half) + normal.cross(radius_vector) * math.sin(half) class Build123dGeometryAdapter: """All B-rep construction and mutation lives in this adapter.""" @staticmethod def plane(spec: PlaneSpec) -> Plane: return Plane(origin=_vector(spec.origin_mm), x_dir=_vector(spec.x_dir), z_dir=_vector(spec.normal)) @staticmethod def axis(spec: AxisSpec) -> Axis: return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction)) @staticmethod def _wire(edges: list[dict[str, Any]]) -> Wire: built: list[Edge] = [] for edge in edges: start = _vector(edge["start_mm"]) end = _vector(edge["end_mm"]) if edge.get("type") == "arc" and edge.get("center_mm") is not None: center = _vector(edge["center_mm"]) built.append(Edge.make_three_point_arc(start, _arc_midpoint(edge, start, end, center), end)) else: built.append(Edge.make_line(start, end)) return Wire(built) def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire: origin = Vector(*plane_spec.origin_mm) + Vector(*plane_spec.x_dir) * float(center[0]) + Vector(*plane_spec.y_dir) * float(center[1]) circle_plane = Plane(origin=origin, x_dir=Vector(*plane_spec.x_dir), z_dir=Vector(*plane_spec.normal)) return Wire.make_circle(radius, circle_plane) def _faces_from_circles(self, entities: list[dict[str, Any]], plane_spec: PlaneSpec) -> list[Face]: circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")] if not circles: return [] entries = [] for item in circles: radius = float(item.get("radius_mm") or 0) if radius <= 0: continue center = [float(value) for value in item.get("center") or [0, 0]] entries.append({"center": center, "radius": radius, "wire": self._circle_wire(center, radius, plane_spec)}) faces: list[Face] = [] for entry in entries: containing = sum( math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8 for other in entries if other is not entry ) if containing % 2: continue holes = [ other["wire"] for other in entries if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8 and sum( math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8 for candidate in entries if candidate is not other ) == containing + 1 ] face = Face(entry["wire"]) faces.append(face.make_holes(holes) if holes else face) return faces def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]: regions = sketch.get("contour_regions_mm") or [] if regions: result: list[Face] = [] for region in regions: outer = region.get("outer") or [] if len(outer) < 2: continue face = Face(self._wire(outer)) holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 2] result.append(face.make_holes(holes) if holes else face) return result edges = sketch.get("contour_edges_mm") or [] if len(edges) >= 2: return [Face(self._wire(edges))] plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) return self._faces_from_circles(sketch.get("entities") or [], plane) @staticmethod def extrude(face: Face, direction: Vector3) -> Solid: return Solid.extrude(face, _vector(direction)) @staticmethod def body_center(body: Any) -> Vector3: bbox = body.bounding_box() return ((bbox.min.X + bbox.max.X) / 2, (bbox.min.Y + bbox.max.Y) / 2, (bbox.min.Z + bbox.max.Z) / 2) @staticmethod def body_span(body: Any, direction: Vector3) -> float: unit = _vector(direction).normalized() bbox = body.bounding_box() values = [ Vector(x, y, z).dot(unit) for x in (bbox.min.X, bbox.max.X) for y in (bbox.min.Y, bbox.max.Y) for z in (bbox.min.Z, bbox.max.Z) ] return max(values) - min(values) @staticmethod def vertex_coordinates(vertex: Any) -> Vector3: return (float(vertex.X), float(vertex.Y), float(vertex.Z)) @staticmethod def profile_sample_points(face: Face) -> list[Vector]: """Sample a profile face before a selector-dependent termination. A simple vector extrusion is exact only when the selected target is reached at one common distance over the complete profile. Center and boundary samples let the runtime prove that precondition instead of silently constructing a wrong prismatic solid. """ points = [face.center()] for edge in face.edges(): for fraction in (0.0, 0.25, 0.5, 0.75): points.append(edge.position_at(fraction)) unique: list[Vector] = [] for point in points: if not any((point - current).length <= 1e-6 for current in unique): unique.append(point) return unique @staticmethod def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None: try: intersections = target.find_intersection_points(Axis(point, direction)) or [] except Exception as error: raise ValueError("extent target does not support ray intersection") from error distances = [ (hit_point - point).dot(direction) for hit_point, _normal in intersections if (hit_point - point).dot(direction) > 1e-6 ] return min(distances) if distances else None def uniform_intersection_distance(self, target: Any, faces: Iterable[Face], direction: Vector3) -> float: """Return a proven uniform positive target distance for a profile set.""" unit_direction = _vector(direction).normalized() distances: list[float] = [] for face in faces: for point in self.profile_sample_points(face): distance = self._forward_intersection_distance(target, point, unit_direction) if distance is None: raise ValueError("extent target is not reached by every profile ray") distances.append(distance) if not distances: raise ValueError("extent feature has no profile samples") minimum, maximum = min(distances), max(distances) if maximum - minimum > 1e-5: raise ValueError("extent target requires non-uniform profile trimming") return sum(distances) / len(distances) @staticmethod def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid: return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis)) @staticmethod def _body_shape(value: Any) -> Any: """Normalize boolean results, including disconnected ShapeList values.""" if hasattr(value, "bounding_box"): return value shapes = list(value) if not shapes: raise ValueError("Boolean operation produced no shapes") return shapes[0] if len(shapes) == 1 else Compound(shapes) @staticmethod def fuse(body: Any | None, solid: Solid) -> Any: return solid if body is None else Build123dGeometryAdapter._body_shape(body.fuse(solid)) @staticmethod def cut(body: Any, tool: Any) -> Any: return Build123dGeometryAdapter._body_shape(body.cut(tool)) @staticmethod def sphere(radius_mm: float, center_mm: Vector3) -> Solid: return Solid.make_sphere(radius_mm, Plane(origin=_vector(center_mm))) def hole_tool(self, spec: HoleSpec, starts: Iterable[Vector3], inward: Vector3, through_depth_mm: float) -> Solid: """Build a neutral ``HoleSpec`` into one OCC cutting tool.""" depth = through_depth_mm if spec.end_condition != "blind" else spec.depth_mm result: Solid | None = None for start in starts: plane = Plane(origin=_vector(start), z_dir=_vector(inward)) tool = Solid.make_cylinder(spec.diameter_mm / 2, depth, plane) if spec.counterbore: diameter, bore_depth = spec.counterbore tool = tool.fuse(Solid.make_cylinder(diameter / 2, bore_depth, plane)) if spec.countersink: diameter, angle = spec.countersink sink_depth = ((diameter - spec.diameter_mm) / 2) / math.tan(angle / 2) tool = tool.fuse(Solid.make_cone(diameter / 2, spec.diameter_mm / 2, sink_depth, plane)) result = self.fuse(result, tool) if result is None: raise ValueError("hole has no positions") return result @staticmethod def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any: return body.fillet(radius_mm, list(edges)) @staticmethod def tangent_edges(body: Any, seeds: Iterable[Edge], *, angular_tolerance: float = 1e-6) -> list[Edge]: """Expand selected edges through actual tangent, vertex-adjacent chains. The expansion is based solely on the current B-rep. It never uses a global edge set or source stable IDs, and is consequently safe after a body mutation invalidates earlier topology objects. """ edges = list(body.edges()) selected = [edge for edge in seeds] selected_indexes = { index for index, edge in enumerate(edges) if any(edge.is_same(seed) for seed in selected) } if not selected_indexes: return [] def shared_vertex(first: Edge, second: Edge) -> tuple[float, float] | None: first_ends = [(0.0, vertex) for vertex in first.vertices()[:1]] + [(1.0, vertex) for vertex in first.vertices()[-1:]] second_ends = [(0.0, vertex) for vertex in second.vertices()[:1]] + [(1.0, vertex) for vertex in second.vertices()[-1:]] for first_parameter, first_vertex in first_ends: for second_parameter, second_vertex in second_ends: if first_vertex.is_same(second_vertex): return first_parameter, second_parameter return None # Edges sharing a vertex whose tangents are parallel (orientation is # irrelevant) are a tangent-continuous chain. pending = list(selected_indexes) while pending: current_index = pending.pop() for candidate_index, candidate in enumerate(edges): if candidate_index in selected_indexes: continue shared = shared_vertex(edges[current_index], candidate) if shared is None: continue first_tangent = edges[current_index].tangent_at(shared[0]).normalized() second_tangent = candidate.tangent_at(shared[1]).normalized() if abs(abs(first_tangent.dot(second_tangent)) - 1.0) <= angular_tolerance: selected_indexes.add(candidate_index) pending.append(candidate_index) return [edge for index, edge in enumerate(edges) if index in selected_indexes] @staticmethod def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any: return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face) @staticmethod def mirror(body: Any, plane: PlaneSpec) -> Any: return body.mirror(Build123dGeometryAdapter.plane(plane)) @staticmethod def export(body: Any, path: str) -> None: export_step(body, path) @staticmethod def body_geometry(body: Any) -> dict[str, Any]: bbox = body.bounding_box() return { "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], "volume_mm3": float(body.volume), } @staticmethod def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: records: list[TopologyRecord] = [] faces = list(body.faces()) edges = list(body.edges()) vertices = list(body.vertices()) def index_for(shape: Any, candidates: list[Any]) -> int | None: """Map a subshape returned by a face/edge back to body topology.""" for index, candidate in enumerate(candidates): if shape.is_same(candidate): return index return None edge_faces: list[set[int]] = [set() for _edge in edges] for face_index, face in enumerate(faces): for edge in face.edges(): edge_index = index_for(edge, edges) if edge_index is not None: edge_faces[edge_index].add(face_index) vertex_edges: list[set[int]] = [set() for _vertex in vertices] for edge_index, edge in enumerate(edges): for vertex in edge.vertices(): vertex_index = index_for(vertex, vertices) if vertex_index is not None: vertex_edges[vertex_index].add(edge_index) def edge_signature(edge_index: int) -> str: edge = edges[edge_index] return ":".join(( str(edge.geom_type).split(".")[-1].lower(), f"{float(edge.length):.6f}", str(len(edge_faces[edge_index])), )) for index, face in enumerate(faces): bbox = face.bounding_box() center = face.center() normal = face.normal_at() boundary_edge_indexes = [ edge_index for edge in face.edges() if (edge_index := index_for(edge, edges)) is not None ] geometry = { "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], "center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z], "area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(), "adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes), } if geometry["surface_type"] == "plane": plane_normal, plane_offset = canonical_plane_signature( (normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z), ) geometry["plane_normal"] = list(plane_normal) geometry["plane_offset_mm"] = plane_offset records.append(TopologyRecord( record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face, geometry=geometry, )) for index, edge in enumerate(edges): bbox = edge.bounding_box() center = edge.center() vertices = edge.vertices() geometry = { "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], "center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length), "curve_type": str(edge.geom_type).split(".")[-1].lower(), "adjacent_face_count": len(edge_faces[index]), } if vertices: geometry["start_mm"] = list(vertices[0]) geometry["end_mm"] = list(vertices[-1]) records.append(TopologyRecord( record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge, geometry=geometry, )) for index, vertex in enumerate(vertices): point = [vertex.X, vertex.Y, vertex.Z] records.append(TopologyRecord( record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex, geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])}, )) return records