Import CAD projects and cad-router v1

This commit is contained in:
Jerry
2026-07-22 19:38:36 +08:00
parent e4e0199bd5
commit af9f630c11
2253 changed files with 1994207 additions and 0 deletions
+480
View File
@@ -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",
]
+164
View File
@@ -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
View File
@@ -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()
+165
View File
@@ -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
+169
View File
@@ -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
+591
View File
@@ -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
+415
View File
@@ -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
+82
View File
@@ -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
+397
View File
@@ -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)
+574
View File
@@ -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
+155
View File
@@ -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,
]
+753
View File
@@ -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)
+886
View File
@@ -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",
]