398 lines
18 KiB
Python
398 lines
18 KiB
Python
"""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
|