diff --git a/.gitignore b/.gitignore index 7c3cee55..3243e519 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,7 @@ sandbox-envs/ .agents/ .sharednet/ design-docs/ + +# local, not part of the SDK +examples/workspace/ +phase2-contract-review.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4ba008..2ea41d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and ## Unreleased +## 2.2.6.post2 — 2026-10-21 + +### Changed + +- Operators: `scale_rsolid` and `transform_rsolid` are implemented in `operators/transform.py`, + `boolean_rsolidlist` in `operators/boolean.py`, and `offset_rsolid`, `draft_rsolid`, + `extrude_taper_rsolid`, `extrude_until_rsolid` and `sweep_multisection_rsolid` in + `operators/features.py`, next to the operators of their family. `from simplecadapi import ...` + is unchanged. The one-feature modules they were first written in (`operators.deformation`, + `draft`, `section_features`, `limited_extrusion`) remain as aliases: importing a name from + them still works and raises a `DeprecationWarning` that names the new place. + +### Fixed + +- Skill API docs: the documentation generator listed its source files and public functions by + hand and had never been told about those four modules, so the docs of the eight operators + above were kept by hand and deleted by the generator's clean-up. `operators/` is now + discovered, a function the package exports is public, the content of the hand-written docs + (examples, error conditions, replay notes) is in the docstrings, and tests check that every + public operator has a doc and that the committed docs are exactly what the generator writes. + `boolean_rsolidlist` had no doc at all. +- Skill API docs of `NotebookRun`, `run_notebook` and `add_bspline_rsketch` caught up with the + code (`through`, `product` and `timeout`; a partial run's `product` is `None`; closed + B-splines). + ## 2.2.6.post1 — 2026-10-21 ### Fixed diff --git a/README.md b/README.md index 58a1b55f..c3ba5608 100644 --- a/README.md +++ b/README.md @@ -149,20 +149,19 @@ the full crank cycle sweeps clean in the same sim · --- -## Update Notes (2.2.6.post1) +## Update Notes (2.2.6.post2) > Validate generated definitions, assembly constraints, and manufacturing > geometry before production use. -- **Keyword `scad.var` is a parameter again.** `scad.var(name="j1", default=0.0)` is read - by the editor's parameter panel like the positional form; before, it was skipped. -- **A cached run costs much less.** On the desktop arm: cold 80 s to 35 s, nothing changed - 24 s to 4 s, one joint edited 18 s to 3 s, cache 1.7 GB to 96 MB. Restored shapes are built - on first use, parameters and imported names are keyed by value, and the `use` calls of a - cell run in worker processes (`SCA_JOBS`, 1 turns it off). -- **Slider drags are sent when they settle**, not step by step. +- **Operators live with their family.** `scale_rsolid`, `transform_rsolid`, `boolean_rsolidlist`, + `offset_rsolid`, `draft_rsolid`, `extrude_taper_rsolid`, `extrude_until_rsolid` and + `sweep_multisection_rsolid` moved into `operators/transform.py`, `boolean.py` and `features.py`. + `from simplecadapi import ...` is unchanged; the old module paths warn and still work. +- **Every operator's API reference is generated**, and tests fail when one has no doc or the + committed docs differ from the generator's output. -Full notes for this version: [`docs/updates/2.2.6.post1.md`](docs/updates/2.2.6.post1.md). +Full notes for this version: [`docs/updates/2.2.6.post2.md`](docs/updates/2.2.6.post2.md). Earlier versions: the [changelog](CHANGELOG.md) and [`docs/updates/`](docs/updates/). --- @@ -183,7 +182,7 @@ in a compact public API for creating solids, applying features, tagging semantic intent, querying topology, exporting manufacturing files, and translating recorded models into FreeCAD workflows. -Current release: `simplecadapi==2.2.6.post1`. +Current release: `simplecadapi==2.2.6.post2`. ## What It Provides @@ -698,6 +697,7 @@ and the [STEP reconstruction workflow](docs/skill/references/workflows/step-reco ## Documentation +- 2.2.6.post2 update notes: [`docs/updates/2.2.6.post2.md`](docs/updates/2.2.6.post2.md) - 2.2.6.post1 update notes: [`docs/updates/2.2.6.post1.md`](docs/updates/2.2.6.post1.md) - 2.2.6 update notes: [`docs/updates/2.2.6.md`](docs/updates/2.2.6.md) - 2.2.5 update notes: [`docs/updates/2.2.5.md`](docs/updates/2.2.5.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 24d9dc35..1635da2a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -133,18 +133,16 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 --- -## 更新日志(2.2.6.post1) +## 更新日志(2.2.6.post2) > 投入生产前请验证生成的定义、装配约束和制造几何。 -- **关键字写法的 `scad.var` 重新被识别为参数。** `scad.var(name="j1", default=0.0)` 现在和 - 位置参数写法一样出现在参数面板;之前会被跳过。 -- **缓存运行便宜得多。** 桌面机械臂:冷启动 80 秒到 35 秒,什么都没改 24 秒到 4 秒,改一个关节 18 秒到 3 秒, - 缓存 1.7 GB 到 96 MB。形状在第一次用到时才构建,参数和导入的名字按值定键,cell 里的 `use` 调用在 worker - 进程里并行(`SCA_JOBS`,设为 1 关闭)。 -- **滑块拖动在停下来时才发送**,不再逐步发送。 +- **算子放在自己家族的模块里。** `scale_rsolid`、`transform_rsolid`、`boolean_rsolidlist`、`offset_rsolid`、 + `draft_rsolid`、`extrude_taper_rsolid`、`extrude_until_rsolid`、`sweep_multisection_rsolid` 搬进了 + `operators/transform.py`、`boolean.py`、`features.py`。`from simplecadapi import ...` 用法不变;旧的模块路径仍可用,并会给出警告。 +- **每个算子的 API 参考都由生成器生成**,缺文档、或已提交的文档与生成器输出不一致,测试都会失败。 -本版本的完整说明:[`docs/updates/2.2.6.post1.zh-CN.md`](docs/updates/2.2.6.post1.zh-CN.md)。 +本版本的完整说明:[`docs/updates/2.2.6.post2.zh-CN.md`](docs/updates/2.2.6.post2.zh-CN.md)。 更早的版本:见[更新日志](CHANGELOG.md)和 [`docs/updates/`](docs/updates/)。 --- @@ -162,7 +160,7 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 SimpleCADAPI 是一个基于 OCP 的 Python CAD SDK,提供清晰的函数式建模操作和可重放的模型图。它在 OpenCascade 几何内核之上提供精简的公共 API,可用于创建实体、应用特征、添加语义标签、查询拓扑、导出制造文件,以及将记录的模型转换为 FreeCAD 工作流。 -当前版本:`simplecadapi==2.2.6.post1`。 +当前版本:`simplecadapi==2.2.6.post2`。 ## 核心能力 @@ -588,6 +586,7 @@ uv run --extra fem python examples/ap242_gmsh_volume_mesh/run_calculix.py ## 文档 +- 2.2.6.post2 更新说明:[`docs/updates/2.2.6.post2.zh-CN.md`](docs/updates/2.2.6.post2.zh-CN.md) - 2.2.6.post1 更新说明:[`docs/updates/2.2.6.post1.zh-CN.md`](docs/updates/2.2.6.post1.zh-CN.md) - 2.2.6 更新说明:[`docs/updates/2.2.6.zh-CN.md`](docs/updates/2.2.6.zh-CN.md) - 2.2.5 更新说明:[`docs/updates/2.2.5.zh-CN.md`](docs/updates/2.2.5.zh-CN.md) diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index ee641c37..b687e293 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -4,9 +4,9 @@ description: Build, assemble, inspect, reconstruct, and export parametric CAD mo license: Apache-2.0 metadata: project: simplecadapi - version: 2.2.6.post1 + version: 2.2.6.post2 package-name: simplecadapi - package-version: 2.2.6.post1 + package-version: 2.2.6.post2 --- # SimpleCAD SDK Skill diff --git a/docs/skill/references/docs/api/NotebookRun.md b/docs/skill/references/docs/api/NotebookRun.md index ba4dfd3e..1e7dc631 100644 --- a/docs/skill/references/docs/api/NotebookRun.md +++ b/docs/skill/references/docs/api/NotebookRun.md @@ -3,7 +3,7 @@ ## Class Definition ```python -class NotebookRun(config: NotebookConfig, product: Product, values: Mapping[str, Any], cells: tuple[CellReport, ...], dependencies: frozenset[Path]) +class NotebookRun(config: NotebookConfig, product: Product | None, values: Mapping[str, Any], cells: tuple[CellReport, ...], dependencies: frozenset[Path]) ``` *Source: runtime/runner.py* @@ -17,6 +17,7 @@ class NotebookRun(config: NotebookConfig, product: Product, values: Mapping[str, The outcome of :func:`run_notebook`. ``product`` is the notebook's product and ``definition`` its durable -definition; ``values`` holds every top-level variable, ``cells`` one -report per cell in notebook order, and ``dependencies`` the files -outside the cells the run depends on. +definition (``None`` for a partial run — see ``through``/``product``); +``values`` holds every top-level variable, ``cells`` one report per cell +in notebook order, and ``dependencies`` the files outside the cells the +run depends on. diff --git a/docs/skill/references/docs/api/README.md b/docs/skill/references/docs/api/README.md index 565c2f9f..fcc2d5e6 100644 --- a/docs/skill/references/docs/api/README.md +++ b/docs/skill/references/docs/api/README.md @@ -77,9 +77,12 @@ This index includes generated docs for the public SimpleCAD API surface, includi ## 3D Operations - [extrude_rsolid](extrude_rsolid.md) *(from operators/features.py)* `top-level` +- [extrude_taper_rsolid](extrude_taper_rsolid.md) *(from operators/features.py)* `top-level` +- [extrude_until_rsolid](extrude_until_rsolid.md) *(from operators/features.py)* `top-level` - [loft_rshell](loft_rshell.md) *(from operators/geometry.py)* `top-level` - [loft_rsolid](loft_rsolid.md) *(from operators/features.py)* `top-level` - [revolve_rsolid](revolve_rsolid.md) *(from operators/features.py)* `top-level` +- [sweep_multisection_rsolid](sweep_multisection_rsolid.md) *(from operators/features.py)* `top-level` - [sweep_rsolid](sweep_rsolid.md) *(from operators/features.py)* `top-level` ## Tagging and Selection @@ -345,6 +348,7 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [add_revolute_constraint_rassembly](add_revolute_constraint_rassembly.md) *(from operators/product.py)* `top-level` - [and_](and_.md) *(from ql.py)* `submodule:ql` - [assign_material_rpart](assign_material_rpart.md) *(from operators/product.py)* `top-level` +- [boolean_rsolidlist](boolean_rsolidlist.md) *(from operators/boolean.py)* `top-level` - [canonical_frame](canonical_frame.md) *(from product/placement.py)* `submodule:product/placement` - [capture](capture.md) *(from product/capture.py)* `top-level` - [constrain_angle_rsketch](constrain_angle_rsketch.md) *(from operators/sketch.py)* `top-level` @@ -377,6 +381,7 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [constrain_symmetric_rsketch](constrain_symmetric_rsketch.md) *(from operators/sketch.py)* `top-level` - [constrain_tangent_rsketch](constrain_tangent_rsketch.md) *(from operators/sketch.py)* `top-level` - [constrain_vertical_rsketch](constrain_vertical_rsketch.md) *(from operators/sketch.py)* `top-level` +- [draft_rsolid](draft_rsolid.md) *(from operators/features.py)* `top-level` - [explain_tag](explain_tag.md) *(from operators/selection.py)* `top-level` - [fill_holes_rshell](fill_holes_rshell.md) *(from operators/geometry.py)* `top-level` - [fit_point_grid_rface](fit_point_grid_rface.md) *(from operators/geometry.py)* `top-level` @@ -394,6 +399,7 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [measure_constraint_residual_rconstraintresidual](measure_constraint_residual_rconstraintresidual.md) *(from operators/product.py)* `top-level` - [meta](meta.md) *(from ql.py)* `submodule:ql` - [not_](not_.md) *(from ql.py)* `submodule:ql` +- [offset_rsolid](offset_rsolid.md) *(from operators/features.py)* `top-level` - [operation_event](operation_event.md) *(from ql.py)* `submodule:ql` - [or_](or_.md) *(from ql.py)* `submodule:ql` - [origin_role](origin_role.md) *(from ql.py)* `submodule:ql` @@ -404,6 +410,7 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [placement_ticks](placement_ticks.md) *(from product/placement.py)* `submodule:product/placement` - [radial_pattern_rsolidlist](radial_pattern_rsolidlist.md) *(from operators/transform.py)* `top-level` - [render_screenshot_rpath](render_screenshot_rpath.md) *(from operators/features.py)* `top-level` +- [scale_rsolid](scale_rsolid.md) *(from operators/transform.py)* `top-level` - [select](select.md) *(from ql.py)* `submodule:ql` - [set_public_connector_rassembly](set_public_connector_rassembly.md) *(from operators/product.py)* `top-level` - [sew_faces_rshell](sew_faces_rshell.md) *(from operators/geometry.py)* `top-level` @@ -412,6 +419,7 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [source_binding](source_binding.md) *(from ql.py)* `submodule:ql` - [source_topology](source_topology.md) *(from ql.py)* `submodule:ql` - [tag](tag.md) *(from ql.py)* `submodule:ql` +- [transform_rsolid](transform_rsolid.md) *(from operators/transform.py)* `top-level` - [trim_surface_rface](trim_surface_rface.md) *(from operators/geometry.py)* `top-level` - [twisted_sweep_rsolid](twisted_sweep_rsolid.md) *(from operators/features.py)* `top-level` - [unground_component_rassembly](unground_component_rassembly.md) *(from operators/product.py)* `top-level` diff --git a/docs/skill/references/docs/api/add_bspline_rsketch.md b/docs/skill/references/docs/api/add_bspline_rsketch.md index 60b67708..8fadc436 100644 --- a/docs/skill/references/docs/api/add_bspline_rsketch.md +++ b/docs/skill/references/docs/api/add_bspline_rsketch.md @@ -17,4 +17,6 @@ def add_bspline_rsketch(sketch: Sketch, entity_id: str, start: Union[SketchRef, Add a B-spline curve entity to a sketch. The start/end point refs link the B-spline into a closed profile -loop. Control points may be literal 2-D coordinates or point refs. +loop. A closed curve may reuse one seam ref for both endpoints. +Control points may be literal 2-D coordinates or point refs. A periodic +seam links topology without replacing the periodic curve's poles. diff --git a/docs/skill/references/docs/api/boolean_rsolidlist.md b/docs/skill/references/docs/api/boolean_rsolidlist.md new file mode 100644 index 00000000..b0dec396 --- /dev/null +++ b/docs/skill/references/docs/api/boolean_rsolidlist.md @@ -0,0 +1,45 @@ +# boolean_rsolidlist + +## API Definition + +```python +def boolean_rsolidlist(left: Sequence[Solid], right: Sequence[Solid], operation: str) -> list[Solid] +``` + +*Source: operators/boolean.py* + +## Import Surface + +- top-level: `from simplecadapi import boolean_rsolidlist` + +## Description + +Apply a set boolean to two body groups, retaining every result solid. + +Members of each group are a geometric set. Unlike single-solid booleans, +a cut or intersection may return several solids or an empty list. +Topology lineage is partial; the ordered inputs and all output slots are +recorded so downstream features can replay the same geometry. + +## Parameters + +### left + +- **Description**: The first group of solids. + +### right + +- **Description**: The second group of solids. + +### operation + +- **Description**: ``"cut"``, ``"intersect"`` or ``"union"``. + +## Returns + +The result solids, possibly several or none. + +## Raises + +- **TypeError**: A group contains something that is not a Solid. +- **ValueError**: ``operation`` is unknown, or a member is invalid or empty. diff --git a/docs/skill/references/docs/api/draft_rsolid.md b/docs/skill/references/docs/api/draft_rsolid.md index 3f08e272..cc5278c8 100644 --- a/docs/skill/references/docs/api/draft_rsolid.md +++ b/docs/skill/references/docs/api/draft_rsolid.md @@ -1,39 +1,69 @@ # draft_rsolid +## API Definition + ```python -def draft_rsolid( - solid: Solid, - faces_to_draft: Sequence[Face] | ShapeSelector, - angle: ScalarLike, - plane_origin: Sequence[float] = (0, 0, 0), - plane_normal: Sequence[float] = (0, 0, 1), -) -> Solid +def draft_rsolid(solid: Solid, faces_to_draft: Sequence[Face] | ShapeSelector, angle: ScalarLike, plane_origin: Sequence[float] = (0, 0, 0), plane_normal: Sequence[float] = (0, 0, 1)) -> Solid ``` -*Source: operators/draft.py* +*Source: operators/features.py* + +## Import Surface + +- top-level: `from simplecadapi import draft_rsolid` + +## Description + +Draft planar, cylindrical or conical faces by a signed angle in degrees. + +The neutral plane fixes the unchanged section: the selected faces lean +toward or away from it. Its origin and normal are in current +coordinate-system coordinates. Face selection is checked against the input +body and retained as graph inputs for strict replay. The input solid is +preserved, the output must be one valid solid, and a zero angle records an +unchanged result. Other surface families are explicitly rejected. + +The operation is recorded as ``make_draft_rsolid``; the body and selected +faces are ordered inputs, and the angle, neutral plane and face selection +nodes are parameters, so model JSON replay is strict. + +## Parameters + +### solid + +- **Description**: The body to draft. + +### faces_to_draft + +- **Description**: Faces of ``solid``, or a QL selector that picks them. -Import with `from simplecadapi import draft_rsolid`. +### angle -Apply a signed draft angle in degrees to selected planar, cylindrical, or -conical faces. The neutral plane retains its original section while the selected -faces lean toward or away from it. Its origin and normal use the current -coordinate system. The input solid is preserved, and the output must be one -valid solid. A zero angle records an unchanged result. +- **Description**: Draft angle in degrees, strictly between -90 and 90. + +### plane_origin + +- **Description**: A point of the neutral plane. + +### plane_normal + +- **Description**: Normal of the neutral plane; must be nonzero. + +## Returns + +The drafted solid. + +## Raises + +- **TypeError**: ``solid`` is not a Solid. +- **ValueError**: Empty selection, a selection that is not made of faces of ``solid``, an unsupported surface family, a zero plane normal, or a nonfinite or out-of-range angle. + +## Examples ```python import simplecadapi as scad from simplecadapi import ql - body = scad.make_box_rsolid(10, 10, 10) right = ql.faces().where(ql.prop("geom.normal.x", ">", 0.9)).exactly(1) drafted = scad.draft_rsolid(body, right, angle=10) ``` - -Selections may be explicit faces from the body or a QL selector. Empty -selections, faces from another body, unsupported surface families, a zero plane -normal, and nonfinite parameters are rejected. The angle must be strictly -between -90 and 90 degrees. Kernel failures and invalid topology raise errors. - -The operation `make_draft_rsolid` records the body and selected faces as ordered -inputs. Parameters preserve the angle, neutral plane, and face selection nodes -for strict model JSON replay. diff --git a/docs/skill/references/docs/api/extrude_taper_rsolid.md b/docs/skill/references/docs/api/extrude_taper_rsolid.md index c1422ecc..4e9820ac 100644 --- a/docs/skill/references/docs/api/extrude_taper_rsolid.md +++ b/docs/skill/references/docs/api/extrude_taper_rsolid.md @@ -1,13 +1,51 @@ # extrude_taper_rsolid +## API Definition + ```python -def extrude_taper_rsolid(profile: Face, vector: Sequence[float], - taper: float, flip_inner: bool = True) -> Solid +def extrude_taper_rsolid(profile: Face, vector: Sequence[float], taper: float, flip_inner: bool = True) -> Solid ``` -Extrude a planar sketch face by a nonzero vector, in current coordinate-system -coordinates. The finite taper angle is in degrees and must lie strictly between --90 and 90. Positive taper shrinks the outer section; inner loops taper in the -opposite direction when `flip_inner=True`. Uses an analytic draft prism where -applicable and exact offset sections with lofts otherwise. Raises on collapsed -loops or invalid topology. Records `make_extrude_taper_rsolid` for replay. +*Source: operators/features.py* + +## Import Surface + +- top-level: `from simplecadapi import extrude_taper_rsolid` + +## Description + +Extrude a planar sketch face by a vector, with a taper in degrees. + +The vector is nonzero and in current coordinate-system coordinates. The +finite taper angle lies strictly between -90 and 90; a positive taper +shrinks the outer section, and inner loops taper the opposite way when +``flip_inner`` is true. An analytic draft prism is used where applicable +and exact offset sections with lofts otherwise. Collapsed loops and invalid +topology raise errors. The operation is recorded as +``make_extrude_taper_rsolid`` for replay. + +## Parameters + +### profile + +- **Description**: The planar face to extrude. + +### vector + +- **Description**: Extrusion vector (nonzero). + +### taper + +- **Description**: Taper angle in degrees, strictly between -90 and 90. + +### flip_inner + +- **Description**: Taper inner loops in the opposite direction. + +## Returns + +The tapered solid. + +## Raises + +- **ValueError**: A zero vector, an out-of-range taper, a nonplanar profile, collapsed loops or an invalid result. diff --git a/docs/skill/references/docs/api/extrude_until_rsolid.md b/docs/skill/references/docs/api/extrude_until_rsolid.md index dfa02468..18f073e2 100644 --- a/docs/skill/references/docs/api/extrude_until_rsolid.md +++ b/docs/skill/references/docs/api/extrude_until_rsolid.md @@ -1,53 +1,75 @@ # extrude_until_rsolid +## API Definition + ```python -def extrude_until_rsolid( - profile: Face, - target: Solid | Sequence[Solid], - direction: Sequence[float] = (0, 0, 1), - until: str = "NEXT", -) -> Solid +def extrude_until_rsolid(profile: Face, target: Solid | Sequence[Solid], direction: Sequence[float] = (0, 0, 1), until: str = 'NEXT') -> Solid ``` -*Source: operators/limited_extrusion.py* +*Source: operators/features.py* + +## Import Surface + +- top-level: `from simplecadapi import extrude_until_rsolid` + +## Description + +Extrude a planar face until a target surface, including curved surfaces. + +The direction is interpreted in the current coordinate system and +normalized before construction. ``until`` selects the termination: +``NEXT`` (forward, nearest intersecting target surface), ``LAST`` (forward, +farthest), ``PREVIOUS`` (reverse, nearest) and ``FIRST`` (reverse, +farthest). The temporary prism is intersected with the target, the target's +boundary faces are taken from kernel history and sewn into surfaces, and +the prism is split on the selected one. Bounding boxes only size the +temporary prism; they do not define the termination surface, and side +walls parallel to the direction do not join separate near and far limits. + +Targets must cover the profile at the requested contact: an uncovered, +open or disconnected output is rejected, as are a missing contact, an +invalid mode, a zero or nonfinite direction and an empty target sequence. +Inputs are preserved. The operation is recorded as +``make_extrude_until_rsolid``; its ordered inputs are the profile followed +by every target solid and its parameters keep ``direction``, ``until`` and +``target_count``, so a changed target is recomputed on replay (no final +BRep is embedded). + +## Parameters + +### profile + +- **Description**: The planar face to extrude. + +### target -Import with `from simplecadapi import extrude_until_rsolid`. +- **Description**: A solid, or a sequence of solids, that bounds the extrusion. -Extrude a planar face until it reaches an actual boundary on one or more target -solids. Planar and curved termination surfaces are supported. The direction is -interpreted in the current coordinate system and normalized before construction. +### direction -| Mode | Direction | Termination | -| --- | --- | --- | -| `NEXT` | Forward | Nearest intersecting target surface | -| `LAST` | Forward | Farthest intersecting target surface | -| `PREVIOUS` | Reverse | Nearest intersecting target surface | -| `FIRST` | Reverse | Farthest intersecting target surface | +- **Description**: Extrusion direction in current coordinate-system coordinates. -The implementation intersects a temporary prism with the target, obtains target -boundary faces from kernel history, sews the contact faces into surfaces, and -splits the prism on the selected surface. Bounding boxes only determine a -sufficient temporary prism length; they do not define the termination surface. -Side walls parallel to the direction do not join separate near and far limits. +### until + +- **Description**: ``"NEXT"``, ``"LAST"``, ``"PREVIOUS"`` or ``"FIRST"``. + +## Returns + +One valid solid. + +## Raises + +- **TypeError**: ``profile`` is not a Face, or ``target`` is not a Solid or a sequence of Solid. +- **ValueError**: No target, no contact, insufficient coverage, a bad mode or direction, or an invalid kernel result. + +## Examples ```python import simplecadapi as scad - profile = scad.make_rectangle_rface(2, 2) ceiling = scad.make_box_rsolid(10, 10, 2, bottom_face_center=(0, 0, 5)) -near = scad.extrude_until_rsolid(profile, ceiling, until="NEXT") # Volume 20 -far = scad.extrude_until_rsolid(profile, ceiling, until="LAST") # Volume 28 - +near = scad.extrude_until_rsolid(profile, ceiling, until="NEXT") # volume 20 +far = scad.extrude_until_rsolid(profile, ceiling, until="LAST") # volume 28 sphere = scad.make_sphere_rsolid(5, center=(0, 0, 10)) curved = scad.extrude_until_rsolid(profile, sphere) ``` - -Targets must cover the profile at the requested contact. Missing contact, -insufficient coverage, an invalid mode, a zero/nonfinite direction, and invalid -kernel output raise errors. An empty target sequence is rejected. A successful -result is one valid solid; inputs are preserved. - -The recorded operation is `make_extrude_until_rsolid`. Its ordered inputs are the -profile followed by every target solid. Parameters retain `direction`, `until`, -and `target_count`, so model JSON replay recomputes the boundary when a target -changes. No final BRep is embedded in the feature. diff --git a/docs/skill/references/docs/api/offset_rsolid.md b/docs/skill/references/docs/api/offset_rsolid.md index 7cb20429..bb94b813 100644 --- a/docs/skill/references/docs/api/offset_rsolid.md +++ b/docs/skill/references/docs/api/offset_rsolid.md @@ -1,15 +1,55 @@ # offset_rsolid +## API Definition + ```python -def offset_rsolid(solid: Solid, amount: ScalarLike, - openings: Sequence[Face] | ShapeSelector = (), *, - tolerance: float = 1e-4, kind: str = "ARC") -> Solid +def offset_rsolid(solid: Solid, amount: ScalarLike, openings: Sequence[Face] | ShapeSelector = (), *, tolerance: float = 0.0001, kind: str = 'ARC') -> Solid ``` -Positive `amount` offsets outward; negative offsets inward. The amount must -be finite and nonzero. QL-selected `openings` form an open shell. With no -openings, produce a closed offset solid. Join `kind` is `ARC` or -`INTERSECTION`. Invalid kernel results raise an error. Records a replayable -`make_offset_rsolid` node with explicit face selections; topology tag lineage -is conservatively partial. `shell_rsolid` retains its existing positive -inward wall-thickness contract. +*Source: operators/features.py* + +## Import Surface + +- top-level: `from simplecadapi import offset_rsolid` + +## Description + +Offset a solid outward (positive) or inward (negative). + +The amount must be finite and nonzero. Faces passed as ``openings`` form an +open shell; with no openings the result is the closed offset solid. The +join ``kind`` is ``ARC`` or ``INTERSECTION``. Invalid kernel results raise +an error. The operation is recorded as ``make_offset_rsolid`` with explicit +face selections; topology tag lineage is conservatively partial. +``shell_rsolid`` keeps its own contract (a positive inward wall thickness); +this one supports both signs. + +## Parameters + +### solid + +- **Description**: The solid to offset. + +### amount + +- **Description**: Offset distance; positive outward, negative inward. + +### openings + +- **Description**: Faces of ``solid`` (or a QL selector) left open. + +### tolerance + +- **Description**: Kernel tolerance of the offset. + +### kind + +- **Description**: Join type, ``"ARC"`` or ``"INTERSECTION"``. + +## Returns + +The offset solid. + +## Raises + +- **ValueError**: ``amount`` is zero or not finite, ``tolerance`` is not finite and positive, ``kind`` is unknown, ``openings`` do not select faces, or the kernel produced an invalid result. diff --git a/docs/skill/references/docs/api/run_notebook.md b/docs/skill/references/docs/api/run_notebook.md index 86b2f12d..5afd1dda 100644 --- a/docs/skill/references/docs/api/run_notebook.md +++ b/docs/skill/references/docs/api/run_notebook.md @@ -3,7 +3,7 @@ ## API Definition ```python -def run_notebook(path: str | Path, *, id: str | None = None, overrides: Mapping[str, Any] | None = None, cache: bool = True) -> NotebookRun +def run_notebook(path: str | Path, *, id: str | None = None, overrides: Mapping[str, Any] | None = None, cache: bool = True, through: int | None = None, product: bool = True, timeout: float | None = None) -> NotebookRun ``` *Source: runtime/runner.py* @@ -22,3 +22,9 @@ value with that id, and cells read it with ``notebook_id()``. not run (marimo requires such a cell's variables to be overridden all together). ``cache=False`` neither reads nor writes the cell cache. Errors raised by a cell propagate unchanged. + +*through* runs only the first *through* cells, in notebook order — the +validation of a stage the tail does not have to exist for. With +``product=False`` the run does not look for the notebook's product at +all. *timeout* (seconds) fails the run with :class:`TimeoutError` when +it takes longer; ``None`` (the default) runs unbounded. diff --git a/docs/skill/references/docs/api/scale_rsolid.md b/docs/skill/references/docs/api/scale_rsolid.md index 61c156bc..dd23cc46 100644 --- a/docs/skill/references/docs/api/scale_rsolid.md +++ b/docs/skill/references/docs/api/scale_rsolid.md @@ -1,11 +1,45 @@ # scale_rsolid +## API Definition + ```python def scale_rsolid(solid: Solid, factors: Sequence[float], center: Sequence[float] = (0, 0, 0)) -> Solid ``` -Scale a solid along the current coordinate system's axes about `center`. -The three factors must be finite and nonzero. Uniform scaling preserves -analytic curves and surfaces; nonuniform scaling uses the CAD kernel's -general affine transform. The source stays unchanged. Records a replayable -`make_scale_rsolid` operation. Topology tag lineage is conservatively partial. +*Source: operators/transform.py* + +## Import Surface + +- top-level: `from simplecadapi import scale_rsolid` + +## Description + +Scale a solid along the current coordinate system's axes about a center. + +The three factors must be finite and nonzero. A uniform scale preserves +analytic curves and surfaces; a nonuniform scale uses the CAD kernel's +general affine transform. The source solid stays unchanged. The operation +is recorded as ``make_scale_rsolid`` for graph replay; topology tag lineage +is conservatively partial. + +## Parameters + +### solid + +- **Description**: The solid to scale. + +### factors + +- **Description**: Scale factors along the x, y and z axes of the current coordinate system; finite and nonzero. + +### center + +- **Description**: The fixed point of the scaling, in current coordinate-system coordinates. + +## Returns + +The scaled solid. + +## Raises + +- **ValueError**: A factor is zero or not finite, or the kernel could not scale the solid. diff --git a/docs/skill/references/docs/api/sweep_multisection_rsolid.md b/docs/skill/references/docs/api/sweep_multisection_rsolid.md index 4fc25630..9d743b26 100644 --- a/docs/skill/references/docs/api/sweep_multisection_rsolid.md +++ b/docs/skill/references/docs/api/sweep_multisection_rsolid.md @@ -1,12 +1,51 @@ # sweep_multisection_rsolid +## API Definition + ```python -def sweep_multisection_rsolid(profiles: Sequence[Face | Wire], path: Wire, - is_frenet: bool = False, binormal: Sequence[float] | Wire | None = None) -> Solid +def sweep_multisection_rsolid(profiles: Sequence[Face | Wire], path: Wire, is_frenet: bool = False, binormal: Sequence[float] | Wire | None = None) -> Solid ``` -Sweep through at least two ordered sections along a continuous path. Face -sections supply their outer wire, matching build123d's multi-section sweep -contract. Binormal is an optional direction or auxiliary spine. The kernel -must produce one valid solid. Every section and path is recorded as an input -of `make_sweep_multisection_rsolid`, so model JSON preserves the construction. +*Source: operators/features.py* + +## Import Surface + +- top-level: `from simplecadapi import sweep_multisection_rsolid` + +## Description + +Sweep through explicit ordered sections along a continuous path. + +At least two ordered sections are needed. Face sections supply their outer +wire, matching build123d's multi-section sweep contract. ``binormal`` is an +optional direction vector or an auxiliary spine. The kernel must produce +one valid solid. Every section and the path are recorded as inputs of +``make_sweep_multisection_rsolid``, so model JSON preserves the +construction. + +## Parameters + +### profiles + +- **Description**: Ordered section faces or wires (at least two). + +### path + +- **Description**: The continuous path wire. + +### is_frenet + +- **Description**: Use the Frenet frame along the path. + +### binormal + +- **Description**: A direction, or an auxiliary spine wire, that fixes the section orientation. + +## Returns + +The swept solid. + +## Raises + +- **TypeError**: A section is neither a Face nor a Wire. +- **ValueError**: Fewer than two sections, a zero ``binormal`` direction, or the kernel could not make a solid. diff --git a/docs/skill/references/docs/api/transform_rsolid.md b/docs/skill/references/docs/api/transform_rsolid.md index fba928a7..228ab42a 100644 --- a/docs/skill/references/docs/api/transform_rsolid.md +++ b/docs/skill/references/docs/api/transform_rsolid.md @@ -1,10 +1,40 @@ # transform_rsolid +## API Definition + ```python def transform_rsolid(solid: Solid, matrix: Sequence[Sequence[float]]) -> Solid ``` -Apply a finite nonsingular 4x4 affine matrix in the current coordinate system, -including shear, reflection and nonuniform scale. Reject perspective and -singular matrices. Input geometry stays unchanged. Records a replayable -`make_transform_rsolid` operation; topology tag lineage is partial. +*Source: operators/transform.py* + +## Import Surface + +- top-level: `from simplecadapi import transform_rsolid` + +## Description + +Apply a finite nonsingular 4x4 affine matrix in the current coordinate system. + +The matrix may include shear, reflection and nonuniform scale. Perspective +matrices and singular maps cannot define a solid and are rejected. The +input geometry stays unchanged. The operation is recorded as +``make_transform_rsolid`` for graph replay; topology tag lineage is partial. + +## Parameters + +### solid + +- **Description**: The solid to transform. + +### matrix + +- **Description**: A 4x4 affine matrix (row-major, last row ``0 0 0 1``), finite and nonsingular, in current coordinate-system coordinates. + +## Returns + +The transformed solid. + +## Raises + +- **ValueError**: The matrix is not a finite 4x4 affine matrix, is singular or a perspective map, or the kernel could not transform the solid. diff --git a/docs/updates/2.2.6.post2.md b/docs/updates/2.2.6.post2.md new file mode 100644 index 00000000..024cf91e --- /dev/null +++ b/docs/updates/2.2.6.post2.md @@ -0,0 +1,27 @@ +# 2.2.6.post2 Update Notes + +No change in what the SDK computes. Eight operators moved to the module of their family, and +the API reference they are documented in is now generated for all of them. + +## Operators live with their family + +| operator | now in | +|---|---| +| `scale_rsolid`, `transform_rsolid` | `operators/transform.py` (next to `translate_shape`, `rotate_shape`) | +| `boolean_rsolidlist` | `operators/boolean.py` (next to `union_rsolid`, `cut_rsolid`) | +| `offset_rsolid`, `draft_rsolid`, `extrude_taper_rsolid`, `extrude_until_rsolid`, `sweep_multisection_rsolid` | `operators/features.py` (next to `extrude_rsolid`, `shell_rsolid`, `sweep_rsolid`) | + +`from simplecadapi import scale_rsolid` (and every other name) works as before. Code that +imported them from the modules they were first written in, `simplecadapi.operators.deformation`, +`draft`, `section_features` or `limited_extrusion`, still works and gets a `DeprecationWarning` +naming the new place; import from `simplecadapi` instead. + +## The API reference is generated for every operator + +The documentation generator kept its own hand-written list of source files and public +functions, and nobody had added those four modules to it. The docs of the eight operators above +were therefore written by hand, and the generator deleted them whenever it ran, while +`boolean_rsolidlist` had none. Now `operators/` is discovered, a function the package exports is +public, and what the hand-written docs said (examples, error conditions, how the operation is +replayed) is in the docstrings, so it is in the generated docs. Tests fail if a public operator +has no doc, or if the committed docs differ from what the generator writes. diff --git a/docs/updates/2.2.6.post2.zh-CN.md b/docs/updates/2.2.6.post2.zh-CN.md new file mode 100644 index 00000000..7ceda311 --- /dev/null +++ b/docs/updates/2.2.6.post2.zh-CN.md @@ -0,0 +1,22 @@ +# 2.2.6.post2 更新说明 + +SDK 的计算结果没有变化。八个算子搬进了各自所属家族的模块,并且它们所在的 API 参考现在全部由生成器生成。 + +## 算子放在自己家族的模块里 + +| 算子 | 现在在 | +|---|---| +| `scale_rsolid`、`transform_rsolid` | `operators/transform.py`(和 `translate_shape`、`rotate_shape` 在一起) | +| `boolean_rsolidlist` | `operators/boolean.py`(和 `union_rsolid`、`cut_rsolid` 在一起) | +| `offset_rsolid`、`draft_rsolid`、`extrude_taper_rsolid`、`extrude_until_rsolid`、`sweep_multisection_rsolid` | `operators/features.py`(和 `extrude_rsolid`、`shell_rsolid`、`sweep_rsolid` 在一起) | + +`from simplecadapi import scale_rsolid`(以及其他所有名字)用法不变。原先从它们最初所在的模块 +`simplecadapi.operators.deformation`、`draft`、`section_features`、`limited_extrusion` 导入的代码仍然可用, +会得到一条指出新位置的 `DeprecationWarning`;请改为从 `simplecadapi` 导入。 + +## 每个算子的 API 参考都由生成器生成 + +文档生成器自己维护一份手写的源文件和公开函数清单,没人把那四个模块加进去。于是上面八个算子的文档是手写的, +生成器每次运行都会把它们删掉,`boolean_rsolidlist` 则根本没有文档。现在 `operators/` 自动发现,包导出的函数就是公开 API, +手写文档里的内容(示例、错误条件、操作如何回放)已经进了 docstring,也就进了生成的文档。 +公开算子缺文档、或已提交的文档与生成器输出不一致,测试都会失败。 diff --git a/pyproject.toml b/pyproject.toml index 527ee33e..35089156 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simplecadapi" -version = "2.2.6.post1" +version = "2.2.6.post2" description = "A simplified OCP-native CAD modeling Python API" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/simplecadapi/__init__.py b/src/simplecadapi/__init__.py index 16769cd4..09d2fa47 100644 --- a/src/simplecadapi/__init__.py +++ b/src/simplecadapi/__init__.py @@ -69,10 +69,15 @@ get_current_cs, ) from .errors import SimpleCADError -from .operators.deformation import scale_rsolid, offset_rsolid, transform_rsolid, boolean_rsolidlist -from .operators.section_features import extrude_taper_rsolid, sweep_multisection_rsolid -from .operators.limited_extrusion import extrude_until_rsolid -from .operators.draft import draft_rsolid +from .operators.boolean import boolean_rsolidlist +from .operators.features import ( + draft_rsolid, + extrude_taper_rsolid, + extrude_until_rsolid, + offset_rsolid, + sweep_multisection_rsolid, +) +from .operators.transform import scale_rsolid, transform_rsolid from .params.expr import ( Const, DimensionTolerance, @@ -437,14 +442,6 @@ def notebook_id() -> str: __all__ = [ - "scale_rsolid", - "boolean_rsolidlist", - "transform_rsolid", - "offset_rsolid", - "extrude_taper_rsolid", - "sweep_multisection_rsolid", - "extrude_until_rsolid", - "draft_rsolid", "exporter", # 核心类 "CoordinateSystem", @@ -585,12 +582,19 @@ def notebook_id() -> str: # 变换操作 "mirror_shape", "rotate_shape", + "scale_rsolid", + "transform_rsolid", "translate_shape", # 3D操作 + "draft_rsolid", "extrude_rsolid", + "extrude_taper_rsolid", + "extrude_until_rsolid", "helical_sweep_rsolid", "loft_rsolid", + "offset_rsolid", "revolve_rsolid", + "sweep_multisection_rsolid", "sweep_rsolid", "twisted_sweep_rsolid", # 标签和选择 @@ -601,6 +605,7 @@ def notebook_id() -> str: "select_edges_by_tag", "select_faces_by_tag", # 布尔运算 + "boolean_rsolidlist", "cut_rsolid", "make_2d_cut_rface", "intersect_rsolid", diff --git a/src/simplecadapi/operators/__init__.py b/src/simplecadapi/operators/__init__.py index 4f3299d8..9942bbba 100644 --- a/src/simplecadapi/operators/__init__.py +++ b/src/simplecadapi/operators/__init__.py @@ -16,7 +16,7 @@ import sys as _sys from types import ModuleType as _ModuleType -from . import _support, geometry, transform, boolean, product, sketch, selection, features, deformation, section_features, limited_extrusion, draft +from . import _support, geometry, transform, boolean, product, sketch, selection, features from ._support import * from .geometry import * from .transform import * @@ -25,10 +25,6 @@ from .sketch import * from .selection import * from .features import * -from .deformation import * -from .section_features import * -from .limited_extrusion import * -from .draft import * _IMPLEMENTATION_MODULES = ( _support, @@ -39,10 +35,6 @@ sketch, selection, features, - deformation, - section_features, - limited_extrusion, - draft, ) __all__ = tuple( diff --git a/src/simplecadapi/operators/_support.py b/src/simplecadapi/operators/_support.py index c5c1576e..aac07c00 100644 --- a/src/simplecadapi/operators/_support.py +++ b/src/simplecadapi/operators/_support.py @@ -42,6 +42,8 @@ suppress_vendor_deprecation_warnings() from OCP.BRepCheck import BRepCheck_Analyzer +from OCP.BRepGProp import BRepGProp +from OCP.GProp import GProp_GProps from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex @@ -2250,4 +2252,32 @@ def _apply_tag_rselection( _EXPORTABLE_TYPES = (Compound, Solid, Shell, Face, Wire, Edge, Vertex) + +def _finite_vector(value: Sequence[float], name: str) -> tuple[float, float, float]: + values = tuple(float(v) for v in evaluate_value(value)) + if len(values) != 3 or not all(math.isfinite(v) for v in values): + raise ValueError(f"{name} must contain three finite numbers") + return values + + +def _finish(source: Solid, wrapped, op: str, params: dict, inputs: list) -> Solid: + solids = solids_of(wrapped) + if len(solids) != 1: + raise ValueError(f"{op} must produce exactly one solid, got {len(solids)}") + result = Solid(solids[0]) + props = GProp_GProps() + BRepGProp.VolumePropertiesGK_s(result.wrapped, props, 1e-9, True, True) + if props.Mass() < 0: + result.wrapped.Reverse() + if not BRepCheck_Analyzer(result.wrapped).IsValid(): + raise ValueError(f"{op} produced invalid geometry") + result._metadata = source._metadata.copy() + # These builders do not yet provide a complete face-history channel. + _attach_lineage_from_source(source, result, derivation="continuation", op=op, coverage="partial") + _ensure_geo_selection_input_nodes(inputs) + params = {**params, "input_refs": _serialize_shape_refs(inputs)} + record_operation_if_active(op=op, params=params, outputs=result, + input_shapes=inputs, context=_current_context_metadata()) + return result + __all__ = tuple(name for name in globals() if not name.startswith("__")) diff --git a/src/simplecadapi/operators/boolean.py b/src/simplecadapi/operators/boolean.py index cf438713..a5a335eb 100644 --- a/src/simplecadapi/operators/boolean.py +++ b/src/simplecadapi/operators/boolean.py @@ -696,4 +696,63 @@ def make_2d_intersect_rface(face_a: Face, face_b: Face) -> Face: error=e, ) + +def boolean_rsolidlist( + left: Sequence[Solid], + right: Sequence[Solid], + operation: str, +) -> list[Solid]: + """Apply a set boolean to two body groups, retaining every result solid. + + Members of each group are a geometric set. Unlike single-solid booleans, + a cut or intersection may return several solids or an empty list. + Topology lineage is partial; the ordered inputs and all output slots are + recorded so downstream features can replay the same geometry. + + Args: + left: The first group of solids. + right: The second group of solids. + operation: ``"cut"``, ``"intersect"`` or ``"union"``. + + Returns: + The result solids, possibly several or none. + + Raises: + TypeError: A group contains something that is not a Solid. + ValueError: ``operation`` is unknown, or a member is invalid or empty. + """ + from ..kernel.ocp_booleans import cut_shapes, common_shapes, fuse_shapes + from ..kernel.ocp_export import make_compound_always + lhs, rhs = list(left), list(right) + if operation not in {"cut", "intersect", "union"}: + raise ValueError("operation must be cut, intersect or union") + inputs = [*lhs, *rhs] + if any(not isinstance(body, Solid) for body in inputs): + raise TypeError("boolean groups must contain Solid values") + if any(body.wrapped.IsNull() or not BRepCheck_Analyzer(body.wrapped).IsValid() for body in inputs): + raise ValueError("boolean groups must contain valid nonempty topology") + if operation == "union": + shape = fuse_shapes([body.wrapped for body in inputs]) if inputs else None + elif not lhs or (operation == "intersect" and not rhs): + shape = None + elif operation == "cut": + shape = cut_shapes(make_compound_always([body.wrapped for body in lhs]), + [body.wrapped for body in rhs]) + else: + shape = common_shapes([make_compound_always([body.wrapped for body in group]) + for group in (lhs, rhs)]) + if shape is not None and not BRepCheck_Analyzer(shape).IsValid(): + raise ValueError("set boolean produced invalid topology") + result = [Solid(solid) for solid in solids_of(shape)] if shape is not None else [] + for body in result: + for source in inputs: + _attach_lineage_from_source(source, body, derivation="continuation", + op="make_boolean_rsolidlist", coverage="partial") + _ensure_geo_selection_input_nodes(inputs) + record_operation_if_active(op="make_boolean_rsolidlist", + params={"operation": operation, "left_count": len(lhs), "right_count": len(rhs), + "input_refs": _serialize_shape_refs(inputs)}, outputs=result, + input_shapes=inputs, context=_current_context_metadata()) + return result + __all__ = tuple(name for name in globals() if not name.startswith("__")) diff --git a/src/simplecadapi/operators/deformation.py b/src/simplecadapi/operators/deformation.py index 1121be9f..97aa91e6 100644 --- a/src/simplecadapi/operators/deformation.py +++ b/src/simplecadapi/operators/deformation.py @@ -1,217 +1,30 @@ -"""Recorded scaling and signed offsets for CAD source translation.""" -from __future__ import annotations - -import math -from typing import Sequence - -import numpy as np -from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy, BRepBuilderAPI_GTransform, BRepBuilderAPI_Transform -from OCP.BRepCheck import BRepCheck_Analyzer -from OCP.BRepGProp import BRepGProp -from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid -from OCP.GeomAbs import GeomAbs_Arc, GeomAbs_Intersection -from OCP.GProp import GProp_GProps -from OCP.TopTools import TopTools_ListOfShape -from OCP.gp import gp_GTrsf, gp_Pnt, gp_Trsf - -from ._support import ( - Solid, Face, ShapeSelector, ScalarLike, evaluate_scalar, evaluate_value, - get_current_cs, solids_of, record_operation_if_active, - _attach_lineage_from_source, _current_context_metadata, - _ensure_geo_selection_node_ids, _resolve_selector_or_shapes, - _serialize_shape_refs, - _ensure_geo_selection_input_nodes, -) - - -def _finite_vector(value: Sequence[float], name: str) -> tuple[float, float, float]: - values = tuple(float(v) for v in evaluate_value(value)) - if len(values) != 3 or not all(math.isfinite(v) for v in values): - raise ValueError(f"{name} must contain three finite numbers") - return values - - -def _finish(source: Solid, wrapped, op: str, params: dict, inputs: list) -> Solid: - solids = solids_of(wrapped) - if len(solids) != 1: - raise ValueError(f"{op} must produce exactly one solid, got {len(solids)}") - result = Solid(solids[0]) - props = GProp_GProps() - BRepGProp.VolumePropertiesGK_s(result.wrapped, props, 1e-9, True, True) - if props.Mass() < 0: - result.wrapped.Reverse() - if not BRepCheck_Analyzer(result.wrapped).IsValid(): - raise ValueError(f"{op} produced invalid geometry") - result._metadata = source._metadata.copy() - # These builders do not yet provide a complete face-history channel. - _attach_lineage_from_source(source, result, derivation="continuation", op=op, coverage="partial") - _ensure_geo_selection_input_nodes(inputs) - params = {**params, "input_refs": _serialize_shape_refs(inputs)} - record_operation_if_active(op=op, params=params, outputs=result, - input_shapes=inputs, context=_current_context_metadata()) - return result - - -def scale_rsolid( - solid: Solid, - factors: Sequence[float], - center: Sequence[float] = (0, 0, 0), -) -> Solid: - """Scale along current coordinate-system axes about an explicit center. +"""Moved: the operators this module held live in the module of their family (``_MOVED``). - Every factor must be finite and nonzero. A uniform scale preserves analytic - geometry; a nonuniform scale converts curves/surfaces where the kernel needs - it. The input is unchanged and this operation is recorded for graph replay. - """ - values = _finite_vector(factors, "factors") - center_value = _finite_vector(center, "center") - if any(v == 0 for v in values): - raise ValueError("scale factors must be nonzero") - cs = get_current_cs() - global_center = cs.transform_point(np.array(center_value)) - if values[0] == values[1] == values[2]: - transform = gp_Trsf() - transform.SetScale(gp_Pnt(*global_center), values[0]) - builder = BRepBuilderAPI_Transform(solid.wrapped, transform, True) - else: - basis = np.column_stack([cs.transform_vector(np.eye(3)[i]) for i in range(3)]) - matrix = basis @ np.diag(values) @ basis.T - translation = global_center - matrix @ global_center - transform = gp_GTrsf() - for row in range(3): - for col in range(3): - transform.SetValue(row + 1, col + 1, float(matrix[row, col])) - transform.SetValue(row + 1, 4, float(translation[row])) - # GTransform's copy flag copies topology, but can retain shared curve - # geometry from boolean/fillet history. Isolate that geometry first: - # otherwise OCCT 7.9 can produce invalid p-curves and crash its checker. - copied = BRepBuilderAPI_Copy(solid.wrapped, True, False).Shape() - builder = BRepBuilderAPI_GTransform(copied, transform, True) - builder.Build() - if not builder.IsDone(): - raise ValueError("kernel could not scale the solid") - return _finish(solid, builder.Shape(), "make_scale_rsolid", - {"factors": factors, "center": center}, [solid]) - - -def transform_rsolid(solid: Solid, matrix: Sequence[Sequence[float]]) -> Solid: - """Apply a finite nonsingular 4x4 affine matrix in the current frame. - - General affine transformations include shear and reflection. Perspective - matrices and singular maps cannot define a solid and are rejected. - """ - values = np.asarray(evaluate_value(matrix), dtype=float) - if values.shape != (4, 4) or not np.isfinite(values).all(): - raise ValueError("matrix must be a finite 4x4 affine matrix") - if not np.allclose(values[3], (0, 0, 0, 1), rtol=0, atol=1e-12): - raise ValueError("perspective transforms cannot define an affine solid") - if abs(float(np.linalg.det(values[:3, :3]))) < 1e-14: - raise ValueError("matrix must be nonsingular") - cs = get_current_cs() - basis = np.column_stack([cs.transform_vector(np.eye(3)[i]) for i in range(3)]) - origin = cs.transform_point(np.zeros(3)) - linear = basis @ values[:3, :3] @ basis.T - translation = origin + basis @ values[:3, 3] - linear @ origin - transform = gp_GTrsf() - for row in range(3): - for col in range(3): - transform.SetValue(row + 1, col + 1, float(linear[row, col])) - transform.SetValue(row + 1, 4, float(translation[row])) - copied = BRepBuilderAPI_Copy(solid.wrapped, True, False).Shape() - builder = BRepBuilderAPI_GTransform(copied, transform, True) - builder.Build() - if not builder.IsDone(): - raise ValueError("kernel could not transform the solid") - return _finish(solid, builder.Shape(), "make_transform_rsolid", {"matrix": matrix}, [solid]) - - -def offset_rsolid( - solid: Solid, - amount: ScalarLike, - openings: Sequence[Face] | ShapeSelector = (), - *, - tolerance: float = 1e-4, - kind: str = "ARC", -) -> Solid: - """Offset a solid outward (positive) or inward (negative). - - Selected openings form an open shell. Empty openings produce the closed - offset solid. This is distinct from shell_rsolid's positive inward wall - thickness, and supports both signs and ARC/INTERSECTION joins explicitly. - """ - distance = evaluate_scalar(amount) - if not math.isfinite(distance) or distance == 0: - raise ValueError("amount must be finite and nonzero") - if not math.isfinite(tolerance) or tolerance <= 0: - raise ValueError("tolerance must be finite and positive") - joins = {"ARC": GeomAbs_Arc, "INTERSECTION": GeomAbs_Intersection} - if kind not in joins: - raise ValueError("kind must be ARC or INTERSECTION") - faces = list(_resolve_selector_or_shapes(solid, openings)) - if any(not isinstance(face, Face) for face in faces): - raise ValueError("openings must select faces") - selected_ids = _ensure_geo_selection_node_ids(solid, faces) - face_list = TopTools_ListOfShape() - for face in faces: - face_list.Append(face.wrapped) - builder = BRepOffsetAPI_MakeThickSolid() - builder.MakeThickSolidByJoin(solid.wrapped, face_list, distance, tolerance, - Intersection=True, RemoveIntEdges=True, Join=joins[kind]) - builder.Build() - if not builder.IsDone(): - raise ValueError("kernel could not offset the solid") - params = {"amount": amount, "tolerance": tolerance, "kind": kind, - "removed_face_count": len(faces), "selected_faces": _serialize_shape_refs(faces)} - if selected_ids: - params["selected_face_node_ids"] = selected_ids - return _finish(solid, builder.Shape(), "make_offset_rsolid", params, [solid, *faces]) - - -def boolean_rsolidlist( - left: Sequence[Solid], - right: Sequence[Solid], - operation: str, -) -> list[Solid]: - """Apply a set boolean to two body groups, retaining every result solid. - - Members of each group are a geometric set. Unlike single-solid booleans, - a cut or intersection may return several solids or an empty list. - Topology lineage is partial; the ordered inputs and all output slots are - recorded so downstream features can replay the same geometry. - """ - from ..kernel.ocp_booleans import cut_shapes, common_shapes, fuse_shapes - from ..kernel.ocp_export import make_compound_always - lhs, rhs = list(left), list(right) - if operation not in {"cut", "intersect", "union"}: - raise ValueError("operation must be cut, intersect or union") - inputs = [*lhs, *rhs] - if any(not isinstance(body, Solid) for body in inputs): - raise TypeError("boolean groups must contain Solid values") - if any(body.wrapped.IsNull() or not BRepCheck_Analyzer(body.wrapped).IsValid() for body in inputs): - raise ValueError("boolean groups must contain valid nonempty topology") - if operation == "union": - shape = fuse_shapes([body.wrapped for body in inputs]) if inputs else None - elif not lhs or (operation == "intersect" and not rhs): - shape = None - elif operation == "cut": - shape = cut_shapes(make_compound_always([body.wrapped for body in lhs]), - [body.wrapped for body in rhs]) - else: - shape = common_shapes([make_compound_always([body.wrapped for body in group]) - for group in (lhs, rhs)]) - if shape is not None and not BRepCheck_Analyzer(shape).IsValid(): - raise ValueError("set boolean produced invalid topology") - result = [Solid(solid) for solid in solids_of(shape)] if shape is not None else [] - for body in result: - for source in inputs: - _attach_lineage_from_source(source, body, derivation="continuation", - op="make_boolean_rsolidlist", coverage="partial") - _ensure_geo_selection_input_nodes(inputs) - record_operation_if_active(op="make_boolean_rsolidlist", - params={"operation": operation, "left_count": len(lhs), "right_count": len(rhs), - "input_refs": _serialize_shape_refs(inputs)}, outputs=result, - input_shapes=inputs, context=_current_context_metadata()) - return result +Kept so that ``from simplecadapi.operators.deformation import `` keeps working; +import from the package root instead, ``from simplecadapi import ``. +""" +from __future__ import annotations -__all__ = ("scale_rsolid", "offset_rsolid", "transform_rsolid", "boolean_rsolidlist") +import warnings +from importlib import import_module + +_MOVED = { + "scale_rsolid": "transform", + "transform_rsolid": "transform", + "offset_rsolid": "features", + "boolean_rsolidlist": "boolean", +} + + +def __getattr__(name: str): + family = _MOVED.get(name) + if family is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + warnings.warn( + f"{__name__}.{name} moved to simplecadapi.operators.{family}; " + f"import it from simplecadapi instead (from simplecadapi import {name})", + DeprecationWarning, + stacklevel=2, + ) + return getattr(import_module(f"simplecadapi.operators.{family}"), name) diff --git a/src/simplecadapi/operators/draft.py b/src/simplecadapi/operators/draft.py index 1b427779..d374c240 100644 --- a/src/simplecadapi/operators/draft.py +++ b/src/simplecadapi/operators/draft.py @@ -1,83 +1,27 @@ -"""Draft selected analytic solid faces around a recorded neutral plane.""" -from __future__ import annotations - -import math -from typing import Sequence - -import numpy as np -from OCP.BRepAdaptor import BRepAdaptor_Surface -from OCP.BRepOffsetAPI import BRepOffsetAPI_DraftAngle -from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Cone -from OCP.gp import gp_Dir, gp_Pln, gp_Pnt - -from ._support import ( - Face, Solid, ShapeSelector, ScalarLike, evaluate_scalar, get_current_cs, - _resolve_selector_or_shapes, _ensure_geo_selection_node_ids, _serialize_shape_refs, -) -from .deformation import _finish, _finite_vector +"""Moved: the operators this module held live in the module of their family (``_MOVED``). +Kept so that ``from simplecadapi.operators.draft import `` keeps working; +import from the package root instead, ``from simplecadapi import ``. +""" -def draft_rsolid( - solid: Solid, - faces_to_draft: Sequence[Face] | ShapeSelector, - angle: ScalarLike, - plane_origin: Sequence[float] = (0, 0, 0), - plane_normal: Sequence[float] = (0, 0, 1), -) -> Solid: - """Draft planar, cylindrical or conical faces by a signed angle in degrees. - - The neutral plane fixes the unchanged section. Its origin and normal are in - current coordinate-system coordinates. Face selection is checked against - the input body and retained as graph inputs for strict replay. The original - solid is preserved. Other surface families are explicitly rejected. - """ - if not isinstance(solid, Solid): - raise TypeError("solid must be a Solid") - degrees = float(evaluate_scalar(angle)) - if not math.isfinite(degrees) or abs(degrees) >= 90: - raise ValueError("angle must be finite and strictly between -90 and 90 degrees") - origin = _finite_vector(plane_origin, "plane_origin") - normal = _finite_vector(plane_normal, "plane_normal") - cs = get_current_cs() - global_origin = cs.transform_point(np.asarray(origin)) - global_normal = cs.transform_vector(np.asarray(normal)) - magnitude = float(np.linalg.norm(global_normal)) - if magnitude <= 1e-14: - raise ValueError("plane_normal must be nonzero") - global_normal = global_normal / magnitude - selected = list(_resolve_selector_or_shapes(solid, faces_to_draft)) - if not selected: - raise ValueError("draft requires at least one selected face") - original_faces = solid._iter_faces() - for face in selected: - if not isinstance(face, Face): - raise ValueError("faces_to_draft must select only Face objects") - if not any(face.wrapped.IsSame(other.wrapped) for other in original_faces): - raise ValueError("selected draft faces must belong to the input solid") - if BRepAdaptor_Surface(face.wrapped).GetType() not in {GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Cone}: - raise ValueError("draft supports only planar, cylindrical, and conical faces") - # Remove repeated references without changing the ordered face selection. - selected = list({face.topo_id: face for face in selected}.values()) - selected_ids = _ensure_geo_selection_node_ids(solid, selected) - if degrees == 0: - shape = solid.wrapped - else: - direction = gp_Dir(*global_normal) - plane = gp_Pln(gp_Pnt(*global_origin), direction) - builder = BRepOffsetAPI_DraftAngle(solid.wrapped) - for face in selected: - builder.Add(face.wrapped, direction, math.radians(degrees), plane, True) - if not builder.AddDone(): - raise ValueError("kernel could not add the requested draft angle to a selected face") - builder.Build() - if not builder.IsDone(): - raise ValueError("kernel could not build the drafted solid") - shape = builder.Shape() - params = {"angle": angle, "plane_origin": plane_origin, "plane_normal": plane_normal, - "selected_face_count": len(selected), "selected_faces": _serialize_shape_refs(selected)} - if selected_ids: - params["selected_face_node_ids"] = selected_ids - return _finish(solid, shape, "make_draft_rsolid", params, [solid, *selected]) - +from __future__ import annotations -__all__ = ("draft_rsolid",) +import warnings +from importlib import import_module + +_MOVED = { + "draft_rsolid": "features", +} + + +def __getattr__(name: str): + family = _MOVED.get(name) + if family is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + warnings.warn( + f"{__name__}.{name} moved to simplecadapi.operators.{family}; " + f"import it from simplecadapi instead (from simplecadapi import {name})", + DeprecationWarning, + stacklevel=2, + ) + return getattr(import_module(f"simplecadapi.operators.{family}"), name) diff --git a/src/simplecadapi/operators/features.py b/src/simplecadapi/operators/features.py index fcf4503c..8a189472 100644 --- a/src/simplecadapi/operators/features.py +++ b/src/simplecadapi/operators/features.py @@ -11,6 +11,23 @@ raise_open_wire_failure, render_failure_evidence, ) +from ..kernel.ocp_properties import center_of_mass, distance, volume +from ..kernel.ocp_topology import faces_of +from OCP.BRepAdaptor import BRepAdaptor_Surface +from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Splitter +from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing, BRepBuilderAPI_Transform +from OCP.BRepGProp import BRepGProp, BRepGProp_Face +from OCP.BRepOffsetAPI import BRepOffsetAPI_DraftAngle, BRepOffsetAPI_MakeOffset, BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections +from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace, BRepPrimAPI_MakePrism +from OCP.BRepTools import BRepTools +from OCP.GProp import GProp_GProps +from OCP.GeomAbs import GeomAbs_Arc, GeomAbs_Cone, GeomAbs_Cylinder, GeomAbs_Intersection, GeomAbs_Plane +from OCP.LocOpe import LocOpe_DPrism +from OCP.TopAbs import TopAbs_COMPOUND, TopAbs_FACE, TopAbs_SHELL, TopAbs_WIRE +from OCP.TopExp import TopExp_Explorer +from OCP.TopTools import TopTools_ListOfShape +from OCP.TopoDS import TopoDS_Iterator +from OCP.gp import gp_Pln, gp_Trsf, gp_Vec from ._support import * from .geometry import ( _default_plane_x_direction, @@ -1377,4 +1394,501 @@ def helical_sweep_rsolid( error=e, ) + +def offset_rsolid( + solid: Solid, + amount: ScalarLike, + openings: Sequence[Face] | ShapeSelector = (), + *, + tolerance: float = 1e-4, + kind: str = "ARC", +) -> Solid: + """Offset a solid outward (positive) or inward (negative). + + The amount must be finite and nonzero. Faces passed as ``openings`` form an + open shell; with no openings the result is the closed offset solid. The + join ``kind`` is ``ARC`` or ``INTERSECTION``. Invalid kernel results raise + an error. The operation is recorded as ``make_offset_rsolid`` with explicit + face selections; topology tag lineage is conservatively partial. + ``shell_rsolid`` keeps its own contract (a positive inward wall thickness); + this one supports both signs. + + Args: + solid: The solid to offset. + amount: Offset distance; positive outward, negative inward. + openings: Faces of ``solid`` (or a QL selector) left open. + tolerance: Kernel tolerance of the offset. + kind: Join type, ``"ARC"`` or ``"INTERSECTION"``. + + Returns: + The offset solid. + + Raises: + ValueError: ``amount`` is zero or not finite, ``tolerance`` is not + finite and positive, ``kind`` is unknown, ``openings`` do not select + faces, or the kernel produced an invalid result. + """ + distance = evaluate_scalar(amount) + if not math.isfinite(distance) or distance == 0: + raise ValueError("amount must be finite and nonzero") + if not math.isfinite(tolerance) or tolerance <= 0: + raise ValueError("tolerance must be finite and positive") + joins = {"ARC": GeomAbs_Arc, "INTERSECTION": GeomAbs_Intersection} + if kind not in joins: + raise ValueError("kind must be ARC or INTERSECTION") + faces = list(_resolve_selector_or_shapes(solid, openings)) + if any(not isinstance(face, Face) for face in faces): + raise ValueError("openings must select faces") + selected_ids = _ensure_geo_selection_node_ids(solid, faces) + face_list = TopTools_ListOfShape() + for face in faces: + face_list.Append(face.wrapped) + builder = BRepOffsetAPI_MakeThickSolid() + builder.MakeThickSolidByJoin(solid.wrapped, face_list, distance, tolerance, + Intersection=True, RemoveIntEdges=True, Join=joins[kind]) + builder.Build() + if not builder.IsDone(): + raise ValueError("kernel could not offset the solid") + params = {"amount": amount, "tolerance": tolerance, "kind": kind, + "removed_face_count": len(faces), "selected_faces": _serialize_shape_refs(faces)} + if selected_ids: + params["selected_face_node_ids"] = selected_ids + return _finish(solid, builder.Shape(), "make_offset_rsolid", params, [solid, *faces]) + + +def draft_rsolid( + solid: Solid, + faces_to_draft: Sequence[Face] | ShapeSelector, + angle: ScalarLike, + plane_origin: Sequence[float] = (0, 0, 0), + plane_normal: Sequence[float] = (0, 0, 1), +) -> Solid: + """Draft planar, cylindrical or conical faces by a signed angle in degrees. + + The neutral plane fixes the unchanged section: the selected faces lean + toward or away from it. Its origin and normal are in current + coordinate-system coordinates. Face selection is checked against the input + body and retained as graph inputs for strict replay. The input solid is + preserved, the output must be one valid solid, and a zero angle records an + unchanged result. Other surface families are explicitly rejected. + + The operation is recorded as ``make_draft_rsolid``; the body and selected + faces are ordered inputs, and the angle, neutral plane and face selection + nodes are parameters, so model JSON replay is strict. + + Args: + solid: The body to draft. + faces_to_draft: Faces of ``solid``, or a QL selector that picks them. + angle: Draft angle in degrees, strictly between -90 and 90. + plane_origin: A point of the neutral plane. + plane_normal: Normal of the neutral plane; must be nonzero. + + Returns: + The drafted solid. + + Raises: + TypeError: ``solid`` is not a Solid. + ValueError: Empty selection, a selection that is not made of faces of + ``solid``, an unsupported surface family, a zero plane normal, or + a nonfinite or out-of-range angle. + + Examples: + import simplecadapi as scad + from simplecadapi import ql + body = scad.make_box_rsolid(10, 10, 10) + right = ql.faces().where(ql.prop("geom.normal.x", ">", 0.9)).exactly(1) + drafted = scad.draft_rsolid(body, right, angle=10) + """ + if not isinstance(solid, Solid): + raise TypeError("solid must be a Solid") + degrees = float(evaluate_scalar(angle)) + if not math.isfinite(degrees) or abs(degrees) >= 90: + raise ValueError("angle must be finite and strictly between -90 and 90 degrees") + origin = _finite_vector(plane_origin, "plane_origin") + normal = _finite_vector(plane_normal, "plane_normal") + cs = get_current_cs() + global_origin = cs.transform_point(np.asarray(origin)) + global_normal = cs.transform_vector(np.asarray(normal)) + magnitude = float(np.linalg.norm(global_normal)) + if magnitude <= 1e-14: + raise ValueError("plane_normal must be nonzero") + global_normal = global_normal / magnitude + selected = list(_resolve_selector_or_shapes(solid, faces_to_draft)) + if not selected: + raise ValueError("draft requires at least one selected face") + original_faces = solid._iter_faces() + for face in selected: + if not isinstance(face, Face): + raise ValueError("faces_to_draft must select only Face objects") + if not any(face.wrapped.IsSame(other.wrapped) for other in original_faces): + raise ValueError("selected draft faces must belong to the input solid") + if BRepAdaptor_Surface(face.wrapped).GetType() not in {GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Cone}: + raise ValueError("draft supports only planar, cylindrical, and conical faces") + # Remove repeated references without changing the ordered face selection. + selected = list({face.topo_id: face for face in selected}.values()) + selected_ids = _ensure_geo_selection_node_ids(solid, selected) + if degrees == 0: + shape = solid.wrapped + else: + direction = gp_Dir(*global_normal) + plane = gp_Pln(gp_Pnt(*global_origin), direction) + builder = BRepOffsetAPI_DraftAngle(solid.wrapped) + for face in selected: + builder.Add(face.wrapped, direction, math.radians(degrees), plane, True) + if not builder.AddDone(): + raise ValueError("kernel could not add the requested draft angle to a selected face") + builder.Build() + if not builder.IsDone(): + raise ValueError("kernel could not build the drafted solid") + shape = builder.Shape() + params = {"angle": angle, "plane_origin": plane_origin, "plane_normal": plane_normal, + "selected_face_count": len(selected), "selected_faces": _serialize_shape_refs(selected)} + if selected_ids: + params["selected_face_node_ids"] = selected_ids + return _finish(solid, shape, "make_draft_rsolid", params, [solid, *selected]) + + +def extrude_taper_rsolid( + profile: Face, + vector: Sequence[float], + taper: float, + flip_inner: bool = True, +) -> Solid: + """Extrude a planar sketch face by a vector, with a taper in degrees. + + The vector is nonzero and in current coordinate-system coordinates. The + finite taper angle lies strictly between -90 and 90; a positive taper + shrinks the outer section, and inner loops taper the opposite way when + ``flip_inner`` is true. An analytic draft prism is used where applicable + and exact offset sections with lofts otherwise. Collapsed loops and invalid + topology raise errors. The operation is recorded as + ``make_extrude_taper_rsolid`` for replay. + + Args: + profile: The planar face to extrude. + vector: Extrusion vector (nonzero). + taper: Taper angle in degrees, strictly between -90 and 90. + flip_inner: Taper inner loops in the opposite direction. + + Returns: + The tapered solid. + + Raises: + ValueError: A zero vector, an out-of-range taper, a nonplanar profile, + collapsed loops or an invalid result. + """ + values = _finite_vector(vector, "vector") + angle = float(evaluate_scalar(taper)) + if not math.isfinite(angle) or abs(angle) >= 90: + raise ValueError("taper must be finite and between -90 and 90 degrees") + displacement = get_current_cs().transform_vector(np.asarray(values)) + length = float(np.linalg.norm(displacement)) + if length <= 1e-12: + raise ValueError("extrusion vector must be nonzero") + surface = BRepAdaptor_Surface(profile.wrapped, True) + if surface.GetType() != GeomAbs_Plane: + raise ValueError("taper extrusion requires a planar profile") + normal = np.asarray(tuple(profile.get_normal_at()), dtype=float) + outer = BRepTools.OuterWire_s(profile.wrapped) + wires = [outer] + explorer = TopExp_Explorer(profile.wrapped, TopAbs_WIRE) + while explorer.More(): + wire = TopoDS.Wire_s(explorer.Current()) + if not wire.IsSame(outer): + wires.append(wire) + explorer.Next() + if angle == 0: + shape = BRepPrimAPI_MakePrism(profile.wrapped, gp_Vec(*displacement)).Shape() + elif np.linalg.norm(displacement / length - normal) < 1e-8 and normal[2] > 0 and len(wires) == 1 and angle > 0: + shape = LocOpe_DPrism(profile.wrapped, length / math.cos(math.radians(angle)), math.radians(angle)).Shape() + else: + # Offset in the profile plane before translating the final section. + move = gp_Trsf() + move.SetTranslation(gp_Vec(*displacement)) + solids = [] + for i, wire in enumerate(wires): + # Face inner wires wind opposite to the outer boundary. Normalize + # their winding before applying the signed 2-D offset so that + # flip_inner controls geometry rather than kernel wire orientation. + if i: + wire = TopoDS.Wire_s(wire.Reversed()) + offset = -length * math.tan(math.radians(angle)) * (-1 if i and flip_inner else 1) + builder = BRepOffsetAPI_MakeOffset(wire, GeomAbs_Intersection, False) + builder.Perform(offset) + if not builder.IsDone(): + raise ValueError("taper collapses a profile loop") + offset_shape = builder.Shape() + exp = TopExp_Explorer(offset_shape, TopAbs_WIRE) + found = [] + while exp.More(): + found.append(TopoDS.Wire_s(exp.Current())) + exp.Next() + if len(found) != 1: + raise ValueError("taper changes loop connectivity") + moved = BRepBuilderAPI_Transform(found[0], move, True).Shape() + loft = BRepOffsetAPI_ThruSections(True, False) + loft.AddWire(wire) + loft.AddWire(TopoDS.Wire_s(moved)) + loft.Build() + if not loft.IsDone(): + raise ValueError("kernel could not build tapered sections") + solids.append(loft.Shape()) + shape = solids[0] + for tool in solids[1:]: + cut = BRepAlgoAPI_Cut(shape, tool) + cut.Build() + if not cut.IsDone(): + raise ValueError("kernel could not subtract tapered inner loop") + shape = cut.Shape() + return _finish(profile, shape, "make_extrude_taper_rsolid", + {"vector": vector, "taper": taper, "flip_inner": flip_inner}, [profile]) + + +def sweep_multisection_rsolid( + profiles: Sequence[Face | Wire], + path: Wire, + is_frenet: bool = False, + binormal: Sequence[float] | Wire | None = None, +) -> Solid: + """Sweep through explicit ordered sections along a continuous path. + + At least two ordered sections are needed. Face sections supply their outer + wire, matching build123d's multi-section sweep contract. ``binormal`` is an + optional direction vector or an auxiliary spine. The kernel must produce + one valid solid. Every section and the path are recorded as inputs of + ``make_sweep_multisection_rsolid``, so model JSON preserves the + construction. + + Args: + profiles: Ordered section faces or wires (at least two). + path: The continuous path wire. + is_frenet: Use the Frenet frame along the path. + binormal: A direction, or an auxiliary spine wire, that fixes the + section orientation. + + Returns: + The swept solid. + + Raises: + TypeError: A section is neither a Face nor a Wire. + ValueError: Fewer than two sections, a zero ``binormal`` direction, or + the kernel could not make a solid. + """ + sections = list(profiles) + if len(sections) < 2: + raise ValueError("multi-section sweep requires at least two sections") + builder = BRepOffsetAPI_MakePipeShell(path.wrapped) + rotate = False + if isinstance(binormal, Wire): + builder.SetMode(binormal.wrapped, True) + elif binormal is not None: + values = _finite_vector(binormal, "binormal") + direction = get_current_cs().transform_vector(np.asarray(values)) + if np.linalg.norm(direction) < 1e-12: + raise ValueError("binormal direction must be nonzero") + builder.SetMode(gp_Dir(*direction)) + rotate = True + else: + builder.SetMode(bool(is_frenet)) + for section in sections: + if not isinstance(section, (Face, Wire)): + raise TypeError("sections must be Face or Wire") + wire = BRepTools.OuterWire_s(section.wrapped) if isinstance(section, Face) else section.wrapped + builder.Add(wire, False, rotate) + builder.Build() + if not builder.IsDone() or not builder.MakeSolid(): + raise ValueError("kernel could not make a solid multi-section sweep") + inputs = [*sections, path] + params = {"profile_count": len(sections), "is_frenet": bool(is_frenet), "binormal": None} + if isinstance(binormal, Wire): + inputs.append(binormal) + params["binormal_is_wire"] = True + elif binormal is not None: + params["binormal"] = binormal + return _finish(sections[0], builder.Shape(), "make_sweep_multisection_rsolid", params, inputs) + + +def _top_level(shape): + if shape.ShapeType() != TopAbs_COMPOUND: + return [shape] + output = [] + iterator = TopoDS_Iterator(shape) + while iterator.More(): + output.extend(_top_level(iterator.Value())) + iterator.Next() + return output + + +def _crosses_direction(face, axis): + """Ignore side walls tangent to the extrusion, including coincident walls.""" + u0, u1, v0, v1 = BRepTools.UVBounds_s(face) + properties = BRepGProp_Face(face) + for u in (.25, .5, .75): + for v in (.25, .5, .75): + point, normal = gp_Pnt(), gp_Vec() + properties.Normal(u0 + u * (u1-u0), v0 + v * (v1-v0), point, normal) + length = normal.Magnitude() + if length > 1e-14 and abs(normal.X()*axis[0] + normal.Y()*axis[1] + normal.Z()*axis[2]) / length > 1e-8: + return True + return False + + +def _contact_surfaces(target, prism, axis): + common = BRepAlgoAPI_Common(target, prism) + common.Build() + if not common.IsDone() or not faces_of(common.Shape()): + raise ValueError("No intersection: extrusion does not contact target") + history = common.History() + modified = [] + intersection_faces = faces_of(common.Shape()) + for face in faces_of(target): + affected = list(history.Modified(face)) + if affected: + modified.extend(TopoDS.Face_s(item) for item in affected if item.ShapeType() == TopAbs_FACE) + elif not history.IsRemoved(face) and any(item.IsSame(face) for item in intersection_faces): + # A whole target may lie inside the temporary prism, in which case + # unchanged boundary faces are still genuine contact surfaces. + modified.append(face) + if not modified: + raise ValueError("No target boundary surfaces found in extrusion intersection") + sewing = BRepBuilderAPI_Sewing(1e-6) + for face in modified: + if _crosses_direction(face, axis): + sewing.Add(face) + sewing.Perform() + surfaces = _top_level(sewing.SewedShape()) + if not surfaces or any(part.ShapeType() not in {TopAbs_FACE, TopAbs_SHELL} for part in surfaces): + raise ValueError("Kernel could not sew target contact surfaces") + return surfaces + + +def _split_on_surface(prism, surface, keep_top): + arguments, tools = TopTools_ListOfShape(), TopTools_ListOfShape() + arguments.Append(prism) + tools.Append(surface) + splitter = BRepAlgoAPI_Splitter() + splitter.SetArguments(arguments) + splitter.SetTools(tools) + splitter.Build() + if not splitter.IsDone(): + raise ValueError("Kernel could not split extrusion on target surface") + reference_face = TopoDS.Face_s(surface) if surface.ShapeType() == TopAbs_FACE else faces_of(surface)[0] + u0, u1, v0, v1 = BRepTools.UVBounds_s(reference_face) + point, normal = gp_Pnt(), gp_Vec() + BRepGProp_Face(reference_face).Normal((u0+u1)/2, (v0+v1)/2, point, normal) + if normal.Magnitude() <= 1e-14: + raise ValueError("Undefined normal on termination surface") + normal.Normalize() + reference = point.Translated(normal) + if surface.ShapeType() == TopAbs_FACE: + halfspace = BRepPrimAPI_MakeHalfSpace(TopoDS.Face_s(surface), reference).Solid() + else: + halfspace = BRepPrimAPI_MakeHalfSpace(TopoDS.Shell_s(surface), reference).Solid() + candidates = [] + for part in solids_of(splitter.Shape()): + common = BRepAlgoAPI_Common(part, halfspace) + common.Build() + if not common.IsDone(): + raise ValueError("Kernel could not classify the termination side") + properties = GProp_GProps() + BRepGProp.LinearProperties_s(common.Shape(), properties) + is_top = properties.Mass() >= 1e-6 + if is_top == keep_top: + candidates.append(part) + return candidates + + +def extrude_until_rsolid( + profile: Face, + target: Solid | Sequence[Solid], + direction: Sequence[float] = (0, 0, 1), + until: str = "NEXT", +) -> Solid: + """Extrude a planar face until a target surface, including curved surfaces. + + The direction is interpreted in the current coordinate system and + normalized before construction. ``until`` selects the termination: + ``NEXT`` (forward, nearest intersecting target surface), ``LAST`` (forward, + farthest), ``PREVIOUS`` (reverse, nearest) and ``FIRST`` (reverse, + farthest). The temporary prism is intersected with the target, the target's + boundary faces are taken from kernel history and sewn into surfaces, and + the prism is split on the selected one. Bounding boxes only size the + temporary prism; they do not define the termination surface, and side + walls parallel to the direction do not join separate near and far limits. + + Targets must cover the profile at the requested contact: an uncovered, + open or disconnected output is rejected, as are a missing contact, an + invalid mode, a zero or nonfinite direction and an empty target sequence. + Inputs are preserved. The operation is recorded as + ``make_extrude_until_rsolid``; its ordered inputs are the profile followed + by every target solid and its parameters keep ``direction``, ``until`` and + ``target_count``, so a changed target is recomputed on replay (no final + BRep is embedded). + + Args: + profile: The planar face to extrude. + target: A solid, or a sequence of solids, that bounds the extrusion. + direction: Extrusion direction in current coordinate-system + coordinates. + until: ``"NEXT"``, ``"LAST"``, ``"PREVIOUS"`` or ``"FIRST"``. + + Returns: + One valid solid. + + Raises: + TypeError: ``profile`` is not a Face, or ``target`` is not a Solid or + a sequence of Solid. + ValueError: No target, no contact, insufficient coverage, a bad mode + or direction, or an invalid kernel result. + + Examples: + import simplecadapi as scad + profile = scad.make_rectangle_rface(2, 2) + ceiling = scad.make_box_rsolid(10, 10, 2, bottom_face_center=(0, 0, 5)) + near = scad.extrude_until_rsolid(profile, ceiling, until="NEXT") # volume 20 + far = scad.extrude_until_rsolid(profile, ceiling, until="LAST") # volume 28 + sphere = scad.make_sphere_rsolid(5, center=(0, 0, 10)) + curved = scad.extrude_until_rsolid(profile, sphere) + """ + if not isinstance(profile, Face): + raise TypeError("profile must be a Face") + targets = [target] if isinstance(target, Solid) else list(target) + if not targets: + raise ValueError("target must contain at least one Solid") + if any(not isinstance(item, Solid) for item in targets): + raise TypeError("target must be a Solid or a sequence of Solid") + mode = str(getattr(until, "name", until)).upper() + if mode not in {"NEXT", "LAST", "PREVIOUS", "FIRST"}: + raise ValueError("until must be NEXT, LAST, PREVIOUS, or FIRST") + values = _finite_vector(direction, "direction") + global_direction = get_current_cs().transform_vector(np.asarray(values)) + magnitude = float(np.linalg.norm(global_direction)) + if not math.isfinite(magnitude) or magnitude <= 1e-14: + raise ValueError("direction must be a finite nonzero vector") + axis = global_direction / magnitude + if mode in {"PREVIOUS", "FIRST"}: + axis = -axis + bound_target = make_compound_always([item.wrapped for item in targets]) + box = bounding_box(make_compound_always([profile.wrapped, bound_target])) + length = math.sqrt((box.xmax-box.xmin)**2 + (box.ymax-box.ymin)**2 + (box.zmax-box.zmin)**2) + if length <= 1e-12: + raise ValueError("Degenerate profile and target bounds") + prism = BRepPrimAPI_MakePrism(profile.wrapped, gp_Vec(*(axis * length))).Shape() + surfaces = sorted(_contact_surfaces(bound_target, prism, axis), key=lambda part: distance(part, profile.wrapped)) + nearest = mode in {"NEXT", "PREVIOUS"} + surface = surfaces[0] if nearest else surfaces[-1] + candidates = _split_on_surface(prism, surface, keep_top=nearest) + if not candidates: + raise ValueError("Extrusion produced no solid on the requested side of the target") + origin = np.asarray(tuple(center_of_mass(profile.wrapped))) + chosen = min(candidates, key=lambda part: np.dot(np.asarray(tuple(center_of_mass(part))) - origin, axis)) + if abs(volume(chosen) - volume(prism)) <= max(1e-8, abs(volume(prism))*1e-9): + raise ValueError("Target termination surface does not completely cover the extrusion profile") + if volume(chosen) <= 1e-12 or not BRepCheck_Analyzer(chosen).IsValid(): + raise ValueError("Target surface did not completely cover a valid limited extrusion") + result = _finish(profile, chosen, "make_extrude_until_rsolid", + {"direction": direction, "until": mode, "target_count": len(targets)}, [profile, *targets]) + result._apply_tag("solid.extrusion", propagate=False) + return result + __all__ = tuple(name for name in globals() if not name.startswith("__")) diff --git a/src/simplecadapi/operators/limited_extrusion.py b/src/simplecadapi/operators/limited_extrusion.py index b6de8f27..9d154c10 100644 --- a/src/simplecadapi/operators/limited_extrusion.py +++ b/src/simplecadapi/operators/limited_extrusion.py @@ -1,180 +1,27 @@ -"""Recorded extrusion terminated by a real target face or curved shell. +"""Moved: the operators this module held live in the module of their family (``_MOVED``). -The construction follows the surface-history approach used by build123d: -intersect a long prism, sew the affected target faces, order contact surfaces, -and split by the selected surface. Bounding boxes only bound the temporary -prism; they never define the termination surface. +Kept so that ``from simplecadapi.operators.limited_extrusion import `` keeps working; +import from the package root instead, ``from simplecadapi import ``. """ -from __future__ import annotations - -import math -from typing import Sequence - -import numpy as np -from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Splitter -from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing -from OCP.BRepCheck import BRepCheck_Analyzer -from OCP.BRepGProp import BRepGProp, BRepGProp_Face -from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace, BRepPrimAPI_MakePrism -from OCP.BRepTools import BRepTools -from OCP.GProp import GProp_GProps -from OCP.TopAbs import TopAbs_COMPOUND, TopAbs_FACE, TopAbs_SHELL -from OCP.TopoDS import TopoDS, TopoDS_Iterator -from OCP.TopTools import TopTools_ListOfShape -from OCP.gp import gp_Pnt, gp_Vec - -from ..kernel.ocp_booleans import solids_of -from ..kernel.ocp_export import make_compound_always -from ..kernel.ocp_properties import bounding_box, center_of_mass, distance, volume -from ..kernel.ocp_topology import faces_of -from ._support import Face, Solid, evaluate_value, get_current_cs -from .deformation import _finish, _finite_vector - - -def _top_level(shape): - if shape.ShapeType() != TopAbs_COMPOUND: - return [shape] - output = [] - iterator = TopoDS_Iterator(shape) - while iterator.More(): - output.extend(_top_level(iterator.Value())) - iterator.Next() - return output - - -def _crosses_direction(face, axis): - """Ignore side walls tangent to the extrusion, including coincident walls.""" - u0, u1, v0, v1 = BRepTools.UVBounds_s(face) - properties = BRepGProp_Face(face) - for u in (.25, .5, .75): - for v in (.25, .5, .75): - point, normal = gp_Pnt(), gp_Vec() - properties.Normal(u0 + u * (u1-u0), v0 + v * (v1-v0), point, normal) - length = normal.Magnitude() - if length > 1e-14 and abs(normal.X()*axis[0] + normal.Y()*axis[1] + normal.Z()*axis[2]) / length > 1e-8: - return True - return False - - -def _contact_surfaces(target, prism, axis): - common = BRepAlgoAPI_Common(target, prism) - common.Build() - if not common.IsDone() or not faces_of(common.Shape()): - raise ValueError("No intersection: extrusion does not contact target") - history = common.History() - modified = [] - intersection_faces = faces_of(common.Shape()) - for face in faces_of(target): - affected = list(history.Modified(face)) - if affected: - modified.extend(TopoDS.Face_s(item) for item in affected if item.ShapeType() == TopAbs_FACE) - elif not history.IsRemoved(face) and any(item.IsSame(face) for item in intersection_faces): - # A whole target may lie inside the temporary prism, in which case - # unchanged boundary faces are still genuine contact surfaces. - modified.append(face) - if not modified: - raise ValueError("No target boundary surfaces found in extrusion intersection") - sewing = BRepBuilderAPI_Sewing(1e-6) - for face in modified: - if _crosses_direction(face, axis): - sewing.Add(face) - sewing.Perform() - surfaces = _top_level(sewing.SewedShape()) - if not surfaces or any(part.ShapeType() not in {TopAbs_FACE, TopAbs_SHELL} for part in surfaces): - raise ValueError("Kernel could not sew target contact surfaces") - return surfaces - - -def _split_on_surface(prism, surface, keep_top): - arguments, tools = TopTools_ListOfShape(), TopTools_ListOfShape() - arguments.Append(prism) - tools.Append(surface) - splitter = BRepAlgoAPI_Splitter() - splitter.SetArguments(arguments) - splitter.SetTools(tools) - splitter.Build() - if not splitter.IsDone(): - raise ValueError("Kernel could not split extrusion on target surface") - reference_face = TopoDS.Face_s(surface) if surface.ShapeType() == TopAbs_FACE else faces_of(surface)[0] - u0, u1, v0, v1 = BRepTools.UVBounds_s(reference_face) - point, normal = gp_Pnt(), gp_Vec() - BRepGProp_Face(reference_face).Normal((u0+u1)/2, (v0+v1)/2, point, normal) - if normal.Magnitude() <= 1e-14: - raise ValueError("Undefined normal on termination surface") - normal.Normalize() - reference = point.Translated(normal) - if surface.ShapeType() == TopAbs_FACE: - halfspace = BRepPrimAPI_MakeHalfSpace(TopoDS.Face_s(surface), reference).Solid() - else: - halfspace = BRepPrimAPI_MakeHalfSpace(TopoDS.Shell_s(surface), reference).Solid() - candidates = [] - for part in solids_of(splitter.Shape()): - common = BRepAlgoAPI_Common(part, halfspace) - common.Build() - if not common.IsDone(): - raise ValueError("Kernel could not classify the termination side") - properties = GProp_GProps() - BRepGProp.LinearProperties_s(common.Shape(), properties) - is_top = properties.Mass() >= 1e-6 - if is_top == keep_top: - candidates.append(part) - return candidates - - -def extrude_until_rsolid( - profile: Face, - target: Solid | Sequence[Solid], - direction: Sequence[float] = (0, 0, 1), - until: str = "NEXT", -) -> Solid: - """Extrude a planar face until a target surface, including curved surfaces. - - NEXT and LAST select the nearest and farthest forward contact. PREVIOUS - and FIRST reverse the direction and select the nearest and farthest contact - behind the profile. Target surfaces must cover the profile at the contact; - an uncovered/open or disconnected output is rejected. Every target remains - a graph input, so changes to target geometry replay this feature correctly. - """ - if not isinstance(profile, Face): - raise TypeError("profile must be a Face") - targets = [target] if isinstance(target, Solid) else list(target) - if not targets: - raise ValueError("target must contain at least one Solid") - if any(not isinstance(item, Solid) for item in targets): - raise TypeError("target must be a Solid or a sequence of Solid") - mode = str(getattr(until, "name", until)).upper() - if mode not in {"NEXT", "LAST", "PREVIOUS", "FIRST"}: - raise ValueError("until must be NEXT, LAST, PREVIOUS, or FIRST") - values = _finite_vector(direction, "direction") - global_direction = get_current_cs().transform_vector(np.asarray(values)) - magnitude = float(np.linalg.norm(global_direction)) - if not math.isfinite(magnitude) or magnitude <= 1e-14: - raise ValueError("direction must be a finite nonzero vector") - axis = global_direction / magnitude - if mode in {"PREVIOUS", "FIRST"}: - axis = -axis - bound_target = make_compound_always([item.wrapped for item in targets]) - box = bounding_box(make_compound_always([profile.wrapped, bound_target])) - length = math.sqrt((box.xmax-box.xmin)**2 + (box.ymax-box.ymin)**2 + (box.zmax-box.zmin)**2) - if length <= 1e-12: - raise ValueError("Degenerate profile and target bounds") - prism = BRepPrimAPI_MakePrism(profile.wrapped, gp_Vec(*(axis * length))).Shape() - surfaces = sorted(_contact_surfaces(bound_target, prism, axis), key=lambda part: distance(part, profile.wrapped)) - nearest = mode in {"NEXT", "PREVIOUS"} - surface = surfaces[0] if nearest else surfaces[-1] - candidates = _split_on_surface(prism, surface, keep_top=nearest) - if not candidates: - raise ValueError("Extrusion produced no solid on the requested side of the target") - origin = np.asarray(tuple(center_of_mass(profile.wrapped))) - chosen = min(candidates, key=lambda part: np.dot(np.asarray(tuple(center_of_mass(part))) - origin, axis)) - if abs(volume(chosen) - volume(prism)) <= max(1e-8, abs(volume(prism))*1e-9): - raise ValueError("Target termination surface does not completely cover the extrusion profile") - if volume(chosen) <= 1e-12 or not BRepCheck_Analyzer(chosen).IsValid(): - raise ValueError("Target surface did not completely cover a valid limited extrusion") - result = _finish(profile, chosen, "make_extrude_until_rsolid", - {"direction": direction, "until": mode, "target_count": len(targets)}, [profile, *targets]) - result._apply_tag("solid.extrusion", propagate=False) - return result +from __future__ import annotations -__all__ = ("extrude_until_rsolid",) +import warnings +from importlib import import_module + +_MOVED = { + "extrude_until_rsolid": "features", +} + + +def __getattr__(name: str): + family = _MOVED.get(name) + if family is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + warnings.warn( + f"{__name__}.{name} moved to simplecadapi.operators.{family}; " + f"import it from simplecadapi instead (from simplecadapi import {name})", + DeprecationWarning, + stacklevel=2, + ) + return getattr(import_module(f"simplecadapi.operators.{family}"), name) diff --git a/src/simplecadapi/operators/section_features.py b/src/simplecadapi/operators/section_features.py index 9292eea8..93ed0735 100644 --- a/src/simplecadapi/operators/section_features.py +++ b/src/simplecadapi/operators/section_features.py @@ -1,150 +1,28 @@ -"""Recorded tapered extrusions and sweeps with multiple explicit sections.""" -from __future__ import annotations - -import math -from typing import Sequence - -import numpy as np -from OCP.BRepAdaptor import BRepAdaptor_Surface -from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut -from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform -from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset, BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_ThruSections -from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism -from OCP.BRepTools import BRepTools -from OCP.GeomAbs import GeomAbs_Intersection, GeomAbs_Plane -from OCP.LocOpe import LocOpe_DPrism -from OCP.TopAbs import TopAbs_WIRE -from OCP.TopExp import TopExp_Explorer -from OCP.TopoDS import TopoDS -from OCP.gp import gp_Dir, gp_Trsf, gp_Vec - -from ._support import Face, Wire, Solid, evaluate_scalar, get_current_cs -from .deformation import _finish, _finite_vector - +"""Moved: the operators this module held live in the module of their family (``_MOVED``). -def extrude_taper_rsolid( - profile: Face, - vector: Sequence[float], - taper: float, - flip_inner: bool = True, -) -> Solid: - """Extrude a planar face by a vector, with a taper in degrees. - - Positive taper shrinks the outer section. Inner loops optionally taper in - the opposite direction. Analytic draft prisms are retained where valid; - other orientations/holes use exact offset wire sections and solid lofts. - """ - values = _finite_vector(vector, "vector") - angle = float(evaluate_scalar(taper)) - if not math.isfinite(angle) or abs(angle) >= 90: - raise ValueError("taper must be finite and between -90 and 90 degrees") - displacement = get_current_cs().transform_vector(np.asarray(values)) - length = float(np.linalg.norm(displacement)) - if length <= 1e-12: - raise ValueError("extrusion vector must be nonzero") - surface = BRepAdaptor_Surface(profile.wrapped, True) - if surface.GetType() != GeomAbs_Plane: - raise ValueError("taper extrusion requires a planar profile") - normal = np.asarray(tuple(profile.get_normal_at()), dtype=float) - outer = BRepTools.OuterWire_s(profile.wrapped) - wires = [outer] - explorer = TopExp_Explorer(profile.wrapped, TopAbs_WIRE) - while explorer.More(): - wire = TopoDS.Wire_s(explorer.Current()) - if not wire.IsSame(outer): - wires.append(wire) - explorer.Next() - if angle == 0: - shape = BRepPrimAPI_MakePrism(profile.wrapped, gp_Vec(*displacement)).Shape() - elif np.linalg.norm(displacement / length - normal) < 1e-8 and normal[2] > 0 and len(wires) == 1 and angle > 0: - shape = LocOpe_DPrism(profile.wrapped, length / math.cos(math.radians(angle)), math.radians(angle)).Shape() - else: - # Offset in the profile plane before translating the final section. - move = gp_Trsf() - move.SetTranslation(gp_Vec(*displacement)) - solids = [] - for i, wire in enumerate(wires): - # Face inner wires wind opposite to the outer boundary. Normalize - # their winding before applying the signed 2-D offset so that - # flip_inner controls geometry rather than kernel wire orientation. - if i: - wire = TopoDS.Wire_s(wire.Reversed()) - offset = -length * math.tan(math.radians(angle)) * (-1 if i and flip_inner else 1) - builder = BRepOffsetAPI_MakeOffset(wire, GeomAbs_Intersection, False) - builder.Perform(offset) - if not builder.IsDone(): - raise ValueError("taper collapses a profile loop") - offset_shape = builder.Shape() - exp = TopExp_Explorer(offset_shape, TopAbs_WIRE) - found = [] - while exp.More(): - found.append(TopoDS.Wire_s(exp.Current())) - exp.Next() - if len(found) != 1: - raise ValueError("taper changes loop connectivity") - moved = BRepBuilderAPI_Transform(found[0], move, True).Shape() - loft = BRepOffsetAPI_ThruSections(True, False) - loft.AddWire(wire) - loft.AddWire(TopoDS.Wire_s(moved)) - loft.Build() - if not loft.IsDone(): - raise ValueError("kernel could not build tapered sections") - solids.append(loft.Shape()) - shape = solids[0] - for tool in solids[1:]: - cut = BRepAlgoAPI_Cut(shape, tool) - cut.Build() - if not cut.IsDone(): - raise ValueError("kernel could not subtract tapered inner loop") - shape = cut.Shape() - return _finish(profile, shape, "make_extrude_taper_rsolid", - {"vector": vector, "taper": taper, "flip_inner": flip_inner}, [profile]) - - -def sweep_multisection_rsolid( - profiles: Sequence[Face | Wire], - path: Wire, - is_frenet: bool = False, - binormal: Sequence[float] | Wire | None = None, -) -> Solid: - """Sweep through explicit ordered section wires along a continuous path. - - Face sections use their outer boundary, as in build123d's multi-section - pipe contract. Binormal may be a direction vector or auxiliary spine. - Each supplied section and path remains a graph input for replay. - """ - sections = list(profiles) - if len(sections) < 2: - raise ValueError("multi-section sweep requires at least two sections") - builder = BRepOffsetAPI_MakePipeShell(path.wrapped) - rotate = False - if isinstance(binormal, Wire): - builder.SetMode(binormal.wrapped, True) - elif binormal is not None: - values = _finite_vector(binormal, "binormal") - direction = get_current_cs().transform_vector(np.asarray(values)) - if np.linalg.norm(direction) < 1e-12: - raise ValueError("binormal direction must be nonzero") - builder.SetMode(gp_Dir(*direction)) - rotate = True - else: - builder.SetMode(bool(is_frenet)) - for section in sections: - if not isinstance(section, (Face, Wire)): - raise TypeError("sections must be Face or Wire") - wire = BRepTools.OuterWire_s(section.wrapped) if isinstance(section, Face) else section.wrapped - builder.Add(wire, False, rotate) - builder.Build() - if not builder.IsDone() or not builder.MakeSolid(): - raise ValueError("kernel could not make a solid multi-section sweep") - inputs = [*sections, path] - params = {"profile_count": len(sections), "is_frenet": bool(is_frenet), "binormal": None} - if isinstance(binormal, Wire): - inputs.append(binormal) - params["binormal_is_wire"] = True - elif binormal is not None: - params["binormal"] = binormal - return _finish(sections[0], builder.Shape(), "make_sweep_multisection_rsolid", params, inputs) +Kept so that ``from simplecadapi.operators.section_features import `` keeps working; +import from the package root instead, ``from simplecadapi import ``. +""" +from __future__ import annotations -__all__ = ("extrude_taper_rsolid", "sweep_multisection_rsolid") +import warnings +from importlib import import_module + +_MOVED = { + "extrude_taper_rsolid": "features", + "sweep_multisection_rsolid": "features", +} + + +def __getattr__(name: str): + family = _MOVED.get(name) + if family is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + warnings.warn( + f"{__name__}.{name} moved to simplecadapi.operators.{family}; " + f"import it from simplecadapi instead (from simplecadapi import {name})", + DeprecationWarning, + stacklevel=2, + ) + return getattr(import_module(f"simplecadapi.operators.{family}"), name) diff --git a/src/simplecadapi/operators/transform.py b/src/simplecadapi/operators/transform.py index bf53a6e4..367e06aa 100644 --- a/src/simplecadapi/operators/transform.py +++ b/src/simplecadapi/operators/transform.py @@ -2,6 +2,8 @@ from __future__ import annotations +from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy, BRepBuilderAPI_GTransform, BRepBuilderAPI_Transform +from OCP.gp import gp_GTrsf, gp_Trsf from ._support import * def translate_shape(shape: AnyShape, vector: Tuple[float, float, float]) -> AnyShape: @@ -420,4 +422,107 @@ def mirror_shape( error=e, ) + +def scale_rsolid( + solid: Solid, + factors: Sequence[float], + center: Sequence[float] = (0, 0, 0), +) -> Solid: + """Scale a solid along the current coordinate system's axes about a center. + + The three factors must be finite and nonzero. A uniform scale preserves + analytic curves and surfaces; a nonuniform scale uses the CAD kernel's + general affine transform. The source solid stays unchanged. The operation + is recorded as ``make_scale_rsolid`` for graph replay; topology tag lineage + is conservatively partial. + + Args: + solid: The solid to scale. + factors: Scale factors along the x, y and z axes of the current + coordinate system; finite and nonzero. + center: The fixed point of the scaling, in current coordinate-system + coordinates. + + Returns: + The scaled solid. + + Raises: + ValueError: A factor is zero or not finite, or the kernel could not + scale the solid. + """ + values = _finite_vector(factors, "factors") + center_value = _finite_vector(center, "center") + if any(v == 0 for v in values): + raise ValueError("scale factors must be nonzero") + cs = get_current_cs() + global_center = cs.transform_point(np.array(center_value)) + if values[0] == values[1] == values[2]: + transform = gp_Trsf() + transform.SetScale(gp_Pnt(*global_center), values[0]) + builder = BRepBuilderAPI_Transform(solid.wrapped, transform, True) + else: + basis = np.column_stack([cs.transform_vector(np.eye(3)[i]) for i in range(3)]) + matrix = basis @ np.diag(values) @ basis.T + translation = global_center - matrix @ global_center + transform = gp_GTrsf() + for row in range(3): + for col in range(3): + transform.SetValue(row + 1, col + 1, float(matrix[row, col])) + transform.SetValue(row + 1, 4, float(translation[row])) + # GTransform's copy flag copies topology, but can retain shared curve + # geometry from boolean/fillet history. Isolate that geometry first: + # otherwise OCCT 7.9 can produce invalid p-curves and crash its checker. + copied = BRepBuilderAPI_Copy(solid.wrapped, True, False).Shape() + builder = BRepBuilderAPI_GTransform(copied, transform, True) + builder.Build() + if not builder.IsDone(): + raise ValueError("kernel could not scale the solid") + return _finish(solid, builder.Shape(), "make_scale_rsolid", + {"factors": factors, "center": center}, [solid]) + + +def transform_rsolid(solid: Solid, matrix: Sequence[Sequence[float]]) -> Solid: + """Apply a finite nonsingular 4x4 affine matrix in the current coordinate system. + + The matrix may include shear, reflection and nonuniform scale. Perspective + matrices and singular maps cannot define a solid and are rejected. The + input geometry stays unchanged. The operation is recorded as + ``make_transform_rsolid`` for graph replay; topology tag lineage is partial. + + Args: + solid: The solid to transform. + matrix: A 4x4 affine matrix (row-major, last row ``0 0 0 1``), finite + and nonsingular, in current coordinate-system coordinates. + + Returns: + The transformed solid. + + Raises: + ValueError: The matrix is not a finite 4x4 affine matrix, is singular + or a perspective map, or the kernel could not transform the solid. + """ + values = np.asarray(evaluate_value(matrix), dtype=float) + if values.shape != (4, 4) or not np.isfinite(values).all(): + raise ValueError("matrix must be a finite 4x4 affine matrix") + if not np.allclose(values[3], (0, 0, 0, 1), rtol=0, atol=1e-12): + raise ValueError("perspective transforms cannot define an affine solid") + if abs(float(np.linalg.det(values[:3, :3]))) < 1e-14: + raise ValueError("matrix must be nonsingular") + cs = get_current_cs() + basis = np.column_stack([cs.transform_vector(np.eye(3)[i]) for i in range(3)]) + origin = cs.transform_point(np.zeros(3)) + linear = basis @ values[:3, :3] @ basis.T + translation = origin + basis @ values[:3, 3] - linear @ origin + transform = gp_GTrsf() + for row in range(3): + for col in range(3): + transform.SetValue(row + 1, col + 1, float(linear[row, col])) + transform.SetValue(row + 1, 4, float(translation[row])) + copied = BRepBuilderAPI_Copy(solid.wrapped, True, False).Shape() + builder = BRepBuilderAPI_GTransform(copied, transform, True) + builder.Build() + if not builder.IsDone(): + raise ValueError("kernel could not transform the solid") + return _finish(solid, builder.Shape(), "make_transform_rsolid", {"matrix": matrix}, [solid]) + __all__ = tuple(name for name in globals() if not name.startswith("__")) diff --git a/test/test_auto_docs_gen.py b/test/test_auto_docs_gen.py index 69af0509..d9c5c05d 100644 --- a/test/test_auto_docs_gen.py +++ b/test/test_auto_docs_gen.py @@ -682,3 +682,55 @@ def _private_helper(): if __name__ == "__main__": unittest.main() + + +class TestPublicApiIsDocumented(unittest.TestCase): + """Whatever the package exports has a doc, and the docs are what the + generator writes: a new module cannot be left out of the generator's + list, and a docstring cannot change without the docs following.""" + + ROOT = Path(__file__).resolve().parents[1] + PACKAGE = ROOT / "src/simplecadapi" + DOCS = ROOT / "docs/skill/references/docs/api" + + def test_every_operators_module_is_scanned(self): + scanned = {path.resolve() for path in auto_docs_gen._default_source_files(self.PACKAGE)} + for path in sorted((self.PACKAGE / "operators").glob("*.py")): + if path.name.startswith("_"): + continue + self.assertIn(path.resolve(), scanned, f"{path.name} is not documented by the generator") + + def test_every_public_operator_function_has_its_doc(self): + import inspect + + import simplecadapi + + missing = [] + for name in simplecadapi.__all__: + obj = getattr(simplecadapi, name, None) + # an alias (cut = cut_rsolid) is documented under its real name + if not inspect.isfunction(obj) or obj.__name__ != name: + continue + source = Path(inspect.getsourcefile(obj)).resolve() + if (self.PACKAGE / "operators") not in source.parents or source.name.startswith("_"): + continue + if not (self.DOCS / f"{name}.md").is_file(): + missing.append(name) + self.assertEqual(missing, []) + + def test_the_committed_docs_are_what_the_generator_writes(self): + with tempfile.TemporaryDirectory() as tmp_dir: + out = Path(tmp_dir) / "api" + generator = auto_docs_gen.APIDocumentGenerator( + source_files=auto_docs_gen._resolve_source_files(None), + output_dirs=[out], + clean_stale=True, + quiet=True, + ) + generator.extract_apis() + generator.generate_markdown_docs() + generated = {path.name: path.read_text(encoding="utf-8") for path in out.glob("*.md")} + committed = {path.name: path.read_text(encoding="utf-8") for path in self.DOCS.glob("*.md")} + self.assertEqual(sorted(set(generated) ^ set(committed)), [], "files differ; run tools/auto_docs_gen.py") + stale = sorted(name for name in committed if committed[name] != generated[name]) + self.assertEqual(stale, [], "docs out of date; run tools/auto_docs_gen.py") diff --git a/test/test_draft.py b/test/test_draft.py index 68ba2828..328eeb95 100644 --- a/test/test_draft.py +++ b/test/test_draft.py @@ -6,7 +6,7 @@ import simplecadapi as s from simplecadapi import ql -from simplecadapi.operators.draft import draft_rsolid +from simplecadapi import draft_rsolid from simplecadapi.recording.graph import GraphSession diff --git a/test/test_limited_extrusion.py b/test/test_limited_extrusion.py index 4b1047cd..a4124c21 100644 --- a/test/test_limited_extrusion.py +++ b/test/test_limited_extrusion.py @@ -8,7 +8,7 @@ import simplecadapi as s from simplecadapi.kernel.ocp_booleans import solids_of from simplecadapi.kernel.ocp_properties import center_of_mass, volume -from simplecadapi.operators.limited_extrusion import extrude_until_rsolid +from simplecadapi import extrude_until_rsolid from simplecadapi.recording.graph import GraphSession diff --git a/test/test_operator_layout.py b/test/test_operator_layout.py new file mode 100644 index 00000000..f89ac891 --- /dev/null +++ b/test/test_operator_layout.py @@ -0,0 +1,63 @@ +"""Where the operators live. + +An operator is implemented in the module of its family (transforms next to +``translate_shape``, booleans next to ``union_rsolid``, features next to +``extrude_rsolid``). The one-feature modules the converters' operators were +first written in remain as thin aliases of the new places: old imports keep +working and say where to import from instead. +""" + +from __future__ import annotations + +import importlib +import warnings + +import pytest + +import simplecadapi + +HOME = { + "scale_rsolid": "transform", + "transform_rsolid": "transform", + "boolean_rsolidlist": "boolean", + "offset_rsolid": "features", + "draft_rsolid": "features", + "extrude_taper_rsolid": "features", + "extrude_until_rsolid": "features", + "sweep_multisection_rsolid": "features", +} + +OLD = { + "deformation": ["scale_rsolid", "transform_rsolid", "offset_rsolid", "boolean_rsolidlist"], + "draft": ["draft_rsolid"], + "section_features": ["extrude_taper_rsolid", "sweep_multisection_rsolid"], + "limited_extrusion": ["extrude_until_rsolid"], +} + + +@pytest.mark.parametrize("name,module", sorted(HOME.items())) +def test_an_operator_is_implemented_in_the_module_of_its_family(name, module): + assert getattr(simplecadapi, name).__module__ == f"simplecadapi.operators.{module}" + + +@pytest.mark.parametrize("old,names", sorted(OLD.items())) +def test_the_old_module_paths_still_work_and_point_to_the_new_place(old, names): + module = importlib.import_module(f"simplecadapi.operators.{old}") + for name in names: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + found = getattr(module, name) + assert found is getattr(simplecadapi, name) + assert any(issubclass(w.category, DeprecationWarning) and HOME[name] in str(w.message) for w in caught), name + + +def test_a_name_that_never_existed_is_still_an_attribute_error(): + module = importlib.import_module("simplecadapi.operators.deformation") + with pytest.raises(AttributeError): + module.no_such_operator + + +def test_the_package_root_exports_each_once(): + assert len(simplecadapi.__all__) == len(set(simplecadapi.__all__)) + for name in HOME: + assert name in simplecadapi.__all__ diff --git a/tools/auto_docs_gen.py b/tools/auto_docs_gen.py index 22071145..56f2e241 100644 --- a/tools/auto_docs_gen.py +++ b/tools/auto_docs_gen.py @@ -191,6 +191,15 @@ def _package_root_from(module_file: Path | str | None = None) -> Path: def _default_source_files(package_root: Path) -> List[Path]: source_files = [package_root / name for name in DEFAULT_SOURCE_FILENAMES] + # Every operators module is part of the API surface: found, not listed, so + # a new one cannot be left out (four were, and their docs went stale). + listed = set(source_files) + operators_root = package_root / "operators" + source_files.extend( + path + for path in sorted(operators_root.glob("*.py")) + if not path.name.startswith("_") and path not in listed + ) translator_root = package_root / "translator" for backend_dir in sorted(translator_root.glob("*_translator")): source_files.extend([backend_dir / "api.py", backend_dir / "translator.py"]) @@ -393,6 +402,9 @@ def _should_include_function( return False if module_name in FULL_PUBLIC_FUNCTION_MODULES: return True + if module_name.startswith("operators/") and exported_names: + # a module nobody listed: what the package exports is the API + return name in exported_names if _translator_backend_name(module_name) is not None: return True if module_name.startswith("inspect/brep/"): diff --git a/uv.lock b/uv.lock index 59754e79..cd1eb793 100644 --- a/uv.lock +++ b/uv.lock @@ -3237,7 +3237,7 @@ wheels = [ [[package]] name = "simplecadapi" -version = "2.2.6.post1" +version = "2.2.6.post2" source = { editable = "." } dependencies = [ { name = "cadquery-ocp" },