Import CAD projects and cad-router v1
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
"""SimpleCAD API: a simplified OCP-native Python CAD modeling API."""
|
||||
|
||||
from .core import (
|
||||
# 核心类
|
||||
CoordinateSystem,
|
||||
SimpleWorkplane,
|
||||
Vertex,
|
||||
Edge,
|
||||
Wire,
|
||||
Face,
|
||||
Solid,
|
||||
Compound,
|
||||
AnyShape,
|
||||
TaggedMixin,
|
||||
# 坐标系函数
|
||||
get_current_cs,
|
||||
WORLD_CS,
|
||||
)
|
||||
|
||||
from .operations import (
|
||||
# 基础几何创建
|
||||
make_angle_arc_redge,
|
||||
make_angle_arc_rwire,
|
||||
make_box_rsolid,
|
||||
make_circle_redge,
|
||||
make_circle_rface,
|
||||
make_circle_rwire,
|
||||
make_cone_rsolid,
|
||||
make_cylinder_rsolid,
|
||||
make_face_from_sketch_rface,
|
||||
make_face_from_wire_rface,
|
||||
make_face_from_wires_rface,
|
||||
make_helix_redge,
|
||||
make_helix_rwire,
|
||||
make_line_redge,
|
||||
make_point_rvertex,
|
||||
make_polyline_rwire,
|
||||
make_rectangle_rface,
|
||||
make_rectangle_rwire,
|
||||
make_segment_redge,
|
||||
make_segment_rwire,
|
||||
make_sphere_rsolid,
|
||||
make_sketch_rsketch,
|
||||
make_spline_redge,
|
||||
make_spline_rwire,
|
||||
make_three_point_arc_redge,
|
||||
make_three_point_arc_rwire,
|
||||
make_wire_from_edges_rwire,
|
||||
make_wire_from_sketch_rwire,
|
||||
make_material_rmaterial,
|
||||
make_placement_rplacement,
|
||||
identity_placement_rplacement,
|
||||
make_part_rpart,
|
||||
assign_material_rpart,
|
||||
make_assembly_rassembly,
|
||||
add_component_rassembly,
|
||||
place_component_rassembly,
|
||||
make_compound_from_assembly_rcompound,
|
||||
make_face_connector_rconnector,
|
||||
make_edge_connector_rconnector,
|
||||
make_vertex_connector_rconnector,
|
||||
make_placement_connector_rconnector,
|
||||
add_connector_rpart,
|
||||
add_connector_rassembly,
|
||||
forward_connector_rassembly,
|
||||
make_connector_ref_rconnectorref,
|
||||
make_scalar_limit_rscalarlimit,
|
||||
ground_component_rassembly,
|
||||
unground_component_rassembly,
|
||||
add_fixed_constraint_rassembly,
|
||||
add_revolute_constraint_rassembly,
|
||||
add_prismatic_constraint_rassembly,
|
||||
add_gear_constraint_rassembly,
|
||||
add_belt_constraint_rassembly,
|
||||
add_rack_pinion_constraint_rassembly,
|
||||
solve_assembly_constraints_rassembly,
|
||||
measure_constraint_residual_rconstraintresidual,
|
||||
inspect_assembly_constraints_rconstraintreport,
|
||||
add_point_rsketch,
|
||||
add_circle_rsketch,
|
||||
add_line_rsketch,
|
||||
add_arc_rsketch,
|
||||
add_bspline_rsketch,
|
||||
constrain_angle_rsketch,
|
||||
constrain_coincident_rsketch,
|
||||
constrain_collinear_rsketch,
|
||||
constrain_concentric_rsketch,
|
||||
constrain_connect_rsketch,
|
||||
constrain_diameter_rsketch,
|
||||
constrain_distance_rsketch,
|
||||
constrain_distance_x_rsketch,
|
||||
constrain_distance_y_rsketch,
|
||||
constrain_equal_length_rsketch,
|
||||
constrain_equal_radius_rsketch,
|
||||
constrain_fix_rsketch,
|
||||
constrain_horizontal_rsketch,
|
||||
constrain_length_rsketch,
|
||||
constrain_midpoint_rsketch,
|
||||
constrain_parallel_rsketch,
|
||||
constrain_perpendicular_rsketch,
|
||||
constrain_point_on_rsketch,
|
||||
constrain_radius_rsketch,
|
||||
constrain_symmetric_rsketch,
|
||||
constrain_tangent_rsketch,
|
||||
constrain_vertical_rsketch,
|
||||
get_sketch_entity_rsketchref,
|
||||
get_sketch_point_rsketchref,
|
||||
inspect_sketch_rsketchresult,
|
||||
# 变换操作
|
||||
mirror_shape,
|
||||
rotate_shape,
|
||||
translate_shape,
|
||||
# 3D操作
|
||||
extrude_rsolid,
|
||||
helical_sweep_rsolid,
|
||||
loft_rsolid,
|
||||
revolve_rsolid,
|
||||
sweep_rsolid,
|
||||
# 标签和选择
|
||||
apply_tag,
|
||||
list_tags,
|
||||
select_edges_by_tag,
|
||||
select_faces_by_tag,
|
||||
# 布尔运算
|
||||
cut_rsolid,
|
||||
make_2d_cut_rface,
|
||||
intersect_rsolid,
|
||||
make_2d_intersect_rface,
|
||||
union_rsolid,
|
||||
make_2d_union_rface,
|
||||
# 导出
|
||||
export_step,
|
||||
export_stl,
|
||||
render_screenshot_rpath,
|
||||
# 高级特征操作
|
||||
chamfer_rsolid,
|
||||
fillet_rsolid,
|
||||
shell_rsolid,
|
||||
# 其他
|
||||
linear_pattern_rsolidlist,
|
||||
radial_pattern_rsolidlist,
|
||||
)
|
||||
|
||||
from .evolve import (
|
||||
# 其他
|
||||
make_n_hole_flange_rsolid,
|
||||
make_naca_propeller_blade_rsolid,
|
||||
make_threaded_rod_rsolid,
|
||||
)
|
||||
|
||||
from .graph import GraphSession, suspend_graph_recording
|
||||
from .serializer import export_graph_json, import_graph_json, replay_graph
|
||||
from .serializer import export_session_json, import_session_json
|
||||
from .serializer import export_model_json, import_model_json, replay_model_json
|
||||
from .expr import (
|
||||
Expr,
|
||||
Var,
|
||||
Const,
|
||||
ExpressionGraph,
|
||||
acos,
|
||||
asin,
|
||||
atan,
|
||||
atan2,
|
||||
const,
|
||||
cos,
|
||||
sin,
|
||||
sqrt,
|
||||
tan,
|
||||
var,
|
||||
)
|
||||
from .math import BSplineFitResult, fit_cubic_bspline_control_points
|
||||
from .product import (
|
||||
Assembly,
|
||||
Component,
|
||||
Connector,
|
||||
ConnectorAnchor,
|
||||
ConnectorRef,
|
||||
Constraint,
|
||||
ConstraintReport,
|
||||
ConstraintResidual,
|
||||
GeometryRef,
|
||||
ConstraintResidual,
|
||||
Material,
|
||||
Part,
|
||||
Placement,
|
||||
ScalarLimit,
|
||||
)
|
||||
from .sketch import Sketch, SketchConstraint, SketchConstraintDiagnostic, SketchRef, SketchSolveResult
|
||||
from .topology import SemanticDelta, SemanticRef
|
||||
from .errors import SimpleCADError
|
||||
|
||||
from . import ql
|
||||
from . import math
|
||||
from . import std
|
||||
from . import translator
|
||||
from . import verifier
|
||||
|
||||
# Avoid advertising internal implementation submodules from the top-level package
|
||||
# namespace. They remain importable as `simplecadapi.<module>` when needed.
|
||||
for _name in ("tracking", "autotag", "topology", "graph", "serializer"):
|
||||
globals().pop(_name, None)
|
||||
|
||||
__author__ = "SimpleCAD API Team"
|
||||
__description__ = "Simplified OCP-native CAD modeling Python API"
|
||||
|
||||
# 便于使用的别名
|
||||
Workplane = SimpleWorkplane
|
||||
|
||||
# 创建函数别名
|
||||
create_angle_arc = make_angle_arc_redge
|
||||
create_angle_arc_wire = make_angle_arc_rwire
|
||||
create_arc = make_three_point_arc_redge
|
||||
create_arc_wire = make_three_point_arc_rwire
|
||||
create_box = make_box_rsolid
|
||||
create_circle_edge = make_circle_redge
|
||||
create_circle_face = make_circle_rface
|
||||
create_circle_wire = make_circle_rwire
|
||||
create_cylinder = make_cylinder_rsolid
|
||||
create_face_from_wire = make_face_from_wire_rface
|
||||
create_face_from_wires = make_face_from_wires_rface
|
||||
create_helix = make_helix_redge
|
||||
create_helix_wire = make_helix_rwire
|
||||
create_line = make_line_redge
|
||||
create_point = make_point_rvertex
|
||||
create_polyline_wire = make_polyline_rwire
|
||||
create_rectangle_face = make_rectangle_rface
|
||||
create_rectangle_wire = make_rectangle_rwire
|
||||
create_segment = make_segment_redge
|
||||
create_segment_wire = make_segment_rwire
|
||||
create_sphere = make_sphere_rsolid
|
||||
create_spline = make_spline_redge
|
||||
create_spline_wire = make_spline_rwire
|
||||
create_wire_from_edges = make_wire_from_edges_rwire
|
||||
|
||||
# 变换操作别名
|
||||
rotate = rotate_shape
|
||||
translate = translate_shape
|
||||
|
||||
# 3D操作别名
|
||||
extrude = extrude_rsolid
|
||||
revolve = revolve_rsolid
|
||||
|
||||
# 布尔运算别名
|
||||
cut = cut_rsolid
|
||||
intersect = intersect_rsolid
|
||||
union = union_rsolid
|
||||
|
||||
# 导出别名
|
||||
to_step = export_step
|
||||
to_stl = export_stl
|
||||
|
||||
__all__ = [
|
||||
# 核心类
|
||||
"CoordinateSystem",
|
||||
"SimpleWorkplane",
|
||||
"Workplane",
|
||||
"Vertex",
|
||||
"Edge",
|
||||
"Wire",
|
||||
"Face",
|
||||
"Solid",
|
||||
"Compound",
|
||||
"AnyShape",
|
||||
"TaggedMixin",
|
||||
# 坐标系
|
||||
"get_current_cs",
|
||||
"WORLD_CS",
|
||||
# 基础几何创建
|
||||
"make_angle_arc_redge",
|
||||
"make_angle_arc_rwire",
|
||||
"make_box_rsolid",
|
||||
"make_circle_redge",
|
||||
"make_circle_rface",
|
||||
"make_circle_rwire",
|
||||
"make_cone_rsolid",
|
||||
"make_cylinder_rsolid",
|
||||
"make_face_from_sketch_rface",
|
||||
"make_face_from_wire_rface",
|
||||
"make_face_from_wires_rface",
|
||||
"make_helix_redge",
|
||||
"make_helix_rwire",
|
||||
"make_line_redge",
|
||||
"make_point_rvertex",
|
||||
"make_polyline_rwire",
|
||||
"make_rectangle_rface",
|
||||
"make_rectangle_rwire",
|
||||
"make_segment_redge",
|
||||
"make_segment_rwire",
|
||||
"make_sphere_rsolid",
|
||||
"make_sketch_rsketch",
|
||||
"make_spline_redge",
|
||||
"make_spline_rwire",
|
||||
"make_three_point_arc_redge",
|
||||
"make_three_point_arc_rwire",
|
||||
"make_wire_from_edges_rwire",
|
||||
"make_wire_from_sketch_rwire",
|
||||
"make_material_rmaterial",
|
||||
"make_placement_rplacement",
|
||||
"identity_placement_rplacement",
|
||||
"make_part_rpart",
|
||||
"assign_material_rpart",
|
||||
"make_assembly_rassembly",
|
||||
"add_component_rassembly",
|
||||
"place_component_rassembly",
|
||||
"make_compound_from_assembly_rcompound",
|
||||
"make_face_connector_rconnector",
|
||||
"make_edge_connector_rconnector",
|
||||
"make_vertex_connector_rconnector",
|
||||
"make_placement_connector_rconnector",
|
||||
"add_connector_rpart",
|
||||
"add_connector_rassembly",
|
||||
"forward_connector_rassembly",
|
||||
"make_connector_ref_rconnectorref",
|
||||
"make_scalar_limit_rscalarlimit",
|
||||
"ground_component_rassembly",
|
||||
"unground_component_rassembly",
|
||||
"add_fixed_constraint_rassembly",
|
||||
"add_revolute_constraint_rassembly",
|
||||
"add_prismatic_constraint_rassembly",
|
||||
"add_gear_constraint_rassembly",
|
||||
"add_belt_constraint_rassembly",
|
||||
"add_rack_pinion_constraint_rassembly",
|
||||
"solve_assembly_constraints_rassembly",
|
||||
"measure_constraint_residual_rconstraintresidual",
|
||||
"inspect_assembly_constraints_rconstraintreport",
|
||||
# Sketch construction and constraints
|
||||
"add_point_rsketch",
|
||||
"add_circle_rsketch",
|
||||
"add_line_rsketch",
|
||||
"add_arc_rsketch",
|
||||
"add_bspline_rsketch",
|
||||
"constrain_angle_rsketch",
|
||||
"constrain_coincident_rsketch",
|
||||
"constrain_collinear_rsketch",
|
||||
"constrain_concentric_rsketch",
|
||||
"constrain_connect_rsketch",
|
||||
"constrain_diameter_rsketch",
|
||||
"constrain_distance_rsketch",
|
||||
"constrain_distance_x_rsketch",
|
||||
"constrain_distance_y_rsketch",
|
||||
"constrain_equal_length_rsketch",
|
||||
"constrain_equal_radius_rsketch",
|
||||
"constrain_fix_rsketch",
|
||||
"constrain_horizontal_rsketch",
|
||||
"constrain_length_rsketch",
|
||||
"constrain_midpoint_rsketch",
|
||||
"constrain_parallel_rsketch",
|
||||
"constrain_perpendicular_rsketch",
|
||||
"constrain_point_on_rsketch",
|
||||
"constrain_radius_rsketch",
|
||||
"constrain_symmetric_rsketch",
|
||||
"constrain_tangent_rsketch",
|
||||
"constrain_vertical_rsketch",
|
||||
"get_sketch_entity_rsketchref",
|
||||
"get_sketch_point_rsketchref",
|
||||
"inspect_sketch_rsketchresult",
|
||||
# 变换操作
|
||||
"mirror_shape",
|
||||
"rotate_shape",
|
||||
"translate_shape",
|
||||
# 3D操作
|
||||
"extrude_rsolid",
|
||||
"helical_sweep_rsolid",
|
||||
"loft_rsolid",
|
||||
"revolve_rsolid",
|
||||
"sweep_rsolid",
|
||||
# 标签和选择
|
||||
"apply_tag",
|
||||
"list_tags",
|
||||
"select_edges_by_tag",
|
||||
"select_faces_by_tag",
|
||||
# 布尔运算
|
||||
"cut_rsolid",
|
||||
"make_2d_cut_rface",
|
||||
"intersect_rsolid",
|
||||
"make_2d_intersect_rface",
|
||||
"union_rsolid",
|
||||
"make_2d_union_rface",
|
||||
# 导出
|
||||
"export_step",
|
||||
"export_stl",
|
||||
"replay_model_json",
|
||||
"render_screenshot_rpath",
|
||||
# 高级特征操作
|
||||
"chamfer_rsolid",
|
||||
"fillet_rsolid",
|
||||
"shell_rsolid",
|
||||
# 其他
|
||||
"linear_pattern_rsolidlist",
|
||||
"math",
|
||||
"make_n_hole_flange_rsolid",
|
||||
"make_naca_propeller_blade_rsolid",
|
||||
"make_threaded_rod_rsolid",
|
||||
"radial_pattern_rsolidlist",
|
||||
"ql",
|
||||
"std",
|
||||
"translator",
|
||||
"verifier",
|
||||
# Graph/session + serialization APIs
|
||||
"GraphSession",
|
||||
"suspend_graph_recording",
|
||||
"export_graph_json",
|
||||
"import_graph_json",
|
||||
"replay_graph",
|
||||
"export_session_json",
|
||||
"import_session_json",
|
||||
"export_model_json",
|
||||
"import_model_json",
|
||||
"Expr",
|
||||
"Var",
|
||||
"Const",
|
||||
"ExpressionGraph",
|
||||
"BSplineFitResult",
|
||||
"acos",
|
||||
"asin",
|
||||
"atan",
|
||||
"atan2",
|
||||
"const",
|
||||
"cos",
|
||||
"sin",
|
||||
"sqrt",
|
||||
"tan",
|
||||
"var",
|
||||
"fit_cubic_bspline_control_points",
|
||||
"Sketch",
|
||||
"SketchConstraint",
|
||||
"SketchConstraintDiagnostic",
|
||||
"SketchRef",
|
||||
"SketchSolveResult",
|
||||
"SemanticRef",
|
||||
"SemanticDelta",
|
||||
"SimpleCADError",
|
||||
"Assembly",
|
||||
"Component",
|
||||
"Connector",
|
||||
"ConnectorAnchor",
|
||||
"ConnectorRef",
|
||||
"Constraint",
|
||||
"ConstraintReport",
|
||||
"ConstraintResidual",
|
||||
"GeometryRef",
|
||||
"Material",
|
||||
"Part",
|
||||
"Placement",
|
||||
"ScalarLimit",
|
||||
# 别名
|
||||
"create_angle_arc",
|
||||
"create_angle_arc_wire",
|
||||
"create_arc",
|
||||
"create_arc_wire",
|
||||
"create_box",
|
||||
"create_circle_edge",
|
||||
"create_circle_face",
|
||||
"create_circle_wire",
|
||||
"create_cylinder",
|
||||
"create_face_from_wire",
|
||||
"create_face_from_wires",
|
||||
"create_helix",
|
||||
"create_helix_wire",
|
||||
"create_line",
|
||||
"create_point",
|
||||
"create_polyline_wire",
|
||||
"create_rectangle_face",
|
||||
"create_rectangle_wire",
|
||||
"create_segment",
|
||||
"create_segment_wire",
|
||||
"create_sphere",
|
||||
"create_spline",
|
||||
"create_spline_wire",
|
||||
"create_wire_from_edges",
|
||||
"cut",
|
||||
"extrude",
|
||||
"intersect",
|
||||
"revolve",
|
||||
"rotate",
|
||||
"to_step",
|
||||
"to_stl",
|
||||
"translate",
|
||||
"union",
|
||||
]
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Internal mesh companion data for SimpleCAD geometry.
|
||||
|
||||
This module intentionally stays out of the public API. Users should not need to
|
||||
extract or manage meshes directly; future structural checks will consume this
|
||||
cache internally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .kernel.ocp_mesh import tessellate_face
|
||||
|
||||
|
||||
DEFAULT_LINEAR_TOLERANCE = 0.35
|
||||
DEFAULT_ANGULAR_TOLERANCE = 0.22
|
||||
MESH_RUNTIME_KEY = "mesh.default"
|
||||
MESH_ERROR_RUNTIME_KEY = "mesh.error"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FaceTriangleRange:
|
||||
"""Internal mapping from a source face to a contiguous triangle range."""
|
||||
|
||||
face_index: int
|
||||
start: int
|
||||
count: int
|
||||
source_topo_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TriMesh:
|
||||
"""Internal triangle mesh data attached to OCP-backed solids."""
|
||||
|
||||
vertices: np.ndarray
|
||||
triangles: np.ndarray
|
||||
face_triangle_ranges: Tuple[FaceTriangleRange, ...]
|
||||
linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE
|
||||
angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
vertices = np.asarray(self.vertices, dtype=float)
|
||||
triangles = np.asarray(self.triangles, dtype=np.int64)
|
||||
|
||||
if vertices.ndim != 2 or vertices.shape[1] != 3:
|
||||
raise ValueError("TriMesh vertices must have shape (n, 3)")
|
||||
if triangles.ndim != 2 or triangles.shape[1] != 3:
|
||||
raise ValueError("TriMesh triangles must have shape (m, 3)")
|
||||
if not np.all(np.isfinite(vertices)):
|
||||
raise ValueError("TriMesh vertices must be finite")
|
||||
if len(triangles) and (
|
||||
int(triangles.min()) < 0 or int(triangles.max()) >= len(vertices)
|
||||
):
|
||||
raise ValueError("TriMesh triangle indices are out of bounds")
|
||||
|
||||
object.__setattr__(self, "vertices", vertices)
|
||||
object.__setattr__(self, "triangles", triangles)
|
||||
|
||||
@property
|
||||
def vertex_count(self) -> int:
|
||||
return int(self.vertices.shape[0])
|
||||
|
||||
@property
|
||||
def triangle_count(self) -> int:
|
||||
return int(self.triangles.shape[0])
|
||||
|
||||
@property
|
||||
def bounds(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
||||
if self.vertex_count == 0:
|
||||
zero = (0.0, 0.0, 0.0)
|
||||
return zero, zero
|
||||
lower = self.vertices.min(axis=0)
|
||||
upper = self.vertices.max(axis=0)
|
||||
return tuple(float(v) for v in lower), tuple(float(v) for v in upper)
|
||||
|
||||
|
||||
def build_solid_trimesh(
|
||||
solid: Any,
|
||||
*,
|
||||
linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE,
|
||||
angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE,
|
||||
) -> TriMesh:
|
||||
"""Build an internal triangle mesh from an OCP-backed Solid wrapper."""
|
||||
|
||||
all_vertices: list[tuple[float, float, float]] = []
|
||||
all_triangles: list[tuple[int, int, int]] = []
|
||||
face_ranges: list[FaceTriangleRange] = []
|
||||
|
||||
for face_index, face in enumerate(solid.get_faces()):
|
||||
start = len(all_triangles)
|
||||
vertices, triangles = tessellate_face(
|
||||
face.wrapped,
|
||||
tolerance=linear_tolerance,
|
||||
angular_tolerance=angular_tolerance,
|
||||
)
|
||||
vertex_offset = len(all_vertices)
|
||||
all_vertices.extend((float(x), float(y), float(z)) for x, y, z in vertices)
|
||||
all_triangles.extend(
|
||||
(
|
||||
int(a) + vertex_offset,
|
||||
int(b) + vertex_offset,
|
||||
int(c) + vertex_offset,
|
||||
)
|
||||
for a, b, c in triangles
|
||||
)
|
||||
face_ranges.append(
|
||||
FaceTriangleRange(
|
||||
face_index=face_index,
|
||||
start=start,
|
||||
count=len(all_triangles) - start,
|
||||
source_topo_id=getattr(face, "topo_id", None),
|
||||
)
|
||||
)
|
||||
|
||||
return TriMesh(
|
||||
vertices=np.asarray(all_vertices, dtype=float).reshape((-1, 3)),
|
||||
triangles=np.asarray(all_triangles, dtype=np.int64).reshape((-1, 3)),
|
||||
face_triangle_ranges=tuple(face_ranges),
|
||||
linear_tolerance=float(linear_tolerance),
|
||||
angular_tolerance=float(angular_tolerance),
|
||||
)
|
||||
|
||||
|
||||
def attach_default_mesh(solid: Any) -> None:
|
||||
"""Attach the default internal mesh cache to a Solid wrapper if possible."""
|
||||
|
||||
try:
|
||||
mesh = build_solid_trimesh(solid)
|
||||
except Exception as exc:
|
||||
_set_runtime(solid, MESH_ERROR_RUNTIME_KEY, str(exc))
|
||||
return
|
||||
|
||||
_set_runtime(solid, MESH_RUNTIME_KEY, mesh)
|
||||
_set_runtime(solid, MESH_ERROR_RUNTIME_KEY, None)
|
||||
|
||||
|
||||
def cached_mesh(solid: Any) -> TriMesh | None:
|
||||
"""Return the internal mesh cache for tests and future checkers."""
|
||||
|
||||
mesh = _get_runtime(solid, MESH_RUNTIME_KEY, None)
|
||||
return mesh if isinstance(mesh, TriMesh) else None
|
||||
|
||||
|
||||
def mesh_error(solid: Any) -> str | None:
|
||||
"""Return the internal mesh diagnostic for tests and future checkers."""
|
||||
|
||||
value = _get_runtime(solid, MESH_ERROR_RUNTIME_KEY, None)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _set_runtime(shape: Any, key: str, value: Any) -> None:
|
||||
setter = getattr(shape, "_set_runtime", None)
|
||||
if callable(setter):
|
||||
setter(key, value)
|
||||
|
||||
|
||||
def _get_runtime(shape: Any, key: str, default: Any) -> Any:
|
||||
getter = getattr(shape, "_get_runtime", None)
|
||||
if callable(getter):
|
||||
return getter(key, default)
|
||||
return default
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Targeted warning filters for noisy third-party imports.
|
||||
|
||||
These filters intentionally suppress only known upstream warnings that are not
|
||||
actionable for SimpleCADAPI users and would otherwise pollute LLM-facing error
|
||||
output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
|
||||
_SWIG_MODULE_WARNING = (
|
||||
r"builtin type (SwigPyPacked|SwigPyObject|swigvarlink) has no __module__ attribute"
|
||||
)
|
||||
|
||||
|
||||
def suppress_vendor_deprecation_warnings() -> None:
|
||||
"""Suppress precise third-party deprecation warnings we cannot fix locally."""
|
||||
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=_SWIG_MODULE_WARNING,
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automatically extract modeling functions from a script and append them to `evolve.py`."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
EVOLVE_FILE = Path(__file__).parent.parent / "evolve.py"
|
||||
|
||||
|
||||
def extract_source_code_from_file(file_path: Path):
|
||||
"""Read the source text from a file."""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
source_code = f.read()
|
||||
return source_code
|
||||
|
||||
|
||||
def extract_functions_from_source(source_code: str):
|
||||
"""Extract full function implementations from source code."""
|
||||
import ast
|
||||
|
||||
class FunctionBodyExtractor(ast.NodeVisitor):
|
||||
def __init__(self):
|
||||
self.function_bodies = {}
|
||||
|
||||
def visit_FunctionDef(self, node):
|
||||
# 注意:lineno 和 end_lineno 是从1开始的
|
||||
if hasattr(node, "end_lineno"):
|
||||
lines = source_code.splitlines()
|
||||
func_code = "\n".join(lines[node.lineno - 1 : node.end_lineno])
|
||||
self.function_bodies[node.name] = func_code
|
||||
self.generic_visit(node)
|
||||
|
||||
tree = ast.parse(source_code)
|
||||
extractor = FunctionBodyExtractor()
|
||||
extractor.visit(tree)
|
||||
return [function for function in extractor.function_bodies.values()]
|
||||
|
||||
|
||||
def extract_import_from_source(source_code: str) -> list[str]:
|
||||
"""Extract module-level import statements from source code."""
|
||||
import ast
|
||||
|
||||
try:
|
||||
tree = ast.parse(source_code)
|
||||
except SyntaxError as e:
|
||||
print(f"警告: 解析源码时出现语法错误: {e}")
|
||||
return [] # 或者可以抛出异常
|
||||
|
||||
imports = []
|
||||
|
||||
# 遍历模块主体 (module.body)
|
||||
for node in tree.body:
|
||||
# 检查是否是导入语句
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
imports.append(f"import {alias.name}")
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
module_name = node.module if node.module else ""
|
||||
for alias in node.names:
|
||||
imports.append(f"from {module_name} import {alias.name}")
|
||||
elif isinstance(node, ast.FunctionDef) or isinstance(node, ast.ClassDef):
|
||||
# 一旦遇到函数或类定义,就认为头部导入部分结束
|
||||
# 根据Python惯例,导入通常在文件顶部
|
||||
break
|
||||
|
||||
return imports
|
||||
|
||||
|
||||
def combine_import_into_function(imports: list, function: str):
|
||||
"""Insert import statements into a function body string."""
|
||||
if not imports:
|
||||
return function
|
||||
|
||||
lines = function.splitlines()
|
||||
if not lines:
|
||||
return function
|
||||
|
||||
insert_at = _find_import_insert_index(lines)
|
||||
import_lines = [f" {item}" for item in imports]
|
||||
|
||||
merged_lines = lines[:insert_at] + [""] + import_lines + [""] + lines[insert_at:]
|
||||
return "\n".join(merged_lines)
|
||||
|
||||
|
||||
def _find_import_insert_index(lines: list[str]) -> int:
|
||||
"""Find where imports should be inserted inside a function."""
|
||||
if len(lines) <= 1:
|
||||
return len(lines)
|
||||
|
||||
index = 1
|
||||
while index < len(lines) and not lines[index].strip():
|
||||
index += 1
|
||||
|
||||
if index >= len(lines):
|
||||
return 1
|
||||
|
||||
stripped = lines[index].strip()
|
||||
if not (stripped.startswith('"""') or stripped.startswith("'''")):
|
||||
return 1
|
||||
|
||||
quote = '"""' if stripped.startswith('"""') else "'''"
|
||||
|
||||
if stripped.count(quote) >= 2 and len(stripped) > len(quote) * 2:
|
||||
return index + 1
|
||||
|
||||
index += 1
|
||||
while index < len(lines):
|
||||
if quote in lines[index]:
|
||||
return index + 1
|
||||
index += 1
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract functions from a Python file and add them to evolve.py."
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"file_path", type=str, help="Path to the Python file to extract functions from."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--evolve_file",
|
||||
type=str,
|
||||
default=str(EVOLVE_FILE),
|
||||
help="Path to the evolve.py file to add functions to.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
file_path = Path(args.file_path)
|
||||
evolve_file = Path(args.evolve_file)
|
||||
|
||||
if not file_path.exists():
|
||||
print(f"错误: 文件 {file_path} 不存在。")
|
||||
exit(1)
|
||||
|
||||
if not evolve_file.exists():
|
||||
print(f"错误: evolve.py 文件 {evolve_file} 不存在。")
|
||||
exit(1)
|
||||
|
||||
# 提取源代码
|
||||
source_code = extract_source_code_from_file(file_path)
|
||||
if not source_code.strip():
|
||||
print(f"警告: 文件 {file_path} 为空或只包含空白字符。")
|
||||
exit(1)
|
||||
|
||||
# 提取函数
|
||||
function_body = extract_functions_from_source(source_code)[0]
|
||||
if not function_body:
|
||||
print(f"警告: 文件 {file_path} 中未找到任何函数。")
|
||||
exit(1)
|
||||
|
||||
# 提取导入语句
|
||||
imports = extract_import_from_source(source_code)
|
||||
|
||||
result_function = combine_import_into_function(imports, function_body)
|
||||
|
||||
# 追加到 evolve.py
|
||||
with open(evolve_file, "a", encoding="utf-8") as f:
|
||||
f.write("\n\n" + result_function + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,618 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update `simplecadapi/__init__.py` exports from source modules.
|
||||
|
||||
This tool supports two common workflows:
|
||||
- `--show-api-only`: inspect the public API grouped by module and category.
|
||||
- default mode: regenerate `__init__.py` from the current module set.
|
||||
|
||||
It reads module files directly from the installed package location, so it works both
|
||||
from a source checkout and from an installed virtual environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Mapping, Sequence, Tuple
|
||||
|
||||
|
||||
PACKAGE_NAME = "simplecadapi"
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
||||
INIT_FILE = PACKAGE_ROOT / "__init__.py"
|
||||
|
||||
CORE_EXPORTS = [
|
||||
"CoordinateSystem",
|
||||
"SimpleWorkplane",
|
||||
"Vertex",
|
||||
"Edge",
|
||||
"Wire",
|
||||
"Face",
|
||||
"Solid",
|
||||
"AnyShape",
|
||||
"TaggedMixin",
|
||||
"get_current_cs",
|
||||
"WORLD_CS",
|
||||
]
|
||||
|
||||
MODULE_ORDER = ("operations", "evolve", "ql")
|
||||
MODULE_LABELS = {
|
||||
"operations": "Operations",
|
||||
"evolve": "Evolve",
|
||||
"ql": "QL",
|
||||
}
|
||||
|
||||
FUNCTION_CATEGORIES = {
|
||||
"基础几何创建": [
|
||||
"make_point_",
|
||||
"make_line_",
|
||||
"make_segment_",
|
||||
"make_circle_",
|
||||
"make_rectangle_",
|
||||
"make_box_",
|
||||
"make_cylinder_",
|
||||
"make_sphere_",
|
||||
"make_angle_arc_",
|
||||
"make_three_point_arc_",
|
||||
"make_spline_",
|
||||
"make_polyline_",
|
||||
"make_helix_",
|
||||
"make_face_from_wire_",
|
||||
"make_wire_from_edges_",
|
||||
"make_cone_",
|
||||
],
|
||||
"变换操作": ["translate_", "rotate_", "scale_", "mirror_"],
|
||||
"3D操作": ["extrude_", "revolve_", "loft_", "sweep_", "helical_sweep_"],
|
||||
"标签和选择": ["apply_tag", "list_tags", "select_faces_", "select_edges_"],
|
||||
"布尔运算": ["union_", "cut_", "intersect_", "difference_"],
|
||||
"导出": ["export_", "render_"],
|
||||
"高级特征操作": ["fillet_", "chamfer_", "shell_", "pattern_", "array_"],
|
||||
}
|
||||
|
||||
QL_CATEGORIES = {
|
||||
"标签与元数据谓词": ["tag", "meta", "geo"],
|
||||
"逻辑组合": ["and_", "or_", "not_"],
|
||||
"查询与取值": ["select", "value"],
|
||||
}
|
||||
|
||||
ALIAS_RULES = {
|
||||
"make_point_rvertex": "create_point",
|
||||
"make_line_redge": "create_line",
|
||||
"make_segment_redge": "create_segment",
|
||||
"make_segment_rwire": "create_segment_wire",
|
||||
"make_circle_redge": "create_circle_edge",
|
||||
"make_circle_rwire": "create_circle_wire",
|
||||
"make_circle_rface": "create_circle_face",
|
||||
"make_rectangle_rwire": "create_rectangle_wire",
|
||||
"make_rectangle_rface": "create_rectangle_face",
|
||||
"make_box_rsolid": "create_box",
|
||||
"make_cylinder_rsolid": "create_cylinder",
|
||||
"make_sphere_rsolid": "create_sphere",
|
||||
"make_angle_arc_redge": "create_angle_arc",
|
||||
"make_angle_arc_rwire": "create_angle_arc_wire",
|
||||
"make_three_point_arc_redge": "create_arc",
|
||||
"make_three_point_arc_rwire": "create_arc_wire",
|
||||
"make_spline_redge": "create_spline",
|
||||
"make_spline_rwire": "create_spline_wire",
|
||||
"make_polyline_rwire": "create_polyline_wire",
|
||||
"make_helix_redge": "create_helix",
|
||||
"make_helix_rwire": "create_helix_wire",
|
||||
"make_face_from_wire_rface": "create_face_from_wire",
|
||||
"make_wire_from_edges_rwire": "create_wire_from_edges",
|
||||
"translate_shape": "translate",
|
||||
"rotate_shape": "rotate",
|
||||
"extrude_rsolid": "extrude",
|
||||
"revolve_rsolid": "revolve",
|
||||
"union_rsolid": "union",
|
||||
"cut_rsolid": "cut",
|
||||
"intersect_rsolid": "intersect",
|
||||
"export_step": "to_step",
|
||||
"export_stl": "to_stl",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModuleInventory:
|
||||
name: str
|
||||
display_name: str
|
||||
functions: List[str] = field(default_factory=list)
|
||||
classes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _module_file(module_name: str, package_root: Path | None = None) -> Path:
|
||||
root = package_root.resolve() if package_root is not None else PACKAGE_ROOT
|
||||
return root / f"{module_name}.py"
|
||||
|
||||
|
||||
def _parse_module(file_path: Path) -> ast.Module | None:
|
||||
if not file_path.exists():
|
||||
print(f"警告: {file_path} 文件不存在")
|
||||
return None
|
||||
|
||||
try:
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
return ast.parse(source, filename=str(file_path))
|
||||
except Exception as exc:
|
||||
print(f"警告: 解析 {file_path} 失败: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_public_functions(file_path: Path) -> List[str]:
|
||||
tree = _parse_module(file_path)
|
||||
if tree is None:
|
||||
return []
|
||||
|
||||
functions: List[str] = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
|
||||
functions.append(node.name)
|
||||
return functions
|
||||
|
||||
|
||||
def extract_public_classes(file_path: Path) -> List[str]:
|
||||
tree = _parse_module(file_path)
|
||||
if tree is None:
|
||||
return []
|
||||
|
||||
classes: List[str] = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ClassDef) and not node.name.startswith("_"):
|
||||
classes.append(node.name)
|
||||
return classes
|
||||
|
||||
|
||||
def collect_api_inventory(
|
||||
package_root: Path | None = None,
|
||||
) -> Dict[str, ModuleInventory]:
|
||||
root = package_root.resolve() if package_root is not None else PACKAGE_ROOT
|
||||
inventory: Dict[str, ModuleInventory] = {}
|
||||
|
||||
for module_name in MODULE_ORDER:
|
||||
file_path = _module_file(module_name, root)
|
||||
inventory[module_name] = ModuleInventory(
|
||||
name=module_name,
|
||||
display_name=MODULE_LABELS[module_name],
|
||||
functions=extract_public_functions(file_path),
|
||||
classes=[],
|
||||
)
|
||||
|
||||
return inventory
|
||||
|
||||
|
||||
def categorize_functions(
|
||||
functions: Sequence[str],
|
||||
category_rules: Mapping[str, Sequence[str]],
|
||||
fallback_label: str = "其他",
|
||||
) -> Dict[str, List[str]]:
|
||||
categorized: Dict[str, List[str]] = {name: [] for name in category_rules}
|
||||
|
||||
for func in functions:
|
||||
matched = False
|
||||
for category, prefixes in category_rules.items():
|
||||
if any(func.startswith(prefix) for prefix in prefixes):
|
||||
categorized[category].append(func)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
categorized.setdefault(fallback_label, []).append(func)
|
||||
|
||||
return {name: sorted(funcs) for name, funcs in categorized.items() if funcs}
|
||||
|
||||
|
||||
def categorize_module(
|
||||
module_name: str, functions: Sequence[str]
|
||||
) -> Dict[str, List[str]]:
|
||||
if module_name in {"operations", "evolve"}:
|
||||
return categorize_functions(functions, FUNCTION_CATEGORIES)
|
||||
if module_name == "ql":
|
||||
return categorize_functions(functions, QL_CATEGORIES)
|
||||
return {"其他": list(functions)} if functions else {}
|
||||
|
||||
|
||||
def generate_core_imports() -> str:
|
||||
lines = ["from .core import (", " # 核心类"]
|
||||
|
||||
for name in CORE_EXPORTS[:-2]:
|
||||
lines.append(f" {name},")
|
||||
|
||||
lines.append(" # 坐标系函数")
|
||||
for name in CORE_EXPORTS[-2:]:
|
||||
lines.append(f" {name},")
|
||||
lines.append(")")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_import_block(module_name: str, categorized: Dict[str, List[str]]) -> str:
|
||||
lines = [f"from .{module_name} import ("]
|
||||
for category, functions in categorized.items():
|
||||
lines.append(f" # {category}")
|
||||
for func in functions:
|
||||
lines.append(f" {func},")
|
||||
lines.append("")
|
||||
|
||||
if lines[-1] == "":
|
||||
lines.pop()
|
||||
lines.append(")")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_aliases(functions: Sequence[str]) -> str:
|
||||
alias_lines = ["# 便于使用的别名", "Workplane = SimpleWorkplane", ""]
|
||||
|
||||
alias_categories: Dict[str, List[Tuple[str, str]]] = {}
|
||||
for func in functions:
|
||||
alias = ALIAS_RULES.get(func)
|
||||
if alias is None:
|
||||
continue
|
||||
|
||||
if func.startswith("make_"):
|
||||
category = "创建函数别名"
|
||||
elif func.startswith(("translate_", "rotate_")):
|
||||
category = "变换操作别名"
|
||||
elif func.startswith(("extrude_", "revolve_")):
|
||||
category = "3D操作别名"
|
||||
elif func.startswith(("union_", "cut_", "intersect_")):
|
||||
category = "布尔运算别名"
|
||||
elif func.startswith("export_"):
|
||||
category = "导出别名"
|
||||
else:
|
||||
category = "其他别名"
|
||||
|
||||
alias_categories.setdefault(category, []).append((func, alias))
|
||||
|
||||
for category, aliases in alias_categories.items():
|
||||
alias_lines.append(f"# {category}")
|
||||
for func, alias in sorted(aliases, key=lambda item: item[1]):
|
||||
alias_lines.append(f"{alias} = {func}")
|
||||
alias_lines.append("")
|
||||
|
||||
if alias_lines[-1] == "":
|
||||
alias_lines.pop()
|
||||
return "\n".join(alias_lines)
|
||||
|
||||
|
||||
def generate_all_list(
|
||||
operations_functions: Sequence[str],
|
||||
evolve_functions: Sequence[str],
|
||||
) -> str:
|
||||
all_lines = ["__all__ = [", " # 核心类"]
|
||||
|
||||
core_public = [
|
||||
"CoordinateSystem",
|
||||
"SimpleWorkplane",
|
||||
"Workplane",
|
||||
"Vertex",
|
||||
"Edge",
|
||||
"Wire",
|
||||
"Face",
|
||||
"Solid",
|
||||
"AnyShape",
|
||||
"TaggedMixin",
|
||||
]
|
||||
for name in core_public:
|
||||
all_lines.append(f' "{name}",')
|
||||
|
||||
all_lines.extend(
|
||||
["", " # 坐标系", ' "get_current_cs",', ' "WORLD_CS",', ""]
|
||||
)
|
||||
|
||||
public_functions = list(operations_functions) + list(evolve_functions)
|
||||
categorized = categorize_functions(public_functions, FUNCTION_CATEGORIES)
|
||||
for category, funcs in categorized.items():
|
||||
all_lines.append(f" # {category}")
|
||||
for func in funcs:
|
||||
all_lines.append(f' "{func}",')
|
||||
all_lines.append("")
|
||||
|
||||
all_lines.extend([' "ql",', ' "translator",', "", " # 别名"])
|
||||
|
||||
aliases = sorted(
|
||||
ALIAS_RULES[func] for func in public_functions if func in ALIAS_RULES
|
||||
)
|
||||
for alias in aliases:
|
||||
all_lines.append(f' "{alias}",')
|
||||
|
||||
all_lines.append("]")
|
||||
return "\n".join(all_lines)
|
||||
|
||||
|
||||
def generate_init_file(inventory: Dict[str, ModuleInventory]) -> str:
|
||||
operations = inventory["operations"].functions
|
||||
evolve = inventory["evolve"].functions
|
||||
|
||||
lines = [
|
||||
'"""',
|
||||
"SimpleCAD API - 简化的CAD建模Python API",
|
||||
"基于 OCP 实现,提供直观的几何建模接口",
|
||||
'"""',
|
||||
"",
|
||||
generate_core_imports(),
|
||||
"",
|
||||
]
|
||||
|
||||
if operations:
|
||||
lines.append(
|
||||
_render_import_block(
|
||||
"operations", categorize_module("operations", operations)
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if evolve:
|
||||
lines.append(
|
||||
_render_import_block("evolve", categorize_module("evolve", evolve))
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"from . import ql",
|
||||
"from . import translator",
|
||||
"",
|
||||
'__author__ = "SimpleCAD API Team"',
|
||||
'__description__ = "Simplified OCP-native CAD modeling Python API"',
|
||||
"",
|
||||
generate_aliases(list(operations) + list(evolve)),
|
||||
"",
|
||||
generate_all_list(operations, evolve),
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def backup_init_file(file_path: Path = INIT_FILE) -> None:
|
||||
if not file_path.exists():
|
||||
return
|
||||
|
||||
backup_file = file_path.with_suffix(".py.bak")
|
||||
backup_file.write_text(file_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
print(f"已备份原文件到: {backup_file}")
|
||||
|
||||
|
||||
def check_syntax(file_path: Path) -> bool:
|
||||
try:
|
||||
ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
|
||||
return True
|
||||
except SyntaxError as exc:
|
||||
print(f"语法错误: {exc}")
|
||||
return False
|
||||
except Exception as exc:
|
||||
print(f"文件检查失败: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def extract_existing_auto_exports(file_path: Path = INIT_FILE) -> List[str]:
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
|
||||
except Exception as exc:
|
||||
print(f"比较文件时出错: {exc}")
|
||||
return []
|
||||
|
||||
symbols: List[str] = []
|
||||
managed_modules = {"operations", "evolve"}
|
||||
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ImportFrom) or node.level != 1:
|
||||
continue
|
||||
|
||||
if node.module in managed_modules:
|
||||
for alias in node.names:
|
||||
if alias.name != "*":
|
||||
symbols.append(alias.name)
|
||||
continue
|
||||
|
||||
if node.module is None:
|
||||
for alias in node.names:
|
||||
if alias.name in {"field", "ql", "translator"}:
|
||||
symbols.append(alias.name)
|
||||
|
||||
return sorted(dict.fromkeys(symbols))
|
||||
|
||||
|
||||
def compare_with_existing(target_symbols: Sequence[str]) -> Tuple[List[str], List[str]]:
|
||||
existing = extract_existing_auto_exports()
|
||||
target = sorted(dict.fromkeys(target_symbols))
|
||||
new_additions = [name for name in target if name not in existing]
|
||||
removed = [name for name in existing if name not in target]
|
||||
return new_additions, removed
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="自动更新 SimpleCAD API 的 __init__.py 文件",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
使用示例:
|
||||
python make_export.py # 标准模式
|
||||
python make_export.py --dry-run # 预览模式,不实际修改文件
|
||||
python make_export.py --show-api-only # 只显示 API 列表
|
||||
python make_export.py --force # 强制模式,跳过确认
|
||||
python make_export.py --verbose # 详细输出模式
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="预览模式,显示将要进行的更改但不实际修改文件",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-api-only",
|
||||
action="store_true",
|
||||
help="仅显示 API 函数,不生成 __init__.py 文件",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="强制模式,跳过所有确认提示",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="store_true",
|
||||
help="详细输出模式",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backup",
|
||||
dest="backup",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="创建备份文件 (默认: 创建备份)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-backup",
|
||||
dest="backup",
|
||||
action="store_false",
|
||||
help="不创建备份文件",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _print_module_summary(module_name: str, inventory: ModuleInventory) -> int:
|
||||
categorized = categorize_module(module_name, inventory.functions)
|
||||
total = 0
|
||||
|
||||
print(f"\n🔹 {inventory.display_name} 模块:")
|
||||
for category, funcs in categorized.items():
|
||||
print(f"\n {category} ({len(funcs)} 个函数):")
|
||||
for func in sorted(funcs):
|
||||
print(f" - {func}")
|
||||
total += len(funcs)
|
||||
|
||||
if inventory.classes:
|
||||
print(f"\n 导出类 ({len(inventory.classes)} 个类):")
|
||||
for cls in inventory.classes:
|
||||
print(f" - {cls}")
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def _verbose_module_summary(module_name: str, inventory: ModuleInventory) -> None:
|
||||
categorized = categorize_module(module_name, inventory.functions)
|
||||
print(f" {inventory.display_name} 模块:")
|
||||
for category, funcs in categorized.items():
|
||||
print(f" {category}: {len(funcs)} 个函数")
|
||||
for func in funcs[:3]:
|
||||
print(f" - {func}")
|
||||
if len(funcs) > 3:
|
||||
print(f" ... 和其他 {len(funcs) - 3} 个函数")
|
||||
if inventory.classes:
|
||||
print(f" 导出类: {len(inventory.classes)} 个")
|
||||
|
||||
|
||||
def _target_symbols(inventory: Dict[str, ModuleInventory]) -> List[str]:
|
||||
symbols: List[str] = []
|
||||
for module_name in ("operations", "evolve"):
|
||||
symbols.extend(inventory[module_name].functions)
|
||||
symbols.extend(inventory[module_name].classes)
|
||||
symbols.append("ql")
|
||||
symbols.append("translator")
|
||||
return symbols
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_arguments()
|
||||
|
||||
if args.verbose:
|
||||
print("🚀 开始更新 __init__.py 文件...")
|
||||
print(f"📦 包目录: {PACKAGE_ROOT}")
|
||||
|
||||
inventory = collect_api_inventory()
|
||||
operations_functions = inventory["operations"].functions
|
||||
evolve_functions = inventory["evolve"].functions
|
||||
|
||||
if not any(item.functions or item.classes for item in inventory.values()):
|
||||
print("❌ 未找到任何公共 API,退出")
|
||||
return
|
||||
|
||||
target_symbols = _target_symbols(inventory)
|
||||
new_additions, removed_functions = compare_with_existing(target_symbols)
|
||||
|
||||
if args.verbose:
|
||||
print("\n📊 函数分类统计:")
|
||||
for module_name in MODULE_ORDER:
|
||||
_verbose_module_summary(module_name, inventory[module_name])
|
||||
|
||||
if new_additions and args.verbose:
|
||||
print(f"\n🆕 新增导出 ({len(new_additions)} 个):")
|
||||
for name in new_additions:
|
||||
print(f" + {name}")
|
||||
|
||||
if removed_functions and args.verbose:
|
||||
print(f"\n🗑️ 删除导出 ({len(removed_functions)} 个):")
|
||||
for name in removed_functions:
|
||||
print(f" - {name}")
|
||||
|
||||
if args.show_api_only:
|
||||
print("\n📜 API 函数列表 (按模块和类别分组):")
|
||||
totals: Dict[str, int] = {}
|
||||
for module_name in MODULE_ORDER:
|
||||
totals[module_name] = _print_module_summary(
|
||||
module_name, inventory[module_name]
|
||||
)
|
||||
|
||||
print(
|
||||
"\n📊 总计: "
|
||||
f"Operations {totals['operations']} 个函数, "
|
||||
f"Evolve {totals['evolve']} 个函数, "
|
||||
f"QL {totals['ql']} 个函数, "
|
||||
f"总计 {sum(totals.values())} 个函数"
|
||||
)
|
||||
return
|
||||
|
||||
new_content = generate_init_file(inventory)
|
||||
|
||||
if args.dry_run:
|
||||
print("\n👁️ 预览模式 - 将要进行的更改:")
|
||||
print(f" 生成的文件大小: {len(new_content)} 字符")
|
||||
print(f" Operations 函数数: {len(operations_functions)}")
|
||||
print(f" Evolve 函数数: {len(evolve_functions)}")
|
||||
print(f" QL 函数数: {len(inventory['ql'].functions)}")
|
||||
print(f" 总导出符号数: {len(target_symbols)}")
|
||||
print(
|
||||
f" 别名数: {len([name for name in operations_functions + evolve_functions if name in ALIAS_RULES])}"
|
||||
)
|
||||
print(" (使用 --verbose 查看详细信息)")
|
||||
print("\n💡 要实际执行更改,请移除 --dry-run 参数")
|
||||
return
|
||||
|
||||
if args.backup:
|
||||
backup_init_file()
|
||||
|
||||
print("\n🔄 生成新的 __init__.py 文件...")
|
||||
INIT_FILE.write_text(new_content, encoding="utf-8")
|
||||
|
||||
print("🔍 检查生成文件的语法...")
|
||||
if not check_syntax(INIT_FILE):
|
||||
print("❌ 语法检查失败,请检查生成的文件")
|
||||
return
|
||||
|
||||
print("✅ 语法检查通过")
|
||||
print(f"✅ 已更新 {INIT_FILE}")
|
||||
print("🎉 更新完成!")
|
||||
|
||||
print("\n📈 统计信息:")
|
||||
for module_name in MODULE_ORDER:
|
||||
print(
|
||||
f" {MODULE_LABELS[module_name]} 函数数: {len(inventory[module_name].functions)}"
|
||||
)
|
||||
print(f" 总导出符号数: {len(target_symbols)}")
|
||||
print(
|
||||
f" 别名数: {len([name for name in operations_functions + evolve_functions if name in ALIAS_RULES])}"
|
||||
)
|
||||
|
||||
print("\n💡 建议:")
|
||||
print(f" 1. 检查生成的 {INIT_FILE} 文件")
|
||||
print(" 2. 运行测试确保所有导入正常工作")
|
||||
if args.backup:
|
||||
print(f" 3. 如有问题,可以从备份文件 {INIT_FILE}.bak 恢复")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,980 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a thin Agent Skills bundle for SimpleCAD API.
|
||||
|
||||
This packager intentionally does not bundle SDK source code.
|
||||
The generated skill contains SDK reference documents and generated API/core docs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from email import message_from_string
|
||||
from pathlib import Path
|
||||
from typing import Sequence, cast
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+ # type: ignore[import-not-found]
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
tomllib = None # type: ignore[assignment]
|
||||
|
||||
DEFAULT_PACKAGE_NAME = "simplecadapi"
|
||||
DEFAULT_SKILL_NAME = "simplecadapi"
|
||||
DEFAULT_LICENSE = "AGPL-3.0"
|
||||
DOCS_PATH = Path("docs")
|
||||
LICENSE_PATH = Path("LICENSE")
|
||||
|
||||
SKILL_NAME_PATTERN = re.compile(r"^(?!-)(?!.*--)[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
def _package_root_from(module_file: Path | str | None = None) -> Path:
|
||||
target = Path(module_file) if module_file is not None else Path(__file__)
|
||||
return target.resolve().parents[1]
|
||||
|
||||
|
||||
def _is_source_checkout_root(project_root: Path) -> bool:
|
||||
return (project_root / "pyproject.toml").exists() and (
|
||||
project_root / "src" / DEFAULT_PACKAGE_NAME
|
||||
).exists()
|
||||
|
||||
|
||||
def _source_checkout_root(package_root: Path) -> Path | None:
|
||||
src_dir = package_root.parent
|
||||
project_root = src_dir.parent
|
||||
|
||||
if src_dir.name != "src":
|
||||
return None
|
||||
if not _is_source_checkout_root(project_root):
|
||||
return None
|
||||
return project_root
|
||||
|
||||
|
||||
def _default_project_root(module_file: Path | str | None = None) -> Path:
|
||||
package_root = _package_root_from(module_file)
|
||||
return _source_checkout_root(package_root) or package_root.parent
|
||||
|
||||
|
||||
def _default_output_root(project_root: Path, cwd: Path | None = None) -> Path:
|
||||
if _is_source_checkout_root(project_root):
|
||||
return (project_root / "skills").resolve()
|
||||
return ((cwd if cwd is not None else Path.cwd()) / "skills").resolve()
|
||||
|
||||
|
||||
def _first_existing_path(candidates: Sequence[Path]) -> Path | None:
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _docs_root_for(project_root: Path) -> Path:
|
||||
docs_root = _first_existing_path(
|
||||
(
|
||||
project_root / DOCS_PATH,
|
||||
project_root / "src" / DOCS_PATH,
|
||||
)
|
||||
)
|
||||
return docs_root or (project_root / DOCS_PATH)
|
||||
|
||||
|
||||
def _normalize_dist_name(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "_", name).lower()
|
||||
|
||||
|
||||
def _dist_info_dir(project_root: Path, package_name: str) -> Path | None:
|
||||
candidates: list[Path] = []
|
||||
patterns = (
|
||||
f"{package_name}-*.dist-info",
|
||||
f"{package_name.replace('-', '_')}-*.dist-info",
|
||||
f"{_normalize_dist_name(package_name)}-*.dist-info",
|
||||
)
|
||||
|
||||
for pattern in patterns:
|
||||
for path in sorted(project_root.glob(pattern)):
|
||||
if path not in candidates:
|
||||
candidates.append(path)
|
||||
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def _license_path_for(project_root: Path, package_name: str) -> Path | None:
|
||||
dist_info_dir = _dist_info_dir(project_root, package_name)
|
||||
candidates = [project_root / LICENSE_PATH]
|
||||
if dist_info_dir is not None:
|
||||
candidates.extend(
|
||||
[
|
||||
dist_info_dir / "licenses" / LICENSE_PATH.name,
|
||||
dist_info_dir / LICENSE_PATH.name,
|
||||
]
|
||||
)
|
||||
return _first_existing_path(tuple(candidates))
|
||||
|
||||
|
||||
def _auto_docs_script_path_for(project_root: Path) -> Path | None:
|
||||
return _first_existing_path(
|
||||
(
|
||||
project_root
|
||||
/ "src"
|
||||
/ DEFAULT_PACKAGE_NAME
|
||||
/ "auto_tools"
|
||||
/ "auto_docs_gen.py",
|
||||
project_root / DEFAULT_PACKAGE_NAME / "auto_tools" / "auto_docs_gen.py",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectMetadata:
|
||||
"""Project metadata used for skill rendering."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
readme_text: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildResult:
|
||||
"""Result object for completed build."""
|
||||
|
||||
skill_root: Path
|
||||
archive_path: Path | None
|
||||
|
||||
|
||||
def _load_project_metadata(
|
||||
project_root: Path,
|
||||
default_name: str = DEFAULT_PACKAGE_NAME,
|
||||
) -> ProjectMetadata:
|
||||
pyproject_path = project_root / "pyproject.toml"
|
||||
|
||||
default_version = "0.0.0"
|
||||
default_desc = "SimpleCAD SDK reference skill"
|
||||
|
||||
if not pyproject_path.exists():
|
||||
return ProjectMetadata(default_name, default_version, default_desc)
|
||||
|
||||
if tomllib is not None:
|
||||
try:
|
||||
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
||||
project = data.get("project", {})
|
||||
return ProjectMetadata(
|
||||
name=str(project.get("name") or default_name),
|
||||
version=str(project.get("version") or default_version),
|
||||
description=str(project.get("description") or default_desc),
|
||||
readme_text=None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
name_match = re.search(
|
||||
r'^\s*name\s*=\s*"(?P<name>[^"]+)"\s*$',
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
version_match = re.search(
|
||||
r'^\s*version\s*=\s*"(?P<version>[^"]+)"\s*$',
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
description_match = re.search(
|
||||
r'^\s*description\s*=\s*"(?P<description>[^"]+)"\s*$',
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
return ProjectMetadata(
|
||||
name=name_match.group("name") if name_match else default_name,
|
||||
version=version_match.group("version") if version_match else default_version,
|
||||
description=(
|
||||
description_match.group("description")
|
||||
if description_match
|
||||
else default_desc
|
||||
),
|
||||
readme_text=None,
|
||||
)
|
||||
|
||||
|
||||
def _load_installed_metadata(
|
||||
project_root: Path,
|
||||
package_name: str = DEFAULT_PACKAGE_NAME,
|
||||
) -> ProjectMetadata | None:
|
||||
dist_info_dir = _dist_info_dir(project_root, package_name)
|
||||
if dist_info_dir is None:
|
||||
return None
|
||||
|
||||
metadata_path = dist_info_dir / "METADATA"
|
||||
if not metadata_path.exists():
|
||||
return None
|
||||
|
||||
message = message_from_string(metadata_path.read_text(encoding="utf-8"))
|
||||
payload = cast(str, message.get_payload())
|
||||
readme_text = payload.strip() or None
|
||||
return ProjectMetadata(
|
||||
name=message.get("Name", package_name),
|
||||
version=message.get("Version", "0.0.0"),
|
||||
description=message.get("Summary", "SimpleCAD SDK reference skill"),
|
||||
readme_text=readme_text,
|
||||
)
|
||||
|
||||
|
||||
def _ignore_common_noise(_: str, names: list[str]) -> list[str]:
|
||||
ignored: list[str] = []
|
||||
for name in names:
|
||||
if name in {"__pycache__", ".DS_Store"}:
|
||||
ignored.append(name)
|
||||
continue
|
||||
if name.endswith(".pyc"):
|
||||
ignored.append(name)
|
||||
return ignored
|
||||
|
||||
|
||||
class SkillPackager:
|
||||
"""Build thin SDK skill bundle: SKILL.md plus reference docs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_root: Path,
|
||||
output_root: Path,
|
||||
skill_name: str,
|
||||
license_name: str,
|
||||
package_name: str | None = None,
|
||||
package_version: str | None = None,
|
||||
clean: bool = True,
|
||||
refresh_docs: bool = False,
|
||||
archive: bool = False,
|
||||
quiet: bool = False,
|
||||
):
|
||||
self.project_root = project_root.resolve()
|
||||
self.output_root = output_root.resolve()
|
||||
self.skill_name = skill_name
|
||||
self.license_name = license_name
|
||||
self.clean = clean
|
||||
self.refresh_docs = refresh_docs
|
||||
self.archive = archive
|
||||
self.quiet = quiet
|
||||
|
||||
self.skill_root = self.output_root / self.skill_name
|
||||
self.references_dir = self.skill_root / "references"
|
||||
self.docs_dir = self.references_dir / "docs"
|
||||
|
||||
self.source_checkout = _is_source_checkout_root(self.project_root)
|
||||
default_package_name = package_name or DEFAULT_PACKAGE_NAME
|
||||
self.metadata = _load_project_metadata(
|
||||
self.project_root,
|
||||
default_name=default_package_name,
|
||||
)
|
||||
if self.metadata.version == "0.0.0":
|
||||
installed_metadata = _load_installed_metadata(
|
||||
self.project_root,
|
||||
package_name=default_package_name,
|
||||
)
|
||||
if installed_metadata is not None:
|
||||
self.metadata = installed_metadata
|
||||
|
||||
self.package_name = package_name or self.metadata.name
|
||||
self.package_version = package_version or self.metadata.version
|
||||
self.source_docs = _docs_root_for(self.project_root)
|
||||
self.source_license = _license_path_for(self.project_root, self.package_name)
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
if not self.quiet:
|
||||
print(message)
|
||||
|
||||
def build(self) -> BuildResult:
|
||||
self._validate_inputs()
|
||||
|
||||
if self.refresh_docs:
|
||||
self._refresh_api_docs()
|
||||
|
||||
self._prepare_output_directory()
|
||||
self._copy_reference_docs()
|
||||
self._write_skill_markdown()
|
||||
self._write_reference_files()
|
||||
self._validate_generated_skill()
|
||||
|
||||
archive_path = self._create_archive() if self.archive else None
|
||||
return BuildResult(self.skill_root, archive_path)
|
||||
|
||||
def _validate_inputs(self) -> None:
|
||||
if len(self.skill_name) > 64:
|
||||
raise ValueError("skill_name must be <= 64 characters")
|
||||
if not SKILL_NAME_PATTERN.fullmatch(self.skill_name):
|
||||
raise ValueError(
|
||||
"skill_name must use lowercase letters, numbers, and single hyphens"
|
||||
)
|
||||
|
||||
required = (
|
||||
self.source_docs,
|
||||
self.source_docs / "api",
|
||||
self.source_docs / "core",
|
||||
self.source_docs / "stdlib",
|
||||
)
|
||||
for path in required:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Missing required path: {path}")
|
||||
|
||||
if self.source_license is None:
|
||||
raise FileNotFoundError(
|
||||
"Missing required license file in both project files and dist-info metadata"
|
||||
)
|
||||
|
||||
def _refresh_api_docs(self) -> None:
|
||||
if not self.source_checkout:
|
||||
self.log(
|
||||
"Using packaged docs from installed simplecadapi; skipped --refresh-docs outside source checkout."
|
||||
)
|
||||
return
|
||||
|
||||
script_path = _auto_docs_script_path_for(self.project_root)
|
||||
if script_path is None:
|
||||
raise FileNotFoundError(f"Cannot refresh docs, missing: {script_path}")
|
||||
|
||||
self.log("Refreshing generated docs before packaging...")
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, str(script_path), "--quiet"],
|
||||
cwd=str(self.project_root),
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError("Failed to refresh API docs") from exc
|
||||
|
||||
def _prepare_output_directory(self) -> None:
|
||||
if self.skill_root.exists() and self.clean:
|
||||
self.log(f"Removing existing skill directory: {self.skill_root}")
|
||||
shutil.rmtree(self.skill_root)
|
||||
|
||||
self.references_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.docs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.log(f"Writing skill bundle to: {self.skill_root}")
|
||||
|
||||
def _copy_reference_docs(self) -> None:
|
||||
self.log("Copying reference docs...")
|
||||
target_docs = self.docs_dir
|
||||
shutil.copytree(
|
||||
self.source_docs,
|
||||
target_docs,
|
||||
dirs_exist_ok=True,
|
||||
ignore=_ignore_common_noise,
|
||||
)
|
||||
|
||||
if self.source_license is None:
|
||||
raise FileNotFoundError(
|
||||
"Missing required license file in both project files and dist-info metadata"
|
||||
)
|
||||
shutil.copy2(self.source_license, self.references_dir / "LICENSE.txt")
|
||||
|
||||
def _write_skill_markdown(self) -> None:
|
||||
self.log("Generating SKILL.md...")
|
||||
(self.skill_root / "SKILL.md").write_text(
|
||||
self._build_skill_markdown(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _write_reference_files(self) -> None:
|
||||
self.log("Generating overview references...")
|
||||
(self.references_dir / "SDK_OVERVIEW.md").write_text(
|
||||
self._build_project_overview(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(self.references_dir / "SDK_SURFACES.md").write_text(
|
||||
self._build_runtime_install_reference(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(self.references_dir / "MODELING_WORKFLOWS.md").write_text(
|
||||
self._build_evolve_workflow_reference(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(self.references_dir / "SDK_PACKAGE_SUMMARY.md").write_text(
|
||||
self._build_sdk_package_summary(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _validate_generated_skill(self) -> None:
|
||||
self.log("Validating generated skill...")
|
||||
required = (
|
||||
self.skill_root / "SKILL.md",
|
||||
self.references_dir / "SDK_OVERVIEW.md",
|
||||
self.references_dir / "SDK_SURFACES.md",
|
||||
self.references_dir / "MODELING_WORKFLOWS.md",
|
||||
self.references_dir / "SDK_PACKAGE_SUMMARY.md",
|
||||
self.references_dir / "LICENSE.txt",
|
||||
self.docs_dir / "api" / "README.md",
|
||||
self.docs_dir / "core" / "README.md",
|
||||
self.docs_dir / "stdlib" / "README.md",
|
||||
)
|
||||
|
||||
for path in required:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Generated skill is missing: {path}")
|
||||
|
||||
forbidden = (
|
||||
self.skill_root / "assets" / "project_snapshot" / "src",
|
||||
self.skill_root / "src",
|
||||
)
|
||||
for path in forbidden:
|
||||
if path.exists():
|
||||
raise ValueError(f"Thin skill must not include source code: {path}")
|
||||
|
||||
frontmatter = self._parse_frontmatter(
|
||||
(self.skill_root / "SKILL.md").read_text("utf-8")
|
||||
)
|
||||
if frontmatter.get("name", "") != self.skill_name:
|
||||
raise ValueError("SKILL.md frontmatter name does not match skill directory")
|
||||
if not frontmatter.get("description", ""):
|
||||
raise ValueError("SKILL.md frontmatter description is empty")
|
||||
|
||||
def _create_archive(self) -> Path:
|
||||
archive_path = self.output_root / f"{self.skill_name}.tar.gz"
|
||||
self.log(f"Creating archive: {archive_path}")
|
||||
with tarfile.open(archive_path, "w:gz") as tar:
|
||||
tar.add(self.skill_root, arcname=self.skill_name)
|
||||
return archive_path
|
||||
|
||||
def _build_skill_markdown(self) -> str:
|
||||
package_spec = self._package_spec()
|
||||
body = textwrap.dedent(
|
||||
f"""\
|
||||
---
|
||||
name: {self.skill_name}
|
||||
description: Thin SimpleCAD SDK reference skill focused on the public API surface, core types, and current modeling workflows.
|
||||
license: {self.license_name}
|
||||
compatibility: Documentation/reference bundle for current SimpleCADAPI surfaces.
|
||||
metadata:
|
||||
project: {self.metadata.name}
|
||||
version: {self.metadata.version}
|
||||
package-name: {self.package_name}
|
||||
package-version: {self.metadata.version}
|
||||
---
|
||||
|
||||
# SimpleCAD SDK Skill
|
||||
|
||||
## Philosophy
|
||||
- This is a thin SDK reference skill: docs only.
|
||||
- SDK source code is not bundled in this skill.
|
||||
|
||||
## Working From Repo Root
|
||||
- Tool calls run from the repo root.
|
||||
- Use one explicit skill root: `./skills/{self.skill_name}/` or `./workspace/skills/{self.skill_name}/`.
|
||||
- Main doc paths:
|
||||
- `<skill_root>/SKILL.md`
|
||||
- `<skill_root>/references/docs/api/README.md`
|
||||
- `<skill_root>/references/docs/api/<api_name>.md`
|
||||
- `<skill_root>/references/docs/stdlib/README.md`
|
||||
- `<skill_root>/references/docs/stdlib/<stdlib_api_name>.md`
|
||||
- `<skill_root>/references/docs/core/<type_name>.md`
|
||||
- `<skill_root>/references/SDK_OVERVIEW.md`
|
||||
- `<skill_root>/references/SDK_SURFACES.md`
|
||||
- `<skill_root>/references/MODELING_WORKFLOWS.md`
|
||||
|
||||
## MUST Requirements
|
||||
1. Read `SKILL.md`, `references/docs/api/README.md`, and `references/docs/stdlib/README.md` before choosing APIs.
|
||||
2. Read the exact API Markdown page for every API you use.
|
||||
3. Read the needed `core/` or exact `api/` docs when an API needs `Edge`, `Face`, `Wire`, `Solid`, `GraphSession`, `Sketch`, or expression types.
|
||||
4. Prefer the standard parts library for standard parts before hand-modeling with core geometry APIs.
|
||||
5. Follow the documented API signatures exactly.
|
||||
6. When calling any SimpleCAD public API or standard-library function, use keyword arguments for every documented parameter; do not use positional arguments.
|
||||
7. Use the graph/model JSON workflow for replayable tasks: `GraphSession`, `export_session_json`, `export_model_json`, `import_model_json`, and `replay_model_json`.
|
||||
8. Use geometry APIs for integrated parts: profiles, features, booleans, transforms, tagging, QL inspection, serialization, and exports.
|
||||
9. Use tags consistently through `apply_tag(shape=..., tag=...)` and `list_tags(shape=...)`; do not call shape member tag mutators.
|
||||
10. Build and validate incrementally. Each step MUST include a small grounding `print`, and grounding MUST use QL where possible.
|
||||
11. For inspection/debugging, query geometry with QL and print only the queried facts you need; do not print whole solids or full model objects.
|
||||
12. Boolean operations return a single `Solid`.
|
||||
13. Use `union_rsolid(...)` for boolean union.
|
||||
14. For automated example/test harnesses, prefer the repo-local examples in `examples/` and avoid scratch scripts in `sandbox/`.
|
||||
15. If union cannot produce exactly one merged solid, it fails explicitly; do not silently pick one piece.
|
||||
16. If a single merged solid is required and union fails, slightly adjust part placement so intended bodies overlap/embed, then recompute.
|
||||
17. If a task depends on model replay or interchange, prefer `export_model_json()` output over hand-written payloads.
|
||||
|
||||
## Standard Parts Library
|
||||
- SimpleCAD includes a standard library for parameterized mechanical parts.
|
||||
- When the user needs a standard part and does not require complex custom geometry changes, use a standard-library function first.
|
||||
- Current package-level standard-library surfaces include `scad.std.gear` for involute gears, internal ring gears, racks, and cycloidal discs, plus `scad.std.bearing` for ball bearing assemblies.
|
||||
- Read `references/docs/stdlib/README.md` to discover standard-library functions.
|
||||
- Read `references/docs/stdlib/<function_name>.md` before calling a standard-library function.
|
||||
- Standard-library functions return normal SimpleCAD shapes or product assemblies that can be transformed, tagged, assembled, exported, and used with graph/model JSON workflows.
|
||||
|
||||
## Boolean result discipline
|
||||
- `union_rsolid(...)`, `cut_rsolid(...)`, and `intersect_rsolid(...)` accept mixed inputs: standalone `Solid`, lists of `Solid`, and nested sequences.
|
||||
- They return a single `Solid`.
|
||||
- `union_rsolid(...)` already applies the package's default glue mode and a conservative internal tolerance.
|
||||
- If a union cannot produce exactly one merged solid, it fails explicitly instead of returning multiple pieces.
|
||||
- If a single merged solid is required but union fails, slightly move the parts so they overlap instead of merely touching, then recompute the union.
|
||||
|
||||
## Modeling Mental Model
|
||||
- Start with intent: identify the part, its reference axes, critical profiles, and the features that produce the final solid.
|
||||
- Build from lower-dimensional geometry to higher-dimensional geometry: `Vertex` / `Edge` / `Wire` / `Face` profiles first, then `Solid` features such as extrude, revolve, loft, and sweep.
|
||||
- Keep modeling operations functional. Create new values from public functions such as `make_circle_rface(...)`, `extrude_rsolid(...)`, `cut_rsolid(...)`, and `fillet_rsolid(...)`.
|
||||
- Use keyword arguments for all SimpleCAD function calls, for example `make_box_rsolid(width=10.0, height=20.0, depth=3.0)` instead of positional arguments.
|
||||
- Use `GraphSession` when the model should be replayable, inspectable, exported as model JSON, or translated to another CAD system.
|
||||
- Treat model JSON as the interchange boundary. Prefer `export_model_json(session=...)` and `replay_model_json(json_str=...)` over hand-authored operation payloads.
|
||||
- Use QL for precise grounding. Query faces, edges, centers, normals, areas, lengths, curve types, and tags; print only the facts needed to validate the current step.
|
||||
- Use `get_edges(index)`, `get_faces(index)`, `get_wires(index)`, or `get_vertices(index)` when an indexed topology pick is intentional; these picks are preserved as geo select nodes in replayable graph workflows.
|
||||
- Use tags for semantic intent and selection anchors, such as `role.mounting_surface`, `anchor.datum.primary`, `face.top`, or `group.fasteners`.
|
||||
- Keep numeric and geometric facts in metadata or graph payloads, not in tags.
|
||||
- When a QL-selected face or edge is used by a later feature, expect the graph/model workflow to preserve that selection as a stable geo select node.
|
||||
- For FreeCAD translation, prefer canonical model JSON generated from a `GraphSession`; selected profiles and detail-feature selections should come from the graph rather than ad hoc object lookup.
|
||||
|
||||
## Tagging Mental Model
|
||||
- Public tag attachment is `apply_tag(shape=..., tag=...)`.
|
||||
- Public tag inspection is `list_tags(shape=...)`, which returns a stable sorted list.
|
||||
- Tags are normalized lowercase dot-separated semantic tokens, for example `role.mounting_surface`, `anchor.datum.primary`, `group.fasteners`, `face.top`, or `solid.boolean.cut`.
|
||||
- Do not encode numeric dimensions or descriptive geometry payloads in tags; store them in metadata such as `shape.get_metadata("geo")` or `shape.set_metadata(...)`.
|
||||
- `apply_tag(...)` does not expose propagation controls. The SDK propagates role/anchor/group-style semantic tags downward and keeps topology-specific tags such as `face.*`, `edge.*`, `wire.*`, `vertex.*`, and `solid.*` local.
|
||||
- Primitives, face auto-tagging, features, booleans, transforms, and tracking may add normalized topology/operation tags automatically.
|
||||
- Prefer QL tag predicates (`ql.tag("role.*")`, `ql.select(...).where(...)`) for inspection and grounding.
|
||||
|
||||
## SDK Focus
|
||||
- This skill is intended to describe the public CAD Python SDK surface.
|
||||
- Prefer the generated API, stdlib, and core docs over environment/bootstrap instructions.
|
||||
- API docs include an `Import Surface` section that distinguishes top-level exports, submodule APIs, and translator backend APIs under `simplecadapi.translator.<backend>`.
|
||||
- Stdlib docs include an `Import Surface` section that identifies the package-level `simplecadapi.std.gear` module export.
|
||||
- Use `references/SDK_OVERVIEW.md` for the package-level map.
|
||||
- Use `references/SDK_SURFACES.md` for the main public surfaces.
|
||||
- Use `references/MODELING_WORKFLOWS.md` for graph/model-oriented patterns.
|
||||
|
||||
## Example SDK usage
|
||||
|
||||
```python
|
||||
import simplecadapi as scad
|
||||
from simplecadapi import GraphSession, export_model_json, make_box_rsolid
|
||||
```
|
||||
|
||||
Typical replayable usage in a Python script:
|
||||
|
||||
```python
|
||||
import simplecadapi as scad
|
||||
from simplecadapi import GraphSession, export_model_json, replay_model_json
|
||||
|
||||
with GraphSession() as session:
|
||||
shape = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0)
|
||||
|
||||
model_json = export_model_json(session=session)
|
||||
rebuilt = replay_model_json(json_str=model_json)
|
||||
print(len(rebuilt))
|
||||
```
|
||||
|
||||
Use the graph/model JSON workflow when the task needs reproducibility, interchange, or replayable outputs.
|
||||
|
||||
## References
|
||||
- `references/SDK_OVERVIEW.md`
|
||||
- `references/SDK_SURFACES.md`
|
||||
- `references/MODELING_WORKFLOWS.md`
|
||||
- `references/SDK_PACKAGE_SUMMARY.md`
|
||||
- `references/docs/api/`
|
||||
- `references/docs/stdlib/`
|
||||
- `references/docs/core/`
|
||||
"""
|
||||
)
|
||||
return body.rstrip() + "\n"
|
||||
|
||||
def _build_project_overview(self) -> str:
|
||||
package_spec = self._package_spec()
|
||||
lines = [
|
||||
"# SDK Overview",
|
||||
"",
|
||||
f"- Project: `{self.metadata.name}`",
|
||||
f"- Version: `{self.metadata.version}`",
|
||||
f"- Package distribution: `{package_spec}`",
|
||||
"",
|
||||
"## What this skill bundles",
|
||||
"",
|
||||
"- Skill instructions (`SKILL.md`)",
|
||||
"- Documentation references (`references/docs/`)",
|
||||
"- Generated core API docs (`references/docs/api/`) and standard-library docs (`references/docs/stdlib/`)",
|
||||
"- High-level SDK summaries (`references/*.md`)",
|
||||
"",
|
||||
"## What this skill does not bundle",
|
||||
"",
|
||||
"- SDK source code (`src/simplecadapi`) is intentionally excluded.",
|
||||
"- Environment/bootstrap workflows are intentionally not the focus here.",
|
||||
"- Self-evolving or skill-local case packaging is intentionally excluded.",
|
||||
"",
|
||||
"## Main SDK surfaces",
|
||||
"",
|
||||
"- Geometry and modeling operations in `docs/api/`.",
|
||||
"- Standard parts library in `docs/stdlib/`, including `scad.std.gear` gear, ring gear, rack, and cycloidal disc factories plus `scad.std.bearing` bearing assembly factories.",
|
||||
"- Core shape/type semantics in `docs/core/`.",
|
||||
"- Graph/model serialization and replay APIs.",
|
||||
"- Expression, parameter, and semantic reference types.",
|
||||
"- Functional tagging with `apply_tag(shape=..., tag=...)`, `list_tags(shape=...)`, and QL tag predicates.",
|
||||
"",
|
||||
"## Preferred replayable workflow",
|
||||
"",
|
||||
"- Record modeling steps inside `GraphSession` when you need replayable outputs.",
|
||||
"- Export session/model payloads with `export_session_json()` and `export_model_json()`.",
|
||||
"- Re-import or replay with `import_model_json()` and `replay_model_json()`.",
|
||||
]
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
def _build_runtime_install_reference(self) -> str:
|
||||
body = textwrap.dedent(
|
||||
f"""\
|
||||
# SDK Surfaces
|
||||
|
||||
## Public API groups
|
||||
|
||||
- Primitive and sketch construction functions
|
||||
- Standard parts library modules for reusable mechanical parts
|
||||
- Transform, feature, boolean, and export functions
|
||||
- Functional tagging and selection helpers
|
||||
- Graph/model serialization and replay entry points
|
||||
- Expression and semantic reference data types
|
||||
|
||||
## Standard Parts Surface
|
||||
|
||||
```python
|
||||
import simplecadapi as scad
|
||||
|
||||
gear = scad.std.gear.make_spur_gear_rsolid(
|
||||
n_teeth=24,
|
||||
module=1.5,
|
||||
gear_height=8.0,
|
||||
)
|
||||
ring = scad.std.gear.make_spur_ring_gear_rsolid(
|
||||
n_teeth=72,
|
||||
module=1.5,
|
||||
gear_height=8.0,
|
||||
rim_thickness=4.0,
|
||||
backlash=0.08 * 1.5,
|
||||
)
|
||||
rack = scad.std.gear.make_spur_rack_rsolid(module=1.5, n_teeth=18)
|
||||
bearing = scad.std.bearing.make_ball_bearing_rassembly(
|
||||
8.0,
|
||||
22.0,
|
||||
7.0,
|
||||
3.5,
|
||||
)
|
||||
```
|
||||
|
||||
Use standard-library functions first when a task asks for a standard part and does not require complex custom geometry changes. Read `references/docs/stdlib/README.md` for the standard-library index and `references/docs/stdlib/<function_name>.md` for exact signatures.
|
||||
|
||||
## Tagging Surface
|
||||
|
||||
```python
|
||||
import simplecadapi as scad
|
||||
|
||||
body = scad.make_box_rsolid(width=10.0, height=20.0, depth=3.0)
|
||||
scad.apply_tag(shape=body, tag="role.mounting_plate")
|
||||
body.auto_tag_faces("box")
|
||||
|
||||
top_faces = [face for face in body.get_faces() if "face.top" in scad.list_tags(shape=face)]
|
||||
print(len(top_faces))
|
||||
```
|
||||
|
||||
Use `apply_tag(shape=..., tag=...)` for user-authored semantic tags and `list_tags(shape=...)` for deterministic inspection. Keep numeric dimensions, measurements, and rich descriptive data in metadata rather than tags.
|
||||
|
||||
## Recommended reading order
|
||||
|
||||
1. `references/docs/api/README.md`
|
||||
2. `references/docs/stdlib/README.md`
|
||||
3. `references/SDK_OVERVIEW.md`
|
||||
4. `references/MODELING_WORKFLOWS.md`
|
||||
5. Specific pages under `references/docs/api/` or `references/docs/stdlib/`
|
||||
6. Supporting pages under `references/docs/core/`
|
||||
|
||||
## Typical replayable surface
|
||||
|
||||
```python
|
||||
from simplecadapi import GraphSession, export_model_json, replay_model_json
|
||||
|
||||
with GraphSession() as session:
|
||||
...
|
||||
|
||||
model_json = export_model_json(session=session)
|
||||
rebuilt = replay_model_json(json_str=model_json)
|
||||
print(len(rebuilt))
|
||||
```
|
||||
"""
|
||||
)
|
||||
return body.rstrip() + "\n"
|
||||
|
||||
def _build_evolve_workflow_reference(self) -> str:
|
||||
body = textwrap.dedent(
|
||||
f"""\
|
||||
# Modeling Workflows
|
||||
|
||||
## Modeling Mental Model
|
||||
|
||||
- Model the part as a sequence of intentional operations, not as one opaque final shape.
|
||||
- Use the standard parts library first when a requested standard component is available and does not need complex custom geometry changes.
|
||||
- Start from profiles and reference geometry, then create solids with features such as extrude, revolve, loft, and sweep.
|
||||
- Use booleans and detail features after the base form is clear: cut openings, union intended merged bodies, then apply fillets, chamfers, or shell operations.
|
||||
- Use `GraphSession` whenever the result should be replayable, inspectable, serialized, or translated.
|
||||
- Use QL for grounding and selection. Query the facts you need, such as face normals, centers, areas, edge lengths, curve types, and tags.
|
||||
- Use indexed child-geometry getters such as `get_edges(index)` and `get_faces(index)` when an indexed topology pick is intentional.
|
||||
- Use semantic tags for design intent and anchors. Keep numeric measurements and geometry facts in metadata or model JSON payloads.
|
||||
- Treat `export_model_json()` as the interchange boundary for replay and CAD translation.
|
||||
- Validate incrementally: after each major step, print small QL-derived facts such as selected face count, top face center, edge count, volume, or replay result count.
|
||||
|
||||
## 1) Capture a replayable modeling flow
|
||||
|
||||
```python
|
||||
from simplecadapi import GraphSession, export_model_json
|
||||
|
||||
with GraphSession() as session:
|
||||
...
|
||||
|
||||
payload = export_model_json(session=session)
|
||||
```
|
||||
|
||||
## 2) Import and use in Python
|
||||
|
||||
```python
|
||||
import simplecadapi as scad
|
||||
from simplecadapi import GraphSession, export_model_json
|
||||
```
|
||||
|
||||
## 3) Keep replay payloads as the interchange boundary
|
||||
|
||||
- Prefer `export_model_json()` output instead of hand-written payloads.
|
||||
- Use `replay_model_json()` when you need deterministic reconstruction.
|
||||
- Use `import_model_json()` when consuming previously exported payloads.
|
||||
|
||||
## 4) Use standard parts when they fit
|
||||
|
||||
```python
|
||||
import simplecadapi as scad
|
||||
|
||||
gear = scad.std.gear.make_spur_gear_rsolid(
|
||||
n_teeth=24,
|
||||
module=1.5,
|
||||
gear_height=8.0,
|
||||
)
|
||||
rack = scad.std.gear.make_spur_rack_rsolid(module=1.5, n_teeth=18)
|
||||
bearing = scad.std.bearing.make_ball_bearing_rassembly(
|
||||
8.0,
|
||||
22.0,
|
||||
7.0,
|
||||
3.5,
|
||||
)
|
||||
```
|
||||
|
||||
- Read `references/docs/stdlib/README.md` before hand-modeling a standard mechanical part.
|
||||
- Use `references/docs/stdlib/<function_name>.md` for exact standard-library signatures.
|
||||
- Continue with core geometry APIs when the standard part requires substantial custom geometry beyond the provided parameters.
|
||||
|
||||
## 5) QL-grounded feature workflow
|
||||
|
||||
```python
|
||||
import simplecadapi as scad
|
||||
from simplecadapi import ql
|
||||
|
||||
with scad.GraphSession() as session:
|
||||
profile = scad.make_circle_rface(center=(0, 0, 0), radius=1.0)
|
||||
body = scad.extrude_rsolid(
|
||||
profile=profile,
|
||||
direction=(0, 0, 1),
|
||||
distance=4.0,
|
||||
)
|
||||
end_face = (
|
||||
ql.faces()
|
||||
.where(ql.tag("face.extrusion.end"))
|
||||
.exactly(1)
|
||||
.resolve(body)[0]
|
||||
)
|
||||
print("end face center", end_face.get_center())
|
||||
path = scad.make_segment_rwire(start=(0, 0, 4), end=(0, 0, 8))
|
||||
swept = scad.sweep_rsolid(profile=end_face, path=path)
|
||||
|
||||
payload = scad.export_model_json(session=session)
|
||||
rebuilt = scad.replay_model_json(json_str=payload)
|
||||
print("rebuilt", len(rebuilt))
|
||||
```
|
||||
|
||||
## 6) Selection and tag discipline
|
||||
|
||||
- Prefer QL selectors for semantic/geometric feature input selection.
|
||||
- Use `get_edges(index)`, `get_faces(index)`, `get_wires(index)`, or `get_vertices(index)` for intentional indexed picks in examples.
|
||||
- Attach semantic tags with `apply_tag(shape=..., tag=...)` and inspect with `list_tags(shape=...)`.
|
||||
- Use tags for intent, roles, anchors, groups, and topology names.
|
||||
- Store dimensions, positions, measured geometry, and descriptive payloads in metadata or model JSON, not in tags.
|
||||
- Keep QL result prints concise: selected count, centers, normals, areas, lengths, or tags.
|
||||
|
||||
## 7) Boolean and body discipline
|
||||
|
||||
- Use `union_rsolid(...)` when multiple solids should become one integrated body.
|
||||
- Ensure bodies that should union into one solid have real geometric overlap or embedding.
|
||||
- Use `cut_rsolid(...)` for subtractive features and `intersect_rsolid(...)` for common-volume workflows.
|
||||
- Validate body count and volume after major boolean operations.
|
||||
"""
|
||||
)
|
||||
return body.rstrip() + "\n"
|
||||
|
||||
def _build_sdk_package_summary(self) -> str:
|
||||
summary = self.metadata.description or self.package_name
|
||||
readme_excerpt = (self.metadata.readme_text or "").strip()
|
||||
excerpt_lines = [
|
||||
line.strip() for line in readme_excerpt.splitlines() if line.strip()
|
||||
]
|
||||
excerpt = "\n".join(excerpt_lines[:6])
|
||||
|
||||
body = textwrap.dedent(
|
||||
f"""\
|
||||
# SDK Package Summary
|
||||
|
||||
- Project: `{self.metadata.name}`
|
||||
- Version: `{self.metadata.version}`
|
||||
- Summary: {summary}
|
||||
|
||||
## Scope
|
||||
|
||||
- OCP-native public CAD Python SDK for geometry and replayable modeling.
|
||||
- Includes generated API, standard-library, and core type references under `references/docs/`.
|
||||
- Includes a standard parts library for reusable mechanical parts such as bearings, gears, internal ring gears, racks, and cycloidal discs.
|
||||
- Emphasizes public surfaces rather than repository operations.
|
||||
|
||||
## Main reference entry points
|
||||
|
||||
- `references/docs/api/README.md`
|
||||
- `references/docs/stdlib/README.md`
|
||||
- `references/docs/core/README.md`
|
||||
- `references/SDK_OVERVIEW.md`
|
||||
- `references/SDK_SURFACES.md`
|
||||
- `references/MODELING_WORKFLOWS.md`
|
||||
"""
|
||||
)
|
||||
|
||||
if excerpt:
|
||||
body += "\n## Package excerpt\n\n" + excerpt + "\n"
|
||||
|
||||
return body.rstrip() + "\n"
|
||||
|
||||
def _package_spec(self) -> str:
|
||||
if self.package_version:
|
||||
return f"{self.package_name}=={self.package_version}"
|
||||
return self.package_name
|
||||
|
||||
@staticmethod
|
||||
def _parse_frontmatter(content: str) -> dict[str, str]:
|
||||
lines = content.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
raise ValueError("SKILL.md is missing YAML frontmatter start marker")
|
||||
|
||||
data: dict[str, str] = {}
|
||||
end_index = None
|
||||
for index in range(1, len(lines)):
|
||||
line = lines[index]
|
||||
if line.strip() == "---":
|
||||
end_index = index
|
||||
break
|
||||
if not line.strip() or line.startswith((" ", "\t")):
|
||||
continue
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
data[key.strip()] = value.strip().strip('"').strip("'")
|
||||
|
||||
if end_index is None:
|
||||
raise ValueError("SKILL.md is missing YAML frontmatter end marker")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Package SimpleCAD API into a thin Agent Skills bundle"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Project root (default: source checkout root, or installed environment root)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-root",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Output directory for generated skill bundle (default: repo skills/ in source checkout, otherwise ./skills)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skill-name",
|
||||
default=DEFAULT_SKILL_NAME,
|
||||
help="Skill directory name and SKILL.md frontmatter name",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--license-name",
|
||||
default=DEFAULT_LICENSE,
|
||||
help="License value written into SKILL.md frontmatter",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--package-name",
|
||||
default=None,
|
||||
help="Runtime package name to install from PyPI (default: project.name)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--package-version",
|
||||
default=None,
|
||||
help="Runtime package version to install (default: project.version)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refresh-docs",
|
||||
action="store_true",
|
||||
help="Refresh docs/api and docs/stdlib via auto_docs_gen.py before packaging",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-clean",
|
||||
action="store_true",
|
||||
help="Do not remove existing output skill directory before packaging",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--archive",
|
||||
action="store_true",
|
||||
help="Create <skill-name>.tar.gz after generation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Reduce console output",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parse_args()
|
||||
project_root = (
|
||||
args.project_root.resolve()
|
||||
if args.project_root is not None
|
||||
else _default_project_root()
|
||||
)
|
||||
output_root = (
|
||||
args.output_root.resolve()
|
||||
if args.output_root is not None
|
||||
else _default_output_root(project_root)
|
||||
)
|
||||
|
||||
packager = SkillPackager(
|
||||
project_root=project_root,
|
||||
output_root=output_root,
|
||||
skill_name=args.skill_name,
|
||||
license_name=args.license_name,
|
||||
package_name=args.package_name,
|
||||
package_version=args.package_version,
|
||||
clean=not args.no_clean,
|
||||
refresh_docs=args.refresh_docs,
|
||||
archive=args.archive,
|
||||
quiet=args.quiet,
|
||||
)
|
||||
|
||||
try:
|
||||
result = packager.build()
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
if not args.quiet:
|
||||
print("Skill package generated successfully.")
|
||||
print(f"Skill directory: {result.skill_root}")
|
||||
if result.archive_path is not None:
|
||||
print(f"Archive path: {result.archive_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Auto-tagging based on TopoDelta: applies operation semantic tags to result shapes.
|
||||
|
||||
After a tracked operation (cut, union, fillet, etc.), this module can match
|
||||
result faces to their delta entries and apply semantic tags like:
|
||||
- ``op.cut.modified`` / ``op.cut.generated`` / ``op.cut.preserved``
|
||||
- ``origin.body`` / ``origin.tool``
|
||||
- ``role.section_face``
|
||||
|
||||
These tags can then be queried via the QL (``Q.tag("op.cut.generated")``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .core import Solid, Face
|
||||
from .topology import TopoKind, TopoEvent, TopoDelta
|
||||
from .tracking import _topo_id
|
||||
|
||||
|
||||
def apply_tracking_tags(
|
||||
solid: Solid,
|
||||
delta: TopoDelta,
|
||||
delta_entries: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
op_prefix: str = "op",
|
||||
) -> Solid:
|
||||
"""Apply operation semantic tags to a result solid based on TopoDelta.
|
||||
|
||||
For each face in the result solid, checks whether it matches a face in the
|
||||
delta's modified/generated/preserved/deleted lists by topo_id, and applies
|
||||
corresponding tags.
|
||||
|
||||
Faces in the result that don't match any delta entry are tagged as
|
||||
``{op_prefix}.generated`` (inferred new faces).
|
||||
|
||||
Args:
|
||||
solid: The result solid to tag.
|
||||
delta: The TopoDelta from the tracked operation.
|
||||
delta_entries: Per-entity metadata dict (optional, used for origin_role).
|
||||
op_prefix: Tag prefix for the operation (default ``"op"``).
|
||||
|
||||
Returns:
|
||||
The same ``solid`` (mutated in place) for convenience.
|
||||
"""
|
||||
entries = delta_entries or {}
|
||||
|
||||
# Build a lookup: topo_id -> event
|
||||
id_to_event: Dict[str, str] = {}
|
||||
for ref in delta.modified:
|
||||
id_to_event[ref.topo_id] = "modified"
|
||||
for ref in delta.generated:
|
||||
id_to_event[ref.topo_id] = "generated"
|
||||
for ref in delta.preserved:
|
||||
id_to_event[ref.topo_id] = "preserved"
|
||||
for ref in delta.deleted:
|
||||
id_to_event[ref.topo_id] = "deleted"
|
||||
|
||||
# Section edges
|
||||
section_ids = {ref.topo_id for ref in delta.section_edges}
|
||||
delta_is_pure_preserve = (
|
||||
len(delta.preserved) > 0
|
||||
and len(delta.modified) == 0
|
||||
and len(delta.generated) == 0
|
||||
and len(delta.deleted) == 0
|
||||
)
|
||||
|
||||
for face in solid.get_faces():
|
||||
fid = _topo_id(face.wrapped)
|
||||
event = id_to_event.get(fid)
|
||||
|
||||
if event is None:
|
||||
# Check delta_entries by input_topo_id
|
||||
entry = entries.get(fid)
|
||||
if entry:
|
||||
event = entry.get("event", "")
|
||||
|
||||
if event is None:
|
||||
if delta_is_pure_preserve:
|
||||
event = "preserved"
|
||||
else:
|
||||
# Face not found in delta -> it's a generated face
|
||||
event = "generated"
|
||||
|
||||
tag = f"{op_prefix}.{event}"
|
||||
face._add_tag(tag)
|
||||
face._apply_tag(f"face.{tag}", propagate=False)
|
||||
face.set_metadata(
|
||||
"track",
|
||||
{
|
||||
"event": event,
|
||||
"topo_id": fid,
|
||||
"op": op_prefix,
|
||||
},
|
||||
)
|
||||
|
||||
# Origin role tagging from entries
|
||||
entry = entries.get(fid, {})
|
||||
origin_role = entry.get("origin_role")
|
||||
if origin_role:
|
||||
face._add_tag(f"origin.{origin_role}")
|
||||
face._apply_tag(f"face.origin.{origin_role}", propagate=False)
|
||||
face.get_metadata("track", {})["origin_role"] = origin_role
|
||||
|
||||
# Section face tagging (faces at boolean intersection)
|
||||
if fid in section_ids:
|
||||
face._apply_tag("role.section_face", propagate=False)
|
||||
face._apply_tag("face.role.section", propagate=False)
|
||||
|
||||
return solid
|
||||
|
||||
|
||||
def apply_tracking_tags_to_delta(
|
||||
solid: Solid,
|
||||
delta: TopoDelta,
|
||||
delta_entries: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
op: str = "unknown",
|
||||
source_solid: Optional[Solid] = None,
|
||||
) -> Solid:
|
||||
"""Convenience wrapper that prefixes the operation name.
|
||||
|
||||
Args:
|
||||
solid: The result solid.
|
||||
delta: The TopoDelta.
|
||||
delta_entries: Per-entity metadata.
|
||||
op: Operation name (e.g. ``"cut"``, ``"union"``, ``"extrude"``).
|
||||
source_solid: If provided, carry over tags from this solid's faces
|
||||
to the result faces that originated from them.
|
||||
|
||||
Returns:
|
||||
The tagged solid.
|
||||
"""
|
||||
result = apply_tracking_tags(solid, delta, delta_entries, op_prefix=f"op.{op}")
|
||||
|
||||
if source_solid is not None:
|
||||
_carry_source_tags(result, delta_entries or {}, source_solid)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _carry_source_tags(
|
||||
result_solid: Solid,
|
||||
delta_entries: Dict[str, Dict[str, Any]],
|
||||
source_solid: Solid,
|
||||
) -> None:
|
||||
"""Carry over tags from source solid faces to result faces.
|
||||
|
||||
For each face in the result, looks up its delta entry to find the
|
||||
``input_topo_id`` of the source face, then copies propagatable tags.
|
||||
"""
|
||||
# Build source face lookup by topo_id
|
||||
source_face_tags: Dict[str, set] = {}
|
||||
for face in source_solid.get_faces():
|
||||
fid = _topo_id(face.wrapped)
|
||||
source_face_tags[fid] = set(face._tags)
|
||||
|
||||
# For each result face, find its source and copy tags
|
||||
for face in result_solid.get_faces():
|
||||
fid = _topo_id(face.wrapped)
|
||||
entry = delta_entries.get(fid, {})
|
||||
input_id = entry.get("input_topo_id")
|
||||
if input_id and input_id in source_face_tags:
|
||||
for tag in source_face_tags[input_id]:
|
||||
# Don't overwrite operation tags
|
||||
if not tag.startswith("op.") and not tag.startswith("origin."):
|
||||
face._add_tag(tag)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
"""Shared error types and formatting for LLM-facing SDK feedback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import importlib
|
||||
import inspect
|
||||
from typing import Any, Iterable, Optional, Sequence, Tuple, NoReturn
|
||||
|
||||
|
||||
_TECHNICAL_DETAIL_TRANSLATIONS = {
|
||||
"宽度、高度和深度必须大于0": "width, height, and depth must be greater than zero.",
|
||||
"如果传入线框作为拉伸对象,那么线框必须是闭合的, 而你的线框没有闭合,请检查构成线框的点是否正确": (
|
||||
"wire profiles must be closed before extrusion; check the points that form the wire."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _contains_cjk(value: str) -> bool:
|
||||
return any("\u3400" <= char <= "\u9fff" or "\uf900" <= char <= "\ufaff" for char in value)
|
||||
|
||||
|
||||
def _normalize_lines(values: Iterable[str]) -> Tuple[str, ...]:
|
||||
return tuple(str(value).strip() for value in values if str(value).strip())
|
||||
|
||||
|
||||
def _technical_details_from_error(error: BaseException) -> str:
|
||||
message = str(error).strip()
|
||||
if message:
|
||||
for source, target in _TECHNICAL_DETAIL_TRANSLATIONS.items():
|
||||
message = message.replace(source, target)
|
||||
if _contains_cjk(message):
|
||||
message = "The underlying validation failed; use the structured guidance above to repair the operation."
|
||||
return f"{type(error).__name__}: {message}"
|
||||
return type(error).__name__
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorGuidance:
|
||||
what_happened: str
|
||||
possible_causes: Tuple[str, ...]
|
||||
how_to_fix: Tuple[str, ...]
|
||||
technical_details: Optional[str] = None
|
||||
signature: Optional[str] = None
|
||||
documentation_hint: Optional[str] = None
|
||||
|
||||
|
||||
def _resolve_operation_callable(operation: str) -> Any:
|
||||
op = str(operation).strip()
|
||||
if not op:
|
||||
return None
|
||||
|
||||
if "." in op:
|
||||
module_name, attr_path = op.rsplit(".", 1)
|
||||
try:
|
||||
obj = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
obj = None
|
||||
if obj is not None:
|
||||
for part in attr_path.split("."):
|
||||
if not hasattr(obj, part):
|
||||
obj = None
|
||||
break
|
||||
obj = getattr(obj, part)
|
||||
if obj is not None:
|
||||
return obj
|
||||
|
||||
try:
|
||||
scad = importlib.import_module("simplecadapi")
|
||||
except Exception:
|
||||
return None
|
||||
obj = scad
|
||||
for part in op.split("."):
|
||||
if not hasattr(obj, part):
|
||||
return None
|
||||
obj = getattr(obj, part)
|
||||
return obj
|
||||
|
||||
try:
|
||||
scad = importlib.import_module("simplecadapi")
|
||||
except Exception:
|
||||
return None
|
||||
return getattr(scad, op, None)
|
||||
|
||||
|
||||
def _operation_signature(operation: str) -> Optional[str]:
|
||||
obj = _resolve_operation_callable(operation)
|
||||
if obj is None:
|
||||
return None
|
||||
try:
|
||||
return f"{operation}{inspect.signature(obj)}"
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _documentation_hint(operation: str) -> str:
|
||||
op = str(operation).strip()
|
||||
if not op:
|
||||
return "For full usage details, run help(...) on the failing operation."
|
||||
if "." in op:
|
||||
return f"For full usage details, run help({op})."
|
||||
return f"For full usage details, run help(simplecadapi.{op})."
|
||||
|
||||
|
||||
def format_llm_error(operation: str, guidance: ErrorGuidance) -> str:
|
||||
lines = [f"Operation: {operation}"]
|
||||
if guidance.signature:
|
||||
lines.append(f"Signature: {guidance.signature}")
|
||||
if guidance.documentation_hint:
|
||||
lines.append(f"Documentation: {guidance.documentation_hint}")
|
||||
lines.append(f"What happened: {guidance.what_happened}")
|
||||
lines.append("Possible causes:")
|
||||
lines.extend(f"- {item}" for item in guidance.possible_causes)
|
||||
lines.append("How to fix:")
|
||||
lines.extend(f"- {item}" for item in guidance.how_to_fix)
|
||||
if guidance.technical_details:
|
||||
lines.append(f"Technical details: {guidance.technical_details}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class SimpleCADError(ValueError):
|
||||
"""Structured ValueError variant for LLM-oriented repair guidance."""
|
||||
|
||||
def __init__(self, operation: str, guidance: ErrorGuidance):
|
||||
self.operation = str(operation)
|
||||
self.guidance = guidance
|
||||
super().__init__(format_llm_error(self.operation, self.guidance))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"operation": self.operation,
|
||||
"what_happened": self.guidance.what_happened,
|
||||
"possible_causes": list(self.guidance.possible_causes),
|
||||
"how_to_fix": list(self.guidance.how_to_fix),
|
||||
"technical_details": self.guidance.technical_details,
|
||||
"signature": self.guidance.signature,
|
||||
"documentation_hint": self.guidance.documentation_hint,
|
||||
}
|
||||
|
||||
|
||||
def raise_harness_error(
|
||||
*,
|
||||
operation: str,
|
||||
what_happened: str,
|
||||
possible_causes: Sequence[str],
|
||||
how_to_fix: Sequence[str],
|
||||
technical_details: Optional[str] = None,
|
||||
error: Optional[BaseException] = None,
|
||||
) -> NoReturn:
|
||||
if isinstance(error, SimpleCADError):
|
||||
raise error
|
||||
|
||||
resolved_details = technical_details
|
||||
if resolved_details is None and error is not None:
|
||||
resolved_details = _technical_details_from_error(error)
|
||||
|
||||
guidance = ErrorGuidance(
|
||||
what_happened=str(what_happened).strip(),
|
||||
possible_causes=_normalize_lines(possible_causes),
|
||||
how_to_fix=_normalize_lines(how_to_fix),
|
||||
technical_details=(
|
||||
str(resolved_details).strip()
|
||||
if resolved_details is not None and str(resolved_details).strip()
|
||||
else None
|
||||
),
|
||||
signature=_operation_signature(str(operation)),
|
||||
documentation_hint=_documentation_hint(str(operation)),
|
||||
)
|
||||
raise SimpleCADError(str(operation), guidance) from error
|
||||
@@ -0,0 +1,591 @@
|
||||
from simplecadapi import *
|
||||
import math
|
||||
|
||||
from .errors import raise_harness_error
|
||||
from .math import fit_cubic_bspline_control_points
|
||||
|
||||
|
||||
def make_n_hole_flange_rsolid(
|
||||
flange_outer_diameter=120.0,
|
||||
flange_inner_diameter=60.0,
|
||||
flange_thickness=15.0,
|
||||
boss_outer_diameter=80.0,
|
||||
boss_height=5.0,
|
||||
hole_diameter=8.0,
|
||||
hole_circle_diameter=100.0,
|
||||
hole_count=8,
|
||||
chamfer_size=1.0,
|
||||
) -> Solid:
|
||||
"""Create an n-hole flange with a raised boss ring and optional chamfers. The center of the bottom face is placed at the origin."""
|
||||
|
||||
print(f"开始创建{hole_count}孔法兰...")
|
||||
|
||||
from typing import cast
|
||||
import math
|
||||
|
||||
try:
|
||||
# 步骤1: 创建法兰主体圆盘
|
||||
print(" 步骤1: 创建法兰主体圆盘...")
|
||||
|
||||
# 创建外圆柱体
|
||||
outer_cylinder = make_cylinder_rsolid(
|
||||
radius=flange_outer_diameter / 2,
|
||||
height=flange_thickness,
|
||||
bottom_face_center=(0, 0, 0),
|
||||
)
|
||||
print(
|
||||
f" 法兰外圆柱创建成功,直径: {flange_outer_diameter}mm, 厚度: {flange_thickness}mm"
|
||||
)
|
||||
|
||||
# 创建内孔圆柱体
|
||||
inner_cylinder = make_cylinder_rsolid(
|
||||
radius=flange_inner_diameter / 2,
|
||||
height=flange_thickness + 2, # 确保完全切穿
|
||||
bottom_face_center=(0, 0, -1),
|
||||
)
|
||||
print(f" 法兰内孔圆柱创建成功,直径: {flange_inner_diameter}mm")
|
||||
|
||||
# 从外圆柱中减去内孔形成法兰主体
|
||||
flange_body = cut_rsolid(outer_cylinder, inner_cylinder)
|
||||
body_volume = flange_body.get_volume()
|
||||
print(f" 法兰主体创建完成,体积: {body_volume:.2f} mm³")
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤1创建法兰主体失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_n_hole_flange_rsolid",
|
||||
what_happened="Failed while creating the flange body.",
|
||||
possible_causes=[
|
||||
"The flange diameter or thickness parameters are invalid.",
|
||||
"The inner cut did not produce a single flange solid.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Check flange_outer_diameter, flange_inner_diameter, and flange_thickness.",
|
||||
"Make sure the inner diameter is smaller than the outer diameter.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
try:
|
||||
# 步骤2: 创建中心凸起圆环(保持中心通孔)
|
||||
print(" 步骤2: 创建中心凸起圆环...")
|
||||
|
||||
# 创建凸起圆环的实心部分(位于法兰顶部)
|
||||
boss_outer_solid = make_cylinder_rsolid(
|
||||
radius=boss_outer_diameter / 2,
|
||||
height=boss_height,
|
||||
bottom_face_center=(0, 0, flange_thickness),
|
||||
)
|
||||
print(
|
||||
f" 凸起圆环外圆柱创建成功,直径: {boss_outer_diameter}mm,高度: {boss_height}mm"
|
||||
)
|
||||
|
||||
# 创建凸起圆环的内孔(与法兰内孔保持一致)
|
||||
boss_inner_hole = make_cylinder_rsolid(
|
||||
radius=flange_inner_diameter / 2,
|
||||
height=boss_height + 2, # 确保完全切穿凸起圆环
|
||||
bottom_face_center=(0, 0, flange_thickness - 1),
|
||||
)
|
||||
print(f" 凸起圆环内孔创建成功,直径: {flange_inner_diameter}mm")
|
||||
|
||||
# 从凸起圆环中减去内孔,形成环形凸起
|
||||
boss_ring = cut_rsolid(boss_outer_solid, boss_inner_hole)
|
||||
print(" 凸起圆环内孔切割完成,形成环形凸起")
|
||||
|
||||
# 合并法兰主体和凸起圆环
|
||||
flange_with_boss = union_rsolid([flange_body, boss_ring])
|
||||
boss_volume = flange_with_boss.get_volume()
|
||||
print(f" 法兰主体与凸起圆环合并完成,体积: {boss_volume:.2f} mm³")
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤2创建中心凸起圆环失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_n_hole_flange_rsolid",
|
||||
what_happened="Failed while creating or merging the center boss ring.",
|
||||
possible_causes=[
|
||||
"The boss ring parameters are incompatible with the flange body.",
|
||||
"The boss cut or union result was not a single solid.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Check boss_outer_diameter, boss_height, and flange_inner_diameter.",
|
||||
"Make sure the boss overlaps the flange body as intended before union.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
try:
|
||||
# 步骤3: 创建连接孔
|
||||
print(" 步骤3: 创建连接孔...")
|
||||
|
||||
hole_radius = hole_diameter / 2
|
||||
hole_circle_radius = hole_circle_diameter / 2
|
||||
total_height = flange_thickness + boss_height + 2 # 确保完全穿透
|
||||
|
||||
print(
|
||||
f" 连接孔参数:孔径{hole_diameter}mm,分布圆直径{hole_circle_diameter}mm,孔数{hole_count}"
|
||||
)
|
||||
|
||||
# 逐个创建每个孔并切割
|
||||
current_flange = flange_with_boss
|
||||
angle_step = 360.0 / hole_count
|
||||
|
||||
for i in range(hole_count):
|
||||
angle = i * angle_step
|
||||
x = hole_circle_radius * math.cos(math.radians(angle))
|
||||
y = hole_circle_radius * math.sin(math.radians(angle))
|
||||
|
||||
# 创建单个孔
|
||||
hole = make_cylinder_rsolid(
|
||||
radius=hole_radius,
|
||||
height=total_height,
|
||||
bottom_face_center=(x, y, -1), # 从底部向上切割
|
||||
)
|
||||
|
||||
# 切割孔
|
||||
current_flange = cut_rsolid(current_flange, hole)
|
||||
print(f" 第{i + 1}个孔位置: ({x:.2f}, {y:.2f}), 角度: {angle:.1f}°")
|
||||
|
||||
flange_with_holes = current_flange
|
||||
final_volume_holes = flange_with_holes.get_volume()
|
||||
print(f" 所有连接孔切割完成,最终体积: {final_volume_holes:.2f} mm³")
|
||||
print(f" 总体积减少: {boss_volume - final_volume_holes:.2f} mm³")
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤3创建连接孔失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_n_hole_flange_rsolid",
|
||||
what_happened="Failed while cutting the bolt holes.",
|
||||
possible_causes=[
|
||||
"The hole pattern parameters place holes outside the flange body.",
|
||||
"One of the hole cuts did not produce a valid single solid.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Check hole_diameter, hole_circle_diameter, and hole_count.",
|
||||
"Verify the bolt circle lies inside the flange body.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
try:
|
||||
# 步骤4: 添加倒角处理
|
||||
print(" 步骤4: 添加倒角处理...")
|
||||
|
||||
# 获取所有边
|
||||
all_edges = flange_with_holes.get_edges()
|
||||
print(f" 找到{len(all_edges)}条边")
|
||||
|
||||
# 尝试对主要边进行倒角
|
||||
try:
|
||||
chamfered_flange = chamfer_rsolid(
|
||||
flange_with_holes, all_edges, chamfer_size
|
||||
)
|
||||
print(f" 倒角处理完成,倒角尺寸: {chamfer_size}mm")
|
||||
except:
|
||||
print(" 倒角处理失败,使用无倒角版本")
|
||||
chamfered_flange = flange_with_holes
|
||||
|
||||
except Exception as e:
|
||||
print(f" 警告: 步骤4倒角处理失败,使用无倒角版本 - {e}")
|
||||
chamfered_flange = flange_with_holes
|
||||
|
||||
try:
|
||||
# 步骤5: 验证最终结果
|
||||
print(" 步骤5: 验证最终结果...")
|
||||
|
||||
if not isinstance(chamfered_flange, Solid):
|
||||
raise ValueError("最终结果不是有效的Solid对象")
|
||||
|
||||
volume = chamfered_flange.get_volume()
|
||||
print(f" 最终法兰体积: {volume:.2f} mm³")
|
||||
print(f" 法兰外径: {flange_outer_diameter}mm")
|
||||
print(f" 法兰内径: {flange_inner_diameter}mm")
|
||||
print(f" 法兰厚度: {flange_thickness}mm")
|
||||
print(f" 凸起圆环外径: {boss_outer_diameter}mm")
|
||||
print(f" 凸起圆环高度: {boss_height}mm")
|
||||
print(f" 连接孔: {hole_count}个,直径{hole_diameter}mm")
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤5验证失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_n_hole_flange_rsolid",
|
||||
what_happened="The final flange result failed validation.",
|
||||
possible_causes=[
|
||||
"An earlier modeling step returned an invalid solid.",
|
||||
"The final solid lost validity during later feature operations.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Inspect the intermediate flange body after each step.",
|
||||
"Temporarily skip chamfering to isolate the first failing stage.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
print(f"{hole_count}孔法兰创建完成!")
|
||||
return chamfered_flange
|
||||
|
||||
|
||||
def make_naca_propeller_blade_rsolid(
|
||||
blade_length=5.0,
|
||||
root_chord=1.5,
|
||||
tip_chord=0.3,
|
||||
total_twist_angle=45.0,
|
||||
num_sections=7,
|
||||
t_c=0.16,
|
||||
) -> Solid:
|
||||
"""Create a single propeller blade solid from a twisted NACA 0016 profile. The blade root starts at the origin and extends along +Z."""
|
||||
|
||||
import math
|
||||
|
||||
print(f" 参数: 长度={blade_length}, 根部弦长={root_chord}, 叶尖弦长={tip_chord}")
|
||||
print(f" 扭转角度={total_twist_angle}°, 截面数={num_sections}")
|
||||
|
||||
def generate_naca_0016_points(chord_length=1.0, num_points=50):
|
||||
"""Generate sample points for a NACA 0016 airfoil profile."""
|
||||
print(f" 生成NACA 0016翼型点,弦长={chord_length:.3f}")
|
||||
|
||||
# NACA 0016翼型厚度分布函数
|
||||
# y/c = 0.16 * (0.2969*sqrt(x/c) - 0.1260*(x/c) - 0.3516*(x/c)^2 + 0.2843*(x/c)^3 - 0.1015*(x/c)^4)
|
||||
def naca_0016_thickness(x_c):
|
||||
"""Return the NACA 0016 half-thickness at a given x/c position."""
|
||||
if x_c < 0 or x_c > 1:
|
||||
return 0.0
|
||||
return t_c * (
|
||||
0.2969 * math.sqrt(x_c)
|
||||
- 0.1260 * x_c
|
||||
- 0.3516 * x_c**2
|
||||
+ 0.2843 * x_c**3
|
||||
- 0.1015 * x_c**4
|
||||
)
|
||||
|
||||
points = []
|
||||
|
||||
# 生成上表面点(从前缘到后缘)
|
||||
for i in range(num_points + 1):
|
||||
x_c = i / num_points # x/c从0到1
|
||||
y_c = naca_0016_thickness(x_c) # 上表面半厚度
|
||||
x = x_c * chord_length
|
||||
y = y_c * chord_length
|
||||
points.append((x, y, 0.0))
|
||||
|
||||
# 生成下表面点(从后缘到前缘,排除重复的后缘点)
|
||||
for i in range(num_points - 1, -1, -1):
|
||||
x_c = i / num_points
|
||||
y_c = -naca_0016_thickness(x_c) # 下表面负半厚度
|
||||
x = x_c * chord_length
|
||||
y = y_c * chord_length
|
||||
points.append((x, y, 0.0))
|
||||
|
||||
print(f" 生成了 {len(points)} 个翼型点")
|
||||
return points
|
||||
|
||||
try:
|
||||
# 步骤1: 生成各个径向位置的翼型截面
|
||||
print(" 步骤1: 生成各个径向位置的翼型截面...")
|
||||
|
||||
section_wires = []
|
||||
|
||||
for i in range(num_sections):
|
||||
print(f" 创建第 {i + 1}/{num_sections} 个截面...")
|
||||
|
||||
# 计算径向位置 (0到blade_length)
|
||||
r_i = (i / (num_sections - 1)) * blade_length
|
||||
print(f" 径向位置: {r_i:.3f}")
|
||||
|
||||
# 计算当前弦长(线性插值)
|
||||
chord_ratio = r_i / blade_length
|
||||
chord_i = root_chord - chord_ratio * (root_chord - tip_chord)
|
||||
print(f" 弦长: {chord_i:.3f}")
|
||||
|
||||
# 计算当前扭转角度(线性插值)
|
||||
twist_angle_i = chord_ratio * total_twist_angle
|
||||
print(f" 扭转角度: {twist_angle_i:.1f}°")
|
||||
|
||||
# 生成当前弦长的NACA 0016翼型点
|
||||
airfoil_points = generate_naca_0016_points(chord_i, num_points=30)
|
||||
|
||||
# 创建基础翼型线。BSpline 构造函数接收 exact 控制点;这里先把采样翼型
|
||||
# 拟合成 cubic 控制多边形,再显式传入解析后的 knot 数据。
|
||||
if airfoil_points[0] != airfoil_points[-1]:
|
||||
airfoil_points.append(airfoil_points[0])
|
||||
airfoil_fit = fit_cubic_bspline_control_points(airfoil_points, tolerance=0.01)
|
||||
airfoil_wire = make_spline_rwire(
|
||||
control_points=airfoil_fit.control_points,
|
||||
knots=airfoil_fit.unique_knots,
|
||||
multiplicities=airfoil_fit.multiplicities,
|
||||
)
|
||||
print(f" 基础翼型线创建成功")
|
||||
|
||||
# 先绕Z轴旋转(扭转角度)
|
||||
if abs(twist_angle_i) > 1e-6: # 如果扭转角度不为零
|
||||
airfoil_wire = rotate_shape(
|
||||
airfoil_wire, twist_angle_i, (0, 0, 1), (0, 0, 0)
|
||||
) # type: ignore
|
||||
print(f" 翼型扭转完成: {twist_angle_i:.1f}°")
|
||||
|
||||
# 然后平移到径向位置
|
||||
airfoil_wire = translate_shape(airfoil_wire, (0, 0, r_i)) # type: ignore
|
||||
print(f" 翼型平移到径向位置: Z={r_i:.3f}")
|
||||
|
||||
section_wires.append(airfoil_wire)
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤1生成翼型截面失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_naca_propeller_blade_rsolid",
|
||||
what_happened="Failed while generating blade section profiles.",
|
||||
possible_causes=[
|
||||
"The airfoil generation produced invalid points.",
|
||||
"The section transform or twist step produced invalid geometry.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Check blade_length, root_chord, tip_chord, total_twist_angle, and num_sections.",
|
||||
"Inspect the generated section wires before lofting.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
try:
|
||||
# 步骤2: 通过放样创建桨叶实体
|
||||
print(" 步骤2: 通过放样创建桨叶实体...")
|
||||
|
||||
# 验证截面数量
|
||||
if len(section_wires) < 2:
|
||||
raise ValueError(
|
||||
f"截面数量不足:需要至少2个截面,当前有{len(section_wires)}个"
|
||||
)
|
||||
|
||||
print(f" 准备放样 {len(section_wires)} 个截面...")
|
||||
|
||||
# 使用放样创建实体
|
||||
blade_solid = loft_rsolid(section_wires, ruled=False)
|
||||
print(f" 放样完成")
|
||||
|
||||
# 验证结果
|
||||
if not isinstance(blade_solid, Solid):
|
||||
raise ValueError("放样结果不是有效的Solid对象")
|
||||
|
||||
print(f" 桨叶体积: {blade_solid.get_volume():.6f}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤2放样创建实体失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_naca_propeller_blade_rsolid",
|
||||
what_happened="Failed while lofting the blade sections.",
|
||||
possible_causes=[
|
||||
"The section wires are not mutually compatible for lofting.",
|
||||
"One or more section profiles are invalid or self-intersecting.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Validate every section wire individually.",
|
||||
"Try reducing twist or simplifying the airfoil section count to isolate the incompatibility.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
print("螺旋桨叶片创建完成!")
|
||||
print(f"最终参数总结:")
|
||||
print(f" - 叶片长度: {blade_length}")
|
||||
print(f" - 根部弦长: {root_chord} -> 叶尖弦长: {tip_chord}")
|
||||
print(f" - 总扭转: {total_twist_angle}°")
|
||||
print(f" - 截面数: {num_sections}")
|
||||
print(f" - 厚度比: {t_c}")
|
||||
|
||||
return blade_solid
|
||||
|
||||
|
||||
def make_threaded_rod_rsolid(
|
||||
thread_diameter=8.0,
|
||||
thread_length=20.0,
|
||||
total_length=30.0,
|
||||
thread_pitch=1.25,
|
||||
thread_start_position=0.0,
|
||||
chamfer_size=0.5,
|
||||
) -> Solid:
|
||||
"""Create a threaded rod with configurable rod length, thread span, and pitch. The top center is placed at the origin and the rod extends in -Z."""
|
||||
print(f"开始创建螺杆...")
|
||||
print(f" 参数: 直径={thread_diameter}mm, 总长度={total_length}mm")
|
||||
print(
|
||||
f" 螺纹: 长度={thread_length}mm, 螺距={thread_pitch}mm, 起始位置={thread_start_position}mm"
|
||||
)
|
||||
|
||||
from typing import cast
|
||||
import math
|
||||
|
||||
# 参数验证
|
||||
if thread_diameter <= 0:
|
||||
raise ValueError("螺纹直径必须大于0")
|
||||
if total_length <= 0:
|
||||
raise ValueError("螺杆总长度必须大于0")
|
||||
if thread_length <= 0:
|
||||
raise ValueError("螺纹长度必须大于0")
|
||||
if thread_pitch <= 0:
|
||||
raise ValueError("螺纹螺距必须大于0")
|
||||
if thread_length > total_length:
|
||||
raise ValueError("螺纹长度不能大于螺杆总长度")
|
||||
if (
|
||||
thread_start_position < 0
|
||||
or thread_start_position + thread_length > total_length
|
||||
):
|
||||
raise ValueError("螺纹起始位置或结束位置超出螺杆范围")
|
||||
|
||||
try:
|
||||
# 步骤1: 创建螺杆主体
|
||||
print(" 步骤1: 创建螺杆主体...")
|
||||
|
||||
thread_radius = thread_diameter / 2.0
|
||||
thread_face = make_circle_rface((0, 0, 0), thread_radius)
|
||||
thread_solid = extrude_rsolid(thread_face, (0, 0, 1), total_length)
|
||||
print(
|
||||
f" 螺杆主体创建成功,直径: {thread_diameter}mm,长度: {total_length}mm"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤1创建螺杆主体失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_threaded_rod_rsolid",
|
||||
what_happened="Failed while creating the rod body.",
|
||||
possible_causes=[
|
||||
"The rod diameter or length parameters are invalid.",
|
||||
"The base rod extrusion failed.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Check thread_diameter and total_length.",
|
||||
"Make sure all rod dimensions are positive and finite.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
try:
|
||||
# 步骤2: 创建螺纹切割体
|
||||
print(" 步骤2: 创建螺纹切割体...")
|
||||
|
||||
# 计算螺纹深度(根据标准螺纹深度约为螺距的0.613倍)
|
||||
thread_depth = thread_pitch * 0.613 # 约0.766mm for M8
|
||||
|
||||
# 定义V型螺纹切割轮廓的顶点
|
||||
thread_points = [
|
||||
(float(thread_radius), 0.0, 0.0), # 螺纹大径处,轴向位置0
|
||||
(
|
||||
float(thread_radius - thread_depth),
|
||||
float(thread_pitch * 0.5),
|
||||
0.0,
|
||||
), # 螺纹根部,轴向偏移pitch/2
|
||||
(
|
||||
float(thread_radius),
|
||||
float(thread_pitch),
|
||||
0.0,
|
||||
), # 螺纹大径处,轴向偏移pitch
|
||||
]
|
||||
|
||||
# 创建V型螺纹切割轮廓
|
||||
thread_cut_wire = make_polyline_rwire(thread_points, closed=True)
|
||||
print(f" V型螺纹切割轮廓创建成功,螺纹深度: {thread_depth:.3f}mm")
|
||||
|
||||
# 使用螺旋扫掠创建螺纹切割体
|
||||
helical_cut_solid = helical_sweep_rsolid(
|
||||
thread_cut_wire, thread_pitch, thread_length, thread_radius
|
||||
)
|
||||
|
||||
# 调整螺旋切割体位置到螺纹起始位置
|
||||
translated_helical_cut = translate_shape(
|
||||
helical_cut_solid, (0, 0, -thread_length - thread_start_position)
|
||||
)
|
||||
helical_cut_solid = cast(Solid, translated_helical_cut)
|
||||
print(
|
||||
f" 螺旋切割体创建成功,螺距: {thread_pitch}mm,起始位置: {thread_start_position}mm"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤2创建螺纹切割体失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_threaded_rod_rsolid",
|
||||
what_happened="Failed while creating the helical thread cutter.",
|
||||
possible_causes=[
|
||||
"The thread pitch or thread length is invalid.",
|
||||
"The helical sweep failed for the chosen thread profile.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Check thread_pitch, thread_length, and thread_start_position.",
|
||||
"Try a simpler thread profile or shorter thread span to isolate the sweep failure.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
try:
|
||||
# 步骤3: 从螺杆中减去螺纹切割体形成螺纹
|
||||
print(" 步骤3: 切割螺纹...")
|
||||
|
||||
thread_solid = cut_rsolid(thread_solid, helical_cut_solid)
|
||||
print(" 螺纹切割完成")
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤3切割螺纹失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_threaded_rod_rsolid",
|
||||
what_happened="Failed while cutting the thread geometry into the rod body.",
|
||||
possible_causes=[
|
||||
"The thread cutter does not intersect the rod body as expected.",
|
||||
"The boolean cut failed on the current geometry.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Inspect the rod body and the translated helical cutter before the cut step.",
|
||||
"Reduce thread depth or simplify the cutter geometry to isolate the boolean failure.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
try:
|
||||
# 步骤4: 添加螺杆末端倒角
|
||||
print(" 步骤4: 添加螺杆末端倒角...")
|
||||
|
||||
# 使用QL语义选择器选择螺杆底端的圆形边,避免依赖拓扑顺序。
|
||||
bottom_edge_selector = (
|
||||
ql.edges()
|
||||
.where(ql.curve_type("circle"))
|
||||
.order_by(ql.center_axis("z"))
|
||||
.take(1)
|
||||
.exactly(1)
|
||||
)
|
||||
|
||||
# 对螺杆末端进行倒角处理
|
||||
thread_solid = chamfer_rsolid(thread_solid, bottom_edge_selector, chamfer_size)
|
||||
print(f" 螺杆末端倒角完成,倒角尺寸: {chamfer_size}mm")
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤4添加螺杆末端倒角失败 - {e}")
|
||||
print(f" 跳过倒角处理,继续使用原始螺杆")
|
||||
# 倒角失败时继续使用原始螺杆
|
||||
|
||||
try:
|
||||
# 步骤5: 验证最终结果
|
||||
print(" 步骤5: 验证最终结果...")
|
||||
|
||||
if not isinstance(thread_solid, Solid):
|
||||
raise ValueError("最终结果不是有效的Solid对象")
|
||||
|
||||
volume = thread_solid.get_volume()
|
||||
print(f" 最终螺杆体积: {volume:.2f} mm³")
|
||||
print(f" 螺杆直径: {thread_diameter}mm")
|
||||
print(f" 螺杆总长度: {total_length}mm")
|
||||
print(f" 螺纹长度: {thread_length}mm")
|
||||
print(f" 螺纹螺距: {thread_pitch}mm")
|
||||
print(f" 螺纹起始位置: {thread_start_position}mm")
|
||||
|
||||
except Exception as e:
|
||||
print(f" 错误: 步骤5验证失败 - {e}")
|
||||
raise_harness_error(
|
||||
operation="make_threaded_rod_rsolid",
|
||||
what_happened="The final threaded rod result failed validation.",
|
||||
possible_causes=[
|
||||
"An earlier rod or thread step returned an invalid solid.",
|
||||
"The final result is no longer a valid Solid after feature operations.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Inspect the intermediate rod body, thread cutter, and final cut result.",
|
||||
"Temporarily skip chamfering to isolate the first failing stage.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
|
||||
print("螺杆创建完成!")
|
||||
return thread_solid
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Minimal expression graph support for SimpleCADAPI 2.0.
|
||||
|
||||
The goal of this module is to provide a low-intrusion parametric layer:
|
||||
|
||||
- users explicitly create variables with ``var(name, default)``
|
||||
- plain numeric literals are automatically lifted to constants when needed
|
||||
- arithmetic on variables/expressions builds a small expression DAG
|
||||
- public modeling APIs can keep their existing pure-function signatures while
|
||||
accepting expression values transparently
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import math
|
||||
from typing import Any, Dict, List, Mapping, Tuple, Union, cast
|
||||
import uuid
|
||||
|
||||
|
||||
def _make_expr_id(prefix: str = "expr") -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
class ScalarExprBase:
|
||||
"""Base class for scalar expression nodes."""
|
||||
|
||||
expr_id: str
|
||||
|
||||
def evaluate(self, bindings: Mapping[str, float] | None = None) -> float:
|
||||
raise NotImplementedError
|
||||
|
||||
def __float__(self) -> float:
|
||||
return float(self.evaluate())
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
raise TypeError(
|
||||
"Expression objects do not define truthiness. Evaluate them explicitly first."
|
||||
)
|
||||
|
||||
def _binary_expr(self, op: str, other: ScalarLike) -> Expr:
|
||||
return Expr(op=op, args=(lift_scalar(self), lift_scalar(other)))
|
||||
|
||||
def _rbinary_expr(self, op: str, other: ScalarLike) -> Expr:
|
||||
return Expr(op=op, args=(lift_scalar(other), lift_scalar(self)))
|
||||
|
||||
def __add__(self, other: ScalarLike) -> Expr:
|
||||
return self._binary_expr("add", other)
|
||||
|
||||
def __radd__(self, other: ScalarLike) -> Expr:
|
||||
return self._rbinary_expr("add", other)
|
||||
|
||||
def __sub__(self, other: ScalarLike) -> Expr:
|
||||
return self._binary_expr("sub", other)
|
||||
|
||||
def __rsub__(self, other: ScalarLike) -> Expr:
|
||||
return self._rbinary_expr("sub", other)
|
||||
|
||||
def __mul__(self, other: ScalarLike) -> Expr:
|
||||
return self._binary_expr("mul", other)
|
||||
|
||||
def __rmul__(self, other: ScalarLike) -> Expr:
|
||||
return self._rbinary_expr("mul", other)
|
||||
|
||||
def __truediv__(self, other: ScalarLike) -> Expr:
|
||||
return self._binary_expr("div", other)
|
||||
|
||||
def __rtruediv__(self, other: ScalarLike) -> Expr:
|
||||
return self._rbinary_expr("div", other)
|
||||
|
||||
def __pow__(self, other: ScalarLike) -> Expr:
|
||||
return self._binary_expr("pow", other)
|
||||
|
||||
def __rpow__(self, other: ScalarLike) -> Expr:
|
||||
return self._rbinary_expr("pow", other)
|
||||
|
||||
def __neg__(self) -> Expr:
|
||||
return Expr(op="neg", args=(lift_scalar(self),))
|
||||
|
||||
def __abs__(self) -> Expr:
|
||||
return Expr(op="abs", args=(lift_scalar(self),))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Const(ScalarExprBase):
|
||||
"""Immutable constant node used in the v2 expression graph."""
|
||||
|
||||
value: float
|
||||
expr_id: str = field(default_factory=lambda: _make_expr_id("const"))
|
||||
|
||||
def evaluate(self, bindings: Mapping[str, float] | None = None) -> float:
|
||||
return float(self.value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Var(ScalarExprBase):
|
||||
"""Named scalar parameter with a default fallback value."""
|
||||
|
||||
name: str
|
||||
default: float
|
||||
comment: str | None = None
|
||||
expr_id: str = field(default_factory=lambda: _make_expr_id("var"))
|
||||
|
||||
def evaluate(self, bindings: Mapping[str, float] | None = None) -> float:
|
||||
if bindings is not None and self.name in bindings:
|
||||
return float(bindings[self.name])
|
||||
return float(self.default)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Expr(ScalarExprBase):
|
||||
"""Derived scalar expression node built from one or more operands."""
|
||||
|
||||
op: str
|
||||
args: Tuple[ScalarExpr, ...]
|
||||
expr_id: str = field(default_factory=lambda: _make_expr_id("expr"))
|
||||
|
||||
def evaluate(self, bindings: Mapping[str, float] | None = None) -> float:
|
||||
values = [arg.evaluate(bindings) for arg in self.args]
|
||||
if self.op == "add":
|
||||
return values[0] + values[1]
|
||||
if self.op == "sub":
|
||||
return values[0] - values[1]
|
||||
if self.op == "mul":
|
||||
return values[0] * values[1]
|
||||
if self.op == "div":
|
||||
return values[0] / values[1]
|
||||
if self.op == "pow":
|
||||
return values[0] ** values[1]
|
||||
if self.op == "neg":
|
||||
return -values[0]
|
||||
if self.op == "abs":
|
||||
return abs(values[0])
|
||||
if self.op == "sin":
|
||||
return math.sin(values[0])
|
||||
if self.op == "cos":
|
||||
return math.cos(values[0])
|
||||
if self.op == "tan":
|
||||
return math.tan(values[0])
|
||||
if self.op == "sqrt":
|
||||
return math.sqrt(values[0])
|
||||
if self.op == "acos":
|
||||
return math.acos(values[0])
|
||||
if self.op == "asin":
|
||||
return math.asin(values[0])
|
||||
if self.op == "atan":
|
||||
return math.atan(values[0])
|
||||
if self.op == "atan2":
|
||||
return math.atan2(values[0], values[1])
|
||||
raise ValueError(f"Unsupported expression op '{self.op}'")
|
||||
|
||||
|
||||
ScalarExpr = Union[Const, Var, Expr]
|
||||
ScalarLike = Union[int, float, ScalarExpr]
|
||||
|
||||
|
||||
def const(value: int | float) -> Const:
|
||||
"""Create a constant scalar node for parameterized modeling."""
|
||||
|
||||
return Const(float(value))
|
||||
|
||||
|
||||
def var(name: str, default: int | float, comment: str | None = None) -> Var:
|
||||
"""Create a named variable node for v2 expression-driven parameters."""
|
||||
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ValueError("Variable name must be a non-empty string")
|
||||
if comment is not None and not isinstance(comment, str):
|
||||
raise ValueError("Variable comment must be a string when provided")
|
||||
return Var(name=name, default=float(default), comment=comment)
|
||||
|
||||
|
||||
def lift_scalar(value: ScalarLike) -> ScalarExpr:
|
||||
if isinstance(value, (Const, Var, Expr)):
|
||||
return value
|
||||
if isinstance(value, bool):
|
||||
raise TypeError("Boolean values are not valid scalar expression inputs")
|
||||
if isinstance(value, (int, float)):
|
||||
return Const(float(value))
|
||||
raise TypeError(f"Unsupported scalar expression value: {type(value)!r}")
|
||||
|
||||
|
||||
def evaluate_scalar(
|
||||
value: ScalarLike, bindings: Mapping[str, float] | None = None
|
||||
) -> float:
|
||||
return float(lift_scalar(value).evaluate(bindings))
|
||||
|
||||
|
||||
def sin(value: ScalarLike) -> Expr:
|
||||
return Expr(op="sin", args=(lift_scalar(value),))
|
||||
|
||||
|
||||
def cos(value: ScalarLike) -> Expr:
|
||||
return Expr(op="cos", args=(lift_scalar(value),))
|
||||
|
||||
|
||||
def tan(value: ScalarLike) -> Expr:
|
||||
return Expr(op="tan", args=(lift_scalar(value),))
|
||||
|
||||
|
||||
def sqrt(value: ScalarLike) -> Expr:
|
||||
return Expr(op="sqrt", args=(lift_scalar(value),))
|
||||
|
||||
|
||||
def acos(value: ScalarLike) -> Expr:
|
||||
return Expr(op="acos", args=(lift_scalar(value),))
|
||||
|
||||
|
||||
def asin(value: ScalarLike) -> Expr:
|
||||
return Expr(op="asin", args=(lift_scalar(value),))
|
||||
|
||||
|
||||
def atan(value: ScalarLike) -> Expr:
|
||||
return Expr(op="atan", args=(lift_scalar(value),))
|
||||
|
||||
|
||||
def atan2(y: ScalarLike, x: ScalarLike) -> Expr:
|
||||
return Expr(op="atan2", args=(lift_scalar(y), lift_scalar(x)))
|
||||
|
||||
|
||||
def evaluate_value(value: Any, bindings: Mapping[str, float] | None = None) -> Any:
|
||||
if isinstance(value, tuple):
|
||||
return tuple(evaluate_value(item, bindings) for item in value)
|
||||
if isinstance(value, list):
|
||||
return [evaluate_value(item, bindings) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: evaluate_value(item, bindings) for key, item in value.items()}
|
||||
if isinstance(value, (Const, Var, Expr, int, float)) and not isinstance(
|
||||
value, bool
|
||||
):
|
||||
return evaluate_scalar(value, bindings)
|
||||
return value
|
||||
|
||||
|
||||
def _expr_to_node_payload(expr: ScalarExpr) -> Dict[str, Any]:
|
||||
if isinstance(expr, Const):
|
||||
return {
|
||||
"expr_id": expr.expr_id,
|
||||
"kind": "const",
|
||||
"value": float(expr.value),
|
||||
}
|
||||
if isinstance(expr, Var):
|
||||
payload = {
|
||||
"expr_id": expr.expr_id,
|
||||
"kind": "var",
|
||||
"name": expr.name,
|
||||
"default": float(expr.default),
|
||||
}
|
||||
if expr.comment:
|
||||
payload["comment"] = expr.comment
|
||||
return payload
|
||||
return {
|
||||
"expr_id": expr.expr_id,
|
||||
"kind": "expr",
|
||||
"op": expr.op,
|
||||
"args": [arg.expr_id for arg in expr.args],
|
||||
}
|
||||
|
||||
|
||||
class ExpressionGraph:
|
||||
"""A lightweight registry of expression DAG nodes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._nodes: Dict[str, ScalarExpr] = {}
|
||||
|
||||
@property
|
||||
def node_count(self) -> int:
|
||||
return len(self._nodes)
|
||||
|
||||
def get(self, expr_id: str) -> ScalarExpr | None:
|
||||
return self._nodes.get(expr_id)
|
||||
|
||||
def register(self, value: ScalarLike) -> ScalarExpr:
|
||||
expr = lift_scalar(value)
|
||||
self._register_recursive(expr)
|
||||
return expr
|
||||
|
||||
def _register_recursive(self, expr: ScalarExpr) -> None:
|
||||
if expr.expr_id in self._nodes:
|
||||
return
|
||||
if isinstance(expr, Expr):
|
||||
for arg in expr.args:
|
||||
self._register_recursive(arg)
|
||||
self._nodes[expr.expr_id] = expr
|
||||
|
||||
def _topological_expr_ids(self) -> List[str]:
|
||||
ordered: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def visit(expr: ScalarExpr) -> None:
|
||||
if expr.expr_id in seen:
|
||||
return
|
||||
if isinstance(expr, Expr):
|
||||
for arg in expr.args:
|
||||
visit(arg)
|
||||
seen.add(expr.expr_id)
|
||||
ordered.append(expr.expr_id)
|
||||
|
||||
for expr in list(self._nodes.values()):
|
||||
visit(expr)
|
||||
return ordered
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"nodes": [
|
||||
_expr_to_node_payload(self._nodes[expr_id])
|
||||
for expr_id in self._topological_expr_ids()
|
||||
]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ExpressionGraph":
|
||||
graph = cls()
|
||||
node_map: Dict[str, ScalarExpr] = {}
|
||||
for node in data.get("nodes", []):
|
||||
kind = node.get("kind")
|
||||
expr_id = str(node["expr_id"])
|
||||
if kind == "const":
|
||||
expr = Const(value=float(node["value"]), expr_id=expr_id)
|
||||
elif kind == "var":
|
||||
expr = Var(
|
||||
name=str(node["name"]),
|
||||
default=float(node["default"]),
|
||||
comment=str(node["comment"]) if node.get("comment") else None,
|
||||
expr_id=expr_id,
|
||||
)
|
||||
elif kind == "expr":
|
||||
args = tuple(node_map[arg_id] for arg_id in node.get("args", []))
|
||||
expr = Expr(op=str(node["op"]), args=args, expr_id=expr_id)
|
||||
else:
|
||||
raise ValueError(f"Unknown expression node kind: {kind!r}")
|
||||
graph._nodes[expr_id] = expr
|
||||
node_map[expr_id] = expr
|
||||
return graph
|
||||
|
||||
|
||||
def _canonicalize_param_value(
|
||||
value: Any, expression_graph: ExpressionGraph
|
||||
) -> Tuple[Any, Any | None]:
|
||||
if isinstance(value, tuple):
|
||||
numeric_items: List[Any] = []
|
||||
expr_items: List[Any] = []
|
||||
has_expr = False
|
||||
for item in value:
|
||||
numeric_item, expr_item = _canonicalize_param_value(item, expression_graph)
|
||||
numeric_items.append(numeric_item)
|
||||
expr_items.append(expr_item)
|
||||
has_expr = has_expr or expr_item is not None
|
||||
return tuple(numeric_items), expr_items if has_expr else None
|
||||
|
||||
if isinstance(value, list):
|
||||
numeric_items = []
|
||||
expr_items = []
|
||||
has_expr = False
|
||||
for item in value:
|
||||
numeric_item, expr_item = _canonicalize_param_value(item, expression_graph)
|
||||
numeric_items.append(numeric_item)
|
||||
expr_items.append(expr_item)
|
||||
has_expr = has_expr or expr_item is not None
|
||||
return numeric_items, expr_items if has_expr else None
|
||||
|
||||
if isinstance(value, dict):
|
||||
numeric_dict: Dict[str, Any] = {}
|
||||
expr_dict: Dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
numeric_item, expr_item = _canonicalize_param_value(item, expression_graph)
|
||||
numeric_dict[str(key)] = numeric_item
|
||||
if expr_item is not None:
|
||||
expr_dict[str(key)] = expr_item
|
||||
return numeric_dict, expr_dict or None
|
||||
|
||||
if isinstance(value, (Const, Var, Expr)):
|
||||
expr = expression_graph.register(cast(ScalarLike, value))
|
||||
return float(expr.evaluate()), {"expr_id": expr.expr_id}
|
||||
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return float(value), None
|
||||
|
||||
return value, None
|
||||
|
||||
|
||||
def _is_discrete_param_name(key: str) -> bool:
|
||||
key_lower = key.lower()
|
||||
return key_lower.endswith("_indices") or key_lower in {
|
||||
"edge_count",
|
||||
"face_count",
|
||||
"geo_selector",
|
||||
"removed_face_count",
|
||||
"profile_count",
|
||||
"profile",
|
||||
"count",
|
||||
"degree",
|
||||
"multiplicities",
|
||||
"output_count",
|
||||
"periodic",
|
||||
}
|
||||
|
||||
|
||||
def canonicalize_params(
|
||||
params: Dict[str, Any] | None,
|
||||
expression_graph: ExpressionGraph,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
if not params:
|
||||
return {}, {}
|
||||
|
||||
numeric_params: Dict[str, Any] = {}
|
||||
param_exprs: Dict[str, Any] = {}
|
||||
for key, value in params.items():
|
||||
if _is_discrete_param_name(key):
|
||||
numeric_params[key] = value
|
||||
continue
|
||||
numeric_value, expr_value = _canonicalize_param_value(value, expression_graph)
|
||||
numeric_params[key] = numeric_value
|
||||
if expr_value is not None:
|
||||
param_exprs[key] = expr_value
|
||||
return numeric_params, param_exprs
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Explicit frame graph for the 2.0 rearchitecture path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameNode:
|
||||
frame_id: str
|
||||
origin: tuple[float, float, float]
|
||||
x_axis: tuple[float, float, float]
|
||||
y_axis: tuple[float, float, float]
|
||||
z_axis: tuple[float, float, float]
|
||||
parent_frame_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class FrameGraph:
|
||||
def __init__(self) -> None:
|
||||
self._nodes: Dict[str, FrameNode] = {}
|
||||
|
||||
def ensure_frame(
|
||||
self,
|
||||
frame_id: str,
|
||||
*,
|
||||
origin: tuple[float, float, float],
|
||||
x_axis: tuple[float, float, float],
|
||||
y_axis: tuple[float, float, float],
|
||||
z_axis: tuple[float, float, float],
|
||||
parent_frame_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> FrameNode:
|
||||
node = FrameNode(
|
||||
frame_id=frame_id,
|
||||
origin=origin,
|
||||
x_axis=x_axis,
|
||||
y_axis=y_axis,
|
||||
z_axis=z_axis,
|
||||
parent_frame_id=parent_frame_id,
|
||||
metadata=dict(metadata or {}),
|
||||
)
|
||||
self._nodes[frame_id] = node
|
||||
return node
|
||||
|
||||
@property
|
||||
def node_count(self) -> int:
|
||||
return len(self._nodes)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"frame_id": node.frame_id,
|
||||
"origin": node.origin,
|
||||
"x_axis": node.x_axis,
|
||||
"y_axis": node.y_axis,
|
||||
"z_axis": node.z_axis,
|
||||
"parent_frame_id": node.parent_frame_id,
|
||||
"metadata": dict(node.metadata),
|
||||
}
|
||||
for node in self._nodes.values()
|
||||
]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "FrameGraph":
|
||||
graph = cls()
|
||||
for item in data.get("nodes", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
graph.ensure_frame(
|
||||
str(item["frame_id"]),
|
||||
origin=tuple(item.get("origin", (0.0, 0.0, 0.0))),
|
||||
x_axis=tuple(item.get("x_axis", (1.0, 0.0, 0.0))),
|
||||
y_axis=tuple(item.get("y_axis", (0.0, 1.0, 0.0))),
|
||||
z_axis=tuple(item.get("z_axis", (0.0, 0.0, 1.0))),
|
||||
parent_frame_id=item.get("parent_frame_id"),
|
||||
metadata=dict(item.get("metadata", {})),
|
||||
)
|
||||
return graph
|
||||
@@ -0,0 +1,397 @@
|
||||
"""DAG session recorder for building operation graphs.
|
||||
|
||||
Usage::
|
||||
|
||||
from simplecadapi.graph import GraphSession, record_operation
|
||||
|
||||
with GraphSession() as session:
|
||||
line_a = record_operation(
|
||||
"make_line_redge", {"start": (0, 0, 0), "end": (10, 0, 0)}
|
||||
)
|
||||
line_b = record_operation(
|
||||
"make_line_redge", {"start": (10, 0, 0), "end": (10, 5, 0)}
|
||||
)
|
||||
wire = record_operation(
|
||||
"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[line_a, line_b]
|
||||
)
|
||||
|
||||
# Session graph is now available
|
||||
assert session.graph.node_count == 3
|
||||
json_str = session.graph.to_json()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
from .expr import ExpressionGraph, canonicalize_params
|
||||
from .frame import FrameGraph
|
||||
from .topology import OperationGraph, OperationNode, TopoDelta
|
||||
from .topology import SemanticDelta
|
||||
from .topology import TopoKind, TopoRef, topo_ref_to_dict
|
||||
from .core import Compound, Edge, Face, Solid, Vertex, Wire, get_current_cs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_active_session_var: ContextVar[Optional["GraphSession"]] = ContextVar(
|
||||
"simplecadapi_active_graph_session", default=None
|
||||
)
|
||||
_recording_suspend_depth_var: ContextVar[int] = ContextVar(
|
||||
"simplecadapi_recording_suspend_depth", default=0
|
||||
)
|
||||
|
||||
|
||||
class GraphSession:
|
||||
"""Context manager that records CAD operations into a DAG.
|
||||
|
||||
Usage::
|
||||
|
||||
with GraphSession() as session:
|
||||
n1 = record_operation(
|
||||
"make_line_redge", {"start": (0, 0, 0), "end": (1, 0, 0)}
|
||||
)
|
||||
n2 = record_operation(
|
||||
"make_line_redge", {"start": (1, 0, 0), "end": (1, 1, 0)}
|
||||
)
|
||||
record_operation(
|
||||
"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[n1, n2]
|
||||
)
|
||||
|
||||
# Access the graph after the session
|
||||
print(session.graph.topological_order())
|
||||
"""
|
||||
|
||||
def __init__(self, graph_id: Optional[str] = None) -> None:
|
||||
self.graph = OperationGraph(graph_id=graph_id)
|
||||
self.expression_graph = ExpressionGraph()
|
||||
self.frame_graph = FrameGraph()
|
||||
self._active_session_token: Optional[Token[Optional["GraphSession"]]] = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._active_session_token is not None:
|
||||
raise RuntimeError("GraphSession is already active")
|
||||
self._active_session_token = _active_session_var.set(self)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._active_session_token is not None:
|
||||
_active_session_var.reset(self._active_session_token)
|
||||
self._active_session_token = None
|
||||
|
||||
def __enter__(self) -> "GraphSession":
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.stop()
|
||||
|
||||
|
||||
def get_active_session() -> Optional[GraphSession]:
|
||||
"""Return the currently active GraphSession, or None."""
|
||||
return _active_session_var.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suspend_graph_recording():
|
||||
"""Temporarily suspend automatic graph recording for internal API composition."""
|
||||
|
||||
token = _recording_suspend_depth_var.set(_recording_suspend_depth_var.get() + 1)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_recording_suspend_depth_var.reset(token)
|
||||
|
||||
|
||||
def _normalize_output_shapes(outputs: Any) -> List[Any]:
|
||||
if outputs is None:
|
||||
return []
|
||||
if isinstance(outputs, (list, tuple)):
|
||||
return list(outputs)
|
||||
return [outputs]
|
||||
|
||||
|
||||
def _extract_input_nodes(inputs: Optional[Iterable[Any]]) -> List[OperationNode]:
|
||||
if not inputs:
|
||||
return []
|
||||
|
||||
nodes: List[OperationNode] = []
|
||||
seen: Set[str] = set()
|
||||
for obj in inputs:
|
||||
if obj is None:
|
||||
continue
|
||||
node = getattr(obj, "_get_runtime", lambda *_args, **_kwargs: None)(
|
||||
"graph.node"
|
||||
)
|
||||
if node is None:
|
||||
continue
|
||||
if node.node_id in seen:
|
||||
continue
|
||||
seen.add(node.node_id)
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
|
||||
def _current_context_snapshot() -> Dict[str, Any]:
|
||||
cs = get_current_cs()
|
||||
return {
|
||||
"origin": tuple(float(v) for v in cs.origin),
|
||||
"x_axis": tuple(float(v) for v in cs.x_axis),
|
||||
"y_axis": tuple(float(v) for v in cs.y_axis),
|
||||
"z_axis": tuple(float(v) for v in cs.z_axis),
|
||||
}
|
||||
|
||||
|
||||
def _register_current_frame(session: GraphSession, node_id: str) -> None:
|
||||
cs = get_current_cs()
|
||||
session.frame_graph.ensure_frame(
|
||||
f"frame:{node_id}",
|
||||
origin=tuple(float(v) for v in cs.origin),
|
||||
x_axis=tuple(float(v) for v in cs.x_axis),
|
||||
y_axis=tuple(float(v) for v in cs.y_axis),
|
||||
z_axis=tuple(float(v) for v in cs.z_axis),
|
||||
metadata={"node_id": node_id},
|
||||
)
|
||||
|
||||
|
||||
def _shape_kind(shape: Any) -> Optional[TopoKind]:
|
||||
if isinstance(shape, Vertex):
|
||||
return TopoKind.VERTEX
|
||||
if isinstance(shape, Edge):
|
||||
return TopoKind.EDGE
|
||||
if isinstance(shape, Wire):
|
||||
return TopoKind.WIRE
|
||||
if isinstance(shape, Face):
|
||||
return TopoKind.FACE
|
||||
if isinstance(shape, Solid):
|
||||
return TopoKind.SOLID
|
||||
if isinstance(shape, Compound):
|
||||
return TopoKind.COMPOUND
|
||||
return None
|
||||
|
||||
|
||||
def _wrapped_shape(shape: Any) -> Any:
|
||||
if isinstance(shape, (Vertex, Edge, Wire, Face, Solid, Compound)):
|
||||
return shape.wrapped
|
||||
return None
|
||||
|
||||
|
||||
def _shape_topo_id(shape: Any) -> str:
|
||||
topo_id = getattr(shape, "topo_id", None)
|
||||
if topo_id is not None:
|
||||
kind = _shape_kind(shape)
|
||||
prefix = kind.name.lower() if kind is not None else "shape"
|
||||
return f"{prefix}_{topo_id}"
|
||||
wrapped = _wrapped_shape(shape)
|
||||
if wrapped is None:
|
||||
return f"obj_{id(shape)}"
|
||||
kind = _shape_kind(shape)
|
||||
prefix = kind.name.lower() if kind is not None else "shape"
|
||||
try:
|
||||
return f"{prefix}_{wrapped.HashCode(1000000)}"
|
||||
except AttributeError:
|
||||
return f"{prefix}_{hash(wrapped)}"
|
||||
|
||||
|
||||
def _attach_topo_refs_recursive(
|
||||
shape: Any,
|
||||
*,
|
||||
graph_id: str,
|
||||
node: OperationNode,
|
||||
output_slot: int,
|
||||
) -> None:
|
||||
kind = _shape_kind(shape)
|
||||
if kind is None:
|
||||
return
|
||||
|
||||
topo_ref = TopoRef(
|
||||
graph_id=graph_id,
|
||||
node_id=node.node_id,
|
||||
output_slot=output_slot,
|
||||
kind=kind,
|
||||
topo_id=_shape_topo_id(shape),
|
||||
)
|
||||
|
||||
setter = getattr(shape, "_set_runtime", None)
|
||||
if callable(setter):
|
||||
setter("topo.ref", topo_ref)
|
||||
setter("topo.kind", kind.name)
|
||||
setter("topo.id", topo_ref.topo_id)
|
||||
|
||||
set_metadata = getattr(shape, "set_metadata", None)
|
||||
if callable(set_metadata):
|
||||
set_metadata("topo_ref", topo_ref_to_dict(topo_ref))
|
||||
|
||||
children = getattr(shape, "get_children", None)
|
||||
if callable(children):
|
||||
for child in children():
|
||||
_attach_topo_refs_recursive(
|
||||
child,
|
||||
graph_id=graph_id,
|
||||
node=node,
|
||||
output_slot=output_slot,
|
||||
)
|
||||
|
||||
|
||||
def attach_graph_node(
|
||||
output: Any,
|
||||
node: OperationNode,
|
||||
output_slot: int = 0,
|
||||
graph_id: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Attach graph-node lineage to a shape-like object.
|
||||
|
||||
The attachment is intentionally stored in runtime state plus lightweight
|
||||
metadata so later operations can discover upstream node identity without
|
||||
changing the public API.
|
||||
"""
|
||||
|
||||
if output is None:
|
||||
return output
|
||||
|
||||
setter = getattr(output, "_set_runtime", None)
|
||||
if callable(setter):
|
||||
setter("graph.node", node)
|
||||
setter("graph.node_id", node.node_id)
|
||||
setter("graph.output_slot", output_slot)
|
||||
|
||||
set_metadata = getattr(output, "set_metadata", None)
|
||||
effective_graph_id = graph_id
|
||||
if effective_graph_id is None:
|
||||
active = get_active_session()
|
||||
effective_graph_id = active.graph.graph_id if active is not None else ""
|
||||
|
||||
if callable(set_metadata):
|
||||
set_metadata(
|
||||
"graph",
|
||||
{
|
||||
"graph_id": effective_graph_id or None,
|
||||
"node_id": node.node_id,
|
||||
"op": node.op,
|
||||
"output_slot": output_slot,
|
||||
},
|
||||
)
|
||||
|
||||
if effective_graph_id:
|
||||
_attach_topo_refs_recursive(
|
||||
output,
|
||||
graph_id=effective_graph_id,
|
||||
node=node,
|
||||
output_slot=output_slot,
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def record_operation_if_active(
|
||||
op: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
outputs: Any = None,
|
||||
input_shapes: Optional[Iterable[Any]] = None,
|
||||
semantic_delta: Optional[SemanticDelta] = None,
|
||||
topo_delta: Optional[TopoDelta] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
tags: Optional[Set[str]] = None,
|
||||
) -> Optional[OperationNode]:
|
||||
"""Record an operation only when a session is active.
|
||||
|
||||
This is the seamless bridge used by the original modeling APIs.
|
||||
Users keep calling `make_box_rsolid(...)` or `cut_rsolid(...)`; when a
|
||||
graph session exists, the operation is recorded automatically and its
|
||||
outputs are annotated with hidden lineage state.
|
||||
"""
|
||||
|
||||
session = get_active_session()
|
||||
if session is None or _recording_suspend_depth_var.get() > 0:
|
||||
return None
|
||||
|
||||
numeric_params = dict(params) if params else {}
|
||||
param_exprs: Dict[str, Any] = {}
|
||||
if params:
|
||||
numeric_params, param_exprs = canonicalize_params(
|
||||
params, session.expression_graph
|
||||
)
|
||||
|
||||
output_list = _normalize_output_shapes(outputs)
|
||||
input_nodes = _extract_input_nodes(input_shapes)
|
||||
node = session.graph.add_node(
|
||||
op=op,
|
||||
params=numeric_params,
|
||||
param_exprs=param_exprs or None,
|
||||
inputs=input_nodes or None,
|
||||
output_count=max(len(output_list), 1),
|
||||
semantic_delta=semantic_delta,
|
||||
topo_delta=topo_delta,
|
||||
context=context or _current_context_snapshot(),
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
_register_current_frame(session, node.node_id)
|
||||
|
||||
for idx, output in enumerate(output_list):
|
||||
attach_graph_node(
|
||||
output, node, output_slot=idx, graph_id=session.graph.graph_id
|
||||
)
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def record_operation(
|
||||
op: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
inputs: Optional[List[OperationNode]] = None,
|
||||
node_id: Optional[str] = None,
|
||||
output_count: int = 1,
|
||||
semantic_delta: Optional[SemanticDelta] = None,
|
||||
topo_delta: Optional[TopoDelta] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
tags: Optional[Set[str]] = None,
|
||||
) -> OperationNode:
|
||||
"""Record an operation to the active graph session.
|
||||
|
||||
Args:
|
||||
op: Operation type (e.g. ``"make_box"``, ``"cut"``).
|
||||
params: Operation parameters (serialisable).
|
||||
inputs: Upstream nodes whose outputs feed into this node.
|
||||
node_id: Optional explicit node id.
|
||||
output_count: Number of output shapes.
|
||||
topo_delta: Optional topological change set from tracking.
|
||||
context: Optional work-plane / coordinate-system snapshot.
|
||||
tags: Optional free-form labels.
|
||||
|
||||
Returns:
|
||||
The created :class:`OperationNode`.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no active session exists.
|
||||
"""
|
||||
session = get_active_session()
|
||||
if session is None:
|
||||
raise RuntimeError(
|
||||
"No active GraphSession. Use `with GraphSession() as session:` "
|
||||
"or call `session.start()` before recording."
|
||||
)
|
||||
numeric_params = dict(params) if params else {}
|
||||
param_exprs: Dict[str, Any] = {}
|
||||
if params:
|
||||
numeric_params, param_exprs = canonicalize_params(
|
||||
params, session.expression_graph
|
||||
)
|
||||
|
||||
node = session.graph.add_node(
|
||||
op=op,
|
||||
params=numeric_params,
|
||||
param_exprs=param_exprs or None,
|
||||
inputs=inputs,
|
||||
node_id=node_id,
|
||||
output_count=output_count,
|
||||
semantic_delta=semantic_delta,
|
||||
topo_delta=topo_delta,
|
||||
context=context,
|
||||
tags=tags,
|
||||
)
|
||||
_register_current_frame(session, node.node_id)
|
||||
return node
|
||||
@@ -0,0 +1 @@
|
||||
"""OCP-native kernel helpers for the 2.0 rearchitecture path."""
|
||||
@@ -0,0 +1,93 @@
|
||||
"""OCP-native boolean helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
from OCP.BOPAlgo import BOPAlgo_GlueOff, BOPAlgo_GlueShift
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
||||
from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
||||
from OCP.TopAbs import TopAbs_SOLID
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Solid
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
|
||||
|
||||
def _list_of(shapes: Sequence[TopoDS_Shape]) -> TopTools_ListOfShape:
|
||||
out = TopTools_ListOfShape()
|
||||
for shape in shapes:
|
||||
out.Append(shape)
|
||||
return out
|
||||
|
||||
|
||||
def solids_of(shape: TopoDS_Shape) -> List[TopoDS_Solid]:
|
||||
out: List[TopoDS_Solid] = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
|
||||
while explorer.More():
|
||||
out.append(TopoDS.Solid_s(explorer.Current()))
|
||||
explorer.Next()
|
||||
if not out and shape.ShapeType() == TopAbs_SOLID:
|
||||
out.append(TopoDS.Solid_s(shape))
|
||||
return out
|
||||
|
||||
|
||||
def clean_shape(shape: TopoDS_Shape) -> TopoDS_Shape:
|
||||
unifier = ShapeUpgrade_UnifySameDomain(shape, True, True, True)
|
||||
unifier.Build()
|
||||
return unifier.Shape()
|
||||
|
||||
|
||||
def fuse_shapes(shapes: Sequence[TopoDS_Shape], *, glue: bool = True, tol: Optional[float] = None, clean: bool = True) -> TopoDS_Shape:
|
||||
if not shapes:
|
||||
raise ValueError("fuse_shapes requires at least one shape")
|
||||
if len(shapes) == 1:
|
||||
return shapes[0]
|
||||
builder = BRepAlgoAPI_Fuse()
|
||||
builder.SetRunParallel(True)
|
||||
builder.SetUseOBB(True)
|
||||
builder.SetArguments(_list_of([shapes[0]]))
|
||||
builder.SetTools(_list_of(list(shapes[1:])))
|
||||
if tol is not None:
|
||||
builder.SetFuzzyValue(float(tol))
|
||||
# Match CadQuery's Shape.fuse(glue=True) behavior: CadQuery maps glue=True
|
||||
# to OCC's GlueShift, not GlueFull. GlueFull can leave overlapping solids
|
||||
# separate in cases where CQ would return one fused solid.
|
||||
builder.SetGlue(BOPAlgo_GlueShift if glue else BOPAlgo_GlueOff)
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP fuse failed")
|
||||
result = builder.Shape()
|
||||
return clean_shape(result) if clean else result
|
||||
|
||||
|
||||
def cut_shapes(body: TopoDS_Shape, tools: Sequence[TopoDS_Shape]) -> TopoDS_Shape:
|
||||
if not tools:
|
||||
return body
|
||||
builder = BRepAlgoAPI_Cut()
|
||||
builder.SetRunParallel(True)
|
||||
builder.SetUseOBB(True)
|
||||
builder.SetArguments(_list_of([body]))
|
||||
builder.SetTools(_list_of(tools))
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP cut failed")
|
||||
return builder.Shape()
|
||||
|
||||
|
||||
def common_shapes(shapes: Sequence[TopoDS_Shape]) -> TopoDS_Shape:
|
||||
if not shapes:
|
||||
raise ValueError("common_shapes requires at least one shape")
|
||||
if len(shapes) == 1:
|
||||
return shapes[0]
|
||||
result = shapes[0]
|
||||
for tool in shapes[1:]:
|
||||
builder = BRepAlgoAPI_Common()
|
||||
builder.SetRunParallel(True)
|
||||
builder.SetUseOBB(True)
|
||||
builder.SetArguments(_list_of([result]))
|
||||
builder.SetTools(_list_of([tool]))
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP common failed")
|
||||
result = builder.Shape()
|
||||
return result
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Thin OCP-native primitive builders used by the public API layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from OCP.BRepPrimAPI import (
|
||||
BRepPrimAPI_MakeBox,
|
||||
BRepPrimAPI_MakeCone,
|
||||
BRepPrimAPI_MakeCylinder,
|
||||
BRepPrimAPI_MakeSphere,
|
||||
)
|
||||
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
|
||||
|
||||
|
||||
def _point(value: tuple[float, float, float]) -> gp_Pnt:
|
||||
return gp_Pnt(float(value[0]), float(value[1]), float(value[2]))
|
||||
|
||||
|
||||
def _axis2(
|
||||
origin: tuple[float, float, float], direction: tuple[float, float, float]
|
||||
) -> gp_Ax2:
|
||||
return gp_Ax2(
|
||||
_point(origin),
|
||||
gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])),
|
||||
)
|
||||
|
||||
|
||||
def make_box_solid(corner: tuple[float, float, float], dx: float, dy: float, dz: float):
|
||||
builder = BRepPrimAPI_MakeBox(_point(corner), float(dx), float(dy), float(dz))
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP box builder failed")
|
||||
return builder.Solid()
|
||||
|
||||
|
||||
def make_cylinder_solid(
|
||||
origin: tuple[float, float, float],
|
||||
axis: tuple[float, float, float],
|
||||
radius: float,
|
||||
height: float,
|
||||
):
|
||||
builder = BRepPrimAPI_MakeCylinder(
|
||||
_axis2(origin, axis), float(radius), float(height)
|
||||
)
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP cylinder builder failed")
|
||||
return builder.Solid()
|
||||
|
||||
|
||||
def make_cone_solid(
|
||||
origin: tuple[float, float, float],
|
||||
axis: tuple[float, float, float],
|
||||
bottom_radius: float,
|
||||
top_radius: float,
|
||||
height: float,
|
||||
):
|
||||
builder = BRepPrimAPI_MakeCone(
|
||||
_axis2(origin, axis),
|
||||
float(bottom_radius),
|
||||
float(top_radius),
|
||||
float(height),
|
||||
)
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP cone builder failed")
|
||||
return builder.Solid()
|
||||
|
||||
|
||||
def make_sphere_solid(center: tuple[float, float, float], radius: float):
|
||||
builder = BRepPrimAPI_MakeSphere(_point(center), float(radius))
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP sphere builder failed")
|
||||
return builder.Solid()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""OCP shape casting and type helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from OCP.TopAbs import (
|
||||
TopAbs_COMPOUND,
|
||||
TopAbs_EDGE,
|
||||
TopAbs_FACE,
|
||||
TopAbs_SOLID,
|
||||
TopAbs_VERTEX,
|
||||
TopAbs_WIRE,
|
||||
)
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopoDS import TopoDS, TopoDS_Shape
|
||||
|
||||
|
||||
def shape_type_name(shape: TopoDS_Shape) -> str:
|
||||
st = shape.ShapeType()
|
||||
if st == TopAbs_VERTEX:
|
||||
return "vertex"
|
||||
if st == TopAbs_EDGE:
|
||||
return "edge"
|
||||
if st == TopAbs_WIRE:
|
||||
return "wire"
|
||||
if st == TopAbs_FACE:
|
||||
return "face"
|
||||
if st == TopAbs_SOLID:
|
||||
return "solid"
|
||||
if st == TopAbs_COMPOUND:
|
||||
return "compound"
|
||||
return str(st)
|
||||
|
||||
|
||||
def as_vertex(shape: TopoDS_Shape):
|
||||
return TopoDS.Vertex_s(shape)
|
||||
|
||||
|
||||
def as_edge(shape: TopoDS_Shape):
|
||||
return TopoDS.Edge_s(shape)
|
||||
|
||||
|
||||
def as_wire(shape: TopoDS_Shape):
|
||||
return TopoDS.Wire_s(shape)
|
||||
|
||||
|
||||
def as_face(shape: TopoDS_Shape):
|
||||
return TopoDS.Face_s(shape)
|
||||
|
||||
|
||||
def as_solid(shape: TopoDS_Shape):
|
||||
st = shape.ShapeType()
|
||||
if st == TopAbs_SOLID:
|
||||
return TopoDS.Solid_s(shape)
|
||||
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
|
||||
if explorer.More():
|
||||
return TopoDS.Solid_s(explorer.Current())
|
||||
raise ValueError(f"Expected a solid-compatible OCP shape, got {shape_type_name(shape)}")
|
||||
|
||||
|
||||
def as_compound(shape: TopoDS_Shape):
|
||||
st = shape.ShapeType()
|
||||
if st == TopAbs_COMPOUND:
|
||||
return TopoDS.Compound_s(shape)
|
||||
raise ValueError(f"Expected a compound OCP shape, got {shape_type_name(shape)}")
|
||||
|
||||
|
||||
def require_shape(value: Any) -> TopoDS_Shape:
|
||||
if isinstance(value, TopoDS_Shape):
|
||||
return value
|
||||
raise TypeError(f"Expected an OCP TopoDS_Shape, got {type(value).__name__}")
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Thin OCP-native curve and wire builders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Iterable, Optional, Sequence
|
||||
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeWire
|
||||
from OCP.BRepLib import BRepLib
|
||||
from OCP.GC import GC_MakeArcOfCircle, GC_MakeCircle
|
||||
from OCP.GCE2d import GCE2d_MakeSegment
|
||||
from OCP.Geom import Geom_BSplineCurve
|
||||
from OCP.Geom2d import Geom2d_Line
|
||||
from OCP.Geom import Geom_ConicalSurface, Geom_CylindricalSurface
|
||||
from OCP.TColgp import TColgp_Array1OfPnt
|
||||
from OCP.TColStd import TColStd_Array1OfInteger, TColStd_Array1OfReal
|
||||
from OCP.gp import (
|
||||
gp_Ax2,
|
||||
gp_Ax3,
|
||||
gp_Circ,
|
||||
gp_Dir,
|
||||
gp_Dir2d,
|
||||
gp_Pnt,
|
||||
gp_Pnt2d,
|
||||
)
|
||||
|
||||
|
||||
def _pnt(value: Sequence[float]) -> gp_Pnt:
|
||||
return gp_Pnt(float(value[0]), float(value[1]), float(value[2]))
|
||||
|
||||
|
||||
def _dir(value: Sequence[float]) -> gp_Dir:
|
||||
return gp_Dir(float(value[0]), float(value[1]), float(value[2]))
|
||||
|
||||
|
||||
def make_line_edge(start: Sequence[float], end: Sequence[float]):
|
||||
return BRepBuilderAPI_MakeEdge(_pnt(start), _pnt(end)).Edge()
|
||||
|
||||
|
||||
def make_circle_edge(center: Sequence[float], radius: float, normal: Sequence[float]):
|
||||
geom = GC_MakeCircle(gp_Ax2(_pnt(center), _dir(normal)), float(radius)).Value()
|
||||
return BRepBuilderAPI_MakeEdge(geom).Edge()
|
||||
|
||||
|
||||
def make_arc_three_point_edge(
|
||||
start: Sequence[float], middle: Sequence[float], end: Sequence[float]
|
||||
):
|
||||
geom = GC_MakeArcOfCircle(_pnt(start), _pnt(middle), _pnt(end)).Value()
|
||||
return BRepBuilderAPI_MakeEdge(geom).Edge()
|
||||
|
||||
|
||||
def make_arc_angle_edge(
|
||||
center: Sequence[float],
|
||||
radius: float,
|
||||
start_angle: float,
|
||||
end_angle: float,
|
||||
normal: Sequence[float],
|
||||
):
|
||||
circ = gp_Circ(gp_Ax2(_pnt(center), _dir(normal)), float(radius))
|
||||
geom = GC_MakeArcOfCircle(circ, float(start_angle), float(end_angle), True).Value()
|
||||
return BRepBuilderAPI_MakeEdge(geom).Edge()
|
||||
|
||||
|
||||
def make_bspline_edge(
|
||||
*,
|
||||
control_points: Sequence[Sequence[float]],
|
||||
degree: int,
|
||||
knots: Sequence[float],
|
||||
multiplicities: Sequence[int],
|
||||
weights: Optional[Sequence[float]] = None,
|
||||
periodic: bool = False,
|
||||
):
|
||||
poles = TColgp_Array1OfPnt(1, len(control_points))
|
||||
for idx, point in enumerate(control_points, start=1):
|
||||
poles.SetValue(idx, _pnt(point))
|
||||
|
||||
knot_array = TColStd_Array1OfReal(1, len(knots))
|
||||
for idx, knot in enumerate(knots, start=1):
|
||||
knot_array.SetValue(idx, float(knot))
|
||||
|
||||
mult_array = TColStd_Array1OfInteger(1, len(multiplicities))
|
||||
for idx, multiplicity in enumerate(multiplicities, start=1):
|
||||
mult_array.SetValue(idx, int(multiplicity))
|
||||
|
||||
if weights is None:
|
||||
curve = Geom_BSplineCurve(
|
||||
poles,
|
||||
knot_array,
|
||||
mult_array,
|
||||
int(degree),
|
||||
bool(periodic),
|
||||
)
|
||||
else:
|
||||
weight_array = TColStd_Array1OfReal(1, len(weights))
|
||||
for idx, weight in enumerate(weights, start=1):
|
||||
weight_array.SetValue(idx, float(weight))
|
||||
curve = Geom_BSplineCurve(
|
||||
poles,
|
||||
weight_array,
|
||||
knot_array,
|
||||
mult_array,
|
||||
int(degree),
|
||||
bool(periodic),
|
||||
)
|
||||
return BRepBuilderAPI_MakeEdge(curve).Edge()
|
||||
|
||||
|
||||
def make_wire_from_edges(edges: Iterable[Any]):
|
||||
builder = BRepBuilderAPI_MakeWire()
|
||||
for edge in edges:
|
||||
builder.Add(edge)
|
||||
return builder.Wire()
|
||||
|
||||
|
||||
def make_polyline_wire(points: Iterable[Sequence[float]], closed: bool = False):
|
||||
pts = list(points)
|
||||
edges = [make_line_edge(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
|
||||
if closed and len(pts) > 2:
|
||||
edges.append(make_line_edge(pts[-1], pts[0]))
|
||||
return make_wire_from_edges(edges)
|
||||
|
||||
|
||||
def make_helix_wire(
|
||||
pitch: float,
|
||||
height: float,
|
||||
radius: float,
|
||||
center: Sequence[float],
|
||||
direction: Sequence[float],
|
||||
):
|
||||
geom_surf = Geom_CylindricalSurface(
|
||||
gp_Ax3(_pnt(center), _dir(direction)), float(radius)
|
||||
)
|
||||
geom_line = Geom2d_Line(gp_Pnt2d(0.0, 0.0), gp_Dir2d(2 * math.pi, float(pitch)))
|
||||
n_turns = float(height) / float(pitch)
|
||||
u_start = geom_line.Value(0.0)
|
||||
u_stop = geom_line.Value(
|
||||
n_turns * math.sqrt((2 * math.pi) ** 2 + float(pitch) ** 2)
|
||||
)
|
||||
geom_seg = GCE2d_MakeSegment(u_start, u_stop).Value()
|
||||
edge = BRepBuilderAPI_MakeEdge(geom_seg, geom_surf).Edge()
|
||||
wire = BRepBuilderAPI_MakeWire(edge).Wire()
|
||||
BRepLib.BuildCurves3d_s(wire, 1e-6, MaxSegment=2000)
|
||||
return wire
|
||||
@@ -0,0 +1,53 @@
|
||||
"""OCP-native export helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from OCP.BRep import BRep_Builder
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer
|
||||
from OCP.StlAPI import StlAPI_Writer
|
||||
from OCP.TopoDS import TopoDS_Compound, TopoDS_Shape
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
|
||||
|
||||
def make_compound(shapes: Sequence[TopoDS_Shape]) -> TopoDS_Shape:
|
||||
if not shapes:
|
||||
raise ValueError("No shapes to export")
|
||||
if len(shapes) == 1:
|
||||
return shapes[0]
|
||||
return make_compound_always(shapes)
|
||||
|
||||
|
||||
def make_compound_always(shapes: Sequence[TopoDS_Shape]) -> TopoDS_Shape:
|
||||
if not shapes:
|
||||
raise ValueError("No shapes to export")
|
||||
builder = BRep_Builder()
|
||||
compound = TopoDS_Compound()
|
||||
builder.MakeCompound(compound)
|
||||
for shape in shapes:
|
||||
builder.Add(compound, shape)
|
||||
return compound
|
||||
|
||||
|
||||
def export_step_shapes(shapes: Sequence[TopoDS_Shape], filename: str) -> None:
|
||||
writer = STEPControl_Writer()
|
||||
compound = make_compound(shapes)
|
||||
status = writer.Transfer(compound, STEPControl_AsIs)
|
||||
# Some OCP builds return int-like statuses; keep failure detection conservative.
|
||||
if status != IFSelect_RetDone and int(status) != int(IFSelect_RetDone):
|
||||
raise ValueError(f"STEP transfer failed: {status}")
|
||||
path = str(Path(filename))
|
||||
write_status = writer.Write(path)
|
||||
if write_status != IFSelect_RetDone and int(write_status) != int(IFSelect_RetDone):
|
||||
raise ValueError(f"STEP write failed: {write_status}")
|
||||
|
||||
|
||||
def export_stl_shape(shape: TopoDS_Shape, filename: str) -> None:
|
||||
BRepMesh_IncrementalMesh(shape, 0.1).Perform()
|
||||
writer = StlAPI_Writer()
|
||||
ok = writer.Write(shape, str(Path(filename)))
|
||||
if ok is False:
|
||||
raise ValueError("STL write failed")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Thin OCP-native feature builders for loft/sweep/helical sweep."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_Transform
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_ThruSections
|
||||
from OCP.gp import gp_Trsf, gp_Vec
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
from .ocp_curves import make_helix_wire
|
||||
|
||||
|
||||
def make_face_from_wire(wire):
|
||||
builder = BRepBuilderAPI_MakeFace(wire, True)
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP face builder failed")
|
||||
return builder.Face()
|
||||
|
||||
|
||||
def make_face_from_wires(outer_wire, inner_wires: Sequence[Any]):
|
||||
builder = BRepBuilderAPI_MakeFace(outer_wire, True)
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP face builder failed for outer wire")
|
||||
for inner_wire in inner_wires:
|
||||
builder.Add(TopoDS.Wire_s(inner_wire.Reversed()))
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP face builder failed while adding inner wire")
|
||||
return builder.Face()
|
||||
|
||||
|
||||
def make_loft_solid(wires: Iterable[Any], ruled: bool = False):
|
||||
builder = BRepOffsetAPI_ThruSections(True, bool(ruled))
|
||||
builder.CheckCompatibility(True)
|
||||
for wire in wires:
|
||||
builder.AddWire(wire)
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP loft builder failed")
|
||||
return builder.Shape()
|
||||
|
||||
|
||||
def make_sweep_solid(profile_wire, path_wire, is_frenet: bool = False):
|
||||
builder = BRepOffsetAPI_MakePipeShell(path_wire)
|
||||
builder.SetMode(bool(is_frenet))
|
||||
builder.Add(profile_wire, False, False)
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP sweep builder failed")
|
||||
if not builder.MakeSolid():
|
||||
raise ValueError("OCP sweep solid conversion failed")
|
||||
return builder.Shape()
|
||||
|
||||
|
||||
def translate_shape(shape, vector: Sequence[float]):
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetTranslation(gp_Vec(float(vector[0]), float(vector[1]), float(vector[2])))
|
||||
builder = BRepBuilderAPI_Transform(shape, trsf, True)
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCP feature translation failed")
|
||||
return builder.Shape()
|
||||
|
||||
|
||||
def make_helical_sweep_solid(
|
||||
profile_wire,
|
||||
pitch: float,
|
||||
height: float,
|
||||
radius: float,
|
||||
center: Sequence[float],
|
||||
direction: Sequence[float],
|
||||
):
|
||||
helix = make_helix_wire(pitch, height, radius, center, direction)
|
||||
moved_profile = translate_shape(profile_wire, (float(radius), 0.0, 0.0))
|
||||
return make_sweep_solid(moved_profile, helix, is_frenet=True)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""OCP-native mesh/shell construction and tessellation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, List, Sequence, Tuple
|
||||
|
||||
from OCP.BRep import BRep_Builder, BRep_Tool
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeSolid
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.Poly import Poly_Triangulation
|
||||
from OCP.TopAbs import TopAbs_FORWARD, TopAbs_REVERSED
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shell
|
||||
from OCP.gp import gp_Pnt
|
||||
|
||||
from .ocp_properties import bounding_box
|
||||
from .ocp_topology import faces_of
|
||||
|
||||
|
||||
def make_triangle_face(points: Sequence[Sequence[float]]) -> TopoDS_Face:
|
||||
if len(points) != 3:
|
||||
raise ValueError("Triangle face requires exactly three points")
|
||||
polygon = BRepBuilderAPI_MakePolygon()
|
||||
for p in points:
|
||||
polygon.Add(gp_Pnt(float(p[0]), float(p[1]), float(p[2])))
|
||||
polygon.Close()
|
||||
if not polygon.IsDone():
|
||||
raise ValueError("OCP polygon builder failed")
|
||||
face = BRepBuilderAPI_MakeFace(polygon.Wire(), True)
|
||||
if not face.IsDone():
|
||||
raise ValueError("OCP triangle face builder failed")
|
||||
return face.Face()
|
||||
|
||||
|
||||
def shell_metric(shell) -> tuple[int, float]:
|
||||
bb = bounding_box(shell)
|
||||
volume = bb.xlen * bb.ylen * bb.zlen
|
||||
return (len(faces_of(shell)), float(volume))
|
||||
|
||||
|
||||
def shell_is_closed(shell) -> bool:
|
||||
return bool(TopoDS.Shell_s(shell).Closed())
|
||||
|
||||
|
||||
def solid_from_shell(shell):
|
||||
maker = BRepBuilderAPI_MakeSolid(TopoDS.Shell_s(shell))
|
||||
if not maker.IsDone():
|
||||
raise ValueError("OCP solid-from-shell builder failed")
|
||||
return maker.Solid()
|
||||
|
||||
|
||||
def tessellate_face(face: TopoDS_Face, tolerance: float = 0.35, angular_tolerance: float = 0.22):
|
||||
mesh = BRepMesh_IncrementalMesh(face, float(tolerance), False, float(angular_tolerance), True)
|
||||
mesh.Perform()
|
||||
loc = TopLoc_Location()
|
||||
tri = BRep_Tool.Triangulation_s(face, loc)
|
||||
if tri is None:
|
||||
return [], []
|
||||
trsf = loc.Transformation()
|
||||
vertices = []
|
||||
for idx in range(1, tri.NbNodes() + 1):
|
||||
p = tri.Node(idx).Transformed(trsf)
|
||||
vertices.append((float(p.X()), float(p.Y()), float(p.Z())))
|
||||
triangles = []
|
||||
reversed_face = face.Orientation() == TopAbs_REVERSED
|
||||
for idx in range(1, tri.NbTriangles() + 1):
|
||||
a, b, c = tri.Triangle(idx).Get()
|
||||
if reversed_face:
|
||||
triangles.append((a - 1, c - 1, b - 1))
|
||||
else:
|
||||
triangles.append((a - 1, b - 1, c - 1))
|
||||
return vertices, triangles
|
||||
@@ -0,0 +1,266 @@
|
||||
"""OCP-native geometry properties, bounding boxes, distance and normals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence, Tuple
|
||||
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.BRepLProp import BRepLProp_SLProps
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.GeomAbs import GeomAbs_Plane
|
||||
import math
|
||||
|
||||
from OCP.TopAbs import (
|
||||
TopAbs_COMPOUND,
|
||||
TopAbs_COMPSOLID,
|
||||
TopAbs_EDGE,
|
||||
TopAbs_FACE,
|
||||
TopAbs_REVERSED,
|
||||
TopAbs_SHELL,
|
||||
TopAbs_SOLID,
|
||||
TopAbs_VERTEX,
|
||||
TopAbs_WIRE,
|
||||
)
|
||||
from OCP.TopoDS import TopoDS, TopoDS_Edge, TopoDS_Face, TopoDS_Shape, TopoDS_Solid
|
||||
from OCP.gp import gp_Pnt, gp_Vec
|
||||
|
||||
from .ocp_topology import vertex_point
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Vec3:
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
def __iter__(self):
|
||||
yield self.x
|
||||
yield self.y
|
||||
yield self.z
|
||||
|
||||
def to_tuple(self) -> tuple[float, float, float]:
|
||||
return (self.x, self.y, self.z)
|
||||
|
||||
def dot(self, other: object) -> float:
|
||||
ox, oy, oz = _coerce_vec3(other)
|
||||
return self.x * ox + self.y * oy + self.z * oz
|
||||
|
||||
def norm(self) -> float:
|
||||
return math.sqrt(self.dot(self))
|
||||
|
||||
def normalized(self) -> "Vec3":
|
||||
n = self.norm()
|
||||
if n <= 1e-15:
|
||||
raise ValueError("Cannot normalize a zero-length vector")
|
||||
return Vec3(self.x / n, self.y / n, self.z / n)
|
||||
|
||||
def getAngle(self, other: object) -> float:
|
||||
ox, oy, oz = _coerce_vec3(other)
|
||||
on = math.sqrt(ox * ox + oy * oy + oz * oz)
|
||||
sn = self.norm()
|
||||
if sn <= 1e-15 or on <= 1e-15:
|
||||
raise ValueError("Cannot compute angle with a zero-length vector")
|
||||
value = max(-1.0, min(1.0, self.dot((ox, oy, oz)) / (sn * on)))
|
||||
return math.acos(value)
|
||||
|
||||
def __mul__(self, scalar: float) -> "Vec3":
|
||||
return Vec3(self.x * float(scalar), self.y * float(scalar), self.z * float(scalar))
|
||||
|
||||
__rmul__ = __mul__
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Vec3({self.x:.6g}, {self.y:.6g}, {self.z:.6g})"
|
||||
|
||||
|
||||
def _coerce_vec3(value: object) -> tuple[float, float, float]:
|
||||
if isinstance(value, Vec3):
|
||||
return value.to_tuple()
|
||||
if hasattr(value, "x") and hasattr(value, "y") and hasattr(value, "z"):
|
||||
return (float(getattr(value, "x")), float(getattr(value, "y")), float(getattr(value, "z")))
|
||||
if isinstance(value, (tuple, list)) and len(value) == 3:
|
||||
return (float(value[0]), float(value[1]), float(value[2]))
|
||||
raise TypeError(f"Expected a 3D vector-like value, got {type(value).__name__}")
|
||||
|
||||
|
||||
def _props_point(props: GProp_GProps) -> Vec3:
|
||||
p = props.CentreOfMass()
|
||||
return Vec3(float(p.X()), float(p.Y()), float(p.Z()))
|
||||
|
||||
|
||||
def linear_length(edge: TopoDS_Edge) -> float:
|
||||
props = GProp_GProps()
|
||||
BRepGProp.LinearProperties_s(edge, props)
|
||||
return float(props.Mass())
|
||||
|
||||
|
||||
def surface_area(face: TopoDS_Face) -> float:
|
||||
props = GProp_GProps()
|
||||
BRepGProp.SurfaceProperties_s(face, props)
|
||||
return float(props.Mass())
|
||||
|
||||
|
||||
def volume(solid: TopoDS_Solid) -> float:
|
||||
props = GProp_GProps()
|
||||
BRepGProp.VolumeProperties_s(solid, props)
|
||||
return float(props.Mass())
|
||||
|
||||
|
||||
def _bbox_center(shape: TopoDS_Shape) -> Vec3:
|
||||
bb = bounding_box(shape)
|
||||
return Vec3((bb.xmin + bb.xmax) / 2, (bb.ymin + bb.ymax) / 2, (bb.zmin + bb.zmax) / 2)
|
||||
|
||||
|
||||
def _props_mass_point(props: GProp_GProps, eps: float = 1e-12) -> Vec3 | None:
|
||||
"""Return the CentreOfMass when the reported mass is meaningfully nonzero."""
|
||||
try:
|
||||
mass = float(props.Mass())
|
||||
except Exception:
|
||||
return None
|
||||
if abs(mass) <= eps:
|
||||
return None
|
||||
return _props_point(props)
|
||||
|
||||
|
||||
def center_of_mass(shape: TopoDS_Shape) -> Vec3:
|
||||
"""Dimension-aware centre of mass.
|
||||
|
||||
Selects the OCP mass-properties routine that matches the shape's own
|
||||
topological dimension. This avoids the numerical residue that
|
||||
``VolumeProperties_s`` produces when applied to lower-dimensional shapes
|
||||
(e.g. a planar face), which previously leaked a bogus centre into
|
||||
``Face.get_center()``.
|
||||
"""
|
||||
|
||||
try:
|
||||
kind = shape.ShapeType()
|
||||
except Exception:
|
||||
kind = None
|
||||
|
||||
# Solids / compsolids / compounds carry a meaningful volume.
|
||||
if kind in (TopAbs_SOLID, TopAbs_COMPSOLID, TopAbs_COMPOUND):
|
||||
props = GProp_GProps()
|
||||
try:
|
||||
BRepGProp.VolumeProperties_s(shape, props)
|
||||
point = _props_mass_point(props)
|
||||
if point is not None:
|
||||
return point
|
||||
except Exception:
|
||||
pass
|
||||
# A compound may wrap only faces/edges; fall through to the
|
||||
# lower-dimensional properties below.
|
||||
if kind == TopAbs_COMPOUND:
|
||||
try:
|
||||
BRepGProp.SurfaceProperties_s(shape, props)
|
||||
point = _props_mass_point(props)
|
||||
if point is not None:
|
||||
return point
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
BRepGProp.LinearProperties_s(shape, props)
|
||||
point = _props_mass_point(props)
|
||||
if point is not None:
|
||||
return point
|
||||
except Exception:
|
||||
pass
|
||||
return _bbox_center(shape)
|
||||
|
||||
# Shells / faces are 2D -> use surface properties.
|
||||
if kind in (TopAbs_FACE, TopAbs_SHELL):
|
||||
props = GProp_GProps()
|
||||
try:
|
||||
BRepGProp.SurfaceProperties_s(shape, props)
|
||||
point = _props_mass_point(props)
|
||||
if point is not None:
|
||||
return point
|
||||
except Exception:
|
||||
pass
|
||||
return _bbox_center(shape)
|
||||
|
||||
# Wires / edges are 1D -> use linear properties.
|
||||
if kind in (TopAbs_WIRE, TopAbs_EDGE):
|
||||
props = GProp_GProps()
|
||||
try:
|
||||
BRepGProp.LinearProperties_s(shape, props)
|
||||
point = _props_mass_point(props)
|
||||
if point is not None:
|
||||
return point
|
||||
except Exception:
|
||||
pass
|
||||
return _bbox_center(shape)
|
||||
|
||||
# Vertex is a point.
|
||||
if kind == TopAbs_VERTEX:
|
||||
try:
|
||||
return Vec3(*vertex_point(TopoDS.Vertex_s(shape)))
|
||||
except Exception:
|
||||
pass
|
||||
return _bbox_center(shape)
|
||||
|
||||
# Unknown / undetermined kind: fall back to bbox centre.
|
||||
return _bbox_center(shape)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundingBox:
|
||||
xmin: float
|
||||
ymin: float
|
||||
zmin: float
|
||||
xmax: float
|
||||
ymax: float
|
||||
zmax: float
|
||||
|
||||
@property
|
||||
def xlen(self) -> float:
|
||||
return self.xmax - self.xmin
|
||||
|
||||
@property
|
||||
def ylen(self) -> float:
|
||||
return self.ymax - self.ymin
|
||||
|
||||
@property
|
||||
def zlen(self) -> float:
|
||||
return self.zmax - self.zmin
|
||||
|
||||
|
||||
def bounding_box(shape: TopoDS_Shape) -> BoundingBox:
|
||||
box = Bnd_Box()
|
||||
box.SetGap(0.0)
|
||||
BRepBndLib.AddOptimal_s(shape, box, False, False)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = box.Get()
|
||||
return BoundingBox(float(xmin), float(ymin), float(zmin), float(xmax), float(ymax), float(zmax))
|
||||
|
||||
|
||||
def distance(shape_a: TopoDS_Shape, shape_b: TopoDS_Shape) -> float:
|
||||
dist = BRepExtrema_DistShapeShape(shape_a, shape_b)
|
||||
dist.Perform()
|
||||
if not dist.IsDone():
|
||||
raise ValueError("OCP distance calculation failed")
|
||||
return float(dist.Value())
|
||||
|
||||
|
||||
def face_normal_at(face: TopoDS_Face, u: float = 0.5, v: float = 0.5) -> Vec3:
|
||||
adaptor = BRepAdaptor_Surface(face, True)
|
||||
umin = float(adaptor.FirstUParameter())
|
||||
umax = float(adaptor.LastUParameter())
|
||||
vmin = float(adaptor.FirstVParameter())
|
||||
vmax = float(adaptor.LastVParameter())
|
||||
uu = umin + (umax - umin) * float(u)
|
||||
vv = vmin + (vmax - vmin) * float(v)
|
||||
props = BRepLProp_SLProps(adaptor, uu, vv, 1, 1e-7)
|
||||
if not props.IsNormalDefined():
|
||||
raise ValueError("Face normal is not defined at the requested parameters")
|
||||
n = props.Normal()
|
||||
if face.Orientation() == TopAbs_REVERSED:
|
||||
n.Reverse()
|
||||
return Vec3(float(n.X()), float(n.Y()), float(n.Z()))
|
||||
|
||||
|
||||
def edge_center(edge: TopoDS_Edge) -> Vec3:
|
||||
return center_of_mass(edge)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""OCP-native topology traversal helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepTools import BRepTools
|
||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_VERTEX, TopAbs_WIRE
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopoDS import TopoDS, TopoDS_Edge, TopoDS_Face, TopoDS_Shape, TopoDS_Vertex, TopoDS_Wire
|
||||
|
||||
|
||||
def vertices_of(shape: TopoDS_Shape) -> List[TopoDS_Vertex]:
|
||||
out: List[TopoDS_Vertex] = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_VERTEX)
|
||||
while explorer.More():
|
||||
out.append(TopoDS.Vertex_s(explorer.Current()))
|
||||
explorer.Next()
|
||||
return out
|
||||
|
||||
|
||||
def edges_of(shape: TopoDS_Shape) -> List[TopoDS_Edge]:
|
||||
out: List[TopoDS_Edge] = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
while explorer.More():
|
||||
out.append(TopoDS.Edge_s(explorer.Current()))
|
||||
explorer.Next()
|
||||
return out
|
||||
|
||||
|
||||
def wires_of(shape: TopoDS_Shape) -> List[TopoDS_Wire]:
|
||||
out: List[TopoDS_Wire] = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_WIRE)
|
||||
while explorer.More():
|
||||
out.append(TopoDS.Wire_s(explorer.Current()))
|
||||
explorer.Next()
|
||||
return out
|
||||
|
||||
|
||||
def faces_of(shape: TopoDS_Shape) -> List[TopoDS_Face]:
|
||||
out: List[TopoDS_Face] = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
out.append(TopoDS.Face_s(explorer.Current()))
|
||||
explorer.Next()
|
||||
return out
|
||||
|
||||
|
||||
def vertex_point(vertex: TopoDS_Vertex) -> tuple[float, float, float]:
|
||||
p = BRep_Tool.Pnt_s(vertex)
|
||||
return (float(p.X()), float(p.Y()), float(p.Z()))
|
||||
|
||||
|
||||
def is_wire_closed(wire: TopoDS_Wire) -> bool:
|
||||
return bool(BRep_Tool.IsClosed_s(wire))
|
||||
|
||||
|
||||
def outer_wire_of(face: TopoDS_Face) -> TopoDS_Wire:
|
||||
return BRepTools.OuterWire_s(face)
|
||||
|
||||
|
||||
def inner_wires_of(face: TopoDS_Face) -> List[TopoDS_Wire]:
|
||||
outer = outer_wire_of(face)
|
||||
out: List[TopoDS_Wire] = []
|
||||
for wire in wires_of(face):
|
||||
if not wire.IsSame(outer):
|
||||
out.append(wire)
|
||||
return out
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Thin OCP-native transform helpers for public geometry wrappers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Tuple
|
||||
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.TopAbs import (
|
||||
TopAbs_COMPOUND,
|
||||
TopAbs_EDGE,
|
||||
TopAbs_FACE,
|
||||
TopAbs_SOLID,
|
||||
TopAbs_VERTEX,
|
||||
TopAbs_WIRE,
|
||||
)
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
|
||||
from ..core import AnyShape, Compound, Edge, Face, Solid, Vertex, Wire
|
||||
|
||||
|
||||
def _shape_from_transformed(shape: AnyShape, transformed) -> AnyShape:
|
||||
shape_type = transformed.ShapeType()
|
||||
if shape_type == TopAbs_VERTEX:
|
||||
return Vertex(TopoDS.Vertex_s(transformed))
|
||||
if shape_type == TopAbs_EDGE:
|
||||
return Edge(TopoDS.Edge_s(transformed))
|
||||
if shape_type == TopAbs_WIRE:
|
||||
return Wire(TopoDS.Wire_s(transformed))
|
||||
if shape_type == TopAbs_FACE:
|
||||
return Face(TopoDS.Face_s(transformed))
|
||||
if shape_type == TopAbs_SOLID:
|
||||
return Solid(TopoDS.Solid_s(transformed))
|
||||
if shape_type == TopAbs_COMPOUND:
|
||||
return Compound(TopoDS.Compound_s(transformed))
|
||||
raise ValueError(f"Unsupported transformed shape type: {shape_type}")
|
||||
|
||||
|
||||
def apply_transform(shape: AnyShape, trsf: gp_Trsf) -> AnyShape:
|
||||
xform = BRepBuilderAPI_Transform(shape.wrapped, trsf, True)
|
||||
xform.Build()
|
||||
if not xform.IsDone():
|
||||
raise ValueError("OCP transform build failed")
|
||||
return _shape_from_transformed(shape, xform.Shape())
|
||||
|
||||
|
||||
def translate_shape_ocp(
|
||||
shape: AnyShape, vector: Tuple[float, float, float]
|
||||
) -> AnyShape:
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetTranslation(gp_Vec(float(vector[0]), float(vector[1]), float(vector[2])))
|
||||
return apply_transform(shape, trsf)
|
||||
|
||||
|
||||
def rotate_shape_ocp(
|
||||
shape: AnyShape,
|
||||
angle_degrees: float,
|
||||
axis: Tuple[float, float, float],
|
||||
origin: Tuple[float, float, float],
|
||||
) -> AnyShape:
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetRotation(
|
||||
gp_Ax1(
|
||||
gp_Pnt(float(origin[0]), float(origin[1]), float(origin[2])),
|
||||
gp_Dir(float(axis[0]), float(axis[1]), float(axis[2])),
|
||||
),
|
||||
math.radians(float(angle_degrees)),
|
||||
)
|
||||
return apply_transform(shape, trsf)
|
||||
|
||||
|
||||
def mirror_shape_ocp(
|
||||
shape: AnyShape,
|
||||
plane_origin: Tuple[float, float, float],
|
||||
plane_normal: Tuple[float, float, float],
|
||||
) -> AnyShape:
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetMirror(
|
||||
gp_Ax2(
|
||||
gp_Pnt(
|
||||
float(plane_origin[0]), float(plane_origin[1]), float(plane_origin[2])
|
||||
),
|
||||
gp_Dir(
|
||||
float(plane_normal[0]),
|
||||
float(plane_normal[1]),
|
||||
float(plane_normal[2]),
|
||||
),
|
||||
)
|
||||
)
|
||||
return apply_transform(shape, trsf)
|
||||
|
||||
|
||||
def place_shape_ocp(
|
||||
shape: AnyShape,
|
||||
origin: Tuple[float, float, float],
|
||||
x_axis: Tuple[float, float, float],
|
||||
y_axis: Tuple[float, float, float],
|
||||
z_axis: Tuple[float, float, float],
|
||||
) -> AnyShape:
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetValues(
|
||||
float(x_axis[0]),
|
||||
float(y_axis[0]),
|
||||
float(z_axis[0]),
|
||||
float(origin[0]),
|
||||
float(x_axis[1]),
|
||||
float(y_axis[1]),
|
||||
float(z_axis[1]),
|
||||
float(origin[1]),
|
||||
float(x_axis[2]),
|
||||
float(y_axis[2]),
|
||||
float(z_axis[2]),
|
||||
float(origin[2]),
|
||||
)
|
||||
return apply_transform(shape, trsf)
|
||||
@@ -0,0 +1,574 @@
|
||||
"""Math helpers for CAD-friendly curve parameterization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math as _math
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
PointTuple = Tuple[float, ...]
|
||||
|
||||
|
||||
def _as_sample_array(
|
||||
sample_points: Iterable[Sequence[float]], *, duplicate_tolerance: float
|
||||
) -> np.ndarray:
|
||||
if duplicate_tolerance < 0.0 or not _math.isfinite(float(duplicate_tolerance)):
|
||||
raise ValueError("duplicate_tolerance must be a finite non-negative number")
|
||||
|
||||
raw_points = list(sample_points)
|
||||
if not raw_points:
|
||||
raise ValueError("sample_points must contain at least two distinct points")
|
||||
|
||||
converted: List[PointTuple] = []
|
||||
dimension: Optional[int] = None
|
||||
for index, point in enumerate(raw_points):
|
||||
try:
|
||||
values = tuple(float(value) for value in point)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"sample point {index} is not a numeric sequence") from exc
|
||||
if dimension is None:
|
||||
dimension = len(values)
|
||||
if dimension not in {2, 3}:
|
||||
raise ValueError("sample points must be 2D or 3D")
|
||||
elif len(values) != dimension:
|
||||
raise ValueError("all sample points must have the same dimension")
|
||||
if not all(_math.isfinite(value) for value in values):
|
||||
raise ValueError("all sample point coordinates must be finite")
|
||||
converted.append(values)
|
||||
|
||||
deduped: List[PointTuple] = []
|
||||
for point in converted:
|
||||
if not deduped:
|
||||
deduped.append(point)
|
||||
continue
|
||||
previous = deduped[-1]
|
||||
distance = _math.sqrt(sum((a - b) ** 2 for a, b in zip(point, previous)))
|
||||
if distance <= duplicate_tolerance:
|
||||
continue
|
||||
deduped.append(point)
|
||||
|
||||
if len(deduped) < 2:
|
||||
raise ValueError("sample_points must contain at least two distinct points")
|
||||
return np.asarray(deduped, dtype=float)
|
||||
|
||||
|
||||
def _chord_length_parameters(points: np.ndarray) -> np.ndarray:
|
||||
deltas = np.linalg.norm(np.diff(points, axis=0), axis=1)
|
||||
total = float(np.sum(deltas))
|
||||
if total <= 0.0 or not _math.isfinite(total):
|
||||
raise ValueError("sample_points must span a non-zero chord length")
|
||||
parameters = np.concatenate(([0.0], np.cumsum(deltas) / total))
|
||||
parameters[-1] = 1.0
|
||||
return parameters
|
||||
|
||||
|
||||
def _full_knot_vector(degree: int, interior_knots: Sequence[float]) -> Tuple[float, ...]:
|
||||
return (
|
||||
tuple(0.0 for _ in range(degree + 1))
|
||||
+ tuple(float(knot) for knot in sorted(interior_knots))
|
||||
+ tuple(1.0 for _ in range(degree + 1))
|
||||
)
|
||||
|
||||
|
||||
def _validate_interior_knots(interior_knots: Sequence[float], *, knot_tolerance: float) -> Tuple[float, ...]:
|
||||
validated: List[float] = []
|
||||
for knot in sorted(float(value) for value in interior_knots):
|
||||
if not _math.isfinite(knot):
|
||||
raise ValueError("adaptive knot values must be finite")
|
||||
if knot <= knot_tolerance or knot >= 1.0 - knot_tolerance:
|
||||
continue
|
||||
if validated and abs(knot - validated[-1]) <= knot_tolerance:
|
||||
continue
|
||||
validated.append(knot)
|
||||
return tuple(validated)
|
||||
|
||||
|
||||
def _bspline_basis_row(parameter: float, degree: int, knots: Sequence[float]) -> np.ndarray:
|
||||
control_count = len(knots) - degree - 1
|
||||
if control_count <= 0:
|
||||
raise ValueError("invalid knot vector/control count combination")
|
||||
|
||||
u = min(max(float(parameter), float(knots[degree])), float(knots[-degree - 1]))
|
||||
if u >= float(knots[-degree - 1]):
|
||||
row = np.zeros(control_count, dtype=float)
|
||||
row[-1] = 1.0
|
||||
return row
|
||||
|
||||
basis = np.zeros(len(knots) - 1, dtype=float)
|
||||
for index in range(len(basis)):
|
||||
if float(knots[index]) <= u < float(knots[index + 1]):
|
||||
basis[index] = 1.0
|
||||
|
||||
active = basis
|
||||
for current_degree in range(1, degree + 1):
|
||||
next_basis = np.zeros(len(knots) - 1 - current_degree, dtype=float)
|
||||
for index in range(len(next_basis)):
|
||||
left_den = float(knots[index + current_degree] - knots[index])
|
||||
right_den = float(knots[index + current_degree + 1] - knots[index + 1])
|
||||
left = 0.0
|
||||
right = 0.0
|
||||
if left_den > 0.0:
|
||||
left = (u - float(knots[index])) / left_den * active[index]
|
||||
if right_den > 0.0:
|
||||
right = (float(knots[index + current_degree + 1]) - u) / right_den * active[index + 1]
|
||||
next_basis[index] = left + right
|
||||
active = next_basis
|
||||
return active[:control_count]
|
||||
|
||||
|
||||
def _basis_matrix(parameters: Sequence[float], degree: int, knots: Sequence[float]) -> np.ndarray:
|
||||
return np.vstack([_bspline_basis_row(float(parameter), degree, knots) for parameter in parameters])
|
||||
|
||||
|
||||
def _straight_line_result(
|
||||
points: np.ndarray,
|
||||
parameters: np.ndarray,
|
||||
*,
|
||||
tolerance: float,
|
||||
fairing: float,
|
||||
) -> "BSplineFitResult":
|
||||
start = points[0]
|
||||
end = points[-1]
|
||||
delta = end - start
|
||||
control_points = np.vstack(
|
||||
[
|
||||
start,
|
||||
start + delta / 3.0,
|
||||
start + 2.0 * delta / 3.0,
|
||||
end,
|
||||
]
|
||||
)
|
||||
knots = _full_knot_vector(3, ())
|
||||
fitted = _basis_matrix(parameters, 3, knots) @ control_points
|
||||
errors = np.linalg.norm(fitted - points, axis=1)
|
||||
return BSplineFitResult(
|
||||
degree=3,
|
||||
control_points=_array_to_points(control_points),
|
||||
knots=tuple(float(knot) for knot in knots),
|
||||
sample_parameters=tuple(float(value) for value in parameters),
|
||||
max_error=float(np.max(errors)) if len(errors) else 0.0,
|
||||
rms_error=float(np.sqrt(np.mean(errors**2))) if len(errors) else 0.0,
|
||||
tolerance=float(tolerance),
|
||||
fairing=float(fairing),
|
||||
iterations=0,
|
||||
converged=True,
|
||||
)
|
||||
|
||||
|
||||
def _array_to_points(values: np.ndarray) -> Tuple[PointTuple, ...]:
|
||||
return tuple(tuple(float(component) for component in row) for row in values.tolist())
|
||||
|
||||
|
||||
def _fit_for_knots(
|
||||
points: np.ndarray,
|
||||
parameters: np.ndarray,
|
||||
*,
|
||||
degree: int,
|
||||
interior_knots: Sequence[float],
|
||||
fairing: float,
|
||||
tolerance: float,
|
||||
iterations: int,
|
||||
) -> "BSplineFitResult":
|
||||
knots = _full_knot_vector(degree, interior_knots)
|
||||
basis = _basis_matrix(parameters, degree, knots)
|
||||
control_count = basis.shape[1]
|
||||
dimension = points.shape[1]
|
||||
|
||||
controls = np.zeros((control_count, dimension), dtype=float)
|
||||
controls[0, :] = points[0, :]
|
||||
controls[-1, :] = points[-1, :]
|
||||
|
||||
unknown_indices = list(range(1, control_count - 1))
|
||||
unknown_map = {control_index: index for index, control_index in enumerate(unknown_indices)}
|
||||
fixed_rhs = (
|
||||
np.outer(basis[:, 0], controls[0, :])
|
||||
+ np.outer(basis[:, -1], controls[-1, :])
|
||||
)
|
||||
matrix = basis[:, unknown_indices]
|
||||
rhs = points - fixed_rhs
|
||||
|
||||
if fairing > 0.0 and unknown_indices:
|
||||
fairing_rows: List[np.ndarray] = []
|
||||
fairing_rhs: List[np.ndarray] = []
|
||||
scale = _math.sqrt(float(fairing))
|
||||
for start_index in range(control_count - 2):
|
||||
row = np.zeros(len(unknown_indices), dtype=float)
|
||||
target = np.zeros(dimension, dtype=float)
|
||||
for control_index, coefficient in (
|
||||
(start_index, 1.0),
|
||||
(start_index + 1, -2.0),
|
||||
(start_index + 2, 1.0),
|
||||
):
|
||||
mapped = unknown_map.get(control_index)
|
||||
if mapped is None:
|
||||
target -= coefficient * controls[control_index, :]
|
||||
else:
|
||||
row[mapped] += coefficient
|
||||
fairing_rows.append(row * scale)
|
||||
fairing_rhs.append(target * scale)
|
||||
if fairing_rows:
|
||||
matrix = np.vstack((matrix, np.vstack(fairing_rows)))
|
||||
rhs = np.vstack((rhs, np.vstack(fairing_rhs)))
|
||||
|
||||
if unknown_indices:
|
||||
solved, *_unused = np.linalg.lstsq(matrix, rhs, rcond=None)
|
||||
for control_index, solved_index in unknown_map.items():
|
||||
controls[control_index, :] = solved[solved_index, :]
|
||||
|
||||
fitted = basis @ controls
|
||||
errors = np.linalg.norm(fitted - points, axis=1)
|
||||
max_error = float(np.max(errors)) if len(errors) else 0.0
|
||||
rms_error = float(np.sqrt(np.mean(errors**2))) if len(errors) else 0.0
|
||||
|
||||
return BSplineFitResult(
|
||||
degree=int(degree),
|
||||
control_points=_array_to_points(controls),
|
||||
knots=tuple(float(knot) for knot in knots),
|
||||
sample_parameters=tuple(float(value) for value in parameters),
|
||||
max_error=max_error,
|
||||
rms_error=rms_error,
|
||||
tolerance=float(tolerance),
|
||||
fairing=float(fairing),
|
||||
iterations=int(iterations),
|
||||
converged=max_error <= float(tolerance),
|
||||
)
|
||||
|
||||
|
||||
def _insert_adaptive_knot(
|
||||
interior_knots: Sequence[float],
|
||||
parameters: np.ndarray,
|
||||
errors: np.ndarray,
|
||||
*,
|
||||
knot_tolerance: float,
|
||||
) -> Tuple[float, ...]:
|
||||
current = _validate_interior_knots(interior_knots, knot_tolerance=knot_tolerance)
|
||||
boundaries = (0.0,) + current + (1.0,)
|
||||
|
||||
best_span: Optional[Tuple[float, float, int, float]] = None
|
||||
for left, right in zip(boundaries, boundaries[1:]):
|
||||
if right - left <= 2.0 * knot_tolerance:
|
||||
continue
|
||||
mask = (parameters > left + knot_tolerance) & (parameters < right - knot_tolerance)
|
||||
if not np.any(mask):
|
||||
span_error = -1.0
|
||||
local_index = -1
|
||||
else:
|
||||
masked_indices = np.nonzero(mask)[0]
|
||||
local_offset = int(np.argmax(errors[masked_indices]))
|
||||
local_index = int(masked_indices[local_offset])
|
||||
span_error = float(errors[local_index])
|
||||
if best_span is None or span_error > best_span[3] or (
|
||||
span_error == best_span[3] and (right - left) > (best_span[1] - best_span[0])
|
||||
):
|
||||
best_span = (float(left), float(right), local_index, span_error)
|
||||
|
||||
if best_span is None:
|
||||
return current
|
||||
|
||||
left, right, local_index, _span_error = best_span
|
||||
if local_index >= 0:
|
||||
candidate = float(parameters[local_index])
|
||||
else:
|
||||
candidate = 0.5 * (left + right)
|
||||
candidate = min(max(candidate, left + knot_tolerance), right - knot_tolerance)
|
||||
if any(abs(candidate - knot) <= knot_tolerance for knot in current):
|
||||
candidate = 0.5 * (left + right)
|
||||
if candidate <= knot_tolerance or candidate >= 1.0 - knot_tolerance:
|
||||
candidate = 0.5 * (left + right)
|
||||
if any(abs(candidate - knot) <= knot_tolerance for knot in current):
|
||||
for left, right in sorted(
|
||||
zip(boundaries, boundaries[1:]), key=lambda span: span[1] - span[0], reverse=True
|
||||
):
|
||||
if right - left > 2.0 * knot_tolerance:
|
||||
candidate = 0.5 * (left + right)
|
||||
break
|
||||
return _validate_interior_knots((*current, candidate), knot_tolerance=knot_tolerance)
|
||||
|
||||
|
||||
def _prune_knots(
|
||||
points: np.ndarray,
|
||||
parameters: np.ndarray,
|
||||
interior_knots: Sequence[float],
|
||||
*,
|
||||
degree: int,
|
||||
fairing: float,
|
||||
tolerance: float,
|
||||
knot_tolerance: float,
|
||||
iterations: int,
|
||||
) -> Tuple[Tuple[float, ...], "BSplineFitResult"]:
|
||||
current = _validate_interior_knots(interior_knots, knot_tolerance=knot_tolerance)
|
||||
current_result = _fit_for_knots(
|
||||
points,
|
||||
parameters,
|
||||
degree=degree,
|
||||
interior_knots=current,
|
||||
fairing=fairing,
|
||||
tolerance=tolerance,
|
||||
iterations=iterations,
|
||||
)
|
||||
changed = True
|
||||
while changed and current:
|
||||
changed = False
|
||||
best_candidate: Optional[Tuple[Tuple[float, ...], BSplineFitResult]] = None
|
||||
for index in range(len(current)):
|
||||
candidate = current[:index] + current[index + 1 :]
|
||||
result = _fit_for_knots(
|
||||
points,
|
||||
parameters,
|
||||
degree=degree,
|
||||
interior_knots=candidate,
|
||||
fairing=fairing,
|
||||
tolerance=tolerance,
|
||||
iterations=iterations,
|
||||
)
|
||||
if result.max_error <= tolerance:
|
||||
if best_candidate is None or result.max_error < best_candidate[1].max_error:
|
||||
best_candidate = (candidate, result)
|
||||
if best_candidate is not None:
|
||||
current, current_result = best_candidate
|
||||
changed = True
|
||||
return current, current_result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BSplineFitResult:
|
||||
"""Result from fitting a cubic B-spline to sampled curve points.
|
||||
|
||||
The result stores a complete, normalized B-spline definition suitable for
|
||||
passing into the exact B-spline edge/wire APIs: cubic degree, control
|
||||
points, and a full clamped knot vector.
|
||||
"""
|
||||
|
||||
degree: int
|
||||
control_points: Tuple[PointTuple, ...]
|
||||
knots: Tuple[float, ...]
|
||||
sample_parameters: Tuple[float, ...]
|
||||
max_error: float
|
||||
rms_error: float
|
||||
tolerance: float
|
||||
fairing: float
|
||||
iterations: int
|
||||
converged: bool
|
||||
|
||||
@property
|
||||
def control_count(self) -> int:
|
||||
"""Number of fitted B-spline control points."""
|
||||
|
||||
return len(self.control_points)
|
||||
|
||||
@property
|
||||
def dimension(self) -> int:
|
||||
"""Coordinate dimension of each fitted control point."""
|
||||
|
||||
return len(self.control_points[0]) if self.control_points else 0
|
||||
|
||||
@property
|
||||
def unique_knots(self) -> Tuple[float, ...]:
|
||||
"""Return knot values with repeated entries collapsed."""
|
||||
|
||||
unique: List[float] = []
|
||||
for knot in self.knots:
|
||||
if not unique or abs(float(knot) - unique[-1]) > 1e-12:
|
||||
unique.append(float(knot))
|
||||
return tuple(unique)
|
||||
|
||||
@property
|
||||
def multiplicities(self) -> Tuple[int, ...]:
|
||||
"""Return knot multiplicities aligned with `unique_knots`."""
|
||||
|
||||
if not self.knots:
|
||||
return ()
|
||||
multiplicities: List[int] = []
|
||||
current = float(self.knots[0])
|
||||
count = 0
|
||||
for knot in self.knots:
|
||||
value = float(knot)
|
||||
if abs(value - current) <= 1e-12:
|
||||
count += 1
|
||||
continue
|
||||
multiplicities.append(count)
|
||||
current = value
|
||||
count = 1
|
||||
multiplicities.append(count)
|
||||
return tuple(multiplicities)
|
||||
|
||||
def evaluate(self, parameter: float) -> PointTuple:
|
||||
"""Evaluate the fitted B-spline at a normalized parameter in `[0, 1]`."""
|
||||
|
||||
controls = np.asarray(self.control_points, dtype=float)
|
||||
row = _bspline_basis_row(float(parameter), int(self.degree), self.knots)
|
||||
point = row @ controls
|
||||
return tuple(float(component) for component in point.tolist())
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return a JSON-serializable representation of the fit result."""
|
||||
|
||||
return {
|
||||
"degree": int(self.degree),
|
||||
"control_points": [list(point) for point in self.control_points],
|
||||
"knots": list(self.knots),
|
||||
"unique_knots": list(self.unique_knots),
|
||||
"multiplicities": list(self.multiplicities),
|
||||
"sample_parameters": list(self.sample_parameters),
|
||||
"max_error": float(self.max_error),
|
||||
"rms_error": float(self.rms_error),
|
||||
"tolerance": float(self.tolerance),
|
||||
"fairing": float(self.fairing),
|
||||
"iterations": int(self.iterations),
|
||||
"converged": bool(self.converged),
|
||||
}
|
||||
|
||||
|
||||
def fit_cubic_bspline_control_points(
|
||||
sample_points: Sequence[Sequence[float]],
|
||||
*,
|
||||
tolerance: float = 1e-3,
|
||||
max_control_points: Optional[int] = None,
|
||||
fairing: float = 1e-6,
|
||||
duplicate_tolerance: float = 1e-12,
|
||||
knot_tolerance: float = 1e-9,
|
||||
raise_on_failure: bool = True,
|
||||
) -> BSplineFitResult:
|
||||
"""Fit a minimal cubic B-spline control polygon to sampled curve points.
|
||||
|
||||
Uses chord-length parameterization, cubic clamped B-spline least squares,
|
||||
second-difference fairing regularization, and adaptive simple knot insertion
|
||||
until the maximum sample error is within `tolerance`. Only simple interior
|
||||
knots are inserted, so a cubic result remains C2-continuous at every interior
|
||||
knot.
|
||||
|
||||
Args:
|
||||
sample_points: Ordered 2D or 3D points sampled along the intended curve.
|
||||
Consecutive duplicate points within `duplicate_tolerance` are ignored.
|
||||
tolerance: Maximum allowed Euclidean fitting error at the input samples.
|
||||
max_control_points: Upper bound for fitted control points. Defaults to the
|
||||
cleaned sample count, with a cubic minimum of four controls.
|
||||
fairing: Non-negative second-difference regularization weight. Larger
|
||||
values prefer smoother control polygons while still respecting the
|
||||
error tolerance when possible.
|
||||
duplicate_tolerance: Distance threshold for removing consecutive duplicate
|
||||
sample points before chord-length parameterization.
|
||||
knot_tolerance: Normalized parameter spacing threshold used to avoid
|
||||
duplicate or near-boundary interior knots.
|
||||
raise_on_failure: Raise `ValueError` when the tolerance cannot be reached
|
||||
within `max_control_points`. If false, return the best non-converged
|
||||
result instead.
|
||||
|
||||
Returns:
|
||||
`BSplineFitResult` containing cubic degree, control points, a full clamped
|
||||
knot vector, knot multiplicities, sample parameters, and fitting error.
|
||||
|
||||
Raises:
|
||||
ValueError: If inputs are invalid, or if the tolerance cannot be met and
|
||||
`raise_on_failure=True`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from simplecadapi.math import fit_cubic_bspline_control_points
|
||||
|
||||
samples = [(0.0, 0.0, 0.0), (1.0, 0.4, 0.0), (2.0, 0.0, 0.0)]
|
||||
fit = fit_cubic_bspline_control_points(samples, tolerance=0.01)
|
||||
print(fit.control_points)
|
||||
print(fit.knots, fit.multiplicities)
|
||||
```
|
||||
"""
|
||||
|
||||
tolerance = float(tolerance)
|
||||
fairing = float(fairing)
|
||||
knot_tolerance = float(knot_tolerance)
|
||||
if tolerance <= 0.0 or not _math.isfinite(tolerance):
|
||||
raise ValueError("tolerance must be a finite positive number")
|
||||
if fairing < 0.0 or not _math.isfinite(fairing):
|
||||
raise ValueError("fairing must be a finite non-negative number")
|
||||
if knot_tolerance <= 0.0 or not _math.isfinite(knot_tolerance):
|
||||
raise ValueError("knot_tolerance must be a finite positive number")
|
||||
|
||||
points = _as_sample_array(sample_points, duplicate_tolerance=float(duplicate_tolerance))
|
||||
parameters = _chord_length_parameters(points)
|
||||
degree = 3
|
||||
|
||||
if len(points) == 2:
|
||||
result = _straight_line_result(
|
||||
points,
|
||||
parameters,
|
||||
tolerance=tolerance,
|
||||
fairing=fairing,
|
||||
)
|
||||
if result.max_error <= tolerance or not raise_on_failure:
|
||||
return result
|
||||
raise ValueError("failed to fit a straight cubic B-spline within tolerance")
|
||||
|
||||
default_max = max(degree + 1, len(points))
|
||||
max_controls = default_max if max_control_points is None else int(max_control_points)
|
||||
if max_controls < degree + 1:
|
||||
raise ValueError("max_control_points must be at least 4 for a cubic B-spline")
|
||||
|
||||
interior_knots: Tuple[float, ...] = ()
|
||||
best_result: Optional[BSplineFitResult] = None
|
||||
max_insertions = max_controls - (degree + 1)
|
||||
|
||||
for iteration in range(max_insertions + 1):
|
||||
result = _fit_for_knots(
|
||||
points,
|
||||
parameters,
|
||||
degree=degree,
|
||||
interior_knots=interior_knots,
|
||||
fairing=fairing,
|
||||
tolerance=tolerance,
|
||||
iterations=iteration,
|
||||
)
|
||||
if best_result is None or result.max_error < best_result.max_error:
|
||||
best_result = result
|
||||
if result.max_error <= tolerance:
|
||||
_pruned_knots, pruned_result = _prune_knots(
|
||||
points,
|
||||
parameters,
|
||||
interior_knots,
|
||||
degree=degree,
|
||||
fairing=fairing,
|
||||
tolerance=tolerance,
|
||||
knot_tolerance=knot_tolerance,
|
||||
iterations=iteration,
|
||||
)
|
||||
return pruned_result
|
||||
if iteration >= max_insertions:
|
||||
break
|
||||
|
||||
basis = _basis_matrix(parameters, degree, result.knots)
|
||||
fitted = basis @ np.asarray(result.control_points, dtype=float)
|
||||
errors = np.linalg.norm(fitted - points, axis=1)
|
||||
next_knots = _insert_adaptive_knot(
|
||||
interior_knots,
|
||||
parameters,
|
||||
errors,
|
||||
knot_tolerance=knot_tolerance,
|
||||
)
|
||||
if len(next_knots) == len(interior_knots):
|
||||
break
|
||||
interior_knots = next_knots
|
||||
|
||||
assert best_result is not None
|
||||
if not raise_on_failure:
|
||||
return BSplineFitResult(
|
||||
degree=best_result.degree,
|
||||
control_points=best_result.control_points,
|
||||
knots=best_result.knots,
|
||||
sample_parameters=best_result.sample_parameters,
|
||||
max_error=best_result.max_error,
|
||||
rms_error=best_result.rms_error,
|
||||
tolerance=best_result.tolerance,
|
||||
fairing=best_result.fairing,
|
||||
iterations=best_result.iterations,
|
||||
converged=False,
|
||||
)
|
||||
raise ValueError(
|
||||
"failed to fit a cubic B-spline within tolerance "
|
||||
f"{tolerance:g}; best max_error={best_result.max_error:g} with "
|
||||
f"{best_result.control_count} control points"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["BSplineFitResult", "fit_cubic_bspline_control_points"]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
"""SimpleCAD standard parts library.
|
||||
|
||||
Each sub-module provides parameterised standard mechanical components
|
||||
built entirely from the public SimpleCAD modelling API surface.
|
||||
|
||||
Modules:
|
||||
gear — involute spur / helical / herringbone gears
|
||||
bearing — ball bearing standard assemblies
|
||||
screw — (future) threaded fasteners
|
||||
pin — (future) dowel pins, flat keys, split pins
|
||||
"""
|
||||
|
||||
from . import bearing, gear
|
||||
|
||||
__all__ = ["bearing", "gear"]
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Bearing standard assemblies built from public SimpleCAD product APIs.
|
||||
|
||||
The factories in this module return product-level assemblies rather than a
|
||||
single merged solid when the standard part has meaningful internal motion. A
|
||||
ball bearing is a small assembly: the important kinematic relationship is the
|
||||
revolute axis between the outer ring and the inner ring, while direct sphere
|
||||
balls sit in continuous toroidal raceway grooves at authored positions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ..product import Assembly, Part
|
||||
from ..operations import (
|
||||
add_component_rassembly,
|
||||
add_connector_rpart,
|
||||
add_revolute_constraint_rassembly,
|
||||
apply_tag,
|
||||
chamfer_rsolid,
|
||||
forward_connector_rassembly,
|
||||
identity_placement_rplacement,
|
||||
make_assembly_rassembly,
|
||||
make_connector_ref_rconnectorref,
|
||||
make_face_connector_rconnector,
|
||||
make_face_from_wire_rface,
|
||||
make_line_redge,
|
||||
make_part_rpart,
|
||||
make_placement_rplacement,
|
||||
make_sphere_rsolid,
|
||||
make_three_point_arc_redge,
|
||||
make_wire_from_edges_rwire,
|
||||
revolve_rsolid,
|
||||
)
|
||||
from ..core import Face, Solid
|
||||
|
||||
__all__ = ["make_ball_bearing_rassembly"]
|
||||
|
||||
|
||||
def _validate_positive_finite(name: str, value: float) -> float:
|
||||
resolved = float(value)
|
||||
if not math.isfinite(resolved):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
if resolved <= 0.0:
|
||||
raise ValueError(f"{name} must be positive")
|
||||
return resolved
|
||||
|
||||
|
||||
def _validate_non_negative_finite(name: str, value: float) -> float:
|
||||
resolved = float(value)
|
||||
if not math.isfinite(resolved):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
if resolved < 0.0:
|
||||
raise ValueError(f"{name} must be non-negative")
|
||||
return resolved
|
||||
|
||||
|
||||
def _infer_ball_count(ball_pitch_radius: float, ball_diameter: float) -> int:
|
||||
circumference = 2.0 * math.pi * ball_pitch_radius
|
||||
# A conservative visual default leaves room for a printed cage/gap between
|
||||
# adjacent balls instead of packing tangent spheres around the pitch circle.
|
||||
return max(3, int(math.floor(circumference / (1.5 * ball_diameter))))
|
||||
|
||||
|
||||
def _validate_ball_count(
|
||||
ball_count: Optional[int],
|
||||
ball_pitch_radius: float,
|
||||
ball_diameter: float,
|
||||
) -> int:
|
||||
if ball_count is None:
|
||||
resolved = _infer_ball_count(ball_pitch_radius, ball_diameter)
|
||||
else:
|
||||
resolved = int(ball_count)
|
||||
if resolved < 3:
|
||||
raise ValueError("ball_count must be at least 3")
|
||||
|
||||
chord_spacing = 2.0 * ball_pitch_radius * math.sin(math.pi / resolved)
|
||||
if chord_spacing <= ball_diameter:
|
||||
raise ValueError(
|
||||
"ball_count is too high for ball_diameter on the bearing pitch circle"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def _candidate_chamfer_edges(solid: Solid, bearing_width: float) -> List[object]:
|
||||
circular_min_length = max(1e-6, bearing_width * 1.1)
|
||||
return [edge for edge in solid.get_edges() if edge.get_length() > circular_min_length]
|
||||
|
||||
|
||||
def _apply_edge_chamfer(solid: Solid, edge_chamfer: float, bearing_width: float) -> Solid:
|
||||
if edge_chamfer <= 0.0:
|
||||
return solid
|
||||
edges = _candidate_chamfer_edges(solid, bearing_width)
|
||||
if not edges:
|
||||
return solid
|
||||
return chamfer_rsolid(solid, edges, edge_chamfer)
|
||||
|
||||
|
||||
def _make_race_ring_solid(
|
||||
bore_or_inner_radius: float,
|
||||
outer_or_shoulder_radius: float,
|
||||
ball_pitch_radius: float,
|
||||
groove_radius: float,
|
||||
bearing_width: float,
|
||||
edge_chamfer: float,
|
||||
role: str,
|
||||
) -> Solid:
|
||||
half_width = bearing_width / 2.0
|
||||
if role == "inner_ring":
|
||||
inner_radius = bore_or_inner_radius
|
||||
outer_radius = outer_or_shoulder_radius
|
||||
mouth_offset = ball_pitch_radius - outer_radius
|
||||
if mouth_offset <= 0.0 or mouth_offset >= groove_radius:
|
||||
raise ValueError("inner ring shoulder radius must expose the raceway groove")
|
||||
mouth_z = math.sqrt(max(0.0, groove_radius * groove_radius - mouth_offset * mouth_offset))
|
||||
points = [
|
||||
(inner_radius, 0.0, -half_width),
|
||||
(outer_radius, 0.0, -half_width),
|
||||
(outer_radius, 0.0, -mouth_z),
|
||||
(ball_pitch_radius - groove_radius, 0.0, 0.0),
|
||||
(outer_radius, 0.0, mouth_z),
|
||||
(outer_radius, 0.0, half_width),
|
||||
(inner_radius, 0.0, half_width),
|
||||
]
|
||||
arc_start = points[2]
|
||||
arc_mid = points[3]
|
||||
arc_end = points[4]
|
||||
edges = [
|
||||
make_line_redge(points[0], points[1]),
|
||||
make_line_redge(points[1], arc_start),
|
||||
make_three_point_arc_redge(arc_start, arc_mid, arc_end),
|
||||
make_line_redge(arc_end, points[5]),
|
||||
make_line_redge(points[5], points[6]),
|
||||
make_line_redge(points[6], points[0]),
|
||||
]
|
||||
elif role == "outer_ring":
|
||||
inner_radius = bore_or_inner_radius
|
||||
outer_radius = outer_or_shoulder_radius
|
||||
mouth_offset = inner_radius - ball_pitch_radius
|
||||
if mouth_offset <= 0.0 or mouth_offset >= groove_radius:
|
||||
raise ValueError("outer ring shoulder radius must expose the raceway groove")
|
||||
mouth_z = math.sqrt(max(0.0, groove_radius * groove_radius - mouth_offset * mouth_offset))
|
||||
points = [
|
||||
(inner_radius, 0.0, -half_width),
|
||||
(outer_radius, 0.0, -half_width),
|
||||
(outer_radius, 0.0, half_width),
|
||||
(inner_radius, 0.0, half_width),
|
||||
(inner_radius, 0.0, mouth_z),
|
||||
(ball_pitch_radius + groove_radius, 0.0, 0.0),
|
||||
(inner_radius, 0.0, -mouth_z),
|
||||
]
|
||||
arc_start = points[4]
|
||||
arc_mid = points[5]
|
||||
arc_end = points[6]
|
||||
edges = [
|
||||
make_line_redge(points[0], points[1]),
|
||||
make_line_redge(points[1], points[2]),
|
||||
make_line_redge(points[2], points[3]),
|
||||
make_line_redge(points[3], arc_start),
|
||||
make_three_point_arc_redge(arc_start, arc_mid, arc_end),
|
||||
make_line_redge(arc_end, points[0]),
|
||||
]
|
||||
else:
|
||||
raise ValueError("role must be inner_ring or outer_ring")
|
||||
|
||||
profile = make_face_from_wire_rface(
|
||||
make_wire_from_edges_rwire(edges),
|
||||
normal=(0.0, -1.0, 0.0),
|
||||
)
|
||||
ring = revolve_rsolid(
|
||||
profile,
|
||||
axis=(0.0, 0.0, 1.0),
|
||||
angle=360.0,
|
||||
origin=(0.0, 0.0, 0.0),
|
||||
)
|
||||
ring = apply_tag(ring, f"role.{role}")
|
||||
ring = apply_tag(ring, "group.ball_bearing")
|
||||
ring = _apply_edge_chamfer(ring, edge_chamfer, bearing_width)
|
||||
ring.set_metadata(
|
||||
"std.bearing.ring",
|
||||
{
|
||||
"role": role,
|
||||
"inner_radius": inner_radius,
|
||||
"outer_radius": outer_radius,
|
||||
"ball_pitch_radius": ball_pitch_radius,
|
||||
"groove_radius": groove_radius,
|
||||
"raceway_mouth_z": mouth_z,
|
||||
"bearing_width": bearing_width,
|
||||
"edge_chamfer": edge_chamfer,
|
||||
},
|
||||
)
|
||||
return ring
|
||||
|
||||
|
||||
def _axis_face(solid: Solid, target_z: float) -> Face:
|
||||
candidates = []
|
||||
for face in solid.get_faces():
|
||||
normal = face.get_normal_at()
|
||||
if normal.z < 0.7:
|
||||
continue
|
||||
center = face.get_center()
|
||||
candidates.append((abs(center.z - target_z), -face.get_area(), face))
|
||||
if not candidates:
|
||||
raise ValueError("no +Z bearing axis face found")
|
||||
return min(candidates, key=lambda item: item[0:2])[2]
|
||||
|
||||
|
||||
def _part_with_axis_connector(
|
||||
part_id: str,
|
||||
body: Solid,
|
||||
name: str,
|
||||
target_z: float,
|
||||
) -> Part:
|
||||
part = make_part_rpart(part_id, body, name=name)
|
||||
axis = make_face_connector_rconnector("axis", _axis_face(body, target_z))
|
||||
return add_connector_rpart(part, axis)
|
||||
|
||||
|
||||
def _ball_placement(ball_pitch_radius: float, angle_degrees: float):
|
||||
angle = math.radians(angle_degrees)
|
||||
cos_a = math.cos(angle)
|
||||
sin_a = math.sin(angle)
|
||||
return make_placement_rplacement(
|
||||
origin=(ball_pitch_radius * cos_a, ball_pitch_radius * sin_a, 0.0),
|
||||
x_axis=(cos_a, sin_a, 0.0),
|
||||
y_axis=(-sin_a, cos_a, 0.0),
|
||||
)
|
||||
|
||||
|
||||
def make_ball_bearing_rassembly(
|
||||
bore_diameter: float,
|
||||
outer_diameter: float,
|
||||
bearing_width: float,
|
||||
ball_diameter: float,
|
||||
ball_count: Optional[int] = None,
|
||||
raceway_clearance: float = 0.02,
|
||||
edge_chamfer: float = 0.0,
|
||||
assembly_id: str = "ball_bearing",
|
||||
drive_angle_degrees: Optional[float] = None,
|
||||
) -> Assembly:
|
||||
"""Create a parameterized radial ball bearing assembly.
|
||||
|
||||
This factory returns an `Assembly`, not a merged `Solid`, because a bearing
|
||||
has useful internal structure. The returned assembly contains stable
|
||||
component ids `outer_ring`, `inner_ring`, and `ball_00`, `ball_01`, ... .
|
||||
The inner and outer rings each carry an `axis` connector, and the assembly
|
||||
includes one revolute constraint named `inner_outer_revolute` between those
|
||||
two axes. Use `bearing.get_component("inner_ring").item.body` to access
|
||||
the inner-ring geometry directly, or use connector refs such as
|
||||
`make_connector_ref_rconnectorref("inner_ring", "axis")` when adding shaft
|
||||
or housing constraints to the same assembly.
|
||||
|
||||
The returned bearing assembly also forwards public assembly-level connectors
|
||||
`inner_axis` and `outer_axis` from `inner_ring.axis` and `outer_ring.axis`.
|
||||
Parent assemblies can constrain to those connectors without depending on the
|
||||
bearing's internal component structure. These public axes are offset to the
|
||||
bearing center plane.
|
||||
|
||||
The returned bearing is not grounded. Ground the parent assembly's housing,
|
||||
shaft, or fixture components explicitly; the standard bearing assembly does
|
||||
not emit `GroundedJoint` objects that would lock a parent mechanism.
|
||||
|
||||
Parameters use explicit SDK-style names rather than compact catalog labels:
|
||||
`bore_diameter` maps to common `id`, `outer_diameter` maps to `od`,
|
||||
`bearing_width` maps to axial bearing thickness, `ball_diameter` maps to
|
||||
ball size, `raceway_clearance` maps to print clearance around the balls, and
|
||||
`edge_chamfer` maps to edge break/chamfer. There is intentionally no
|
||||
Python keyword-only `*` separator in this signature so the function remains
|
||||
callable with either positional or keyword arguments.
|
||||
|
||||
`ball_count=None` lets the factory infer a conservative visual ball count
|
||||
from the pitch circle. Explicit `ball_count` is accepted when you need to
|
||||
match a real bearing or a printed cage design. Balls are direct sphere
|
||||
primitive solids, and the inner and outer rings are revolved from arc-groove
|
||||
profiles to create continuous toroidal raceway grooves. Balls are visual
|
||||
rolling elements fixed at their authored positions; the currently modeled
|
||||
kinematic degree of freedom is only the inner-ring-to-outer-ring revolute
|
||||
joint.
|
||||
|
||||
For printable bearings, the classic checks from many parametric generators
|
||||
are still useful: `((outer_diameter - bore_diameter) / 2) - ball_diameter`
|
||||
should leave enough radial wall thickness, and `bearing_width - ball_diameter`
|
||||
should be positive so balls do not protrude axially.
|
||||
"""
|
||||
|
||||
bore_diameter_value = _validate_positive_finite("bore_diameter", bore_diameter)
|
||||
outer_diameter_value = _validate_positive_finite("outer_diameter", outer_diameter)
|
||||
bearing_width_value = _validate_positive_finite("bearing_width", bearing_width)
|
||||
ball_diameter_value = _validate_positive_finite("ball_diameter", ball_diameter)
|
||||
raceway_clearance_value = _validate_non_negative_finite(
|
||||
"raceway_clearance",
|
||||
raceway_clearance,
|
||||
)
|
||||
edge_chamfer_value = _validate_non_negative_finite("edge_chamfer", edge_chamfer)
|
||||
|
||||
if outer_diameter_value <= bore_diameter_value:
|
||||
raise ValueError("outer_diameter must be greater than bore_diameter")
|
||||
|
||||
bore_radius = bore_diameter_value / 2.0
|
||||
outer_radius = outer_diameter_value / 2.0
|
||||
ball_radius = ball_diameter_value / 2.0
|
||||
ball_pitch_radius = (bore_radius + outer_radius) / 2.0
|
||||
groove_radius = ball_radius + raceway_clearance_value
|
||||
inner_groove_root_radius = ball_pitch_radius - groove_radius
|
||||
outer_groove_root_radius = ball_pitch_radius + groove_radius
|
||||
inner_wall_thickness = inner_groove_root_radius - bore_radius
|
||||
outer_wall_thickness = outer_radius - outer_groove_root_radius
|
||||
axial_clearance = bearing_width_value / 2.0 - groove_radius
|
||||
|
||||
if inner_wall_thickness <= 0.0 or outer_wall_thickness <= 0.0:
|
||||
raise ValueError(
|
||||
"ball_diameter plus raceway_clearance leaves no radial wall thickness"
|
||||
)
|
||||
if axial_clearance <= 0.0:
|
||||
raise ValueError(
|
||||
"bearing_width must be greater than ball_diameter plus raceway_clearance"
|
||||
)
|
||||
|
||||
smallest_feature = min(inner_wall_thickness, outer_wall_thickness, axial_clearance)
|
||||
if edge_chamfer_value >= smallest_feature:
|
||||
raise ValueError("edge_chamfer must be smaller than the thinnest bearing feature")
|
||||
|
||||
resolved_ball_count = _validate_ball_count(
|
||||
ball_count,
|
||||
ball_pitch_radius,
|
||||
ball_diameter_value,
|
||||
)
|
||||
|
||||
outer_ring = _make_race_ring_solid(
|
||||
ball_pitch_radius + ball_radius * 0.75,
|
||||
outer_radius,
|
||||
ball_pitch_radius,
|
||||
groove_radius,
|
||||
bearing_width_value,
|
||||
edge_chamfer_value,
|
||||
"outer_ring",
|
||||
)
|
||||
inner_ring = _make_race_ring_solid(
|
||||
bore_radius,
|
||||
ball_pitch_radius - ball_radius * 0.75,
|
||||
ball_pitch_radius,
|
||||
groove_radius,
|
||||
bearing_width_value,
|
||||
edge_chamfer_value,
|
||||
"inner_ring",
|
||||
)
|
||||
ball = make_sphere_rsolid(radius=ball_radius, center=(0.0, 0.0, 0.0))
|
||||
ball = apply_tag(ball, "role.rolling_element")
|
||||
ball = apply_tag(ball, "group.ball_bearing")
|
||||
ball.set_metadata(
|
||||
"std.bearing.ball",
|
||||
{
|
||||
"diameter": ball_diameter_value,
|
||||
"pitch_radius": ball_pitch_radius,
|
||||
},
|
||||
)
|
||||
|
||||
outer_part = _part_with_axis_connector(
|
||||
"outer_ring",
|
||||
outer_ring,
|
||||
"Outer bearing ring",
|
||||
bearing_width_value / 2.0,
|
||||
)
|
||||
inner_part = _part_with_axis_connector(
|
||||
"inner_ring",
|
||||
inner_ring,
|
||||
"Inner bearing ring",
|
||||
bearing_width_value / 2.0,
|
||||
)
|
||||
ball_part = make_part_rpart("ball", ball, name="Bearing ball")
|
||||
|
||||
assembly = make_assembly_rassembly(assembly_id, name="Ball bearing")
|
||||
assembly = add_component_rassembly(
|
||||
assembly,
|
||||
outer_part,
|
||||
component_id="outer_ring",
|
||||
placement=identity_placement_rplacement(),
|
||||
)
|
||||
assembly = add_component_rassembly(
|
||||
assembly,
|
||||
inner_part,
|
||||
component_id="inner_ring",
|
||||
placement=identity_placement_rplacement(),
|
||||
)
|
||||
|
||||
ball_component_ids: List[str] = []
|
||||
ball_angles: Dict[str, float] = {}
|
||||
digits = max(2, len(str(resolved_ball_count - 1)))
|
||||
for index in range(resolved_ball_count):
|
||||
component_id = f"ball_{index:0{digits}d}"
|
||||
angle_degrees = 360.0 * index / resolved_ball_count
|
||||
ball_component_ids.append(component_id)
|
||||
ball_angles[component_id] = angle_degrees
|
||||
assembly = add_component_rassembly(
|
||||
assembly,
|
||||
ball_part,
|
||||
component_id=component_id,
|
||||
placement=_ball_placement(ball_pitch_radius, angle_degrees),
|
||||
)
|
||||
|
||||
assembly = add_revolute_constraint_rassembly(
|
||||
assembly,
|
||||
"inner_outer_revolute",
|
||||
make_connector_ref_rconnectorref("outer_ring", "axis"),
|
||||
make_connector_ref_rconnectorref("inner_ring", "axis"),
|
||||
drive_angle_degrees=drive_angle_degrees,
|
||||
name="Inner ring spins in outer ring",
|
||||
)
|
||||
public_axis_offset = make_placement_rplacement(
|
||||
origin=(0.0, 0.0, -bearing_width_value / 2.0),
|
||||
)
|
||||
assembly = forward_connector_rassembly(
|
||||
assembly,
|
||||
connector_id="outer_axis",
|
||||
source_component_id="outer_ring",
|
||||
source_connector_id="axis",
|
||||
name="Outer ring housing axis",
|
||||
offset=public_axis_offset,
|
||||
)
|
||||
assembly = forward_connector_rassembly(
|
||||
assembly,
|
||||
connector_id="inner_axis",
|
||||
source_component_id="inner_ring",
|
||||
source_connector_id="axis",
|
||||
name="Inner ring shaft axis",
|
||||
offset=public_axis_offset,
|
||||
)
|
||||
assembly.set_metadata(
|
||||
"std.bearing.ball_bearing",
|
||||
{
|
||||
"bore_diameter": bore_diameter_value,
|
||||
"outer_diameter": outer_diameter_value,
|
||||
"bearing_width": bearing_width_value,
|
||||
"ball_diameter": ball_diameter_value,
|
||||
"ball_count": resolved_ball_count,
|
||||
"raceway_clearance": raceway_clearance_value,
|
||||
"edge_chamfer": edge_chamfer_value,
|
||||
"ball_pitch_radius": ball_pitch_radius,
|
||||
"groove_radius": groove_radius,
|
||||
"inner_groove_root_radius": inner_groove_root_radius,
|
||||
"outer_groove_root_radius": outer_groove_root_radius,
|
||||
"inner_wall_thickness": inner_wall_thickness,
|
||||
"outer_wall_thickness": outer_wall_thickness,
|
||||
"axial_clearance": axial_clearance,
|
||||
"outer_component_id": "outer_ring",
|
||||
"inner_component_id": "inner_ring",
|
||||
"ball_component_ids": ball_component_ids,
|
||||
"ball_angles_degrees": ball_angles,
|
||||
"axis_connector_id": "axis",
|
||||
"outer_axis_connector_id": "outer_axis",
|
||||
"inner_axis_connector_id": "inner_axis",
|
||||
"revolute_constraint_id": "inner_outer_revolute",
|
||||
},
|
||||
)
|
||||
return assembly
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
|
||||
_TAG_SEGMENT = r"[a-z][a-z0-9_-]*"
|
||||
_TAG_RE = re.compile(rf"^{_TAG_SEGMENT}(?:\.{_TAG_SEGMENT})*$")
|
||||
|
||||
|
||||
def is_normalized_tag(tag: str) -> bool:
|
||||
"""Check whether a tag matches the normalized format.
|
||||
|
||||
Args:
|
||||
tag: Tag string.
|
||||
|
||||
Returns:
|
||||
bool: Whether the tag is valid.
|
||||
"""
|
||||
if not isinstance(tag, str):
|
||||
return False
|
||||
return bool(_TAG_RE.fullmatch(tag))
|
||||
|
||||
|
||||
def normalize_tag(tag: str, *, strict: bool = True) -> str:
|
||||
"""Normalize a tag.
|
||||
|
||||
Args:
|
||||
tag: Raw tag.
|
||||
strict: Whether to validate strictly. When True, only already-normalized
|
||||
tags are accepted.
|
||||
|
||||
Returns:
|
||||
str: Normalized tag.
|
||||
|
||||
Raises:
|
||||
TypeError: If tag is not a string.
|
||||
ValueError: If normalization fails.
|
||||
"""
|
||||
if not isinstance(tag, str):
|
||||
raise TypeError("tag must be a string")
|
||||
cleaned = tag.strip()
|
||||
if strict:
|
||||
if not is_normalized_tag(cleaned):
|
||||
raise ValueError(f"tag '{tag}' is not normalized")
|
||||
return cleaned
|
||||
|
||||
lowered = cleaned.lower()
|
||||
lowered = re.sub(r"\s+", "_", lowered)
|
||||
lowered = lowered.replace(":", "_")
|
||||
lowered = lowered.replace("/", ".")
|
||||
if not is_normalized_tag(lowered):
|
||||
raise ValueError(f"tag '{tag}' cannot be normalized")
|
||||
return lowered
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TagPolicy:
|
||||
"""Tag propagation policy."""
|
||||
|
||||
propagate_prefixes: tuple[str, ...] = (
|
||||
"role.",
|
||||
"anchor.",
|
||||
"group.",
|
||||
)
|
||||
propagate_exact: tuple[str, ...] = (
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
"front",
|
||||
"back",
|
||||
"side",
|
||||
"surface",
|
||||
)
|
||||
block_prefixes: tuple[str, ...] = (
|
||||
"feature.",
|
||||
"state.",
|
||||
"face.",
|
||||
"edge.",
|
||||
"wire.",
|
||||
"vertex.",
|
||||
"solid.",
|
||||
"legacy.",
|
||||
)
|
||||
block_exact: tuple[str, ...] = ()
|
||||
|
||||
def should_propagate(self, tag: str) -> bool:
|
||||
"""Check whether a tag should propagate downward.
|
||||
|
||||
Args:
|
||||
tag: Tag string.
|
||||
|
||||
Returns:
|
||||
bool: Whether the tag should propagate.
|
||||
"""
|
||||
if tag in self.block_exact:
|
||||
return False
|
||||
if any(tag.startswith(prefix) for prefix in self.block_prefixes):
|
||||
return False
|
||||
if tag in self.propagate_exact:
|
||||
return True
|
||||
if any(tag.startswith(prefix) for prefix in self.propagate_prefixes):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
DEFAULT_TAG_POLICY = TagPolicy()
|
||||
|
||||
|
||||
def resolve_anchor_tag_candidates(tag: str) -> List[str]:
|
||||
"""Generate candidate tags for anchor lookup.
|
||||
|
||||
Args:
|
||||
tag: User-provided tag.
|
||||
|
||||
Returns:
|
||||
List[str]: Candidate tags ordered by priority.
|
||||
"""
|
||||
token = tag.strip().lower()
|
||||
if not token:
|
||||
return []
|
||||
|
||||
topology_prefixes = ("face.", "edge.", "wire.", "vertex.", "solid.")
|
||||
if is_normalized_tag(token) and "." in token:
|
||||
if token.startswith("role."):
|
||||
bare = token[len("role.") :]
|
||||
elif token.startswith("anchor."):
|
||||
bare = token[len("anchor.") :]
|
||||
elif any(token.startswith(prefix) for prefix in topology_prefixes):
|
||||
bare = token.split(".", 1)[1]
|
||||
elif token.startswith("legacy."):
|
||||
bare = token[len("legacy.") :]
|
||||
else:
|
||||
return [token]
|
||||
return [
|
||||
f"role.{bare}",
|
||||
f"anchor.{bare}",
|
||||
*(f"{prefix}{bare}" for prefix in topology_prefixes),
|
||||
f"legacy.{bare}",
|
||||
bare,
|
||||
]
|
||||
|
||||
return [
|
||||
f"role.{token}",
|
||||
f"anchor.{token}",
|
||||
f"face.{token}",
|
||||
f"edge.{token}",
|
||||
f"wire.{token}",
|
||||
f"vertex.{token}",
|
||||
f"solid.{token}",
|
||||
f"legacy.{token}",
|
||||
token,
|
||||
]
|
||||
@@ -0,0 +1,753 @@
|
||||
"""Topology tracking data models for recording CAD operations and their lineage.
|
||||
|
||||
These models form the foundation for:
|
||||
- Tracking how topological entities (vertex/edge/wire/face/solid) change through operations
|
||||
- Recording an operation DAG that can be serialized and replayed
|
||||
- Providing stable references for auto-tagging and query-language integration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from importlib import metadata as importlib_metadata
|
||||
from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
|
||||
|
||||
|
||||
GRAPH_SCHEMA_VERSION = "2.0"
|
||||
|
||||
|
||||
def _producer_version() -> str:
|
||||
try:
|
||||
return importlib_metadata.version("simplecadapi")
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
return "0+unknown"
|
||||
|
||||
|
||||
def graph_capabilities_payload() -> Dict[str, Any]:
|
||||
return {
|
||||
"selection_ref_strategies": True,
|
||||
"geo_select_nodes": True,
|
||||
"selector_hint_fallback": True,
|
||||
"display_payload": True,
|
||||
"sketch_constraints": True,
|
||||
"sketch_solve_snapshots": True,
|
||||
"product_semantics": True,
|
||||
"assembly_graph": True,
|
||||
"topology_delta_summary": False,
|
||||
"scalar_field_graph": False,
|
||||
"expression_graph": True,
|
||||
}
|
||||
|
||||
|
||||
class TopoKind(Enum):
|
||||
"""Type of topological entity."""
|
||||
|
||||
VERTEX = auto()
|
||||
EDGE = auto()
|
||||
WIRE = auto()
|
||||
FACE = auto()
|
||||
SOLID = auto()
|
||||
COMPOUND = auto()
|
||||
|
||||
|
||||
class TopoEvent(Enum):
|
||||
"""What happened to a subshape during an operation."""
|
||||
|
||||
PRESERVED = auto() # Unchanged through the operation
|
||||
MODIFIED = auto() # Modified (split, trimmed, re-faceted, etc.)
|
||||
GENERATED = auto() # Newly created by the operation
|
||||
DELETED = auto() # Removed from the result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TopoRef:
|
||||
"""Stable reference to a subshape within a specific graph + node.
|
||||
|
||||
Attributes:
|
||||
graph_id: Identifier of the parent graph this ref belongs to.
|
||||
node_id: Identifier of the operation node that produced this subshape.
|
||||
output_slot: Index into the node's output list (0 for single-output ops).
|
||||
kind: The topological kind (vertex/edge/wire/face/solid).
|
||||
topo_id: An opaque string that identifies this particular subshape
|
||||
within the node's output. Exact format is implementation-
|
||||
defined; it may be a sequential integer, a hash, etc.
|
||||
"""
|
||||
|
||||
graph_id: str
|
||||
node_id: str
|
||||
output_slot: int
|
||||
kind: TopoKind
|
||||
topo_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticRef:
|
||||
"""Stable reference to a semantic model entity in the recorded graph."""
|
||||
|
||||
graph_id: str
|
||||
node_id: str
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
|
||||
|
||||
def semantic_ref_to_dict(ref: SemanticRef) -> Dict[str, Any]:
|
||||
return {
|
||||
"graph_id": ref.graph_id,
|
||||
"node_id": ref.node_id,
|
||||
"entity_type": ref.entity_type,
|
||||
"entity_id": ref.entity_id,
|
||||
}
|
||||
|
||||
|
||||
def semantic_ref_from_dict(data: Dict[str, Any]) -> SemanticRef:
|
||||
return SemanticRef(
|
||||
graph_id=str(data["graph_id"]),
|
||||
node_id=str(data["node_id"]),
|
||||
entity_type=str(data["entity_type"]),
|
||||
entity_id=str(data["entity_id"]),
|
||||
)
|
||||
|
||||
|
||||
def semantic_delta_to_dict(delta: SemanticDelta) -> Dict[str, Any]:
|
||||
return {
|
||||
"created": [semantic_ref_to_dict(ref) for ref in delta.created],
|
||||
"modified": [semantic_ref_to_dict(ref) for ref in delta.modified],
|
||||
"deleted": [semantic_ref_to_dict(ref) for ref in delta.deleted],
|
||||
"metadata": dict(delta.metadata),
|
||||
}
|
||||
|
||||
|
||||
def semantic_delta_from_dict(data: Dict[str, Any]) -> SemanticDelta:
|
||||
return SemanticDelta(
|
||||
created=tuple(semantic_ref_from_dict(item) for item in data.get("created", [])),
|
||||
modified=tuple(
|
||||
semantic_ref_from_dict(item) for item in data.get("modified", [])
|
||||
),
|
||||
deleted=tuple(semantic_ref_from_dict(item) for item in data.get("deleted", [])),
|
||||
metadata=dict(data.get("metadata", {})),
|
||||
)
|
||||
|
||||
|
||||
def topo_ref_to_dict(ref: TopoRef) -> Dict[str, Any]:
|
||||
"""Serialize a TopoRef into a JSON-compatible dictionary."""
|
||||
|
||||
return {
|
||||
"graph_id": ref.graph_id,
|
||||
"node_id": ref.node_id,
|
||||
"output_slot": ref.output_slot,
|
||||
"kind": ref.kind.name,
|
||||
"topo_id": ref.topo_id,
|
||||
}
|
||||
|
||||
|
||||
def topo_ref_from_dict(data: Dict[str, Any]) -> TopoRef:
|
||||
"""Reconstruct a TopoRef from serialized data."""
|
||||
|
||||
return TopoRef(
|
||||
graph_id=str(data["graph_id"]),
|
||||
node_id=str(data["node_id"]),
|
||||
output_slot=int(data.get("output_slot", 0)),
|
||||
kind=TopoKind[str(data["kind"])],
|
||||
topo_id=str(data["topo_id"]),
|
||||
)
|
||||
|
||||
|
||||
def topo_entry_to_dict(entry: "TopoEntry") -> Dict[str, Any]:
|
||||
return {
|
||||
"ref": topo_ref_to_dict(entry.ref),
|
||||
"event": entry.event.name,
|
||||
"origin_role": entry.origin_role,
|
||||
"parent_refs": [topo_ref_to_dict(ref) for ref in entry.parent_refs],
|
||||
"metadata": dict(entry.metadata),
|
||||
}
|
||||
|
||||
|
||||
def topo_entry_from_dict(data: Dict[str, Any]) -> "TopoEntry":
|
||||
return TopoEntry(
|
||||
ref=topo_ref_from_dict(data["ref"]),
|
||||
event=TopoEvent[str(data["event"])],
|
||||
origin_role=(
|
||||
str(data["origin_role"]) if data.get("origin_role") is not None else None
|
||||
),
|
||||
parent_refs=tuple(
|
||||
topo_ref_from_dict(item) for item in data.get("parent_refs", [])
|
||||
),
|
||||
metadata=dict(data.get("metadata", {})),
|
||||
)
|
||||
|
||||
|
||||
def topo_delta_to_dict(delta: "TopoDelta") -> Dict[str, Any]:
|
||||
return {
|
||||
"preserved": [topo_ref_to_dict(ref) for ref in delta.preserved],
|
||||
"modified": [topo_ref_to_dict(ref) for ref in delta.modified],
|
||||
"generated": [topo_ref_to_dict(ref) for ref in delta.generated],
|
||||
"deleted": [topo_ref_to_dict(ref) for ref in delta.deleted],
|
||||
"section_edges": [topo_ref_to_dict(ref) for ref in delta.section_edges],
|
||||
"entries": [topo_entry_to_dict(entry) for entry in delta.entries],
|
||||
"raw_event": dict(delta.raw_event),
|
||||
}
|
||||
|
||||
|
||||
def topo_delta_from_dict(data: Dict[str, Any]) -> "TopoDelta":
|
||||
return TopoDelta(
|
||||
preserved=tuple(topo_ref_from_dict(item) for item in data.get("preserved", [])),
|
||||
modified=tuple(topo_ref_from_dict(item) for item in data.get("modified", [])),
|
||||
generated=tuple(topo_ref_from_dict(item) for item in data.get("generated", [])),
|
||||
deleted=tuple(topo_ref_from_dict(item) for item in data.get("deleted", [])),
|
||||
section_edges=tuple(
|
||||
topo_ref_from_dict(item) for item in data.get("section_edges", [])
|
||||
),
|
||||
entries=tuple(topo_entry_from_dict(item) for item in data.get("entries", [])),
|
||||
raw_event=dict(data.get("raw_event", {})),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TopoEntry:
|
||||
"""Record for one topological entity after an operation.
|
||||
|
||||
Attributes:
|
||||
ref: Reference to this entity.
|
||||
event: What happened to it (preserved / modified / generated / deleted).
|
||||
origin_role: Semantic role of the input that produced this entity
|
||||
(e.g. ``"body"`` or ``"tool"``). ``None`` if not applicable.
|
||||
parent_refs: References to upstream entities that were the ancestors.
|
||||
metadata: Arbitrary key-value metadata for extensibility.
|
||||
"""
|
||||
|
||||
ref: TopoRef
|
||||
event: TopoEvent
|
||||
origin_role: Optional[str] = None
|
||||
parent_refs: Tuple[TopoRef, ...] = ()
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TopoDelta:
|
||||
"""Complete topological change set for a single operation.
|
||||
|
||||
Each operation produces a delta describing how its inputs' subshapes
|
||||
map to the outputs. All lists store *references* (``TopoRef``), not
|
||||
the geometric objects themselves.
|
||||
|
||||
Attributes:
|
||||
preserved: Entities that survived unchanged.
|
||||
modified: Entities that were altered (split, trimmed, re-faced…).
|
||||
generated: Entities newly created by the operation.
|
||||
deleted: Entities completely removed.
|
||||
section_edges: Edges created by boolean intersection (convenience subset
|
||||
of ``generated``).
|
||||
entries: Optional richer per-entity records with lineage.
|
||||
raw_event: Opaque dict for transport of OCC-specific detail.
|
||||
"""
|
||||
|
||||
preserved: Tuple[TopoRef, ...] = ()
|
||||
modified: Tuple[TopoRef, ...] = ()
|
||||
generated: Tuple[TopoRef, ...] = ()
|
||||
deleted: Tuple[TopoRef, ...] = ()
|
||||
section_edges: Tuple[TopoRef, ...] = ()
|
||||
entries: Tuple[TopoEntry, ...] = ()
|
||||
raw_event: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticDelta:
|
||||
"""Semantic entity change set attached to a single recorded operation."""
|
||||
|
||||
created: Tuple[SemanticRef, ...] = ()
|
||||
modified: Tuple[SemanticRef, ...] = ()
|
||||
deleted: Tuple[SemanticRef, ...] = ()
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def bind_semantic_delta(
|
||||
delta: Optional[SemanticDelta], graph_id: str, node_id: str
|
||||
) -> Optional[SemanticDelta]:
|
||||
if delta is None:
|
||||
return None
|
||||
|
||||
def bind_ref(ref: SemanticRef) -> SemanticRef:
|
||||
return SemanticRef(
|
||||
graph_id=graph_id if ref.graph_id in {"", "pending"} else ref.graph_id,
|
||||
node_id=node_id if ref.node_id in {"", "pending"} else ref.node_id,
|
||||
entity_type=ref.entity_type,
|
||||
entity_id=ref.entity_id,
|
||||
)
|
||||
|
||||
return SemanticDelta(
|
||||
created=tuple(bind_ref(ref) for ref in delta.created),
|
||||
modified=tuple(bind_ref(ref) for ref in delta.modified),
|
||||
deleted=tuple(bind_ref(ref) for ref in delta.deleted),
|
||||
metadata=dict(delta.metadata),
|
||||
)
|
||||
|
||||
|
||||
def bind_topo_delta(
|
||||
delta: Optional[TopoDelta], graph_id: str, node_id: str
|
||||
) -> Optional[TopoDelta]:
|
||||
if delta is None:
|
||||
return None
|
||||
|
||||
def bind_ref(ref: TopoRef) -> TopoRef:
|
||||
return TopoRef(
|
||||
graph_id=graph_id,
|
||||
node_id=node_id,
|
||||
output_slot=ref.output_slot,
|
||||
kind=ref.kind,
|
||||
topo_id=ref.topo_id,
|
||||
)
|
||||
|
||||
return TopoDelta(
|
||||
preserved=tuple(bind_ref(ref) for ref in delta.preserved),
|
||||
modified=tuple(bind_ref(ref) for ref in delta.modified),
|
||||
generated=tuple(bind_ref(ref) for ref in delta.generated),
|
||||
deleted=tuple(bind_ref(ref) for ref in delta.deleted),
|
||||
section_edges=tuple(bind_ref(ref) for ref in delta.section_edges),
|
||||
entries=tuple(
|
||||
TopoEntry(
|
||||
ref=bind_ref(entry.ref),
|
||||
event=entry.event,
|
||||
origin_role=entry.origin_role,
|
||||
parent_refs=tuple(bind_ref(ref) for ref in entry.parent_refs),
|
||||
metadata=dict(entry.metadata),
|
||||
)
|
||||
for entry in delta.entries
|
||||
),
|
||||
raw_event=dict(delta.raw_event),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationNode:
|
||||
"""A single node in the operation DAG.
|
||||
|
||||
Attributes:
|
||||
node_id: Unique identifier within the graph.
|
||||
op: Operation type (e.g. ``"make_line_redge"``, ``"make_cut_rsolid"``).
|
||||
params: Serialisable parameters for re-creation.
|
||||
inputs: Upstream nodes whose outputs feed into this node.
|
||||
context: Work-plane / coordinate-system snapshot taken at creation time.
|
||||
output_count: Number of output shapes this node produces (usually 1).
|
||||
topo_delta: Topological change set (may be ``None`` for simple primitives).
|
||||
tags: Free-form labels for annotation.
|
||||
"""
|
||||
|
||||
node_id: str
|
||||
op: str
|
||||
params: Dict[str, Any] = field(default_factory=dict)
|
||||
param_exprs: Dict[str, Any] = field(default_factory=dict)
|
||||
inputs: Tuple["OperationNode", ...] = ()
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
output_count: int = 1
|
||||
semantic_delta: Optional[SemanticDelta] = None
|
||||
topo_delta: Optional[TopoDelta] = None
|
||||
tags: FrozenSet[str] = frozenset()
|
||||
|
||||
|
||||
def _make_id(prefix: str = "node") -> str:
|
||||
"""Generate a short unique id."""
|
||||
return f"{prefix}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _node_display_category(op: str) -> str:
|
||||
if op.startswith("make_select_"):
|
||||
return "selection"
|
||||
if op in {
|
||||
"cut",
|
||||
"union",
|
||||
"intersect",
|
||||
"make_cut_rsolid",
|
||||
"make_union_rsolid",
|
||||
"make_intersect_rsolid",
|
||||
}:
|
||||
return "boolean"
|
||||
if op in {
|
||||
"translate",
|
||||
"rotate",
|
||||
"mirror",
|
||||
"make_translate_rshape",
|
||||
"make_rotate_rshape",
|
||||
"make_mirror_rshape",
|
||||
}:
|
||||
return "transform"
|
||||
if op in {"linear_pattern", "radial_pattern"}:
|
||||
return "pattern"
|
||||
if op in {
|
||||
"extrude",
|
||||
"revolve",
|
||||
"loft",
|
||||
"sweep",
|
||||
"helical_sweep",
|
||||
"make_extrude_rsolid",
|
||||
"make_revolve_rsolid",
|
||||
"make_loft_rsolid",
|
||||
"make_sweep_rsolid",
|
||||
}:
|
||||
return "feature"
|
||||
if op in {
|
||||
"fillet",
|
||||
"chamfer",
|
||||
"shell",
|
||||
"make_fillet_rsolid",
|
||||
"make_chamfer_rsolid",
|
||||
"make_shell_rsolid",
|
||||
}:
|
||||
return "detail"
|
||||
if op.startswith("make_"):
|
||||
if any(token in op for token in ("_wire", "_edge", "_face", "point")):
|
||||
return "profile"
|
||||
return "primitive"
|
||||
return "operation"
|
||||
|
||||
|
||||
def _node_display_label(op: str) -> str:
|
||||
label = op.replace("_", " ")
|
||||
if label.startswith("make "):
|
||||
label = label[5:]
|
||||
return " ".join(word.capitalize() for word in label.split())
|
||||
|
||||
|
||||
def _node_display_summary(op: str, params: Dict[str, Any]) -> str:
|
||||
ignored = {
|
||||
"selected_edges",
|
||||
"selected_faces",
|
||||
"selected_edge_node_ids",
|
||||
"selected_face_node_ids",
|
||||
"selected_edge_indices",
|
||||
"selected_face_indices",
|
||||
"geo_selector",
|
||||
}
|
||||
summary_parts: List[str] = []
|
||||
for key, value in params.items():
|
||||
if key in ignored:
|
||||
continue
|
||||
if isinstance(value, float):
|
||||
summary_parts.append(f"{key}={value:.4g}")
|
||||
elif isinstance(value, (list, tuple)) and len(value) > 4:
|
||||
summary_parts.append(f"{key}[{len(value)}]")
|
||||
else:
|
||||
summary_parts.append(f"{key}={value}")
|
||||
if len(summary_parts) == 3:
|
||||
break
|
||||
|
||||
if not summary_parts:
|
||||
return _node_display_label(op)
|
||||
return ", ".join(summary_parts)
|
||||
|
||||
|
||||
def _node_display_payload(op: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
selection_count = 0
|
||||
if isinstance(params.get("selected_edges"), list):
|
||||
selection_count = len(params["selected_edges"])
|
||||
elif isinstance(params.get("selected_faces"), list):
|
||||
selection_count = len(params["selected_faces"])
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"label": _node_display_label(op),
|
||||
"category": _node_display_category(op),
|
||||
"summary": _node_display_summary(op, params),
|
||||
}
|
||||
if selection_count:
|
||||
payload["selection_count"] = selection_count
|
||||
return payload
|
||||
|
||||
|
||||
class OperationGraph:
|
||||
"""A directed acyclic graph of CAD operations.
|
||||
|
||||
The graph tracks nodes (operations) and edges (data-flow dependencies).
|
||||
It supports topological ordering, upstream/downstream queries, and
|
||||
root/leaf enumeration.
|
||||
|
||||
Usage::
|
||||
|
||||
g = OperationGraph()
|
||||
e1 = g.add_node("make_line_redge", {"start": (0, 0, 0), "end": (1, 0, 0)})
|
||||
e2 = g.add_node("make_line_redge", {"start": (1, 0, 0), "end": (1, 1, 0)})
|
||||
wire = g.add_node("make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[e1, e2])
|
||||
assert wire.node_id == g.leaf_nodes()[0].node_id
|
||||
"""
|
||||
|
||||
def __init__(self, graph_id: Optional[str] = None) -> None:
|
||||
self.graph_id: str = graph_id or _make_id("graph")
|
||||
self._nodes: Dict[str, OperationNode] = {}
|
||||
self._edges: List[Tuple[str, str]] = []
|
||||
self._adj: Dict[str, List[str]] = defaultdict(list)
|
||||
self._radj: Dict[str, List[str]] = defaultdict(list)
|
||||
self._counter: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_node(
|
||||
self,
|
||||
op: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
param_exprs: Optional[Dict[str, Any]] = None,
|
||||
inputs: Optional[List[OperationNode]] = None,
|
||||
node_id: Optional[str] = None,
|
||||
output_count: int = 1,
|
||||
semantic_delta: Optional[SemanticDelta] = None,
|
||||
topo_delta: Optional[TopoDelta] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
tags: Optional[Set[str]] = None,
|
||||
) -> OperationNode:
|
||||
"""Add an operation node and wire its input edges.
|
||||
|
||||
Returns the created :class:`OperationNode`.
|
||||
"""
|
||||
nid = node_id or _make_id()
|
||||
if nid in self._nodes:
|
||||
raise ValueError(f"node id '{nid}' already exists in graph")
|
||||
|
||||
input_nodes = tuple(inputs) if inputs else ()
|
||||
for inp in input_nodes:
|
||||
if inp.node_id not in self._nodes:
|
||||
raise ValueError(
|
||||
f"input node '{inp.node_id}' is not part of this graph"
|
||||
)
|
||||
|
||||
bound_semantic_delta = bind_semantic_delta(semantic_delta, self.graph_id, nid)
|
||||
bound_topo_delta = bind_topo_delta(topo_delta, self.graph_id, nid)
|
||||
|
||||
node = OperationNode(
|
||||
node_id=nid,
|
||||
op=op,
|
||||
params=dict(params) if params else {},
|
||||
param_exprs=dict(param_exprs) if param_exprs else {},
|
||||
inputs=input_nodes,
|
||||
context=context,
|
||||
output_count=output_count,
|
||||
semantic_delta=bound_semantic_delta,
|
||||
topo_delta=bound_topo_delta,
|
||||
tags=frozenset(tags) if tags else frozenset(),
|
||||
)
|
||||
self._nodes[nid] = node
|
||||
|
||||
for inp in input_nodes:
|
||||
self._edges.append((inp.node_id, nid))
|
||||
self._adj[inp.node_id].append(nid)
|
||||
self._radj[nid].append(inp.node_id)
|
||||
|
||||
return node
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_node(self, node_id: str) -> Optional[OperationNode]:
|
||||
return self._nodes.get(node_id)
|
||||
|
||||
@property
|
||||
def nodes(self) -> List[OperationNode]:
|
||||
return list(self._nodes.values())
|
||||
|
||||
@property
|
||||
def node_count(self) -> int:
|
||||
return len(self._nodes)
|
||||
|
||||
@property
|
||||
def edges(self) -> Set[Tuple[str, str]]:
|
||||
return set(self._edges)
|
||||
|
||||
@property
|
||||
def edge_count(self) -> int:
|
||||
return len(self._edges)
|
||||
|
||||
def upstream_nodes(self, node_id: str) -> List[str]:
|
||||
"""Return ids of nodes that feed into *node_id*."""
|
||||
return list(self._radj.get(node_id, []))
|
||||
|
||||
def downstream_nodes(self, node_id: str) -> List[str]:
|
||||
"""Return ids of nodes that consume *node_id*'s output."""
|
||||
return list(self._adj.get(node_id, []))
|
||||
|
||||
def root_nodes(self) -> List[OperationNode]:
|
||||
"""Nodes with no inputs."""
|
||||
return [self._nodes[nid] for nid in self._nodes if not self._radj.get(nid)]
|
||||
|
||||
def leaf_nodes(self) -> List[OperationNode]:
|
||||
"""Nodes with no downstream consumers."""
|
||||
return [self._nodes[nid] for nid in self._nodes if not self._adj.get(nid)]
|
||||
|
||||
def is_dag(self) -> bool:
|
||||
"""Return ``True`` if the graph has no cycles (always valid for correct usage)."""
|
||||
visited: Set[str] = set()
|
||||
on_stack: Set[str] = set()
|
||||
|
||||
def dfs(nid: str) -> bool:
|
||||
visited.add(nid)
|
||||
on_stack.add(nid)
|
||||
for child in self._adj.get(nid, []):
|
||||
if child not in visited:
|
||||
if not dfs(child):
|
||||
return False
|
||||
elif child in on_stack:
|
||||
return False
|
||||
on_stack.discard(nid)
|
||||
return True
|
||||
|
||||
for nid in self._nodes:
|
||||
if nid not in visited:
|
||||
if not dfs(nid):
|
||||
return False
|
||||
return True
|
||||
|
||||
def topological_order(self) -> List[OperationNode]:
|
||||
"""Return nodes in valid execution (topological) order.
|
||||
|
||||
Raises ``ValueError`` if the graph contains a cycle.
|
||||
"""
|
||||
if not self.is_dag():
|
||||
raise ValueError("graph contains a cycle")
|
||||
|
||||
in_degree: Dict[str, int] = {nid: 0 for nid in self._nodes}
|
||||
for child, parent in self._edges:
|
||||
in_degree[parent] = in_degree.get(parent, 0) + 1
|
||||
|
||||
queue: List[str] = [nid for nid, d in in_degree.items() if d == 0]
|
||||
order: List[str] = []
|
||||
|
||||
while queue:
|
||||
nid = queue.pop(0)
|
||||
order.append(nid)
|
||||
for child in self._adj.get(nid, []):
|
||||
in_degree[child] -= 1
|
||||
if in_degree[child] == 0:
|
||||
queue.append(child)
|
||||
|
||||
return [self._nodes[nid] for nid in order]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialization
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize the graph to a JSON-compatible dictionary."""
|
||||
nodes_data = []
|
||||
for node in self.topological_order():
|
||||
node_data: Dict[str, Any] = {
|
||||
"node_id": node.node_id,
|
||||
"op": node.op,
|
||||
"params": dict(node.params),
|
||||
"inputs": [inp.node_id for inp in node.inputs],
|
||||
"output_count": node.output_count,
|
||||
"tags": sorted(node.tags),
|
||||
"display": _node_display_payload(node.op, node.params),
|
||||
}
|
||||
if node.param_exprs:
|
||||
node_data["param_exprs"] = dict(node.param_exprs)
|
||||
if node.context:
|
||||
node_data["context"] = node.context
|
||||
if node.semantic_delta is not None:
|
||||
node_data["semantic_delta"] = semantic_delta_to_dict(
|
||||
node.semantic_delta
|
||||
)
|
||||
if node.topo_delta is not None:
|
||||
node_data["topo_delta"] = topo_delta_to_dict(node.topo_delta)
|
||||
nodes_data.append(node_data)
|
||||
|
||||
return {
|
||||
"schema_version": GRAPH_SCHEMA_VERSION,
|
||||
"producer_version": _producer_version(),
|
||||
"capabilities": graph_capabilities_payload(),
|
||||
"graph_id": self.graph_id,
|
||||
"nodes": nodes_data,
|
||||
"edges": [[src, dst] for src, dst in self._edges],
|
||||
}
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
"""Serialize the graph to a JSON string."""
|
||||
import json
|
||||
|
||||
return json.dumps(self.to_dict(), indent=indent)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any], *, strict: bool = True) -> "OperationGraph":
|
||||
"""Reconstruct a graph from a dictionary.
|
||||
|
||||
Nodes are added in topological order so that input references resolve.
|
||||
"""
|
||||
graph = cls(graph_id=data.get("graph_id"))
|
||||
|
||||
# Build nodes first (without edges)
|
||||
node_map: Dict[str, OperationNode] = {}
|
||||
for nd in data.get("nodes", []):
|
||||
tags_set = set(nd.get("tags", []))
|
||||
node = graph.add_node(
|
||||
op=nd["op"],
|
||||
params=nd.get("params", {}),
|
||||
param_exprs=nd.get("param_exprs", {}),
|
||||
node_id=nd["node_id"],
|
||||
output_count=nd.get("output_count", 1),
|
||||
semantic_delta=(
|
||||
semantic_delta_from_dict(nd["semantic_delta"])
|
||||
if isinstance(nd.get("semantic_delta"), dict)
|
||||
else None
|
||||
),
|
||||
topo_delta=(
|
||||
topo_delta_from_dict(nd["topo_delta"])
|
||||
if isinstance(nd.get("topo_delta"), dict)
|
||||
else None
|
||||
),
|
||||
context=nd.get("context"),
|
||||
tags=tags_set if tags_set else None,
|
||||
)
|
||||
node_map[nd["node_id"]] = node
|
||||
|
||||
# Wire edges
|
||||
for edge in data.get("edges", []):
|
||||
if not isinstance(edge, (list, tuple)) or len(edge) != 2:
|
||||
if strict:
|
||||
raise ValueError(f"malformed graph edge entry: {edge!r}")
|
||||
continue
|
||||
src, dst = edge
|
||||
if src not in node_map or dst not in node_map:
|
||||
if strict:
|
||||
raise ValueError(
|
||||
f"graph edge references missing node(s): {src!r} -> {dst!r}"
|
||||
)
|
||||
continue
|
||||
graph._edges.append((src, dst))
|
||||
graph._adj[src].append(dst)
|
||||
graph._radj[dst].append(src)
|
||||
|
||||
# Fix up inputs references
|
||||
for nd in data.get("nodes", []):
|
||||
node = node_map.get(nd["node_id"])
|
||||
if node and nd.get("inputs"):
|
||||
missing_inputs = [iid for iid in nd["inputs"] if iid not in node_map]
|
||||
if missing_inputs and strict:
|
||||
raise ValueError(
|
||||
f"graph node '{node.node_id}' references missing input node(s): "
|
||||
+ ", ".join(str(iid) for iid in missing_inputs)
|
||||
)
|
||||
input_nodes = tuple(
|
||||
node_map[iid] for iid in nd["inputs"] if iid in node_map
|
||||
)
|
||||
# Rebuild the node with correct inputs
|
||||
graph._nodes[node.node_id] = OperationNode(
|
||||
node_id=node.node_id,
|
||||
op=node.op,
|
||||
params=node.params,
|
||||
param_exprs=node.param_exprs,
|
||||
inputs=input_nodes,
|
||||
context=node.context,
|
||||
output_count=node.output_count,
|
||||
semantic_delta=node.semantic_delta,
|
||||
topo_delta=node.topo_delta,
|
||||
tags=node.tags,
|
||||
)
|
||||
|
||||
return graph
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str, *, strict: bool = True) -> "OperationGraph":
|
||||
"""Reconstruct a graph from a JSON string."""
|
||||
import json
|
||||
|
||||
return cls.from_dict(json.loads(json_str), strict=strict)
|
||||
@@ -0,0 +1,886 @@
|
||||
"""BRep tracking layer for capturing operation history via OCC Modified/Generated/IsDeleted.
|
||||
|
||||
This module wraps OCC builders directly (not through CadQuery's ``_bool_op`` which
|
||||
discards the builder). It preserves the builder object so that ``Modified()``,
|
||||
``Generated()``, ``IsDeleted()``, and ``SectionEdges()`` can be queried for each
|
||||
input subshape, producing a :class:`TopoDelta` that records exactly what happened
|
||||
topologically.
|
||||
|
||||
Supported operations:
|
||||
- Boolean: cut, union (fuse), intersect (common)
|
||||
- Transforms: translate, rotate
|
||||
- Features: extrude, fillet, chamfer
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from OCP.BRepAlgoAPI import (
|
||||
BRepAlgoAPI_Cut,
|
||||
BRepAlgoAPI_Fuse,
|
||||
BRepAlgoAPI_Common,
|
||||
BRepAlgoAPI_BooleanOperation,
|
||||
)
|
||||
from OCP.BOPAlgo import BOPAlgo_GlueOff, BOPAlgo_GlueShift
|
||||
from OCP.BRepBuilderAPI import (
|
||||
BRepBuilderAPI_Transform,
|
||||
BRepBuilderAPI_MakeShape,
|
||||
)
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
|
||||
from OCP.BRepFilletAPI import (
|
||||
BRepFilletAPI_MakeFillet,
|
||||
BRepFilletAPI_MakeChamfer,
|
||||
)
|
||||
from OCP.BRepOffsetAPI import (
|
||||
BRepOffsetAPI_MakePipeShell,
|
||||
BRepOffsetAPI_MakeThickSolid,
|
||||
BRepOffsetAPI_ThruSections,
|
||||
)
|
||||
from OCP.BRepOffset import BRepOffset_Skin
|
||||
from OCP.GeomAbs import GeomAbs_Arc
|
||||
from OCP.gp import gp_Vec
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
from .core import Solid, Face, Edge, Vertex
|
||||
from .topology import (
|
||||
TopoKind,
|
||||
TopoEvent,
|
||||
TopoRef,
|
||||
TopoDelta,
|
||||
_make_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackedBooleanResult:
|
||||
"""Result of a tracked boolean operation.
|
||||
|
||||
Attributes:
|
||||
solid: The resulting SimpleCADAPI Solid (or ``None`` on failure).
|
||||
delta: Complete topological change set.
|
||||
delta_entries: Per-entity metadata dict keyed by ``topo_id``.
|
||||
"""
|
||||
|
||||
solid: Optional[Solid]
|
||||
delta: TopoDelta
|
||||
delta_entries: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _topo_id(shape) -> str:
|
||||
"""Stable-ish string identifier for an OCC TopoDS_Shape.
|
||||
|
||||
Uses TShape pointer + Location hash for uniqueness within a single build.
|
||||
"""
|
||||
try:
|
||||
return f"{shape.HashCode(1000000)}"
|
||||
except AttributeError:
|
||||
return f"{hash(shape)}"
|
||||
|
||||
|
||||
def _iter_subshapes(shape, shape_type: int):
|
||||
"""Yield all subshapes of a given type from an OCC shape."""
|
||||
explorer = TopExp_Explorer(shape, shape_type)
|
||||
while explorer.More():
|
||||
yield explorer.Current()
|
||||
explorer.Next()
|
||||
|
||||
|
||||
def _tolist_oftools(shapes) -> TopTools_ListOfShape:
|
||||
"""Convert a Python list of TopoDS_Shape to TopTools_ListOfShape."""
|
||||
tl = TopTools_ListOfShape()
|
||||
for s in shapes:
|
||||
tl.Append(s)
|
||||
return tl
|
||||
|
||||
|
||||
def _query_history(
|
||||
builder: BRepAlgoAPI_BooleanOperation,
|
||||
input_solid,
|
||||
graph_id: str,
|
||||
node_id: str,
|
||||
origin_role: str,
|
||||
kind: TopoKind,
|
||||
shape_type: int,
|
||||
) -> Tuple[
|
||||
List[TopoRef], List[TopoRef], List[TopoRef], List[TopoRef], List[Dict[str, Any]]
|
||||
]:
|
||||
"""Query Modified/Generated/IsDeleted for every subshape of ``input_solid``.
|
||||
|
||||
Returns five lists: ``preserved, modified, generated, deleted`` as ``TopoRef``
|
||||
lists, plus a list of per-entity metadata dicts.
|
||||
"""
|
||||
preserved: List[TopoRef] = []
|
||||
modified: List[TopoRef] = []
|
||||
generated: List[TopoRef] = []
|
||||
deleted: List[TopoRef] = []
|
||||
entries: List[Dict[str, Any]] = []
|
||||
|
||||
for sub in _iter_subshapes(input_solid, shape_type):
|
||||
input_id = _topo_id(sub)
|
||||
|
||||
if builder.IsDeleted(sub):
|
||||
ref = TopoRef(graph_id, node_id, 0, kind, input_id)
|
||||
deleted.append(ref)
|
||||
entries.append(
|
||||
{
|
||||
"topo_id": input_id,
|
||||
"event": "deleted",
|
||||
"origin_role": origin_role,
|
||||
"input_topo_id": input_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
mod_list = builder.Modified(sub)
|
||||
gen_list = builder.Generated(sub)
|
||||
|
||||
mod_size = mod_list.Size() if hasattr(mod_list, "Size") else 0
|
||||
gen_size = gen_list.Size() if hasattr(gen_list, "Size") else 0
|
||||
|
||||
# Check if Modified returns the exact same shape (no actual change)
|
||||
same_shape_in_mod = False
|
||||
if mod_size == 1:
|
||||
try:
|
||||
first_mod = mod_list.First()
|
||||
same_shape_in_mod = first_mod.IsSame(sub)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Case 1: No modifications, no generations -> PRESERVED
|
||||
if mod_size == 0 and gen_size == 0:
|
||||
ref = TopoRef(graph_id, node_id, 0, kind, input_id)
|
||||
preserved.append(ref)
|
||||
entries.append(
|
||||
{
|
||||
"topo_id": input_id,
|
||||
"event": "preserved",
|
||||
"origin_role": origin_role,
|
||||
"input_topo_id": input_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Case 2: Modified returns the same shape and no generations -> PRESERVED
|
||||
if same_shape_in_mod and gen_size == 0:
|
||||
ref = TopoRef(graph_id, node_id, 0, kind, input_id)
|
||||
preserved.append(ref)
|
||||
entries.append(
|
||||
{
|
||||
"topo_id": input_id,
|
||||
"event": "preserved",
|
||||
"origin_role": origin_role,
|
||||
"input_topo_id": input_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Case 3: Has modifications (different or multiple shapes) -> MODIFIED
|
||||
if mod_size > 0 and not same_shape_in_mod:
|
||||
for mod_shape in mod_list:
|
||||
mod_id = _topo_id(mod_shape)
|
||||
ref = TopoRef(graph_id, node_id, 0, kind, mod_id)
|
||||
modified.append(ref)
|
||||
entries.append(
|
||||
{
|
||||
"topo_id": mod_id,
|
||||
"event": "modified",
|
||||
"origin_role": origin_role,
|
||||
"input_topo_id": input_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Case 4: Has generated new shapes -> GENERATED
|
||||
if gen_size > 0:
|
||||
for gen_shape in gen_list:
|
||||
gen_id = _topo_id(gen_shape)
|
||||
ref = TopoRef(graph_id, node_id, 0, kind, gen_id)
|
||||
generated.append(ref)
|
||||
entries.append(
|
||||
{
|
||||
"topo_id": gen_id,
|
||||
"event": "generated",
|
||||
"origin_role": origin_role,
|
||||
"input_topo_id": input_id,
|
||||
}
|
||||
)
|
||||
|
||||
return preserved, modified, generated, deleted, entries
|
||||
|
||||
|
||||
def _collect_section_edges(
|
||||
builder: BRepAlgoAPI_BooleanOperation,
|
||||
graph_id: str,
|
||||
node_id: str,
|
||||
) -> List[TopoRef]:
|
||||
"""Collect section (intersection) edges from a boolean builder."""
|
||||
section_refs: List[TopoRef] = []
|
||||
try:
|
||||
sec_list = builder.SectionEdges()
|
||||
for edge_shape in sec_list:
|
||||
eid = _topo_id(edge_shape)
|
||||
section_refs.append(TopoRef(graph_id, node_id, 0, TopoKind.EDGE, eid))
|
||||
except Exception:
|
||||
pass
|
||||
return section_refs
|
||||
|
||||
|
||||
def _build_boolean_result(
|
||||
builder: BRepAlgoAPI_BooleanOperation,
|
||||
body: Solid,
|
||||
tool: Solid,
|
||||
op: str,
|
||||
) -> TrackedBooleanResult:
|
||||
"""Common post-build logic for boolean operations."""
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
result_shape = builder.Shape()
|
||||
|
||||
# Wrap as SimpleCADAPI Solid directly from the OCP TopoDS result.
|
||||
try:
|
||||
result_solid = Solid(result_shape)
|
||||
except Exception:
|
||||
if hasattr(result_shape, "Solids") and result_shape.Solids():
|
||||
result_solid = Solid(result_shape.Solids()[0])
|
||||
else:
|
||||
result_solid = None
|
||||
|
||||
# Query face-level history for body
|
||||
b_pres, b_mod, b_gen, b_del, b_entries = _query_history(
|
||||
builder,
|
||||
body.wrapped,
|
||||
graph_id,
|
||||
node_id,
|
||||
"body",
|
||||
TopoKind.FACE,
|
||||
TopAbs_FACE,
|
||||
)
|
||||
# Query face-level history for tool
|
||||
t_pres, t_mod, t_gen, t_del, t_entries = _query_history(
|
||||
builder,
|
||||
tool.wrapped,
|
||||
graph_id,
|
||||
node_id,
|
||||
"tool",
|
||||
TopoKind.FACE,
|
||||
TopAbs_FACE,
|
||||
)
|
||||
|
||||
# Section edges
|
||||
section_edges = _collect_section_edges(builder, graph_id, node_id)
|
||||
|
||||
delta = TopoDelta(
|
||||
preserved=tuple(b_pres + t_pres),
|
||||
modified=tuple(b_mod + t_mod),
|
||||
generated=tuple(b_gen + t_gen),
|
||||
deleted=tuple(b_del + t_del),
|
||||
section_edges=tuple(section_edges),
|
||||
)
|
||||
|
||||
# Build per-entity metadata
|
||||
all_entries: Dict[str, Dict[str, Any]] = {}
|
||||
for e in b_entries + t_entries:
|
||||
all_entries[e["topo_id"]] = e
|
||||
|
||||
return TrackedBooleanResult(
|
||||
solid=result_solid,
|
||||
delta=delta,
|
||||
delta_entries=all_entries,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def tracked_cut(body: Solid, tool: Solid) -> TrackedBooleanResult:
|
||||
"""Perform a boolean cut with full face-level history tracking.
|
||||
|
||||
Args:
|
||||
body: The base solid.
|
||||
tool: The solid to subtract.
|
||||
|
||||
Returns:
|
||||
:class:`TrackedBooleanResult` with the cut solid and topological delta.
|
||||
"""
|
||||
cut_op = BRepAlgoAPI_Cut()
|
||||
cut_op.SetRunParallel(True)
|
||||
cut_op.SetUseOBB(True)
|
||||
cut_op.SetToFillHistory(True)
|
||||
|
||||
args = TopTools_ListOfShape()
|
||||
args.Append(body.wrapped)
|
||||
tools = TopTools_ListOfShape()
|
||||
tools.Append(tool.wrapped)
|
||||
|
||||
cut_op.SetArguments(args)
|
||||
cut_op.SetTools(tools)
|
||||
cut_op.Build()
|
||||
|
||||
if not cut_op.IsDone():
|
||||
raise ValueError("Boolean cut failed: OCC build did not complete")
|
||||
|
||||
return _build_boolean_result(cut_op, body, tool, "cut")
|
||||
|
||||
|
||||
def tracked_union(
|
||||
body: Solid, tool: Solid, glue: bool = True, tol: float = 1e-7
|
||||
) -> TrackedBooleanResult:
|
||||
"""Perform a boolean union with full face-level history tracking.
|
||||
|
||||
Args:
|
||||
body: First solid.
|
||||
tool: Second solid.
|
||||
glue: Enable glue mode.
|
||||
tol: Fuzzy tolerance.
|
||||
|
||||
Returns:
|
||||
:class:`TrackedBooleanResult` with the fused solid and topological delta.
|
||||
"""
|
||||
fuse_op = BRepAlgoAPI_Fuse()
|
||||
fuse_op.SetRunParallel(True)
|
||||
fuse_op.SetUseOBB(True)
|
||||
fuse_op.SetToFillHistory(True)
|
||||
fuse_op.SetGlue(BOPAlgo_GlueShift if glue else BOPAlgo_GlueOff)
|
||||
if tol is not None:
|
||||
fuse_op.SetFuzzyValue(float(tol))
|
||||
|
||||
args = TopTools_ListOfShape()
|
||||
args.Append(body.wrapped)
|
||||
tools = TopTools_ListOfShape()
|
||||
tools.Append(tool.wrapped)
|
||||
|
||||
fuse_op.SetArguments(args)
|
||||
fuse_op.SetTools(tools)
|
||||
fuse_op.Build()
|
||||
|
||||
if not fuse_op.IsDone():
|
||||
raise ValueError("Boolean union failed: OCC build did not complete")
|
||||
|
||||
result = _build_boolean_result(fuse_op, body, tool, "union")
|
||||
# Add section edges from the builder
|
||||
return result
|
||||
|
||||
|
||||
def tracked_intersect(body: Solid, tool: Solid) -> TrackedBooleanResult:
|
||||
"""Perform a boolean intersection with full face-level history tracking.
|
||||
|
||||
Args:
|
||||
body: First solid.
|
||||
tool: Second solid.
|
||||
|
||||
Returns:
|
||||
:class:`TrackedBooleanResult` with the intersection solid and topological delta.
|
||||
"""
|
||||
common_op = BRepAlgoAPI_Common()
|
||||
common_op.SetRunParallel(True)
|
||||
common_op.SetUseOBB(True)
|
||||
common_op.SetToFillHistory(True)
|
||||
|
||||
args = TopTools_ListOfShape()
|
||||
args.Append(body.wrapped)
|
||||
tools = TopTools_ListOfShape()
|
||||
tools.Append(tool.wrapped)
|
||||
|
||||
common_op.SetArguments(args)
|
||||
common_op.SetTools(tools)
|
||||
common_op.Build()
|
||||
|
||||
if not common_op.IsDone():
|
||||
raise ValueError("Boolean intersect failed: OCC build did not complete")
|
||||
|
||||
return _build_boolean_result(common_op, body, tool, "intersect")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generalized result + single-shape history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackedResult:
|
||||
"""Result of a tracked single-shape operation (transform, extrude, fillet…).
|
||||
|
||||
Attributes:
|
||||
shape: The resulting SimpleCADAPI shape (``Solid``, ``Face``, etc.) or ``None``.
|
||||
delta: Topological change set.
|
||||
delta_entries: Per-entity metadata dict keyed by ``topo_id``.
|
||||
"""
|
||||
|
||||
shape: Optional[Solid]
|
||||
delta: TopoDelta
|
||||
delta_entries: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _query_single_shape_history(
|
||||
builder: BRepBuilderAPI_MakeShape,
|
||||
input_solid,
|
||||
graph_id: str,
|
||||
node_id: str,
|
||||
op: str,
|
||||
force_preserved: bool = False,
|
||||
) -> Tuple[TopoDelta, Dict[str, Dict[str, Any]]]:
|
||||
"""Query history for a single-input operation (transforms, extrude, fillet…).
|
||||
|
||||
Args:
|
||||
force_preserved: If True, treat all input faces as PRESERVED regardless
|
||||
of what OCC reports. Useful for pure transforms that create new
|
||||
TShape copies but don't actually change topology.
|
||||
"""
|
||||
if force_preserved:
|
||||
pres: List[TopoRef] = []
|
||||
entries: List[Dict[str, Any]] = []
|
||||
for sub in _iter_subshapes(input_solid, TopAbs_FACE):
|
||||
input_id = _topo_id(sub)
|
||||
ref = TopoRef(graph_id, node_id, 0, TopoKind.FACE, input_id)
|
||||
pres.append(ref)
|
||||
entries.append(
|
||||
{
|
||||
"topo_id": input_id,
|
||||
"event": "preserved",
|
||||
"origin_role": "body",
|
||||
"input_topo_id": input_id,
|
||||
}
|
||||
)
|
||||
delta = TopoDelta(preserved=tuple(pres))
|
||||
all_entries: Dict[str, Dict[str, Any]] = {e["topo_id"]: e for e in entries}
|
||||
return delta, all_entries
|
||||
|
||||
pres, mod, gen, del_, entries = _query_history(
|
||||
builder, input_solid, graph_id, node_id, "body", TopoKind.FACE, TopAbs_FACE
|
||||
)
|
||||
delta = TopoDelta(
|
||||
preserved=tuple(pres),
|
||||
modified=tuple(mod),
|
||||
generated=tuple(gen),
|
||||
deleted=tuple(del_),
|
||||
)
|
||||
all_entries: Dict[str, Dict[str, Any]] = {}
|
||||
for e in entries:
|
||||
all_entries[e["topo_id"]] = e
|
||||
return delta, all_entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transform tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def tracked_translate(
|
||||
shape: Solid, vector: Tuple[float, float, float]
|
||||
) -> TrackedResult:
|
||||
"""Translate a solid with face-level history tracking.
|
||||
|
||||
Args:
|
||||
shape: Solid to translate.
|
||||
vector: Translation vector ``(dx, dy, dz)``.
|
||||
|
||||
Returns:
|
||||
:class:`TrackedResult` with the translated solid and topological delta.
|
||||
"""
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
gp_vec = gp_Vec(*vector)
|
||||
from OCP.gp import gp_Trsf
|
||||
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetTranslation(gp_vec)
|
||||
|
||||
xform = BRepBuilderAPI_Transform(shape.wrapped, trsf, True)
|
||||
xform.Build()
|
||||
|
||||
if not xform.IsDone():
|
||||
raise ValueError("Translate failed: OCC build did not complete")
|
||||
|
||||
result_solid = Solid(xform.Shape())
|
||||
|
||||
delta, entries = _query_single_shape_history(
|
||||
xform,
|
||||
shape.wrapped,
|
||||
graph_id,
|
||||
node_id,
|
||||
"translate",
|
||||
force_preserved=True,
|
||||
)
|
||||
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
|
||||
|
||||
def tracked_rotate(
|
||||
shape: Solid,
|
||||
angle_degrees: float,
|
||||
axis: Tuple[float, float, float] = (0, 0, 1),
|
||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||
) -> TrackedResult:
|
||||
"""Rotate a solid with face-level history tracking.
|
||||
|
||||
Args:
|
||||
shape: Solid to rotate.
|
||||
angle_degrees: Rotation angle in degrees.
|
||||
axis: Rotation axis direction.
|
||||
origin: Rotation center.
|
||||
|
||||
Returns:
|
||||
:class:`TrackedResult` with the rotated solid and topological delta.
|
||||
"""
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
import math
|
||||
|
||||
angle_rad = math.radians(angle_degrees)
|
||||
|
||||
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
|
||||
|
||||
trsf = gp_Trsf()
|
||||
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
|
||||
trsf.SetRotation(ax1, angle_rad)
|
||||
|
||||
xform = BRepBuilderAPI_Transform(shape.wrapped, trsf, True)
|
||||
xform.Build()
|
||||
|
||||
if not xform.IsDone():
|
||||
raise ValueError("Rotate failed: OCC build did not complete")
|
||||
|
||||
result_solid = Solid(xform.Shape())
|
||||
|
||||
delta, entries = _query_single_shape_history(
|
||||
xform,
|
||||
shape.wrapped,
|
||||
graph_id,
|
||||
node_id,
|
||||
"rotate",
|
||||
force_preserved=True,
|
||||
)
|
||||
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
|
||||
|
||||
def tracked_mirror(
|
||||
shape: Solid,
|
||||
plane_origin: Tuple[float, float, float],
|
||||
plane_normal: Tuple[float, float, float],
|
||||
) -> TrackedResult:
|
||||
"""Mirror a solid with face-level history tracking."""
|
||||
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
|
||||
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetMirror(
|
||||
gp_Ax2(
|
||||
gp_Pnt(*plane_origin),
|
||||
gp_Dir(*plane_normal),
|
||||
)
|
||||
)
|
||||
|
||||
xform = BRepBuilderAPI_Transform(shape.wrapped, trsf, True)
|
||||
xform.Build()
|
||||
|
||||
if not xform.IsDone():
|
||||
raise ValueError("Mirror failed: OCC build did not complete")
|
||||
|
||||
result_solid = Solid(xform.Shape())
|
||||
|
||||
delta, entries = _query_single_shape_history(
|
||||
xform,
|
||||
shape.wrapped,
|
||||
graph_id,
|
||||
node_id,
|
||||
"mirror",
|
||||
force_preserved=True,
|
||||
)
|
||||
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def tracked_extrude(
|
||||
profile: Face, direction: Tuple[float, float, float], distance: float
|
||||
) -> TrackedResult:
|
||||
"""Extrude a profile face into a solid with history tracking.
|
||||
|
||||
Args:
|
||||
profile: Face to extrude.
|
||||
direction: Extrusion direction.
|
||||
distance: Extrusion distance.
|
||||
|
||||
Returns:
|
||||
:class:`TrackedResult` with the extruded solid and topological delta.
|
||||
"""
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
arr = np.array(direction, dtype=float)
|
||||
norm = float(np.linalg.norm(arr))
|
||||
if norm <= 1e-15:
|
||||
raise ValueError("Extrude direction cannot be zero-length")
|
||||
arr = arr / norm * float(distance)
|
||||
gp_vec = gp_Vec(float(arr[0]), float(arr[1]), float(arr[2]))
|
||||
|
||||
prism = BRepPrimAPI_MakePrism(profile.wrapped, gp_vec)
|
||||
prism.Build()
|
||||
|
||||
if not prism.IsDone():
|
||||
raise ValueError("Extrude failed: OCC build did not complete")
|
||||
|
||||
result_solid = Solid(prism.Shape())
|
||||
|
||||
delta, entries = _query_single_shape_history(
|
||||
prism, profile.wrapped, graph_id, node_id, "extrude"
|
||||
)
|
||||
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
|
||||
|
||||
def tracked_revolve(
|
||||
profile: Face,
|
||||
axis: Tuple[float, float, float],
|
||||
origin: Tuple[float, float, float],
|
||||
angle_degrees: float,
|
||||
) -> TrackedResult:
|
||||
"""Revolve a profile face into a solid with history tracking."""
|
||||
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt
|
||||
|
||||
angle_rad = math.radians(float(angle_degrees))
|
||||
revolve_op = BRepPrimAPI_MakeRevol(
|
||||
profile.wrapped,
|
||||
gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis)),
|
||||
angle_rad,
|
||||
True,
|
||||
)
|
||||
revolve_op.Build()
|
||||
|
||||
if not revolve_op.IsDone():
|
||||
raise ValueError("Revolve failed: OCC build did not complete")
|
||||
|
||||
result_solid = Solid(revolve_op.Shape())
|
||||
|
||||
delta, entries = _query_single_shape_history(
|
||||
revolve_op, profile.wrapped, graph_id, node_id, "revolve"
|
||||
)
|
||||
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
|
||||
|
||||
def tracked_fillet(solid: Solid, edges: List[Edge], radius: float) -> TrackedResult:
|
||||
"""Apply fillet with face-level history tracking.
|
||||
|
||||
Args:
|
||||
solid: Solid to fillet.
|
||||
edges: Edges to fillet.
|
||||
radius: Fillet radius.
|
||||
|
||||
Returns:
|
||||
:class:`TrackedResult` with the filleted solid and topological delta.
|
||||
"""
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
fillet_op = BRepFilletAPI_MakeFillet(solid.wrapped)
|
||||
for edge in edges:
|
||||
fillet_op.Add(radius, edge.wrapped)
|
||||
fillet_op.Build()
|
||||
|
||||
if not fillet_op.IsDone():
|
||||
raise ValueError("Fillet failed: OCC build did not complete")
|
||||
|
||||
result_solid = Solid(fillet_op.Shape())
|
||||
|
||||
delta, entries = _query_single_shape_history(
|
||||
fillet_op, solid.wrapped, graph_id, node_id, "fillet"
|
||||
)
|
||||
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
|
||||
|
||||
def tracked_chamfer(solid: Solid, edges: List[Edge], distance: float) -> TrackedResult:
|
||||
"""Apply chamfer with face-level history tracking.
|
||||
|
||||
Args:
|
||||
solid: Solid to chamfer.
|
||||
edges: Edges to chamfer.
|
||||
distance: Chamfer distance.
|
||||
|
||||
Returns:
|
||||
:class:`TrackedResult` with the chamfered solid and topological delta.
|
||||
"""
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
chamfer_op = BRepFilletAPI_MakeChamfer(solid.wrapped)
|
||||
for edge in edges:
|
||||
chamfer_op.Add(distance, edge.wrapped)
|
||||
chamfer_op.Build()
|
||||
|
||||
if not chamfer_op.IsDone():
|
||||
raise ValueError("Chamfer failed: OCC build did not complete")
|
||||
|
||||
result_solid = Solid(chamfer_op.Shape())
|
||||
|
||||
delta, entries = _query_single_shape_history(
|
||||
chamfer_op, solid.wrapped, graph_id, node_id, "chamfer"
|
||||
)
|
||||
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
|
||||
|
||||
def tracked_shell(
|
||||
solid: Solid, faces_to_remove: List[Face], thickness: float, tol: float = 1e-6
|
||||
) -> TrackedResult:
|
||||
"""Apply shell/thick-solid operation with face-level history tracking."""
|
||||
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
shell_op = BRepOffsetAPI_MakeThickSolid()
|
||||
closing_faces = TopTools_ListOfShape()
|
||||
for face in faces_to_remove:
|
||||
closing_faces.Append(face.wrapped)
|
||||
|
||||
shell_op.MakeThickSolidByJoin(
|
||||
solid.wrapped,
|
||||
closing_faces,
|
||||
-abs(float(thickness)),
|
||||
float(tol),
|
||||
BRepOffset_Skin,
|
||||
False,
|
||||
False,
|
||||
GeomAbs_Arc,
|
||||
False,
|
||||
)
|
||||
shell_op.Build()
|
||||
|
||||
if not shell_op.IsDone():
|
||||
raise ValueError("Shell failed: OCC build did not complete")
|
||||
|
||||
result_solid = Solid(shell_op.Shape())
|
||||
|
||||
delta, entries = _query_single_shape_history(
|
||||
shell_op, solid.wrapped, graph_id, node_id, "shell"
|
||||
)
|
||||
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
|
||||
|
||||
def tracked_loft(profiles: List[Wire], ruled: bool = False) -> TrackedResult:
|
||||
"""Loft profile wires into a solid with wire-level history tracking."""
|
||||
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
loft_op = BRepOffsetAPI_ThruSections(True, bool(ruled))
|
||||
loft_op.CheckCompatibility(True)
|
||||
for profile in profiles:
|
||||
loft_op.AddWire(profile.wrapped)
|
||||
loft_op.Build()
|
||||
|
||||
if not loft_op.IsDone():
|
||||
raise ValueError("Loft failed: OCC build did not complete")
|
||||
|
||||
result_solid = Solid(loft_op.Shape())
|
||||
|
||||
preserved: List[TopoRef] = []
|
||||
modified: List[TopoRef] = []
|
||||
generated: List[TopoRef] = []
|
||||
deleted: List[TopoRef] = []
|
||||
entries: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for idx, profile in enumerate(profiles):
|
||||
pres, mod, gen, del_, profile_entries = _query_history(
|
||||
loft_op,
|
||||
profile.wrapped,
|
||||
graph_id,
|
||||
node_id,
|
||||
f"profile_{idx}",
|
||||
TopoKind.EDGE,
|
||||
TopAbs_EDGE,
|
||||
)
|
||||
preserved.extend(pres)
|
||||
modified.extend(mod)
|
||||
generated.extend(gen)
|
||||
deleted.extend(del_)
|
||||
for item in profile_entries:
|
||||
entries[item["topo_id"]] = item
|
||||
|
||||
delta = TopoDelta(
|
||||
preserved=tuple(preserved),
|
||||
modified=tuple(modified),
|
||||
generated=tuple(generated),
|
||||
deleted=tuple(deleted),
|
||||
)
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
|
||||
|
||||
def tracked_sweep(profile: Face, path: Wire, is_frenet: bool = False) -> TrackedResult:
|
||||
"""Sweep a profile face along a wire path with history tracking."""
|
||||
|
||||
graph_id = _make_id("g")
|
||||
node_id = _make_id("n")
|
||||
|
||||
sweep_op = BRepOffsetAPI_MakePipeShell(path.wrapped)
|
||||
sweep_op.SetMode(bool(is_frenet))
|
||||
sweep_op.Add(profile.get_outer_wire().wrapped, False, False)
|
||||
sweep_op.Build()
|
||||
if not sweep_op.IsDone():
|
||||
raise ValueError("Sweep failed: OCC build did not complete")
|
||||
if not sweep_op.MakeSolid():
|
||||
raise ValueError("Sweep failed: OCC solid conversion did not complete")
|
||||
|
||||
result_solid = Solid(sweep_op.Shape())
|
||||
|
||||
p_pres, p_mod, p_gen, p_del, p_entries = _query_history(
|
||||
sweep_op,
|
||||
profile.get_outer_wire().wrapped,
|
||||
graph_id,
|
||||
node_id,
|
||||
"profile",
|
||||
TopoKind.EDGE,
|
||||
TopAbs_EDGE,
|
||||
)
|
||||
path_pres, path_mod, path_gen, path_del, path_entries = _query_history(
|
||||
sweep_op,
|
||||
path.wrapped,
|
||||
graph_id,
|
||||
node_id,
|
||||
"path",
|
||||
TopoKind.EDGE,
|
||||
TopAbs_EDGE,
|
||||
)
|
||||
|
||||
delta = TopoDelta(
|
||||
preserved=tuple(p_pres + path_pres),
|
||||
modified=tuple(p_mod + path_mod),
|
||||
generated=tuple(p_gen + path_gen),
|
||||
deleted=tuple(p_del + path_del),
|
||||
)
|
||||
entries: Dict[str, Dict[str, Any]] = {}
|
||||
for item in p_entries + path_entries:
|
||||
entries[item["topo_id"]] = item
|
||||
return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Translator backends for exporting SimpleCAD model JSON to external CAD systems."""
|
||||
|
||||
from . import freecad_translator
|
||||
|
||||
__all__ = ["freecad_translator"]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""FreeCAD translator backend for SimpleCAD model JSON."""
|
||||
|
||||
from .api import translate_model_json_to_fcstd, translate_model_json_to_freecad_script
|
||||
from .script_translator import FreeCADScriptTranslator
|
||||
|
||||
__all__ = [
|
||||
"FreeCADScriptTranslator",
|
||||
"translate_model_json_to_fcstd",
|
||||
"translate_model_json_to_freecad_script",
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Public FreeCAD translator entrypoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Optional
|
||||
|
||||
from ...errors import raise_harness_error
|
||||
from .script_translator import FreeCADScriptTranslator
|
||||
|
||||
|
||||
def _json_ascii(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def _discover_freecad_executable() -> Optional[str]:
|
||||
candidates = [
|
||||
shutil.which("FreeCADCmd"),
|
||||
shutil.which("freecadcmd"),
|
||||
shutil.which("FreeCAD"),
|
||||
"/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd",
|
||||
"/Applications/FreeCAD.app/Contents/MacOS/FreeCAD",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate and os.path.exists(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def translate_model_json_to_freecad_script(
|
||||
json_str: str,
|
||||
document_name: str = "SimpleCADModel",
|
||||
) -> str:
|
||||
"""Translate exported model JSON into a FreeCAD Python script.
|
||||
|
||||
Part/Assembly product nodes are emitted as editable FreeCAD document
|
||||
structure: parts use `App::Part`, assemblies use native
|
||||
`Assembly::AssemblyObject`, part components use `App::Link`, and
|
||||
subassembly components use `Assembly::AssemblyLink` when the Assembly
|
||||
workbench module is available.
|
||||
"""
|
||||
|
||||
return FreeCADScriptTranslator(
|
||||
document_name=document_name
|
||||
).translate_model_json_to_script(json_str)
|
||||
|
||||
|
||||
def translate_model_json_to_fcstd(
|
||||
json_str: str,
|
||||
output_path: str,
|
||||
*,
|
||||
document_name: str = "SimpleCADModel",
|
||||
freecad_cmd: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Translate canonical model JSON to `.FCStd` via FreeCADCmd/FreeCAD.
|
||||
|
||||
Functional sketch promotions are written as visible `Sketcher::SketchObject`
|
||||
nodes with mapped/skipped constraint evidence. Exact B-spline edges are
|
||||
exported to FreeCAD using `Part.BSplineCurve().buildFromPolesMultsKnots(...)`.
|
||||
Safe single-use profile transforms such as section rotate/translate chains are
|
||||
folded into the section object's placement so downstream `Part::Loft` receives
|
||||
already-positioned sections instead of placement-bearing `App::Link` proxies.
|
||||
Part/Assembly product nodes are written as editable FreeCAD assembly structure:
|
||||
parts use `App::Part`, assemblies use native `Assembly::AssemblyObject`, part
|
||||
components use `App::Link`, and nested assembly components use
|
||||
`Assembly::AssemblyLink`. Explicit assembly-to-compound projections remain in
|
||||
the document for geometry workflows but do not replace the visible assembly
|
||||
tree.
|
||||
"""
|
||||
|
||||
freecad_exe = freecad_cmd or _discover_freecad_executable()
|
||||
if not freecad_exe:
|
||||
raise_harness_error(
|
||||
operation="translate_model_json_to_fcstd",
|
||||
what_happened="Could not locate a FreeCAD command-line executable.",
|
||||
possible_causes=[
|
||||
"FreeCADCmd is not installed or not available on PATH.",
|
||||
"Only the GUI app is installed and no CLI entrypoint is reachable.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Install FreeCAD with FreeCADCmd, or pass freecad_cmd=... explicitly.",
|
||||
"Make sure FreeCADCmd or FreeCAD is on PATH.",
|
||||
],
|
||||
error=FileNotFoundError("FreeCADCmd/FreeCAD not found"),
|
||||
)
|
||||
|
||||
script = translate_model_json_to_freecad_script(
|
||||
json_str, document_name=document_name
|
||||
)
|
||||
resolved_output_path = os.path.abspath(output_path)
|
||||
save_tail = (
|
||||
f"\nOUTPUT_PATH = {_json_ascii(resolved_output_path)}\n"
|
||||
"_apply_result_visibility(RESULT_NODE_IDS)\n"
|
||||
"_set_active_result_object(RESULT_NODE_IDS)\n"
|
||||
"_save_fcstd_with_gui_visibility(OUTPUT_PATH)\n"
|
||||
"print(OUTPUT_PATH)\n"
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix="_simplecad_freecad_export.py", delete=False
|
||||
) as handle:
|
||||
temp_script_path = handle.name
|
||||
handle.write(script)
|
||||
handle.write(save_tail)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[freecad_exe, temp_script_path],
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if not os.path.exists(resolved_output_path) or os.path.getsize(resolved_output_path) <= 0:
|
||||
raise RuntimeError(
|
||||
"FreeCAD export completed without creating a non-empty .FCStd file. "
|
||||
f"stderr={completed.stderr.strip()!r}"
|
||||
)
|
||||
return output_path
|
||||
except Exception as e:
|
||||
raise_harness_error(
|
||||
operation="translate_model_json_to_fcstd",
|
||||
what_happened="Failed to execute the generated FreeCAD export script.",
|
||||
possible_causes=[
|
||||
"FreeCADCmd started but the generated script hit an unsupported API call.",
|
||||
"The output path is invalid or not writable.",
|
||||
"The installed FreeCAD build lacks Part or Spreadsheet support needed by the translator.",
|
||||
],
|
||||
how_to_fix=[
|
||||
"Inspect the generated script first with translate_model_json_to_freecad_script().",
|
||||
"Use a writable .FCStd output path.",
|
||||
"Run the same script manually inside a matching FreeCAD environment to isolate runtime differences.",
|
||||
],
|
||||
error=e,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,442 @@
|
||||
"""Static verification helpers for SimpleCAD assemblies.
|
||||
|
||||
The verifier namespace exposes high-level structural checks. The first checker
|
||||
uses the internal cached solid meshes plus python-fcl to report current-pose mesh
|
||||
contact penetrations deeper than the configured tolerance. It intentionally does
|
||||
not attempt complete solid containment detection yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import combinations
|
||||
from typing import Any, Iterable, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .. import _mesh
|
||||
from ..product import Assembly, Part, Placement, compose_placements, identity_placement
|
||||
|
||||
|
||||
ComponentPath = Tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComponentPair:
|
||||
"""Unordered pair of component paths used by verifier scopes."""
|
||||
|
||||
component_a: str | Sequence[str]
|
||||
component_b: str | Sequence[str]
|
||||
|
||||
def normalized(self) -> tuple[ComponentPath, ComponentPath]:
|
||||
a = _component_path(self.component_a)
|
||||
b = _component_path(self.component_b)
|
||||
ordered = sorted((a, b))
|
||||
return ordered[0], ordered[1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollisionScope:
|
||||
"""Select which component pairs a static collision check should inspect.
|
||||
|
||||
Args:
|
||||
component_paths: Optional component path filter. When non-empty, only
|
||||
pairs where both components are in this set are considered.
|
||||
include_pairs: Optional explicit unordered pair set. When non-empty,
|
||||
only these pairs are considered.
|
||||
exclude_pairs: Unordered pairs to skip. This is a strict skip, not a
|
||||
joint-aware mating-region rule.
|
||||
"""
|
||||
|
||||
component_paths: tuple[str | Sequence[str], ...] = ()
|
||||
include_pairs: tuple[ComponentPair, ...] = ()
|
||||
exclude_pairs: tuple[ComponentPair, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollisionCheckConfig:
|
||||
"""Configuration for current-pose static mesh penetration checks.
|
||||
|
||||
Args:
|
||||
max_allowed_penetration: Maximum accepted FCL contact penetration depth
|
||||
in model units. Contact or shallower penetration passes; deeper
|
||||
penetration fails.
|
||||
scope: Optional component pair scope.
|
||||
max_contacts_per_pair: Maximum FCL contacts requested per checked pair.
|
||||
"""
|
||||
|
||||
max_allowed_penetration: float = 0.01
|
||||
scope: CollisionScope = field(default_factory=CollisionScope)
|
||||
max_contacts_per_pair: int = 64
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_allowed_penetration < 0:
|
||||
raise ValueError("max_allowed_penetration must be non-negative")
|
||||
if self.max_contacts_per_pair <= 0:
|
||||
raise ValueError("max_contacts_per_pair must be positive")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationWarning:
|
||||
"""Non-fatal verifier diagnostic."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
component_path: ComponentPath | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollisionContact:
|
||||
"""One FCL contact returned for a component pair."""
|
||||
|
||||
position: tuple[float, float, float]
|
||||
normal: tuple[float, float, float]
|
||||
penetration_depth: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollisionFailure:
|
||||
"""A component pair with over-tolerance mesh contact penetration."""
|
||||
|
||||
component_a: ComponentPath
|
||||
component_b: ComponentPath
|
||||
penetration_depth: float
|
||||
allowed_penetration: float
|
||||
contacts: tuple[CollisionContact, ...] = ()
|
||||
kind: str = "contact_penetration"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollisionReport:
|
||||
"""Result of a static collision verification pass."""
|
||||
|
||||
completed: bool
|
||||
passed: bool
|
||||
checked_pair_count: int
|
||||
failed_pair_count: int
|
||||
failures: tuple[CollisionFailure, ...] = ()
|
||||
warnings: tuple[VerificationWarning, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlacedSolid:
|
||||
path: ComponentPath
|
||||
part: Part
|
||||
placement: Placement
|
||||
mesh: _mesh.TriMesh | None
|
||||
bounds: tuple[tuple[float, float, float], tuple[float, float, float]] | None
|
||||
|
||||
|
||||
def check_collision_rcollisionreport(
|
||||
assembly: Assembly,
|
||||
config: CollisionCheckConfig | None = None,
|
||||
) -> CollisionReport:
|
||||
"""Check current assembly pose for FCL-reported over-tolerance mesh penetration.
|
||||
|
||||
This first verifier version checks only FCL contact penetration between
|
||||
leaf Part component meshes at their current placements. It does not solve
|
||||
constraints, does not sample motion, and does not detect complete containment
|
||||
cases where two closed solids overlap without surface contact.
|
||||
"""
|
||||
|
||||
if not isinstance(assembly, Assembly):
|
||||
raise TypeError("assembly must be an Assembly")
|
||||
cfg = config or CollisionCheckConfig()
|
||||
warnings: list[VerificationWarning] = []
|
||||
|
||||
fcl = _import_fcl()
|
||||
if fcl is None:
|
||||
return CollisionReport(
|
||||
completed=False,
|
||||
passed=False,
|
||||
checked_pair_count=0,
|
||||
failed_pair_count=0,
|
||||
warnings=(
|
||||
VerificationWarning(
|
||||
code="backend_unavailable",
|
||||
message="python-fcl is required for collision verification; install simplecadapi[collision].",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
placed = _collect_placed_solids(assembly, warnings=warnings)
|
||||
selected = _apply_scope(placed, cfg.scope)
|
||||
checked_count = 0
|
||||
failures: list[CollisionFailure] = []
|
||||
models = {
|
||||
item.path: _make_fcl_model(fcl, item.mesh)
|
||||
for item in selected
|
||||
if item.mesh is not None
|
||||
}
|
||||
|
||||
for solid_a, solid_b in combinations(selected, 2):
|
||||
if not _scoped_pair_allowed(solid_a.path, solid_b.path, cfg.scope):
|
||||
continue
|
||||
if solid_a.mesh is None or solid_b.mesh is None:
|
||||
continue
|
||||
checked_count += 1
|
||||
if solid_a.bounds is not None and solid_b.bounds is not None:
|
||||
if not _aabb_overlaps(solid_a.bounds, solid_b.bounds):
|
||||
continue
|
||||
failure = _check_pair_with_fcl(
|
||||
fcl=fcl,
|
||||
solid_a=solid_a,
|
||||
solid_b=solid_b,
|
||||
model_a=models[solid_a.path],
|
||||
model_b=models[solid_b.path],
|
||||
max_allowed_penetration=cfg.max_allowed_penetration,
|
||||
max_contacts_per_pair=cfg.max_contacts_per_pair,
|
||||
)
|
||||
if failure is not None:
|
||||
failures.append(failure)
|
||||
|
||||
return CollisionReport(
|
||||
completed=True,
|
||||
passed=not failures,
|
||||
checked_pair_count=checked_count,
|
||||
failed_pair_count=len(failures),
|
||||
failures=tuple(failures),
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
|
||||
def _import_fcl() -> Any | None:
|
||||
try:
|
||||
import fcl # type: ignore[import-untyped]
|
||||
except ModuleNotFoundError:
|
||||
return None
|
||||
return fcl
|
||||
|
||||
|
||||
def _collect_placed_solids(
|
||||
assembly: Assembly,
|
||||
*,
|
||||
warnings: list[VerificationWarning],
|
||||
prefix: ComponentPath = (),
|
||||
parent_placement: Placement | None = None,
|
||||
) -> list[_PlacedSolid]:
|
||||
base_placement = parent_placement or identity_placement()
|
||||
placed: list[_PlacedSolid] = []
|
||||
|
||||
for component in assembly.components:
|
||||
path = (*prefix, component.component_id)
|
||||
placement = compose_placements(base_placement, component.placement)
|
||||
if isinstance(component.item, Part):
|
||||
mesh = _mesh.cached_mesh(component.item.body)
|
||||
if mesh is None:
|
||||
detail = _mesh.mesh_error(component.item.body) or "no cached mesh"
|
||||
warnings.append(
|
||||
VerificationWarning(
|
||||
code="mesh_missing",
|
||||
message=detail,
|
||||
component_path=path,
|
||||
)
|
||||
)
|
||||
elif mesh.triangle_count == 0:
|
||||
warnings.append(
|
||||
VerificationWarning(
|
||||
code="mesh_empty",
|
||||
message="component mesh has no triangles",
|
||||
component_path=path,
|
||||
)
|
||||
)
|
||||
mesh = None
|
||||
placed.append(
|
||||
_PlacedSolid(
|
||||
path=path,
|
||||
part=component.item,
|
||||
placement=placement,
|
||||
mesh=mesh,
|
||||
bounds=_world_bounds(mesh, placement) if mesh is not None else None,
|
||||
)
|
||||
)
|
||||
elif isinstance(component.item, Assembly):
|
||||
placed.extend(
|
||||
_collect_placed_solids(
|
||||
component.item,
|
||||
warnings=warnings,
|
||||
prefix=path,
|
||||
parent_placement=placement,
|
||||
)
|
||||
)
|
||||
return placed
|
||||
|
||||
|
||||
def _apply_scope(placed: list[_PlacedSolid], scope: CollisionScope) -> list[_PlacedSolid]:
|
||||
if not scope.component_paths and not scope.include_pairs and not scope.exclude_pairs:
|
||||
return placed
|
||||
|
||||
allowed_paths = {_component_path(path) for path in scope.component_paths}
|
||||
if allowed_paths:
|
||||
placed = [item for item in placed if item.path in allowed_paths]
|
||||
if not scope.include_pairs:
|
||||
return placed
|
||||
|
||||
include_pairs = {pair.normalized() for pair in scope.include_pairs}
|
||||
paths_with_pairs: set[ComponentPath] = set()
|
||||
for a, b in include_pairs:
|
||||
paths_with_pairs.add(a)
|
||||
paths_with_pairs.add(b)
|
||||
return [item for item in placed if item.path in paths_with_pairs]
|
||||
|
||||
|
||||
def _scoped_pair_allowed(
|
||||
path_a: ComponentPath,
|
||||
path_b: ComponentPath,
|
||||
scope: CollisionScope,
|
||||
) -> bool:
|
||||
pair = tuple(sorted((path_a, path_b)))
|
||||
include_pairs = {item.normalized() for item in scope.include_pairs}
|
||||
exclude_pairs = {item.normalized() for item in scope.exclude_pairs}
|
||||
if include_pairs and pair not in include_pairs:
|
||||
return False
|
||||
return pair not in exclude_pairs
|
||||
|
||||
|
||||
def _check_pair_with_fcl(
|
||||
*,
|
||||
fcl: Any,
|
||||
solid_a: _PlacedSolid,
|
||||
solid_b: _PlacedSolid,
|
||||
model_a: Any,
|
||||
model_b: Any,
|
||||
max_allowed_penetration: float,
|
||||
max_contacts_per_pair: int,
|
||||
) -> CollisionFailure | None:
|
||||
assert solid_a.mesh is not None
|
||||
assert solid_b.mesh is not None
|
||||
|
||||
obj_a = fcl.CollisionObject(
|
||||
model_a,
|
||||
_fcl_transform(fcl, solid_a.placement),
|
||||
)
|
||||
obj_b = fcl.CollisionObject(
|
||||
model_b,
|
||||
_fcl_transform(fcl, solid_b.placement),
|
||||
)
|
||||
request = fcl.CollisionRequest(
|
||||
num_max_contacts=int(max_contacts_per_pair),
|
||||
enable_contact=True,
|
||||
)
|
||||
result = fcl.CollisionResult()
|
||||
fcl.collide(obj_a, obj_b, request, result)
|
||||
|
||||
contacts = tuple(
|
||||
_contact_from_fcl(contact)
|
||||
for contact in result.contacts
|
||||
if float(contact.penetration_depth) > 0.0
|
||||
)
|
||||
max_depth = max((contact.penetration_depth for contact in contacts), default=0.0)
|
||||
if max_depth <= max_allowed_penetration:
|
||||
return None
|
||||
|
||||
return CollisionFailure(
|
||||
component_a=solid_a.path,
|
||||
component_b=solid_b.path,
|
||||
penetration_depth=max_depth,
|
||||
allowed_penetration=float(max_allowed_penetration),
|
||||
contacts=tuple(sorted(contacts, key=lambda item: item.penetration_depth, reverse=True)),
|
||||
)
|
||||
|
||||
|
||||
def _make_fcl_model(fcl: Any, mesh: _mesh.TriMesh) -> Any:
|
||||
vertices = np.asarray(mesh.vertices, dtype=np.float64)
|
||||
triangles = np.asarray(mesh.triangles, dtype=np.int32)
|
||||
model = fcl.BVHModel()
|
||||
model.beginModel(int(mesh.vertex_count), int(mesh.triangle_count))
|
||||
model.addSubModel(vertices, triangles)
|
||||
model.endModel()
|
||||
return model
|
||||
|
||||
|
||||
def _world_bounds(
|
||||
mesh: _mesh.TriMesh,
|
||||
placement: Placement,
|
||||
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
||||
lower, upper = mesh.bounds
|
||||
corners = np.asarray(
|
||||
[
|
||||
(lower[0], lower[1], lower[2]),
|
||||
(lower[0], lower[1], upper[2]),
|
||||
(lower[0], upper[1], lower[2]),
|
||||
(lower[0], upper[1], upper[2]),
|
||||
(upper[0], lower[1], lower[2]),
|
||||
(upper[0], lower[1], upper[2]),
|
||||
(upper[0], upper[1], lower[2]),
|
||||
(upper[0], upper[1], upper[2]),
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
rotation = np.asarray(
|
||||
[
|
||||
[placement.x_axis[0], placement.y_axis[0], placement.z_axis[0]],
|
||||
[placement.x_axis[1], placement.y_axis[1], placement.z_axis[1]],
|
||||
[placement.x_axis[2], placement.y_axis[2], placement.z_axis[2]],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
transformed = corners @ rotation.T + np.asarray(placement.origin, dtype=np.float64)
|
||||
world_lower = transformed.min(axis=0)
|
||||
world_upper = transformed.max(axis=0)
|
||||
return tuple(float(v) for v in world_lower), tuple(float(v) for v in world_upper)
|
||||
|
||||
|
||||
def _aabb_overlaps(
|
||||
bounds_a: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
bounds_b: tuple[tuple[float, float, float], tuple[float, float, float]],
|
||||
) -> bool:
|
||||
lower_a, upper_a = bounds_a
|
||||
lower_b, upper_b = bounds_b
|
||||
return all(
|
||||
upper_a[index] >= lower_b[index] and upper_b[index] >= lower_a[index]
|
||||
for index in range(3)
|
||||
)
|
||||
|
||||
|
||||
def _fcl_transform(fcl: Any, placement: Placement) -> Any:
|
||||
rotation = np.asarray(
|
||||
[
|
||||
[placement.x_axis[0], placement.y_axis[0], placement.z_axis[0]],
|
||||
[placement.x_axis[1], placement.y_axis[1], placement.z_axis[1]],
|
||||
[placement.x_axis[2], placement.y_axis[2], placement.z_axis[2]],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
translation = np.asarray(placement.origin, dtype=np.float64)
|
||||
return fcl.Transform(rotation, translation)
|
||||
|
||||
|
||||
def _contact_from_fcl(contact: Any) -> CollisionContact:
|
||||
return CollisionContact(
|
||||
position=_float3(contact.pos),
|
||||
normal=_float3(contact.normal),
|
||||
penetration_depth=float(contact.penetration_depth),
|
||||
)
|
||||
|
||||
|
||||
def _float3(value: Iterable[float]) -> tuple[float, float, float]:
|
||||
items = tuple(float(v) for v in value)
|
||||
if len(items) != 3:
|
||||
raise ValueError("expected 3D vector")
|
||||
return items # type: ignore[return-value]
|
||||
|
||||
|
||||
def _component_path(value: str | Sequence[str]) -> ComponentPath:
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
path = tuple(str(item) for item in value)
|
||||
if not path or any(not item for item in path):
|
||||
raise ValueError("component path must not be empty")
|
||||
return path
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CollisionCheckConfig",
|
||||
"CollisionContact",
|
||||
"CollisionFailure",
|
||||
"CollisionReport",
|
||||
"CollisionScope",
|
||||
"ComponentPair",
|
||||
"VerificationWarning",
|
||||
"check_collision_rcollisionreport",
|
||||
]
|
||||
Reference in New Issue
Block a user