Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 68 additions & 22 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ on:
description: Existing v-prefixed tag to release (a branch or tag name when dry_run is set)
required: true
type: string
platforms:
description: "Wheel platforms to build: all, or a comma list of linux-x86_64, linux-aarch64, macos-arm64, windows-x86_64"
required: false
default: all
type: string
dry_run:
description: Build and verify everything, but publish nothing (no GitHub release, no PyPI upload)
required: false
Expand Down Expand Up @@ -57,6 +62,7 @@ jobs:
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
python-version: "3.12"
enable-cache: false

- name: Validate tag and project version
shell: bash
Expand Down Expand Up @@ -114,31 +120,46 @@ jobs:
path: src/simplecadapi/editor/webui
if-no-files-found: error

plan:
# Which wheels to build. A release builds all four; a dry run may pick some (to iterate on one platform).
name: Plan the wheels
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- name: Select the platforms
id: matrix
shell: bash
env:
PLATFORMS: ${{ github.event_name == 'workflow_dispatch' && inputs.platforms || 'all' }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os
every = [
{"name": "linux-x86_64", "os": "ubuntu-latest", "archs": "x86_64", "occt_cache": "/home/runner/occt-cache"},
{"name": "linux-aarch64", "os": "ubuntu-24.04-arm", "archs": "aarch64", "occt_cache": "/home/runner/occt-cache"},
{"name": "macos-arm64", "os": "macos-14", "archs": "arm64", "occt_cache": "/tmp/occt"},
{"name": "windows-x86_64", "os": "windows-2022", "archs": "AMD64", "occt_cache": "C:\\occt"},
]
wanted = [p.strip() for p in os.environ["PLATFORMS"].split(",") if p.strip()]
chosen = every if wanted in ([], ["all"]) else [e for e in every if e["name"] in wanted]
unknown = [p for p in wanted if p != "all" and p not in {e["name"] for e in every}]
if unknown or not chosen:
raise SystemExit(f"unknown platforms {unknown}; choose from {[e['name'] for e in every]}")
print("matrix=" + json.dumps({"include": chosen}))
PY

wheels:
name: Wheels (${{ matrix.name }})
needs: webui
needs: [webui, plan]
runs-on: ${{ matrix.os }}
timeout-minutes: 240
strategy:
fail-fast: false
matrix:
include:
- name: linux-x86_64
os: ubuntu-latest
archs: x86_64
occt_cache: /home/runner/occt-cache
- name: linux-aarch64
os: ubuntu-24.04-arm
archs: aarch64
occt_cache: /home/runner/occt-cache
- name: macos-arm64
os: macos-14
archs: arm64
occt_cache: /tmp/occt
- name: windows-x86_64
os: windows-2022
archs: AMD64
occt_cache: C:\occt
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
steps:
- name: Check out tagged revision
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
Expand All @@ -153,8 +174,11 @@ jobs:

# The static OpenCASCADE prefix. On Linux the build runs inside the manylinux container, which gets this directory
# mounted at /opt/occt; elsewhere cibuildwheel's before-all writes to the path configured in pyproject.toml.
# Restored here and saved by the explicit step after the build, which runs even when the wheel build fails:
# the default post-step saves only a successful job, and rebuilding OCCT costs the better part of an hour.
- name: Restore the static OpenCASCADE build
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: occt
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ matrix.occt_cache }}
key: occt-${{ matrix.name }}-${{ hashFiles('src/cpp/tools/build_occt.py') }}
Expand All @@ -179,11 +203,32 @@ jobs:
# share the cached prefix with the container (before-all in pyproject.toml builds OCCT only if it is absent)
CIBW_CONTAINER_ENGINE: "docker; create_args: --volume ${{ matrix.occt_cache }}:/opt/occt"

- name: Build the wheels (macOS, Windows)
if: runner.os != 'Linux'
- name: Build the wheels (macOS)
if: runner.os == 'macOS'
uses: pypa/cibuildwheel@b9e566a2b284570af229f6c2a33673410d0389a3 # v4.3.4
env:
CIBW_ARCHS: ${{ matrix.archs }}

- name: Build the wheels (Windows)
if: runner.os == 'Windows'
uses: pypa/cibuildwheel@b9e566a2b284570af229f6c2a33673410d0389a3 # v4.3.4
env:
CIBW_ARCHS: ${{ matrix.archs }}
# the MSVC environment is active (above): Ninja, every translation unit in parallel, and keep going after a
# failure so one run reports all the compiler errors rather than the first
CIBW_ENVIRONMENT_WINDOWS: >-
SCAD_OCCT_PREFIX="C:/occt"
CMAKE_ARGS="-DSCAD_OCCT_PREFIX=C:/occt"
CMAKE_GENERATOR="Ninja"
SKBUILD_BUILD_TOOL_ARGS="-k;0"

- name: Save the static OpenCASCADE build
if: always() && steps.occt.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ matrix.occt_cache }}
key: occt-${{ matrix.name }}-${{ hashFiles('src/cpp/tools/build_occt.py') }}

- name: Keep the wheels
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
Expand Down Expand Up @@ -213,6 +258,7 @@ jobs:
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
python-version: "3.12"
enable-cache: false

- name: Build the source distribution
run: uv build --sdist --out-dir dist
Expand Down
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,25 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and
## 2.2.7b1 — 2026-10-24

### Added
- `cut_rsolid_list(base, *tools)`: the boolean cut that keeps every piece. A slot through a bar returns both halves, a
tool that encloses the body returns an empty list. Recorded as `make_cut_rsolid_list` and replayable.
- Prebuilt wheels for Linux x86_64, Linux aarch64, macOS arm64 and Windows x86_64, each for Python 3.10-3.13,
built by the release workflow against one static OpenCASCADE 7.9.3.

### Changed
- The geometry engine is C++ on OpenCASCADE, shipped in the wheel as `simplecadapi._cpp`; one distribution, one
import package. The public API and the previous test suite are unchanged.
import package. The public API is unchanged, and the previous test suite passes (three tests that relied on a dropped
sliver of a tangent cut now use a tool clear of the wall).
- Every message the library writes is English; the tag-validation and semantic-query errors render in full with
possible causes and fixes.
- macOS wheels require macOS 14 or later.

### Fixed
- `cut_rsolid` no longer drops pieces silently. When a cut split the body it returned only the first piece (a slot
through a 100x20x10 bar reported half the volume); it now raises and points to `cut_rsolid_list`. A tool that only
touches a wall tangentially (a cylinder of radius 2 at (3, 3) in a 10x10 block) leaves a separate sliver and now
raises too.

### Removed
- Dependencies `cadquery-ocp`, `ocp-gordon` and `py-slvs`; the `SCA_BACKEND` switch.

Expand Down
2 changes: 1 addition & 1 deletion docs/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ it and stop that route; never invent an alternative route.
boundary comment `# ---- feature: <slug> (<role>) ----`; 2D
profiles and planar paths go through the sketch API, primitives
only when the shape is completely contained in the basic form.
4. Booleans (`union_rsolid`, `cut_rsolid`, `intersect_rsolid`)
4. Booleans (`union_rsolid`, `cut_rsolid`, `cut_rsolid_list`, `intersect_rsolid`)
accept mixed inputs and return exactly one `Solid`; union
defaults to `glue=False` with a conservative scale-relative
tolerance and fails explicitly when it cannot produce one
Expand Down
4 changes: 4 additions & 0 deletions docs/skill/references/discipline/failure-and-repair.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ no patching exported or solved artifacts.
- Isolate and simplify the failing base/tool pair.
- `cut_rsolid(..., skip_non_intersecting=False)` is the strict diagnostic mode;
with the default `True`, non-intersecting tools may be ignored.
- `cut_rsolid` expected a single Solid but the kernel returned several: the cut
split the body. If that is intended, call `cut_rsolid_list`; if not, the tool
crosses the body completely or touches a wall tangentially — resize or
reposition it.
- After repeated failure, reconsider operation order — not just retry the same
cut harder.

Expand Down
1 change: 1 addition & 0 deletions docs/skill/references/docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ This index includes generated docs for the public SimpleCAD API surface, includi
## Boolean Operations

- [cut_rsolid](cut_rsolid.md) *(from operators/boolean.py)* `top-level`
- [cut_rsolid_list](cut_rsolid_list.md) *(from operators/boolean.py)* `top-level`
- [intersect_rsolid](intersect_rsolid.md) *(from operators/boolean.py)* `top-level`
- [union_rsolid](union_rsolid.md) *(from operators/boolean.py)* `top-level`

Expand Down
4 changes: 4 additions & 0 deletions docs/skill/references/docs/api/cut_rsolid.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,7 @@ sequences, and returns a single `Solid`.
## Returns

Solid: The cut result solid.

## Raises

- **SimpleCADError**: The cut produced no solid, or split the body into several (a slot through a bar). Use ``cut_rsolid_list`` to keep every piece.
49 changes: 49 additions & 0 deletions docs/skill/references/docs/api/cut_rsolid_list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# cut_rsolid_list

## API Definition

```python
def cut_rsolid_list(*solids, skip_non_intersecting=True, tracking_policy='full')
```

*Source: operators/boolean.py*

## Import Surface

- top-level: `from simplecadapi import cut_rsolid_list`

## Description

Compute the boolean difference of solids and keep every piece.

A cut can split the body (a slot through a bar leaves two parts) or consume it
(a tool that encloses the whole body leaves nothing). ``cut_rsolid`` refuses
such a result because it returns exactly one ``Solid``; this operator returns
them all. A tool is cut from every piece it meets, so several tools may split
a body into several parts.

``pieces = cut_rsolid_list(bar, slot)`` returns both halves of a bar cut by
a through slot. Use ``cut_rsolid`` when the result must be one solid.

## Parameters

### solids

- **Description**: One or more Solid objects or sequences of Solid. Nested sequences are flattened before processing; the first solid is the base, the rest are subtracted in order.

### skip_non_intersecting

- **Description**: When True, a tool that meets none of the current pieces is ignored; when False it raises.

### tracking_policy

- **Description**: FULL computes topology history and lineage. GRAPH keeps the replayable operation node without computing a TopoDelta.

## Returns

list[Solid]: Every piece of the cut, in kernel order; empty when the tools
consume the base.

## Raises

- **SimpleCADError**: An input is not a valid Solid, or the kernel could not compute the cut.
4 changes: 4 additions & 0 deletions docs/skill/references/domains/part-modeling.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ consult their own documentation before the first call.
- `cut_rsolid` skips non-intersecting tools by default
(`skip_non_intersecting=True`); pass `skip_non_intersecting=False` for strict
diagnostics — a skipped cut is never a completed feature.
- A cut can split a body (a slot through a bar leaves two parts) or consume it
(a tool that encloses the whole body). `cut_rsolid` returns one `Solid`, so it
raises instead of dropping parts; use `cut_rsolid_list(base, tools)` to get
every piece as a list (empty when nothing is left).

## Validation gates

Expand Down
9 changes: 9 additions & 0 deletions docs/updates/2.2.7b1.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ The models computed are the same (volumes agree to 1e-12 relative).
* BREP rendering through VTK is part of the `inspect` extra (`pip install "simplecadapi[inspect]"`); `cadquery-ocp` used
to bring VTK in, so it is no longer installed by default.

## Booleans that split a body

* New `cut_rsolid_list(base, *tools)` returns every piece of a cut: a slot through a bar gives both halves, a tool that
encloses the whole body gives an empty list. It is recorded as `make_cut_rsolid_list` and replays.
* `cut_rsolid` used to return only the first piece when a cut split the body, silently losing the rest (a slot through a
100x20x10 bar reported half the volume). It now raises and names `cut_rsolid_list`. A tool that only touches a wall
tangentially also leaves a separate sliver, so a script that relied on the dropped sliver needs a tool that does not
touch the wall, or `cut_rsolid_list`.

## Errors

* Every message the library writes is English.
Expand Down
8 changes: 8 additions & 0 deletions docs/updates/2.2.7b1.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ macOS Intel、musl Linux、Windows ARM 没有预编译包;这些平台由源
* 通过 VTK 的 BREP 渲染归入 `inspect` 额外依赖(`pip install "simplecadapi[inspect]"`);原来 `cadquery-ocp` 会顺带装上 VTK,
现在默认不再安装。

## 会把实体切成多段的布尔运算

* 新增 `cut_rsolid_list(base, *tools)`,返回切割产生的每一段:在棒上开通槽得到两段,工具把整个实体包住则得到空列表。
它记录为 `make_cut_rsolid_list`,可以回放。
* 以前切割把实体分成多段时,`cut_rsolid` 只返回第一段,其余静默丢失(100x20x10 的棒开通槽,体积只剩一半)。
现在它会报错并指向 `cut_rsolid_list`。工具只与壁面相切时也会留下一块独立的残片,所以依赖"丢掉残片"的脚本需要把工具
改成不碰壁面,或改用 `cut_rsolid_list`。

## 报错

* 库写出的所有消息都是英文。
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Issues = "https://github.com/PhySpace/SimpleCADAPI/issues"

[build-system]
# One distribution: the Python package and the compiled C++ module (``simplecadapi._cpp``, src/cpp/) it runs on.
requires = ["scikit-build-core>=0.10", "pybind11>=2.13"]
requires = ["scikit-build-core>=0.10", "pybind11>=2.13", "ninja>=1.11; platform_system == 'Windows'"]
build-backend = "scikit_build_core.build"

[tool.scikit-build]
Expand Down
11 changes: 11 additions & 0 deletions src/cpp/bindings/python/bind_ops_features.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,17 @@ void bind_booleans(py::module_& m) {
py::kw_only(), py::arg("skip_non_intersecting") = true, py::arg("tracking_policy") = "full",
"Compute the boolean difference of solids: the first is the base, the rest are subtracted in order.");

m.def(
"cut_rsolid_list",
[](const py::args& solids, bool skip_non_intersecting, const py::object& tracking_policy) {
const std::vector<Solid> operands = flattened(solids, "cut_rsolid_list");
const CutOptions options{skip_non_intersecting, policy_text(tracking_policy)};
return solids_to_py(unlocked(false, [&] { return cut_rsolid_list(operands, options); }));
},
py::kw_only(), py::arg("skip_non_intersecting") = true, py::arg("tracking_policy") = "full",
"Compute the boolean difference of solids and keep every piece: a cut that splits the body returns each part, "
"and one that consumes the body returns an empty list.");

m.def(
"intersect_rsolid",
[](const py::args& solids) {
Expand Down
5 changes: 5 additions & 0 deletions src/cpp/include/scad/ops/features/booleans.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ struct CutOptions {
// The first solid minus every other. Error "cut_rsolid".
Solid cut_rsolid(const std::vector<Solid>& solids, const CutOptions& options = {});

// The same cut, keeping every piece: a cut that splits the body (a slot through a bar) returns each part, and one that
// consumes the body returns an empty list. cut_rsolid refuses such a result instead of dropping parts.
// Error "cut_rsolid_list"; recorded as make_cut_rsolid_list.
std::vector<Solid> cut_rsolid_list(const std::vector<Solid>& solids, const CutOptions& options = {});

// The overlap of all `solids`. Error "intersect_rsolid".
Solid intersect_rsolid(const std::vector<Solid>& solids);

Expand Down
4 changes: 3 additions & 1 deletion src/cpp/include/scad/semantic/tracked_result.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include <map>
#include <optional>
#include <vector>
#include <string>

#include "scad/core/value.hpp"
Expand Down Expand Up @@ -43,7 +44,8 @@ struct TrackedResult {

// A tracked boolean. `solid` is empty when the boolean produced no solid.
struct TrackedBooleanResult {
std::optional<Solid> solid;
std::optional<Solid> solid; // the first solid of the kernel's answer
std::vector<Solid> solids; // every solid of the answer (a cut can split the body)
TopoDelta delta;
DeltaEntries delta_entries;
};
Expand Down
Loading
Loading