diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0fc901d..10e90df8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,9 +122,41 @@ jobs: if: always() run: sccache --show-stats + oracle: + name: Reference tests + needs: changes + if: needs.changes.outputs.native == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CARGO_INCREMENTAL: "0" + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: "true" + SCCACHE_IGNORE_SERVER_IO_ERROR: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: oracle + - name: Set up compiler cache + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 + - name: Build the command line + run: cargo build -p solvecraft-cli --profile ci --locked + - name: Replay tests/oracle and compare with expected.json + run: cargo xtask oracle --cli target/ci/solvecraft-cli + - name: Upload the report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: oracle-report + path: docs/oracle.md + # Preserve the existing check name for branch protection, including docs-only PRs. ci: - needs: [changes, checks, clippy, tests] + needs: [changes, checks, clippy, tests, oracle] if: always() runs-on: ubuntu-24.04 steps: @@ -135,12 +167,14 @@ jobs: NATIVE: ${{ needs.changes.outputs.native }} CLIPPY: ${{ needs.clippy.result }} TESTS: ${{ needs.tests.result }} + ORACLE: ${{ needs.oracle.result }} run: | test "$CHANGES" = success test "$CHECKS" = success if [ "$NATIVE" = true ]; then test "$CLIPPY" = success test "$TESTS" = success + test "$ORACLE" = success fi wasm: diff --git a/.github/workflows/freecad-oracle.yml b/.github/workflows/freecad-oracle.yml new file mode 100644 index 00000000..3e74f15e --- /dev/null +++ b/.github/workflows/freecad-oracle.yml @@ -0,0 +1,47 @@ +# Optional, run by hand: rebuild the reference cases whose expected values come from FreeCAD +# (tools/freecad-oracle/) and show what changed. FreeCAD runs as a separate program on the runner; +# it is not a dependency of SolidCraft. Nothing is committed: review the diff and the uploaded +# files, then commit them in a pull request if the change is intended. +name: FreeCAD reference values + +on: + workflow_dispatch: + inputs: + cases: + description: "Case directories (space separated); empty = every case whose source is FreeCAD" + type: string + default: "" + +permissions: + contents: read + +jobs: + regenerate: + name: Regenerate FreeCAD expected values + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install FreeCAD + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends freecad + freecadcmd --version || true + - name: Rebuild the cases + env: + CASES: ${{ inputs.cases }} + run: | + if [ -z "$CASES" ]; then + CASES=$(grep -l '"source": *"freecad' tests/oracle/*/expected.json | xargs -n1 dirname | tr '\n' ' ') + fi + # shellcheck disable=SC2086 + tools/freecad-oracle/run.sh --write $CASES + - name: Show the changes + run: git diff --stat && git diff -- tests/oracle/*/expected.json + - name: Upload the regenerated files + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: freecad-expected + path: | + tests/oracle/*/expected.json + tests/oracle/*/reference.stl diff --git a/AGENTS.md b/AGENTS.md index 9cd197be..a7f9951c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,7 @@ compatibility notes. ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/` - (`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their - recipes and measurements). + (`menu-tree.json` is Fusion's toolbar dump). 2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what is rejected for licence reasons). 3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask @@ -49,7 +48,9 @@ People trust SolidCraft with their designs; a crash loses their work. **This out Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv` holds ids and names, nothing else). File formats come from public specifications. **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace, - CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend + CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/` runs + FreeCAD as a separate program through its public scripting API to compute reference values; + nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend operations are our own. - **Assets — absolutely essential.** SolidCraft contains **no Autodesk iconography, images, fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any @@ -123,8 +124,9 @@ before the draft is verified. `ui.render` or `solvecraft-cli snapshot`. - Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`, `solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`. -- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares - with Fusion's `measure.json` (writes `docs/oracle.md`). +- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with + each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected + values never come from Fusion or any other commercial CAD product. - Build with `CARGO_TARGET_DIR=/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`. ## Roadmap diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 2a33880b..f4ae45e2 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,6 +27,26 @@ Generated-in-code assets are original and have no file to list: | docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 | | crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 | | apps/solvecraft-web/fonts/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in apps/solvecraft-web/fonts/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 | +| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/30-fillet-after-shell/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/30-fillet-after-shell/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/31-fillet-l-all-edges/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/31-fillet-l-all-edges/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/34-chamfer-then-fillet/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/34-chamfer-then-fillet/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/35-fillet-curved-curved/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/35-fillet-curved-curved/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/36-corner-blend-two-radii/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/36-corner-blend-two-radii/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/37-pyramid-apex-blend/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/37-pyramid-apex-blend/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/39-tee-pipe/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/39-tee-pipe/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/41-tube-radial-holes/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/41-tube-radial-holes/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/42-overlapping-patterns/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/42-overlapping-patterns/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/47-draft-curved-pocket/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/47-draft-curved-pocket/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/51-loft-rect-circle-offset/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/51-loft-rect-circle-offset/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/56-holes-on-curved-face/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/56-holes-on-curved-face/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/58-phone-stand/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/58-phone-stand/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/59-enclosure-base/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/59-enclosure-base/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | +| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 | ## Code adapted from sibling projects diff --git a/CLAUDE.md b/CLAUDE.md index 4d5dce6c..102d885e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,7 @@ compatibility notes. ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/` - (`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their - recipes and measurements). + (`menu-tree.json` is Fusion's toolbar dump). 2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what is rejected for licence reasons). 3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask @@ -49,7 +48,9 @@ People trust SolidCraft with their designs; a crash loses their work. **This out Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv` holds ids and names, nothing else). File formats come from public specifications. **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace, - CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend + CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/` runs + FreeCAD as a separate program through its public scripting API to compute reference values; + nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend operations are our own. - **Assets — absolutely essential.** SolidCraft contains **no Autodesk iconography, images, fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any @@ -100,8 +101,9 @@ before the draft is verified. `ui.render` or `solvecraft-cli snapshot`. - Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`, `solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`. -- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares - with Fusion's `measure.json` (writes `docs/oracle.md`). +- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with + each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected + values never come from Fusion or any other commercial CAD product. - Build with `CARGO_TARGET_DIR=/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`. ## Roadmap diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8c87dcb..72595b8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,7 @@ the most complete description of the house style. |---|---|---| | Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) | | UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` | -| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) | +| Reference tests | `cargo xtask oracle` | `tests/oracle/` (see below) | **UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point, press keys, then assert on the result. @@ -150,11 +150,15 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test `cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not depend on wall-clock timing. -**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe. -SolidCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's -measurements; [docs/oracle.md](docs/oracle.md) has the current results. The recipes and -measurements live outside the repo (`plan/`, gitignored), because they come from a Fusion -install. If you work on geometry and want to check against it, ask in an issue. +**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json` +in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares +body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`, +the surface itself). Expected values are either worked out in closed form +(`tools/oracle-analytic/`) or computed by building the same recipe in FreeCAD, run as a separate +program (`tools/freecad-oracle/`); each file says which. [docs/oracle.md](docs/oracle.md) has +the current results, and CI runs `cargo xtask oracle` on every pull request. A case that fails +because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never +change an expected value to match SolidCraft's output. ## Sending a change diff --git a/README.md b/README.md index 0ea28d8f..cb585e3f 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ developer guide and command reference; sources in [docs/book](docs/book)), and t Linux Flatpak installation needs the Freedesktop runtime from Flathub as well as the SolidCraft application feed. See [install and launch commands](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak). -**Status: early but usable for simple parts.** The historical benchmark report records -76 of 76 reference parts matching volume, area and topology ([docs/oracle.md](docs/oracle.md)). +**Status: early but usable for simple parts.** 75 of 76 public reference parts +match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box +([docs/oracle.md](docs/oracle.md)); the other is a tracked bug. These checks do not establish complete geometric or workflow equivalence. Feature coverage is tracked in [docs/parity.md](docs/parity.md) and [ROADMAP.md](ROADMAP.md). Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), diff --git a/ROADMAP.md b/ROADMAP.md index 596a0127..79231e73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat |---|---| | Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. | | Command parity (in scope) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity | -| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. | +| Reference tests | **75 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR; 75-sm-bracket-holes fails (#296). | | Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green | | Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) | @@ -50,6 +50,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 | +| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 | | M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | @@ -175,8 +176,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. containment shortcut. Spheres are built from six pole-free patches. - Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles that cut through curved faces (trimmed faces after booleans) are split onto the surface first. -- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass - `cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units, +- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units, product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres and regular tori are read exactly (signed/nested distances, units and face sense). Freeform offset surfaces and pcurve-only edges are not read yet, diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index aff72a9b..dd62bd43 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -8,8 +8,7 @@ //! solvecraft-cli exec [json-params] run one command on an empty design //! solvecraft-cli commands the command registry as JSON //! solvecraft-cli recipe translate an oracle recipe to a script -//! solvecraft-cli oracle ... replay recipes and compare with measure.json -//! solvecraft-cli step-corpus ... import part.step and compare with measure.json +//! solvecraft-cli oracle ... replay recipes and compare with expected.json //! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md) //! solvecraft-cli licences SolveCraft's licence and the third-party licences //! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file) @@ -20,7 +19,7 @@ mod fuzz; mod oracle; mod recipe; -mod seams; +mod spatial; use std::process::ExitCode; @@ -42,7 +41,6 @@ const USAGE: &str = "usage: solvecraft-cli recipe solvecraft-cli oracle ... [--json] solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE] - solvecraft-cli step-corpus ... [--json] [--iges] solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT] solvecraft-cli licences solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico @@ -73,7 +71,6 @@ fn main() -> ExitCode { recipe::to_script(&v).map(|s| println!("{}", pretty(&s))) }), Some("oracle") => oracle::run(&args[1..]), - Some("step-corpus") => oracle::step_corpus(&args[1..]), Some("fuzz") => fuzz::run(&args[1..]), Some("fuzz-one") => fuzz::one(&args[1..]), Some("mcp") => cmd_mcp(&args[1..]), diff --git a/apps/solvecraft-cli/src/oracle.rs b/apps/solvecraft-cli/src/oracle.rs index 063b0ac6..2d1b1ec8 100644 --- a/apps/solvecraft-cli/src/oracle.rs +++ b/apps/solvecraft-cli/src/oracle.rs @@ -1,129 +1,149 @@ -//! Replay oracle recipes and compare with Fusion's measurements; import oracle STEP files and -//! compare them with the same measurements. +//! Reference tests: replay `tests/oracle//recipe.json` through the command engine and +//! compare the part with `expected.json` (closed-form values, or values from an independent +//! kernel; see tests/oracle/README.md), and with `reference.stl` where one exists. + +#![allow(clippy::needless_range_loop)] use serde_json::{Value, json}; +use solvecraft_engine::geom::Vec3; -use crate::{read_json, recipe, seams}; +use crate::spatial::{self, Tri}; +use crate::{read_json, recipe}; -/// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4). +/// Default relative tolerance for volume and area (our curved-face tessellation is within ~2e-4). pub const REL_TOL: f64 = 1e-3; +/// Default tolerance for centre of mass and bounding box, as a fraction of the bbox diagonal. +const ABS_FRAC: f64 = 1e-3; +/// Chord tolerance used to triangulate our part for the surface-distance check (mm). +const MESH_TOL: f64 = 0.01; +/// Relative tolerance on the principal second moments of the two triangulations. +const INERTIA_REL: f64 = 2e-3; -/// Cases compared loosely (relative tolerance on volume and area, topology not compared), and why. -const APPROXIMATE: [(&str, f64, &str); 1] = [( - "49-helical-sweep", - 5e-3, - "Fusion sweeps along a spline fitted through helix points; SolidCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all", -)]; +/// What SolidCraft built: totals over every body. +struct Got { + body_count: usize, + volume: f64, + area: f64, + com: [f64; 3], + bbox: ([f64; 3], [f64; 3]), + faces: u64, +} + +fn v3(v: &Value) -> Option<[f64; 3]> { + let a = v.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?]) +} + +fn totals(m: &Value) -> Got { + let bodies = m["bodies"].as_array().cloned().unwrap_or_default(); + let (mut volume, mut area, mut faces) = (0.0, 0.0, 0); + let mut moment = [0.0; 3]; + let mut lo = [f64::INFINITY; 3]; + let mut hi = [f64::NEG_INFINITY; 3]; + for b in &bodies { + let v = b["volume_mm3"].as_f64().unwrap_or(f64::NAN); + volume += v; + area += b["area_mm2"].as_f64().unwrap_or(f64::NAN); + faces += b["faces"].as_u64().unwrap_or(0); + let c = v3(&b["center_of_mass"]).unwrap_or([f64::NAN; 3]); + let (bl, bh) = (v3(&b["bbox"]["min"]).unwrap_or([f64::NAN; 3]), v3(&b["bbox"]["max"]).unwrap_or([f64::NAN; 3])); + for k in 0..3 { + moment[k] += v * c[k]; + lo[k] = lo[k].min(bl[k]); + hi[k] = hi[k].max(bh[k]); + } + } + let com = moment.map(|x| x / volume); + Got { body_count: bodies.len(), volume, area, com, bbox: (lo, hi), faces } +} + +struct Checks { + list: Vec, + pass: bool, +} + +impl Checks { + fn push(&mut self, what: &str, got: Value, want: Value, ok: bool) { + self.pass &= ok; + self.list.push(json!({"check": what, "got": got, "want": want, "ok": ok})); + } + fn rel(&mut self, what: &str, g: f64, w: Option, rel: f64) { + let ok = w.is_some_and(|w| g.is_finite() && (g - w).abs() <= rel * w.abs().max(1.0)); + self.push(what, json!(g), json!(w), ok); + } + fn vec(&mut self, what: &str, g: [f64; 3], w: Option<[f64; 3]>, tol: f64) { + let ok = w.is_some_and(|w| g.iter().zip(w).all(|(g, w)| g.is_finite() && (g - w).abs() <= tol)); + self.push(what, json!(g), json!(w), ok); + } +} fn check_case(dir: &str) -> Value { let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default(); - let rp = format!("{dir}/recipe.json"); - let mp = format!("{dir}/measure.json"); - let (recipe, want) = match (read_json(&rp), read_json(&mp)) { - (Ok(r), Ok(m)) => (r, m), - (Err(e), _) | (_, Err(e)) => return json!({"case": name, "pass": false, "error": e}), + let recipe = match read_json(&format!("{dir}/recipe.json")) { + Ok(r) => r, + Err(e) => return json!({"case": name, "pass": false, "error": e}), }; + let want = read_json(&format!("{dir}/expected.json")).ok(); let script = match recipe::to_script(&recipe) { Ok(s) => s, Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}), }; let mut s = solvecraft_engine::Session::default(); if let Err(e) = s.run_script(&script) { - return json!({"case": name, "pass": false, "error": e.to_string()}); + return json!({"case": name, "pass": false, "error": e.to_string(), "source": want.as_ref().map(|w| w["source"].clone())}); } let got = match s.execute("inspect.measure", &json!({})) { - Ok(m) => m, + Ok(m) => totals(&m), Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}), }; - let mut checks = Vec::new(); - let mut pass = true; - let mut num = |what: &str, g: Option, w: Option, rel: f64| { - let ok = match (g, w) { - (Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0), - _ => false, - }; - pass &= ok; - checks.push(json!({"check": what, "got": g, "want": w, "ok": ok})); + let got_json = json!({ + "body_count": got.body_count, "volume_mm3": got.volume, "area_mm2": got.area, + "center_of_mass": got.com, "bbox": {"min": got.bbox.0, "max": got.bbox.1}, "faces": got.faces, + }); + let Some(want) = want else { + return json!({"case": name, "pass": false, "skipped": true, "error": "no expected.json yet", "got": got_json}); }; - // Cases where Fusion's part is itself an approximation: compare size loosely, not topology. - let approx = APPROXIMATE.iter().find(|(c, _, _)| *c == name); - let tol = approx.map(|(_, t, _)| *t).unwrap_or(REL_TOL); - let within = |k: &str, rel: f64| match (got["total"][k].as_f64(), want["total"][k].as_f64()) { - (Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0), - _ => false, - }; - let size_ok = - got["body_count"] == want["body_count"] && within("volume_mm3", tol) && (FUSION_AREA_OFF.contains(&name.as_str()) || within("area_mm2", tol)); - num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0); - num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), tol); - let area_off = FUSION_AREA_OFF.contains(&name.as_str()); - if !area_off { - num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), tol); + let only: Option> = want["compare"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()); + let wanted = |k: &str| want.get(k).is_some_and(|v| !v.is_null()) && only.as_ref().is_none_or(|o| o.iter().any(|x| x == k)); + let rel = want["tolerance"]["rel"].as_f64().unwrap_or(REL_TOL); + let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt(); + let want_bbox = v3(&want["bbox"]["min"]).zip(v3(&want["bbox"]["max"])); + let abs = want["tolerance"]["abs_mm"].as_f64().unwrap_or_else(|| ABS_FRAC * want_bbox.map(diag).unwrap_or_else(|| diag(got.bbox)).max(1.0)); + let mut c = Checks { list: Vec::new(), pass: true }; + if wanted("body_count") { + let ok = want["body_count"].as_u64() == Some(got.body_count as u64); + c.push("body_count", json!(got.body_count), want["body_count"].clone(), ok); } - let mut seam_note = None; - if approx.is_none() { - let exact = ["faces", "edges", "vertices"].iter().all(|k| got["total"][k].as_f64().is_some() && got["total"][k] == want["total"][k]); - let fusion_seams = want["bodies"] - .as_array() - .and_then(|bs| bs.iter().map(seams::normalised).try_fold((0, 0, 0), |a, n| n.map(|n| (a.0 + n.0, a.1 + n.1, a.2 + n.2)))); - match fusion_seams { - // Size matches and only the topology differs: compare with Fusion's seam splits merged. - Some((f, e, v)) if size_ok && !exact => { - let raw: Vec = ["faces", "edges", "vertices"] - .iter() - .map(|k| { - let ours: i64 = got["bodies"].as_array().into_iter().flatten().filter_map(|b| b["kernel"][k].as_i64()).sum(); - format!("{k} {ours}/{}", want["total"][k]) - }) - .collect(); - seam_note = Some(format!( - "topology compared seam-normalised: Fusion's {}/{}/{} faces/edges/vertices become {f}/{e}/{v} with closed-surface patches counted once and seam edges and vertices dropped (raw SolidCraft/Fusion: {})", - want["total"]["faces"], - want["total"]["edges"], - want["total"]["vertices"], - raw.join(", ") - )); - num("faces (seam-normalised)", got["total"]["faces"].as_f64(), Some(f as f64), 0.0); - num("edges (seam-normalised)", got["total"]["edges"].as_f64(), Some(e as f64), 0.0); - num("vertices (seam-normalised)", got["total"]["vertices"].as_f64(), Some(v as f64), 0.0); - } - _ => { - num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0); - num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0); - num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0); - } - } + if wanted("volume_mm3") { + c.rel("volume_mm3", got.volume, want["volume_mm3"].as_f64(), rel); + } + if wanted("area_mm2") { + c.rel("area_mm2", got.area, want["area_mm2"].as_f64(), rel); + } + if wanted("center_of_mass") { + c.vec("center_of_mass", got.com, v3(&want["center_of_mass"]), abs); + } + if wanted("bbox") { + c.vec("bbox.min", got.bbox.0, want_bbox.map(|b| b.0), abs); + c.vec("bbox.max", got.bbox.1, want_bbox.map(|b| b.1), abs); + } + if wanted("faces") { + let ok = want["faces"].as_u64() == Some(got.faces); + c.push("faces", json!(got.faces), want["faces"].clone(), ok); + } + let stl = format!("{dir}/reference.stl"); + if std::path::Path::new(&stl).is_file() && only.as_ref().is_none_or(|o| o.iter().any(|x| x == "surface")) { + let tol = want["tolerance"]["surface_mm"].as_f64().unwrap_or(0.05); + reference_checks(&s, &stl, tol, &mut c); } // Our STEP export of the rebuilt part: well formed, and read back with no warnings and the // same volume. - let (ok, note) = step_export_check(&s, got["total"]["volume_mm3"].as_f64().unwrap_or(0.0)); - pass &= ok; - checks.push(json!({"check": "step_export", "got": note, "want": "valid, re-imports cleanly", "ok": ok})); - let mut out = json!({"case": name, "pass": pass, "checks": checks}); - if let Some((_, _, why)) = approx { - out["note"] = json!(why); - } - if let Some(n) = seam_note { + let (ok, note) = step_export_check(&s, got.volume); + c.push("step_export", json!(note), json!("valid, re-imports cleanly"), ok); + let mut out = json!({"case": name, "pass": c.pass, "source": want["source"], "checks": c.list, "got": got_json}); + if let Some(n) = want["note"].as_str() { out["note"] = json!(n); } - if area_off { - out["note"] = json!( - "area not compared: Fusion reports this tight sweep's horn torus face at three times its area (π²·R·r quarter-turn value; 740 mm² against 246.7 by Pappus); volume and topology are compared" - ); - } - let effective: Vec = recipe["features"] - .as_array() - .into_iter() - .flatten() - .filter(|f| f.get("fusion_effective_extent").is_some()) - .map(|f| f["name"].as_str().unwrap_or("?").to_string()) - .collect(); - if !effective.is_empty() { - out["note"] = json!(format!( - "known Fusion difference: {} replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolidCraft's through-all goes truly through)", - effective.join(", ") - )); - } out } @@ -163,77 +183,43 @@ fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, Stri } } -/// Cases whose Fusion area is not the geometric one: Fusion reports the horn torus face of a -/// tight sweep (tube touching its axis) at three times π²·R·r, its quarter-turn area; the -/// volumes agree. Their area is not compared. -const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"]; - -/// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements: -/// body count, volume and area (relative 1e-3) and the faces read from the file. -/// Open the case's `part.step` (or `part.igs` with `iges`) and compare with measure.json. -fn check_step_case(dir: &str, iges: bool) -> Value { - let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default(); - let sp = format!("{dir}/{}", if iges { "part.igs" } else { "part.step" }); - let want = match read_json(&format!("{dir}/measure.json")) { - Ok(m) => m, - Err(e) => return json!({"case": name, "pass": false, "error": e}), - }; - let t0 = std::time::Instant::now(); - let mut s = solvecraft_engine::Session::default(); - let opened = match s.execute("doc.open", &json!({"path": sp})) { - Ok(v) => v, - Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}), - }; - let got = match s.execute("inspect.measure", &json!({})) { - Ok(m) => m, - Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}), - }; - let ms = t0.elapsed().as_secs_f64() * 1000.0; - // Faces read from the file (the kernel may split a face that wraps around a closed surface). - let file_faces = std::fs::read(&sp) - .ok() - .and_then(|b| { - let text = String::from_utf8_lossy(&b); - if iges { - solvecraft_engine::kernel::iges_import(&text).ok().map(std::sync::Arc::new) - } else { - solvecraft_engine::kernel::step_import_shared(&text).ok() +/// Our part as a triangle soup (curved faces refined onto the surface). +fn our_mesh(s: &solvecraft_engine::Session) -> Result, String> { + let mut ours: Vec = Vec::new(); + for b in &s.world_state().bodies { + let m = b.body.tessellate_with(MESH_TOL, true).map_err(|e| e.to_string())?; + let p = |i: u32| m.positions.get(i as usize).map(|v: &Vec3| [v.x, v.y, v.z]); + for t in &m.triangles { + if let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) { + ours.push([a, b, c]); } - }) - .map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::() as f64); - let mut checks = Vec::new(); - let mut pass = true; - let mut num = |what: &str, g: Option, w: Option, rel: f64| { - let ok = match (g, w) { - (Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0), - _ => false, - }; - pass &= ok; - checks.push(json!({"check": what, "got": g, "want": w, "ok": ok})); - }; - num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0); - num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), REL_TOL); - if !FUSION_AREA_OFF.contains(&name.as_str()) { - num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), REL_TOL); + } } - num("faces", file_faces, want["total"]["faces"].as_f64(), 0.0); - json!({ - "case": name, "pass": pass, "checks": checks, "ms": ms, - "bodies": opened["bodies"], "warnings": opened["warnings"], - "info": {"kernel_faces_merged": got["total"]["faces"], "edges": got["total"]["edges"], "want_edges": want["total"]["edges"], "vertices": got["total"]["vertices"], "want_vertices": want["total"]["vertices"]}, - }) + if ours.is_empty() { Err("empty mesh".into()) } else { Ok(ours) } } -pub fn step_corpus(args: &[String]) -> Result<(), String> { - let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect(); - if dirs.is_empty() { - return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into()); - } - let iges = args.iter().any(|a| a == "--iges"); - let results: Vec = dirs.iter().map(|d| check_step_case(d, iges)).collect(); - report(args, &results); - let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count(); - if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) } +/// Compare our part with the reference triangulation: the symmetric surface distance, and the +/// principal second moments of volume of both meshes (the shape's inertia, independent of how it +/// sits in space). +fn reference_checks(s: &solvecraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) { + let (ours, reference) = match (our_mesh(s), spatial::read_stl(stl)) { + (Ok(o), Ok(r)) if !r.is_empty() => (o, r), + (Err(e), _) | (_, Err(e)) => return c.push("surface_distance_mm", json!(e), json!(format!("<= {tol}")), false), + _ => return c.push("surface_distance_mm", json!("empty reference.stl"), json!(format!("<= {tol}")), false), + }; + let (lo, hi) = spatial::bounds(&reference); + let size = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max); + let h = spatial::hausdorff(&ours, &reference, size.max(1.0)); + let worst = h.a_to_b.max(h.b_to_a); + c.push( + "surface_distance_mm", + json!({"max": worst, "mean": h.mean, "ours_to_reference": h.a_to_b, "reference_to_ours": h.b_to_a, "worst_at": h.worst_at}), + json!(format!("<= {tol}")), + worst <= tol, + ); + let ((_, _, pa), (_, _, pb)) = (spatial::mass_properties(&ours), spatial::mass_properties(&reference)); + let ok = pa.iter().zip(pb).all(|(a, b)| (a - b).abs() <= INERTIA_REL * b.abs().max(1.0)); + c.push("principal_moments_mm5", json!(pa), json!(pb), ok); } fn report(args: &[String], results: &[Value]) { @@ -268,7 +254,7 @@ fn report(args: &[String], results: &[Value]) { pub fn run(args: &[String]) -> Result<(), String> { let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect(); if dirs.is_empty() { - return Err("oracle: give one or more case directories".into()); + return Err("oracle: give one or more case directories (each with recipe.json and expected.json)".into()); } let results: Vec = dirs.iter().map(|d| check_case(d)).collect(); report(args, &results); diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index 6669eaf6..5f7708b9 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -1,4 +1,4 @@ -//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft command scripts. +//! Reference-test recipes (`tests/oracle/*/recipe.json`) → SolidCraft command scripts. //! //! A recipe lists features with sketch entities in sketch-local coordinates; point references //! (`l1.end`) share points. Each feature becomes one or more commands. @@ -85,14 +85,6 @@ fn sketch_cmds(f: &Value, out: &mut Vec) -> Result<(), String> { /// Extent parameters for the Extrude command. fn extent(f: &Value, p: &mut Value) { - // Where Fusion's own result differs from the geometric meaning (its through-all is sized - // from vertices), the recipe records the extent Fusion actually used: replay that. - if let Some(fx) = f.get("fusion_effective_extent").filter(|x| x.get("type").and_then(Value::as_str) == Some("distance")) { - let dir = fx.get("direction").and_then(Value::as_str).unwrap_or("positive"); - p["distance"] = fx.get("distance").cloned().unwrap_or(Value::Null); - p["direction"] = json!(if dir.starts_with("negative") { "negative" } else { "positive" }); - return; - } let e = f.get("extent").cloned().unwrap_or(Value::Null); let ty = e.get("type").and_then(Value::as_str).unwrap_or("distance"); let dist = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null); @@ -545,8 +537,8 @@ fn edge_points(f: &Value) -> Value { ) } -/// Fusion names a pattern's source features with an occurrence suffix ("Hole1 (1)"); the -/// features are the recipe's own names. +/// A pattern's source features may carry an occurrence suffix ("Hole1 (1)"); the features are +/// the recipe's own names. fn feature_refs(v: Option<&Value>) -> Value { let list: Vec = v .and_then(Value::as_array) diff --git a/apps/solvecraft-cli/src/seams.rs b/apps/solvecraft-cli/src/seams.rs deleted file mode 100644 index 5911cec1..00000000 --- a/apps/solvecraft-cli/src/seams.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Seam-normalised topology of Fusion's measurements. -//! -//! Fusion splits a closed surface along seams: a full cylinder can be two half faces, and a -//! closed curve crossing a seam becomes two edges with a vertex on the seam. SolveCraft's merged -//! counts already count each closed surface patch once and ignore seam edges and vertices, so -//! the oracle normalises Fusion's side the same way from its face, edge and vertex lists: -//! - faces cut from one periodic surface (cylinder, cone, sphere, torus) and separated only by -//! seam edges count once; -//! - a seam edge lies on that surface and on no other face's surface: it is dropped; -//! - a vertex left with exactly two edges is no corner (a solid's corners have three or more): -//! the two edges join, and a curve that closes on itself keeps one vertex. - -use std::collections::HashMap; - -use serde_json::Value; - -type V3 = [f64; 3]; - -const TOL: f64 = 1e-4; - -fn v3(v: &Value) -> Option { - let a = v.as_array()?; - Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?]) -} -fn sub(a: V3, b: V3) -> V3 { - [a[0] - b[0], a[1] - b[1], a[2] - b[2]] -} -fn add(a: V3, b: V3) -> V3 { - [a[0] + b[0], a[1] + b[1], a[2] + b[2]] -} -fn mul(a: V3, s: f64) -> V3 { - [a[0] * s, a[1] * s, a[2] * s] -} -fn dot(a: V3, b: V3) -> f64 { - a[0] * b[0] + a[1] * b[1] + a[2] * b[2] -} -fn cross(a: V3, b: V3) -> V3 { - [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] -} -fn len(a: V3) -> f64 { - dot(a, a).sqrt() -} -fn unit(a: V3) -> Option { - let l = len(a); - (l > 1e-12).then(|| mul(a, 1.0 / l)) -} - -#[derive(Clone, Copy, Debug, PartialEq)] -enum Surf { - Plane { p: V3, n: V3 }, - Cylinder { o: V3, a: V3, r: f64 }, - Cone { o: V3, a: V3, r: f64, tan: f64 }, - Sphere { c: V3, r: f64 }, - Torus { c: V3, a: V3, big: f64, small: f64 }, - Other, -} - -impl Surf { - fn of(f: &Value) -> Surf { - let g = |k: &str| v3(&f[k]); - let n = |k: &str| f[k].as_f64(); - let s = match f["type"].as_str().unwrap_or_default() { - "plane" => g("centroid").zip(g("plane_normal_outward").and_then(unit)).map(|(p, n)| Surf::Plane { p, n }), - "cylinder" => (|| Some(Surf::Cylinder { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")? }))(), - "cone" => { - (|| Some(Surf::Cone { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")?, tan: n("half_angle_deg")?.to_radians().tan() }))( - ) - } - "sphere" => (|| Some(Surf::Sphere { c: g("center")?, r: n("radius_mm")? }))(), - "torus" => (|| Some(Surf::Torus { c: g("center")?, a: unit(g("axis")?)?, big: n("major_radius_mm")?, small: n("minor_radius_mm")? }))(), - _ => None, - }; - s.unwrap_or(Surf::Other) - } - - fn periodic(&self) -> bool { - matches!(self, Surf::Cylinder { .. } | Surf::Cone { .. } | Surf::Sphere { .. } | Surf::Torus { .. }) - } - - /// Whether the point lies on the surface; `None` when that can't be told (free-form faces). - fn contains(&self, p: V3) -> Option { - let radial = |o: V3, a: V3| { - let d = sub(p, o); - let t = dot(d, a); - (t, len(sub(d, mul(a, t)))) - }; - Some(match *self { - Surf::Plane { p: q, n } => dot(sub(p, q), n).abs() < TOL, - Surf::Cylinder { o, a, r } => (radial(o, a).1 - r).abs() < TOL, - Surf::Cone { o, a, r, tan } => { - let (t, rho) = radial(o, a); - (rho - (r + t * tan)).abs() < TOL || (rho - (r - t * tan)).abs() < TOL - } - Surf::Sphere { c, r } => (len(sub(p, c)) - r).abs() < TOL, - Surf::Torus { c, a, big, small } => { - let (t, rho) = radial(c, a); - ((rho - big).hypot(t) - small).abs() < TOL - } - Surf::Other => return None, - }) - } - - fn same(&self, o: &Surf) -> bool { - let close = |a: f64, b: f64| (a - b).abs() < TOL; - let pt = |a: V3, b: V3| len(sub(a, b)) < TOL; - let par = |a: V3, b: V3| len(cross(a, b)) < TOL; - // Same axis line: parallel, and either origin on the other's axis. - let axis = |o1: V3, a1: V3, o2: V3, a2: V3| par(a1, a2) && len(cross(sub(o2, o1), a1)) < TOL; - match (*self, *o) { - (Surf::Cylinder { o: o1, a: a1, r: r1 }, Surf::Cylinder { o: o2, a: a2, r: r2 }) => axis(o1, a1, o2, a2) && close(r1, r2), - (Surf::Cone { o: o1, a: a1, r: r1, tan: t1 }, Surf::Cone { .. }) => { - // Same cone: the other's frame circle lies on this one, same opening angle. - let Surf::Cone { o: o2, a: a2, r: r2, tan: t2 } = *o else { return false }; - axis(o1, a1, o2, a2) && close(t1, t2) && { - let t = dot(sub(o2, o1), a1) * dot(a1, a2).signum(); - close(r2, r1 + t * t1) || close(r2, r1 - t * t1) - } - } - (Surf::Sphere { c: c1, r: r1 }, Surf::Sphere { c: c2, r: r2 }) => pt(c1, c2) && close(r1, r2), - (Surf::Torus { c: c1, a: a1, big: b1, small: s1 }, Surf::Torus { c: c2, a: a2, big: b2, small: s2 }) => { - pt(c1, c2) && par(a1, a2) && close(b1, b2) && close(s1, s2) - } - _ => false, - } - } -} - -/// Points along an edge from Fusion's edge list. -fn edge_points(e: &Value) -> Vec { - let (s, t) = (v3(&e["start"]), v3(&e["end"])); - let mut pts: Vec = s.into_iter().chain(t).collect(); - let (c, r, a) = (v3(&e["center"]), e["radius_mm"].as_f64(), v3(&e["axis"]).and_then(unit)); - match (e["type"].as_str().unwrap_or_default(), s, t) { - ("line", Some(s), Some(t)) => pts.push(mul(add(s, t), 0.5)), - ("arc", Some(s), Some(t)) => { - if let (Some(c), Some(r)) = (c, r) - && let Some(m) = unit(add(sub(s, c), sub(t, c))) - { - let long = e["length_mm"].as_f64().is_some_and(|l| l > std::f64::consts::PI * r); - pts.push(add(c, mul(m, if long { -r } else { r }))); - } - } - ("circle", Some(s), _) => { - if let (Some(c), Some(a)) = (c, a) { - let d = sub(s, c); - for th in [2.0 * std::f64::consts::FRAC_PI_3, 4.0 * std::f64::consts::FRAC_PI_3] { - pts.push(add(c, add(mul(d, th.cos()), mul(cross(a, d), th.sin())))); - } - } - } - _ => {} - } - pts -} - -fn key(p: V3) -> [i64; 3] { - p.map(|x| (x * 1e6).round() as i64) -} - -fn find(p: &mut [usize], mut i: usize) -> usize { - while let Some(&q) = p.get(i) { - if q == i { - break; - } - i = q; - } - i -} - -/// Faces, edges and vertices of one Fusion body after merging seam splits; `None` when the -/// lists are missing. -pub fn normalised(body: &Value) -> Option<(usize, usize, usize)> { - let faces = body["face_list"].as_array()?; - let edges = body["edge_list"].as_array()?; - let surfs: Vec = faces.iter().map(Surf::of).collect(); - // Group faces cut from one periodic surface. - let mut group: Vec = (0..surfs.len()).collect(); - for i in 0..surfs.len() { - for j in 0..i { - if surfs[i].periodic() && surfs[i].same(&surfs[j]) { - group[i] = group[j]; - break; - } - } - } - let size = |g: usize| group.iter().filter(|x| **x == g).count(); - // Seam edges: on a split group's surface and on no other face's surface. - let mut seams_in: HashMap = HashMap::new(); - let mut seam = vec![false; edges.len()]; - for (ei, e) in edges.iter().enumerate() { - let pts = edge_points(e); - if pts.is_empty() { - continue; - } - let on = |s: &Surf| pts.iter().map(|p| s.contains(*p)).try_fold(true, |acc, x| Some(acc && x?)); - let carriers: Vec> = surfs.iter().map(on).collect(); - if carriers.contains(&None) { - continue; - } - let groups: Vec = (0..surfs.len()).filter(|f| carriers[*f] == Some(true)).map(|f| group[f]).collect(); - if let Some(&g) = groups.first() - && groups.iter().all(|x| *x == g) - && surfs[g].periodic() - && size(g) > 1 - { - seam[ei] = true; - *seams_in.entry(g).or_insert(0) += 1; - } - } - let mut face_count = 0; - for g in 0..surfs.len() { - let k = size(g); - if k > 0 { - face_count += k.saturating_sub(seams_in.get(&g).copied().unwrap_or(0)).max(1); - } - } - // Vertex degrees over the remaining edges; a closed edge counts once at its vertex. - let mut ends: Vec<(usize, Option<[i64; 3]>, Option<[i64; 3]>)> = Vec::new(); - for (ei, e) in edges.iter().enumerate() { - if seam[ei] { - continue; - } - let (s, t) = (v3(&e["start"]).map(key), v3(&e["end"]).map(key)); - let t = if t == s { None } else { t }; - ends.push((ends.len(), s, t)); - } - // A seam between two closed curves (a closed face's seam whose surface we can't evaluate): - // an open edge whose ends each meet only a closed edge. At a real vertex three edges meet. - let closed_at = |v: &[i64; 3], ends: &[(usize, Option<[i64; 3]>, Option<[i64; 3]>)]| { - let closed = ends.iter().filter(|(_, s, t)| t.is_none() && s.as_ref() == Some(v)).count(); - let open = ends.iter().filter(|(_, s, t)| t.is_some() && (s.as_ref() == Some(v) || t.as_ref() == Some(v))).count(); - closed == 1 && open == 1 - }; - let loop_seams: Vec = - ends.iter().filter(|(_, s, t)| matches!((s, t), (Some(s), Some(t)) if closed_at(s, &ends) && closed_at(t, &ends))).map(|e| e.0).collect(); - ends.retain(|e| !loop_seams.contains(&e.0)); - for (k, e) in ends.iter_mut().enumerate() { - e.0 = k; - } - let mut deg: HashMap<[i64; 3], Vec> = HashMap::new(); - for (i, s, t) in &ends { - for v in [s, t].into_iter().flatten() { - deg.entry(*v).or_default().push(*i); - } - } - let mut ep: Vec = (0..ends.len()).collect(); - let mut merged: Vec<[i64; 3]> = Vec::new(); - for (v, es) in ° { - // Two open edges meeting: one curve split at a seam. - if let [i, j] = es[..] - && i != j - && ends.get(i).is_some_and(|e| e.2.is_some()) - && ends.get(j).is_some_and(|e| e.2.is_some()) - { - let (ri, rj) = (find(&mut ep, i), find(&mut ep, j)); - if let Some(x) = ep.get_mut(ri) { - *x = rj; - } - merged.push(*v); - } - } - let mut real: HashMap = HashMap::new(); - for (i, s, t) in &ends { - let r = find(&mut ep, *i); - let has_real = [s, t].into_iter().flatten().any(|v| !merged.contains(v)) || (s.is_none() && t.is_none()); - *real.entry(r).or_insert(false) |= has_real; - } - let closed = real.values().filter(|r| !**r).count(); - let vertices = deg.keys().filter(|v| !merged.contains(v)).count() + closed; - Some((face_count, real.len(), vertices)) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - /// A cylinder (r 5, height 10) split by Fusion into two halves along two seams. - #[test] - fn split_cylinder_counts_once() { - let cyl = json!({"type": "cylinder", "axis_origin": [0, 0, 0], "axis": [0, 0, 1], "radius_mm": 5.0}); - let plane = |z: f64, n: f64| json!({"type": "plane", "centroid": [0, 0, z], "plane_normal_outward": [0, 0, n]}); - let arc = |z: f64, s: [f64; 3], t: [f64; 3]| json!({"type": "arc", "start": s, "end": t, "center": [0, 0, z], "radius_mm": 5.0, "axis": [0, 0, 1], "length_mm": 15.707963}); - let line = |x: f64| json!({"type": "line", "start": [x, 0, 0], "end": [x, 0, 10]}); - let body = json!({ - "face_list": [cyl.clone(), cyl, plane(0.0, -1.0), plane(10.0, 1.0)], - "edge_list": [line(5.0), line(-5.0), arc(0.0, [5.0, 0.0, 0.0], [-5.0, 0.0, 0.0]), arc(0.0, [-5.0, 0.0, 0.0], [5.0, 0.0, 0.0]), arc(10.0, [5.0, 0.0, 10.0], [-5.0, 0.0, 10.0]), arc(10.0, [-5.0, 0.0, 10.0], [5.0, 0.0, 10.0])], - }); - assert_eq!(normalised(&body), Some((3, 2, 2))); - } - - /// Oracle 35: a fillet (free-form face, surface unknown) between two cylinders is bounded by - /// two closed contact curves joined by its seam arc. The arc goes; the curves and circles stay. - #[test] - fn seam_between_closed_curves_on_an_unknown_surface() { - let circle = - |s: [f64; 3], c: [f64; 3], a: [f64; 3]| json!({"type": "circle", "start": s, "end": s, "center": c, "radius_mm": 1.0, "axis": a}); - let lp = |p: [f64; 3]| json!({"type": "nurbs", "start": p, "end": p}); - let (p, q) = ([0.0, -10.833333333, 10.374916332], [0.0, -10.0, 12.449899598]); - let body = json!({ - "face_list": [{"type": "nurbs"}], - "edge_list": [ - {"type": "arc", "start": p, "end": q, "center": [0.0, -13.0, 12.449899598], "radius_mm": 3.0, "axis": [1, 0, 0]}, - circle([-40.0, 0.0, 15.0], [-40.0, 0.0, 0.0], [-1.0, 0.0, 0.0]), - circle([-10.0, 0.0, 35.0], [0.0, 0.0, 35.0], [0.0, 0.0, 1.0]), - circle([40.0, 0.0, 15.0], [40.0, 0.0, 0.0], [1.0, 0.0, 0.0]), - lp(p), - lp(q), - ], - }); - assert_eq!(normalised(&body), Some((1, 5, 5))); - } - - /// A closed curve split into two edges at a seam vertex joins again (Fusion part 61's keyway). - #[test] - fn degree_two_vertices_join() { - let n = |s: [f64; 3], t: [f64; 3]| json!({"type": "nurbs", "start": s, "end": t}); - let body = json!({"face_list": [], "edge_list": [n([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), n([1.0, 0.0, 0.0], [0.0, 0.0, 0.0])]}); - assert_eq!(normalised(&body), Some((0, 1, 1))); - } -} diff --git a/apps/solvecraft-cli/src/spatial.rs b/apps/solvecraft-cli/src/spatial.rs new file mode 100644 index 00000000..95ed5ef4 --- /dev/null +++ b/apps/solvecraft-cli/src/spatial.rs @@ -0,0 +1,333 @@ +//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal +//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two +//! triangle meshes. + +use std::collections::HashMap; + +/// A triangle soup in mm. +pub type Tri = [[f64; 3]; 3]; + +fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn scale(a: [f64; 3], s: f64) -> [f64; 3] { + [a[0] * s, a[1] * s, a[2] * s] +} +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +/// Read a binary (or ASCII) STL file. +pub fn read_stl(path: &str) -> Result, String> { + let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + if b.len() >= 84 { + let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize; + if b.len() == 84 + 50 * n { + let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])); + return Ok((0..n) + .map(|i| { + let o = 84 + 50 * i + 12; + let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)]; + [v(0), v(1), v(2)] + }) + .collect()); + } + } + let text = String::from_utf8_lossy(&b); + let mut verts = Vec::new(); + for line in text.lines() { + let mut it = line.split_whitespace(); + if it.next() == Some("vertex") { + let c: Vec = it.filter_map(|x| x.parse().ok()).collect(); + if c.len() == 3 { + verts.push([c[0], c[1], c[2]]); + } + } + } + if verts.is_empty() || verts.len() % 3 != 0 { + return Err(format!("{path}: not an STL file")); + } + Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect()) +} + +/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a +/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin. +pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) { + let (mut v6, mut m1) = (0.0, [0.0; 3]); + let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV + for t in tris { + let d = dot(t[0], cross(t[1], t[2])); + v6 += d; + let s = add(add(t[0], t[1]), t[2]); + m1 = add(m1, scale(s, d / 24.0)); + for (i, row) in c.iter_mut().enumerate() { + for (j, cell) in row.iter_mut().enumerate() { + let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::() + s[i] * s[j]; + *cell += d / 120.0 * vv; + } + } + } + let vol = v6 / 6.0; + if vol.abs() < 1e-12 { + return (0.0, [0.0; 3], [0.0; 3]); + } + let com = scale(m1, 1.0 / vol); + // Central covariance and inertia tensor I = tr(C)·1 − C. + let mut cc = c; + for (i, row) in cc.iter_mut().enumerate() { + for (j, cell) in row.iter_mut().enumerate() { + *cell -= vol * com[i] * com[j]; + } + } + let tr = cc[0][0] + cc[1][1] + cc[2][2]; + let mut inertia = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] }; + } + } + let mut ev = eigen_sym3(inertia); + ev.sort_by(f64::total_cmp); + (vol, com, ev) +} + +/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations). +fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] { + for _ in 0..50 { + let (mut p, mut q, mut big) = (0, 1, 0.0); + for (i, j) in [(0, 1), (0, 2), (1, 2)] { + if a[i][j].abs() > big { + (p, q, big) = (i, j, a[i][j].abs()); + } + } + if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) { + break; + } + let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]); + let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt()); + let t = if theta == 0.0 { 1.0 } else { t }; + let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt()); + let mut b = a; + for k in 0..3 { + b[k][p] = cs * a[k][p] - sn * a[k][q]; + b[k][q] = sn * a[k][p] + cs * a[k][q]; + } + let mut c2 = b; + for k in 0..3 { + c2[p][k] = cs * b[p][k] - sn * b[q][k]; + c2[q][k] = sn * b[p][k] + cs * b[q][k]; + } + a = c2; + } + [a[0][0], a[1][1], a[2][2]] +} + +/// Axis-aligned bounds of a triangle soup. +pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) { + let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]); + for p in tris.iter().flatten() { + for k in 0..3 { + lo[k] = lo[k].min(p[k]); + hi[k] = hi[k].max(p[k]); + } + } + (lo, hi) +} + +/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5). +fn point_tri(p: [f64; 3], t: &Tri) -> f64 { + let (a, b, c) = (t[0], t[1], t[2]); + let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a)); + let (d1, d2) = (dot(ab, ap), dot(ac, ap)); + let q = if d1 <= 0.0 && d2 <= 0.0 { + a + } else { + let bp = sub(p, b); + let (d3, d4) = (dot(ab, bp), dot(ac, bp)); + if d3 >= 0.0 && d4 <= d3 { + b + } else { + let vc = d1 * d4 - d3 * d2; + if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 { + add(a, scale(ab, d1 / (d1 - d3))) + } else { + let cp = sub(p, c); + let (d5, d6) = (dot(ab, cp), dot(ac, cp)); + if d6 >= 0.0 && d5 <= d6 { + c + } else { + let vb = d5 * d2 - d1 * d6; + if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 { + add(a, scale(ac, d2 / (d2 - d6))) + } else { + let va = d3 * d6 - d5 * d4; + if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 { + add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6)))) + } else { + let den = va + vb + vc; + if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) } + } + } + } + } + } + }; + let d = sub(p, q); + dot(d, d).sqrt() +} + +/// Uniform grid of triangles for distance queries up to `cap`. +struct Grid<'a> { + tris: &'a [Tri], + cell: f64, + cells: HashMap<(i64, i64, i64), Vec>, +} + +impl<'a> Grid<'a> { + fn new(tris: &'a [Tri], cell: f64) -> Self { + let mut cells: HashMap<(i64, i64, i64), Vec> = HashMap::new(); + let key = |x: f64| (x / cell).floor() as i64; + for (i, t) in tris.iter().enumerate() { + let (lo, hi) = bounds(std::slice::from_ref(t)); + for x in key(lo[0])..=key(hi[0]) { + for y in key(lo[1])..=key(hi[1]) { + for z in key(lo[2])..=key(hi[2]) { + cells.entry((x, y, z)).or_default().push(i as u32); + } + } + } + } + Grid { tris, cell, cells } + } + + /// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells + /// of cells outwards and stops once the nearest hit is closer than the unsearched shells. + fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 { + let rmax = (cap / self.cell).ceil() as i64; + let k = |x: f64| (x / self.cell).floor() as i64; + let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2])); + let mut best = cap; + for r in 0..=rmax { + for x in cx - r..=cx + r { + for y in cy - r..=cy + r { + for z in cz - r..=cz + r { + // only the shell at Chebyshev distance r + if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r { + continue; + } + let Some(list) = self.cells.get(&(x, y, z)) else { continue }; + for &i in list { + let Some(slot) = seen.get_mut(i as usize) else { continue }; + if *slot == stamp { + continue; + } + *slot = stamp; + if let Some(t) = self.tris.get(i as usize) { + best = best.min(point_tri(p, t)); + } + } + } + } + } + // Everything not yet searched is at least r·cell away from p. + if best <= r as f64 * self.cell { + break; + } + } + best + } +} + +/// At most this many sample points per side (larger meshes are sampled with an even stride). +const MAX_SAMPLES: usize = 250_000; + +/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for +/// very large meshes). +fn samples(tris: &[Tri]) -> Vec<[f64; 3]> { + let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1); + let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4); + for t in tris.iter().step_by(stride) { + out.extend_from_slice(t); + out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0)); + } + out +} + +/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are +/// capped at `cap`. +fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) { + let (lo, hi) = bounds(to); + let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt(); + // Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no + // finer than diag/400 (so big flat triangles don't fill too many cells). + let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum(); + let typical = (area / to.len().max(1) as f64).sqrt(); + let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3)); + let mut seen = vec![0u32; to.len()]; + let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize); + for (i, p) in samples(from).into_iter().enumerate() { + let d = grid.distance(p, cap, &mut seen, i as u32 + 1); + sum += d; + n += 1; + if d > max { + max = d; + worst = p; + } + } + (max, if n > 0 { sum / n as f64 } else { 0.0 }, worst) +} + +/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`). +pub struct Hausdorff { + pub a_to_b: f64, + pub b_to_a: f64, + pub mean: f64, + /// Where the larger one-sided distance was found (mm, world). + pub worst_at: [f64; 3], +} + +pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff { + let (a, am, ap) = one_sided(a_mesh, b_mesh, cap); + let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap); + Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cube(s: f64, o: [f64; 3]) -> Vec { + let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s]; + let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]]; + let mut t = Vec::new(); + t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.))); + t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.))); + t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.))); + t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.))); + t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.))); + t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.))); + t + } + + #[test] + fn box_moments_and_distance() { + let c = cube(10.0, [5.0, -3.0, 2.0]); + let (v, com, pm) = mass_properties(&c); + assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9); + // V·(a²+b²)/12 for a cube of side 10: 1000·200/12. + for m in pm { + assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}"); + } + let moved = cube(10.0, [5.0, -3.0, 2.3]); + let h = hausdorff(&c, &moved, 1.0); + assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9); + let same = hausdorff(&c, &c, 1.0); + assert!(same.a_to_b < 1e-12); + } +} diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index ffe3dbe9..1f319341 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -25,7 +25,7 @@ - [Persistent naming](dev/naming.md) - [Test harnesses](dev/testing.md) - [Control protocol](dev/control-protocol.md) -- [Fusion oracle](dev/oracle.md) +- [Reference tests](dev/oracle.md) - [Command parity](dev/parity.md) - [STEP import](dev/step-import.md) diff --git a/docs/book/src/dev/architecture.md b/docs/book/src/dev/architecture.md index b2ad94c5..cd06991d 100644 --- a/docs/book/src/dev/architecture.md +++ b/docs/book/src/dev/architecture.md @@ -7,7 +7,7 @@ SolidCraft is a Cargo workspace in layers. A crate only depends on crates in low The web app, `apps/solvecraft-web`, is the desktop front end compiled to wasm with [trunk](https://trunkrs.dev). `xtask` holds the repository's own tooling: `cargo xtask ci`, -`layers`, `assets`, `oracle`, `parity`, `step-corpus` and `book`. +`layers`, `assets`, `oracle`, `parity` and `book`. A few rules follow from the layering: diff --git a/docs/book/src/dev/testing.md b/docs/book/src/dev/testing.md index 2e903b54..1d8ec605 100644 --- a/docs/book/src/dev/testing.md +++ b/docs/book/src/dev/testing.md @@ -13,8 +13,7 @@ profile and the wasm build check. | Layout tests | `crates/ui-egui/tests/ui_layout.rs`, `window_widths.rs` | panels and dialogs at every window width | | Design-file corpus | `apps/solvecraft-cli/tests/corpus` | every saved file version still opens and evaluates the same | | Kill mid-save | `apps/solvecraft-cli/tests/kill_mid_save.rs` | a save killed at any moment leaves a readable file | -| Fusion oracle | `cargo xtask oracle` → [docs/oracle.md](oracle.md) | parts rebuilt from recipes, compared with Fusion's measurements | -| STEP corpus | `cargo xtask step-corpus` → [docs/step-import.md](step-import.md) | STEP files from Fusion opened and measured | +| Reference tests | `cargo xtask oracle` → [docs/oracle.md](oracle.md) | parts rebuilt from `tests/oracle/*/recipe.json`, compared with analytic or FreeCAD expected values | ## UI scenarios diff --git a/docs/book/src/introduction.md b/docs/book/src/introduction.md index e8848264..ce6462d0 100644 --- a/docs/book/src/introduction.md +++ b/docs/book/src/introduction.md @@ -21,7 +21,7 @@ also script, test or hand to an agent. SolidCraft is early. Sketching, solid modelling, parameters, components with joints, sheet metal and a plastic-enclosure subset work; surfaces, drawings, CAM and simulation are deferred. The [roadmap](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md) has the details, and -[command parity](dev/parity.md) and the [Fusion oracle](dev/oracle.md) measure how far along it +[command parity](dev/parity.md) and the [reference tests](dev/oracle.md) measure how far along it is. Existing `.solvecraft` designs and `solvecraft-cli` commands keep their names and behavior. diff --git a/docs/iges-import.md b/docs/iges-import.md deleted file mode 100644 index 910d81bc..00000000 --- a/docs/iges-import.md +++ /dev/null @@ -1,84 +0,0 @@ -# IGES import (Fusion corpus) - -Generated by `cargo xtask step-corpus --iges`. Each case is a reference part exported from Fusion as IGES 5.3 (kept locally in `plan/fusion/oracle/`, never committed). SolidCraft opens `part.igs` exactly as File → Open does and compares with Fusion's measurements of the part it exported: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file. How IGES is read: see [STEP import](step-import.md#iges). 50-sweep-tight-bend is written by Fusion as five loose, partly degenerate surfaces (seams running into slits, a ring given twice); Fusion cannot read it back as a solid either. - -**75 / 76 files pass.** - -| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes | -|---|---|---|---|---|---|---|---| -| 01-box | pass | Body1 | 24000.000 / 24000.000 | 5200.000 / 5200.000 | 6 / 6 | 35 | | -| 02-cylinder | pass | Body1 | 35339.346 / 35342.917 | 6125.844 / 6126.106 | 3 / 3 | 399 | | -| 03-box-through-hole | pass | Body1 | 45738.459 / 45738.053 | 9327.794 / 9327.788 | 7 / 7 | 292 | | -| 04-revolve-vase | pass | Body1 | 112488.577 / 112499.479 | 13156.092 / 13156.615 | 5 / 5 | 1515 | | -| 05-taper-extrude | pass | Body1 | 61814.826 / 61814.826 | 9676.915 / 9676.915 | 6 / 6 | 28 | | -| 06-symmetric-extrude | pass | Body1 | 30000.000 / 30000.000 | 6200.000 / 6200.000 | 6 / 6 | 33 | | -| 07-fillet-all-edges | pass | Body1 | 23340.463 / 23340.849 | 4726.195 / 4726.265 | 26 / 26 | 1285 | | -| 08-chamfer | pass | Body1 | 23730.667 / 23730.667 | 5029.352 / 5029.352 | 10 / 10 | 64 | | -| 09-shell | pass | Body1 | 13632.000 / 13632.000 | 13992.000 / 13992.000 | 11 / 11 | 76 | | -| 10-rect-pattern-holes | pass | Body1 | 29803.844 / 29803.540 | 14652.460 / 14652.389 | 18 / 18 | 1988 | | -| 11-circular-pattern-flange | pass | Body1 | 66758.418 / 66758.844 | 19320.582 / 19320.795 | 10 / 10 | 2360 | | -| 12-mirror-feature | pass | Body1 | 21392.312 / 21392.920 | 6530.923 / 6530.973 | 10 / 10 | 753 | | -| 13-combine-join | pass | Body1 | 45569.249 / 45571.680 | 8634.154 / 8634.336 | 9 / 9 | 383 | | -| 14-combine-cut | pass | Body1 | 42932.838 / 42931.417 | 9706.788 / 9706.858 | 7 / 7 | 1401 | | -| 15-combine-intersect | pass | Body1 | 54451.677 / 54454.273 | 7382.501 / 7382.743 | 7 / 7 | 2559 | solid 'Body1': face wrapping around a closed surface kept whole: unsupported seam loop layout (8 runs) | -| 16-sweep | pass | Body1 | 2584.300 / 2584.724 | 1779.617 / 1779.698 | 5 / 5 | 1957 | | -| 17-loft | pass | Body1 | 35120.291 / 35125.498 | 6495.290 / 6495.000 | 6 / 6 | 601 | | -| 18-hole-features | pass | Body1 | 33890.962 / 33890.710 | 8568.687 / 8568.696 | 13 / 13 | 1552 | | -| 19-l-bracket-constrained | pass | Body1 | 14720.000 / 14720.000 | 5472.000 / 5472.000 | 8 / 8 | 39 | | -| 20-slot-constrained | pass | Body1 | 2965.385 / 2965.487 | 1774.641 / 1774.690 | 6 / 6 | 88 | | -| 21-draft | pass | Body1 | 54574.737 / 54574.737 | 8781.998 / 8781.998 | 6 / 6 | 38 | | -| 22-split-body | pass | Body1, Body2 | 75390.604 / 75398.224 | 12954.438 / 12955.175 | 6 / 6 | 802 | | -| 23-bracket-multifeature | pass | Body1 | 59331.547 / 59331.129 | 18290.759 / 18290.737 | 19 / 19 | 1441 | | -| 24-user-parameters | pass | Body1 | 34822.114 / 34821.903 | 8114.166 / 8114.159 | 7 / 7 | 437 | | -| 25-sketch-triangle | pass | Body1 | 4032.155 / 4032.155 | 2274.099 / 2274.099 | 5 / 5 | 51 | | -| 26-sketch-belt | pass | Body1 | 11326.049 / 11326.170 | 5637.345 / 5637.404 | 7 / 7 | 599 | | -| 27-sketch-trapezoid | pass | Body1 | 5342.307 / 5342.257 | 3066.958 / 3066.946 | 8 / 8 | 832 | | -| 28-blind-pocket-fillets | pass | Body1 | 84299.377 / 84299.249 | 16279.553 / 16279.572 | 23 / 23 | 2087 | | -| 29-two-sided-and-partial-revolve | pass | Body1 | 25731.359 / 25731.307 | 6312.589 / 6312.602 | 12 / 12 | 229 | | -| 30-fillet-after-shell | pass | Body1 | 21745.916 / 21745.876 | 14968.283 / 14968.332 | 27 / 27 | 1298 | | -| 31-fillet-l-all-edges | pass | Body1 | 14321.380 / 14321.626 | 5023.977 / 5024.045 | 38 / 38 | 2673 | | -| 32-boss-plate-fillets | pass | Body1 | 33590.273 / 33590.350 | 9929.855 / 9929.864 | 10 / 10 | 3878 | | -| 33-fillet-tangent-chain | pass | Body1 | 8236.989 / 8237.122 | 2793.535 / 2793.569 | 14 / 14 | 1687 | | -| 34-chamfer-then-fillet | pass | Body1 | 22892.359 / 22892.421 | 4753.672 / 4753.688 | 18 / 18 | 577 | | -| 35-fillet-curved-curved | pass | Body1 | 63169.296 / 63171.443 | 10269.098 / 10269.348 | 6 / 6 | 9098 | solid 'Body1': face wrapping around a closed surface kept whole: split line does not reach the rings (×2); solid 'Body1': face wrapping around a closed surface kept whole: unsupported seam loop layout (8 runs) | -| 36-corner-blend-two-radii | pass | Body1 | 57799.380 / 57800.240 | 8585.423 / 8585.524 | 18 / 18 | 3015 | | -| 37-pyramid-apex-blend | pass | Body1 | 15465.384 / 15465.575 | 3914.545 / 3914.581 | 18 / 18 | 5090 | | -| 38-coincident-cylinders | pass | Body1 | 15706.154 / 15707.963 | 3769.748 / 3769.911 | 3 / 3 | 354 | | -| 39-tee-pipe | pass | Body1 | 24095.711 / 24096.237 | 16709.770 / 16710.048 | 7 / 7 | 2800 | solid 'Body1': face wrapping around a closed surface kept whole: unsupported seam loop layout (8 runs) (×2) | -| 40-box-sphere-torus | pass | Body1 | 36287.765 / 36287.433 | 10696.188 / 10696.165 | 10 / 10 | 6187 | | -| 41-tube-radial-holes | pass | Body1 | 20243.087 / 20243.981 | 14873.680 / 14873.773 | 22 / 22 | 9763 | | -| 42-overlapping-patterns | pass | Body1 | 38488.942 / 38487.064 | 21687.167 / 21686.603 | 43 / 43 | 5322 | a solid's shell does not close; solid 'Body1': imported as an open body (the shell is not closed) | -| 43-cut-splits-body | pass | Body1, Body2 | 28000.000 / 28000.000 | 7200.000 / 7200.000 | 12 / 12 | 128 | | -| 44-shell-l-bracket | pass | Body1 | 8384.000 / 8384.000 | 8784.000 / 8784.000 | 15 / 15 | 132 | | -| 45-shell-boss-plate | pass | Body1 | 13676.813 / 13676.602 | 14156.507 / 14156.602 | 15 / 15 | 785 | | -| 46-shell-two-open-faces | pass | Body1 | 16044.000 / 16044.000 | 11398.000 / 11398.000 | 10 / 10 | 141 | | -| 47-draft-curved-pocket | pass | Body1 | 111388.066 / 111387.712 | 17700.333 / 17700.361 | 11 / 11 | 290 | | -| 48-pulley | pass | Body1 | 168508.132 / 168510.841 | 23718.191 / 23718.644 | 12 / 12 | 4041 | | -| 49-helical-sweep | pass | Body1 | 4740.421 / 4738.099 | 4768.066 / 4767.652 | 3 / 3 | 33507 | | -| 50-sweep-tight-bend | FAIL | Body1 | 1621.826 / 3758.443 | – / – | 4 / 5 | 813 | the surfaces do not close into a solid: imported as an open body; solid 'Body1': face #231: face boundary is not a closed loop; solid 'Body1': imported as an open body (the shell is not closed) | -| 51-loft-rect-circle-offset | pass | Body1 | 61129.309 / 61133.116 | 9412.484 / 9411.655 | 6 / 6 | 532 | | -| 52-circular-pattern-fillet | pass | Body1 | 47206.121 / 47206.850 | 14141.995 / 14142.171 | 21 / 21 | 5990 | | -| 53-path-pattern | pass | Body1 | 35010.576 / 35010.398 | 16463.935 / 16463.894 | 13 / 13 | 1431 | | -| 54-mirror-body-fillets | pass | Body1 | 22780.189 / 22780.089 | 6972.821 / 6972.832 | 12 / 12 | 861 | | -| 55-tapped-hole-cosmetic | pass | Body1 | 17542.374 / 17542.314 | 4761.564 / 4761.596 | 8 / 8 | 543 | | -| 56-holes-on-curved-face | pass | Body1 | 77940.974 / 77942.388 | 10592.891 / 10593.139 | 7 / 7 | 3231 | solid 'Body1': face wrapping around a closed surface kept whole: a hole crosses the split line | -| 57-hole-pattern-sketch-points | pass | Body1 | 36767.089 / 36766.712 | 11818.157 / 11818.153 | 24 / 24 | 2099 | | -| 58-phone-stand | pass | Body1 | 74559.445 / 74559.177 | 29148.741 / 29148.785 | 19 / 19 | 181 | | -| 59-enclosure-base | pass | Body1 | 34309.026 / 34309.486 | 33795.404 / 33795.610 | 39 / 39 | 2120 | solid 'Body1': 2 face(s) could not be meshed and are not shown; solid 'Body1': face wrapping around a closed surface kept whole: cannot cut a ring edge at the split line (×4) | -| 60-pipe-flange | pass | Body1 | 323660.822 / 323660.979 | 56984.283 / 56984.564 | 16 / 16 | 4410 | | -| 61-shaft-keyway | pass | Body1 | 47521.369 / 47525.946 | 9676.777 / 9677.096 | 14 / 14 | 3019 | solid 'Body1': face wrapping around a closed surface kept whole: a hole crosses the split line | -| 62-heat-sink | pass | Body1 | 43125.000 / 43125.000 | 45725.000 / 45725.000 | 66 / 66 | 278 | | -| 63-hinge-knuckle | pass | Body1 | 4783.060 / 4783.117 | 4110.247 / 4110.287 | 14 / 14 | 974 | solid 'Body1': face wrapping around a closed surface kept whole: a hole crosses the split line | -| 64-sketch-tangent-arc-chain | pass | Body1 | 4800.000 / 4800.000 | 3843.701 / 3843.712 | 9 / 9 | 113 | | -| 65-sketch-gear-tooth | pass | Body1 | 3987.829 / 3988.084 | 1945.416 / 1945.485 | 9 / 9 | 119 | | -| 66-sketch-linkage | pass | Body1 | 8045.432 / 8045.386 | 4308.169 / 4308.175 | 10 / 10 | 1662 | | -| 67-sm-base-flange | pass | Body1 | 15000.000 / 15000.000 | 12800.000 / 12800.000 | 6 / 6 | 31 | | -| 68-sm-edge-flange | pass | Body1 | 18972.511 / 18972.622 | 16057.510 / 16057.550 | 14 / 14 | 182 | | -| 69-sm-tray | pass | Body1 | 29665.924 / 29666.234 | 24979.972 / 24980.086 | 46 / 46 | 568 | | -| 70-sm-flange-45 | pass | Body1 | 18450.720 / 18450.777 | 15629.618 / 15629.637 | 14 / 14 | 112 | | -| 71-sm-custom-rule | pass | Body1 | 10053.662 / 10053.717 | 13896.235 / 13896.299 | 22 / 22 | 277 | | -| 72-sm-contour-flange | pass | Body1 | 12097.511 / 12097.622 | 10411.960 / 10412.002 | 22 / 22 | 264 | | -| 73-sm-hem | pass | Body1 | 21334.436 / 21334.723 | 17994.374 / 17994.473 | 22 / 22 | 317 | | -| 74-sm-unfold-refold | pass | Body1 | 15800.138 / 15800.304 | 13610.263 / 13610.326 | 32 / 32 | 286 | | -| 75-sm-bracket-holes | pass | Body1 | 13915.905 / 13916.012 | 12035.926 / 12035.962 | 25 / 25 | 558 | | -| 76-sm-thickness-variant | pass | Body1 | 22520.416 / 22520.575 | 16064.080 / 16064.128 | 14 / 14 | 139 | | diff --git a/docs/oracle.md b/docs/oracle.md index 30eb452e..4e8ce5f2 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -1,96 +1,101 @@ -# Fusion oracle +# Reference tests -Generated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe and measurements kept locally in `plan/fusion/oracle/`, not committed). SolidCraft replays the recipe through its command engine and compares body count, volume and area (relative tolerance 1e-3) and face/edge/vertex counts (exact). SolidCraft counts each closed-surface patch once and ignores seam edges and vertices; when body count, volume and area pass but the raw counts differ, Fusion's counts are normalised the same way from its face, edge and vertex lists (faces cut from one cylinder, cone, sphere or torus and separated only by seams count once; an edge on that surface and no other is a seam and is dropped; a vertex left with two edges joins them) and compared exactly; the note gives both sides' raw counts. +Generated by `cargo xtask oracle`. Each case in [`tests/oracle/`](../tests/oracle/) is a part described by our own `recipe.json`. SolidCraft replays the recipe through its command engine and compares the result with `expected.json`: body count, volume and area (relative tolerance 1e-3 unless the case states another), centre of mass and bounding box (within 1e-3 of the part's size), face count where the geometry fixes it, and, where the case has a `reference.stl`, the sampled symmetric (Hausdorff) distance between the two surfaces and the principal second moments of both triangulations. Every rebuilt part is also exported as STEP, validated and read back. -**76 / 76 cases pass** (0 need features not implemented yet). +Where the expected values come from (the **Source** column): -| Case | Result | Notes | -|---|---|---| -| 01-box | pass | | -| 02-cylinder | pass | | -| 03-box-through-hole | pass | | -| 04-revolve-vase | pass | | -| 05-taper-extrude | pass | | -| 06-symmetric-extrude | pass | | -| 07-fillet-all-edges | pass | | -| 08-chamfer | pass | | -| 09-shell | pass | | -| 10-rect-pattern-holes | pass | | -| 11-circular-pattern-flange | pass | | -| 12-mirror-feature | pass | | -| 13-combine-join | pass | | -| 14-combine-cut | pass | | -| 15-combine-intersect | pass | | -| 16-sweep | pass | | -| 17-loft | pass | | -| 18-hole-features | pass | | -| 19-l-bracket-constrained | pass | | -| 20-slot-constrained | pass | | -| 21-draft | pass | | -| 22-split-body | pass | | -| 23-bracket-multifeature | pass | | -| 24-user-parameters | pass | | -| 25-sketch-triangle | pass | | -| 26-sketch-belt | pass | | -| 27-sketch-trapezoid | pass | | -| 28-blind-pocket-fillets | pass | | -| 29-two-sided-and-partial-revolve | pass | | -| 30-fillet-after-shell | pass | | -| 31-fillet-l-all-edges | pass | | -| 32-boss-plate-fillets | pass | | -| 33-fillet-tangent-chain | pass | | -| 34-chamfer-then-fillet | pass | | -| 35-fillet-curved-curved | pass | topology compared seam-normalised: Fusion's 6/6/5 faces/edges/vertices become 6/5/5 with closed-surface patches counted once and seam edges and vertices dropped (raw SolidCraft/Fusion: faces 9/6, edges 17/6, vertices 10/5) | -| 36-corner-blend-two-radii | pass | | -| 37-pyramid-apex-blend | pass | | -| 38-coincident-cylinders | pass | | -| 39-tee-pipe | pass | | -| 40-box-sphere-torus | pass | | -| 41-tube-radial-holes | pass | | -| 42-overlapping-patterns | pass | | -| 43-cut-splits-body | pass | | -| 44-shell-l-bracket | pass | | -| 45-shell-boss-plate | pass | | -| 46-shell-two-open-faces | pass | | -| 47-draft-curved-pocket | pass | | -| 48-pulley | pass | | -| 49-helical-sweep | pass | Fusion sweeps along a spline fitted through helix points; SolidCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all | -| 50-sweep-tight-bend | pass | area not compared: Fusion reports this tight sweep's horn torus face at three times its area (π²·R·r quarter-turn value; 740 mm² against 246.7 by Pappus); volume and topology are compared | -| 51-loft-rect-circle-offset | pass | | -| 52-circular-pattern-fillet | pass | | -| 53-path-pattern | pass | | -| 54-mirror-body-fillets | pass | | -| 55-tapped-hole-cosmetic | pass | | -| 56-holes-on-curved-face | pass | topology compared seam-normalised: Fusion's 7/10/10 faces/edges/vertices become 7/8/8 with closed-surface patches counted once and seam edges and vertices dropped (raw SolidCraft/Fusion: faces 16/7, edges 32/10, vertices 19/10) | -| 57-hole-pattern-sketch-points | pass | | -| 58-phone-stand | pass | | -| 59-enclosure-base | pass | | -| 60-pipe-flange | pass | | -| 61-shaft-keyway | pass | topology compared seam-normalised: Fusion's 14/22/18 faces/edges/vertices become 14/20/16 with closed-surface patches counted once and seam edges and vertices dropped (raw SolidCraft/Fusion: faces 24/14, edges 54/22, vertices 34/18) | -| 62-heat-sink | pass | | -| 63-hinge-knuckle | pass | known Fusion difference: Gap replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolidCraft's through-all goes truly through) | -| 64-sketch-tangent-arc-chain | pass | | -| 65-sketch-gear-tooth | pass | | -| 66-sketch-linkage | pass | | -| 67-sm-base-flange | pass | | -| 68-sm-edge-flange | pass | | -| 69-sm-tray | pass | | -| 70-sm-flange-45 | pass | | -| 71-sm-custom-rule | pass | | -| 72-sm-contour-flange | pass | | -| 73-sm-hem | pass | | -| 74-sm-unfold-refold | pass | | -| 75-sm-bracket-holes | pass | | -| 76-sm-thickness-variant | pass | | +- **analytic**: closed-form geometry worked out by hand (prisms, cylinders, cones, spheres, tori and Pappus' theorems, constant-radius fillets on straight edges, sheet-metal bends as annular sectors). The scripts in [`tools/oracle-analytic/`](../tools/oracle-analytic/) compute them and give the formulas. +- **freecad <version>**: the same recipe built with FreeCAD's public Python scripting API by [`tools/freecad-oracle/`](../tools/freecad-oracle/), an independent kernel run as an external program. FreeCAD is not a dependency of SolidCraft. Where the two kernels legitimately build a different shape (for example the corner patch where fillets meet), only volume and area are compared, within the tolerance the case states. -## No oracle +No expected value comes from a commercial CAD product. -| Feature | Status | Tested by | -|---|---|---| -| Boss (draft, root fillet, hole, ribs) | no oracle (licence) | post, hole and rib volumes (`boss_with_hole_fillet_and_ribs`) | -| Lip / Groove | no oracle (licence) | band offsets and groove clearance on a shelled box (`lip_and_groove_on_a_shelled_box`, `groove_takes_the_rule_clearance`) | -| Snap Fit | no oracle (licence) | arm and catch volume, hook direction (`snap_fit_arm_and_catch`) | -| Rest | no oracle (licence) | rectangle, round, hollow and drafted pads (`rest_pads`) | -| Plastic Rules (manage, assign) | no oracle (licence) | library values, Thickness-based values, rule draft as the boss default (`plastic_rules_library_edit_and_assign`) | +**75 / 76 cases pass**; sources: 55 analytic, 21 freecad. 1 known failure(s) tracked in issues; 0 case(s) without expected values yet. -Fusion builds these only with its Design Extension, which the oracle account lacks, so there is no Fusion part to compare with. They follow Autodesk's public descriptions of the features and are checked against hand-computed geometry; the plastic rule library values were read from Fusion's API. +| Case | Source | Result | Surface distance mm (max / mean) | Method | Notes | +|---|---|---|---|---|---| +| 01-box | analytic | pass | | box 40 x 30 x 20 from the origin: V = abc, A = 2(ab + bc + ca) | | +| 02-cylinder | analytic | pass | | cylinder r=15 h=50 on XY: V = pi r^2 h, A = 2 pi r^2 + 2 pi r h | | +| 03-box-through-hole | analytic | pass | | box 60x40x20 minus centred through cylinder r=6: V = abc - pi r^2 c, A = 2(ab+bc+ca) - 2 pi r^2 + 2 pi r c; hole is centred so the centroid stays at the box centre | | +| 04-revolve-vase | analytic | pass | | revolve of a line/3-point-arc profile (XZ, x = radius, z = -sketch y) 360 deg about Z. Pappus/Green: V = 2 pi * int x dA, z_c = int x z dA / int x dA, A = sum over profile edges of 2 pi int x ds (bottom disk r20, frustum r20->25, torus zone of the arc (centre (-16.000000, 33.000000), R=41.109610), frustum r15->18, top disk r18) | | +| 05-taper-extrude | analytic | pass | | square frustum: base 40 at z=0, top a1 = 40 + 2*30*tan10 at z=30 (positive taper grows outward); V = h/3 (A0 + A1 + sqrt(A0 A1)), z_c = h (A0 + 2 sqrt(A0A1) + 3 A1) / (4 (A0 + sqrt(A0A1) + A1)), sides = 4 trapezoids (a0+a1)/2 * h/cos(10) | | +| 06-symmetric-extrude | analytic | pass | | 50 (x) by 20 (z) rectangle on XZ extruded +-15 along Y: box 50x30x20 centred at origin | | +| 07-fillet-all-edges | analytic | pass | | box 40x30x20 with every edge rounded r=3 = inner box (34x24x14) grown by a ball r (Steiner): V = abc + 2r(ab+bc+ca) + pi r^2 (a+b+c) + 4/3 pi r^3, A = 2(ab+bc+ca) + 2 pi r (a+b+c) + 4 pi r^2; faces 6 planes + 12 quarter cylinders + 8 sphere octants | | +| 08-chamfer | analytic | pass | | box 40x30x20 with the 4 top edges chamfered 2: lower box 40x30x18 + top slab with section (36+2u)x(26+2u) at depth u (V, moment by exact polynomial integration); A = bottom + sides(h=18) + top 36x26 + 4 trapezoid bevels of slant 2 sqrt2 | | +| 09-shell | analytic | pass | | 50x40x30 box shelled inward 2 with the top open: box minus cavity [2,48]x[2,38]x[2,30]; A = outer bottom + outer sides + rim + cavity floor + cavity walls | | +| 10-rect-pattern-holes | analytic | pass | | plate 90x70x5 minus 12 through cylinders r=3 at (15+20i, 15+20j), i<4, j<3: V = abc - 12 pi r^2 t, A = 2(ab+bc+ca) + 12(2 pi r t - 2 pi r^2) | | +| 11-circular-pattern-flange | analytic | pass | | annulus R50/r15 h10 minus 6 through cylinders r=5 on a 40 radius: V = pi(R^2-r^2)h - 6 pi rh^2 h, A = 2 pi(R^2-r^2) + 2 pi(R+r)h + 6(2 pi rh h - 2 pi rh^2); 6-fold symmetry keeps the centroid on the axis | | +| 12-mirror-feature | analytic | pass | | block [-30,30]x[0,30]x[0,10] + two bosses r=6 h=15 at (+-15,15) on top (mirror across YZ): V = abc + 2 pi r^2 h, A = 2(ab+bc+ca) + 2 * 2 pi r h | | +| 13-combine-join | analytic | pass | | box [0,40]^2x[0,20] union cylinder r=12 at (40,20) z 0..40: V = box + half-cylinder (x>40, z<20, centroid 40 + 4r/3pi) + full cylinder z 20..40; A = bottom (1600 + pi r^2/2) + box top (1600 - pi r^2/2) + cyl top + 3 full sides + x=40 side minus 24x20 + cyl side (pi r 40 outside half + pi r 20 upper inside half) | | +| 14-combine-cut | analytic | pass | | box [-25,25]^2x[-20,0] minus sphere r=15 at the origin (lower hemisphere removed): V = abc - 2/3 pi r^3, hemisphere centroid z = -3r/8; A = 2(ab+bc+ca) - pi r^2 + 2 pi r^2 | | +| 15-combine-intersect | analytic | pass | | cube [-20,20]^3 intersect sphere R=25: sphere minus 6 disjoint caps of height h=5 (caps cannot overlap: two would need x^2+y^2 > 800 > R^2); V = 4/3 pi R^3 - 6 pi h^2 (3R-h)/3, A = 4 pi R^2 - 6 * 2 pi R h + 6 pi (R^2-20^2) | | +| 16-sweep | analytic | pass | | circle r=3 swept along line 30 (+Z) + tangent R20 quarter arc + line 30 (+X): two cylinders + quarter torus (Pappus: V = pi r^2 R theta, A = 2 pi r R theta; torus centroid at rho = (R^2 + r^2/4)/R * sin(theta/2)/(theta/2) from the bend axis along the bisector) + 2 end disks | | +| 17-loft | freecad 1.1.4 | pass | 0.013 / 0.0019 (worst at -7.9, -10.8, 31.2) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | | +| 18-hole-features | analytic | pass | | plate 60x40x15 minus: counterbore (r7 x 5 deep + r4 through), countersink (90 deg cone frustum r6->r3 over 3 mm + r3 through), blind r3 hole 10 deep to the shoulder with a 118 deg cone point (height 3/tan59, centroid 1/4 up from the apex); areas: removed top/bottom disks, cylinder walls 2 pi r h, cb floor annulus, cone laterals pi r s | | +| 19-l-bracket-constrained | analytic | pass | | solved L: (0,0)-(60,0)-(60,8)-(8,8)-(8,40)-(0,40); area 60x8 + 8x32, extruded 20: V = A2 h, A = 2 A2 + perimeter*h, centroid of the two rectangles | | +| 20-slot-constrained | analytic | pass | | obround: arc centres (0,0) and (40,0), R6, tangent horizontal lines y=+-6: A2 = 2rL + pi r^2, V = A2 h, A = 2 A2 + perimeter (2L + 2 pi r) h | | +| 21-draft | analytic | pass | | 40x40x30 box, 5 deg draft hinged on the bottom (z=0), pull -Z: square frustum 40 -> 40 + 2*30*tan5; V = h/3 (A0 + A1 + sqrt(A0A1)), sides 4 trapezoids of slant h/cos5 | | +| 22-split-body | analytic | pass | | cylinder r20 z -30..30 split by the plane through the X axis tilted 30 deg (z = y tan30, stays within /z/<=11.6 inside the cylinder): 2 congruent halves (point symmetry), V total = pi r^2 h, A total = cylinder + 2 ellipses pi r (r/cos30) | | +| 23-bracket-multifeature | freecad 1.1.4 | pass | 0.007 / 0.0010 (worst at 57.3, 49.3, 25.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part makeFillet, Part extrude join, Part extrude cut, Part makeChamfer | | +| 24-user-parameters | analytic | pass | | width 60 x depth 40 x height 15 block from the origin with a centred d10 through hole: V = (wd - pi r^2) h, A = 2(wd - pi r^2) + 2(w+d)h + 2 pi r h | | +| 25-sketch-triangle | analytic | pass | | solved triangle (0,0), (50,0), P = t(cos60, sin60) with t = 25 + sqrt(150) from /P-(50,0)/ = 45 (the root nearest the drawn apex); V = (1/2 * 50 * Py) * 5, A = 2 A2 + perimeter*5, centroid = vertex mean | | +| 26-sketch-belt | analytic | pass | | belt: convex hull of circles R20 @ (0,0) and r10 @ (50,0) (external tangents, sin a = (R-r)/d) minus the d8 hole at (50,0), extruded 5 as one body; hull area = (R+r) sqrt(d^2-(R-r)^2) + R^2(pi+2a)/2 + r^2(pi-2a)/2; moments by Green's theorem over the line/arc boundary | | +| 27-sketch-trapezoid | analytic | pass | | solved trapezoid bottom (-30,0)-(30,0), top (-15,25)-(15,25) minus two d6 holes at (+-18, 8) (symmetric about the axis, 36 apart, 8 above l1); trapezoid centroid y = H(a+2b)/(3(a+b)); V = A2 t, A = 2 A2 + perimeter t | | +| 28-blind-pocket-fillets | freecad 1.1.4 | pass | 0.009 / 0.0015 (worst at 60.0, 15.1, 13.4) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, Part makeFillet | | +| 29-two-sided-and-partial-revolve | analytic | pass | | block [-20,20]x[-25,10]x[0,20] minus the 270 deg revolve (from +X towards +Y) of the 8x8 square r 4..12, z 12..20; the removed section is the annular sector minus the circular segment y > 10 (R^2 acos(d/R) - d sqrt(R^2-d^2), y-moment 2/3 (R^2-d^2)^1.5) that lies outside the block; sector moments (R^3-r^3)/3 (sin, 1-cos); A = box - top opening + floor - front-face notch 2 sqrt(44) x 8 + inner wall r th h + outer wall (th - pi + 2 asin(5/6)) R h + 2 end caps | | +| 30-fillet-after-shell | freecad 1.1.4 | pass | 0.008 / 0.0025 (worst at 56.3, 35.7, 3.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, makeThickness shell, Part makeFillet | | +| 31-fillet-l-all-edges | freecad 1.1.4 | pass | 0.009 / 0.0018 (worst at 7.9, 38.6, 18.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet | | +| 32-boss-plate-fillets | analytic | pass | | plate 60x60x8 + boss (r10, z 8..23) with R3 concave base and R2 convex top fillets taken as one solid of revolution: V, z-moment and lateral area from Pappus/Green on its (r,z) profile (quarter-circle fillet arcs); plate top loses the r13 disc | | +| 33-fillet-tangent-chain | analytic | pass | | obround (R8, centres 40 apart) x 10 minus R2 fillet spandrel R^2(1-pi/4) and 1 mm chamfer triangle swept round the whole loop (Pappus: section x path of its centroid, inset R(10-3pi)/(3(4-pi)) resp. c/3); surfaces likewise (fillet arc centroid inset R-2R/pi) | | +| 34-chamfer-then-fillet | freecad 1.1.4 | pass | 0.009 / 0.0022 (worst at 38.5, 5.6, 19.9) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeChamfer, Part makeFillet | | +| 35-fillet-curved-curved | freecad 1.1.4 | pass | 0.011 / 0.0024 (worst at -9.5, 5.3, 14.6) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part makeFillet | | +| 36-corner-blend-two-radii | freecad 1.1.4 | pass | 0.012 / 0.0026 (worst at 45.4, 39.2, 26.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet | | +| 37-pyramid-apex-blend | freecad 1.1.4 | pass | 0.020 / 0.0029 (worst at -0.0, 0.0, 28.4) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft, Part makeFillet | | +| 38-coincident-cylinders | analytic | pass | | union of r10 z0..30 and r10 z20..50 = cylinder r10 h50: V = pi r^2 h, A = 2 pi r h + 2 pi r^2 | | +| 39-tee-pipe | freecad 1.1.4 | pass | 0.008 / 0.0014 (worst at -9.7, 2.4, 40.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | | +| 40-box-sphere-torus | analytic | pass | | box 60x60x10 (z -10..0) + upper hemisphere r12 + spherical cap h2 below z=-10 (V = pi h^2(3R-h)/3, A = 2 pi R h) - lower half torus R22 r4 (Pappus: V = pi r^2/2 * 2 pi R, centroid -4r/3pi, A = pi r * 2 pi R); top loses disc r12 and annulus 18..26, bottom loses disc r sqrt(44) | faces: bottom, 4 sides, top split by the groove into an annulus (r 12..18) and the outer part, upper sphere, lower cap, torus | +| 41-tube-radial-holes | freecad 1.1.4 | pass | 0.010 / 0.0012 (worst at 1.9, 19.9, 24.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied) | | +| 42-overlapping-patterns | freecad 1.1.4 | pass | 0.030 / 0.0010 (worst at -20.0, -0.0, 5.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied), Part extrude cut, pattern (tools transformed and re-applied) | | +| 43-cut-splits-body | analytic | pass | | two boxes 35x20x20 (x 0..35 and 45..80): V = 2abc, A = 2*2(ab+bc+ca) | | +| 44-shell-l-bracket | analytic | pass | | L prism (area 736, perimeter 200) x 20 minus cavity = L shrunk by 2 (SolidCraft's intersection-join shell convention: inner faces offset by 2 and extended to meet, sharp inner corner at (6,6); area 352) from z=2 to 20. A rounded (arc) join would give V 8368.5487, A 8768.5487, 16 faces | | +| 45-shell-boss-plate | analytic | pass | | plate 60x60x10 + boss r12 h20 minus cavity (box 56x56x8 + cylinder r10 z8..28) under SolidCraft's intersection-join shell convention (offset faces extended to meet: sharp circular inner edge at r10, z8). A rounded (arc) join would give V 13620.2567, A 14104.4455, 16 faces (torus round r2) | | +| 46-shell-two-open-faces | analytic | pass | | box 50x40x30 minus cavity x3..47, y0..37, z3..30 (3 mm walls, top and front open) | | +| 47-draft-curved-pocket | freecad 1.1.4 | pass | 0.008 / 0.0019 (worst at 22.1, 21.3, 15.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, PartDesign Draft | | +| 48-pulley | analytic | pass | | revolve of the 12-segment (r,z) polygon about Z: V = 2 pi int r dA, Mz = 2 pi int r z dA, A = 2 pi int r ds (Pappus) | | +| 49-helical-sweep | analytic | pass | | true helix coil (screw motion of the axial d4 section, R20, pitch 8, 3 turns): V = turns * 2 pi R * pi a^2; lateral A = turns * 2 pi * int sqrt(r^2 + (p/2pi)^2 (dr/ds)^2) ds (periodic trapezoid, exact to round-off) + two end discs; COM z = pitch*turns/2 | | +| 50-sweep-tight-bend | analytic | pass | | tube r5 along line 20 + R5 quarter bend + line 20: V = pi r^2 L, A = 2 pi r L + 2 pi r^2 (Pappus, valid while bend radius >= r; here equal so the inner side pinches to a line); bend centroid at (R^2 + r^2/4)/R * sinc(pi/4) along the bisector | | +| 51-loft-rect-circle-offset | freecad 1.1.4 | pass | 0.025 / 0.0030 (worst at -11.7, 15.0, 0.4) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | | +| 52-circular-pattern-fillet | analytic | pass | | disc r40 h8 + 6 x (boss r6 h10 with R2 concave base fillet as a solid of revolution, Pappus/Green on its (r,z) profile) at radius 28; disc top loses 6 discs r8 | | +| 53-path-pattern | analytic | pass | | plate 120x60x5 minus 7 through holes d6 at arc lengths k*17.875031 along the arc through (100,15),(60,45),(20,15) (centre (60,10/3), R 125/3), translated copies | | +| 54-mirror-body-fillets | analytic | pass | | prism h10 of the 60x40 rectangle with four R5 corner rounds and two d8 holes at (+-15,20): V = A h, area = 2A + perimeter*h | | +| 55-tapped-hole-cosmetic | analytic | pass | | plate 40x30x15 minus d6.8 cylinder 12 deep (to the shoulder) and 118 deg drill point cone (h = r/tan59, V = pi r^2 h/3, lateral pi r * r/sin59); cosmetic thread adds no geometry | | +| 56-holes-on-curved-face | freecad 1.1.4 | pass | 0.009 / 0.0018 (worst at -16.9, 18.5, 0.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, hole tool (cylinder/cone) cut | | +| 57-hole-pattern-sketch-points | analytic | pass | | plate 80x50x10 minus 6 counterbored through holes (cb d10 x 5.5, hole d5.5) at x 15/40/65, y 15/35 | | +| 58-phone-stand | freecad 1.1.4 | pass | 0.007 / 0.0010 (worst at 82.5, 11.7, 6.1) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet, Part extrude cut | | +| 59-enclosure-base | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at 90.9, 51.0, 6.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet, makeThickness shell, Part extrude join, Part extrude cut | | +| 60-pipe-flange | freecad 1.1.4 | pass | | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part revolve, Part extrude cut, pattern (tools transformed and re-applied), Part makeChamfer, Part makeFillet | No reference.stl: over 409600 bytes even at 0.02 mm deflection (11380 facets). | +| 61-shaft-keyway | freecad 1.1.4 | pass | 0.008 / 0.0019 (worst at 11.9, 3.9, 45.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part revolve, Part makeChamfer, Part extrude cut | | +| 62-heat-sink | analytic | pass | | base 60x50x5 + 15 fins 1.5x50x25 at x = 2 + 4k: sums of boxes; fin ends coplanar with the base ends | faces: bottom, x=0, x=60, y=0 and y=50 (each merged with the fin ends), 16 base-top strips, 15x3 fin faces | +| 63-hinge-knuckle | freecad 1.1.4 | pass | 0.008 / 0.0016 (worst at 28.0, 36.9, 4.7) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | | +| 64-sketch-tangent-arc-chain | analytic | pass | | solved sketch: 80x20 rectangle whose top is 4 tangent R12 arcs on 20 mm chords alternating up/down (net area 1600); prism h3: V = A h, area = 2A + perimeter*h, Green's theorem for centroid | | +| 65-sketch-gear-tooth | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at 6.5, 39.5, 8.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Sketcher-solved sketch, Part extrude | | +| 66-sketch-linkage | analytic | pass | | solved sketch: hull of circles R12 at (0,0) and R8 at (60,0) (outer tangent lines, cos a = 4/60) minus holes d12, d8 (concentric) and d4 at (30,+-4) (symmetry keeps radii equal); prism h6 | | +| 67-sm-base-flange | analytic | pass | | plate 100 x 60 x t, t = 2.5 (Steel (mm) rule), thickness along +Z: V = 100*60*t, A = 2(ab+bc+ca) | | +| 68-sm-edge-flange | analytic | pass | | extrusion along x (100) of the YZ section: base y 5..60 x t; 90 deg bend annulus R 2.5..5 about (y 5, z 5); flange y 0..2.5, z 5..20. Setback (R+t)tan45 = 5 (inside bend position, outer-face height datum: outer face of flange reaches z = 20); V = L*A_sec, A = 2 A_sec + L P_sec | trim/setback and height datum are SolidCraft's documented edge-flange conventions (sheet.rs add_flanges) | +| 69-sm-tray | analytic | pass | | tray: base 90 x 50 x 2.5 (each edge trimmed by the setback 5); four 90 deg bends R 2.5..5 along the trimmed edges (90, 50, 90, 50 long); four walls 2.5 thick, z 5..25, each running on past its bend by ext = (R+t) - (t + gap/sqrt2) = 0.732233047 at both ends; open corners. A = sum of prisms - 2 x joints | corner treatment is SolidCraft's convention (sheet.rs close_corners: panels extended until their inner corners are the rule gap = t apart; no corner fill, open corner relief below the walls) | +| 70-sm-flange-45 | analytic | pass | | as 68 with a 45 deg bend: setback (R+t)tan22.5 = 2.071068, base y 2.071068..60, bend annulus 2.5..5 over 45 deg about (y 2.071068, z 5), flat leg 15 - setback = 12.928932 turned 45 deg; extrusion along x 100 | SolidCraft convention: flange height (15) is measured along the flange's outer face from the virtual sharp at the original edge (y 0, z 0), so the outer top corner is at (y, z) = (-15 cos45, 15 sin45). | +| 71-sm-custom-rule | analytic | pass | | U channel, rule t 1.5, R 3, K 0.33 (K unused folded): base y 4.5..45.5 x 1.5; two 90 deg bend annuli R 3..4.5; flanges y 0..1.5 and 48.5..50, z 4.5..20; extrusion along x 80 | the rule edit applies to the whole sheet body (base built before it is 1.5 thick too, as the recipe's edge picks at z = 1.5 state) | +| 72-sm-contour-flange | analytic | pass | | contour flange, width 50 along +Y, XZ section: plate x 0..35 z 0..2.5; bend R 2.5..5 about (35, 5) (sketch corner = outer virtual sharp, setback R+t); wall x 37.5..40 z 5..27.5; bend R 2.5..5 about (42.5, 27.5) (sketch corner = inner virtual sharp, setback R); plate x 42.5..70 z 30..32.5 | SolidCraft convention: the sketch lines are the sheet face away from the material (material right of travel); thickness grows to the material side | +| 73-sm-hem | analytic | pass | | 68's part (height 20) plus a flat hem on the flange top edge: 180 deg bend, inner radius = hem gap 0.01, outer 2.51, about (y 2.51, z 20), folded toward +y (inside); hem leg 8 - (0.01 + 2.5) = 5.49: y 2.52..5.02, z 14.51..20; extrusion along x 100 | SolidCraft conventions: hem gap 0.01 mm (rule hem_gap), hem length measured to the outer face of the hem bend (top at z 22.51), hem folds over the flange's inner face. | +| 74-sm-unfold-refold | analytic | pass | | U channel 80x50 (t 2.5, R 2.5, base y 5..45, flanges z 5..20) with the flat-pattern slot x 35..45, y -12..10 refolded: BA = (pi/2)(R + K t) = 5.654866776, so the slot removes base y 5..10, the whole y=0 bend and the flange from z 5 to 5 + (12 - (BA - 5)) = 16.345133224; x-segments 0..35 / 35..45 / 45..80 | unfold/refold maps flat distance through the K-factor neutral layer (K 0.44) | +| 75-sm-bracket-holes | analytic | known failure (#296) | | base x 0..75, y 5..40, z 0..2.5 (trimmed 5 by each flange's setback); bend 1 (R 2.5..5 about y 5, z 5) x 0..75; flange 1 y 0..2.5, z 5..30, x 0..80; bend 2 (R 2.5..5 about x 75, z 5) y 5..40; flange 2 x 77.5..80, z 5..15, y 5..40; minus two d6 through holes at (25,20), (55,20) in the base. A = sum of prism areas - 2 x 4 joint areas (75t, 75t, 35t, 35t) + hole walls - hole discs | step 14 (solid.extrude): invalid input: sew: nonmanifold edge contact; SolidCraft convention: the second flange trims the base under the first bend, so bend 1 stops at x 75 while flange 1's panel runs to x 80 (open corner relief, no fill). | +| 76-sm-thickness-variant | analytic | pass | | final rule thickness 3 (bend radius = Thickness = 3): base y 6..60 x 3; bend annulus R 3..6 about (y 6, z 6); flange y 0..3, z 6..20; extrusion along x 100 | the rule's thickness edits regenerate the whole sheet body; only the final 3 mm state matters | + +## Not covered by a reference case + +| Feature | Tested by | +|---|---| +| Boss (draft, root fillet, hole, ribs) | post, hole and rib volumes (`boss_with_hole_fillet_and_ribs`) | +| Lip / Groove | band offsets and groove clearance on a shelled box (`lip_and_groove_on_a_shelled_box`, `groove_takes_the_rule_clearance`) | +| Snap Fit | arm and catch volume, hook direction (`snap_fit_arm_and_catch`) | +| Rest | rectangle, round, hollow and drafted pads (`rest_pads`) | +| Plastic Rules (manage, assign) | library values, Thickness-based values, rule draft as the boss default (`plastic_rules_library_edit_and_assign`) | diff --git a/docs/robustness.md b/docs/robustness.md index 0713bdbc..b5f3fa7a 100644 --- a/docs/robustness.md +++ b/docs/robustness.md @@ -1,6 +1,6 @@ # Kernel robustness -Real users hit edge cases the Fusion oracle (docs/oracle.md) never sees: faces that coincide +Real users hit edge cases the reference tests (docs/oracle.md) never see: faces that coincide by accident, tangencies, seams lying where another body cuts, walls thinner than a shell. The robustness runs build random but realistic designs and check every step. diff --git a/docs/step-import.md b/docs/step-import.md index bb373090..62032213 100644 --- a/docs/step-import.md +++ b/docs/step-import.md @@ -18,88 +18,3 @@ display on the resulting body. ## IGES IGES 5.3 files (`.igs`/`.iges`) open, insert and drop the same way: `crates/kernel/src/iges_in.rs` (written from the published specification) restates the file as STEP and the STEP reader reads it, so the feature holds STEP text. Read: manifold solid B-reps (186) with their shells, faces, loops, edge and vertex lists; trimmed (144) and bounded (143) surfaces, and bare B-spline patches, sewn into a solid by their shared edges (an open body when they do not close); lines, circular arcs, ellipses, composite curves, rational B-spline curves; planes (108, 190), cylinders, cones, spheres, tori, rational B-spline surfaces, surfaces of revolution and tabulated cylinders; transformation matrices; units; colours (314 and the predefined numbers); body names (name properties, else labels). Not read yet: ruled and offset surfaces, curves on surfaces given only in parameter space. Export (`file.export` to `.igs`) writes each body as a manifold solid B-rep on the same surfaces a STEP export uses, with its name and colour. - -## Fusion corpus - -Generated by `cargo xtask step-corpus`. Each case is a part exported from Fusion as STEP (AP214; kept locally in `plan/fusion/oracle/`, never committed). SolidCraft opens `part.step` exactly as File → Open does (an Import base feature) and compares with Fusion's own measurements: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file. - -**76 / 76 files pass.** - -| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes | -|---|---|---|---|---|---|---|---| -| 01-box | pass | Box | 24000.000 / 24000.000 | 5200.000 / 5200.000 | 6 / 6 | 3 | | -| 02-cylinder | pass | Cylinder | 35339.429 / 35342.917 | 6125.845 / 6126.106 | 3 / 3 | 87 | | -| 03-box-through-hole | pass | Box | 45738.283 / 45738.053 | 9327.809 / 9327.788 | 7 / 7 | 62 | | -| 04-revolve-vase | pass | Vase | 112488.847 / 112499.479 | 13156.093 / 13156.615 | 5 / 5 | 511 | | -| 05-taper-extrude | pass | Frustum | 61814.826 / 61814.826 | 9676.915 / 9676.915 | 6 / 6 | 2 | | -| 06-symmetric-extrude | pass | Block | 30000.000 / 30000.000 | 6200.000 / 6200.000 | 6 / 6 | 1 | | -| 07-fillet-all-edges | pass | Box | 23340.621 / 23340.849 | 4726.220 / 4726.265 | 26 / 26 | 532 | | -| 08-chamfer | pass | Box | 23730.667 / 23730.667 | 5029.352 / 5029.352 | 10 / 10 | 3 | | -| 09-shell | pass | Box | 13632.000 / 13632.000 | 13992.000 / 13992.000 | 11 / 11 | 2 | | -| 10-rect-pattern-holes | pass | Plate | 29803.713 / 29803.540 | 14652.481 / 14652.389 | 18 / 18 | 252 | | -| 11-circular-pattern-flange | pass | Flange | 66758.144 / 66758.844 | 19320.617 / 19320.795 | 10 / 10 | 168 | | -| 12-mirror-feature | pass | Base | 21392.575 / 21392.920 | 6530.944 / 6530.973 | 10 / 10 | 80 | | -| 13-combine-join | pass | A | 45570.295 / 45571.680 | 8634.196 / 8634.336 | 9 / 9 | 43 | | -| 14-combine-cut | pass | A | 42933.034 / 42931.417 | 9706.783 / 9706.858 | 7 / 7 | 402 | | -| 15-combine-intersect | pass | A | 54452.172 / 54454.273 | 7382.532 / 7382.743 | 7 / 7 | 1022 | | -| 16-sweep | pass | Tube | 2584.428 / 2584.724 | 1779.638 / 1779.698 | 5 / 5 | 612 | | -| 17-loft | pass | Loft | 35121.595 / 35125.498 | 6495.373 / 6495.000 | 6 / 6 | 191 | | -| 18-hole-features | pass | Plate | 33890.908 / 33890.710 | 8568.695 / 8568.696 | 13 / 13 | 184 | | -| 19-l-bracket-constrained | pass | LBracket | 14720.000 / 14720.000 | 5472.000 / 5472.000 | 8 / 8 | 2 | | -| 20-slot-constrained | pass | Slot | 2965.428 / 2965.487 | 1774.644 / 1774.690 | 6 / 6 | 15 | | -| 21-draft | pass | Box | 54574.737 / 54574.737 | 8781.998 / 8781.998 | 6 / 6 | 1 | | -| 22-split-body | pass | Cyl, Cyl (1) | 75390.813 / 75398.224 | 12954.440 / 12955.175 | 6 / 6 | 109 | | -| 23-bracket-multifeature | pass | Bracket | 59331.477 / 59331.129 | 18290.767 / 18290.737 | 19 / 19 | 117 | | -| 24-user-parameters | pass | Block | 34822.022 / 34821.903 | 8114.175 / 8114.159 | 7 / 7 | 58 | | -| 25-sketch-triangle | pass | Triangle | 4032.155 / 4032.155 | 2274.099 / 2274.099 | 5 / 5 | 1 | | -| 26-sketch-belt | pass | Belt | 11326.042 / 11326.170 | 5637.347 / 5637.404 | 7 / 7 | 42 | | -| 27-sketch-trapezoid | pass | Trapezoid | 5342.286 / 5342.257 | 3066.961 / 3066.946 | 8 / 8 | 53 | | -| 28-blind-pocket-fillets | pass | Block | 84299.345 / 84299.249 | 16279.560 / 16279.572 | 23 / 23 | 320 | | -| 29-two-sided-and-partial-revolve | pass | Block | 25731.356 / 25731.307 | 6312.589 / 6312.602 | 12 / 12 | 26 | | -| 30-fillet-after-shell | pass | Box | 21745.915 / 21745.876 | 14968.284 / 14968.332 | 27 / 27 | 464 | | -| 31-fillet-l-all-edges | pass | L | 14321.483 / 14321.626 | 5024.004 / 5024.045 | 38 / 38 | 747 | | -| 32-boss-plate-fillets | pass | Part | 33590.273 / 33590.350 | 9929.854 / 9929.864 | 10 / 10 | 1214 | | -| 33-fillet-tangent-chain | pass | Slot | 8237.037 / 8237.122 | 2793.541 / 2793.569 | 14 / 14 | 555 | | -| 34-chamfer-then-fillet | pass | Box | 22892.383 / 22892.421 | 4753.677 / 4753.688 | 18 / 18 | 54 | | -| 35-fillet-curved-curved | pass | Tee | 63169.685 / 63171.443 | 10269.168 / 10269.348 | 6 / 6 | 904 | | -| 36-corner-blend-two-radii | pass | Box | 57799.504 / 57800.240 | 8585.437 / 8585.524 | 18 / 18 | 519 | | -| 37-pyramid-apex-blend | pass | Pyramid | 15465.376 / 15465.575 | 3914.529 / 3914.581 | 18 / 18 | 312 | | -| 38-coincident-cylinders | pass | A | 15706.406 / 15707.963 | 3769.760 / 3769.911 | 3 / 3 | 67 | | -| 39-tee-pipe | pass | Tee | 24095.763 / 24096.237 | 16709.772 / 16710.048 | 7 / 7 | 182 | | -| 40-box-sphere-torus | pass | A | 36287.558 / 36287.433 | 10696.143 / 10696.165 | 10 / 10 | 2393 | | -| 41-tube-radial-holes | pass | Tube | 20243.046 / 20243.981 | 14873.689 / 14873.773 | 22 / 22 | 1409 | | -| 42-overlapping-patterns | pass | Plate | 38488.411 / 38487.064 | 21687.215 / 21686.603 | 43 / 43 | 533 | solid 'Plate': imported as an open body (the shell is not closed) | -| 43-cut-splits-body | pass | Bar, Bar (1) | 28000.000 / 28000.000 | 7200.000 / 7200.000 | 12 / 12 | 5 | | -| 44-shell-l-bracket | pass | L | 8384.000 / 8384.000 | 8784.000 / 8784.000 | 15 / 15 | 3 | | -| 45-shell-boss-plate | pass | Part | 13676.354 / 13676.602 | 14156.531 / 14156.602 | 15 / 15 | 69 | | -| 46-shell-two-open-faces | pass | Box | 16044.000 / 16044.000 | 11398.000 / 11398.000 | 10 / 10 | 3 | | -| 47-draft-curved-pocket | pass | Block | 111387.998 / 111387.712 | 17700.336 / 17700.361 | 11 / 11 | 27 | | -| 48-pulley | pass | Pulley | 168508.160 / 168510.841 | 23718.215 / 23718.644 | 12 / 12 | 321 | | -| 49-helical-sweep | pass | Spring | 4733.931 / 4738.099 | 4765.903 / 4767.652 | 3 / 3 | 10552 | solid 'Spring': face wrapping around a closed surface kept whole: split line does not reach the rings | -| 50-sweep-tight-bend | pass | Bend | 3758.025 / 3758.443 | – / – | 5 / 5 | 405 | | -| 51-loft-rect-circle-offset | pass | Loft | 61129.957 / 61133.116 | 9412.473 / 9411.655 | 6 / 6 | 251 | | -| 52-circular-pattern-fillet | pass | Hub | 47206.182 / 47206.850 | 14141.988 / 14142.171 | 21 / 21 | 820 | | -| 53-path-pattern | pass | Plate | 35010.499 / 35010.398 | 16463.947 / 16463.894 | 13 / 13 | 130 | | -| 54-mirror-body-fillets | pass | Bracket | 22780.113 / 22780.089 | 6972.831 / 6972.832 | 12 / 12 | 98 | | -| 55-tapped-hole-cosmetic | pass | Plate | 17542.360 / 17542.314 | 4761.590 / 4761.596 | 8 / 8 | 84 | | -| 56-holes-on-curved-face | pass | Cyl | 77941.171 / 77942.388 | 10592.951 / 10593.139 | 7 / 7 | 378 | | -| 57-hole-pattern-sketch-points | pass | Plate | 36767.029 / 36766.712 | 11818.165 / 11818.153 | 24 / 24 | 197 | | -| 58-phone-stand | pass | Stand | 74559.442 / 74559.177 | 29148.741 / 29148.785 | 19 / 19 | 24 | | -| 59-enclosure-base | pass | Enclosure | 34309.038 / 34309.486 | 33795.405 / 33795.610 | 39 / 39 | 286 | | -| 60-pipe-flange | pass | Flange | 323661.027 / 323660.979 | 56984.270 / 56984.564 | 16 / 16 | 993 | | -| 61-shaft-keyway | pass | Shaft | 47521.504 / 47525.946 | 9676.781 / 9677.096 | 14 / 14 | 182 | | -| 62-heat-sink | pass | HeatSink | 43125.000 / 43125.000 | 45725.000 / 45725.000 | 66 / 66 | 8 | | -| 63-hinge-knuckle | pass | Hinge | 4783.029 / 4783.117 | 4110.255 / 4110.287 | 14 / 14 | 118 | | -| 64-sketch-tangent-arc-chain | pass | Wave | 4800.000 / 4800.000 | 3843.701 / 3843.712 | 9 / 9 | 10 | | -| 65-sketch-gear-tooth | pass | Tooth | 3987.831 / 3988.084 | 1945.416 / 1945.485 | 9 / 9 | 8 | | -| 66-sketch-linkage | pass | Link | 8045.410 / 8045.386 | 4308.172 / 4308.175 | 10 / 10 | 129 | | -| 67-sm-base-flange | pass | Body1 | 15000.000 / 15000.000 | 12800.000 / 12800.000 | 6 / 6 | 2 | | -| 68-sm-edge-flange | pass | Body1 | 18972.476 / 18972.622 | 16057.518 / 16057.550 | 14 / 14 | 26 | | -| 69-sm-tray | pass | Body1 | 29665.828 / 29666.234 | 24979.993 / 24980.086 | 46 / 46 | 66 | | -| 70-sm-flange-45 | pass | Body1 | 18450.704 / 18450.777 | 15629.621 / 15629.637 | 14 / 14 | 14 | | -| 71-sm-custom-rule | pass | Body1 | 10053.579 / 10053.717 | 13896.249 / 13896.299 | 22 / 22 | 38 | | -| 72-sm-contour-flange | pass | Body1 | 12097.478 / 12097.622 | 10411.968 / 10412.002 | 22 / 22 | 36 | | -| 73-sm-hem | pass | Body1 | 21334.478 / 21334.723 | 17994.397 / 17994.473 | 22 / 22 | 60 | | -| 74-sm-unfold-refold | pass | Body1 | 15800.086 / 15800.304 | 13610.275 / 13610.326 | 32 / 32 | 84 | | -| 75-sm-bracket-holes | pass | Body1 | 13915.866 / 13916.012 | 12035.934 / 12035.962 | 25 / 25 | 100 | | -| 76-sm-thickness-variant | pass | Body1 | 22520.368 / 22520.575 | 16064.089 / 16064.128 | 14 / 14 | 31 | | diff --git a/tests/oracle/01-box/expected.json b/tests/oracle/01-box/expected.json new file mode 100644 index 00000000..51353dfe --- /dev/null +++ b/tests/oracle/01-box/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "box 40 x 30 x 20 from the origin: V = abc, A = 2(ab + bc + ca)", + "body_count": 1, + "volume_mm3": 24000.0, + "area_mm2": 5200.0, + "center_of_mass": [ + 20.0, + 15.0, + 10.0 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 40.0, + 30.0, + 20.0 + ] + }, + "faces": 6 +} diff --git a/tests/oracle/01-box/recipe.json b/tests/oracle/01-box/recipe.json new file mode 100644 index 00000000..35aa4db4 --- /dev/null +++ b/tests/oracle/01-box/recipe.json @@ -0,0 +1,153 @@ +{ + "id": "01-box", + "name": "Box extrude", + "description": "40 x 30 rectangle on XY extruded 20 mm.", + "exercises": [ + "sketch", + "extrude" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Sketch1", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 40, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + 30.0 + ] + }, + "end": { + "at": [ + 0, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Sketch1", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + } + ] +} diff --git a/tests/oracle/02-cylinder/expected.json b/tests/oracle/02-cylinder/expected.json new file mode 100644 index 00000000..6113b075 --- /dev/null +++ b/tests/oracle/02-cylinder/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "cylinder r=15 h=50 on XY: V = pi r^2 h, A = 2 pi r^2 + 2 pi r h", + "body_count": 1, + "volume_mm3": 35342.917352885175, + "area_mm2": 6126.105674500097, + "center_of_mass": [ + 0.0, + 0.0, + 25.0 + ], + "bbox": { + "min": [ + -15.0, + -15.0, + 0.0 + ], + "max": [ + 15.0, + 15.0, + 50.0 + ] + }, + "faces": 3 +} diff --git a/tests/oracle/02-cylinder/recipe.json b/tests/oracle/02-cylinder/recipe.json new file mode 100644 index 00000000..cdec45a6 --- /dev/null +++ b/tests/oracle/02-cylinder/recipe.json @@ -0,0 +1,94 @@ +{ + "id": "02-cylinder", + "name": "Cylinder", + "description": "Circle r=15 at origin on XY extruded 50 mm.", + "exercises": [ + "sketch", + "circle", + "extrude" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Sketch1", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 15, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Sketch1", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 50, + "direction": "positive" + }, + "result_bodies": [ + "Cylinder" + ] + } + ] +} diff --git a/tests/oracle/03-box-through-hole/expected.json b/tests/oracle/03-box-through-hole/expected.json new file mode 100644 index 00000000..a219555b --- /dev/null +++ b/tests/oracle/03-box-through-hole/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "box 60x40x20 minus centred through cylinder r=6: V = abc - pi r^2 c, A = 2(ab+bc+ca) - 2 pi r^2 + 2 pi r c; hole is centred so the centroid stays at the box centre", + "body_count": 1, + "volume_mm3": 45738.05328941535, + "area_mm2": 9327.787565803084, + "center_of_mass": [ + 30.0, + 20.0, + 10.0 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 60.0, + 40.0, + 20.0 + ] + }, + "faces": 7 +} diff --git a/tests/oracle/03-box-through-hole/recipe.json b/tests/oracle/03-box-through-hole/recipe.json new file mode 100644 index 00000000..5f91ab59 --- /dev/null +++ b/tests/oracle/03-box-through-hole/recipe.json @@ -0,0 +1,240 @@ +{ + "id": "03-box-through-hole", + "name": "Box with through hole", + "description": "60x40x20 box; d=12 circle sketched on the top face at the face centre, cut through all.", + "exercises": [ + "extrude", + "sketch_on_face", + "extrude_cut", + "through_all" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 60, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 60.0, + 0.0 + ] + }, + "end": { + "at": [ + 60, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 60.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + }, + { + "op": "sketch", + "name": "Sketch2", + "plane": { + "ref": "face: top planar face of Box (z=20, normal +Z)", + "origin": [ + 0.0, + 0.0, + 20.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 30.0, + 20.0, + 20.0 + ] + } + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 30.0, + 20.0 + ] + }, + "radius": 6, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Cut1", + "operation": "cut", + "profiles": [ + { + "sketch": "Sketch2", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "negative" + }, + "result_bodies": [ + "Box" + ] + } + ] +} diff --git a/tests/oracle/04-revolve-vase/expected.json b/tests/oracle/04-revolve-vase/expected.json new file mode 100644 index 00000000..e3806370 --- /dev/null +++ b/tests/oracle/04-revolve-vase/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "revolve of a line/3-point-arc profile (XZ, x = radius, z = -sketch y) 360 deg about Z. Pappus/Green: V = 2 pi * int x dA, z_c = int x z dA / int x dA, A = sum over profile edges of 2 pi int x ds (bottom disk r20, frustum r20->25, torus zone of the arc (centre (-16.000000, 33.000000), R=41.109610), frustum r15->18, top disk r18)", + "body_count": 1, + "volume_mm3": 112499.47865571639, + "area_mm2": 13156.614546780698, + "center_of_mass": [ + 0.0, + 0.0, + 35.737480169007014 + ], + "bbox": { + "min": [ + -25.10960958218893, + -25.10960958218893, + 0.0 + ], + "max": [ + 25.10960958218893, + 25.10960958218893, + 80.0 + ] + }, + "faces": 5 +} diff --git a/tests/oracle/04-revolve-vase/recipe.json b/tests/oracle/04-revolve-vase/recipe.json new file mode 100644 index 00000000..e912877b --- /dev/null +++ b/tests/oracle/04-revolve-vase/recipe.json @@ -0,0 +1,201 @@ +{ + "id": "04-revolve-vase", + "name": "Revolved vase profile", + "description": "Closed profile (lines + 3-point arc) on XZ revolved 360 deg about the world Z axis.", + "exercises": [ + "sketch_xz", + "arc", + "revolve" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Profile", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0.0, + 0.0 + ] + }, + "end": { + "at": [ + 20.0, + 0.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 20.0, + 0.0 + ] + }, + "end": { + "at": [ + 25.0, + -30.0 + ] + }, + "id": "l2" + }, + { + "type": "arc_three_point", + "start": { + "ref": "l2.end", + "at": [ + 25.0, + -30.0 + ] + }, + "mid": [ + 23.0, + -46.0 + ], + "end": { + "at": [ + 15.0, + -60.0 + ] + }, + "id": "a1" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + 15.0, + -60.0 + ] + }, + "end": { + "at": [ + 18.0, + -80.0 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 18.0, + -80.0 + ] + }, + "end": { + "at": [ + 0.0, + -80.0 + ] + }, + "id": "l4" + }, + { + "type": "line", + "start": { + "ref": "l4.end", + "at": [ + 0.0, + -80.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l5" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "revolve", + "name": "Revolve1", + "operation": "new", + "profile": { + "sketch": "Profile", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "l1", + "l2", + "l3", + "l4", + "l5" + ] + } + ] + }, + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "angle": 360, + "result_bodies": [ + "Vase" + ] + } + ] +} diff --git a/tests/oracle/05-taper-extrude/expected.json b/tests/oracle/05-taper-extrude/expected.json new file mode 100644 index 00000000..66252988 --- /dev/null +++ b/tests/oracle/05-taper-extrude/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "square frustum: base 40 at z=0, top a1 = 40 + 2*30*tan10 at z=30 (positive taper grows outward); V = h/3 (A0 + A1 + sqrt(A0 A1)), z_c = h (A0 + 2 sqrt(A0A1) + 3 A1) / (4 (A0 + sqrt(A0A1) + A1)), sides = 4 trapezoids (a0+a1)/2 * h/cos(10)", + "body_count": 1, + "volume_mm3": 61814.82595953696, + "area_mm2": 9676.91517028238, + "center_of_mass": [ + 0.0, + 0.0, + 16.162703882981894 + ], + "bbox": { + "min": [ + -25.28980942125395, + -25.28980942125395, + 0.0 + ], + "max": [ + 25.28980942125395, + 25.28980942125395, + 30.0 + ] + }, + "faces": 6 +} diff --git a/tests/oracle/05-taper-extrude/recipe.json b/tests/oracle/05-taper-extrude/recipe.json new file mode 100644 index 00000000..74ab9fa5 --- /dev/null +++ b/tests/oracle/05-taper-extrude/recipe.json @@ -0,0 +1,154 @@ +{ + "id": "05-taper-extrude", + "name": "Tapered extrude", + "description": "40x40 square centred on origin, extruded 30 mm with a 10 deg taper.", + "exercises": [ + "extrude", + "taper" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Sketch1", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -20, + -20 + ] + }, + "end": { + "at": [ + 20, + -20 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 20.0, + -20.0 + ] + }, + "end": { + "at": [ + 20, + 20 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 20.0, + 20.0 + ] + }, + "end": { + "at": [ + -20, + 20 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -20.0, + 20.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -20.0, + -20.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Sketch1", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 30, + "direction": "positive", + "taper_deg": 10 + }, + "result_bodies": [ + "Frustum" + ] + } + ] +} diff --git a/tests/oracle/06-symmetric-extrude/expected.json b/tests/oracle/06-symmetric-extrude/expected.json new file mode 100644 index 00000000..6d001169 --- /dev/null +++ b/tests/oracle/06-symmetric-extrude/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "50 (x) by 20 (z) rectangle on XZ extruded +-15 along Y: box 50x30x20 centred at origin", + "body_count": 1, + "volume_mm3": 30000.0, + "area_mm2": 6200.0, + "center_of_mass": [ + 0.0, + 0.0, + 0.0 + ], + "bbox": { + "min": [ + -25.0, + -15.0, + -10.0 + ], + "max": [ + 25.0, + 15.0, + 10.0 + ] + }, + "faces": 6 +} diff --git a/tests/oracle/06-symmetric-extrude/recipe.json b/tests/oracle/06-symmetric-extrude/recipe.json new file mode 100644 index 00000000..98c8fb65 --- /dev/null +++ b/tests/oracle/06-symmetric-extrude/recipe.json @@ -0,0 +1,155 @@ +{ + "id": "06-symmetric-extrude", + "name": "Symmetric extrude", + "description": "50x20 rectangle centred on origin on the XZ plane, extruded symmetrically 15 mm each side (30 total).", + "exercises": [ + "sketch_xz", + "extrude", + "symmetric" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Sketch1", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -25, + -10 + ] + }, + "end": { + "at": [ + 25, + -10 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 25.0, + -10.0 + ] + }, + "end": { + "at": [ + 25, + 10 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 25.0, + 10.0 + ] + }, + "end": { + "at": [ + -25, + 10 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -25.0, + 10.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -25.0, + -10.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Sketch1", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 15, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "Block" + ] + } + ] +} diff --git a/tests/oracle/07-fillet-all-edges/expected.json b/tests/oracle/07-fillet-all-edges/expected.json new file mode 100644 index 00000000..8e81cae1 --- /dev/null +++ b/tests/oracle/07-fillet-all-edges/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "box 40x30x20 with every edge rounded r=3 = inner box (34x24x14) grown by a ball r (Steiner): V = abc + 2r(ab+bc+ca) + pi r^2 (a+b+c) + 4/3 pi r^3, A = 2(ab+bc+ca) + 2 pi r (a+b+c) + 4 pi r^2; faces 6 planes + 12 quarter cylinders + 8 sphere octants", + "body_count": 1, + "volume_mm3": 23340.84937505542, + "area_mm2": 4726.265361880023, + "center_of_mass": [ + 20.0, + 15.0, + 10.0 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 40.0, + 30.0, + 20.0 + ] + }, + "faces": 26 +} diff --git a/tests/oracle/07-fillet-all-edges/recipe.json b/tests/oracle/07-fillet-all-edges/recipe.json new file mode 100644 index 00000000..6f7295b3 --- /dev/null +++ b/tests/oracle/07-fillet-all-edges/recipe.json @@ -0,0 +1,379 @@ +{ + "id": "07-fillet-all-edges", + "name": "Box with all edges filleted", + "description": "40x30x20 box, constant-radius 3 mm fillet on all 12 edges.", + "exercises": [ + "extrude", + "fillet" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 40, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + 30.0 + ] + }, + "end": { + "at": [ + 0, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + }, + { + "op": "fillet", + "name": "Fillet1", + "type": "constant_radius", + "radius": 3, + "tangent_chain": false, + "edge_selection": "all 12 edges of Box", + "edges": [ + { + "type": "line", + "start": [ + 40.0, + 30.0, + 0.0 + ], + "end": [ + 0.0, + 30.0, + 0.0 + ], + "point": [ + 20.0, + 30.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 30.0, + 0.0 + ], + "end": [ + 0.0, + 30.0, + 20.0 + ], + "point": [ + 0.0, + 30.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 30.0, + 20.0 + ], + "end": [ + 0.0, + 30.0, + 20.0 + ], + "point": [ + 20.0, + 30.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 30.0, + 0.0 + ], + "end": [ + 40.0, + 30.0, + 20.0 + ], + "point": [ + 40.0, + 30.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 0.0, + 0.0 + ], + "end": [ + 40.0, + 30.0, + 0.0 + ], + "point": [ + 40.0, + 15.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 0.0, + 20.0 + ], + "end": [ + 40.0, + 30.0, + 20.0 + ], + "point": [ + 40.0, + 15.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 0.0, + 0.0 + ], + "end": [ + 40.0, + 0.0, + 20.0 + ], + "point": [ + 40.0, + 0.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 40.0, + 0.0, + 0.0 + ], + "point": [ + 20.0, + 0.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 20.0 + ], + "end": [ + 40.0, + 0.0, + 20.0 + ], + "point": [ + 20.0, + 0.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 20.0 + ], + "point": [ + 0.0, + 0.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 30.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 0.0 + ], + "point": [ + 0.0, + 15.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 30.0, + 20.0 + ], + "end": [ + 0.0, + 0.0, + 20.0 + ], + "point": [ + 0.0, + 15.0, + 20.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/08-chamfer/expected.json b/tests/oracle/08-chamfer/expected.json new file mode 100644 index 00000000..1018eb35 --- /dev/null +++ b/tests/oracle/08-chamfer/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "box 40x30x20 with the 4 top edges chamfered 2: lower box 40x30x18 + top slab with section (36+2u)x(26+2u) at depth u (V, moment by exact polynomial integration); A = bottom + sides(h=18) + top 36x26 + 4 trapezoid bevels of slant 2 sqrt2", + "body_count": 1, + "volume_mm3": 23730.666666666668, + "area_mm2": 5029.352380466497, + "center_of_mass": [ + 19.999999999999996, + 15.0, + 9.894145409596582 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 40.0, + 30.0, + 20.0 + ] + }, + "faces": 10 +} diff --git a/tests/oracle/08-chamfer/recipe.json b/tests/oracle/08-chamfer/recipe.json new file mode 100644 index 00000000..d6b0e127 --- /dev/null +++ b/tests/oracle/08-chamfer/recipe.json @@ -0,0 +1,235 @@ +{ + "id": "08-chamfer", + "name": "Box with top edges chamfered", + "description": "40x30x20 box, equal-distance 2 mm chamfer on the 4 edges of the top face.", + "exercises": [ + "extrude", + "chamfer" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 40, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + 30.0 + ] + }, + "end": { + "at": [ + 0, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + }, + { + "op": "chamfer", + "name": "Chamfer1", + "type": "equal_distance", + "distance": 2, + "tangent_chain": false, + "edge_selection": "the 4 edges bounding the top face (z=20)", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 30.0, + 20.0 + ], + "end": [ + 0.0, + 0.0, + 20.0 + ], + "point": [ + 0.0, + 15.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 20.0 + ], + "end": [ + 40.0, + 0.0, + 20.0 + ], + "point": [ + 20.0, + 0.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 0.0, + 20.0 + ], + "end": [ + 40.0, + 30.0, + 20.0 + ], + "point": [ + 40.0, + 15.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 30.0, + 20.0 + ], + "end": [ + 0.0, + 30.0, + 20.0 + ], + "point": [ + 20.0, + 30.0, + 20.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/09-shell/expected.json b/tests/oracle/09-shell/expected.json new file mode 100644 index 00000000..7f18e2b4 --- /dev/null +++ b/tests/oracle/09-shell/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "50x40x30 box shelled inward 2 with the top open: box minus cavity [2,48]x[2,38]x[2,30]; A = outer bottom + outer sides + rim + cavity floor + cavity walls", + "body_count": 1, + "volume_mm3": 13632.0, + "area_mm2": 13992.0, + "center_of_mass": [ + 25.0, + 20.0, + 11.598591549295774 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 50.0, + 40.0, + 30.0 + ] + }, + "faces": 11 +} diff --git a/tests/oracle/09-shell/recipe.json b/tests/oracle/09-shell/recipe.json new file mode 100644 index 00000000..cfd46dbb --- /dev/null +++ b/tests/oracle/09-shell/recipe.json @@ -0,0 +1,181 @@ +{ + "id": "09-shell", + "name": "Shelled open box", + "description": "50x40x30 box shelled inward 2 mm with the top face removed.", + "exercises": [ + "extrude", + "shell" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 50, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 50.0, + 0.0 + ] + }, + "end": { + "at": [ + 50, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 50.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 30, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + }, + { + "op": "shell", + "name": "Shell1", + "faces_removed": [ + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 25.0, + 20.0, + 30.0 + ] + } + ], + "face_selection": "top face (z=30)", + "inside_thickness": 2, + "outside_thickness": 0, + "tangent_chain": false + } + ] +} diff --git a/tests/oracle/10-rect-pattern-holes/expected.json b/tests/oracle/10-rect-pattern-holes/expected.json new file mode 100644 index 00000000..c3a98c80 --- /dev/null +++ b/tests/oracle/10-rect-pattern-holes/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "plate 90x70x5 minus 12 through cylinders r=3 at (15+20i, 15+20j), i<4, j<3: V = abc - 12 pi r^2 t, A = 2(ab+bc+ca) + 12(2 pi r t - 2 pi r^2)", + "body_count": 1, + "volume_mm3": 29803.53996706153, + "area_mm2": 14652.38934211693, + "center_of_mass": [ + 44.99999999999998, + 34.99999999999999, + 2.4999999999999964 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 90.0, + 70.0, + 5.0 + ] + }, + "faces": 18 +} diff --git a/tests/oracle/10-rect-pattern-holes/recipe.json b/tests/oracle/10-rect-pattern-holes/recipe.json new file mode 100644 index 00000000..6eea42bc --- /dev/null +++ b/tests/oracle/10-rect-pattern-holes/recipe.json @@ -0,0 +1,248 @@ +{ + "id": "10-rect-pattern-holes", + "name": "Rectangular pattern of holes", + "description": "90x70x5 plate; d=6 hole at (15,15) cut through; feature pattern 4 along X @20 mm x 3 along Y @20 mm.", + "exercises": [ + "extrude", + "extrude_cut", + "rectangular_pattern" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Plate_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 90, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 90.0, + 0.0 + ] + }, + "end": { + "at": [ + 90, + 70 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 90.0, + 70.0 + ] + }, + "end": { + "at": [ + 0, + 70 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 70.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Plate", + "operation": "new", + "profiles": [ + { + "sketch": "Plate_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 5, + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "sketch", + "name": "HoleSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 15, + 15 + ] + }, + "radius": 3, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Hole1", + "operation": "cut", + "profiles": [ + { + "sketch": "HoleSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "rectangular_pattern", + "name": "RectPattern1", + "pattern_of": "features", + "features": [ + "Hole1" + ], + "direction1": { + "axis": [ + 1, + 0, + 0 + ], + "count": 4, + "spacing": 20 + }, + "direction2": { + "axis": [ + 0, + 1, + 0 + ], + "count": 3, + "spacing": 20 + }, + "distance_type": "spacing (distance between instances)" + } + ] +} diff --git a/tests/oracle/11-circular-pattern-flange/expected.json b/tests/oracle/11-circular-pattern-flange/expected.json new file mode 100644 index 00000000..fdad437f --- /dev/null +++ b/tests/oracle/11-circular-pattern-flange/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "annulus R50/r15 h10 minus 6 through cylinders r=5 on a 40 radius: V = pi(R^2-r^2)h - 6 pi rh^2 h, A = 2 pi(R^2-r^2) + 2 pi(R+r)h + 6(2 pi rh h - 2 pi rh^2); 6-fold symmetry keeps the centroid on the axis", + "body_count": 1, + "volume_mm3": 66758.84388878311, + "area_mm2": 19320.79481957723, + "center_of_mass": [ + 0.0, + 0.0, + 5.0 + ], + "bbox": { + "min": [ + -50.0, + -50.0, + 0.0 + ], + "max": [ + 50.0, + 50.0, + 10.0 + ] + }, + "faces": 10 +} diff --git a/tests/oracle/11-circular-pattern-flange/recipe.json b/tests/oracle/11-circular-pattern-flange/recipe.json new file mode 100644 index 00000000..cba823ae --- /dev/null +++ b/tests/oracle/11-circular-pattern-flange/recipe.json @@ -0,0 +1,204 @@ +{ + "id": "11-circular-pattern-flange", + "name": "Flange with circular hole pattern", + "description": "Annulus OD100 / ID30 extruded 10; d=10 hole at (40,0) cut through; circular pattern x6 over 360 about Z.", + "exercises": [ + "extrude", + "annulus_profile", + "extrude_cut", + "circular_pattern" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "FlangeSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 50, + "id": "c_out" + }, + { + "type": "circle", + "center": { + "ref": "c_out.center", + "at": [ + 0.0, + 0.0 + ] + }, + "radius": 15, + "id": "c_in" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Flange", + "operation": "new", + "profiles": [ + { + "sketch": "FlangeSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c_out" + ] + }, + { + "outer": false, + "curves": [ + "c_in" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "positive" + }, + "result_bodies": [ + "Flange" + ] + }, + { + "op": "sketch", + "name": "HoleSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 40, + 0 + ] + }, + "radius": 5, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Hole1", + "operation": "cut", + "profiles": [ + { + "sketch": "HoleSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Flange" + ] + }, + { + "op": "circular_pattern", + "name": "CircPattern1", + "pattern_of": "features", + "features": [ + "Hole1" + ], + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "count": 6, + "total_angle": 360, + "symmetric": false + } + ] +} diff --git a/tests/oracle/12-mirror-feature/expected.json b/tests/oracle/12-mirror-feature/expected.json new file mode 100644 index 00000000..6a622297 --- /dev/null +++ b/tests/oracle/12-mirror-feature/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "block [-30,30]x[0,30]x[0,10] + two bosses r=6 h=15 at (+-15,15) on top (mirror across YZ): V = abc + 2 pi r^2 h, A = 2(ab+bc+ca) + 2 * 2 pi r h", + "body_count": 1, + "volume_mm3": 21392.920065876977, + "area_mm2": 6530.973355292325, + "center_of_mass": [ + 0.0, + 14.999999999999998, + 6.98250171986157 + ], + "bbox": { + "min": [ + -30.0, + 0.0, + 0.0 + ], + "max": [ + 30.0, + 30.0, + 25.0 + ] + }, + "faces": 10 +} diff --git a/tests/oracle/12-mirror-feature/recipe.json b/tests/oracle/12-mirror-feature/recipe.json new file mode 100644 index 00000000..3046c5e7 --- /dev/null +++ b/tests/oracle/12-mirror-feature/recipe.json @@ -0,0 +1,262 @@ +{ + "id": "12-mirror-feature", + "name": "Mirror feature", + "description": "60x30x10 block centred on X; d=12 boss 15 tall joined at (15,15) on top; boss feature mirrored across YZ.", + "exercises": [ + "extrude", + "extrude_join", + "mirror" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -30, + 0 + ] + }, + "end": { + "at": [ + 30, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 30.0, + 0.0 + ] + }, + "end": { + "at": [ + 30, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 30.0, + 30.0 + ] + }, + "end": { + "at": [ + -30, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -30.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -30.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Base", + "operation": "new", + "profiles": [ + { + "sketch": "Base_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "positive" + }, + "result_bodies": [ + "Base" + ] + }, + { + "op": "sketch", + "name": "BossSketch", + "plane": { + "ref": "face: top face of Base (z=10)", + "origin": [ + 0.0, + 0.0, + 10.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 0.0, + 15.0, + 10.0 + ] + } + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 15.0, + 15.0 + ] + }, + "radius": 6, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Boss", + "operation": "join", + "profiles": [ + { + "sketch": "BossSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 15, + "direction": "positive" + }, + "result_bodies": [ + "Base" + ] + }, + { + "op": "mirror", + "name": "Mirror1", + "mirror_of": "features", + "features": [ + "Boss" + ], + "plane": { + "ref": "YZ", + "origin": [ + 0, + 0, + 0 + ], + "normal": [ + 1, + 0, + 0 + ] + }, + "operation": "join" + } + ] +} diff --git a/tests/oracle/13-combine-join/expected.json b/tests/oracle/13-combine-join/expected.json new file mode 100644 index 00000000..553f6b1e --- /dev/null +++ b/tests/oracle/13-combine-join/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "box [0,40]^2x[0,20] union cylinder r=12 at (40,20) z 0..40: V = box + half-cylinder (x>40, z<20, centroid 40 + 4r/3pi) + full cylinder z 20..40; A = bottom (1600 + pi r^2/2) + box top (1600 - pi r^2/2) + cyl top + 3 full sides + x=40 side minus 24x20 + cyl side (pi r 40 outside half + pi r 20 upper inside half)", + "body_count": 1, + "volume_mm3": 45571.68026350791, + "area_mm2": 8634.336052701581, + "center_of_mass": [ + 26.46176756194705, + 19.999999999999996, + 13.970793611305016 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 52.0, + 40.0, + 40.0 + ] + }, + "faces": 9 +} diff --git a/tests/oracle/13-combine-join/recipe.json b/tests/oracle/13-combine-join/recipe.json new file mode 100644 index 00000000..14880a7d --- /dev/null +++ b/tests/oracle/13-combine-join/recipe.json @@ -0,0 +1,231 @@ +{ + "id": "13-combine-join", + "name": "Boolean union", + "description": "Box 40x40x20 at origin + cylinder r=12 h=40 at (40,20) as separate bodies, then combine join.", + "exercises": [ + "extrude", + "combine_join" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "BoxA_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 40, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "BoxA", + "operation": "new", + "profiles": [ + { + "sketch": "BoxA_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "A" + ] + }, + { + "op": "sketch", + "name": "CylSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 40, + 20 + ] + }, + "radius": 12, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "CylB", + "operation": "new", + "profiles": [ + { + "sketch": "CylSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 40, + "direction": "positive" + }, + "result_bodies": [ + "B" + ] + }, + { + "op": "combine", + "name": "Combine1", + "operation": "join", + "target_body": "A", + "tool_bodies": [ + "B" + ], + "keep_tools": false + } + ] +} diff --git a/tests/oracle/14-combine-cut/expected.json b/tests/oracle/14-combine-cut/expected.json new file mode 100644 index 00000000..ef851168 --- /dev/null +++ b/tests/oracle/14-combine-cut/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "box [-25,25]^2x[-20,0] minus sphere r=15 at the origin (lower hemisphere removed): V = abc - 2/3 pi r^3, hemisphere centroid z = -3r/8; A = 2(ab+bc+ca) - pi r^2 + 2 pi r^2", + "body_count": 1, + "volume_mm3": 42931.41652942297, + "area_mm2": 9706.858347057703, + "center_of_mass": [ + 0.0, + 0.0, + -10.720336182305564 + ], + "bbox": { + "min": [ + -25.0, + -25.0, + -20.0 + ], + "max": [ + 25.0, + 25.0, + 0.0 + ] + }, + "faces": 7 +} diff --git a/tests/oracle/14-combine-cut/recipe.json b/tests/oracle/14-combine-cut/recipe.json new file mode 100644 index 00000000..812cb4ee --- /dev/null +++ b/tests/oracle/14-combine-cut/recipe.json @@ -0,0 +1,267 @@ +{ + "id": "14-combine-cut", + "name": "Boolean subtract", + "description": "Box (-25,-25,-20)..(25,25,0) minus a sphere r=15 at origin (hemispherical dimple).", + "exercises": [ + "extrude", + "revolve", + "combine_cut" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "BoxA_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -25, + -25 + ] + }, + "end": { + "at": [ + 25, + -25 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 25.0, + -25.0 + ] + }, + "end": { + "at": [ + 25, + 25 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 25.0, + 25.0 + ] + }, + "end": { + "at": [ + -25, + 25 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -25.0, + 25.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -25.0, + -25.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "BoxA", + "operation": "new", + "profiles": [ + { + "sketch": "BoxA_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "negative" + }, + "result_bodies": [ + "A" + ] + }, + { + "op": "sketch", + "name": "SphereB_sketch", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "arc_three_point", + "start": { + "at": [ + 0.0, + 15.0 + ] + }, + "mid": [ + 15.0, + 0.0 + ], + "end": { + "at": [ + 0.0, + -15.0 + ] + }, + "id": "a1" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + 0.0, + -15.0 + ] + }, + "end": { + "ref": "a1.start", + "at": [ + 0.0, + 15.0 + ] + }, + "id": "l1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "revolve", + "name": "SphereB", + "operation": "new", + "profile": { + "sketch": "SphereB_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "l1" + ] + } + ] + }, + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "angle": 360, + "result_bodies": [ + "B" + ] + }, + { + "op": "combine", + "name": "Combine1", + "operation": "cut", + "target_body": "A", + "tool_bodies": [ + "B" + ], + "keep_tools": false + } + ] +} diff --git a/tests/oracle/15-combine-intersect/expected.json b/tests/oracle/15-combine-intersect/expected.json new file mode 100644 index 00000000..d212cc37 --- /dev/null +++ b/tests/oracle/15-combine-intersect/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "cube [-20,20]^3 intersect sphere R=25: sphere minus 6 disjoint caps of height h=5 (caps cannot overlap: two would need x^2+y^2 > 800 > R^2); V = 4/3 pi R^3 - 6 pi h^2 (3R-h)/3, A = 4 pi R^2 - 6 * 2 pi R h + 6 pi (R^2-20^2)", + "body_count": 1, + "volume_mm3": 54454.27266222308, + "area_mm2": 7382.7427359360145, + "center_of_mass": [ + 0.0, + 0.0, + 0.0 + ], + "bbox": { + "min": [ + -20.0, + -20.0, + -20.0 + ], + "max": [ + 20.0, + 20.0, + 20.0 + ] + }, + "faces": 7 +} diff --git a/tests/oracle/15-combine-intersect/recipe.json b/tests/oracle/15-combine-intersect/recipe.json new file mode 100644 index 00000000..cc86a542 --- /dev/null +++ b/tests/oracle/15-combine-intersect/recipe.json @@ -0,0 +1,269 @@ +{ + "id": "15-combine-intersect", + "name": "Boolean intersect", + "description": "Cube 40 centred at origin intersected with sphere r=25 at origin.", + "exercises": [ + "extrude", + "symmetric", + "revolve", + "combine_intersect" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "CubeSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -20, + -20 + ] + }, + "end": { + "at": [ + 20, + -20 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 20.0, + -20.0 + ] + }, + "end": { + "at": [ + 20, + 20 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 20.0, + 20.0 + ] + }, + "end": { + "at": [ + -20, + 20 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -20.0, + 20.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -20.0, + -20.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "CubeA", + "operation": "new", + "profiles": [ + { + "sketch": "CubeSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 20, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "A" + ] + }, + { + "op": "sketch", + "name": "SphereB_sketch", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "arc_three_point", + "start": { + "at": [ + 0.0, + 25.0 + ] + }, + "mid": [ + 25.0, + 0.0 + ], + "end": { + "at": [ + 0.0, + -25.0 + ] + }, + "id": "a1" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + 0.0, + -25.0 + ] + }, + "end": { + "ref": "a1.start", + "at": [ + 0.0, + 25.0 + ] + }, + "id": "l1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "revolve", + "name": "SphereB", + "operation": "new", + "profile": { + "sketch": "SphereB_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "l1" + ] + } + ] + }, + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "angle": 360, + "result_bodies": [ + "B" + ] + }, + { + "op": "combine", + "name": "Combine1", + "operation": "intersect", + "target_body": "A", + "tool_bodies": [ + "B" + ], + "keep_tools": false + } + ] +} diff --git a/tests/oracle/16-sweep/expected.json b/tests/oracle/16-sweep/expected.json new file mode 100644 index 00000000..9e39f6d8 --- /dev/null +++ b/tests/oracle/16-sweep/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "circle r=3 swept along line 30 (+Z) + tangent R20 quarter arc + line 30 (+X): two cylinders + quarter torus (Pappus: V = pi r^2 R theta, A = 2 pi r R theta; torus centroid at rho = (R^2 + r^2/4)/R * sin(theta/2)/(theta/2) from the bend axis along the bisector) + 2 end disks", + "body_count": 1, + "volume_mm3": 2584.7244290365306, + "area_mm2": 1779.6982871223033, + "center_of_mass": [ + 13.958930123810118, + 0.0, + 36.04106987618987 + ], + "bbox": { + "min": [ + -3.0, + -3.0, + 0.0 + ], + "max": [ + 50.0, + 3.0, + 53.0 + ] + }, + "faces": 5 +} diff --git a/tests/oracle/16-sweep/recipe.json b/tests/oracle/16-sweep/recipe.json new file mode 100644 index 00000000..ec534a26 --- /dev/null +++ b/tests/oracle/16-sweep/recipe.json @@ -0,0 +1,182 @@ +{ + "id": "16-sweep", + "name": "Sweep along path", + "description": "d=6 circle on XY at origin swept along a path on XZ: line up 30, tangent R20 arc, line 30 along +X.", + "exercises": [ + "sketch_xz", + "sweep", + "path_chain" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Path", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0.0, + 0.0 + ] + }, + "end": { + "at": [ + 0.0, + -30.0 + ] + }, + "id": "l1" + }, + { + "type": "arc_three_point", + "start": { + "ref": "l1.end", + "at": [ + 0.0, + -30.0 + ] + }, + "mid": [ + 5.857864376, + -44.142135624 + ], + "end": { + "at": [ + 20.0, + -50.0 + ] + }, + "id": "a1" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + 20.0, + -50.0 + ] + }, + "end": { + "at": [ + 50.0, + -50.0 + ] + }, + "id": "l2" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sketch", + "name": "ProfileSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 3, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sweep", + "name": "Sweep1", + "operation": "new", + "profile": { + "sketch": "ProfileSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + "path": { + "sketch": "Path", + "curves": [ + "l1", + "a1", + "l2" + ], + "chained": true + }, + "orientation": "perpendicular", + "result_bodies": [ + "Tube" + ] + } + ] +} diff --git a/tests/oracle/17-loft/expected.json b/tests/oracle/17-loft/expected.json new file mode 100644 index 00000000..790fe3b1 --- /dev/null +++ b/tests/oracle/17-loft/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft", + "body_count": 1, + "volume_mm3": 35125.49757633739, + "area_mm2": 6495.0000512370025, + "center_of_mass": [ + 4.902280481153527e-11, + 9.800242527565077e-16, + 15.119060422795876 + ], + "bbox": { + "min": [ + -20.0000001, + -20.0000001, + -1e-07 + ], + "max": [ + 20.0000001, + 20.0000001, + 40.000000100000015 + ] + } +} diff --git a/tests/oracle/17-loft/recipe.json b/tests/oracle/17-loft/recipe.json new file mode 100644 index 00000000..e1eba87e --- /dev/null +++ b/tests/oracle/17-loft/recipe.json @@ -0,0 +1,211 @@ +{ + "id": "17-loft", + "name": "Loft square to circle", + "description": "Loft from a 40x40 square on XY (z=0) to a d=20 circle on a plane offset 40 mm.", + "exercises": [ + "loft", + "offset_plane" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Bottom", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -20, + -20 + ] + }, + "end": { + "at": [ + 20, + -20 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 20.0, + -20.0 + ] + }, + "end": { + "at": [ + 20, + 20 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 20.0, + 20.0 + ] + }, + "end": { + "at": [ + -20, + 20 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -20.0, + 20.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -20.0, + -20.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "construction_plane", + "name": "Plane40", + "method": "offset", + "base": "XY", + "offset": 40 + }, + { + "op": "sketch", + "name": "Top", + "plane": { + "ref": "construction plane Plane40 (XY offset 40)", + "origin": [ + 0.0, + 0.0, + 40.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 10, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "loft", + "name": "Loft1", + "operation": "new", + "sections": [ + { + "sketch": "Bottom", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + { + "sketch": "Top", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "end_conditions": "free (none)", + "rails": [], + "solid": true, + "result_bodies": [ + "Loft" + ] + } + ] +} diff --git a/tests/oracle/17-loft/reference.stl b/tests/oracle/17-loft/reference.stl new file mode 100644 index 00000000..a8a0cee3 Binary files /dev/null and b/tests/oracle/17-loft/reference.stl differ diff --git a/tests/oracle/18-hole-features/expected.json b/tests/oracle/18-hole-features/expected.json new file mode 100644 index 00000000..30ae9ec3 --- /dev/null +++ b/tests/oracle/18-hole-features/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "plate 60x40x15 minus: counterbore (r7 x 5 deep + r4 through), countersink (90 deg cone frustum r6->r3 over 3 mm + r3 through), blind r3 hole 10 deep to the shoulder with a 118 deg cone point (height 3/tan59, centroid 1/4 up from the apex); areas: removed top/bottom disks, cylinder walls 2 pi r h, cb floor annulus, cone laterals pi r s", + "body_count": 1, + "volume_mm3": 33890.710358950084, + "area_mm2": 8568.695781624992, + "center_of_mass": [ + 30.307000593669976, + 20.000000000000004, + 7.38224663372136 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 60.0, + 40.0, + 15.0 + ] + }, + "faces": 13 +} diff --git a/tests/oracle/18-hole-features/recipe.json b/tests/oracle/18-hole-features/recipe.json new file mode 100644 index 00000000..827957e4 --- /dev/null +++ b/tests/oracle/18-hole-features/recipe.json @@ -0,0 +1,253 @@ +{ + "id": "18-hole-features", + "name": "Hole features (counterbore, countersink, blind drilled)", + "description": "60x40x15 plate; on the top face: counterbore hole at (15,20) through all, countersink at (35,20) through all, simple blind hole depth 10 at (50,20) with 118 deg drill tip.", + "exercises": [ + "hole_counterbore", + "hole_countersink", + "hole_blind" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Plate_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 60, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 60.0, + 0.0 + ] + }, + "end": { + "at": [ + 60, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 60.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Plate", + "operation": "new", + "profiles": [ + { + "sketch": "Plate_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 15, + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "hole", + "name": "CBore", + "hole_type": "counterbore", + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 30.0, + 20.0, + 15.0 + ] + }, + "position": [ + 15, + 20, + 15 + ], + "direction": "into the material (-Z)", + "diameter": 8, + "counterbore_diameter": 14, + "counterbore_depth": 5, + "extent": "through_all" + }, + { + "op": "hole", + "name": "CSink", + "hole_type": "countersink", + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 30.0, + 20.0, + 15.0 + ] + }, + "position": [ + 35, + 20, + 15 + ], + "direction": "into the material (-Z)", + "diameter": 6, + "countersink_diameter": 12, + "countersink_angle": 90, + "extent": "through_all" + }, + { + "op": "hole", + "name": "Blind", + "hole_type": "simple", + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 25.5, + 20.0, + 15.0 + ] + }, + "position": [ + 50, + 20, + 15 + ], + "direction": "into the material (-Z)", + "diameter": 6, + "depth": 10, + "tip_angle": 118, + "extent": "distance" + } + ] +} diff --git a/tests/oracle/19-l-bracket-constrained/expected.json b/tests/oracle/19-l-bracket-constrained/expected.json new file mode 100644 index 00000000..0c83c404 --- /dev/null +++ b/tests/oracle/19-l-bracket-constrained/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "solved L: (0,0)-(60,0)-(60,8)-(8,8)-(8,40)-(0,40); area 60x8 + 8x32, extruded 20: V = A2 h, A = 2 A2 + perimeter*h, centroid of the two rectangles", + "body_count": 1, + "volume_mm3": 14720.0, + "area_mm2": 5472.0, + "center_of_mass": [ + 20.956521739130434, + 10.956521739130435, + 10.0 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 60.0, + 40.0, + 20.0 + ] + }, + "faces": 8 +} diff --git a/tests/oracle/19-l-bracket-constrained/recipe.json b/tests/oracle/19-l-bracket-constrained/recipe.json new file mode 100644 index 00000000..bc483c90 --- /dev/null +++ b/tests/oracle/19-l-bracket-constrained/recipe.json @@ -0,0 +1,275 @@ +{ + "id": "19-l-bracket-constrained", + "name": "Constrained L-bracket sketch extruded", + "description": "L outline drawn roughly, fully constrained with H/V constraints + 4 dimensions + corner on origin; solved profile extruded 20 mm.", + "exercises": [ + "sketch_constraints", + "sketch_dimensions", + "extrude" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "LSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 1, + 1 + ] + }, + "end": { + "at": [ + 55, + -2 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 55.0, + -2.0 + ] + }, + "end": { + "at": [ + 57, + 9 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 57.0, + 9.0 + ] + }, + "end": { + "at": [ + 10, + 7 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 10.0, + 7.0 + ] + }, + "end": { + "at": [ + 9, + 45 + ] + }, + "id": "l4" + }, + { + "type": "line", + "start": { + "ref": "l4.end", + "at": [ + 9.0, + 45.0 + ] + }, + "end": { + "at": [ + -1, + 38 + ] + }, + "id": "l5" + }, + { + "type": "line", + "start": { + "ref": "l5.end", + "at": [ + -1.0, + 38.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 1.0, + 1.0 + ] + }, + "id": "l6" + } + ], + "constraints": [ + { + "type": "horizontal", + "refs": [ + "l1" + ] + }, + { + "type": "horizontal", + "refs": [ + "l3" + ] + }, + { + "type": "horizontal", + "refs": [ + "l5" + ] + }, + { + "type": "vertical", + "refs": [ + "l2" + ] + }, + { + "type": "vertical", + "refs": [ + "l4" + ] + }, + { + "type": "vertical", + "refs": [ + "l6" + ] + }, + { + "type": "coincident", + "refs": [ + "l1.start", + "origin" + ] + } + ], + "dimensions": [ + { + "type": "distance", + "refs": [ + "l1.start", + "l1.end" + ], + "value": 60, + "unit": "mm", + "orientation": "horizontal" + }, + { + "type": "distance", + "refs": [ + "l2.start", + "l2.end" + ], + "value": 8, + "unit": "mm", + "orientation": "vertical" + }, + { + "type": "distance", + "refs": [ + "l6.start", + "l6.end" + ], + "value": 40, + "unit": "mm", + "orientation": "vertical" + }, + { + "type": "distance", + "refs": [ + "l5.start", + "l5.end" + ], + "value": 8, + "unit": "mm", + "orientation": "horizontal" + } + ] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "LSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4", + "l5", + "l6" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "LBracket" + ] + } + ] +} diff --git a/tests/oracle/20-slot-constrained/expected.json b/tests/oracle/20-slot-constrained/expected.json new file mode 100644 index 00000000..4549733a --- /dev/null +++ b/tests/oracle/20-slot-constrained/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "obround: arc centres (0,0) and (40,0), R6, tangent horizontal lines y=+-6: A2 = 2rL + pi r^2, V = A2 h, A = 2 A2 + perimeter (2L + 2 pi r) h", + "body_count": 1, + "volume_mm3": 2965.4866776461627, + "area_mm2": 1774.6902302738526, + "center_of_mass": [ + 20.0, + 0.0, + 2.5 + ], + "bbox": { + "min": [ + -6.0, + -6.0, + 0.0 + ], + "max": [ + 46.0, + 6.0, + 5.0 + ] + }, + "faces": 6 +} diff --git a/tests/oracle/20-slot-constrained/recipe.json b/tests/oracle/20-slot-constrained/recipe.json new file mode 100644 index 00000000..96f32828 --- /dev/null +++ b/tests/oracle/20-slot-constrained/recipe.json @@ -0,0 +1,225 @@ +{ + "id": "20-slot-constrained", + "name": "Constrained slot extruded", + "description": "Obround: two arcs + two lines drawn inexactly, solved with tangent/equal/horizontal constraints, R6 and 40 mm centre distance; extruded 5.", + "exercises": [ + "sketch_constraints", + "tangent", + "equal", + "extrude" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "SlotSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "arc_center_start_sweep", + "center": { + "at": [ + 1, + 1 + ] + }, + "start": { + "at": [ + 1, + 7 + ] + }, + "sweep_deg": 180, + "id": "a1" + }, + { + "type": "arc_center_start_sweep", + "center": { + "at": [ + 37, + 2 + ] + }, + "start": { + "at": [ + 37, + -3 + ] + }, + "sweep_deg": 180, + "id": "a2" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + 1.0, + -5.0 + ] + }, + "end": { + "ref": "a2.start", + "at": [ + 37.0, + -3.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "a2.end", + "at": [ + 37.0, + 7.0 + ] + }, + "end": { + "ref": "a1.start", + "at": [ + 1.0, + 7.0 + ] + }, + "id": "l2" + } + ], + "constraints": [ + { + "type": "coincident", + "refs": [ + "a1.center", + "origin" + ] + }, + { + "type": "tangent", + "refs": [ + "a1", + "l1" + ] + }, + { + "type": "tangent", + "refs": [ + "l1", + "a2" + ] + }, + { + "type": "tangent", + "refs": [ + "a2", + "l2" + ] + }, + { + "type": "tangent", + "refs": [ + "l2", + "a1" + ] + }, + { + "type": "equal", + "refs": [ + "a1", + "a2" + ] + }, + { + "type": "horizontal", + "refs": [ + "l1" + ] + } + ], + "dimensions": [ + { + "type": "radius", + "refs": [ + "a1" + ], + "value": 6, + "unit": "mm" + }, + { + "type": "distance", + "refs": [ + "a1.center", + "a2.center" + ], + "value": 40, + "unit": "mm", + "orientation": "horizontal" + } + ] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "SlotSketch", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "a2", + "l1", + "l2" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 5, + "direction": "positive" + }, + "result_bodies": [ + "Slot" + ] + } + ] +} diff --git a/tests/oracle/21-draft/expected.json b/tests/oracle/21-draft/expected.json new file mode 100644 index 00000000..20016aef --- /dev/null +++ b/tests/oracle/21-draft/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "40x40x30 box, 5 deg draft hinged on the bottom (z=0), pull -Z: square frustum 40 -> 40 + 2*30*tan5; V = h/3 (A0 + A1 + sqrt(A0A1)), sides 4 trapezoids of slant h/cos5", + "body_count": 1, + "volume_mm3": 54574.73735870641, + "area_mm2": 8781.998445243411, + "center_of_mass": [ + 0.0, + 0.0, + 15.614983642248854 + ], + "bbox": { + "min": [ + -22.62465990577772, + -22.62465990577772, + 0.0 + ], + "max": [ + 22.62465990577772, + 22.62465990577772, + 30.0 + ] + }, + "faces": 6 +} diff --git a/tests/oracle/21-draft/recipe.json b/tests/oracle/21-draft/recipe.json new file mode 100644 index 00000000..670a8163 --- /dev/null +++ b/tests/oracle/21-draft/recipe.json @@ -0,0 +1,253 @@ +{ + "id": "21-draft", + "name": "Draft side faces", + "description": "40x40x30 box (centred on origin in XY); 5 deg draft on the 4 side faces, neutral plane = bottom face.", + "exercises": [ + "extrude", + "draft" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -20, + -20 + ] + }, + "end": { + "at": [ + 20, + -20 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 20.0, + -20.0 + ] + }, + "end": { + "at": [ + 20, + 20 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 20.0, + 20.0 + ] + }, + "end": { + "at": [ + -20, + 20 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -20.0, + 20.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -20.0, + -20.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 30, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + }, + { + "op": "draft", + "name": "Draft1", + "faces": [ + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 1.0, + 0.0 + ], + "plane_normal_outward": [ + 0.0, + 1.0, + 0.0 + ], + "point": [ + 0.0, + 20.0, + 15.0 + ] + }, + { + "type": "plane", + "normal_at_point_on_face": [ + 1.0, + 0.0, + 0.0 + ], + "plane_normal_outward": [ + 1.0, + 0.0, + 0.0 + ], + "point": [ + 20.0, + 0.0, + 15.0 + ] + }, + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + -1.0, + 0.0 + ], + "plane_normal_outward": [ + 0.0, + -1.0, + 0.0 + ], + "point": [ + 0.0, + -20.0, + 15.0 + ] + }, + { + "type": "plane", + "normal_at_point_on_face": [ + -1.0, + 0.0, + 0.0 + ], + "plane_normal_outward": [ + -1.0, + 0.0, + 0.0 + ], + "point": [ + -20.0, + 0.0, + 15.0 + ] + } + ], + "face_selection": "the 4 vertical side faces", + "neutral_plane": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + -1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + -1.0 + ], + "point": [ + 0.0, + 0.0, + 0.0 + ] + }, + "neutral_selection": "bottom face (z=0)", + "angle": 5, + "flipped": false + } + ] +} diff --git a/tests/oracle/22-split-body/expected.json b/tests/oracle/22-split-body/expected.json new file mode 100644 index 00000000..822a52f0 --- /dev/null +++ b/tests/oracle/22-split-body/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "cylinder r20 z -30..30 split by the plane through the X axis tilted 30 deg (z = y tan30, stays within |z|<=11.6 inside the cylinder): 2 congruent halves (point symmetry), V total = pi r^2 h, A total = cylinder + 2 ellipses pi r (r/cos30)", + "body_count": 2, + "volume_mm3": 75398.22368615503, + "area_mm2": 12955.175474262087, + "center_of_mass": [ + 0.0, + 0.0, + 0.0 + ], + "bbox": { + "min": [ + -20.0, + -20.0, + -30.0 + ], + "max": [ + 20.0, + 20.0, + 30.0 + ] + }, + "faces": 6 +} diff --git a/tests/oracle/22-split-body/recipe.json b/tests/oracle/22-split-body/recipe.json new file mode 100644 index 00000000..796d2520 --- /dev/null +++ b/tests/oracle/22-split-body/recipe.json @@ -0,0 +1,125 @@ +{ + "id": "22-split-body", + "name": "Split body by angled plane", + "description": "Cylinder r=20, z from -30 to 30 (symmetric extrude) split by a plane containing the X axis tilted 30 deg from XY.", + "exercises": [ + "extrude", + "symmetric", + "construction_plane_angle", + "split_body" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Sketch1", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 20, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Sketch1", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 30, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "Cyl" + ] + }, + { + "op": "construction_plane", + "name": "Tilt30", + "method": "at_angle", + "axis": "world X axis", + "base_plane": "XY", + "angle": 30, + "result_origin": [ + 12.125, + 0.0, + 0.0 + ], + "result_normal": [ + 0.0, + -0.5, + 0.866025404 + ] + }, + { + "op": "split_body", + "name": "Split1", + "body": "Cyl", + "tool": "Tilt30", + "extend_tool": true, + "result_bodies": [ + "Cyl", + "Cyl (1)" + ] + } + ] +} diff --git a/tests/oracle/23-bracket-multifeature/expected.json b/tests/oracle/23-bracket-multifeature/expected.json new file mode 100644 index 00000000..25ca8076 --- /dev/null +++ b/tests/oracle/23-bracket-multifeature/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part makeFillet, Part extrude join, Part extrude cut, Part makeChamfer", + "body_count": 1, + "volume_mm3": 59331.128616959795, + "area_mm2": 18290.736978057597, + "center_of_mass": [ + 40.00000000000001, + 34.567350242237325, + 15.289013135744947 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 50.0, + 50.0 + ] + } +} diff --git a/tests/oracle/23-bracket-multifeature/recipe.json b/tests/oracle/23-bracket-multifeature/recipe.json new file mode 100644 index 00000000..a007f090 --- /dev/null +++ b/tests/oracle/23-bracket-multifeature/recipe.json @@ -0,0 +1,693 @@ +{ + "id": "23-bracket-multifeature", + "name": "Multi-feature bracket", + "description": "Base plate 80x50x8, back wall, inner fillet R5, gusset, 2 base holes, wall hole, front corner chamfers.", + "exercises": [ + "extrude", + "extrude_join", + "fillet", + "symmetric", + "offset_plane", + "extrude_cut", + "chamfer" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 80, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + 0.0 + ] + }, + "end": { + "at": [ + 80, + 50 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 80.0, + 50.0 + ] + }, + "end": { + "at": [ + 0, + 50 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 50.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Base", + "operation": "new", + "profiles": [ + { + "sketch": "Base_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 8, + "direction": "positive" + }, + "result_bodies": [ + "Bracket" + ] + }, + { + "op": "sketch", + "name": "WallSketch", + "plane": { + "ref": "face: top of Base (z=8)", + "origin": [ + 0.0, + 0.0, + 8.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 40.0, + 25.0, + 8.0 + ] + } + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0.0, + 42.0 + ] + }, + "end": { + "at": [ + 80.0, + 42.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + 42.0 + ] + }, + "end": { + "at": [ + 80.0, + 50.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 80.0, + 50.0 + ] + }, + "end": { + "at": [ + 0.0, + 50.0 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 50.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 42.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Wall", + "operation": "join", + "profiles": [ + { + "sketch": "WallSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 42, + "direction": "positive" + }, + "result_bodies": [ + "Bracket" + ] + }, + { + "op": "fillet", + "name": "InnerFillet", + "type": "constant_radius", + "radius": 5, + "tangent_chain": false, + "edge_selection": "the X-parallel concave edge where base top (z=8) meets wall front (y=42)", + "edges": [ + { + "type": "line", + "start": [ + 80.0, + 42.0, + 8.0 + ], + "end": [ + 0.0, + 42.0, + 8.0 + ], + "point": [ + 40.0, + 42.0, + 8.0 + ] + } + ] + }, + { + "op": "construction_plane", + "name": "MidPlane", + "method": "offset", + "base": "YZ", + "offset": 40 + }, + { + "op": "sketch", + "name": "GussetSketch", + "plane": { + "ref": "construction plane MidPlane (YZ offset 40)", + "origin": [ + 40.0, + 0.0, + 0.0 + ], + "x_dir": [ + 0.0, + 0.0, + -1.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 1.0, + 0.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -8.0, + 42.0 + ] + }, + "end": { + "at": [ + -35.0, + 42.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + -35.0, + 42.0 + ] + }, + "end": { + "at": [ + -8.0, + 15.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + -8.0, + 15.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -8.0, + 42.0 + ] + }, + "id": "l3" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Gusset", + "operation": "join", + "profiles": [ + { + "sketch": "GussetSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 3, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "Bracket" + ] + }, + { + "op": "sketch", + "name": "BaseHoles", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 20, + 20 + ] + }, + "radius": 4, + "id": "c1" + }, + { + "type": "circle", + "center": { + "at": [ + 60, + 20 + ] + }, + "radius": 4, + "id": "c2" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "BaseHoles", + "operation": "cut", + "profiles": [ + { + "sketch": "BaseHoles", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + { + "sketch": "BaseHoles", + "loops": [ + { + "outer": true, + "curves": [ + "c2" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Bracket" + ] + }, + { + "op": "sketch", + "name": "WallHole", + "plane": { + "ref": "face: wall front face (y=42, normal -Y)", + "origin": [ + 0.0, + 42.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + 1.0 + ], + "normal": [ + 0.0, + -1.0, + 0.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + -1.0, + 0.0 + ], + "plane_normal_outward": [ + 0.0, + -1.0, + 0.0 + ], + "point": [ + 61.5, + 42.0, + 31.5 + ] + } + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 20.0, + 30.0 + ] + }, + "radius": 5, + "id": "c1" + }, + { + "type": "circle", + "center": { + "at": [ + 60.0, + 30.0 + ] + }, + "radius": 5, + "id": "c2" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "WallHoles", + "operation": "cut", + "profiles": [ + { + "sketch": "WallHole", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + { + "sketch": "WallHole", + "loops": [ + { + "outer": true, + "curves": [ + "c2" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 8, + "direction": "negative" + }, + "result_bodies": [ + "Bracket" + ] + }, + { + "op": "chamfer", + "name": "FrontChamfer", + "type": "equal_distance", + "distance": 3, + "tangent_chain": false, + "edge_selection": "the 2 vertical edges at the front corners of the base (y=0, z 0..8)", + "edges": [ + { + "type": "line", + "start": [ + 80.0, + 0.0, + 0.0 + ], + "end": [ + 80.0, + 0.0, + 8.0 + ], + "point": [ + 80.0, + 0.0, + 4.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 8.0 + ], + "point": [ + 0.0, + 0.0, + 4.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/23-bracket-multifeature/reference.stl b/tests/oracle/23-bracket-multifeature/reference.stl new file mode 100644 index 00000000..86036751 Binary files /dev/null and b/tests/oracle/23-bracket-multifeature/reference.stl differ diff --git a/tests/oracle/24-user-parameters/expected.json b/tests/oracle/24-user-parameters/expected.json new file mode 100644 index 00000000..e9ffa040 --- /dev/null +++ b/tests/oracle/24-user-parameters/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "width 60 x depth 40 x height 15 block from the origin with a centred d10 through hole: V = (wd - pi r^2) h, A = 2(wd - pi r^2) + 2(w+d)h + 2 pi r h", + "body_count": 1, + "volume_mm3": 34821.90275490383, + "area_mm2": 8114.15926535898, + "center_of_mass": [ + 30.0, + 20.0, + 7.5 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 60.0, + 40.0, + 15.0 + ] + }, + "faces": 7 +} diff --git a/tests/oracle/24-user-parameters/recipe.json b/tests/oracle/24-user-parameters/recipe.json new file mode 100644 index 00000000..9d926442 --- /dev/null +++ b/tests/oracle/24-user-parameters/recipe.json @@ -0,0 +1,275 @@ +{ + "id": "24-user-parameters", + "name": "Parametric block driven by user parameters", + "description": "Rectangle sized by params width x depth (corner at origin), centred hole d=hole_d, extruded height.", + "exercises": [ + "user_parameters", + "sketch_constraints", + "dimension_expressions", + "extrude", + "recompute" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [ + { + "name": "width", + "expression": "60 mm", + "unit": "mm" + }, + { + "name": "depth", + "expression": "40 mm", + "unit": "mm" + }, + { + "name": "height", + "expression": "15 mm", + "unit": "mm" + }, + { + "name": "hole_d", + "expression": "10 mm", + "unit": "mm" + } + ], + "features": [ + { + "op": "sketch", + "name": "Sketch1", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 50, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 50.0, + 0.0 + ] + }, + "end": { + "at": [ + 50, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 50.0, + 30.0 + ] + }, + "end": { + "at": [ + 0, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + }, + { + "type": "circle", + "center": { + "at": [ + 25, + 15 + ] + }, + "radius": 4, + "id": "c1" + } + ], + "constraints": [ + { + "type": "coincident", + "refs": [ + "l1.start", + "origin" + ] + }, + { + "type": "horizontal", + "refs": [ + "l1" + ] + }, + { + "type": "vertical", + "refs": [ + "l2" + ] + }, + { + "type": "horizontal", + "refs": [ + "l3" + ] + }, + { + "type": "vertical", + "refs": [ + "l4" + ] + } + ], + "dimensions": [ + { + "type": "distance", + "refs": [ + "l1.start", + "l1.end" + ], + "value": "width", + "unit": "mm", + "orientation": "horizontal" + }, + { + "type": "distance", + "refs": [ + "l2.start", + "l2.end" + ], + "value": "depth", + "unit": "mm", + "orientation": "vertical" + }, + { + "type": "diameter", + "refs": [ + "c1" + ], + "value": "hole_d", + "unit": "mm" + }, + { + "type": "distance", + "refs": [ + "origin", + "c1.center" + ], + "value": "width / 2", + "unit": "mm", + "orientation": "horizontal" + }, + { + "type": "distance", + "refs": [ + "origin", + "c1.center" + ], + "value": "depth / 2", + "unit": "mm", + "orientation": "vertical" + } + ] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Sketch1", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + }, + { + "outer": false, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": "height", + "direction": "positive" + }, + "result_bodies": [ + "Block" + ] + } + ] +} diff --git a/tests/oracle/25-sketch-triangle/expected.json b/tests/oracle/25-sketch-triangle/expected.json new file mode 100644 index 00000000..21fa6181 --- /dev/null +++ b/tests/oracle/25-sketch-triangle/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "solved triangle (0,0), (50,0), P = t(cos60, sin60) with t = 25 + sqrt(150) from |P-(50,0)| = 45 (the root nearest the drawn apex); V = (1/2 * 50 * Py) * 5, A = 2 A2 + perimeter*5, centroid = vertex mean", + "body_count": 1, + "volume_mm3": 4032.1546015511467, + "area_mm2": 2274.0990841900384, + "center_of_mass": [ + 22.87457478565265, + 10.752412270803058, + 2.5 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 50.0, + 32.257236812409175, + 5.0 + ] + }, + "faces": 5 +} diff --git a/tests/oracle/25-sketch-triangle/recipe.json b/tests/oracle/25-sketch-triangle/recipe.json new file mode 100644 index 00000000..65516990 --- /dev/null +++ b/tests/oracle/25-sketch-triangle/recipe.json @@ -0,0 +1,184 @@ +{ + "id": "25-sketch-triangle", + "name": "Sketch solver: triangle with lengths and angle", + "description": "Triangle drawn roughly; l1 horizontal from origin, |l1|=50, |l2|=45, angle(l1,l3)=60 deg. Solved sketch extruded 5 mm.", + "exercises": [ + "sketch_only", + "sketch_constraints", + "angle_dim" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Triangle", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 2, + 1 + ] + }, + "end": { + "at": [ + 45, + 3 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 45.0, + 3.0 + ] + }, + "end": { + "at": [ + 22, + 35 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 22.0, + 35.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 2.0, + 1.0 + ] + }, + "id": "l3" + } + ], + "constraints": [ + { + "type": "coincident", + "refs": [ + "l1.start", + "origin" + ] + }, + { + "type": "horizontal", + "refs": [ + "l1" + ] + } + ], + "dimensions": [ + { + "type": "distance", + "refs": [ + "l1.start", + "l1.end" + ], + "value": 50, + "unit": "mm", + "orientation": "aligned" + }, + { + "type": "distance", + "refs": [ + "l2.start", + "l2.end" + ], + "value": 45, + "unit": "mm", + "orientation": "aligned" + }, + { + "type": "angle", + "refs": [ + "l1", + "l3" + ], + "value": 60, + "unit": "deg", + "text_at": [ + 8, + 4 + ] + } + ] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Triangle", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 5, + "direction": "positive" + }, + "result_bodies": [ + "Triangle" + ] + } + ] +} diff --git a/tests/oracle/26-sketch-belt/expected.json b/tests/oracle/26-sketch-belt/expected.json new file mode 100644 index 00000000..5c6bb50f --- /dev/null +++ b/tests/oracle/26-sketch-belt/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "belt: convex hull of circles R20 @ (0,0) and r10 @ (50,0) (external tangents, sin a = (R-r)/d) minus the d8 hole at (50,0), extruded 5 as one body; hull area = (R+r) sqrt(d^2-(R-r)^2) + R^2(pi+2a)/2 + r^2(pi-2a)/2; moments by Green's theorem over the line/arc boundary", + "body_count": 1, + "volume_mm3": 11326.169514235082, + "area_mm2": 5637.404150511762, + "center_of_mass": [ + 15.321857347287981, + 0.0, + 2.5 + ], + "bbox": { + "min": [ + -20.0, + -20.0, + 0.0 + ], + "max": [ + 60.0, + 20.0, + 5.0 + ] + }, + "faces": 7 +} diff --git a/tests/oracle/26-sketch-belt/recipe.json b/tests/oracle/26-sketch-belt/recipe.json new file mode 100644 index 00000000..74083f47 --- /dev/null +++ b/tests/oracle/26-sketch-belt/recipe.json @@ -0,0 +1,296 @@ +{ + "id": "26-sketch-belt", + "name": "Sketch solver: tangent lines between circles", + "description": "Circles d40 at origin and d20 at 50 mm (horizontal), concentric d8 hole, two lines tangent to both. All profiles extruded 5 mm.", + "exercises": [ + "sketch_only", + "sketch_constraints", + "tangent", + "concentric", + "point_on_curve" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Belt", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 2, + -1 + ] + }, + "radius": 18, + "id": "c1" + }, + { + "type": "circle", + "center": { + "at": [ + 45, + 3 + ] + }, + "radius": 12, + "id": "c2" + }, + { + "type": "circle", + "center": { + "at": [ + 46, + 2 + ] + }, + "radius": 5, + "id": "c3" + }, + { + "type": "line", + "start": { + "at": [ + 3, + 19 + ] + }, + "end": { + "at": [ + 47, + 13 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "at": [ + 1, + -22 + ] + }, + "end": { + "at": [ + 44, + -8 + ] + }, + "id": "l2" + } + ], + "constraints": [ + { + "type": "coincident", + "refs": [ + "c1.center", + "origin" + ] + }, + { + "type": "horizontal_points", + "refs": [ + "c1.center", + "c2.center" + ] + }, + { + "type": "concentric", + "refs": [ + "c3", + "c2" + ] + }, + { + "type": "coincident", + "refs": [ + "l1.start", + "c1" + ] + }, + { + "type": "coincident", + "refs": [ + "l1.end", + "c2" + ] + }, + { + "type": "tangent", + "refs": [ + "l1", + "c1" + ] + }, + { + "type": "tangent", + "refs": [ + "l1", + "c2" + ] + }, + { + "type": "coincident", + "refs": [ + "l2.start", + "c1" + ] + }, + { + "type": "coincident", + "refs": [ + "l2.end", + "c2" + ] + }, + { + "type": "tangent", + "refs": [ + "l2", + "c1" + ] + }, + { + "type": "tangent", + "refs": [ + "l2", + "c2" + ] + } + ], + "dimensions": [ + { + "type": "diameter", + "refs": [ + "c1" + ], + "value": 40, + "unit": "mm" + }, + { + "type": "diameter", + "refs": [ + "c2" + ], + "value": 20, + "unit": "mm" + }, + { + "type": "diameter", + "refs": [ + "c3" + ], + "value": 8, + "unit": "mm" + }, + { + "type": "distance", + "refs": [ + "c1.center", + "c2.center" + ], + "value": 50, + "unit": "mm", + "orientation": "horizontal" + } + ] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Belt", + "loops": [ + { + "outer": false, + "curves": [ + "c3" + ] + }, + { + "outer": true, + "curves": [ + "c2", + "c2" + ] + } + ] + }, + { + "sketch": "Belt", + "loops": [ + { + "outer": true, + "curves": [ + "c1", + "c1" + ] + } + ] + }, + { + "sketch": "Belt", + "loops": [ + { + "outer": true, + "curves": [ + "c1", + "c2", + "l1", + "l2" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 5, + "direction": "positive" + }, + "result_bodies": [ + "Belt" + ] + } + ] +} diff --git a/tests/oracle/27-sketch-trapezoid/expected.json b/tests/oracle/27-sketch-trapezoid/expected.json new file mode 100644 index 00000000..f3eb4f01 --- /dev/null +++ b/tests/oracle/27-sketch-trapezoid/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "solved trapezoid bottom (-30,0)-(30,0), top (-15,25)-(15,25) minus two d6 holes at (+-18, 8) (symmetric about the axis, 36 apart, 8 above l1); trapezoid centroid y = H(a+2b)/(3(a+b)); V = A2 t, A = 2 A2 + perimeter t", + "body_count": 1, + "volume_mm3": 5342.256661176918, + "area_mm2": 3066.94581842842, + "center_of_mass": [ + 0.0, + 11.275769231975591, + 2.5 + ], + "bbox": { + "min": [ + -30.0, + 0.0, + 0.0 + ], + "max": [ + 30.0, + 25.0, + 5.0 + ] + }, + "faces": 8 +} diff --git a/tests/oracle/27-sketch-trapezoid/recipe.json b/tests/oracle/27-sketch-trapezoid/recipe.json new file mode 100644 index 00000000..742500b2 --- /dev/null +++ b/tests/oracle/27-sketch-trapezoid/recipe.json @@ -0,0 +1,318 @@ +{ + "id": "27-sketch-trapezoid", + "name": "Sketch solver: parallel/perpendicular/midpoint/symmetry", + "description": "Trapezoid: bottom horizontal 60 centred on origin (midpoint), construction axis perpendicular, top parallel 30 with its midpoint on the axis end (height 25); two d6 holes symmetric about the axis.", + "exercises": [ + "sketch_only", + "sketch_constraints", + "parallel", + "perpendicular", + "midpoint", + "symmetric", + "offset_dim", + "construction_geometry" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Trapezoid", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -27, + 2 + ] + }, + "end": { + "at": [ + 31, + -1 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 31.0, + -1.0 + ] + }, + "end": { + "at": [ + 18, + 23 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 18.0, + 23.0 + ] + }, + "end": { + "at": [ + -13, + 27 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -13.0, + 27.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -27.0, + 2.0 + ] + }, + "id": "l4" + }, + { + "type": "line", + "start": { + "at": [ + 1, + 1 + ] + }, + "end": { + "at": [ + 2, + 22 + ] + }, + "id": "l0", + "construction": true + }, + { + "type": "circle", + "center": { + "at": [ + -14, + 9 + ] + }, + "radius": 2.5, + "id": "h1" + }, + { + "type": "circle", + "center": { + "at": [ + 16, + 7 + ] + }, + "radius": 3.5, + "id": "h2" + } + ], + "constraints": [ + { + "type": "horizontal", + "refs": [ + "l1" + ] + }, + { + "type": "midpoint", + "refs": [ + "origin", + "l1" + ] + }, + { + "type": "perpendicular", + "refs": [ + "l0", + "l1" + ] + }, + { + "type": "coincident", + "refs": [ + "l0.start", + "origin" + ] + }, + { + "type": "parallel", + "refs": [ + "l3", + "l1" + ] + }, + { + "type": "midpoint", + "refs": [ + "l0.end", + "l3" + ] + }, + { + "type": "symmetric", + "refs": [ + "h1", + "h2", + "l0" + ] + } + ], + "dimensions": [ + { + "type": "distance", + "refs": [ + "l1.start", + "l1.end" + ], + "value": 60, + "unit": "mm", + "orientation": "aligned" + }, + { + "type": "distance", + "refs": [ + "l0.start", + "l0.end" + ], + "value": 25, + "unit": "mm", + "orientation": "aligned" + }, + { + "type": "distance", + "refs": [ + "l3.start", + "l3.end" + ], + "value": 30, + "unit": "mm", + "orientation": "aligned" + }, + { + "type": "diameter", + "refs": [ + "h1" + ], + "value": 6, + "unit": "mm" + }, + { + "type": "offset", + "refs": [ + "l1", + "h1.center" + ], + "value": 8, + "unit": "mm" + }, + { + "type": "distance", + "refs": [ + "h1.center", + "h2.center" + ], + "value": 36, + "unit": "mm", + "orientation": "horizontal" + } + ] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Trapezoid", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + }, + { + "outer": false, + "curves": [ + "h1" + ] + }, + { + "outer": false, + "curves": [ + "h2" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 5, + "direction": "positive" + }, + "result_bodies": [ + "Trapezoid" + ] + } + ] +} diff --git a/tests/oracle/28-blind-pocket-fillets/expected.json b/tests/oracle/28-blind-pocket-fillets/expected.json new file mode 100644 index 00000000..4d7c7fa3 --- /dev/null +++ b/tests/oracle/28-blind-pocket-fillets/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, Part makeFillet", + "body_count": 1, + "volume_mm3": 84299.24852854047, + "area_mm2": 16279.571562823978, + "center_of_mass": [ + 39.99999999999999, + 29.999999999999996, + 9.161827142086619 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 60.0, + 20.0 + ] + } +} diff --git a/tests/oracle/28-blind-pocket-fillets/recipe.json b/tests/oracle/28-blind-pocket-fillets/recipe.json new file mode 100644 index 00000000..4fb5720a --- /dev/null +++ b/tests/oracle/28-blind-pocket-fillets/recipe.json @@ -0,0 +1,577 @@ +{ + "id": "28-blind-pocket-fillets", + "name": "Blind pocket with filleted corners", + "description": "80x60x20 block; 50x30 rectangle on top face cut 8 mm deep (blind); 4 vertical pocket corner edges filleted R5, then pocket floor edges R2.", + "exercises": [ + "extrude_cut", + "blind", + "fillet", + "concave_fillet" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Block_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 80, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + 0.0 + ] + }, + "end": { + "at": [ + 80, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 80.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Block", + "operation": "new", + "profiles": [ + { + "sketch": "Block_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "Block" + ] + }, + { + "op": "sketch", + "name": "PocketSketch", + "plane": { + "ref": "face: top of Block (z=20)", + "origin": [ + 0.0, + 0.0, + 20.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 40.0, + 30.0, + 20.0 + ] + } + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 15.0, + 15.0 + ] + }, + "end": { + "at": [ + 65.0, + 15.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 65.0, + 15.0 + ] + }, + "end": { + "at": [ + 65.0, + 45.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 65.0, + 45.0 + ] + }, + "end": { + "at": [ + 15.0, + 45.0 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 15.0, + 45.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 15.0, + 15.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Pocket", + "operation": "cut", + "profiles": [ + { + "sketch": "PocketSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 8, + "direction": "negative" + }, + "result_bodies": [ + "Block" + ] + }, + { + "op": "fillet", + "name": "CornerFillet", + "type": "constant_radius", + "radius": 5, + "tangent_chain": false, + "edge_selection": "the 4 vertical pocket corner edges (z 12..20)", + "edges": [ + { + "type": "line", + "start": [ + 65.0, + 15.0, + 20.0 + ], + "end": [ + 65.0, + 15.0, + 12.0 + ], + "point": [ + 65.0, + 15.0, + 16.0 + ] + }, + { + "type": "line", + "start": [ + 15.0, + 15.0, + 20.0 + ], + "end": [ + 15.0, + 15.0, + 12.0 + ], + "point": [ + 15.0, + 15.0, + 16.0 + ] + }, + { + "type": "line", + "start": [ + 65.0, + 45.0, + 20.0 + ], + "end": [ + 65.0, + 45.0, + 12.0 + ], + "point": [ + 65.0, + 45.0, + 16.0 + ] + }, + { + "type": "line", + "start": [ + 15.0, + 45.0, + 20.0 + ], + "end": [ + 15.0, + 45.0, + 12.0 + ], + "point": [ + 15.0, + 45.0, + 16.0 + ] + } + ] + }, + { + "op": "fillet", + "name": "FloorFillet", + "type": "constant_radius", + "radius": 2, + "tangent_chain": false, + "edge_selection": "all edges of the pocket floor (z=12) - 4 lines + 4 arcs", + "edges": [ + { + "type": "arc", + "start": [ + 20.0, + 45.0, + 12.0 + ], + "end": [ + 15.0, + 40.0, + 12.0 + ], + "center": [ + 20.0, + 40.0, + 12.0 + ], + "axis": [ + 0.0, + -0.0, + 1.0 + ], + "point": [ + 16.464466094, + 43.535533906, + 12.0 + ] + }, + { + "type": "line", + "start": [ + 15.0, + 20.0, + 12.0 + ], + "end": [ + 15.0, + 40.0, + 12.0 + ], + "point": [ + 15.0, + 30.0, + 12.0 + ] + }, + { + "type": "arc", + "start": [ + 15.0, + 20.0, + 12.0 + ], + "end": [ + 20.0, + 15.0, + 12.0 + ], + "center": [ + 20.0, + 20.0, + 12.0 + ], + "axis": [ + 0.0, + -0.0, + 1.0 + ], + "point": [ + 16.464466094, + 16.464466094, + 12.0 + ] + }, + { + "type": "line", + "start": [ + 60.0, + 15.0, + 12.0 + ], + "end": [ + 20.0, + 15.0, + 12.0 + ], + "point": [ + 40.0, + 15.0, + 12.0 + ] + }, + { + "type": "arc", + "start": [ + 60.0, + 15.0, + 12.0 + ], + "end": [ + 65.0, + 20.0, + 12.0 + ], + "center": [ + 60.0, + 20.0, + 12.0 + ], + "axis": [ + -0.0, + 0.0, + 1.0 + ], + "point": [ + 63.535533906, + 16.464466094, + 12.0 + ] + }, + { + "type": "line", + "start": [ + 65.0, + 40.0, + 12.0 + ], + "end": [ + 65.0, + 20.0, + 12.0 + ], + "point": [ + 65.0, + 30.0, + 12.0 + ] + }, + { + "type": "arc", + "start": [ + 65.0, + 40.0, + 12.0 + ], + "end": [ + 60.0, + 45.0, + 12.0 + ], + "center": [ + 60.0, + 40.0, + 12.0 + ], + "axis": [ + 0.0, + -0.0, + 1.0 + ], + "point": [ + 63.535533906, + 43.535533906, + 12.0 + ] + }, + { + "type": "line", + "start": [ + 20.0, + 45.0, + 12.0 + ], + "end": [ + 60.0, + 45.0, + 12.0 + ], + "point": [ + 40.0, + 45.0, + 12.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/28-blind-pocket-fillets/reference.stl b/tests/oracle/28-blind-pocket-fillets/reference.stl new file mode 100644 index 00000000..a38e3f7d Binary files /dev/null and b/tests/oracle/28-blind-pocket-fillets/reference.stl differ diff --git a/tests/oracle/29-two-sided-and-partial-revolve/expected.json b/tests/oracle/29-two-sided-and-partial-revolve/expected.json new file mode 100644 index 00000000..845ec645 --- /dev/null +++ b/tests/oracle/29-two-sided-and-partial-revolve/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "block [-20,20]x[-25,10]x[0,20] minus the 270 deg revolve (from +X towards +Y) of the 8x8 square r 4..12, z 12..20; the removed section is the annular sector minus the circular segment y > 10 (R^2 acos(d/R) - d sqrt(R^2-d^2), y-moment 2/3 (R^2-d^2)^1.5) that lies outside the block; sector moments (R^3-r^3)/3 (sin, 1-cos); A = box - top opening + floor - front-face notch 2 sqrt(44) x 8 + inner wall r th h + outer wall (th - pi + 2 asin(5/6)) R h + 2 end caps", + "body_count": 1, + "volume_mm3": 25731.30662164881, + "area_mm2": 6312.602171854095, + "center_of_mass": [ + 0.17244881492337513, + -8.273218841749548, + 9.4709883773001 + ], + "bbox": { + "min": [ + -20.0, + -25.0, + 0.0 + ], + "max": [ + 20.0, + 10.0, + 20.0 + ] + }, + "faces": 12 +} diff --git a/tests/oracle/29-two-sided-and-partial-revolve/recipe.json b/tests/oracle/29-two-sided-and-partial-revolve/recipe.json new file mode 100644 index 00000000..9fcbeb0e --- /dev/null +++ b/tests/oracle/29-two-sided-and-partial-revolve/recipe.json @@ -0,0 +1,289 @@ +{ + "id": "29-two-sided-and-partial-revolve", + "name": "Two-sided extrude + partial revolve cut", + "description": "Rectangle 40x20 on XZ extruded two sides (+10 along normal, 25 opposite); then a 270 deg revolve cut of an 8x8 square about the world Z axis.", + "exercises": [ + "extrude_two_sides", + "revolve_partial", + "revolve_cut" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Sketch1", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -20.0, + 0.0 + ] + }, + "end": { + "at": [ + 20.0, + 0.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 20.0, + 0.0 + ] + }, + "end": { + "at": [ + 20.0, + -20.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 20.0, + -20.0 + ] + }, + "end": { + "at": [ + -20.0, + -20.0 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -20.0, + -20.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -20.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Sketch1", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "two_sides", + "side1": 10, + "side2_opposite": 25 + }, + "result_bodies": [ + "Block" + ] + }, + { + "op": "sketch", + "name": "RevolveSketch", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 4.0, + -12.0 + ] + }, + "end": { + "at": [ + 12.0, + -12.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 12.0, + -12.0 + ] + }, + "end": { + "at": [ + 12.0, + -20.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 12.0, + -20.0 + ] + }, + "end": { + "at": [ + 4.0, + -20.0 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 4.0, + -20.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 4.0, + -12.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "revolve", + "name": "RevolveCut", + "operation": "cut", + "profile": { + "sketch": "RevolveSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "angle": 270, + "result_bodies": [ + "Block" + ] + } + ] +} diff --git a/tests/oracle/30-fillet-after-shell/expected.json b/tests/oracle/30-fillet-after-shell/expected.json new file mode 100644 index 00000000..3717aa55 --- /dev/null +++ b/tests/oracle/30-fillet-after-shell/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, makeThickness shell, Part makeFillet", + "body_count": 1, + "volume_mm3": 21745.87649398271, + "area_mm2": 14968.331952825032, + "center_of_mass": [ + 30.000000000000004, + 20.000000000000007, + 11.575289961678543 + ], + "bbox": { + "min": [ + -8.881784197001252e-16, + -8.881784197001252e-16, + 0.0 + ], + "max": [ + 60.0, + 40.0, + 30.0 + ] + } +} diff --git a/tests/oracle/30-fillet-after-shell/recipe.json b/tests/oracle/30-fillet-after-shell/recipe.json new file mode 100644 index 00000000..3db40eae --- /dev/null +++ b/tests/oracle/30-fillet-after-shell/recipe.json @@ -0,0 +1,385 @@ +{ + "id": "30-fillet-after-shell", + "name": "Fillets after shell", + "description": "60x40x30 box shelled 3 mm (top open); outer vertical edges R6, inner vertical edges R3, then the inner floor loop R1 (tangent chain).", + "exercises": [ + "shell", + "fillet", + "fillet_after_shell", + "tangent_chain" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 60, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 60.0, + 0.0 + ] + }, + "end": { + "at": [ + 60, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 60.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 30, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + }, + { + "op": "shell", + "name": "Shell1", + "faces_removed": [ + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 30.0, + 20.0, + 30.0 + ] + } + ], + "face_selection": "top face (z=30)", + "inside_thickness": 3, + "outside_thickness": 0, + "tangent_chain": false + }, + { + "op": "fillet", + "name": "OuterFillet", + "type": "constant_radius", + "radius": 6, + "tangent_chain": false, + "edge_selection": "the 4 outer vertical edges (x in {0,60}, y in {0,40})", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 40.0, + 0.0 + ], + "end": [ + 0.0, + 40.0, + 30.0 + ], + "point": [ + 0.0, + 40.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 60.0, + 40.0, + 0.0 + ], + "end": [ + 60.0, + 40.0, + 30.0 + ], + "point": [ + 60.0, + 40.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 60.0, + 0.0, + 0.0 + ], + "end": [ + 60.0, + 0.0, + 30.0 + ], + "point": [ + 60.0, + 0.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 30.0 + ], + "point": [ + 0.0, + 0.0, + 15.0 + ] + } + ] + }, + { + "op": "fillet", + "name": "InnerFillet", + "type": "constant_radius", + "radius": 3, + "tangent_chain": false, + "edge_selection": "the 4 inner vertical edges (x in {3,57}, y in {3,37}, z 3..30)", + "edges": [ + { + "type": "line", + "start": [ + 57.0, + 37.0, + 3.0 + ], + "end": [ + 57.0, + 37.0, + 30.0 + ], + "point": [ + 57.0, + 37.0, + 16.5 + ] + }, + { + "type": "line", + "start": [ + 3.0, + 37.0, + 3.0 + ], + "end": [ + 3.0, + 37.0, + 30.0 + ], + "point": [ + 3.0, + 37.0, + 16.5 + ] + }, + { + "type": "line", + "start": [ + 57.0, + 3.0, + 3.0 + ], + "end": [ + 57.0, + 3.0, + 30.0 + ], + "point": [ + 57.0, + 3.0, + 16.5 + ] + }, + { + "type": "line", + "start": [ + 3.0, + 3.0, + 3.0 + ], + "end": [ + 3.0, + 3.0, + 30.0 + ], + "point": [ + 3.0, + 3.0, + 16.5 + ] + } + ] + }, + { + "op": "fillet", + "name": "FloorFillet", + "type": "constant_radius", + "radius": 1, + "tangent_chain": true, + "edge_selection": "one edge of the inner floor (z=3) + tangent chain = whole floor boundary loop", + "edges": [ + { + "type": "arc", + "start": [ + 3.0, + 6.0, + 3.0 + ], + "end": [ + 6.0, + 3.0, + 3.0 + ], + "center": [ + 6.0, + 6.0, + 3.0 + ], + "axis": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 3.878679656, + 3.878679656, + 3.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/30-fillet-after-shell/reference.stl b/tests/oracle/30-fillet-after-shell/reference.stl new file mode 100644 index 00000000..8ed767be Binary files /dev/null and b/tests/oracle/30-fillet-after-shell/reference.stl differ diff --git a/tests/oracle/31-fillet-l-all-edges/expected.json b/tests/oracle/31-fillet-l-all-edges/expected.json new file mode 100644 index 00000000..6e912683 --- /dev/null +++ b/tests/oracle/31-fillet-l-all-edges/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet", + "body_count": 1, + "volume_mm3": 14321.625800678561, + "area_mm2": 5024.044667641301, + "center_of_mass": [ + 20.90086014062925, + 10.902918183990028, + 10.000000000000005 + ], + "bbox": { + "min": [ + -4.440892098500626e-16, + -4.440892098500626e-16, + -4.440892098500626e-16 + ], + "max": [ + 60.0, + 40.0, + 20.0 + ] + } +} diff --git a/tests/oracle/31-fillet-l-all-edges/recipe.json b/tests/oracle/31-fillet-l-all-edges/recipe.json new file mode 100644 index 00000000..1a713730 --- /dev/null +++ b/tests/oracle/31-fillet-l-all-edges/recipe.json @@ -0,0 +1,524 @@ +{ + "id": "31-fillet-l-all-edges", + "name": "All-edge fillet on an L block", + "description": "L profile (60x40, 8 thick legs) extruded 20; every edge filleted R2 - mixed convex/concave corners.", + "exercises": [ + "fillet", + "concave_edge", + "mixed_corner" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "LSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 60, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 60.0, + 0.0 + ] + }, + "end": { + "at": [ + 60, + 8 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 60.0, + 8.0 + ] + }, + "end": { + "at": [ + 8, + 8 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 8.0, + 8.0 + ] + }, + "end": { + "at": [ + 8, + 40 + ] + }, + "id": "l4" + }, + { + "type": "line", + "start": { + "ref": "l4.end", + "at": [ + 8.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l5" + }, + { + "type": "line", + "start": { + "ref": "l5.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l6" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "LSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4", + "l5", + "l6" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "L" + ] + }, + { + "op": "fillet", + "name": "Fillet1", + "type": "constant_radius", + "radius": 2, + "tangent_chain": false, + "edge_selection": "all 18 edges of L", + "edges": [ + { + "type": "line", + "start": [ + 8.0, + 40.0, + 0.0 + ], + "end": [ + 0.0, + 40.0, + 0.0 + ], + "point": [ + 4.0, + 40.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 40.0, + 0.0 + ], + "end": [ + 0.0, + 40.0, + 20.0 + ], + "point": [ + 0.0, + 40.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 8.0, + 40.0, + 20.0 + ], + "end": [ + 0.0, + 40.0, + 20.0 + ], + "point": [ + 4.0, + 40.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 8.0, + 40.0, + 0.0 + ], + "end": [ + 8.0, + 40.0, + 20.0 + ], + "point": [ + 8.0, + 40.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 8.0, + 8.0, + 0.0 + ], + "end": [ + 8.0, + 40.0, + 0.0 + ], + "point": [ + 8.0, + 24.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 8.0, + 8.0, + 20.0 + ], + "end": [ + 8.0, + 40.0, + 20.0 + ], + "point": [ + 8.0, + 24.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 8.0, + 8.0, + 0.0 + ], + "end": [ + 8.0, + 8.0, + 20.0 + ], + "point": [ + 8.0, + 8.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 60.0, + 8.0, + 0.0 + ], + "end": [ + 8.0, + 8.0, + 0.0 + ], + "point": [ + 34.0, + 8.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 60.0, + 8.0, + 20.0 + ], + "end": [ + 8.0, + 8.0, + 20.0 + ], + "point": [ + 34.0, + 8.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 60.0, + 8.0, + 0.0 + ], + "end": [ + 60.0, + 8.0, + 20.0 + ], + "point": [ + 60.0, + 8.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 60.0, + 0.0, + 0.0 + ], + "end": [ + 60.0, + 8.0, + 0.0 + ], + "point": [ + 60.0, + 4.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 60.0, + 0.0, + 20.0 + ], + "end": [ + 60.0, + 8.0, + 20.0 + ], + "point": [ + 60.0, + 4.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 60.0, + 0.0, + 0.0 + ], + "end": [ + 60.0, + 0.0, + 20.0 + ], + "point": [ + 60.0, + 0.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 60.0, + 0.0, + 0.0 + ], + "point": [ + 30.0, + 0.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 20.0 + ], + "end": [ + 60.0, + 0.0, + 20.0 + ], + "point": [ + 30.0, + 0.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 20.0 + ], + "point": [ + 0.0, + 0.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 40.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 0.0 + ], + "point": [ + 0.0, + 20.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 40.0, + 20.0 + ], + "end": [ + 0.0, + 0.0, + 20.0 + ], + "point": [ + 0.0, + 20.0, + 20.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/31-fillet-l-all-edges/reference.stl b/tests/oracle/31-fillet-l-all-edges/reference.stl new file mode 100644 index 00000000..711138d2 Binary files /dev/null and b/tests/oracle/31-fillet-l-all-edges/reference.stl differ diff --git a/tests/oracle/32-boss-plate-fillets/expected.json b/tests/oracle/32-boss-plate-fillets/expected.json new file mode 100644 index 00000000..b0cbfaed --- /dev/null +++ b/tests/oracle/32-boss-plate-fillets/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "plate 60x60x8 + boss (r10, z 8..23) with R3 concave base and R2 convex top fillets taken as one solid of revolution: V, z-moment and lateral area from Pappus/Green on its (r,z) profile (quarter-circle fillet arcs); plate top loses the r13 disc", + "body_count": 1, + "volume_mm3": 33590.34963015678, + "area_mm2": 9929.863617615047, + "center_of_mass": [ + 0.0, + 0.0, + 5.602823546473236 + ], + "bbox": { + "min": [ + -30.0, + -30.0, + 0.0 + ], + "max": [ + 30.0, + 30.0, + 23.0 + ] + }, + "faces": 10 +} diff --git a/tests/oracle/32-boss-plate-fillets/recipe.json b/tests/oracle/32-boss-plate-fillets/recipe.json new file mode 100644 index 00000000..7cf4ec86 --- /dev/null +++ b/tests/oracle/32-boss-plate-fillets/recipe.json @@ -0,0 +1,316 @@ +{ + "id": "32-boss-plate-fillets", + "name": "Boss on plate: base and top fillets", + "description": "60x60x8 plate centred on origin; d20 boss 15 tall joined on top; base circle edge R3 (concave), top circle edge R2 (convex).", + "exercises": [ + "extrude_join", + "fillet", + "cylinder_plane_edge" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Plate_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -30, + -30 + ] + }, + "end": { + "at": [ + 30, + -30 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 30.0, + -30.0 + ] + }, + "end": { + "at": [ + 30, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 30.0, + 30.0 + ] + }, + "end": { + "at": [ + -30, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -30.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -30.0, + -30.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Plate", + "operation": "new", + "profiles": [ + { + "sketch": "Plate_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 8, + "direction": "positive" + }, + "result_bodies": [ + "Part" + ] + }, + { + "op": "sketch", + "name": "BossSketch", + "plane": { + "ref": "face: top of plate (z=8)", + "origin": [ + 0.0, + 0.0, + 8.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 0.0, + 0.0, + 8.0 + ] + } + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0.0, + 0.0 + ] + }, + "radius": 10, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Boss", + "operation": "join", + "profiles": [ + { + "sketch": "BossSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 15, + "direction": "positive" + }, + "result_bodies": [ + "Part" + ] + }, + { + "op": "fillet", + "name": "BaseFillet", + "type": "constant_radius", + "radius": 3, + "tangent_chain": false, + "edge_selection": "circular edge r=10 at z=8 (boss meets plate)", + "edges": [ + { + "type": "circle", + "start": [ + -10.0, + -0.0, + 8.0 + ], + "end": [ + -10.0, + -0.0, + 8.0 + ], + "center": [ + 0.0, + 0.0, + 8.0 + ], + "axis": [ + 0.0, + 0.0, + -1.0 + ], + "point": [ + 10.0, + 0.0, + 8.0 + ] + } + ] + }, + { + "op": "fillet", + "name": "TopFillet", + "type": "constant_radius", + "radius": 2, + "tangent_chain": false, + "edge_selection": "circular edge r=10 at z=23 (boss top)", + "edges": [ + { + "type": "circle", + "start": [ + -10.0, + -0.0, + 23.0 + ], + "end": [ + -10.0, + -0.0, + 23.0 + ], + "center": [ + 0.0, + 0.0, + 23.0 + ], + "axis": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 10.0, + 0.0, + 23.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/33-fillet-tangent-chain/expected.json b/tests/oracle/33-fillet-tangent-chain/expected.json new file mode 100644 index 00000000..45dff156 --- /dev/null +++ b/tests/oracle/33-fillet-tangent-chain/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "obround (R8, centres 40 apart) x 10 minus R2 fillet spandrel R^2(1-pi/4) and 1 mm chamfer triangle swept round the whole loop (Pappus: section x path of its centroid, inset R(10-3pi)/(3(4-pi)) resp. c/3); surfaces likewise (fillet arc centroid inset R-2R/pi)", + "body_count": 1, + "volume_mm3": 8237.12239193832, + "area_mm2": 2793.569488148386, + "center_of_mass": [ + 20.0, + 0.0, + 4.975827363940953 + ], + "bbox": { + "min": [ + -8.0, + -8.0, + 0.0 + ], + "max": [ + 48.0, + 8.0, + 10.0 + ] + }, + "faces": 14 +} diff --git a/tests/oracle/33-fillet-tangent-chain/recipe.json b/tests/oracle/33-fillet-tangent-chain/recipe.json new file mode 100644 index 00000000..96ea9118 --- /dev/null +++ b/tests/oracle/33-fillet-tangent-chain/recipe.json @@ -0,0 +1,212 @@ +{ + "id": "33-fillet-tangent-chain", + "name": "Fillet chain across tangent faces", + "description": "Obround (centres 0 and 40, R8) extruded 10; one top edge filleted R2 with tangent chain (whole top loop), then one bottom edge chamfered 1 with tangent chain.", + "exercises": [ + "fillet", + "tangent_chain", + "chamfer" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "SlotSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "arc_center_start_sweep", + "center": { + "at": [ + 0, + 0 + ] + }, + "start": { + "at": [ + 0, + 8 + ] + }, + "sweep_deg": 180, + "id": "a1" + }, + { + "type": "arc_center_start_sweep", + "center": { + "at": [ + 40, + 0 + ] + }, + "start": { + "at": [ + 40, + -8 + ] + }, + "sweep_deg": 180, + "id": "a2" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + -0.0, + -8.0 + ] + }, + "end": { + "ref": "a2.start", + "at": [ + 40.0, + -8.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "a2.end", + "at": [ + 40.0, + 8.0 + ] + }, + "end": { + "ref": "a1.start", + "at": [ + 0.0, + 8.0 + ] + }, + "id": "l2" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "SlotSketch", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "a2", + "l1", + "l2" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "positive" + }, + "result_bodies": [ + "Slot" + ] + }, + { + "op": "fillet", + "name": "TopFillet", + "type": "constant_radius", + "radius": 2, + "tangent_chain": true, + "edge_selection": "top straight edge at y=-8, z=10, plus tangent chain", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + -8.0, + 10.0 + ], + "end": [ + 40.0, + -8.0, + 10.0 + ], + "point": [ + 20.0, + -8.0, + 10.0 + ] + } + ] + }, + { + "op": "chamfer", + "name": "BottomChamfer", + "type": "equal_distance", + "distance": 1, + "tangent_chain": true, + "edge_selection": "bottom straight edge at y=-8, z=0, plus tangent chain", + "edges": [ + { + "type": "line", + "start": [ + -0.0, + -8.0, + 0.0 + ], + "end": [ + 40.0, + -8.0, + 0.0 + ], + "point": [ + 20.0, + -8.0, + 0.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/34-chamfer-then-fillet/expected.json b/tests/oracle/34-chamfer-then-fillet/expected.json new file mode 100644 index 00000000..7ed149fc --- /dev/null +++ b/tests/oracle/34-chamfer-then-fillet/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeChamfer, Part makeFillet", + "body_count": 1, + "volume_mm3": 22892.42132988775, + "area_mm2": 4753.688476928938, + "center_of_mass": [ + 20.000000000000004, + 15.000000000000012, + 9.95512677479753 + ], + "bbox": { + "min": [ + -1.3322676295501878e-15, + -8.881784197001252e-16, + 0.0 + ], + "max": [ + 40.0, + 30.0, + 20.0 + ] + } +} diff --git a/tests/oracle/34-chamfer-then-fillet/recipe.json b/tests/oracle/34-chamfer-then-fillet/recipe.json new file mode 100644 index 00000000..bfcd0aab --- /dev/null +++ b/tests/oracle/34-chamfer-then-fillet/recipe.json @@ -0,0 +1,390 @@ +{ + "id": "34-chamfer-then-fillet", + "name": "Chamfer then fillet", + "description": "40x30x20 box; 4 vertical edges chamfered 5; then all 8 edges of the (now octagonal) top face filleted R2.", + "exercises": [ + "chamfer", + "fillet", + "chamfer_fillet_interaction" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 40, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + 30.0 + ] + }, + "end": { + "at": [ + 0, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + }, + { + "op": "chamfer", + "name": "Chamfer1", + "type": "equal_distance", + "distance": 5, + "tangent_chain": false, + "edge_selection": "the 4 vertical edges", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 30.0, + 0.0 + ], + "end": [ + 0.0, + 30.0, + 20.0 + ], + "point": [ + 0.0, + 30.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 30.0, + 0.0 + ], + "end": [ + 40.0, + 30.0, + 20.0 + ], + "point": [ + 40.0, + 30.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 0.0, + 0.0 + ], + "end": [ + 40.0, + 0.0, + 20.0 + ], + "point": [ + 40.0, + 0.0, + 10.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 20.0 + ], + "point": [ + 0.0, + 0.0, + 10.0 + ] + } + ] + }, + { + "op": "fillet", + "name": "Fillet1", + "type": "constant_radius", + "radius": 2, + "tangent_chain": false, + "edge_selection": "all 8 edges of the top face (z=20)", + "edges": [ + { + "type": "line", + "start": [ + 5.0, + 0.0, + 20.0 + ], + "end": [ + 0.0, + 5.0, + 20.0 + ], + "point": [ + 2.5, + 2.5, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 5.0, + 0.0, + 20.0 + ], + "end": [ + 35.0, + 0.0, + 20.0 + ], + "point": [ + 20.0, + 0.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 5.0, + 20.0 + ], + "end": [ + 35.0, + 0.0, + 20.0 + ], + "point": [ + 37.5, + 2.5, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 40.0, + 5.0, + 20.0 + ], + "end": [ + 40.0, + 25.0, + 20.0 + ], + "point": [ + 40.0, + 15.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 35.0, + 30.0, + 20.0 + ], + "end": [ + 40.0, + 25.0, + 20.0 + ], + "point": [ + 37.5, + 27.5, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 35.0, + 30.0, + 20.0 + ], + "end": [ + 5.0, + 30.0, + 20.0 + ], + "point": [ + 20.0, + 30.0, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 25.0, + 20.0 + ], + "end": [ + 5.0, + 30.0, + 20.0 + ], + "point": [ + 2.5, + 27.5, + 20.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 25.0, + 20.0 + ], + "end": [ + 0.0, + 5.0, + 20.0 + ], + "point": [ + 0.0, + 15.0, + 20.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/34-chamfer-then-fillet/reference.stl b/tests/oracle/34-chamfer-then-fillet/reference.stl new file mode 100644 index 00000000..f338108e Binary files /dev/null and b/tests/oracle/34-chamfer-then-fillet/reference.stl differ diff --git a/tests/oracle/35-fillet-curved-curved/expected.json b/tests/oracle/35-fillet-curved-curved/expected.json new file mode 100644 index 00000000..39dddae7 --- /dev/null +++ b/tests/oracle/35-fillet-curved-curved/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part makeFillet", + "body_count": 1, + "volume_mm3": 63171.61517720092, + "area_mm2": 10269.29821399885, + "center_of_mass": [ + 1.2666242694772013e-06, + -8.296669862737878e-08, + 2.5627304852052224 + ], + "bbox": { + "min": [ + -40.0000001, + -15.0000001, + -15.0000001 + ], + "max": [ + 40.0000001, + 15.0000001, + 35.0000001 + ] + } +} diff --git a/tests/oracle/35-fillet-curved-curved/recipe.json b/tests/oracle/35-fillet-curved-curved/recipe.json new file mode 100644 index 00000000..d131d327 --- /dev/null +++ b/tests/oracle/35-fillet-curved-curved/recipe.json @@ -0,0 +1,193 @@ +{ + "id": "35-fillet-curved-curved", + "name": "Fillet between two cylinders", + "description": "Cylinder r15 along X (x -40..40) joined with a cylinder r10 along +Z (z 0..35); the saddle intersection edge filleted R3.", + "exercises": [ + "sketch_yz", + "symmetric", + "extrude_join", + "fillet", + "curved_curved_edge" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "MainSketch", + "plane": { + "ref": "YZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 0.0, + 0.0, + -1.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 1.0, + 0.0, + 0.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0.0, + 0.0 + ] + }, + "radius": 15, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Main", + "operation": "new", + "profiles": [ + { + "sketch": "MainSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 40, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "Tee" + ] + }, + { + "op": "sketch", + "name": "BranchSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 10, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Branch", + "operation": "join", + "profiles": [ + { + "sketch": "BranchSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 35, + "direction": "positive" + }, + "result_bodies": [ + "Tee" + ] + }, + { + "op": "fillet", + "name": "SaddleFillet", + "type": "constant_radius", + "radius": 3, + "tangent_chain": false, + "edge_selection": "edge(s) whose both adjacent faces are cylinders (the branch/main intersection curve)", + "edges": [ + { + "type": "nurbs", + "start": [ + 0.0, + -10.0, + 11.180339887 + ], + "end": [ + 0.0, + -10.0, + 11.180339887 + ], + "point": [ + 0.0, + 10.0, + 11.180339887 + ] + } + ] + } + ] +} diff --git a/tests/oracle/35-fillet-curved-curved/reference.stl b/tests/oracle/35-fillet-curved-curved/reference.stl new file mode 100644 index 00000000..132d80b9 Binary files /dev/null and b/tests/oracle/35-fillet-curved-curved/reference.stl differ diff --git a/tests/oracle/36-corner-blend-two-radii/expected.json b/tests/oracle/36-corner-blend-two-radii/expected.json new file mode 100644 index 00000000..619d2848 --- /dev/null +++ b/tests/oracle/36-corner-blend-two-radii/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet", + "body_count": 1, + "volume_mm3": 57800.23972405685, + "area_mm2": 8585.524087145672, + "center_of_mass": [ + 24.999999999999996, + 19.999999999999996, + 14.865497483183885 + ], + "bbox": { + "min": [ + -1.7763568394002505e-15, + -1.0000000177635683e-07, + 0.0 + ], + "max": [ + 50.0, + 40.0, + 30.0 + ] + } +} diff --git a/tests/oracle/36-corner-blend-two-radii/recipe.json b/tests/oracle/36-corner-blend-two-radii/recipe.json new file mode 100644 index 00000000..ad419206 --- /dev/null +++ b/tests/oracle/36-corner-blend-two-radii/recipe.json @@ -0,0 +1,274 @@ +{ + "id": "36-corner-blend-two-radii", + "name": "Corner blends with two radii", + "description": "50x40x30 box; 4 vertical edges R8, then the top-face loop R4 (tangent chain) - 3-edge corner blends with unequal radii.", + "exercises": [ + "fillet", + "corner_blend", + "tangent_chain" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 50, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 50.0, + 0.0 + ] + }, + "end": { + "at": [ + 50, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 50.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 30, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + }, + { + "op": "fillet", + "name": "VerticalFillet", + "type": "constant_radius", + "radius": 8, + "tangent_chain": false, + "edge_selection": "the 4 vertical edges", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 40.0, + 0.0 + ], + "end": [ + 0.0, + 40.0, + 30.0 + ], + "point": [ + 0.0, + 40.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 50.0, + 40.0, + 0.0 + ], + "end": [ + 50.0, + 40.0, + 30.0 + ], + "point": [ + 50.0, + 40.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 50.0, + 0.0, + 0.0 + ], + "end": [ + 50.0, + 0.0, + 30.0 + ], + "point": [ + 50.0, + 0.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 30.0 + ], + "point": [ + 0.0, + 0.0, + 15.0 + ] + } + ] + }, + { + "op": "fillet", + "name": "TopFillet", + "type": "constant_radius", + "radius": 4, + "tangent_chain": true, + "edge_selection": "one edge of the top face + tangent chain (whole top loop)", + "edges": [ + { + "type": "arc", + "start": [ + 8.0, + 0.0, + 30.0 + ], + "end": [ + 0.0, + 8.0, + 30.0 + ], + "center": [ + 8.0, + 8.0, + 30.0 + ], + "axis": [ + -0.0, + -0.0, + -1.0 + ], + "point": [ + 2.343145751, + 2.343145751, + 30.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/36-corner-blend-two-radii/reference.stl b/tests/oracle/36-corner-blend-two-radii/reference.stl new file mode 100644 index 00000000..9d2b147c Binary files /dev/null and b/tests/oracle/36-corner-blend-two-radii/reference.stl differ diff --git a/tests/oracle/37-pyramid-apex-blend/expected.json b/tests/oracle/37-pyramid-apex-blend/expected.json new file mode 100644 index 00000000..faf45e1f --- /dev/null +++ b/tests/oracle/37-pyramid-apex-blend/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft, Part makeFillet", + "body_count": 1, + "volume_mm3": 15465.577113384288, + "area_mm2": 3914.585460194058, + "center_of_mass": [ + -7.50627101581177e-12, + 1.801248348273918e-11, + 7.673359735250147 + ], + "bbox": { + "min": [ + -18.26296581635734, + -18.262965816357344, + -2.220446049250313e-16 + ], + "max": [ + 18.262965816357344, + 18.262965816357344, + 28.373982542073644 + ] + } +} diff --git a/tests/oracle/37-pyramid-apex-blend/recipe.json b/tests/oracle/37-pyramid-apex-blend/recipe.json new file mode 100644 index 00000000..a95aeb45 --- /dev/null +++ b/tests/oracle/37-pyramid-apex-blend/recipe.json @@ -0,0 +1,361 @@ +{ + "id": "37-pyramid-apex-blend", + "name": "Pyramid apex: 4-edge corner blend", + "description": "Loft from a 40x40 square (XY) to a point at (0,0,30) = square pyramid; all edges filleted R2 (apex blends 4 edges).", + "exercises": [ + "loft_to_point", + "fillet", + "corner_blend_4" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -20, + -20 + ] + }, + "end": { + "at": [ + 20, + -20 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 20.0, + -20.0 + ] + }, + "end": { + "at": [ + 20, + 20 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 20.0, + 20.0 + ] + }, + "end": { + "at": [ + -20, + 20 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -20.0, + 20.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -20.0, + -20.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "construction_plane", + "name": "Apex30", + "method": "offset", + "base": "XY", + "offset": 30 + }, + { + "op": "sketch", + "name": "Apex", + "plane": { + "ref": "construction plane Apex30 (XY offset 30)", + "origin": [ + 0.0, + 0.0, + 30.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "point", + "at": [ + 0, + 0 + ], + "fixed": false, + "id": "p1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "loft", + "name": "Loft1", + "operation": "new", + "sections": [ + { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + { + "sketch": "Apex", + "point": "p1", + "world": [ + 0, + 0, + 30 + ] + } + ], + "end_conditions": "free", + "solid": true, + "result_bodies": [ + "Pyramid" + ] + }, + { + "op": "fillet", + "name": "Fillet1", + "type": "constant_radius", + "radius": 2, + "tangent_chain": false, + "edge_selection": "all 8 edges of the pyramid", + "edges": [ + { + "type": "line", + "start": [ + -20.0, + 20.0, + 0.0 + ], + "end": [ + -20.0, + -20.0, + 0.0 + ], + "point": [ + -20.0, + 0.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 20.0, + 20.0, + 0.0 + ], + "end": [ + -20.0, + 20.0, + 0.0 + ], + "point": [ + 0.0, + 20.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + 20.0, + -20.0, + 0.0 + ], + "end": [ + 20.0, + 20.0, + 0.0 + ], + "point": [ + 20.0, + 0.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + -20.0, + -20.0, + 0.0 + ], + "end": [ + 20.0, + -20.0, + 0.0 + ], + "point": [ + 0.0, + -20.0, + 0.0 + ] + }, + { + "type": "line", + "start": [ + -20.0, + -20.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 30.0 + ], + "point": [ + -10.0, + -10.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + -20.0, + 20.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 30.0 + ], + "point": [ + -10.0, + 10.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 20.0, + -20.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 30.0 + ], + "point": [ + 10.0, + -10.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 20.0, + 20.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 30.0 + ], + "point": [ + 10.0, + 10.0, + 15.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/37-pyramid-apex-blend/reference.stl b/tests/oracle/37-pyramid-apex-blend/reference.stl new file mode 100644 index 00000000..bf2740d4 Binary files /dev/null and b/tests/oracle/37-pyramid-apex-blend/reference.stl differ diff --git a/tests/oracle/38-coincident-cylinders/expected.json b/tests/oracle/38-coincident-cylinders/expected.json new file mode 100644 index 00000000..bdddcc63 --- /dev/null +++ b/tests/oracle/38-coincident-cylinders/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "union of r10 z0..30 and r10 z20..50 = cylinder r10 h50: V = pi r^2 h, A = 2 pi r h + 2 pi r^2", + "body_count": 1, + "volume_mm3": 15707.963267948966, + "area_mm2": 3769.9111843077517, + "center_of_mass": [ + 0.0, + 0.0, + 25.0 + ], + "bbox": { + "min": [ + -10.0, + -10.0, + 0.0 + ], + "max": [ + 10.0, + 10.0, + 50.0 + ] + }, + "faces": 3 +} diff --git a/tests/oracle/38-coincident-cylinders/recipe.json b/tests/oracle/38-coincident-cylinders/recipe.json new file mode 100644 index 00000000..659e104b --- /dev/null +++ b/tests/oracle/38-coincident-cylinders/recipe.json @@ -0,0 +1,179 @@ +{ + "id": "38-coincident-cylinders", + "name": "Union of coaxial equal cylinders", + "description": "Cylinder r10 z 0..30 and cylinder r10 z 20..50 (coincident cylindrical faces), combine join.", + "exercises": [ + "combine_join", + "coincident_faces", + "offset_plane" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "S1", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 10, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "CylA", + "operation": "new", + "profiles": [ + { + "sketch": "S1", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 30, + "direction": "positive" + }, + "result_bodies": [ + "A" + ] + }, + { + "op": "construction_plane", + "name": "Z20", + "method": "offset", + "base": "XY", + "offset": 20 + }, + { + "op": "sketch", + "name": "S2", + "plane": { + "ref": "construction plane Z20", + "origin": [ + 0.0, + 0.0, + 20.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 10, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "CylB", + "operation": "new", + "profiles": [ + { + "sketch": "S2", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 30, + "direction": "positive" + }, + "result_bodies": [ + "B" + ] + }, + { + "op": "combine", + "name": "Combine1", + "operation": "join", + "target_body": "A", + "tool_bodies": [ + "B" + ], + "keep_tools": false + } + ] +} diff --git a/tests/oracle/39-tee-pipe/expected.json b/tests/oracle/39-tee-pipe/expected.json new file mode 100644 index 00000000..7ebd702c --- /dev/null +++ b/tests/oracle/39-tee-pipe/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut", + "body_count": 1, + "volume_mm3": 24097.79380441197, + "area_mm2": 16710.06166887699, + "center_of_mass": [ + 4.75919240426363e-06, + -3.866233490238524e-07, + 4.436155001585041 + ], + "bbox": { + "min": [ + -40.0000001, + -15.0000001, + -15.0000001 + ], + "max": [ + 40.0000001, + 15.0000001, + 40.0000001 + ] + } +} diff --git a/tests/oracle/39-tee-pipe/recipe.json b/tests/oracle/39-tee-pipe/recipe.json new file mode 100644 index 00000000..ed664457 --- /dev/null +++ b/tests/oracle/39-tee-pipe/recipe.json @@ -0,0 +1,302 @@ +{ + "id": "39-tee-pipe", + "name": "Tee pipe (cylinder through cylinder)", + "description": "Main pipe OD30/ID24 along X (x -40..40); branch OD20/ID14 along +Z to z=40; outer join then bores cut.", + "exercises": [ + "sketch_yz", + "symmetric", + "extrude_join", + "extrude_cut", + "cylinder_cylinder" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "MainOuter", + "plane": { + "ref": "YZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 0.0, + 0.0, + -1.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 1.0, + 0.0, + 0.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0.0, + 0.0 + ] + }, + "radius": 15, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "MainOuter", + "operation": "new", + "profiles": [ + { + "sketch": "MainOuter", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 40, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "Tee" + ] + }, + { + "op": "sketch", + "name": "BranchOuter", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 10, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "BranchOuter", + "operation": "join", + "profiles": [ + { + "sketch": "BranchOuter", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 40, + "direction": "positive" + }, + "result_bodies": [ + "Tee" + ] + }, + { + "op": "sketch", + "name": "MainBore", + "plane": { + "ref": "YZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 0.0, + 0.0, + -1.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 1.0, + 0.0, + 0.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0.0, + 0.0 + ] + }, + "radius": 12, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "MainBore", + "operation": "cut", + "profiles": [ + { + "sketch": "MainBore", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 40, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "Tee" + ] + }, + { + "op": "sketch", + "name": "BranchBore", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 7, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "BranchBore", + "operation": "cut", + "profiles": [ + { + "sketch": "BranchBore", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 40, + "direction": "positive" + }, + "result_bodies": [ + "Tee" + ] + } + ] +} diff --git a/tests/oracle/39-tee-pipe/reference.stl b/tests/oracle/39-tee-pipe/reference.stl new file mode 100644 index 00000000..d86e1765 Binary files /dev/null and b/tests/oracle/39-tee-pipe/reference.stl differ diff --git a/tests/oracle/40-box-sphere-torus/expected.json b/tests/oracle/40-box-sphere-torus/expected.json new file mode 100644 index 00000000..e4cfcaf5 --- /dev/null +++ b/tests/oracle/40-box-sphere-torus/expected.json @@ -0,0 +1,26 @@ +{ + "source": "analytic", + "method": "box 60x60x10 (z -10..0) + upper hemisphere r12 + spherical cap h2 below z=-10 (V = pi h^2(3R-h)/3, A = 2 pi R h) - lower half torus R22 r4 (Pappus: V = pi r^2/2 * 2 pi R, centroid -4r/3pi, A = pi r * 2 pi R); top loses disc r12 and annulus 18..26, bottom loses disc r sqrt(44)", + "body_count": 1, + "volume_mm3": 36287.43285471473, + "area_mm2": 10696.165473259409, + "center_of_mass": [ + 0.0, + 0.0, + -4.390960875015068 + ], + "bbox": { + "min": [ + -30.0, + -30.0, + -12.0 + ], + "max": [ + 30.0, + 30.0, + 12.0 + ] + }, + "faces": 10, + "note": "faces: bottom, 4 sides, top split by the groove into an annulus (r 12..18) and the outer part, upper sphere, lower cap, torus" +} diff --git a/tests/oracle/40-box-sphere-torus/recipe.json b/tests/oracle/40-box-sphere-torus/recipe.json new file mode 100644 index 00000000..9383c011 --- /dev/null +++ b/tests/oracle/40-box-sphere-torus/recipe.json @@ -0,0 +1,354 @@ +{ + "id": "40-box-sphere-torus", + "name": "Box + sphere - torus", + "description": "Box (-30,-30,-10)..(30,30,0); sphere r12 at origin joined (dome); torus (R22, r4, axis Z, centre origin) cut (half-round groove ring in the box top).", + "exercises": [ + "revolve", + "combine_join", + "combine_cut", + "torus", + "sphere" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Box_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -30, + -30 + ] + }, + "end": { + "at": [ + 30, + -30 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 30.0, + -30.0 + ] + }, + "end": { + "at": [ + 30, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 30.0, + 30.0 + ] + }, + "end": { + "at": [ + -30, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -30.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -30.0, + -30.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Box", + "operation": "new", + "profiles": [ + { + "sketch": "Box_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "negative" + }, + "result_bodies": [ + "A" + ] + }, + { + "op": "sketch", + "name": "Sphere_sketch", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "arc_three_point", + "start": { + "at": [ + 0.0, + 12.0 + ] + }, + "mid": [ + 12.0, + 0.0 + ], + "end": { + "at": [ + 0.0, + -12.0 + ] + }, + "id": "a1" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + 0.0, + -12.0 + ] + }, + "end": { + "ref": "a1.start", + "at": [ + 0.0, + 12.0 + ] + }, + "id": "l1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "revolve", + "name": "Sphere", + "operation": "new", + "profile": { + "sketch": "Sphere_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "l1" + ] + } + ] + }, + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "angle": 360, + "result_bodies": [ + "S" + ] + }, + { + "op": "combine", + "name": "JoinSphere", + "operation": "join", + "target_body": "A", + "tool_bodies": [ + "S" + ], + "keep_tools": false + }, + { + "op": "sketch", + "name": "TorusSketch", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 22.0, + 0.0 + ] + }, + "radius": 4, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "revolve", + "name": "Torus", + "operation": "new", + "profile": { + "sketch": "TorusSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "angle": 360, + "result_bodies": [ + "T" + ] + }, + { + "op": "combine", + "name": "CutTorus", + "operation": "cut", + "target_body": "A", + "tool_bodies": [ + "T" + ], + "keep_tools": false + } + ] +} diff --git a/tests/oracle/41-tube-radial-holes/expected.json b/tests/oracle/41-tube-radial-holes/expected.json new file mode 100644 index 00000000..605c5af0 --- /dev/null +++ b/tests/oracle/41-tube-radial-holes/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied)", + "body_count": 1, + "volume_mm3": 20243.55643647215, + "area_mm2": 14873.859284949844, + "center_of_mass": [ + -9.45514125510865e-05, + -0.00016121064416442976, + 29.9999903705421 + ], + "bbox": { + "min": [ + -20.0000001, + -20.000000101011352, + -1e-07 + ], + "max": [ + 20.0000001, + 20.000000101011356, + 60.0000001 + ] + } +} diff --git a/tests/oracle/41-tube-radial-holes/recipe.json b/tests/oracle/41-tube-radial-holes/recipe.json new file mode 100644 index 00000000..026e5d07 --- /dev/null +++ b/tests/oracle/41-tube-radial-holes/recipe.json @@ -0,0 +1,252 @@ +{ + "id": "41-tube-radial-holes", + "name": "Many small holes in a curved wall", + "description": "Tube OD40/ID34 length 60; three d4 radial holes along Y (z=15,30,45) cut symmetric through both walls; circular pattern x3 over 120 deg -> 18 holes.", + "exercises": [ + "annulus_profile", + "sketch_xz", + "symmetric", + "extrude_cut", + "circular_pattern", + "holes_in_cylinder" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "TubeSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 20, + "id": "c_out" + }, + { + "type": "circle", + "center": { + "ref": "c_out.center", + "at": [ + 0.0, + 0.0 + ] + }, + "radius": 17, + "id": "c_in" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Tube", + "operation": "new", + "profiles": [ + { + "sketch": "TubeSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c_out" + ] + }, + { + "outer": false, + "curves": [ + "c_in" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 60, + "direction": "positive" + }, + "result_bodies": [ + "Tube" + ] + }, + { + "op": "sketch", + "name": "HoleSketch", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0.0, + -15.0 + ] + }, + "radius": 2, + "id": "c1" + }, + { + "type": "circle", + "center": { + "at": [ + 0.0, + -30.0 + ] + }, + "radius": 2, + "id": "c2" + }, + { + "type": "circle", + "center": { + "at": [ + 0.0, + -45.0 + ] + }, + "radius": 2, + "id": "c3" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Holes", + "operation": "cut", + "profiles": [ + { + "sketch": "HoleSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + { + "sketch": "HoleSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c2" + ] + } + ] + }, + { + "sketch": "HoleSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c3" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 25, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "Tube" + ] + }, + { + "op": "circular_pattern", + "name": "HolePattern", + "pattern_of": "features", + "features": [ + "Holes" + ], + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "count": 3, + "total_angle": 120, + "symmetric": false + } + ] +} diff --git a/tests/oracle/41-tube-radial-holes/reference.stl b/tests/oracle/41-tube-radial-holes/reference.stl new file mode 100644 index 00000000..a1213132 Binary files /dev/null and b/tests/oracle/41-tube-radial-holes/reference.stl differ diff --git a/tests/oracle/42-overlapping-patterns/expected.json b/tests/oracle/42-overlapping-patterns/expected.json new file mode 100644 index 00000000..829339d1 --- /dev/null +++ b/tests/oracle/42-overlapping-patterns/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied), Part extrude cut, pattern (tools transformed and re-applied)", + "body_count": 1, + "volume_mm3": 38487.063633850754, + "area_mm2": 21686.603498756176, + "center_of_mass": [ + -4.7262358616155635e-15, + 2.1740684963431593e-15, + 2.499999999999997 + ], + "bbox": { + "min": [ + -50.0, + -50.0, + -1e-07 + ], + "max": [ + 50.0, + 50.0, + 5.0000001 + ] + } +} diff --git a/tests/oracle/42-overlapping-patterns/recipe.json b/tests/oracle/42-overlapping-patterns/recipe.json new file mode 100644 index 00000000..411fd50f --- /dev/null +++ b/tests/oracle/42-overlapping-patterns/recipe.json @@ -0,0 +1,339 @@ +{ + "id": "42-overlapping-patterns", + "name": "Overlapping hole patterns", + "description": "100x100x5 plate centred; d10 hole at (-30,-30) rect-patterned 5x5 @15; d10 hole at (25,0) circular-patterned x8 about Z - several holes overlap and merge.", + "exercises": [ + "rectangular_pattern", + "circular_pattern", + "overlapping_cuts" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Plate_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -50, + -50 + ] + }, + "end": { + "at": [ + 50, + -50 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 50.0, + -50.0 + ] + }, + "end": { + "at": [ + 50, + 50 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 50.0, + 50.0 + ] + }, + "end": { + "at": [ + -50, + 50 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -50.0, + 50.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -50.0, + -50.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Plate", + "operation": "new", + "profiles": [ + { + "sketch": "Plate_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 5, + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "sketch", + "name": "HoleA", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + -30, + -30 + ] + }, + "radius": 5, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "HoleA", + "operation": "cut", + "profiles": [ + { + "sketch": "HoleA", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "rectangular_pattern", + "name": "GridPattern", + "pattern_of": "features", + "features": [ + "HoleA (1)" + ], + "direction1": { + "axis": [ + 1, + 0, + 0 + ], + "count": 5, + "spacing": 15 + }, + "distance_type": "spacing (distance between instances)", + "direction2": { + "axis": [ + 0, + 1, + 0 + ], + "count": 5, + "spacing": 15 + } + }, + { + "op": "sketch", + "name": "HoleB", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 25, + 0 + ] + }, + "radius": 5, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "HoleB", + "operation": "cut", + "profiles": [ + { + "sketch": "HoleB", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "circular_pattern", + "name": "RingPattern", + "pattern_of": "features", + "features": [ + "HoleB (1)" + ], + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "count": 8, + "total_angle": 360, + "symmetric": false + } + ] +} diff --git a/tests/oracle/42-overlapping-patterns/reference.stl b/tests/oracle/42-overlapping-patterns/reference.stl new file mode 100644 index 00000000..12002cb1 Binary files /dev/null and b/tests/oracle/42-overlapping-patterns/reference.stl differ diff --git a/tests/oracle/43-cut-splits-body/expected.json b/tests/oracle/43-cut-splits-body/expected.json new file mode 100644 index 00000000..5b108371 --- /dev/null +++ b/tests/oracle/43-cut-splits-body/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "two boxes 35x20x20 (x 0..35 and 45..80): V = 2abc, A = 2*2(ab+bc+ca)", + "body_count": 2, + "volume_mm3": 28000, + "area_mm2": 7200, + "center_of_mass": [ + 40.0, + 10.0, + 10.0 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 20.0, + 20.0 + ] + }, + "faces": 12 +} diff --git a/tests/oracle/43-cut-splits-body/recipe.json b/tests/oracle/43-cut-splits-body/recipe.json new file mode 100644 index 00000000..97c01258 --- /dev/null +++ b/tests/oracle/43-cut-splits-body/recipe.json @@ -0,0 +1,282 @@ +{ + "id": "43-cut-splits-body", + "name": "Cut that splits a body in two", + "description": "80x20x20 box; rectangle x 35..45 (wider than the box in Y) cut through all -> two separate bodies.", + "exercises": [ + "extrude_cut", + "through_all", + "multi_body_result" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 80, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + 0.0 + ] + }, + "end": { + "at": [ + 80, + 20 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 80.0, + 20.0 + ] + }, + "end": { + "at": [ + 0, + 20 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 20.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "Bar" + ] + }, + { + "op": "sketch", + "name": "CutSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 35, + -5 + ] + }, + "end": { + "at": [ + 45, + -5 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 45.0, + -5.0 + ] + }, + "end": { + "at": [ + 45, + 25 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 45.0, + 25.0 + ] + }, + "end": { + "at": [ + 35, + 25 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 35.0, + 25.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 35.0, + -5.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Cut1", + "operation": "cut", + "profiles": [ + { + "sketch": "CutSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Bar", + "Bar (1)" + ] + } + ] +} diff --git a/tests/oracle/44-shell-l-bracket/expected.json b/tests/oracle/44-shell-l-bracket/expected.json new file mode 100644 index 00000000..f09ec972 --- /dev/null +++ b/tests/oracle/44-shell-l-bracket/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "L prism (area 736, perimeter 200) x 20 minus cavity = L shrunk by 2 (SolidCraft's intersection-join shell convention: inner faces offset by 2 and extended to meet, sharp inner corner at (6,6); area 352) from z=2 to 20. A rounded (arc) join would give V 8368.5487, A 8768.5487, 16 faces", + "body_count": 1, + "volume_mm3": 8384.000000000004, + "area_mm2": 8784.000000000004, + "center_of_mass": [ + 21.26717557251906, + 11.267175572519093, + 9.244274809160306 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 60.0, + 40.0, + 20.0 + ] + }, + "faces": 15 +} diff --git a/tests/oracle/44-shell-l-bracket/recipe.json b/tests/oracle/44-shell-l-bracket/recipe.json new file mode 100644 index 00000000..ab32a6a2 --- /dev/null +++ b/tests/oracle/44-shell-l-bracket/recipe.json @@ -0,0 +1,217 @@ +{ + "id": "44-shell-l-bracket", + "name": "Shelled L block", + "description": "L profile extruded 20, shelled 2 mm inward with the top face (z=20) removed - non-convex shell.", + "exercises": [ + "shell", + "non_convex" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "LSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 60, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 60.0, + 0.0 + ] + }, + "end": { + "at": [ + 60, + 8 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 60.0, + 8.0 + ] + }, + "end": { + "at": [ + 8, + 8 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 8.0, + 8.0 + ] + }, + "end": { + "at": [ + 8, + 40 + ] + }, + "id": "l4" + }, + { + "type": "line", + "start": { + "ref": "l4.end", + "at": [ + 8.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l5" + }, + { + "type": "line", + "start": { + "ref": "l5.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l6" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "LSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4", + "l5", + "l6" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "L" + ] + }, + { + "op": "shell", + "name": "Shell1", + "faces_removed": [ + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 30.0, + 4.0, + 20.0 + ] + } + ], + "face_selection": "top face (z=20)", + "inside_thickness": 2, + "outside_thickness": 0, + "tangent_chain": false + } + ] +} diff --git a/tests/oracle/45-shell-boss-plate/expected.json b/tests/oracle/45-shell-boss-plate/expected.json new file mode 100644 index 00000000..37c5dbee --- /dev/null +++ b/tests/oracle/45-shell-boss-plate/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "plate 60x60x10 + boss r12 h20 minus cavity (box 56x56x8 + cylinder r10 z8..28) under SolidCraft's intersection-join shell convention (offset faces extended to meet: sharp circular inner edge at r10, z8). A rounded (arc) join would give V 13620.2567, A 14104.4455, 16 faces (torus round r2)", + "body_count": 1, + "volume_mm3": 13676.601535159018, + "area_mm2": 14156.601535159018, + "center_of_mass": [ + 0.0, + 0.0, + 10.785311024697076 + ], + "bbox": { + "min": [ + -30.0, + -30.0, + 0.0 + ], + "max": [ + 30.0, + 30.0, + 30.0 + ] + }, + "faces": 15 +} diff --git a/tests/oracle/45-shell-boss-plate/recipe.json b/tests/oracle/45-shell-boss-plate/recipe.json new file mode 100644 index 00000000..86509f25 --- /dev/null +++ b/tests/oracle/45-shell-boss-plate/recipe.json @@ -0,0 +1,268 @@ +{ + "id": "45-shell-boss-plate", + "name": "Shelled boss-on-plate", + "description": "60x60x10 plate + d24 boss 20 tall, shelled 2 mm with the bottom face removed (hollow from below).", + "exercises": [ + "extrude_join", + "shell", + "non_convex" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Plate_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -30, + -30 + ] + }, + "end": { + "at": [ + 30, + -30 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 30.0, + -30.0 + ] + }, + "end": { + "at": [ + 30, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 30.0, + 30.0 + ] + }, + "end": { + "at": [ + -30, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -30.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -30.0, + -30.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Plate", + "operation": "new", + "profiles": [ + { + "sketch": "Plate_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "positive" + }, + "result_bodies": [ + "Part" + ] + }, + { + "op": "sketch", + "name": "BossSketch", + "plane": { + "ref": "face: top of plate (z=10)", + "origin": [ + 0.0, + 0.0, + 10.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 0.0, + 0.0, + 10.0 + ] + } + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0.0, + 0.0 + ] + }, + "radius": 12, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Boss", + "operation": "join", + "profiles": [ + { + "sketch": "BossSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "Part" + ] + }, + { + "op": "shell", + "name": "Shell1", + "faces_removed": [ + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + -1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + -1.0 + ], + "point": [ + 0.0, + 0.0, + 0.0 + ] + } + ], + "face_selection": "bottom face (z=0)", + "inside_thickness": 2, + "outside_thickness": 0, + "tangent_chain": false + } + ] +} diff --git a/tests/oracle/46-shell-two-open-faces/expected.json b/tests/oracle/46-shell-two-open-faces/expected.json new file mode 100644 index 00000000..e34e75b0 --- /dev/null +++ b/tests/oracle/46-shell-two-open-faces/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "box 50x40x30 minus cavity x3..47, y0..37, z3..30 (3 mm walls, top and front open)", + "body_count": 1, + "volume_mm3": 16044, + "area_mm2": 11398, + "center_of_mass": [ + 25.0, + 24.1095736724009, + 10.890426327599103 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 50.0, + 40.0, + 30.0 + ] + }, + "faces": 10 +} diff --git a/tests/oracle/46-shell-two-open-faces/recipe.json b/tests/oracle/46-shell-two-open-faces/recipe.json new file mode 100644 index 00000000..f10dbda4 --- /dev/null +++ b/tests/oracle/46-shell-two-open-faces/recipe.json @@ -0,0 +1,199 @@ +{ + "id": "46-shell-two-open-faces", + "name": "Shell with two open faces", + "description": "50x40x30 box shelled 3 mm with top (z=30) and front (y=0) faces removed.", + "exercises": [ + "shell", + "multi_face_shell" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 50, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 50.0, + 0.0 + ] + }, + "end": { + "at": [ + 50, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 50.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 30, + "direction": "positive" + }, + "result_bodies": [ + "Box" + ] + }, + { + "op": "shell", + "name": "Shell1", + "faces_removed": [ + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 25.0, + 20.0, + 30.0 + ] + }, + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + -1.0, + 0.0 + ], + "plane_normal_outward": [ + 0.0, + -1.0, + 0.0 + ], + "point": [ + 25.0, + 0.0, + 15.0 + ] + } + ], + "face_selection": "top face (z=30) and front face (y=0)", + "inside_thickness": 3, + "outside_thickness": 0, + "tangent_chain": false + } + ] +} diff --git a/tests/oracle/47-draft-curved-pocket/expected.json b/tests/oracle/47-draft-curved-pocket/expected.json new file mode 100644 index 00000000..17698d26 --- /dev/null +++ b/tests/oracle/47-draft-curved-pocket/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, PartDesign Draft", + "body_count": 1, + "volume_mm3": 111387.71155072484, + "area_mm2": 17700.360856055668, + "center_of_mass": [ + 40.00000000000002, + 30.000000000000004, + 11.912254219746519 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 60.0, + 25.0 + ] + } +} diff --git a/tests/oracle/47-draft-curved-pocket/recipe.json b/tests/oracle/47-draft-curved-pocket/recipe.json new file mode 100644 index 00000000..b3dc02dc --- /dev/null +++ b/tests/oracle/47-draft-curved-pocket/recipe.json @@ -0,0 +1,413 @@ +{ + "id": "47-draft-curved-pocket", + "name": "Draft on an obround pocket", + "description": "80x60x25 block; obround pocket (centres (25,30),(55,30), R10) cut 10 deep from the top; 5 deg draft on the 4 pocket walls (2 planes + 2 half-cylinders), neutral plane = top face.", + "exercises": [ + "extrude_cut", + "draft", + "draft_curved" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Block_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 80, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + 0.0 + ] + }, + "end": { + "at": [ + 80, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 80.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Block", + "operation": "new", + "profiles": [ + { + "sketch": "Block_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 25, + "direction": "positive" + }, + "result_bodies": [ + "Block" + ] + }, + { + "op": "sketch", + "name": "PocketSketch", + "plane": { + "ref": "face: top of block (z=25)", + "origin": [ + 0.0, + 0.0, + 25.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 40.0, + 30.0, + 25.0 + ] + } + }, + "entities": [ + { + "type": "arc_center_start_sweep", + "center": { + "at": [ + 25.0, + 30.0 + ] + }, + "start": { + "at": [ + 25.0, + 40.0 + ] + }, + "sweep_deg": 180, + "id": "a1" + }, + { + "type": "arc_center_start_sweep", + "center": { + "at": [ + 55.0, + 30.0 + ] + }, + "start": { + "at": [ + 55.0, + 20.0 + ] + }, + "sweep_deg": 180, + "id": "a2" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + 25.0, + 20.0 + ] + }, + "end": { + "ref": "a2.start", + "at": [ + 55.0, + 20.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "a2.end", + "at": [ + 55.0, + 40.0 + ] + }, + "end": { + "ref": "a1.start", + "at": [ + 25.0, + 40.0 + ] + }, + "id": "l2" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Pocket", + "operation": "cut", + "profiles": [ + { + "sketch": "PocketSketch", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "a2", + "l1", + "l2" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "negative" + }, + "result_bodies": [ + "Block" + ] + }, + { + "op": "draft", + "name": "Draft1", + "faces": [ + { + "type": "cylinder", + "normal_at_point_on_face": [ + 1.0, + -0.0, + 0.0 + ], + "axis_origin": [ + 25.0, + 30.0, + 25.0 + ], + "axis": [ + 0.0, + 0.0, + -1.0 + ], + "point": [ + 15.0, + 30.0, + 20.0 + ] + }, + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 1.0, + 0.0 + ], + "plane_normal_outward": [ + 0.0, + 1.0, + 0.0 + ], + "point": [ + 40.0, + 20.0, + 20.0 + ] + }, + { + "type": "cylinder", + "normal_at_point_on_face": [ + -1.0, + 0.0, + 0.0 + ], + "axis_origin": [ + 55.0, + 30.0, + 25.0 + ], + "axis": [ + 0.0, + 0.0, + -1.0 + ], + "point": [ + 65.0, + 30.0, + 20.0 + ] + }, + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + -1.0, + 0.0 + ], + "plane_normal_outward": [ + 0.0, + -1.0, + 0.0 + ], + "point": [ + 40.0, + 40.0, + 20.0 + ] + } + ], + "face_selection": "the 4 pocket wall faces", + "neutral_plane": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 40.0, + 50.0, + 25.0 + ] + }, + "neutral_selection": "top face (z=25)", + "angle": 5, + "flipped": false, + "tangent_chain": false + } + ] +} diff --git a/tests/oracle/47-draft-curved-pocket/reference.stl b/tests/oracle/47-draft-curved-pocket/reference.stl new file mode 100644 index 00000000..e33f8d8a Binary files /dev/null and b/tests/oracle/47-draft-curved-pocket/reference.stl differ diff --git a/tests/oracle/48-pulley/expected.json b/tests/oracle/48-pulley/expected.json new file mode 100644 index 00000000..d3f2ffd5 --- /dev/null +++ b/tests/oracle/48-pulley/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "revolve of the 12-segment (r,z) polygon about Z: V = 2 pi int r dA, Mz = 2 pi int r z dA, A = 2 pi int r ds (Pappus)", + "body_count": 1, + "volume_mm3": 168510.84114835176, + "area_mm2": 23718.644332702832, + "center_of_mass": [ + 0.0, + 0.0, + 19.999999999999996 + ], + "bbox": { + "min": [ + -40.0, + -40.0, + 0.0 + ], + "max": [ + 40.0, + 40.0, + 40.0 + ] + }, + "faces": 12 +} diff --git a/tests/oracle/48-pulley/recipe.json b/tests/oracle/48-pulley/recipe.json new file mode 100644 index 00000000..6c5241e2 --- /dev/null +++ b/tests/oracle/48-pulley/recipe.json @@ -0,0 +1,304 @@ +{ + "id": "48-pulley", + "name": "Grooved pulley", + "description": "Revolved profile with a d10 bore and two trapezoidal V-grooves, about Z.", + "exercises": [ + "revolve", + "sketch_xz" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Profile", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 5.0, + 0.0 + ] + }, + "end": { + "at": [ + 40.0, + 0.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40.0, + -3.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + -3.0 + ] + }, + "end": { + "at": [ + 33.0, + -8.0 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 33.0, + -8.0 + ] + }, + "end": { + "at": [ + 33.0, + -12.0 + ] + }, + "id": "l4" + }, + { + "type": "line", + "start": { + "ref": "l4.end", + "at": [ + 33.0, + -12.0 + ] + }, + "end": { + "at": [ + 40.0, + -17.0 + ] + }, + "id": "l5" + }, + { + "type": "line", + "start": { + "ref": "l5.end", + "at": [ + 40.0, + -17.0 + ] + }, + "end": { + "at": [ + 40.0, + -23.0 + ] + }, + "id": "l6" + }, + { + "type": "line", + "start": { + "ref": "l6.end", + "at": [ + 40.0, + -23.0 + ] + }, + "end": { + "at": [ + 33.0, + -28.0 + ] + }, + "id": "l7" + }, + { + "type": "line", + "start": { + "ref": "l7.end", + "at": [ + 33.0, + -28.0 + ] + }, + "end": { + "at": [ + 33.0, + -32.0 + ] + }, + "id": "l8" + }, + { + "type": "line", + "start": { + "ref": "l8.end", + "at": [ + 33.0, + -32.0 + ] + }, + "end": { + "at": [ + 40.0, + -37.0 + ] + }, + "id": "l9" + }, + { + "type": "line", + "start": { + "ref": "l9.end", + "at": [ + 40.0, + -37.0 + ] + }, + "end": { + "at": [ + 40.0, + -40.0 + ] + }, + "id": "l10" + }, + { + "type": "line", + "start": { + "ref": "l10.end", + "at": [ + 40.0, + -40.0 + ] + }, + "end": { + "at": [ + 5.0, + -40.0 + ] + }, + "id": "l11" + }, + { + "type": "line", + "start": { + "ref": "l11.end", + "at": [ + 5.0, + -40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 5.0, + 0.0 + ] + }, + "id": "l12" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "revolve", + "name": "Revolve1", + "operation": "new", + "profile": { + "sketch": "Profile", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l10", + "l11", + "l12", + "l2", + "l3", + "l4", + "l5", + "l6", + "l7", + "l8", + "l9" + ] + } + ] + }, + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "angle": 360, + "result_bodies": [ + "Pulley" + ] + } + ] +} diff --git a/tests/oracle/49-helical-sweep/expected.json b/tests/oracle/49-helical-sweep/expected.json new file mode 100644 index 00000000..f7f54c8b --- /dev/null +++ b/tests/oracle/49-helical-sweep/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "true helix coil (screw motion of the axial d4 section, R20, pitch 8, 3 turns): V = turns * 2 pi R * pi a^2; lateral A = turns * 2 pi * int sqrt(r^2 + (p/2pi)^2 (dr/ds)^2) ds (periodic trapezoid, exact to round-off) + two end discs; COM z = pitch*turns/2", + "body_count": 1, + "volume_mm3": 4737.410112522892, + "area_mm2": 4767.351236008563, + "center_of_mass": [ + 0.0, + 0.0, + 12.0 + ], + "bbox": { + "min": [ + -22.0, + -22.0, + -2.0 + ], + "max": [ + 22.0, + 22.0, + 26.0 + ] + }, + "faces": 3 +} diff --git a/tests/oracle/49-helical-sweep/recipe.json b/tests/oracle/49-helical-sweep/recipe.json new file mode 100644 index 00000000..132cb867 --- /dev/null +++ b/tests/oracle/49-helical-sweep/recipe.json @@ -0,0 +1,317 @@ +{ + "id": "49-helical-sweep", + "name": "Helical sweep (spline helix)", + "description": "d4 circle swept along a 3-turn helix (R20, pitch 8) given as a fitted 3D spline through 12 points per turn; replayed as the true helix.", + "exercises": [ + "sweep", + "spline_3d", + "helix" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Helix", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "fitted_spline", + "fit_points": [ + [ + 20.0, + 0.0, + 0.0 + ], + [ + 17.320508076, + 10.0, + 0.666666667 + ], + [ + 10.0, + 17.320508076, + 1.333333333 + ], + [ + 0.0, + 20.0, + 2.0 + ], + [ + -10.0, + 17.320508076, + 2.666666667 + ], + [ + -17.320508076, + 10.0, + 3.333333333 + ], + [ + -20.0, + 0.0, + 4.0 + ], + [ + -17.320508076, + -10.0, + 4.666666667 + ], + [ + -10.0, + -17.320508076, + 5.333333333 + ], + [ + -0.0, + -20.0, + 6.0 + ], + [ + 10.0, + -17.320508076, + 6.666666667 + ], + [ + 17.320508076, + -10.0, + 7.333333333 + ], + [ + 20.0, + -0.0, + 8.0 + ], + [ + 17.320508076, + 10.0, + 8.666666667 + ], + [ + 10.0, + 17.320508076, + 9.333333333 + ], + [ + 0.0, + 20.0, + 10.0 + ], + [ + -10.0, + 17.320508076, + 10.666666667 + ], + [ + -17.320508076, + 10.0, + 11.333333333 + ], + [ + -20.0, + 0.0, + 12.0 + ], + [ + -17.320508076, + -10.0, + 12.666666667 + ], + [ + -10.0, + -17.320508076, + 13.333333333 + ], + [ + -0.0, + -20.0, + 14.0 + ], + [ + 10.0, + -17.320508076, + 14.666666667 + ], + [ + 17.320508076, + -10.0, + 15.333333333 + ], + [ + 20.0, + -0.0, + 16.0 + ], + [ + 17.320508076, + 10.0, + 16.666666667 + ], + [ + 10.0, + 17.320508076, + 17.333333333 + ], + [ + 0.0, + 20.0, + 18.0 + ], + [ + -10.0, + 17.320508076, + 18.666666667 + ], + [ + -17.320508076, + 10.0, + 19.333333333 + ], + [ + -20.0, + 0.0, + 20.0 + ], + [ + -17.320508076, + -10.0, + 20.666666667 + ], + [ + -10.0, + -17.320508076, + 21.333333333 + ], + [ + -0.0, + -20.0, + 22.0 + ], + [ + 10.0, + -17.320508076, + 22.666666667 + ], + [ + 17.320508076, + -10.0, + 23.333333333 + ], + [ + 20.0, + -0.0, + 24.0 + ] + ], + "id": "h1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sketch", + "name": "ProfileSketch", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 20.0, + 0.0 + ] + }, + "radius": 2, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sweep", + "name": "Sweep1", + "operation": "new", + "profile": { + "sketch": "ProfileSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + "path": { + "sketch": "Helix", + "curves": [ + "h1" + ], + "chained": false + }, + "orientation": "perpendicular", + "result_bodies": [ + "Spring" + ] + } + ] +} diff --git a/tests/oracle/50-sweep-tight-bend/expected.json b/tests/oracle/50-sweep-tight-bend/expected.json new file mode 100644 index 00000000..49896543 --- /dev/null +++ b/tests/oracle/50-sweep-tight-bend/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "tube r5 along line 20 + R5 quarter bend + line 20: V = pi r^2 L, A = 2 pi r L + 2 pi r^2 (Pappus, valid while bend radius >= r; here equal so the inner side pinches to a line); bend centroid at (R^2 + r^2/4)/R * sinc(pi/4) along the bisector", + "body_count": 1, + "volume_mm3": 3758.4429286578784, + "area_mm2": 1660.4568041426407, + "center_of_mass": [ + 6.436662063480004, + 0.0, + 18.563337936519996 + ], + "bbox": { + "min": [ + -5.0, + -5.0, + 0.0 + ], + "max": [ + 25.0, + 5.0, + 30.0 + ] + }, + "faces": 5 +} diff --git a/tests/oracle/50-sweep-tight-bend/recipe.json b/tests/oracle/50-sweep-tight-bend/recipe.json new file mode 100644 index 00000000..deae0ec9 --- /dev/null +++ b/tests/oracle/50-sweep-tight-bend/recipe.json @@ -0,0 +1,181 @@ +{ + "id": "50-sweep-tight-bend", + "name": "Sweep with a bend radius equal to the profile radius", + "description": "d10 circle on XY swept along: line up 20, R5 arc 90 deg, line 20 along +X - the inner side of the bend collapses to a line (self-touching).", + "exercises": [ + "sweep", + "degenerate" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Path", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0.0, + 0.0 + ] + }, + "end": { + "at": [ + 0.0, + -20.0 + ] + }, + "id": "l1" + }, + { + "type": "arc_three_point", + "start": { + "ref": "l1.end", + "at": [ + 0.0, + -20.0 + ] + }, + "mid": [ + 1.464466094, + -23.535533906 + ], + "end": { + "at": [ + 5.0, + -25.0 + ] + }, + "id": "a1" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + 5.0, + -25.0 + ] + }, + "end": { + "at": [ + 25.0, + -25.0 + ] + }, + "id": "l2" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sketch", + "name": "ProfileSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 5, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sweep", + "name": "Sweep1", + "operation": "new", + "profile": { + "sketch": "ProfileSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + "path": { + "sketch": "Path", + "curves": [ + "l1", + "a1", + "l2" + ], + "chained": true + }, + "orientation": "perpendicular", + "result_bodies": [ + "Bend" + ] + } + ] +} diff --git a/tests/oracle/51-loft-rect-circle-offset/expected.json b/tests/oracle/51-loft-rect-circle-offset/expected.json new file mode 100644 index 00000000..b5360ba0 --- /dev/null +++ b/tests/oracle/51-loft-rect-circle-offset/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft", + "body_count": 1, + "volume_mm3": 61133.11623557925, + "area_mm2": 9411.65454371453, + "center_of_mass": [ + 4.254944428543755, + 2.1274722141148166, + 21.274722141148143 + ], + "bbox": { + "min": [ + -30.0000001, + -15.0000001, + -1e-07 + ], + "max": [ + 30.0000001, + 20.0000001, + 50.00000010000001 + ] + } +} diff --git a/tests/oracle/51-loft-rect-circle-offset/recipe.json b/tests/oracle/51-loft-rect-circle-offset/recipe.json new file mode 100644 index 00000000..ee46b802 --- /dev/null +++ b/tests/oracle/51-loft-rect-circle-offset/recipe.json @@ -0,0 +1,211 @@ +{ + "id": "51-loft-rect-circle-offset", + "name": "Skewed loft rectangle to circle", + "description": "Loft from a 60x30 rectangle (XY, centred) to a d30 circle centred (10,5) on a plane 50 mm up.", + "exercises": [ + "loft", + "offset_plane", + "skewed" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Bottom", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + -30, + -15 + ] + }, + "end": { + "at": [ + 30, + -15 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 30.0, + -15.0 + ] + }, + "end": { + "at": [ + 30, + 15 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 30.0, + 15.0 + ] + }, + "end": { + "at": [ + -30, + 15 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + -30.0, + 15.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + -30.0, + -15.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "construction_plane", + "name": "Plane50", + "method": "offset", + "base": "XY", + "offset": 50 + }, + { + "op": "sketch", + "name": "Top", + "plane": { + "ref": "construction plane Plane50", + "origin": [ + 0.0, + 0.0, + 50.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 10, + 5 + ] + }, + "radius": 15, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "loft", + "name": "Loft1", + "operation": "new", + "sections": [ + { + "sketch": "Bottom", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + { + "sketch": "Top", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "end_conditions": "free", + "solid": true, + "result_bodies": [ + "Loft" + ] + } + ] +} diff --git a/tests/oracle/51-loft-rect-circle-offset/reference.stl b/tests/oracle/51-loft-rect-circle-offset/reference.stl new file mode 100644 index 00000000..09dbb6dc Binary files /dev/null and b/tests/oracle/51-loft-rect-circle-offset/reference.stl differ diff --git a/tests/oracle/52-circular-pattern-fillet/expected.json b/tests/oracle/52-circular-pattern-fillet/expected.json new file mode 100644 index 00000000..d994f0ff --- /dev/null +++ b/tests/oracle/52-circular-pattern-fillet/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "disc r40 h8 + 6 x (boss r6 h10 with R2 concave base fillet as a solid of revolution, Pappus/Green on its (r,z) profile) at radius 28; disc top loses 6 discs r8", + "body_count": 1, + "volume_mm3": 47206.85017171979, + "area_mm2": 14142.17116758171, + "center_of_mass": [ + 3.8532318867475695e-17, + 5.394524641446597e-16, + 5.313317447963345 + ], + "bbox": { + "min": [ + -40.0, + -40.0, + 0.0 + ], + "max": [ + 40.0, + 40.0, + 18.0 + ] + }, + "faces": 21 +} diff --git a/tests/oracle/52-circular-pattern-fillet/recipe.json b/tests/oracle/52-circular-pattern-fillet/recipe.json new file mode 100644 index 00000000..ae62a9e5 --- /dev/null +++ b/tests/oracle/52-circular-pattern-fillet/recipe.json @@ -0,0 +1,244 @@ +{ + "id": "52-circular-pattern-fillet", + "name": "Circular pattern of boss + fillet", + "description": "Disc r40 h8; d12 boss 10 tall at (28,0) joined; boss base edge filleted R2; boss and fillet features circular-patterned x6 about Z.", + "exercises": [ + "extrude_join", + "fillet", + "circular_pattern", + "pattern_of_fillet" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Disc", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 40, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Disc", + "operation": "new", + "profiles": [ + { + "sketch": "Disc", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 8, + "direction": "positive" + }, + "result_bodies": [ + "Hub" + ] + }, + { + "op": "sketch", + "name": "BossSketch", + "plane": { + "ref": "face: top of disc (z=8)", + "origin": [ + 0.0, + 0.0, + 8.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 0.0, + 0.0, + 8.0 + ] + } + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 28.0, + 0.0 + ] + }, + "radius": 6, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Boss", + "operation": "join", + "profiles": [ + { + "sketch": "BossSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "positive" + }, + "result_bodies": [ + "Hub" + ] + }, + { + "op": "fillet", + "name": "BossFillet", + "type": "constant_radius", + "radius": 2, + "tangent_chain": false, + "edge_selection": "boss base circle r=6 at z=8", + "edges": [ + { + "type": "circle", + "start": [ + 22.0, + -0.0, + 8.0 + ], + "end": [ + 22.0, + -0.0, + 8.0 + ], + "center": [ + 28.0, + 0.0, + 8.0 + ], + "axis": [ + 0.0, + 0.0, + -1.0 + ], + "point": [ + 34.0, + 0.0, + 8.0 + ] + } + ] + }, + { + "op": "circular_pattern", + "name": "BossPattern", + "pattern_of": "features", + "features": [ + "Boss", + "BossFillet" + ], + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "count": 6, + "total_angle": 360, + "symmetric": false + } + ] +} diff --git a/tests/oracle/53-path-pattern/expected.json b/tests/oracle/53-path-pattern/expected.json new file mode 100644 index 00000000..7fbc40dd --- /dev/null +++ b/tests/oracle/53-path-pattern/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "plate 120x60x5 minus 7 through holes d6 at arc lengths k*17.875031 along the arc through (100,15),(60,45),(20,15) (centre (60,10/3), R 125/3), translated copies", + "body_count": 1, + "volume_mm3": 35010.3983141192, + "area_mm2": 16463.89378290154, + "center_of_mass": [ + 60.00000001136545, + 29.96522867057171, + 2.5 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 120.0, + 60.0, + 5.0 + ] + }, + "faces": 13 +} diff --git a/tests/oracle/53-path-pattern/recipe.json b/tests/oracle/53-path-pattern/recipe.json new file mode 100644 index 00000000..85fabaef --- /dev/null +++ b/tests/oracle/53-path-pattern/recipe.json @@ -0,0 +1,260 @@ +{ + "id": "53-path-pattern", + "name": "Pattern along a path", + "description": "120x60x5 plate; d6 hole at (100,15) cut through; patterned 7x along a 3-point arc (100,15)-(60,45)-(20,15), evenly over the arc length.", + "exercises": [ + "path_pattern", + "extrude_cut" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Plate_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 120, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 120.0, + 0.0 + ] + }, + "end": { + "at": [ + 120, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 120.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Plate", + "operation": "new", + "profiles": [ + { + "sketch": "Plate_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 5, + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "sketch", + "name": "PathSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "arc_three_point", + "start": { + "at": [ + 100, + 15 + ] + }, + "mid": [ + 60, + 45 + ], + "end": { + "at": [ + 20, + 15 + ] + }, + "id": "a1" + }, + { + "type": "circle", + "center": { + "ref": "a1.start", + "at": [ + 100.0, + 15.0 + ] + }, + "radius": 3, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Hole1", + "operation": "cut", + "profiles": [ + { + "sketch": "PathSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "path_pattern", + "name": "PathPattern1", + "pattern_of": "features", + "features": [ + "Hole1" + ], + "path": { + "sketch": "PathSketch", + "curves": [ + "a1" + ] + }, + "count": 7, + "spacing": 17.875031, + "distance_type": "spacing (arc-length distance between consecutive instances), starting at the hole and running along the arc toward (20,15)", + "semantics": "instance k = feature translated by path(k*spacing) - path(0); path(0) is the path's start point, so the feature must sit at the path start", + "orientation": "identical (not rotated along the path)" + } + ] +} diff --git a/tests/oracle/54-mirror-body-fillets/expected.json b/tests/oracle/54-mirror-body-fillets/expected.json new file mode 100644 index 00000000..f867f11b --- /dev/null +++ b/tests/oracle/54-mirror-body-fillets/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "prism h10 of the 60x40 rectangle with four R5 corner rounds and two d8 holes at (+-15,20): V = A h, area = 2A + perimeter*h", + "body_count": 1, + "volume_mm3": 22780.08851424851, + "area_mm2": 6972.831792783061, + "center_of_mass": [ + -1.4178560266764932e-15, + 20.00000000000026, + 5.0 + ], + "bbox": { + "min": [ + -30.0, + 0.0, + 0.0 + ], + "max": [ + 30.0, + 40.0, + 10.0 + ] + }, + "faces": 12 +} diff --git a/tests/oracle/54-mirror-body-fillets/recipe.json b/tests/oracle/54-mirror-body-fillets/recipe.json new file mode 100644 index 00000000..e76e188a --- /dev/null +++ b/tests/oracle/54-mirror-body-fillets/recipe.json @@ -0,0 +1,289 @@ +{ + "id": "54-mirror-body-fillets", + "name": "Mirror a filleted body", + "description": "Half bracket x 0..30, y 0..40, h10 with the two x=30 vertical edges R5 and a d8 hole at (15,20); body mirrored across YZ and combined into one body.", + "exercises": [ + "fillet", + "mirror_body", + "mirror_combine" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Half_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 30, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 30.0, + 0.0 + ] + }, + "end": { + "at": [ + 30, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 30.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Half", + "operation": "new", + "profiles": [ + { + "sketch": "Half_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "positive" + }, + "result_bodies": [ + "Bracket" + ] + }, + { + "op": "fillet", + "name": "Fillet1", + "type": "constant_radius", + "radius": 5, + "tangent_chain": false, + "edge_selection": "the 2 vertical edges at x=30", + "edges": [ + { + "type": "line", + "start": [ + 30.0, + 40.0, + 0.0 + ], + "end": [ + 30.0, + 40.0, + 10.0 + ], + "point": [ + 30.0, + 40.0, + 5.0 + ] + }, + { + "type": "line", + "start": [ + 30.0, + 0.0, + 0.0 + ], + "end": [ + 30.0, + 0.0, + 10.0 + ], + "point": [ + 30.0, + 0.0, + 5.0 + ] + } + ] + }, + { + "op": "sketch", + "name": "HoleSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 15, + 20 + ] + }, + "radius": 4, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Hole1", + "operation": "cut", + "profiles": [ + { + "sketch": "HoleSketch", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Bracket" + ] + }, + { + "op": "mirror", + "name": "Mirror1", + "mirror_of": "bodies", + "bodies": [ + "Bracket" + ], + "plane": { + "ref": "YZ", + "origin": [ + 0, + 0, + 0 + ], + "normal": [ + 1, + 0, + 0 + ] + }, + "combine": true + } + ] +} diff --git a/tests/oracle/55-tapped-hole-cosmetic/expected.json b/tests/oracle/55-tapped-hole-cosmetic/expected.json new file mode 100644 index 00000000..6e71ad05 --- /dev/null +++ b/tests/oracle/55-tapped-hole-cosmetic/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "plate 40x30x15 minus d6.8 cylinder 12 deep (to the shoulder) and 118 deg drill point cone (h = r/tan59, V = pi r^2 h/3, lateral pi r * r/sin59); cosmetic thread adds no geometry", + "body_count": 1, + "volume_mm3": 17539.467413298902, + "area_mm2": 4762.405554138465, + "center_of_mass": [ + 20.0, + 15.0, + 7.469794810863136 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 40.0, + 30.0, + 15.0 + ] + }, + "faces": 8 +} diff --git a/tests/oracle/55-tapped-hole-cosmetic/recipe.json b/tests/oracle/55-tapped-hole-cosmetic/recipe.json new file mode 100644 index 00000000..2f2f0e2e --- /dev/null +++ b/tests/oracle/55-tapped-hole-cosmetic/recipe.json @@ -0,0 +1,219 @@ +{ + "id": "55-tapped-hole-cosmetic", + "name": "Tapped hole with cosmetic thread", + "description": "40x30x15 plate; d6.8 simple hole 12 deep (118 deg tip) at (20,15); cosmetic ISO M8x1.25 6H thread on the hole wall, full length.", + "exercises": [ + "hole_blind", + "thread_cosmetic" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Plate_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 40, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + 30.0 + ] + }, + "end": { + "at": [ + 0, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Plate", + "operation": "new", + "profiles": [ + { + "sketch": "Plate_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 15, + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "hole", + "name": "TapDrill", + "hole_type": "simple", + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 20.0, + 15.0, + 15.0 + ] + }, + "position": [ + 20, + 15, + 15 + ], + "direction": "into the material (-Z)", + "diameter": 6.8, + "depth": 12, + "tip_angle": 118, + "extent": "distance" + }, + { + "op": "thread", + "name": "Thread1", + "face": { + "type": "cylinder", + "normal_at_point_on_face": [ + -0.0, + 1.0, + 0.0 + ], + "axis_origin": [ + 20.0, + 15.0, + 9.0 + ], + "axis": [ + 0.0, + 0.0, + -1.0 + ], + "point": [ + 20.0, + 11.61025, + 9.0 + ] + }, + "modeled": false, + "full_length": true, + "internal": true, + "thread_type": "ISO Metric profile", + "designation": "M8x1.25", + "class": "6H" + } + ] +} diff --git a/tests/oracle/56-holes-on-curved-face/expected.json b/tests/oracle/56-holes-on-curved-face/expected.json new file mode 100644 index 00000000..bd34f5b0 --- /dev/null +++ b/tests/oracle/56-holes-on-curved-face/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, hole tool (cylinder/cone) cut", + "body_count": 1, + "volume_mm3": 77942.84309053734, + "area_mm2": 10593.056836186597, + "center_of_mass": [ + -0.07534872185918355, + -0.07534952345333637, + 19.999998003198982 + ], + "bbox": { + "min": [ + -25.0000001, + -25.0000001, + -1e-07 + ], + "max": [ + 25.0000001, + 25.00000011239331, + 40.0000001 + ] + } +} diff --git a/tests/oracle/56-holes-on-curved-face/recipe.json b/tests/oracle/56-holes-on-curved-face/recipe.json new file mode 100644 index 00000000..6a7b5219 --- /dev/null +++ b/tests/oracle/56-holes-on-curved-face/recipe.json @@ -0,0 +1,169 @@ +{ + "id": "56-holes-on-curved-face", + "name": "Holes on a cylindrical face", + "description": "Cylinder r25 h40 (axis Z); two d6 radial blind holes 10 deep placed on the curved face at (25,0,20) and (0,25,20).", + "exercises": [ + "hole_on_curved_face", + "hole_blind" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "S1", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 0, + 0 + ] + }, + "radius": 25, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Cyl", + "operation": "new", + "profiles": [ + { + "sketch": "S1", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 40, + "direction": "positive" + }, + "result_bodies": [ + "Cyl" + ] + }, + { + "op": "hole", + "name": "Hole1", + "hole_type": "simple", + "face": { + "type": "cylinder", + "normal_at_point_on_face": [ + 0.0, + 1.0, + 0.0 + ], + "axis_origin": [ + 0.0, + 0.0, + 0.0 + ], + "axis": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 0.0, + 25.0, + 20.0 + ] + }, + "position": [ + 25, + 0, + 20 + ], + "direction": "along the inward surface normal (radial, toward the axis)", + "diameter": 6, + "depth": 10, + "tip_angle": 118, + "extent": "distance" + }, + { + "op": "hole", + "name": "Hole2", + "hole_type": "simple", + "face": { + "type": "cylinder", + "normal_at_point_on_face": [ + 0.992773892, + 0.12, + 0.0 + ], + "axis_origin": [ + 0.0, + 0.0, + 0.0 + ], + "axis": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 24.819347292, + 3.0, + 20.0 + ] + }, + "position": [ + 0, + 25, + 20 + ], + "direction": "along the inward surface normal (radial, toward the axis)", + "diameter": 6, + "depth": 10, + "tip_angle": 118, + "extent": "distance" + } + ] +} diff --git a/tests/oracle/56-holes-on-curved-face/reference.stl b/tests/oracle/56-holes-on-curved-face/reference.stl new file mode 100644 index 00000000..53d276c5 Binary files /dev/null and b/tests/oracle/56-holes-on-curved-face/reference.stl differ diff --git a/tests/oracle/57-hole-pattern-sketch-points/expected.json b/tests/oracle/57-hole-pattern-sketch-points/expected.json new file mode 100644 index 00000000..ccf7f8bf --- /dev/null +++ b/tests/oracle/57-hole-pattern-sketch-points/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "plate 80x50x10 minus 6 counterbored through holes (cb d10 x 5.5, hole d5.5) at x 15/40/65, y 15/35", + "body_count": 1, + "volume_mm3": 36766.71211083357, + "area_mm2": 11818.152551429443, + "center_of_mass": [ + 39.99999999999997, + 25.0, + 4.889369275430759 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 50.0, + 10.0 + ] + }, + "faces": 24 +} diff --git a/tests/oracle/57-hole-pattern-sketch-points/recipe.json b/tests/oracle/57-hole-pattern-sketch-points/recipe.json new file mode 100644 index 00000000..6d5ae6c6 --- /dev/null +++ b/tests/oracle/57-hole-pattern-sketch-points/recipe.json @@ -0,0 +1,267 @@ +{ + "id": "57-hole-pattern-sketch-points", + "name": "Counterbore holes from sketch points", + "description": "80x50x10 plate; 6 sketch points on the top face; one counterbore hole feature (d5.5, cb d10 x 5.5) through all at all points.", + "exercises": [ + "hole_counterbore", + "hole_from_sketch_points" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Plate_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 80, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + 0.0 + ] + }, + "end": { + "at": [ + 80, + 50 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 80.0, + 50.0 + ] + }, + "end": { + "at": [ + 0, + 50 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 50.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Plate", + "operation": "new", + "profiles": [ + { + "sketch": "Plate_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "positive" + }, + "result_bodies": [ + "Plate" + ] + }, + { + "op": "sketch", + "name": "HolePoints", + "plane": { + "ref": "face: top of plate (z=10)", + "origin": [ + 0.0, + 0.0, + 10.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ], + "face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 40.0, + 25.0, + 10.0 + ] + } + }, + "entities": [ + { + "type": "point", + "at": [ + 15.0, + 15.0 + ], + "fixed": false, + "id": "p1" + }, + { + "type": "point", + "at": [ + 40.0, + 15.0 + ], + "fixed": false, + "id": "p2" + }, + { + "type": "point", + "at": [ + 65.0, + 15.0 + ], + "fixed": false, + "id": "p3" + }, + { + "type": "point", + "at": [ + 15.0, + 35.0 + ], + "fixed": false, + "id": "p4" + }, + { + "type": "point", + "at": [ + 40.0, + 35.0 + ], + "fixed": false, + "id": "p5" + }, + { + "type": "point", + "at": [ + 65.0, + 35.0 + ], + "fixed": false, + "id": "p6" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "hole", + "name": "CBores", + "hole_type": "counterbore", + "positions_from": "sketch HolePoints points p1..p6", + "direction": "into the material (-Z)", + "diameter": 5.5, + "counterbore_diameter": 10, + "counterbore_depth": 5.5, + "extent": "through_all" + } + ] +} diff --git a/tests/oracle/58-phone-stand/expected.json b/tests/oracle/58-phone-stand/expected.json new file mode 100644 index 00000000..26ebf93c --- /dev/null +++ b/tests/oracle/58-phone-stand/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet, Part extrude cut", + "body_count": 1, + "volume_mm3": 74559.17735612375, + "area_mm2": 29148.784986945895, + "center_of_mass": [ + 30.24137598608169, + -1.2447738706242872e-15, + 23.750201033013706 + ], + "bbox": { + "min": [ + -14.035643557, + -35.0, + 0.0 + ], + "max": [ + 90.0, + 35.0, + 87.567700833 + ] + } +} diff --git a/tests/oracle/58-phone-stand/recipe.json b/tests/oracle/58-phone-stand/recipe.json new file mode 100644 index 00000000..f0b3f8f9 --- /dev/null +++ b/tests/oracle/58-phone-stand/recipe.json @@ -0,0 +1,466 @@ +{ + "id": "58-phone-stand", + "name": "Phone stand", + "description": "Side profile on XZ (base, front lip, 65 deg back rest) extruded symmetric 35 each side; root fillets R3 and R2; cable slot cut through the base.", + "exercises": [ + "sketch_xz", + "symmetric", + "fillet", + "extrude_cut", + "realistic" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "SideProfile", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0.0, + 0.0 + ] + }, + "end": { + "at": [ + 90.0, + 0.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 90.0, + 0.0 + ] + }, + "end": { + "at": [ + 90.0, + -18.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 90.0, + -18.0 + ] + }, + "end": { + "at": [ + 84.0, + -18.0 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 84.0, + -18.0 + ] + }, + "end": { + "at": [ + 84.0, + -6.0 + ] + }, + "id": "l4" + }, + { + "type": "line", + "start": { + "ref": "l4.end", + "at": [ + 84.0, + -6.0 + ] + }, + "end": { + "at": [ + 30.0, + -6.0 + ] + }, + "id": "l5" + }, + { + "type": "line", + "start": { + "ref": "l5.end", + "at": [ + 30.0, + -6.0 + ] + }, + "end": { + "at": [ + -8.035643557, + -87.567700833 + ] + }, + "id": "l6" + }, + { + "type": "line", + "start": { + "ref": "l6.end", + "at": [ + -8.035643557, + -87.567700833 + ] + }, + "end": { + "at": [ + -14.035643557, + -87.567700833 + ] + }, + "id": "l7" + }, + { + "type": "line", + "start": { + "ref": "l7.end", + "at": [ + -14.035643557, + -87.567700833 + ] + }, + "end": { + "at": [ + 24.0, + -6.0 + ] + }, + "id": "l8" + }, + { + "type": "line", + "start": { + "ref": "l8.end", + "at": [ + 24.0, + -6.0 + ] + }, + "end": { + "at": [ + 0.0, + -6.0 + ] + }, + "id": "l9" + }, + { + "type": "line", + "start": { + "ref": "l9.end", + "at": [ + 0.0, + -6.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l10" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Body", + "operation": "new", + "profiles": [ + { + "sketch": "SideProfile", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l10", + "l2", + "l3", + "l4", + "l5", + "l6", + "l7", + "l8", + "l9" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 35, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "Stand" + ] + }, + { + "op": "fillet", + "name": "RestFillet", + "type": "constant_radius", + "radius": 3, + "tangent_chain": false, + "edge_selection": "the two Y-parallel edges where the back rest meets the base (x=30,z=6 concave; x=24,z=6 concave)", + "edges": [ + { + "type": "line", + "start": [ + 30.0, + -35.0, + 6.0 + ], + "end": [ + 30.0, + 35.0, + 6.0 + ], + "point": [ + 30.0, + 0.0, + 6.0 + ] + }, + { + "type": "line", + "start": [ + 24.0, + -35.0, + 6.0 + ], + "end": [ + 24.0, + 35.0, + 6.0 + ], + "point": [ + 24.0, + 0.0, + 6.0 + ] + } + ] + }, + { + "op": "fillet", + "name": "LipFillet", + "type": "constant_radius", + "radius": 2, + "tangent_chain": false, + "edge_selection": "the Y-parallel concave edge at the lip root (x=84, z=6)", + "edges": [ + { + "type": "line", + "start": [ + 84.0, + -35.0, + 6.0 + ], + "end": [ + 84.0, + 35.0, + 6.0 + ], + "point": [ + 84.0, + 0.0, + 6.0 + ] + } + ] + }, + { + "op": "sketch", + "name": "SlotSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 45, + -10 + ] + }, + "end": { + "at": [ + 70, + -10 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 70.0, + -10.0 + ] + }, + "end": { + "at": [ + 70, + 10 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 70.0, + 10.0 + ] + }, + "end": { + "at": [ + 45, + 10 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 45.0, + 10.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 45.0, + -10.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "CableSlot", + "operation": "cut", + "profiles": [ + { + "sketch": "SlotSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Stand" + ] + } + ] +} diff --git a/tests/oracle/58-phone-stand/reference.stl b/tests/oracle/58-phone-stand/reference.stl new file mode 100644 index 00000000..8d07ecf9 Binary files /dev/null and b/tests/oracle/58-phone-stand/reference.stl differ diff --git a/tests/oracle/59-enclosure-base/expected.json b/tests/oracle/59-enclosure-base/expected.json new file mode 100644 index 00000000..c9223709 --- /dev/null +++ b/tests/oracle/59-enclosure-base/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet, makeThickness shell, Part extrude join, Part extrude cut", + "body_count": 1, + "volume_mm3": 34309.48619533657, + "area_mm2": 33795.610175573434, + "center_of_mass": [ + 50.0, + 29.999999999999954, + 10.205570681943714 + ], + "bbox": { + "min": [ + -8.881784197001252e-16, + -8.881784197001252e-16, + 0.0 + ], + "max": [ + 100.0, + 60.0, + 30.0 + ] + } +} diff --git a/tests/oracle/59-enclosure-base/recipe.json b/tests/oracle/59-enclosure-base/recipe.json new file mode 100644 index 00000000..9b9a44b0 --- /dev/null +++ b/tests/oracle/59-enclosure-base/recipe.json @@ -0,0 +1,645 @@ +{ + "id": "59-enclosure-base", + "name": "Enclosure base with bosses and ribs", + "description": "100x60x30 box, vertical edges R6, shelled 2 (top open); 4 d8 screw bosses to z=26 with d3 holes 20 deep; a 2 mm rib along Y 10 tall.", + "exercises": [ + "fillet", + "shell", + "extrude_join", + "extrude_cut", + "realistic" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Box_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 100, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 100.0, + 0.0 + ] + }, + "end": { + "at": [ + 100, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 100.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Box", + "operation": "new", + "profiles": [ + { + "sketch": "Box_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 30, + "direction": "positive" + }, + "result_bodies": [ + "Enclosure" + ] + }, + { + "op": "fillet", + "name": "CornerFillet", + "type": "constant_radius", + "radius": 6, + "tangent_chain": false, + "edge_selection": "the 4 outer vertical edges", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 60.0, + 0.0 + ], + "end": [ + 0.0, + 60.0, + 30.0 + ], + "point": [ + 0.0, + 60.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 100.0, + 60.0, + 0.0 + ], + "end": [ + 100.0, + 60.0, + 30.0 + ], + "point": [ + 100.0, + 60.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 100.0, + 0.0, + 0.0 + ], + "end": [ + 100.0, + 0.0, + 30.0 + ], + "point": [ + 100.0, + 0.0, + 15.0 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ], + "end": [ + 0.0, + 0.0, + 30.0 + ], + "point": [ + 0.0, + 0.0, + 15.0 + ] + } + ] + }, + { + "op": "shell", + "name": "Shell1", + "faces_removed": [ + { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + 1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + 50.0, + 30.0, + 30.0 + ] + } + ], + "face_selection": "top face (z=30)", + "inside_thickness": 2, + "outside_thickness": 0, + "tangent_chain": false + }, + { + "op": "construction_plane", + "name": "Floor2", + "method": "offset", + "base": "XY", + "offset": 2 + }, + { + "op": "sketch", + "name": "Bosses", + "plane": { + "ref": "construction plane Floor2 (inner floor, z=2)", + "origin": [ + 0.0, + 0.0, + 2.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 8, + 8 + ] + }, + "radius": 4, + "id": "c1" + }, + { + "type": "circle", + "center": { + "at": [ + 92, + 8 + ] + }, + "radius": 4, + "id": "c2" + }, + { + "type": "circle", + "center": { + "at": [ + 8, + 52 + ] + }, + "radius": 4, + "id": "c3" + }, + { + "type": "circle", + "center": { + "at": [ + 92, + 52 + ] + }, + "radius": 4, + "id": "c4" + }, + { + "type": "line", + "start": { + "at": [ + 49, + 2 + ] + }, + "end": { + "at": [ + 51, + 2 + ] + }, + "id": "r1" + }, + { + "type": "line", + "start": { + "ref": "r1.end", + "at": [ + 51.0, + 2.0 + ] + }, + "end": { + "at": [ + 51, + 58 + ] + }, + "id": "r2" + }, + { + "type": "line", + "start": { + "ref": "r2.end", + "at": [ + 51.0, + 58.0 + ] + }, + "end": { + "at": [ + 49, + 58 + ] + }, + "id": "r3" + }, + { + "type": "line", + "start": { + "ref": "r3.end", + "at": [ + 49.0, + 58.0 + ] + }, + "end": { + "ref": "r1.start", + "at": [ + 49.0, + 2.0 + ] + }, + "id": "r4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Bosses", + "operation": "join", + "profiles": [ + { + "sketch": "Bosses", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + { + "sketch": "Bosses", + "loops": [ + { + "outer": true, + "curves": [ + "c2" + ] + } + ] + }, + { + "sketch": "Bosses", + "loops": [ + { + "outer": true, + "curves": [ + "c3" + ] + } + ] + }, + { + "sketch": "Bosses", + "loops": [ + { + "outer": true, + "curves": [ + "c4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 24, + "direction": "positive" + }, + "result_bodies": [ + "Enclosure" + ] + }, + { + "op": "extrude", + "name": "Rib", + "operation": "join", + "profiles": [ + { + "sketch": "Bosses", + "loops": [ + { + "outer": true, + "curves": [ + "r1", + "r2", + "r3", + "r4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 10, + "direction": "positive" + }, + "result_bodies": [ + "Enclosure" + ] + }, + { + "op": "construction_plane", + "name": "BossTop", + "method": "offset", + "base": "XY", + "offset": 26 + }, + { + "op": "sketch", + "name": "ScrewHoles", + "plane": { + "ref": "construction plane BossTop (z=26)", + "origin": [ + 0.0, + 0.0, + 26.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 8, + 8 + ] + }, + "radius": 1.5, + "id": "c1" + }, + { + "type": "circle", + "center": { + "at": [ + 92, + 8 + ] + }, + "radius": 1.5, + "id": "c2" + }, + { + "type": "circle", + "center": { + "at": [ + 8, + 52 + ] + }, + "radius": 1.5, + "id": "c3" + }, + { + "type": "circle", + "center": { + "at": [ + 92, + 52 + ] + }, + "radius": 1.5, + "id": "c4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "ScrewHoles", + "operation": "cut", + "profiles": [ + { + "sketch": "ScrewHoles", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + { + "sketch": "ScrewHoles", + "loops": [ + { + "outer": true, + "curves": [ + "c3" + ] + } + ] + }, + { + "sketch": "ScrewHoles", + "loops": [ + { + "outer": true, + "curves": [ + "c2" + ] + } + ] + }, + { + "sketch": "ScrewHoles", + "loops": [ + { + "outer": true, + "curves": [ + "c4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "negative" + }, + "result_bodies": [ + "Enclosure" + ] + } + ] +} diff --git a/tests/oracle/59-enclosure-base/reference.stl b/tests/oracle/59-enclosure-base/reference.stl new file mode 100644 index 00000000..3091cc3f Binary files /dev/null and b/tests/oracle/59-enclosure-base/reference.stl differ diff --git a/tests/oracle/60-pipe-flange/expected.json b/tests/oracle/60-pipe-flange/expected.json new file mode 100644 index 00000000..745d26fe --- /dev/null +++ b/tests/oracle/60-pipe-flange/expected.json @@ -0,0 +1,25 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part revolve, Part extrude cut, pattern (tools transformed and re-applied), Part makeChamfer, Part makeFillet", + "body_count": 1, + "volume_mm3": 323660.9792237058, + "area_mm2": 56984.564025960535, + "center_of_mass": [ + 8.992072670155864e-17, + -3.0983486149956997e-15, + 14.841104660417978 + ], + "bbox": { + "min": [ + -75.0, + -75.0, + 0.0 + ], + "max": [ + 75.0, + 75.0, + 50.0 + ] + }, + "note": "No reference.stl: over 409600 bytes even at 0.02 mm deflection (11380 facets)." +} diff --git a/tests/oracle/60-pipe-flange/recipe.json b/tests/oracle/60-pipe-flange/recipe.json new file mode 100644 index 00000000..3a27b3e8 --- /dev/null +++ b/tests/oracle/60-pipe-flange/recipe.json @@ -0,0 +1,367 @@ +{ + "id": "60-pipe-flange", + "name": "Weld-neck pipe flange", + "description": "Revolved flange (bore d50, flange OD150 x16, tapered neck to z=50); 8 x d14 bolt holes on PCD120; bore top edge chamfer 2; neck root fillet R4.", + "exercises": [ + "revolve", + "extrude_cut", + "circular_pattern", + "chamfer", + "fillet", + "realistic" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Profile", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 25.0, + 0.0 + ] + }, + "end": { + "at": [ + 75.0, + 0.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 75.0, + 0.0 + ] + }, + "end": { + "at": [ + 75.0, + -16.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 75.0, + -16.0 + ] + }, + "end": { + "at": [ + 40.0, + -16.0 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 40.0, + -16.0 + ] + }, + "end": { + "at": [ + 37.0, + -50.0 + ] + }, + "id": "l4" + }, + { + "type": "line", + "start": { + "ref": "l4.end", + "at": [ + 37.0, + -50.0 + ] + }, + "end": { + "at": [ + 25.0, + -50.0 + ] + }, + "id": "l5" + }, + { + "type": "line", + "start": { + "ref": "l5.end", + "at": [ + 25.0, + -50.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 25.0, + 0.0 + ] + }, + "id": "l6" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "revolve", + "name": "Revolve1", + "operation": "new", + "profile": { + "sketch": "Profile", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4", + "l5", + "l6" + ] + } + ] + }, + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "angle": 360, + "result_bodies": [ + "Flange" + ] + }, + { + "op": "sketch", + "name": "BoltHole", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 60, + 0 + ] + }, + "radius": 7, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "BoltHole", + "operation": "cut", + "profiles": [ + { + "sketch": "BoltHole", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Flange" + ] + }, + { + "op": "circular_pattern", + "name": "BoltPattern", + "pattern_of": "features", + "features": [ + "BoltHole (1)" + ], + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "count": 8, + "total_angle": 360, + "symmetric": false + }, + { + "op": "chamfer", + "name": "BoreChamfer", + "type": "equal_distance", + "distance": 2, + "tangent_chain": false, + "edge_selection": "bore circle r=25 at z=50 (top of neck)", + "edges": [ + { + "type": "circle", + "start": [ + 25.0, + 0.0, + 50.0 + ], + "end": [ + 25.0, + 0.0, + 50.0 + ], + "center": [ + 0.0, + 0.0, + 50.0 + ], + "axis": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + -25.0, + 0.0, + 50.0 + ] + } + ] + }, + { + "op": "fillet", + "name": "NeckFillet", + "type": "constant_radius", + "radius": 4, + "tangent_chain": false, + "edge_selection": "circle r=40 at z=16 (neck meets flange face, concave)", + "edges": [ + { + "type": "circle", + "start": [ + 40.0, + 0.0, + 16.0 + ], + "end": [ + 40.0, + 0.0, + 16.0 + ], + "center": [ + 0.0, + 0.0, + 16.0 + ], + "axis": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + -40.0, + 0.0, + 16.0 + ] + } + ] + } + ] +} diff --git a/tests/oracle/61-shaft-keyway/expected.json b/tests/oracle/61-shaft-keyway/expected.json new file mode 100644 index 00000000..1fd8d545 --- /dev/null +++ b/tests/oracle/61-shaft-keyway/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part revolve, Part makeChamfer, Part extrude cut", + "body_count": 1, + "volume_mm3": 47526.16490034648, + "area_mm2": 9677.092709919276, + "center_of_mass": [ + -0.15632153741345178, + 2.2099613089702305e-12, + 60.00000055531035 + ], + "bbox": { + "min": [ + -12.5000001, + -12.5000001, + 0.0 + ], + "max": [ + 12.5000001, + 12.5000001, + 120.0 + ] + } +} diff --git a/tests/oracle/61-shaft-keyway/recipe.json b/tests/oracle/61-shaft-keyway/recipe.json new file mode 100644 index 00000000..264a97dc --- /dev/null +++ b/tests/oracle/61-shaft-keyway/recipe.json @@ -0,0 +1,439 @@ +{ + "id": "61-shaft-keyway", + "name": "Stepped shaft with keyway and chamfers", + "description": "Revolved stepped shaft (d20 x30, d25 x60, d20 x30 along Z); end edges chamfered 1; 8 mm wide R4-ended keyway 4 deep on the d25 section (z 45..75), cut from a tangent plane at x=12.5.", + "exercises": [ + "revolve", + "chamfer", + "offset_plane", + "extrude_cut", + "keyway", + "realistic" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Profile", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0.0, + 0.0 + ] + }, + "end": { + "at": [ + 10.0, + 0.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 10.0, + 0.0 + ] + }, + "end": { + "at": [ + 10.0, + -30.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 10.0, + -30.0 + ] + }, + "end": { + "at": [ + 12.5, + -30.0 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 12.5, + -30.0 + ] + }, + "end": { + "at": [ + 12.5, + -90.0 + ] + }, + "id": "l4" + }, + { + "type": "line", + "start": { + "ref": "l4.end", + "at": [ + 12.5, + -90.0 + ] + }, + "end": { + "at": [ + 10.0, + -90.0 + ] + }, + "id": "l5" + }, + { + "type": "line", + "start": { + "ref": "l5.end", + "at": [ + 10.0, + -90.0 + ] + }, + "end": { + "at": [ + 10.0, + -120.0 + ] + }, + "id": "l6" + }, + { + "type": "line", + "start": { + "ref": "l6.end", + "at": [ + 10.0, + -120.0 + ] + }, + "end": { + "at": [ + 0.0, + -120.0 + ] + }, + "id": "l7" + }, + { + "type": "line", + "start": { + "ref": "l7.end", + "at": [ + 0.0, + -120.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l8" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "revolve", + "name": "Revolve1", + "operation": "new", + "profile": { + "sketch": "Profile", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4", + "l5", + "l6", + "l7", + "l8" + ] + } + ] + }, + "axis": { + "ref": "world Z axis", + "origin": [ + 0, + 0, + 0 + ], + "dir": [ + 0, + 0, + 1 + ] + }, + "angle": 360, + "result_bodies": [ + "Shaft" + ] + }, + { + "op": "chamfer", + "name": "EndChamfers", + "type": "equal_distance", + "distance": 1, + "tangent_chain": false, + "edge_selection": "end circles r=10 at z=0 and z=120", + "edges": [ + { + "type": "circle", + "start": [ + 10.0, + 0.0, + 0.0 + ], + "end": [ + 10.0, + 0.0, + 0.0 + ], + "center": [ + 0.0, + 0.0, + 0.0 + ], + "axis": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + -10.0, + 0.0, + 0.0 + ] + }, + { + "type": "circle", + "start": [ + 10.0, + 0.0, + 120.0 + ], + "end": [ + 10.0, + 0.0, + 120.0 + ], + "center": [ + 0.0, + 0.0, + 120.0 + ], + "axis": [ + 0.0, + 0.0, + 1.0 + ], + "point": [ + -10.0, + 0.0, + 120.0 + ] + } + ] + }, + { + "op": "construction_plane", + "name": "Tangent", + "method": "offset", + "base": "YZ", + "offset": 12.5 + }, + { + "op": "sketch", + "name": "KeywaySketch", + "plane": { + "ref": "construction plane Tangent (YZ offset 12.5)", + "origin": [ + 12.5, + 0.0, + 0.0 + ], + "x_dir": [ + 0.0, + 0.0, + -1.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 1.0, + 0.0, + 0.0 + ] + }, + "entities": [ + { + "type": "arc_center_start_sweep", + "center": { + "at": [ + -45.0, + 0.0 + ] + }, + "start": { + "at": [ + -45.0, + 4.0 + ] + }, + "sweep_deg": 180, + "id": "a1" + }, + { + "type": "arc_center_start_sweep", + "center": { + "at": [ + -75.0, + 0.0 + ] + }, + "start": { + "at": [ + -75.0, + -4.0 + ] + }, + "sweep_deg": 180, + "id": "a2" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + -45.0, + -4.0 + ] + }, + "end": { + "ref": "a2.start", + "at": [ + -75.0, + -4.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "a2.end", + "at": [ + -75.0, + 4.0 + ] + }, + "end": { + "ref": "a1.start", + "at": [ + -45.0, + 4.0 + ] + }, + "id": "l2" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Keyway", + "operation": "cut", + "profiles": [ + { + "sketch": "KeywaySketch", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "a2", + "l1", + "l2" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 4, + "direction": "negative" + }, + "result_bodies": [ + "Shaft" + ] + } + ] +} diff --git a/tests/oracle/61-shaft-keyway/reference.stl b/tests/oracle/61-shaft-keyway/reference.stl new file mode 100644 index 00000000..d570ac26 Binary files /dev/null and b/tests/oracle/61-shaft-keyway/reference.stl differ diff --git a/tests/oracle/62-heat-sink/expected.json b/tests/oracle/62-heat-sink/expected.json new file mode 100644 index 00000000..8b07955f --- /dev/null +++ b/tests/oracle/62-heat-sink/expected.json @@ -0,0 +1,26 @@ +{ + "source": "analytic", + "method": "base 60x50x5 + 15 fins 1.5x50x25 at x = 2 + 4k: sums of boxes; fin ends coplanar with the base ends", + "body_count": 1, + "volume_mm3": 43125.0, + "area_mm2": 45725.0, + "center_of_mass": [ + 30.48913043478261, + 25.0, + 12.282608695652174 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 60.0, + 50.0, + 30.0 + ] + }, + "faces": 66, + "note": "faces: bottom, x=0, x=60, y=0 and y=50 (each merged with the fin ends), 16 base-top strips, 15x3 fin faces" +} diff --git a/tests/oracle/62-heat-sink/recipe.json b/tests/oracle/62-heat-sink/recipe.json new file mode 100644 index 00000000..2eba3fb9 --- /dev/null +++ b/tests/oracle/62-heat-sink/recipe.json @@ -0,0 +1,308 @@ +{ + "id": "62-heat-sink", + "name": "Heat sink with 15 fins", + "description": "60x50x5 base; one 1.5x50 fin 25 tall at x=2; rectangular pattern x15 @4 mm.", + "exercises": [ + "extrude_join", + "rectangular_pattern", + "many_faces", + "realistic" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 60, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 60.0, + 0.0 + ] + }, + "end": { + "at": [ + 60, + 50 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 60.0, + 50.0 + ] + }, + "end": { + "at": [ + 0, + 50 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 50.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Base", + "operation": "new", + "profiles": [ + { + "sketch": "Base_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 5, + "direction": "positive" + }, + "result_bodies": [ + "HeatSink" + ] + }, + { + "op": "construction_plane", + "name": "BaseTop", + "method": "offset", + "base": "XY", + "offset": 5 + }, + { + "op": "sketch", + "name": "Fin", + "plane": { + "ref": "construction plane BaseTop (z=5)", + "origin": [ + 0.0, + 0.0, + 5.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 2, + 0 + ] + }, + "end": { + "at": [ + 3.5, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 3.5, + 0.0 + ] + }, + "end": { + "at": [ + 3.5, + 50 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 3.5, + 50.0 + ] + }, + "end": { + "at": [ + 2, + 50 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 2.0, + 50.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 2.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Fin", + "operation": "join", + "profiles": [ + { + "sketch": "Fin", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 25, + "direction": "positive" + }, + "result_bodies": [ + "HeatSink" + ] + }, + { + "op": "rectangular_pattern", + "name": "FinPattern", + "pattern_of": "features", + "features": [ + "Fin (1)" + ], + "direction1": { + "axis": [ + 1, + 0, + 0 + ], + "count": 15, + "spacing": 4 + }, + "distance_type": "spacing (distance between instances)" + } + ] +} diff --git a/tests/oracle/63-hinge-knuckle/expected.json b/tests/oracle/63-hinge-knuckle/expected.json new file mode 100644 index 00000000..f9b9e70e --- /dev/null +++ b/tests/oracle/63-hinge-knuckle/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut", + "body_count": 1, + "volume_mm3": 4436.251164993785, + "area_mm2": 3740.579063328514, + "center_of_mass": [ + 19.99999999999998, + 18.446306909750707, + 2.0056940715466656 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 40.0, + 37.0, + 8.0 + ] + } +} diff --git a/tests/oracle/63-hinge-knuckle/recipe.json b/tests/oracle/63-hinge-knuckle/recipe.json new file mode 100644 index 00000000..16081559 --- /dev/null +++ b/tests/oracle/63-hinge-knuckle/recipe.json @@ -0,0 +1,420 @@ +{ + "id": "63-hinge-knuckle", + "name": "Hinge leaf with knuckles", + "description": "40x30x3 leaf; knuckle cylinder r4 along X at (y=33, z=4); d4 pin hole; gap x 12..28 (y 29..40) cut through all from z=0, which removes the knuckle between x=12 and 28.", + "exercises": [ + "sketch_yz", + "extrude_join", + "extrude_cut", + "symmetric", + "realistic" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Leaf_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 40, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + 30.0 + ] + }, + "end": { + "at": [ + 0, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Leaf", + "operation": "new", + "profiles": [ + { + "sketch": "Leaf_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 3, + "direction": "positive" + }, + "result_bodies": [ + "Hinge" + ] + }, + { + "op": "sketch", + "name": "Knuckle", + "plane": { + "ref": "YZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 0.0, + 0.0, + -1.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 1.0, + 0.0, + 0.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + -4.0, + 33.0 + ] + }, + "radius": 4, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Knuckle", + "operation": "join", + "profiles": [ + { + "sketch": "Knuckle", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 40, + "direction": "positive" + }, + "result_bodies": [ + "Hinge" + ] + }, + { + "op": "sketch", + "name": "Pin", + "plane": { + "ref": "YZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 0.0, + 0.0, + -1.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 1.0, + 0.0, + 0.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + -4.0, + 33.0 + ] + }, + "radius": 2, + "id": "c1" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "PinHole", + "operation": "cut", + "profiles": [ + { + "sketch": "Pin", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + } + ], + "extent": { + "type": "symmetric", + "distance": 100, + "is_full_length": false, + "meaning": "distance on each side" + }, + "result_bodies": [ + "Hinge" + ] + }, + { + "op": "sketch", + "name": "Gap", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 12, + 29 + ] + }, + "end": { + "at": [ + 28, + 29 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 28.0, + 29.0 + ] + }, + "end": { + "at": [ + 28, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 28.0, + 40.0 + ] + }, + "end": { + "at": [ + 12, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 12.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 12.0, + 29.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Gap", + "operation": "cut", + "profiles": [ + { + "sketch": "Gap", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Hinge" + ] + } + ] +} diff --git a/tests/oracle/63-hinge-knuckle/reference.stl b/tests/oracle/63-hinge-knuckle/reference.stl new file mode 100644 index 00000000..d0858f21 Binary files /dev/null and b/tests/oracle/63-hinge-knuckle/reference.stl differ diff --git a/tests/oracle/64-sketch-tangent-arc-chain/expected.json b/tests/oracle/64-sketch-tangent-arc-chain/expected.json new file mode 100644 index 00000000..a3de8d2e --- /dev/null +++ b/tests/oracle/64-sketch-tangent-arc-chain/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "solved sketch: 80x20 rectangle whose top is 4 tangent R12 arcs on 20 mm chords alternating up/down (net area 1600); prism h3: V = A h, area = 2A + perimeter*h, Green's theorem for centroid", + "body_count": 1, + "volume_mm3": 4799.999999999989, + "area_mm2": 3843.711905601287, + "center_of_mass": [ + 38.11191357516195, + -9.585748181923725, + 1.5 + ], + "bbox": { + "min": [ + 0.0, + -20.0, + 0.0 + ], + "max": [ + 80.0, + 5.3667504192892, + 3.0 + ] + }, + "faces": 9 +} diff --git a/tests/oracle/64-sketch-tangent-arc-chain/recipe.json b/tests/oracle/64-sketch-tangent-arc-chain/recipe.json new file mode 100644 index 00000000..776d4b2f --- /dev/null +++ b/tests/oracle/64-sketch-tangent-arc-chain/recipe.json @@ -0,0 +1,357 @@ +{ + "id": "64-sketch-tangent-arc-chain", + "name": "Sketch solver: tangent arc chain", + "description": "Four alternating 3-point arcs (wave) closed by three lines; equal/tangent/horizontal-points constraints, R12, chord 20, line 20; solved then extruded 3.", + "exercises": [ + "sketch_only", + "sketch_constraints", + "tangent", + "equal", + "arc_chain" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Wave", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "arc_three_point", + "start": { + "at": [ + 20, + 0.5 + ] + }, + "mid": [ + 10, + 5.5 + ], + "end": { + "at": [ + 0.5, + -0.5 + ] + }, + "id": "a1" + }, + { + "type": "arc_three_point", + "start": { + "ref": "a1.start", + "at": [ + 20.0, + 0.5 + ] + }, + "mid": [ + 30, + -4.5 + ], + "end": { + "at": [ + 40, + 0.5 + ] + }, + "id": "a2" + }, + { + "type": "arc_three_point", + "start": { + "at": [ + 60, + -0.5 + ] + }, + "mid": [ + 50, + 5 + ], + "end": { + "at": [ + 40.5, + 0.5 + ] + }, + "id": "a3" + }, + { + "type": "arc_three_point", + "start": { + "ref": "a3.start", + "at": [ + 60.0, + -0.5 + ] + }, + "mid": [ + 70, + -5 + ], + "end": { + "at": [ + 79, + 0.5 + ] + }, + "id": "a4" + }, + { + "type": "line", + "start": { + "ref": "a4.end", + "at": [ + 79.0, + 0.5 + ] + }, + "end": { + "at": [ + 80, + -19 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + -19.0 + ] + }, + "end": { + "at": [ + 1, + -21 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 1.0, + -21.0 + ] + }, + "end": { + "ref": "a1.end", + "at": [ + 0.5, + -0.5 + ] + }, + "id": "l3" + } + ], + "constraints": [ + { + "type": "coincident", + "refs": [ + "a1.end", + "origin" + ] + }, + { + "type": "horizontal_points", + "refs": [ + "a1.start", + "a1.end" + ] + }, + { + "type": "coincident", + "refs": [ + "a3.end", + "a2.end" + ] + }, + { + "type": "equal", + "refs": [ + "a1", + "a2" + ] + }, + { + "type": "tangent", + "refs": [ + "a1", + "a2" + ] + }, + { + "type": "equal", + "refs": [ + "a2", + "a3" + ] + }, + { + "type": "tangent", + "refs": [ + "a2", + "a3" + ] + }, + { + "type": "equal", + "refs": [ + "a3", + "a4" + ] + }, + { + "type": "tangent", + "refs": [ + "a3", + "a4" + ] + }, + { + "type": "horizontal_points", + "refs": [ + "a2.start", + "a2.end" + ] + }, + { + "type": "horizontal_points", + "refs": [ + "a3.start", + "a3.end" + ] + }, + { + "type": "horizontal_points", + "refs": [ + "a4.start", + "a4.end" + ] + }, + { + "type": "vertical", + "refs": [ + "l1" + ] + }, + { + "type": "horizontal", + "refs": [ + "l2" + ] + }, + { + "type": "vertical", + "refs": [ + "l3" + ] + } + ], + "dimensions": [ + { + "type": "radius", + "refs": [ + "a1" + ], + "value": 12, + "unit": "mm" + }, + { + "type": "distance", + "refs": [ + "a1.end", + "a1.start" + ], + "value": 20, + "unit": "mm", + "orientation": "horizontal" + }, + { + "type": "distance", + "refs": [ + "l1.start", + "l1.end" + ], + "value": 20, + "unit": "mm", + "orientation": "vertical" + } + ] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Wave", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "a2", + "a3", + "a4", + "l1", + "l2", + "l3" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 3, + "direction": "positive" + }, + "result_bodies": [ + "Wave" + ] + } + ] +} diff --git a/tests/oracle/65-sketch-gear-tooth/expected.json b/tests/oracle/65-sketch-gear-tooth/expected.json new file mode 100644 index 00000000..20fec2a6 --- /dev/null +++ b/tests/oracle/65-sketch-gear-tooth/expected.json @@ -0,0 +1,24 @@ +{ + "source": "freecad 1.1.4", + "method": "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Sketcher-solved sketch, Part extrude", + "body_count": 1, + "volume_mm3": 3988.0844756019715, + "area_mm2": 1945.485055624576, + "center_of_mass": [ + -4.250889726760085e-15, + 29.27488680616214, + 4.0 + ], + "bbox": { + "min": [ + -10.352761804100844, + 0.0, + 0.0 + ], + "max": [ + 10.35276180410083, + 50.0, + 8.0 + ] + } +} diff --git a/tests/oracle/65-sketch-gear-tooth/recipe.json b/tests/oracle/65-sketch-gear-tooth/recipe.json new file mode 100644 index 00000000..1d662ccc --- /dev/null +++ b/tests/oracle/65-sketch-gear-tooth/recipe.json @@ -0,0 +1,374 @@ +{ + "id": "65-sketch-gear-tooth", + "name": "Sketch solver: gear-tooth sector", + "description": "30 deg sector with one tooth: root arcs R40 and tip arc R50 centred on the origin, R15 flank arcs, symmetric about a vertical construction line; fully constrained; extruded 8.", + "exercises": [ + "sketch_only", + "sketch_constraints", + "symmetric", + "angle_dim", + "arcs" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Tooth", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "ref": "origin", + "at": [ + 0.0, + 0.0 + ] + }, + "end": { + "at": [ + 0, + 58 + ] + }, + "id": "l0", + "construction": true + }, + { + "type": "line", + "start": { + "ref": "origin", + "at": [ + 0.0, + 0.0 + ] + }, + "end": { + "at": [ + 9.193, + 36.8712 + ] + }, + "id": "s1" + }, + { + "type": "arc_three_point", + "start": { + "ref": "s1.end", + "at": [ + 9.193, + 36.8712 + ] + }, + "mid": [ + 6.7619, + 37.3935 + ], + "end": { + "at": [ + 4.3017, + 37.7557 + ] + }, + "id": "r1" + }, + { + "type": "arc_three_point", + "start": { + "ref": "r1.end", + "at": [ + 4.3017, + 37.7557 + ] + }, + "mid": [ + 4.4596, + 42.995 + ], + "end": { + "at": [ + 2.6794, + 47.9252 + ] + }, + "id": "f1" + }, + { + "type": "arc_three_point", + "start": { + "ref": "f1.end", + "at": [ + 2.6794, + 47.9252 + ] + }, + "mid": [ + 0.0, + 48.0 + ], + "end": { + "at": [ + -2.6794, + 47.9252 + ] + }, + "id": "t1" + }, + { + "type": "arc_three_point", + "start": { + "ref": "t1.end", + "at": [ + -2.6794, + 47.9252 + ] + }, + "mid": [ + -4.4596, + 42.995 + ], + "end": { + "at": [ + -4.3017, + 37.7557 + ] + }, + "id": "f2" + }, + { + "type": "arc_three_point", + "start": { + "ref": "f2.end", + "at": [ + -4.3017, + 37.7557 + ] + }, + "mid": [ + -6.7619, + 37.3935 + ], + "end": { + "at": [ + -9.193, + 36.8712 + ] + }, + "id": "r2" + }, + { + "type": "line", + "start": { + "ref": "r2.end", + "at": [ + -9.193, + 36.8712 + ] + }, + "end": { + "ref": "origin", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "s2" + } + ], + "constraints": [ + { + "type": "coincident", + "refs": [ + "r1.center", + "origin" + ] + }, + { + "type": "coincident", + "refs": [ + "t1.center", + "origin" + ] + }, + { + "type": "coincident", + "refs": [ + "r2.center", + "origin" + ] + }, + { + "type": "vertical", + "refs": [ + "l0" + ] + }, + { + "type": "equal", + "refs": [ + "f1", + "f2" + ] + }, + { + "type": "symmetric", + "refs": [ + "r1.end", + "f2.end", + "l0" + ] + }, + { + "type": "horizontal_points", + "refs": [ + "f1.end", + "t1.end" + ] + }, + { + "type": "horizontal_points", + "refs": [ + "s1.end", + "r2.end" + ] + } + ], + "dimensions": [ + { + "type": "distance", + "refs": [ + "l0.start", + "l0.end" + ], + "value": 60, + "unit": "mm", + "orientation": "vertical" + }, + { + "type": "radius", + "refs": [ + "r1" + ], + "value": 40, + "unit": "mm" + }, + { + "type": "radius", + "refs": [ + "t1" + ], + "value": 50, + "unit": "mm" + }, + { + "type": "radius", + "refs": [ + "f1" + ], + "value": 15, + "unit": "mm" + }, + { + "type": "angle", + "refs": [ + "l0", + "s1" + ], + "value": 15, + "unit": "deg", + "text_at": [ + 2, + 20 + ] + }, + { + "type": "distance", + "refs": [ + "r1.end", + "f2.end" + ], + "value": 8.4, + "unit": "mm", + "orientation": "horizontal" + }, + { + "type": "distance", + "refs": [ + "f1.end", + "t1.end" + ], + "value": 5, + "unit": "mm", + "orientation": "horizontal" + } + ] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Tooth", + "loops": [ + { + "outer": true, + "curves": [ + "f1", + "f2", + "r1", + "r2", + "s1", + "s2", + "t1" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 8, + "direction": "positive" + }, + "result_bodies": [ + "Tooth" + ] + } + ] +} diff --git a/tests/oracle/65-sketch-gear-tooth/reference.stl b/tests/oracle/65-sketch-gear-tooth/reference.stl new file mode 100644 index 00000000..9b724321 Binary files /dev/null and b/tests/oracle/65-sketch-gear-tooth/reference.stl differ diff --git a/tests/oracle/66-sketch-linkage/expected.json b/tests/oracle/66-sketch-linkage/expected.json new file mode 100644 index 00000000..0b3127b8 --- /dev/null +++ b/tests/oracle/66-sketch-linkage/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "solved sketch: hull of circles R12 at (0,0) and R8 at (60,0) (outer tangent lines, cos a = 4/60) minus holes d12, d8 (concentric) and d4 at (30,+-4) (symmetry keeps radii equal); prism h6", + "body_count": 1, + "volume_mm3": 8045.386394390547, + "area_mm2": 4308.174742415074, + "center_of_mass": [ + 26.196311954241445, + -2.1175331398316964e-16, + 3.0000000000000004 + ], + "bbox": { + "min": [ + -12.0, + -12.0, + 0.0 + ], + "max": [ + 68.0, + 12.0, + 6.0 + ] + }, + "faces": 10 +} diff --git a/tests/oracle/66-sketch-linkage/recipe.json b/tests/oracle/66-sketch-linkage/recipe.json new file mode 100644 index 00000000..68f42934 --- /dev/null +++ b/tests/oracle/66-sketch-linkage/recipe.json @@ -0,0 +1,374 @@ +{ + "id": "66-sketch-linkage", + "name": "Sketch solver: symmetric link", + "description": "Link with R12 and R8 end arcs 60 apart joined by tangent lines, concentric d12/d8 holes, and two d4 holes symmetric about the construction centre line; fully constrained; extruded 6.", + "exercises": [ + "sketch_only", + "sketch_constraints", + "tangent", + "concentric", + "symmetric", + "offset_dim" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Link", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "arc_three_point", + "start": { + "at": [ + 2, + 11 + ] + }, + "mid": [ + -13, + 1 + ], + "end": { + "at": [ + 1, + -12 + ] + }, + "id": "a1" + }, + { + "type": "arc_three_point", + "start": { + "at": [ + 58, + -7 + ] + }, + "mid": [ + 69, + 1 + ], + "end": { + "at": [ + 59, + 8 + ] + }, + "id": "a2" + }, + { + "type": "line", + "start": { + "ref": "a2.end", + "at": [ + 59.0, + 8.0 + ] + }, + "end": { + "ref": "a1.start", + "at": [ + 2.0, + 11.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "a1.end", + "at": [ + 1.0, + -12.0 + ] + }, + "end": { + "ref": "a2.start", + "at": [ + 58.0, + -7.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "a1.center", + "at": [ + -1.246268657, + -0.380597015 + ] + }, + "end": { + "ref": "a2.center", + "at": [ + 61.079617834, + 0.328025478 + ] + }, + "id": "lc", + "construction": true + }, + { + "type": "circle", + "center": { + "ref": "a1.center", + "at": [ + -1.246268657, + -0.380597015 + ] + }, + "radius": 5, + "id": "h1" + }, + { + "type": "circle", + "center": { + "ref": "a2.center", + "at": [ + 61.079617834, + 0.328025478 + ] + }, + "radius": 3.5, + "id": "h2" + }, + { + "type": "circle", + "center": { + "at": [ + 28, + 4 + ] + }, + "radius": 2.5, + "id": "h3" + }, + { + "type": "circle", + "center": { + "at": [ + 31, + -5 + ] + }, + "radius": 1.5, + "id": "h4" + } + ], + "constraints": [ + { + "type": "coincident", + "refs": [ + "a1.center", + "origin" + ] + }, + { + "type": "horizontal", + "refs": [ + "lc" + ] + }, + { + "type": "tangent", + "refs": [ + "l1", + "a1" + ] + }, + { + "type": "tangent", + "refs": [ + "l1", + "a2" + ] + }, + { + "type": "tangent", + "refs": [ + "l2", + "a1" + ] + }, + { + "type": "tangent", + "refs": [ + "l2", + "a2" + ] + }, + { + "type": "symmetric", + "refs": [ + "h3", + "h4", + "lc" + ] + } + ], + "dimensions": [ + { + "type": "distance", + "refs": [ + "a1.center", + "a2.center" + ], + "value": 60, + "unit": "mm", + "orientation": "horizontal" + }, + { + "type": "radius", + "refs": [ + "a1" + ], + "value": 12, + "unit": "mm" + }, + { + "type": "radius", + "refs": [ + "a2" + ], + "value": 8, + "unit": "mm" + }, + { + "type": "diameter", + "refs": [ + "h1" + ], + "value": 12, + "unit": "mm" + }, + { + "type": "diameter", + "refs": [ + "h2" + ], + "value": 8, + "unit": "mm" + }, + { + "type": "diameter", + "refs": [ + "h3" + ], + "value": 4, + "unit": "mm" + }, + { + "type": "distance", + "refs": [ + "a1.center", + "h3.center" + ], + "value": 30, + "unit": "mm", + "orientation": "horizontal" + }, + { + "type": "offset", + "refs": [ + "lc", + "h3.center" + ], + "value": 4, + "unit": "mm" + } + ] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Link", + "loops": [ + { + "outer": true, + "curves": [ + "a1", + "a2", + "l1", + "l2" + ] + }, + { + "outer": false, + "curves": [ + "h1" + ] + }, + { + "outer": false, + "curves": [ + "h2" + ] + }, + { + "outer": false, + "curves": [ + "h3" + ] + }, + { + "outer": false, + "curves": [ + "h4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 6, + "direction": "positive" + }, + "result_bodies": [ + "Link" + ] + } + ] +} diff --git a/tests/oracle/67-sm-base-flange/expected.json b/tests/oracle/67-sm-base-flange/expected.json new file mode 100644 index 00000000..85705908 --- /dev/null +++ b/tests/oracle/67-sm-base-flange/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "plate 100 x 60 x t, t = 2.5 (Steel (mm) rule), thickness along +Z: V = 100*60*t, A = 2(ab+bc+ca)", + "body_count": 1, + "volume_mm3": 15000.0, + "area_mm2": 12800.0, + "center_of_mass": [ + 50.0, + 30.0, + 1.25 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 100.0, + 60.0, + 2.5 + ] + }, + "faces": 6 +} diff --git a/tests/oracle/67-sm-base-flange/recipe.json b/tests/oracle/67-sm-base-flange/recipe.json new file mode 100644 index 00000000..0c15b251 --- /dev/null +++ b/tests/oracle/67-sm-base-flange/recipe.json @@ -0,0 +1,148 @@ +{ + "id": "67-sm-base-flange", + "name": "Sheet metal: base flange", + "description": "100x60 rectangle -> base flange (Steel (mm) rule, 2.5 mm).", + "exercises": [ + "sheet_metal", + "base_flange", + "flat_pattern", + "dxf" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 100, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 100.0, + 0.0 + ] + }, + "end": { + "at": [ + 100, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 100.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "base", + "name": "BaseFlange", + "profile": { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "orientation": "Side 1 (default): thickness grows along the sketch normal (+Z)", + "operation": "new body", + "rule": "Steel (mm)" + } + ] +} diff --git a/tests/oracle/68-sm-edge-flange/expected.json b/tests/oracle/68-sm-edge-flange/expected.json new file mode 100644 index 00000000..265c5203 --- /dev/null +++ b/tests/oracle/68-sm-edge-flange/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "extrusion along x (100) of the YZ section: base y 5..60 x t; 90 deg bend annulus R 2.5..5 about (y 5, z 5); flange y 0..2.5, z 5..20. Setback (R+t)tan45 = 5 (inside bend position, outer-face height datum: outer face of flange reaches z = 20); V = L*A_sec, A = 2 A_sec + L P_sec", + "body_count": 1, + "volume_mm3": 18972.621556370217, + "area_mm2": 16057.549676223574, + "center_of_mass": [ + 50.0, + 23.996671893539865, + 3.572504424184866 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 100.0, + 60.0, + 20.0 + ] + }, + "note": "trim/setback and height datum are SolidCraft's documented edge-flange conventions (sheet.rs add_flanges)" +} diff --git a/tests/oracle/68-sm-edge-flange/recipe.json b/tests/oracle/68-sm-edge-flange/recipe.json new file mode 100644 index 00000000..d1bedbcc --- /dev/null +++ b/tests/oracle/68-sm-edge-flange/recipe.json @@ -0,0 +1,179 @@ +{ + "id": "68-sm-edge-flange", + "name": "Sheet metal: one edge flange", + "description": "100x60 base + 20 mm edge flange on the y=0 top edge.", + "exercises": [ + "sheet_metal", + "edge_flange", + "bend", + "flat_pattern" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 100, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 100.0, + 0.0 + ] + }, + "end": { + "at": [ + 100, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 100.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "base", + "name": "BaseFlange", + "profile": { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "orientation": "Side 1 (default): thickness grows along the sketch normal (+Z)", + "operation": "new body", + "rule": "Steel (mm)" + }, + { + "op": "sm_flange", + "type": "edge", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 0.0, + 2.5 + ], + "end": [ + 100.0, + 0.0, + 2.5 + ], + "point": [ + 50.0, + 0.0, + 2.5 + ] + } + ], + "edge_selection": "top edge at y=0", + "height": 20, + "angle": 90, + "height_datum": "Outer Faces (default)", + "bend_position": "Inside (default)", + "bend_radius": "rule (default)", + "flip": false + } + ] +} diff --git a/tests/oracle/69-sm-tray/expected.json b/tests/oracle/69-sm-tray/expected.json new file mode 100644 index 00000000..2deb505d --- /dev/null +++ b/tests/oracle/69-sm-tray/expected.json @@ -0,0 +1,26 @@ +{ + "source": "analytic", + "method": "tray: base 90 x 50 x 2.5 (each edge trimmed by the setback 5); four 90 deg bends R 2.5..5 along the trimmed edges (90, 50, 90, 50 long); four walls 2.5 thick, z 5..25, each running on past its bend by ext = (R+t) - (t + gap/sqrt2) = 0.732233047 at both ends; open corners. A = sum of prisms - 2 x joints", + "body_count": 1, + "volume_mm3": 29666.23357665006, + "area_mm2": 24980.085907711007, + "center_of_mass": [ + 49.99999999999999, + 29.999999999999996, + 8.051722040173603 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 100.0, + 60.0, + 25.0 + ] + }, + "faces": 46, + "note": "corner treatment is SolidCraft's convention (sheet.rs close_corners: panels extended until their inner corners are the rule gap = t apart; no corner fill, open corner relief below the walls)" +} diff --git a/tests/oracle/69-sm-tray/recipe.json b/tests/oracle/69-sm-tray/recipe.json new file mode 100644 index 00000000..b6403884 --- /dev/null +++ b/tests/oracle/69-sm-tray/recipe.json @@ -0,0 +1,233 @@ +{ + "id": "69-sm-tray", + "name": "Sheet metal: tray (4 flanges, corner reliefs)", + "description": "100x60 base + 25 mm edge flanges on all 4 top edges in one command (2-bend corners).", + "exercises": [ + "sheet_metal", + "edge_flange", + "corner_relief", + "flat_pattern" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 100, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 100.0, + 0.0 + ] + }, + "end": { + "at": [ + 100, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 100.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "base", + "name": "BaseFlange", + "profile": { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "orientation": "Side 1 (default): thickness grows along the sketch normal (+Z)", + "operation": "new body", + "rule": "Steel (mm)" + }, + { + "op": "sm_flange", + "type": "edge", + "edges": [ + { + "type": "line", + "start": [ + 100.0, + 60.0, + 2.5 + ], + "end": [ + 0.0, + 60.0, + 2.5 + ], + "point": [ + 50.0, + 60.0, + 2.5 + ] + }, + { + "type": "line", + "start": [ + 100.0, + 0.0, + 2.5 + ], + "end": [ + 100.0, + 60.0, + 2.5 + ], + "point": [ + 100.0, + 30.0, + 2.5 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 2.5 + ], + "end": [ + 100.0, + 0.0, + 2.5 + ], + "point": [ + 50.0, + 0.0, + 2.5 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 60.0, + 2.5 + ], + "end": [ + 0.0, + 0.0, + 2.5 + ], + "point": [ + 0.0, + 30.0, + 2.5 + ] + } + ], + "edge_selection": "all 4 top edges of the base", + "height": 25, + "angle": 90, + "height_datum": "Outer Faces (default)", + "bend_position": "Inside (default)", + "bend_radius": "rule (default)", + "flip": false + } + ] +} diff --git a/tests/oracle/70-sm-flange-45/expected.json b/tests/oracle/70-sm-flange-45/expected.json new file mode 100644 index 00000000..c609b50f --- /dev/null +++ b/tests/oracle/70-sm-flange-45/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "as 68 with a 45 deg bend: setback (R+t)tan22.5 = 2.071068, base y 2.071068..60, bend annulus 2.5..5 over 45 deg about (y 2.071068, z 5), flat leg 15 - setback = 12.928932 turned 45 deg; extrusion along x 100", + "body_count": 1, + "volume_mm3": 18450.776872252372, + "area_mm2": 15629.637035246944, + "center_of_mass": [ + 50.0, + 23.482458906299712, + 2.253104112858446 + ], + "bbox": { + "min": [ + 0.0, + -10.606601717798213, + 0.0 + ], + "max": [ + 100.0, + 60.0, + 12.37436867076458 + ] + }, + "note": "SolidCraft convention: flange height (15) is measured along the flange's outer face from the virtual sharp at the original edge (y 0, z 0), so the outer top corner is at (y, z) = (-15 cos45, 15 sin45)." +} diff --git a/tests/oracle/70-sm-flange-45/recipe.json b/tests/oracle/70-sm-flange-45/recipe.json new file mode 100644 index 00000000..db966280 --- /dev/null +++ b/tests/oracle/70-sm-flange-45/recipe.json @@ -0,0 +1,178 @@ +{ + "id": "70-sm-flange-45", + "name": "Sheet metal: 45 deg flange", + "description": "100x60 base + 15 mm edge flange on y=0 edge, flange angle parameter set to 45 deg.", + "exercises": [ + "sheet_metal", + "edge_flange", + "flange_angle" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 100, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 100.0, + 0.0 + ] + }, + "end": { + "at": [ + 100, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 100.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "base", + "name": "BaseFlange", + "profile": { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "orientation": "Side 1 (default): thickness grows along the sketch normal (+Z)", + "operation": "new body", + "rule": "Steel (mm)" + }, + { + "op": "sm_flange", + "type": "edge", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 0.0, + 2.5 + ], + "end": [ + 100.0, + 0.0, + 2.5 + ], + "point": [ + 50.0, + 0.0, + 2.5 + ] + } + ], + "edge_selection": "top edge at y=0", + "height": 15, + "angle": 45, + "height_datum": "Outer Faces (default)", + "bend_position": "Inside (default)", + "bend_radius": "rule (default)", + "flip": false + } + ] +} diff --git a/tests/oracle/71-sm-custom-rule/expected.json b/tests/oracle/71-sm-custom-rule/expected.json new file mode 100644 index 00000000..ab349155 --- /dev/null +++ b/tests/oracle/71-sm-custom-rule/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "U channel, rule t 1.5, R 3, K 0.33 (K unused folded): base y 4.5..45.5 x 1.5; two 90 deg bend annuli R 3..4.5; flanges y 0..1.5 and 48.5..50, z 4.5..20; extrusion along x 80", + "body_count": 1, + "volume_mm3": 10053.716694115408, + "area_mm2": 13896.29850950676, + "center_of_mass": [ + 40.0, + 24.999999999999996, + 5.19228129375018 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 50.0, + 20.0 + ] + }, + "note": "the rule edit applies to the whole sheet body (base built before it is 1.5 thick too, as the recipe's edge picks at z = 1.5 state)" +} diff --git a/tests/oracle/71-sm-custom-rule/recipe.json b/tests/oracle/71-sm-custom-rule/recipe.json new file mode 100644 index 00000000..86f9d071 --- /dev/null +++ b/tests/oracle/71-sm-custom-rule/recipe.json @@ -0,0 +1,207 @@ +{ + "id": "71-sm-custom-rule", + "name": "Sheet metal: custom rule (t=1.5, K=0.33, R=3)", + "description": "Rule edited to thickness 1.5, K 0.33, bend radius 3 mm; 80x50 base + 20 mm flanges on both long edges (U channel).", + "exercises": [ + "sheet_metal", + "rule", + "k_factor", + "bend_radius", + "flat_pattern" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 80, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + 0.0 + ] + }, + "end": { + "at": [ + 80, + 50 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 80.0, + 50.0 + ] + }, + "end": { + "at": [ + 0, + 50 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 50.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "base", + "name": "BaseFlange", + "profile": { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "orientation": "Side 1 (default): thickness grows along the sketch normal (+Z)", + "operation": "new body", + "rule": "Steel (mm)" + }, + { + "op": "sheet_metal_rule_edit", + "rule": "Steel (mm)", + "set": { + "thickness": "1.5 mm", + "kFactor": "0.33", + "bendRadius": "3 mm" + } + }, + { + "op": "sm_flange", + "type": "edge", + "edges": [ + { + "type": "line", + "start": [ + 80.0, + 50.0, + 1.5 + ], + "end": [ + 0.0, + 50.0, + 1.5 + ], + "point": [ + 40.0, + 50.0, + 1.5 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 1.5 + ], + "end": [ + 80.0, + 0.0, + 1.5 + ], + "point": [ + 40.0, + 0.0, + 1.5 + ] + } + ], + "edge_selection": "both long top edges (y=0 and y=50)", + "height": 20, + "angle": 90, + "height_datum": "Outer Faces (default)", + "bend_position": "Inside (default)", + "bend_radius": "rule (default)", + "flip": false + } + ] +} diff --git a/tests/oracle/72-sm-contour-flange/expected.json b/tests/oracle/72-sm-contour-flange/expected.json new file mode 100644 index 00000000..c450d469 --- /dev/null +++ b/tests/oracle/72-sm-contour-flange/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "contour flange, width 50 along +Y, XZ section: plate x 0..35 z 0..2.5; bend R 2.5..5 about (35, 5) (sketch corner = outer virtual sharp, setback R+t); wall x 37.5..40 z 5..27.5; bend R 2.5..5 about (42.5, 27.5) (sketch corner = inner virtual sharp, setback R); plate x 42.5..70 z 30..32.5", + "body_count": 1, + "volume_mm3": 12097.621556370217, + "area_mm2": 10412.00210735098, + "center_of_mass": [ + 36.037689993681276, + 25.0, + 15.087581425863405 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 70.0, + 50.0, + 32.5 + ] + }, + "note": "SolidCraft convention: the sketch lines are the sheet face away from the material (material right of travel); thickness grows to the material side" +} diff --git a/tests/oracle/72-sm-contour-flange/recipe.json b/tests/oracle/72-sm-contour-flange/recipe.json new file mode 100644 index 00000000..b5a4c8bd --- /dev/null +++ b/tests/oracle/72-sm-contour-flange/recipe.json @@ -0,0 +1,123 @@ +{ + "id": "72-sm-contour-flange", + "name": "Sheet metal: contour flange", + "description": "Open Z-shaped sketch on XZ (40 / 30 / 30) -> contour flange, width 50 (typed).", + "exercises": [ + "sheet_metal", + "contour_flange", + "bends" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Contour", + "plane": { + "ref": "XZ", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 0.0, + -1.0 + ], + "normal": [ + 0.0, + 1.0, + 0.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0.0, + 0.0 + ] + }, + "end": { + "at": [ + 40.0, + 0.0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40.0, + -30.0 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + -30.0 + ] + }, + "end": { + "at": [ + 70.0, + -30.0 + ] + }, + "id": "l3" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "contour", + "name": "ContourFlange", + "profile_curves": [ + "l1", + "l2", + "l3" + ], + "profile_sketch": "Contour", + "distance": 50, + "orientation": "Side 1 (default)", + "direction": "One Side (default): width along the XZ sketch normal (+Y)", + "bend_radius": "rule (2.50 mm = Thickness)", + "rule": "Steel (mm)" + } + ] +} diff --git a/tests/oracle/73-sm-hem/expected.json b/tests/oracle/73-sm-hem/expected.json new file mode 100644 index 00000000..fa334ffb --- /dev/null +++ b/tests/oracle/73-sm-hem/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "68's part (height 20) plus a flat hem on the flange top edge: 180 deg bend, inner radius = hem gap 0.01, outer 2.51, about (y 2.51, z 20), folded toward +y (inside); hem leg 8 - (0.01 + 2.5) = 5.49: y 2.52..5.02, z 14.51..20; extrusion along x 100", + "body_count": 1, + "volume_mm3": 21334.723242251002, + "area_mm2": 17994.473058645817, + "center_of_mass": [ + 50.0, + 21.698805014882137, + 5.264118547850942 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 100.0, + 60.0, + 22.51 + ] + }, + "note": "SolidCraft conventions: hem gap 0.01 mm (rule hem_gap), hem length measured to the outer face of the hem bend (top at z 22.51), hem folds over the flange's inner face." +} diff --git a/tests/oracle/73-sm-hem/recipe.json b/tests/oracle/73-sm-hem/recipe.json new file mode 100644 index 00000000..7ef6b6c3 --- /dev/null +++ b/tests/oracle/73-sm-hem/recipe.json @@ -0,0 +1,204 @@ +{ + "id": "73-sm-hem", + "name": "Sheet metal: flat hem", + "description": "100x60 base + 20 mm edge flange on y=0; flat hem (length 8) on the flange's outer top edge via HemFeatures.", + "exercises": [ + "sheet_metal", + "edge_flange", + "hem" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 100, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 100.0, + 0.0 + ] + }, + "end": { + "at": [ + 100, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 100.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "base", + "name": "BaseFlange", + "profile": { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "orientation": "Side 1 (default): thickness grows along the sketch normal (+Z)", + "operation": "new body", + "rule": "Steel (mm)" + }, + { + "op": "sm_flange", + "type": "edge", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 0.0, + 2.5 + ], + "end": [ + 100.0, + 0.0, + 2.5 + ], + "point": [ + 50.0, + 0.0, + 2.5 + ] + } + ], + "edge_selection": "top edge at y=0", + "height": 20, + "angle": 90, + "height_datum": "Outer Faces (default)", + "bend_position": "Inside (default)", + "bend_radius": "rule (default)", + "flip": false + }, + { + "op": "sm_hem", + "type": "flat", + "edge": { + "type": "line", + "start": [ + 100.0, + -0.0, + 20.0 + ], + "end": [ + 0.0, + -0.0, + 20.0 + ], + "point": [ + 50.0, + -0.0, + 20.0 + ] + }, + "edge_selection": "straight edge at the top of the flange (z max); outer (min y) tried first", + "length": 8, + "flipped": false, + "bend_position": "StartEdge" + } + ] +} diff --git a/tests/oracle/74-sm-unfold-refold/expected.json b/tests/oracle/74-sm-unfold-refold/expected.json new file mode 100644 index 00000000..3b86e80d --- /dev/null +++ b/tests/oracle/74-sm-unfold-refold/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "U channel 80x50 (t 2.5, R 2.5, base y 5..45, flanges z 5..20) with the flat-pattern slot x 35..45, y -12..10 refolded: BA = (pi/2)(R + K t) = 5.654866776, so the slot removes base y 5..10, the whole y=0 bend and the flange from z 5 to 5 + (12 - (BA - 5)) = 16.345133224; x-segments 0..35 / 35..45 / 45..80", + "body_count": 1, + "volume_mm3": 15800.304003966867, + "area_mm2": 13610.326162673397, + "center_of_mass": [ + 40.0, + 25.774257210144707, + 5.531072022389276 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 50.0, + 20.0 + ] + }, + "note": "unfold/refold maps flat distance through the K-factor neutral layer (K 0.44)" +} diff --git a/tests/oracle/74-sm-unfold-refold/recipe.json b/tests/oracle/74-sm-unfold-refold/recipe.json new file mode 100644 index 00000000..c5dfa488 --- /dev/null +++ b/tests/oracle/74-sm-unfold-refold/recipe.json @@ -0,0 +1,350 @@ +{ + "id": "74-sm-unfold-refold", + "name": "Sheet metal: unfold, cut across bend, refold", + "description": "80x50 base + 20 mm flanges on both long edges; unfold all bends (stationary = base); 10x22 slot (x 35..45, y -12..10) cut across the y=0 bend while flat; refold.", + "exercises": [ + "sheet_metal", + "unfold", + "refold", + "cut_across_bend" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 80, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + 0.0 + ] + }, + "end": { + "at": [ + 80, + 50 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 80.0, + 50.0 + ] + }, + "end": { + "at": [ + 0, + 50 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 50.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "base", + "name": "BaseFlange", + "profile": { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "orientation": "Side 1 (default): thickness grows along the sketch normal (+Z)", + "operation": "new body", + "rule": "Steel (mm)" + }, + { + "op": "sm_flange", + "type": "edge", + "edges": [ + { + "type": "line", + "start": [ + 80.0, + 50.0, + 2.5 + ], + "end": [ + 0.0, + 50.0, + 2.5 + ], + "point": [ + 40.0, + 50.0, + 2.5 + ] + }, + { + "type": "line", + "start": [ + 0.0, + 0.0, + 2.5 + ], + "end": [ + 80.0, + 0.0, + 2.5 + ], + "point": [ + 40.0, + 0.0, + 2.5 + ] + } + ], + "edge_selection": "both long top edges", + "height": 20, + "angle": 90, + "height_datum": "Outer Faces (default)", + "bend_position": "Inside (default)", + "bend_radius": "rule (default)", + "flip": false + }, + { + "op": "sm_unfold", + "stationary_face": { + "type": "plane", + "normal_at_point_on_face": [ + 0.0, + 0.0, + -1.0 + ], + "plane_normal_outward": [ + 0.0, + 0.0, + -1.0 + ], + "point": [ + 40.0, + 25.0, + 0.0 + ] + }, + "all_bends": true + }, + { + "op": "sketch", + "name": "SlotSketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 35, + -12 + ] + }, + "end": { + "at": [ + 45, + -12 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 45.0, + -12.0 + ] + }, + "end": { + "at": [ + 45, + 10 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 45.0, + 10.0 + ] + }, + "end": { + "at": [ + 35, + 10 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 35.0, + 10.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 35.0, + -12.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Slot", + "operation": "cut", + "profiles": [ + { + "sketch": "SlotSketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Body1" + ] + }, + { + "op": "sm_refold", + "unfold": "Unfold1" + } + ] +} diff --git a/tests/oracle/75-sm-bracket-holes/expected.json b/tests/oracle/75-sm-bracket-holes/expected.json new file mode 100644 index 00000000..352f1946 --- /dev/null +++ b/tests/oracle/75-sm-bracket-holes/expected.json @@ -0,0 +1,26 @@ +{ + "source": "analytic", + "method": "base x 0..75, y 5..40, z 0..2.5 (trimmed 5 by each flange's setback); bend 1 (R 2.5..5 about y 5, z 5) x 0..75; flange 1 y 0..2.5, z 5..30, x 0..80; bend 2 (R 2.5..5 about x 75, z 5) y 5..40; flange 2 x 77.5..80, z 5..15, y 5..40; minus two d6 through holes at (25,20), (55,20) in the base. A = sum of prism areas - 2 x 4 joint areas (75t, 75t, 35t, 35t) + hole walls - hole discs", + "body_count": 1, + "volume_mm3": 13916.012042595696, + "area_mm2": 12035.962275939059, + "center_of_mass": [ + 42.447144354989774, + 13.304901335083745, + 7.787102510037218 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 80.0, + 40.0, + 30.0 + ] + }, + "note": "SolidCraft convention: the second flange trims the base under the first bend, so bend 1 stops at x 75 while flange 1's panel runs to x 80 (open corner relief, no fill).", + "known_failure": "#296" +} diff --git a/tests/oracle/75-sm-bracket-holes/recipe.json b/tests/oracle/75-sm-bracket-holes/recipe.json new file mode 100644 index 00000000..7ca403cc --- /dev/null +++ b/tests/oracle/75-sm-bracket-holes/recipe.json @@ -0,0 +1,300 @@ +{ + "id": "75-sm-bracket-holes", + "name": "Sheet metal: bracket with holes for DXF", + "description": "80x40 base, 30 mm flange on y=0 edge and 15 mm flange on x=80 edge; two d6 holes in the base.", + "exercises": [ + "sheet_metal", + "edge_flange", + "holes", + "dxf", + "bend_lines" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 80, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 80.0, + 0.0 + ] + }, + "end": { + "at": [ + 80, + 40 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 80.0, + 40.0 + ] + }, + "end": { + "at": [ + 0, + 40 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 40.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "base", + "name": "BaseFlange", + "profile": { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "orientation": "Side 1 (default): thickness grows along the sketch normal (+Z)", + "operation": "new body", + "rule": "Steel (mm)" + }, + { + "op": "sm_flange", + "type": "edge", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 0.0, + 2.5 + ], + "end": [ + 80.0, + 0.0, + 2.5 + ], + "point": [ + 40.0, + 0.0, + 2.5 + ] + } + ], + "edge_selection": "top edge at y=0", + "height": 30, + "angle": 90, + "height_datum": "Outer Faces (default)", + "bend_position": "Inside (default)", + "bend_radius": "rule (default)", + "flip": false + }, + { + "op": "sm_flange", + "type": "edge", + "edges": [ + { + "type": "line", + "start": [ + 80.0, + 5.0, + 2.5 + ], + "end": [ + 80.0, + 40.0, + 2.5 + ], + "point": [ + 80.0, + 22.5, + 2.5 + ] + } + ], + "edge_selection": "top edge at x=80 of the base", + "height": 15, + "angle": 90, + "height_datum": "Outer Faces (default)", + "bend_position": "Inside (default)", + "bend_radius": "rule (default)", + "flip": false + }, + { + "op": "sketch", + "name": "Holes", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "circle", + "center": { + "at": [ + 25, + 20 + ] + }, + "radius": 3, + "id": "c1" + }, + { + "type": "circle", + "center": { + "at": [ + 55, + 20 + ] + }, + "radius": 3, + "id": "c2" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Holes", + "operation": "cut", + "profiles": [ + { + "sketch": "Holes", + "loops": [ + { + "outer": true, + "curves": [ + "c1" + ] + } + ] + }, + { + "sketch": "Holes", + "loops": [ + { + "outer": true, + "curves": [ + "c2" + ] + } + ] + } + ], + "extent": { + "type": "through_all", + "direction": "positive" + }, + "result_bodies": [ + "Body1" + ] + } + ] +} diff --git a/tests/oracle/76-sm-thickness-variant/expected.json b/tests/oracle/76-sm-thickness-variant/expected.json new file mode 100644 index 00000000..779c94f0 --- /dev/null +++ b/tests/oracle/76-sm-thickness-variant/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "final rule thickness 3 (bend radius = Thickness = 3): base y 6..60 x 3; bend annulus R 3..6 about (y 6, z 6); flange y 0..3, z 6..20; extrusion along x 100", + "body_count": 1, + "volume_mm3": 22520.575041173113, + "area_mm2": 16064.12819493887, + "center_of_mass": [ + 50.0, + 24.30326264966137, + 3.788688791962308 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 100.0, + 60.0, + 20.0 + ] + }, + "note": "the rule's thickness edits regenerate the whole sheet body; only the final 3 mm state matters" +} diff --git a/tests/oracle/76-sm-thickness-variant/recipe.json b/tests/oracle/76-sm-thickness-variant/recipe.json new file mode 100644 index 00000000..8e431c8c --- /dev/null +++ b/tests/oracle/76-sm-thickness-variant/recipe.json @@ -0,0 +1,187 @@ +{ + "id": "76-sm-thickness-variant", + "name": "Sheet metal: thickness change", + "description": "100x60 base + 20 mm flange; rule thickness 2.5 mm, then changed to 1 mm and then to 3 mm after the build (the final state is 3 mm).", + "exercises": [ + "sheet_metal", + "rule", + "thickness", + "recompute" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Base", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 100, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 100.0, + 0.0 + ] + }, + "end": { + "at": [ + 100, + 60 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 100.0, + 60.0 + ] + }, + "end": { + "at": [ + 0, + 60 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 60.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "sm_flange", + "type": "base", + "name": "BaseFlange", + "profile": { + "sketch": "Base", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + }, + "orientation": "Side 1 (default): thickness grows along the sketch normal (+Z)", + "operation": "new body", + "rule": "Steel (mm)" + }, + { + "op": "sm_flange", + "type": "edge", + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 0.0, + 2.5 + ], + "end": [ + 100.0, + 0.0, + 2.5 + ], + "point": [ + 50.0, + 0.0, + 2.5 + ] + } + ], + "edge_selection": "top edge at y=0", + "height": 20, + "angle": 90, + "height_datum": "Outer Faces (default)", + "bend_position": "Inside (default)", + "bend_radius": "rule (default)", + "flip": false + }, + { + "op": "sheet_metal_rule_edit", + "rule": "Steel (mm)", + "sequence": [ + "thickness 1 mm", + "thickness 3 mm" + ] + } + ] +} diff --git a/tests/oracle/README.md b/tests/oracle/README.md new file mode 100644 index 00000000..001e0688 --- /dev/null +++ b/tests/oracle/README.md @@ -0,0 +1,42 @@ +# Reference tests (the oracle) + +Each directory is one case: + +- `recipe.json`: the part, written in SolidCraft's recipe format (sketches in sketch-local + coordinates, features with their parameters, faces and edges picked by a world-space point). + `cargo xtask oracle` replays it through SolidCraft's command engine. +- `expected.json`: what the part must measure, and where those numbers come from. +- `reference.stl` (optional): a fine triangulation of the expected part, used to check the + shape itself (surface distance), not only its totals. + +Run every case with `cargo xtask oracle`; it writes `docs/oracle.md`. + +## expected.json + +```json +{ + "source": "analytic", + "method": "box 40 x 30 x 20: V = abc, A = 2(ab + bc + ca)", + "body_count": 1, + "volume_mm3": 24000.0, + "area_mm2": 5200.0, + "center_of_mass": [20.0, 15.0, 10.0], + "bbox": { "min": [0.0, 0.0, 0.0], "max": [40.0, 30.0, 20.0] }, + "faces": 6 +} +``` + +| Field | Meaning | +|---|---| +| `source` | `"analytic"` (closed-form geometry, worked out by hand; `method` gives the formulas) or `"freecad "` (built from the same recipe by `tools/freecad-oracle/`). | +| `method` | How the numbers were obtained: formulas for analytic cases, the construction for FreeCAD ones. | +| `body_count` | Number of solid bodies. | +| `volume_mm3`, `area_mm2` | Totals over all bodies. | +| `center_of_mass` | Volume-weighted centroid of all bodies (uniform density). | +| `bbox` | Axis-aligned bounding box of all bodies. | +| `faces` | Optional. Only where the geometry fixes the count: a face is a maximal smooth patch of one surface (a full cylinder side is one face; coplanar neighbours are one face). | +| `compare` | Optional. Restricts the comparison to these keys (for example `["volume_mm3", "area_mm2"]` where kernels legitimately build different corner patches); `note` says why. | +| `tolerance` | Optional overrides: `rel` (volume, area; default 1e-3), `abs_mm` (centre of mass and bbox, default 1e-3 × the bbox diagonal), `surface_mm` (STL surface distance). | +| `note` | Optional remarks. | + +No value here was measured in, or exported from, any commercial CAD product. diff --git a/tools/freecad-oracle/.gitignore b/tools/freecad-oracle/.gitignore new file mode 100644 index 00000000..036ce378 --- /dev/null +++ b/tools/freecad-oracle/.gitignore @@ -0,0 +1,3 @@ +squashfs-root/ +*.AppImage +__pycache__/ diff --git a/tools/freecad-oracle/README.md b/tools/freecad-oracle/README.md new file mode 100644 index 00000000..cec7e3ef --- /dev/null +++ b/tools/freecad-oracle/README.md @@ -0,0 +1,57 @@ +# FreeCAD oracle + +Reference values for some of the cases in [`tests/oracle/`](../../tests/oracle/) are computed by +building the case's own `recipe.json` a second time, in a different kernel: FreeCAD +(OpenCASCADE). Those cases have `"source": "freecad "` in their `expected.json`. +The cases with closed-form answers are worked out by hand instead (`"source": "analytic"`, +see [`tools/oracle-analytic/`](../oracle-analytic/)). + +## FreeCAD is an external tool, not a dependency + +FreeCAD is run as a separate program (`freecadcmd`) that executes `build.py` through FreeCAD's +public Python scripting API (Part, PartDesign, Sketcher, MeshPart). SolidCraft does not use, +link, vendor or ship any code, library or binary from FreeCAD or OpenCASCADE, and nothing in +this directory is part of the build. FreeCAD's LGPL licence therefore does not apply to +SolidCraft's code. The scripts here are our own; they were written against FreeCAD's public +API documentation only, never its sources. + +Only values for our own recipes are produced: the numbers in `expected.json` (body count, +volume, area, centre of mass, bounding box) and the triangulated surface in `reference.stl`. + +## Files + +- `build.py`: runs inside FreeCAD. Reads a case's `recipe.json` and builds the part with + FreeCAD's modelling operations, following the meaning SolidCraft gives the recipe + (`apps/solvecraft-cli/src/recipe.rs`): sketches (solved by FreeCAD's sketcher when the recipe + constrains them, otherwise at their recorded coordinates), extrude (distance, symmetric, two + sides, through all, taper), revolve, sweep, loft, holes (simple, drilled tip, counterbore, + countersink), rectangular/circular/path patterns and mirror (each source feature's tool moved + and its operation applied again), combine, shell, draft, fillet and chamfer (edges picked + nearest the recipe's point, tangent chains followed), split body and construction planes. + It then measures the result. +- `emit.py`: prints a result or writes it as `expected.json` / `reference.stl`. +- `run.sh`: the entry point. + +## Running it + +```sh +tools/freecad-oracle/run.sh tests/oracle/17-loft tests/oracle/42-overlapping-patterns # print +tools/freecad-oracle/run.sh --write tests/oracle/17-loft # write +``` + +Without `--write` each case prints one line. With `--write` it writes `/expected.json` +(`source`, `method`, `body_count`, `volume_mm3`, `area_mm2`, `center_of_mass`, `bbox` from +`optimalBoundingBox()`) and `/reference.stl` (binary, linear deflection 0.005 to 0.02 mm, +at most about 400 KB; when that is not possible the STL is skipped and `note` says so). It never +overwrites an `expected.json` whose source is `analytic`. Per-case comparison settings +(`compare`, `tolerance`, `note`) live in `CASE_META` in `build.py`. + +`freecadcmd` is found through `$FREECADCMD`, then `freecadcmd` on `PATH`, then an AppImage +extracted in this directory (`squashfs-root/`, ignored by git). Locally: install FreeCAD 1.x from your distribution or extract the +AppImage (`./FreeCAD_*.AppImage --appimage-extract`) and point `FREECADCMD` at +`squashfs-root/usr/bin/freecadcmd`. In CI (Debian/Ubuntu): `apt install freecad`, then run the +script; it needs no display. + +The values were produced with FreeCAD 1.1.4. A different FreeCAD version may differ in the last +digits; regenerate only when a recipe changes, and check the new values against SolidCraft +(`solvecraft-cli oracle`) before committing them. diff --git a/tools/freecad-oracle/build.py b/tools/freecad-oracle/build.py new file mode 100644 index 00000000..eef0f5b9 --- /dev/null +++ b/tools/freecad-oracle/build.py @@ -0,0 +1,1402 @@ +# FreeCAD oracle builder for SolidCraft reference cases (tests/oracle//recipe.json). +# +# Run under FreeCAD's interpreter (freecadcmd); see run.sh. FreeCAD is used only as an external +# program through its public Python scripting API (Part, PartDesign, Sketcher, Mesh/MeshPart). +# +# Inputs (environment): +# ORACLE_CASE case directory (contains recipe.json) +# ORACLE_OUT path of a JSON file to write the measured result to +# ORACLE_STL optional: path of a binary STL to write (reference.stl) +# +# The recipe format and its meaning are SolidCraft's (apps/solvecraft-cli/src/recipe.rs and the +# engine in crates/); this file re-implements that meaning with FreeCAD's modelling operations. + +import json +import math +import os +import re +import sys +import traceback + +import FreeCAD as App +import Part +from FreeCAD import Vector as V + +TOL = 1e-7 +BIG = 1000.0 + +# Per-case additions to expected.json: "compare", "tolerance", "note", "method_extra". +# (Empty: every case written so far is built by the generic mapping and agrees with SolidCraft +# within the default tolerances, so no comparison is relaxed.) +CASE_META = {} + + +def vec(a): + return V(float(a[0]), float(a[1]), float(a[2]) if len(a) > 2 else 0.0) + + +class RecipeError(Exception): + pass + + +# -------------------------------------------------------------------------------------------- +# Expressions (user parameters) +# -------------------------------------------------------------------------------------------- + +class Exprs: + def __init__(self, params): + self.vals = {} + for p in params or []: + self.vals[p["name"]] = self.eval(p.get("expression", p.get("value"))) + + def eval(self, v, kind="length"): + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + s = str(v).strip() + s = re.sub(r"(?<=[0-9.\s)])\s*(mm|deg|°)\b", "", s) + s = s.replace("°", "") + names = dict(self.vals) + names.update({"pi": math.pi, "sqrt": math.sqrt, "sin": math.sin, "cos": math.cos, "tan": math.tan}) + return float(eval(s, {"__builtins__": {}}, names)) + + +# -------------------------------------------------------------------------------------------- +# Sketches +# -------------------------------------------------------------------------------------------- + +class Plane: + def __init__(self, origin, xd, yd, normal=None): + self.o = vec(origin) + self.x = vec(xd).normalize() + self.y = vec(yd).normalize() + n = self.x.cross(self.y) + self.n = n.normalize() + if normal is not None: + given = vec(normal) + if given.Length > 0 and given.normalize().dot(self.n) < 0.999: + raise RecipeError("sketch plane normal disagrees with x_dir x y_dir") + + def w(self, p): + return self.o + self.x * float(p[0]) + self.y * float(p[1]) + + def placement(self): + m = App.Matrix(self.x.x, self.y.x, self.n.x, self.o.x, + self.x.y, self.y.y, self.n.y, self.o.y, + self.x.z, self.y.z, self.n.z, self.o.z, + 0, 0, 0, 1) + return App.Placement(m) + + +def circumcenter(a, b, c): + """2D circumcentre of three points (tuples).""" + ax, ay = a + bx, by = b + cx, cy = c + d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) + if abs(d) < 1e-14: + raise RecipeError("collinear 3-point arc") + ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d + uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d + return (ux, uy) + + +def ccw3(a, b, c): + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) > 0 + + +class Sketch: + """A solved sketch: world-space edges per entity id and named points.""" + + def __init__(self, f, ex): + pl = f["plane"] + self.name = f.get("name") + self.plane = Plane(pl.get("origin", [0, 0, 0]), pl["x_dir"], pl["y_dir"], pl.get("normal")) + self.ents = {e["id"]: e for e in f.get("entities", [])} + self.order = [e["id"] for e in f.get("entities", [])] + self.edges = {} # id -> Part.Edge (world) + self.pts = {"origin": (0.0, 0.0)} # "id.start" etc -> local 2D + self.ex = ex + if f.get("constraints") or f.get("dimensions"): + self.solved = True + self._solve_with_sketcher(f) + else: + self.solved = False + self._direct() + + # -- direct (unconstrained): coordinates as recorded, shared points reused ----------------- + def _p(self, spec): + if spec is None: + return None + if isinstance(spec, list): + return (float(spec[0]), float(spec[1])) + r = spec.get("ref") + if r and r in self.pts: + return self.pts[r] + at = spec.get("at") + return (float(at[0]), float(at[1])) + + def _direct(self): + P = self.plane + for i in self.order: + e = self.ents[i] + t = e["type"] + if t == "line": + a, b = self._p(e["start"]), self._p(e["end"]) + self.pts[i + ".start"], self.pts[i + ".end"] = a, b + self.edges[i] = Part.LineSegment(P.w(a), P.w(b)).toShape() + elif t == "circle": + c = self._p(e["center"]) + r = self.ex.eval(e["radius"]) + self.pts[i + ".center"] = c + self.edges[i] = Part.Circle(P.w(c), P.n, r).toShape() + elif t in ("arc_three_point", "arc") and e.get("center") is None: + a, b = self._p(e["start"]), self._p(e["end"]) + m = self._p(e.get("mid") or e.get("through")) + self.pts[i + ".start"], self.pts[i + ".end"] = a, b + self.pts[i + ".center"] = circumcenter(a, m, b) + self.edges[i] = Part.Arc(P.w(a), P.w(m), P.w(b)).toShape() + elif t in ("arc_center_start_sweep", "arc"): + c, a = self._p(e["center"]), self._p(e["start"]) + r = math.hypot(a[0] - c[0], a[1] - c[1]) + a0 = math.atan2(a[1] - c[1], a[0] - c[0]) + if t == "arc": + b = self._p(e["end"]) + a1 = math.atan2(b[1] - c[1], b[0] - c[0]) + sw = (a1 - a0) % (2 * math.pi) + else: + sw = math.radians(self.ex.eval(e.get("sweep_deg", e.get("sweep")))) + b = (c[0] + r * math.cos(a0 + sw), c[1] + r * math.sin(a0 + sw)) + m = (c[0] + r * math.cos(a0 + sw / 2), c[1] + r * math.sin(a0 + sw / 2)) + self.pts[i + ".start"], self.pts[i + ".end"], self.pts[i + ".center"] = a, b, c + self.edges[i] = Part.Arc(P.w(a), P.w(m), P.w(b)).toShape() + elif t == "point": + self.pts[i] = self.pts[i + ".start"] = self._p(e.get("at") if isinstance(e.get("at"), list) else e) + elif t == "fitted_spline": + fp = e.get("fit_points", []) + if fp and len(fp[0]) == 3: + self.edges[i] = ("helix3d", [vec(p) for p in fp]) + else: + pts = [P.w(p) for p in fp] + bs = Part.BSplineCurve() + bs.interpolate(pts) + self.edges[i] = bs.toShape() + else: + raise RecipeError("sketch entity type %s not supported" % t) + + # -- constrained: FreeCAD's sketch solver --------------------------------------------------- + def _solve_with_sketcher(self, f): + import Sketcher + doc = App.newDocument("sk") + sk = doc.addObject("Sketcher::SketchObject", "S") + gid = {} # entity id -> geo index + pos = {} # "id.start" -> (geo, posid) + pos["origin"] = (-1, 1) + coinc = [] # (refname_a, refname_b) + swapped = {} + + def at(spec): + if isinstance(spec, list): + return (float(spec[0]), float(spec[1])) + a = spec.get("at") + return (float(a[0]), float(a[1])) + + def V2(p): + return V(p[0], p[1], 0) + + for i in self.order: + e = self.ents[i] + t = e["type"] + cons = bool(e.get("construction", False)) + if t == "line": + g = Part.LineSegment(V2(at(e["start"])), V2(at(e["end"]))) + k = sk.addGeometry(g, cons) + pos[i + ".start"], pos[i + ".end"] = (k, 1), (k, 2) + for nm in ("start", "end"): + if isinstance(e[nm], dict) and e[nm].get("ref"): + coinc.append((i + "." + nm, e[nm]["ref"])) + elif t == "circle": + g = Part.Circle(V2(at(e["center"])), V(0, 0, 1), self.ex.eval(e["radius"])) + k = sk.addGeometry(g, cons) + pos[i + ".center"] = (k, 3) + if isinstance(e["center"], dict) and e["center"].get("ref"): + coinc.append((i + ".center", e["center"]["ref"])) + elif t in ("arc_three_point", "arc_center_start_sweep", "arc"): + if t == "arc_center_start_sweep" or (t == "arc" and e.get("center") is not None): + c, a = at(e["center"]), at(e["start"]) + r = math.hypot(a[0] - c[0], a[1] - c[1]) + a0 = math.atan2(a[1] - c[1], a[0] - c[0]) + if t == "arc": + b = at(e["end"]) + sw = (math.atan2(b[1] - c[1], b[0] - c[0]) - a0) % (2 * math.pi) + else: + sw = math.radians(self.ex.eval(e.get("sweep_deg", e.get("sweep")))) + ccw = sw > 0 + s0, s1 = (a0, a0 + sw) if ccw else (a0 + sw, a0) + else: + a, b = at(e["start"]), at(e["end"]) + m = at(e.get("mid") if isinstance(e.get("mid"), list) else (e.get("mid") or e.get("through"))) + c = circumcenter(a, m, b) + r = math.hypot(a[0] - c[0], a[1] - c[1]) + ccw = ccw3(a, m, b) + aa = math.atan2(a[1] - c[1], a[0] - c[0]) + bb = math.atan2(b[1] - c[1], b[0] - c[0]) + if ccw: + s0, s1 = aa, aa + (bb - aa) % (2 * math.pi) + else: + s0, s1 = bb, bb + (aa - bb) % (2 * math.pi) + g = Part.ArcOfCircle(Part.Circle(V2(c), V(0, 0, 1), r), s0, s1) + k = sk.addGeometry(g, cons) + swapped[i] = not ccw + pos[i + ".start"] = (k, 1) if ccw else (k, 2) + pos[i + ".end"] = (k, 2) if ccw else (k, 1) + pos[i + ".center"] = (k, 3) + for nm in ("start", "end", "center"): + if isinstance(e.get(nm), dict) and e[nm].get("ref"): + coinc.append((i + "." + nm, e[nm]["ref"])) + elif t == "point": + p = e["at"] if isinstance(e.get("at"), list) else at(e) + k = sk.addGeometry(Part.Point(V2(p)), cons) + pos[i] = pos[i + ".start"] = (k, 1) + else: + raise RecipeError("constrained sketch entity type %s not supported" % t) + gid[i] = k + + # union-find of points known to coincide + parent = {} + + def find(x): + parent.setdefault(x, x) + while parent[x] != x: + x = parent[x] + return x + + def union(a, b): + parent[find(a)] = find(b) + + def key(r): + return pos[r] if r in pos else None + + explicit = [] + for c in f.get("constraints", []): + if c["type"].lower() == "coincident": + a, b = c["refs"] + if a in pos and b in pos: + explicit.append((a, b)) + for a, b in coinc + explicit: + union(key(a), key(b)) + + cons_list = [] + drop_coinc = set() + + def geo_of(r): + if r in gid: + return gid[r] + return pos[r][0] + + def ispt(r): + return r in pos and r not in gid + + def ptof(r): + # a circle/arc named where a point is wanted: its centre + if r in pos: + return pos[r] + if r + ".center" in pos: + return pos[r + ".center"] + raise RecipeError("unknown point " + r) + + def ends(eid): + out = [] + for nm in ("start", "end"): + if eid + "." + nm in pos: + out.append(eid + "." + nm) + return out + + for c in f.get("constraints", []): + t = c["type"].lower() + r = c.get("refs", []) + if t in ("horizontal", "vertical") and len(r) == 1: + cons_list.append(Sketcher.Constraint("Horizontal" if t == "horizontal" else "Vertical", gid[r[0]])) + elif t in ("horizontal", "vertical", "horizontal_points", "vertical_points"): + mode = "Horizontal" if t.startswith("horizontal") else "Vertical" + (g1, p1), (g2, p2) = ptof(r[0]), ptof(r[1]) + cons_list.append(Sketcher.Constraint(mode, g1, p1, g2, p2)) + elif t in ("coincident", "point_on_curve"): + a, b = r + if ispt(a) and ispt(b): + coinc.append((a, b)) + else: + if not ispt(a): + a, b = b, a + g, p = ptof(a) + cons_list.append(Sketcher.Constraint("PointOnObject", g, p, gid[b])) + elif t == "midpoint": + a, b = r + if not ispt(a): + a, b = b, a + g, p = ptof(a) + cons_list.append(Sketcher.Constraint("Symmetric", gid[b], 1, gid[b], 2, g, p)) + elif t in ("symmetric", "symmetry"): + a, b, ln = r + if ispt(a) and ispt(b): + (g1, p1), (g2, p2) = ptof(a), ptof(b) + cons_list.append(Sketcher.Constraint("Symmetric", g1, p1, g2, p2, gid[ln])) + elif self.ents[a]["type"] == "line" and self.ents[b]["type"] == "line": + # two lines: their end points mirror + for nm in ("start", "end"): + (g1, p1), (g2, p2) = pos[a + "." + nm], pos[b + "." + nm] + cons_list.append(Sketcher.Constraint("Symmetric", g1, p1, g2, p2, gid[ln])) + else: + # two circles/arcs: mirrored centres and equal radii + (g1, p1), (g2, p2) = ptof(a), ptof(b) + cons_list.append(Sketcher.Constraint("Symmetric", g1, p1, g2, p2, gid[ln])) + cons_list.append(Sketcher.Constraint("Equal", gid[a], gid[b])) + elif t == "tangent": + a, b = r + pair = None + for pa in ends(a): + for pb in ends(b): + if find(pos[pa]) == find(pos[pb]): + pair = (pa, pb) + if pair: + (g1, p1), (g2, p2) = pos[pair[0]], pos[pair[1]] + cons_list.append(Sketcher.Constraint("Tangent", g1, p1, g2, p2)) + drop_coinc.add(frozenset(pair)) + else: + cons_list.append(Sketcher.Constraint("Tangent", gid[a], gid[b])) + elif t == "equal": + cons_list.append(Sketcher.Constraint("Equal", gid[r[0]], gid[r[1]])) + elif t == "parallel": + cons_list.append(Sketcher.Constraint("Parallel", gid[r[0]], gid[r[1]])) + elif t == "perpendicular": + cons_list.append(Sketcher.Constraint("Perpendicular", gid[r[0]], gid[r[1]])) + elif t == "concentric": + coinc.append((r[0] + ".center", r[1] + ".center")) + elif t == "collinear": + cons_list.append(Sketcher.Constraint("Parallel", gid[r[0]], gid[r[1]])) + cons_list.append(Sketcher.Constraint("PointOnObject", gid[r[1]], 1, gid[r[0]])) + elif t == "fix": + cons_list.append(Sketcher.Constraint("Block", gid[r[0]])) + else: + raise RecipeError("constraint %s not supported" % t) + + def cur(gp): + g, p = gp + if g == -1: + return (0.0, 0.0) + v = sk.getPoint(g, p) + return (v.x, v.y) + + for d in f.get("dimensions", []): + t = d["type"].lower() + r = d.get("refs", d.get("entities")) + val = self.ex.eval(d["value"]) + ori = d.get("orientation", "aligned") + if t in ("distance", "length"): + if len(r) == 1: + r = [r[0] + ".start", r[0] + ".end"] + A, B = ptof(r[0]), ptof(r[1]) + pa, pb = cur(A), cur(B) + if ori == "horizontal": + if pb[0] < pa[0]: + A, B = B, A + cons_list.append(Sketcher.Constraint("DistanceX", A[0], A[1], B[0], B[1], val)) + elif ori == "vertical": + if pb[1] < pa[1]: + A, B = B, A + cons_list.append(Sketcher.Constraint("DistanceY", A[0], A[1], B[0], B[1], val)) + else: + cons_list.append(Sketcher.Constraint("Distance", A[0], A[1], B[0], B[1], val)) + elif t == "offset": + a, b = r + if not ispt(a) and a + ".center" not in pos or a in gid and self.ents[a]["type"] == "line": + a, b = b, a + g, p = ptof(a) + cons_list.append(Sketcher.Constraint("Distance", g, p, gid[b], val)) + elif t == "radius": + cons_list.append(Sketcher.Constraint("Radius", gid[r[0]], val)) + elif t == "diameter": + cons_list.append(Sketcher.Constraint("Diameter", gid[r[0]], val)) + elif t == "angle": + g1, g2 = gid[r[0]], gid[r[1]] + d1 = sk.Geometry[g1].EndPoint - sk.Geometry[g1].StartPoint + d2 = sk.Geometry[g2].EndPoint - sk.Geometry[g2].StartPoint + curang = math.atan2(d1.x * d2.y - d1.y * d2.x, d1.x * d2.x + d1.y * d2.y) + v = math.radians(val) + cands = [v, -v, math.pi - v, -(math.pi - v)] + best = min(cands, key=lambda q: abs(math.remainder(q - curang, 2 * math.pi))) + cons_list.append(Sketcher.Constraint("Angle", g1, g2, best)) + else: + raise RecipeError("dimension %s not supported" % t) + + seen = set() + for a, b in coinc: + if frozenset((a, b)) in drop_coinc: + continue + A, B = key(a), key(b) + if A is None or B is None: + raise RecipeError("unknown point ref %s / %s" % (a, b)) + k = frozenset((A, B)) + if k in seen or A == B: + continue + seen.add(k) + cons_list.insert(0, Sketcher.Constraint("Coincident", A[0], A[1], B[0], B[1])) + sk.addConstraint(cons_list) + rc = sk.solve() + doc.recompute() + if rc != 0: + raise RecipeError("sketch %s: FreeCAD sketch solver failed (%d)" % (self.name, rc)) + P = self.plane + M = P.placement().toMatrix() + for i in self.order: + g = sk.Geometry[gid[i]] + e = self.ents[i] + if e["type"] == "point": + p = g.X, g.Y + self.pts[i] = self.pts[i + ".start"] = (p[0], p[1]) + continue + sh = g.toShape() + sh.transformShape(M) + self.edges[i] = sh + for nm in ("start", "end", "center"): + if i + "." + nm in pos: + v = sk.getPoint(*pos[i + "." + nm]) + self.pts[i + "." + nm] = (v.x, v.y) + App.closeDocument(doc.Name) + + # -- profiles -------------------------------------------------------------------------------- + def loop_wire(self, ids): + edges = [] + for i in ids: + e = self.edges.get(i) + if e is None: + raise RecipeError("unknown curve %s in sketch %s" % (i, self.name)) + edges.append(e.copy()) + w = Part.Wire(Part.__sortEdges__(edges)) + if not w.isClosed(): + # fix tiny gaps (recorded coordinates rounded) + w = Part.Wire(Part.__sortEdges__(edges)) + w.fixWire(None, 1e-5) + if not w.isClosed(): + raise RecipeError("loop %s of sketch %s is not closed" % (ids, self.name)) + return w + + def regions(self): + """The sketch's minimal regions (faces of the arrangement of its curves), each with the + set of curve ids its outer boundary runs along.""" + if getattr(self, "_regions", None) is not None: + return self._regions + import BOPTools.SplitAPI as SA + ids = [i for i in self.order if i in self.edges and not isinstance(self.edges[i], tuple) + and not self.ents[i].get("construction")] + edges = [self.edges[i] for i in ids] + c = self.plane.o + big = Part.Plane(c, self.plane.n).toShape(-1e4, 1e4, -1e4, 1e4) + frag = SA.booleanFragments([big] + edges, "Standard", 1e-7) + out = [] + for fc in frag.Faces: + ow = fc.OuterWire + if any(v.Point.distanceToPoint(c) > 5e3 for v in ow.Vertexes): + continue # the unbounded outside + used = set() + for e in ow.Edges: + m = e.valueAt((e.FirstParameter + e.LastParameter) / 2) + for i, x in zip(ids, edges): + if x.distToShape(Part.Vertex(m))[0] < 1e-6: + used.add(i) + out.append((fc, used)) + self._regions = out + return out + + def profile_face(self, prof): + try: + return self._profile_face_loops(prof) + except Exception: + pass + # Loops that are not closed chains of whole curves (e.g. pieces of circles between + # tangent lines): the region whose outer boundary runs along exactly these curves. + want = set(c for lp in prof.get("loops", []) if lp.get("outer", True) for c in lp["curves"]) + hits = [fc for fc, used in self.regions() if used == want] + if len(hits) != 1: + raise RecipeError("profile %s of sketch %s matches %d regions" % (sorted(want), self.name, len(hits))) + return [hits[0]] + + def _profile_face_loops(self, prof): + outer, inner = None, [] + for lp in prof.get("loops", []): + if len(set(lp["curves"])) != len(lp["curves"]): + raise RecipeError("curve repeated in a loop") + if len(lp["curves"]) > 1 and any(self.edges[c].isClosed() for c in lp["curves"]): + raise RecipeError("a full circle in a multi-curve loop") + f = Part.Face(self.loop_wire(lp["curves"])) + if lp.get("outer", True): + outer = f if outer is None else outer.fuse(f) + else: + inner.append(f) + for h in inner: + outer = outer.cut(h) + faces = outer.Faces + return faces + + +# -------------------------------------------------------------------------------------------- +# The model +# -------------------------------------------------------------------------------------------- + +def xform(shape, m): + """Rigid motion or reflection of a shape (exact; no conversion to B-splines).""" + if m.determinant() < 0: + # reflection: m = mirror about a plane; recover it (n from the eigenvector of -1) + import numpy + a = numpy.array([[m.A11, m.A12, m.A13], [m.A21, m.A22, m.A23], [m.A31, m.A32, m.A33]]) + w, vv = numpy.linalg.eig(a) + k = int(numpy.argmin(w.real)) + n = V(*[float(x) for x in vv[:, k].real]).normalize() + t = V(m.A14, m.A24, m.A34) + o = n * (t.dot(n) / 2) + return shape.mirror(o, n) + s = shape.copy() + s.transformShape(m) + return s + + +def solids_of(s): + return list(s.Solids) if s is not None else [] + + +def compound(shapes): + sol = [] + for s in shapes: + sol += solids_of(s) + return Part.makeCompound(sol) + + +def refine(s): + try: + # removeSplitter() can damage the shape it is called on: work on a deep copy + r = s.copy().removeSplitter() + if r.isValid() and s.isValid() and abs(r.Volume - s.Volume) < 1e-6 * max(1.0, abs(s.Volume)): + return r + except Exception: + pass + return s + + +def as_solid_shape(s): + sol = s.Solids + if len(sol) == 1: + return sol[0] + return Part.makeCompound(sol) + + +def nearest(items, p, key=None): + vp = Part.Vertex(p) + best, bd = None, None + for it in items: + d = (key(it) if key else it).distToShape(vp)[0] + if bd is None or d < bd - 1e-9: + best, bd = it, d + return best, bd + + +def edge_tangent_at(e, v): + """Unit tangent of edge e at vertex point v, oriented along the edge.""" + u = e.FirstParameter if (e.Vertexes[0].Point - v).Length < (e.Vertexes[-1].Point - v).Length else e.LastParameter + t = e.tangentAt(u) + return t.normalize() if t.Length > 0 else t + + +def tangent_chain(shape, seeds): + """Grow edge selection along G1-continuous neighbours.""" + edges = list(shape.Edges) + sel = [e for e in seeds] + changed = True + while changed: + changed = False + for e in edges: + if any(e.isSame(s) for s in sel): + continue + for s in sel: + for va in e.Vertexes: + for vb in s.Vertexes: + if va.isSame(vb): + ta = edge_tangent_at(e, va.Point) + tb = edge_tangent_at(s, vb.Point) + if abs(abs(ta.dot(tb)) - 1) < 1e-6: + sel.append(e) + changed = True + break + if changed: + break + if changed: + break + return sel + + +class Model: + def __init__(self, recipe): + self.r = recipe + self.ex = Exprs(recipe.get("parameters")) + self.sketches = {} + self.planes = { + "XY": (V(0, 0, 0), V(0, 0, 1)), + "XZ": (V(0, 0, 0), V(0, 1, 0)), + "YZ": (V(0, 0, 0), V(1, 0, 0)), + } + self.bodies = {} # name -> shape + self.order = [] + self.feats = {} # feature name -> replay record (for patterns / mirror) + self.notes = [] + + # ---- bodies -------------------------------------------------------------------------------- + def set_body(self, name, shape): + if name not in self.bodies: + self.order.append(name) + self.bodies[name] = shape + + def all_shape(self): + return compound([self.bodies[n] for n in self.order if self.bodies.get(n) is not None]) + + def size(self): + s = self.all_shape() + if not s.Solids: + return 100.0 + return s.BoundBox.DiagonalLength + + def targets(self, f, tool=None): + names = [n for n in f.get("result_bodies", []) if n in self.bodies] + if names: + return names[:1] if f.get("operation") == "join" else names + if tool is not None: + hit = [n for n in self.order if self.bodies[n] is not None and self.bodies[n].common(tool).Volume > 1e-9] + if hit: + return hit + return list(self.order) + + def apply(self, op, tools, targets, new_name=None): + tool = tools[0] + for t in tools[1:]: + tool = tool.fuse(t) + if op == "new": + self.set_body(new_name or "Body%d" % (len(self.order) + 1), as_solid_shape(refine(tool))) + return + if op == "join": + if not targets: + self.set_body(new_name or "Body%d" % (len(self.order) + 1), tool) + return + n0 = targets[0] + s = self.bodies[n0] + for n in targets[1:]: + s = s.fuse(self.bodies.pop(n)) + self.order.remove(n) + self.bodies[n0] = as_solid_shape(refine(s.fuse(tool))) + return + for n in targets: + s = self.bodies[n] + if op == "cut": + r = s.cut(tool) + elif op == "intersect": + r = s.common(tool) + else: + raise RecipeError("operation " + op) + self.bodies[n] = as_solid_shape(refine(r)) + + # ---- selections ------------------------------------------------------------------------------ + def pick_edges(self, shape, f, transform=None): + out = [] + + def tp(v): + return transform.multiply(v) if transform is not None else v + + for e in f.get("edges", []): + p = vec(e.get("point") or e.get("mid")) + if transform is not None: + p = transform.multiply(p) + best, d = nearest(shape.Edges, p) + # ties (the point sits on a vertex shared with other edges, e.g. a cylinder seam): + # prefer the edge that also passes through the recipe's start/end and is the same kind + vp = Part.Vertex(p) + cands = [x for x in shape.Edges if x.distToShape(vp)[0] <= d + 1e-6] + if len(cands) > 1: + kind = {"line": "Line", "circle": "Circle", "arc": "Circle"}.get(e.get("type")) + + def score(x): + sc = 0.0 + for k in ("start", "end", "center"): + if k in e and k != "center": + sc += x.distToShape(Part.Vertex(tp(vec(e[k]))))[0] + if kind and x.Curve.__class__.__name__ != kind: + sc += 1e6 + return sc + + best = min(cands, key=score) + if d > 1e-3 * max(1.0, self.size()) + 1e-3: + self.notes.append("edge pick at %s is %.3g mm from the nearest edge" % (p, d)) + sel = [best] + # The recipe's edge runs start..end; if FreeCAD split it, take the G1 neighbours that + # complete it. + if "start" in e and "end" in e: + a, b = tp(vec(e["start"])), tp(vec(e["end"])) + closed = (a - b).Length < 1e-6 + + def spans(es): + w = es + vs = [] + for x in w: + vs += [v.Point for v in x.Vertexes] + if closed: + # a closed run: a closed edge, or every vertex used twice + if len(es) == 1 and es[0].isClosed(): + return True + return all(sum(1 for q in vs if (q - v).Length < 1e-6) % 2 == 0 for v in vs) + ok_a = any((q - a).Length < 1e-4 for q in vs) + ok_b = any((q - b).Length < 1e-4 for q in vs) + return ok_a and ok_b + + if not spans(sel) and e.get("type") in ("line", "arc", "circle", "nurbs"): + sel = tangent_chain(shape, sel) + if f.get("tangent_chain"): + sel = tangent_chain(shape, sel) + for s in sel: + if not any(s.isSame(o) for o in out): + out.append(s) + return out + + def pick_face(self, shape, p): + best, d = nearest(shape.Faces, vec(p)) + return best + + def body_with(self, p): + best, d = None, None + for n in self.order: + s = self.bodies[n] + if s is None: + continue + dd = s.distToShape(Part.Vertex(p))[0] + if d is None or dd < d: + best, d = n, dd + return best + + # ---- features -------------------------------------------------------------------------------- + def profiles(self, f): + profs = f.get("profiles") + if profs is None and f.get("profile") is not None: + profs = [f["profile"]] + faces = [] + sk = None + for p in profs or []: + sk = self.sketches[p["sketch"]] + faces += sk.profile_face(p) + if not faces: + raise RecipeError("feature %s: no profiles" % f.get("name")) + return faces, sk + + def extrude_tools(self, f): + faces, sk = self.profiles(f) + n = sk.plane.n + e = f.get("extent", {}) + ty = e.get("type", "distance") + d = self.ex.eval(e.get("distance", f.get("distance"))) + direction = e.get("direction", "positive") + if ty == "symmetric": + if e.get("is_full_length"): + d = d / 2 + lo, hi = -d, d + elif ty == "two_sides": + lo, hi = -self.ex.eval(e.get("side2_opposite", e.get("side2"))), self.ex.eval(e.get("side1")) + elif ty == "through_all": + L = 4 * self.size() + BIG + lo, hi = (0, L) if direction == "positive" else (-L, 0) + if direction in ("both", "symmetric"): + lo, hi = -L, L + else: + lo, hi = (0, d) if direction == "positive" else (-d, 0) + if direction == "symmetric": + lo, hi = -d, d + taper = e.get("taper_deg") + tools = [] + for fc in faces: + if taper: + if lo != 0: + raise RecipeError("taper only from the sketch plane") + tools.append(self.tapered(fc, n, hi, math.radians(self.ex.eval(taper)))) + continue + g = fc.copy() + g.translate(n * lo) + tools.append(g.extrude(n * (hi - lo))) + return tools + + def tapered(self, face, n, length, ang): + d = length * math.tan(ang) + bottom = face.OuterWire + off = Part.Face(bottom).makeOffset2D(d, 2, False, False, False) + top = off.OuterWire.copy() + top.translate(n * length) + solid = Part.makeLoft([bottom, top], True, True) + for h in face.Wires[1:]: + hoff = Part.Face(h).makeOffset2D(-d, 2, False, False, False).OuterWire.copy() + hoff.translate(n * length) + solid = solid.cut(Part.makeLoft([h, hoff], True, True)) + return solid + + def revolve_tools(self, f): + faces, sk = self.profiles(f) + a = f.get("axis") + if isinstance(a, dict) and a.get("dir") is not None: + o, d = vec(a.get("origin", [0, 0, 0])), vec(a["dir"]) + else: + ln = a.get("line", a.get("ref")) if isinstance(a, dict) else a + ed = sk.edges[ln] + o, d = ed.Vertexes[0].Point, ed.Vertexes[-1].Point - ed.Vertexes[0].Point + ang = self.ex.eval(f.get("angle", 360)) + return [fc.revolve(o, d, ang) for fc in faces] + + def sweep_tools(self, f): + faces, sk = self.profiles(f) + path = f["path"] + psk = self.sketches[path["sketch"]] + ents = [psk.edges[c] for c in path["curves"]] + if isinstance(ents[0], tuple) and ents[0][0] == "helix3d": + return [self.helix_sweep(faces, ents[0][1])] + pw = Part.Wire(Part.__sortEdges__([e.copy() for e in ents])) + tools = [] + for fc in faces: + outer = pw.makePipeShell([fc.OuterWire], True, True) + for h in fc.Wires[1:]: + outer = outer.cut(pw.makePipeShell([h], True, True)) + tools.append(outer) + return tools + + def helix_sweep(self, faces, pts): + # The fitted 3D spline of the recipe is read as the helix it samples (as SolidCraft does). + n = len(pts) + # least squares circle in xy + sx = sy = sxx = syy = sxy = sxz = syz = sz = 0.0 + A = [[0.0] * 3 for _ in range(3)] + bvec = [0.0] * 3 + for p in pts: + row = [p.x, p.y, 1.0] + rhs = -(p.x * p.x + p.y * p.y) + for i in range(3): + for j in range(3): + A[i][j] += row[i] * row[j] + bvec[i] += row[i] * rhs + import numpy + D, E, F = numpy.linalg.solve(numpy.array(A), numpy.array(bvec)) + cx, cy = -D / 2, -E / 2 + rm = sum(math.hypot(p.x - cx, p.y - cy) for p in pts) / n + tot = 0.0 + prev = math.atan2(pts[0].y - cy, pts[0].x - cx) + start = prev + for p in pts[1:]: + a = math.atan2(p.y - cy, p.x - cx) + d = math.remainder(a - prev, 2 * math.pi) + tot += d + prev = a + turns = abs(tot) / (2 * math.pi) + rise = pts[-1].z - pts[0].z + pitch = abs(rise) / turns + lefthand = (tot < 0) != (rise < 0) + h = Part.makeHelix(pitch, abs(rise), rm, 0, lefthand) + h.rotate(V(0, 0, 0), V(0, 0, 1), math.degrees(start)) + h.translate(V(cx, cy, pts[0].z)) + if rise < 0: + raise RecipeError("downward helix not supported") + hw = Part.Wire(h.Edges) + tools = [] + for fc in faces: + tools.append(hw.makePipeShell([fc.OuterWire], True, True)) + return tools[0] + + def loft_tools(self, f): + secs = [] + for s in f["sections"]: + sk = self.sketches[s["sketch"]] + if s.get("point") is not None: + if s.get("world"): + secs.append(Part.Vertex(vec(s["world"]))) + else: + secs.append(Part.Vertex(sk.plane.w(sk.pts[s["point"]]))) + continue + loops = s.get("loops", []) + w = sk.loop_wire(loops[0]["curves"]) + secs.append(w) + ruled = bool(f.get("ruled", False)) + return [Part.makeLoft(secs, True, ruled, False)] + + def hole_tools(self, f, p, d): + r = self.ex.eval(f["diameter"]) / 2 + size = self.size() + top = max(size * 0.01, 0.1) + through = f.get("extent") == "through_all" or f.get("depth") is None + depth = 4 * size + BIG if through else self.ex.eval(f["depth"]) + ty = f.get("hole_type", "simple") + tip = f.get("tip_angle") + tools = [] + start = p - d * top + + def cyl(rad, dep): + return Part.makeCylinder(rad, dep + top, start, d) + + if ty == "counterbore": + rc = self.ex.eval(f["counterbore_diameter"]) / 2 + dc = self.ex.eval(f["counterbore_depth"]) + tools.append(cyl(rc, dc)) + tools.append(cyl(r, depth)) + elif ty == "countersink": + rc = self.ex.eval(f["countersink_diameter"]) / 2 + half = math.radians(self.ex.eval(f["countersink_angle"])) / 2 + r_top = rc + top * math.tan(half) + ln = (r_top - r * 0.9) / math.tan(half) + tools.append(Part.makeCone(r_top, r * 0.9, ln, start, d)) + tools.append(cyl(r, depth)) + elif tip is not None and not through and ty in ("simple", "drilled"): + half = math.radians(self.ex.eval(tip)) / 2 + tl = r / math.tan(half) + # does the drill exit exactly at the shoulder (a through hole as drilled)? + sh = self.all_shape() + eps = size * 1e-6 + q = lambda h: p + d * h + exits = sh.isInside(q(depth - eps), 1e-9, True) and not sh.isInside(q(depth + eps), 1e-9, True) + tools.append(cyl(r, depth)) + if not exits: + tools.append(Part.makeCone(r, 0, tl, p + d * depth, d)) + else: + tools.append(cyl(r, depth)) + t = tools[0] + for x in tools[1:]: + t = t.fuse(x) + return [refine(t)] + + def on_surface(self, p, d): + sh = self.all_shape() + L = 4 * self.size() + BIG + line = Part.LineSegment(p - d * L, p + d * L).toShape() + sec = sh.section(line) + ts = [(v.Point - p).dot(d) for v in sec.Vertexes] + if not ts: + return p + if any(abs(t) < 1e-6 for t in ts): + return p + if sh.isInside(p, 1e-9, False): + neg = [t for t in ts if t < 0] + return p + d * max(neg) if neg else p + pos = [t for t in ts if t > 0] + return p + d * min(pos) if pos else p + + def hole(self, f): + spots = [] + n = None + fc = f.get("face") + if fc is not None: + nn = fc.get("plane_normal_outward") or fc.get("normal_at_point_on_face") + if nn: + n = vec(nn) * -1 + if f.get("positions_from"): + w = f["positions_from"].split() + sk = self.sketches[w[w.index("sketch") + 1]] + rng = w[w.index("points") + 1] + if ".." in rng: + a, b = rng.split("..") + pre = re.match(r"\D*", a).group(0) + ids = ["%s%d" % (pre, k) for k in range(int(a[len(pre):]), int(b[len(pre):]) + 1)] + else: + ids = rng.split(",") + spots = [sk.plane.w(sk.pts[i]) for i in ids] + n = sk.plane.n * -1 + else: + spots = [vec(f["position"])] + if fc is not None and fc.get("type") == "cylinder": + o, ax, p = vec(fc["axis_origin"]), vec(fc["axis"]).normalize(), spots[0] + dd = p - o + rr = dd - ax * dd.dot(ax) + if rr.Length > 1e-9: + n = rr.normalize() * -1 + if n is None: + raise RecipeError("hole direction unknown") + n = n.normalize() + tools = [] + for p in spots: + p = self.on_surface(p, n) + tools += self.hole_tools(f, p, n) + return tools + + def fillet(self, f, transform=None): + pts = [] + for e in f.get("edges", []): + p = vec(e.get("point") or e.get("mid")) + pts.append(transform.multiply(p) if transform else p) + name = self.body_with(pts[0]) + s = self.bodies[name] + edges = self.pick_edges(s, f, transform) + if f["op"] == "fillet": + r = self.ex.eval(f["radius"]) + out = s.makeFillet(r, edges) + else: + out = s.makeChamfer(self.ex.eval(f["distance"]), edges) + if not out.isValid(): + out.fix(1e-7, 1e-7, 1e-7) + self.bodies[name] = as_solid_shape(out) + + def shell(self, f): + pts = [vec(x["point"]) for x in f.get("faces_removed", [])] + name = self.body_with(pts[0]) + s = self.bodies[name] + solid = s.Solids[0] + faces = [self.pick_face(solid, p) for p in pts] + t = self.ex.eval(f.get("inside_thickness")) + out = solid.makeThickness(faces, -t, 1e-6, False, False, 0, 2) + self.bodies[name] = as_solid_shape(out) + + def draft(self, f): + pts = [vec(x["point"]) for x in f["faces"]] + np_ = f["neutral_plane"] + nrm = vec(np_.get("plane_normal_outward") or np_.get("normal_at_point_on_face") or [0, 0, 1]).normalize() + origin = vec(np_.get("point") or np_.get("centroid") or [0, 0, 0]) + ang = float(f["angle"]) * (-1 if f.get("flipped") else 1) + name = self.body_with(pts[0]) + s = self.bodies[name] + out = self._draft_partdesign(s, pts, origin, nrm, ang) + self.bodies[name] = as_solid_shape(out) + + def _draft_partdesign(self, s, pts, origin, pull, ang): + # SolidCraft: faces lean in (toward the material) by `ang` going along `pull`, pivoting on + # their line in the neutral plane. FreeCAD's PartDesign Draft does the same tilt; which + # of its two senses matches is checked on the result below. + doc = App.newDocument("dr") + base = doc.addObject("Part::Feature", "Base") + base.Shape = s + faces = [self.pick_face(s, p) for p in pts] + names = [] + for fc in faces: + for i, g in enumerate(s.Faces): + if g.isSame(fc): + names.append("Face%d" % (i + 1)) + nface = None + for i, g in enumerate(s.Faces): + if g.Surface.__class__.__name__ == "Plane" and abs(abs(g.normalAt(0, 0).dot(pull)) - 1) < 1e-9 and abs((g.Vertexes[0].Point - origin).dot(pull)) < 1e-9: + nface = "Face%d" % (i + 1) + break + if nface is None: + # a datum plane would do; the recipes use body faces + raise RecipeError("neutral face not found") + body = doc.addObject("PartDesign::Body", "Body") + body.BaseFeature = base + results = [] + for rev in (False, True): + dr = body.newObject("PartDesign::Draft", "Draft%d" % rev) + dr.Base = (body.BaseFeature, names) + dr.NeutralPlane = (body.BaseFeature, [nface]) + dr.Angle = abs(ang) + dr.Reversed = rev + doc.recompute() + sh = dr.Shape.copy() + results.append(sh) + body.removeObject(dr) + doc.removeObject(dr.Name) + # expected: a point a little toward the material from the face at height h > 0 along pull + # ends up outside; pick the variant that does this. + fc0 = faces[0] + u0, u1, v0, v1 = fc0.ParameterRange + c = fc0.valueAt((u0 + u1) / 2, (v0 + v1) / 2) + nf = fc0.normalAt((u0 + u1) / 2, (v0 + v1) / 2) + h = (c - origin).dot(pull) + t = math.tan(math.radians(ang)) + delta = -h * t / 2 # half the expected displacement along the outward normal + q = c + nf * delta + pick = None + for sh in results: + inside = sh.isInside(q, 1e-9, True) + if inside == (delta > 0): + pick = sh + App.closeDocument(doc.Name) + if pick is None: + raise RecipeError("draft: no FreeCAD variant matches the recipe's sense") + return pick + + def split(self, f): + name = f["body"] + tool = f["tool"] + o, n = self.planes[tool] + s = self.bodies[name] + L = 4 * self.size() + BIG + pl = Part.Plane(o, n) + face = pl.toShape(-L, L, -L, L) + import BOPTools.SplitAPI as SA + res = SA.slice(s, [face], "Split") + sols = res.Solids + names = f.get("result_bodies") or [name] + self.bodies.pop(name) + self.order.remove(name) + for i, so in enumerate(sols): + self.set_body(names[i] if i < len(names) else "%s (%d)" % (name, i), so) + + def construction_plane(self, f): + if f.get("method") == "offset": + o, n = self.planes[f["base"]] if isinstance(f["base"], str) else (vec(f["base"]["origin"]), vec(f["base"]["normal"])) + self.planes[f["name"]] = (o + n * self.ex.eval(f["offset"]), n) + else: + self.planes[f["name"]] = (vec(f.get("result_origin", [0, 0, 0])), vec(f["result_normal"]).normalize()) + + def transforms_for(self, f): + op = f["op"] + mats = [] + if op == "rectangular_pattern": + d1 = f["direction1"] + d2 = f.get("direction2") + a1 = vec(d1["axis"]).normalize() + c1, s1 = int(d1["count"]), self.ex.eval(d1["spacing"]) + c2, s2, a2 = 1, 0.0, V(0, 0, 0) + if d2: + a2, c2, s2 = vec(d2["axis"]).normalize(), int(d2["count"]), self.ex.eval(d2["spacing"]) + for j in range(c2): + for i in range(c1): + if i == 0 and j == 0: + continue + m = App.Matrix() + m.move(a1 * (i * s1) + a2 * (j * s2)) + mats.append(m) + elif op == "circular_pattern": + ax = f["axis"] + o, dr = vec(ax.get("origin", [0, 0, 0])), vec(ax["dir"]).normalize() + n = int(f["count"]) + tot = self.ex.eval(f.get("total_angle", 360)) + full = abs(abs(tot) - 360) < 1e-9 + step = tot / n if full else (tot / (n - 1) if n > 1 else 0) + for i in range(1, n): + pl = App.Placement(V(0, 0, 0), App.Rotation(dr, step * i), o) + mats.append(pl.toMatrix()) + elif op == "path_pattern": + path = f["path"] + psk = self.sketches[path["sketch"]] + edges = [psk.edges[c] for c in path["curves"]] + n = int(f["count"]) + sp = self.ex.eval(f["spacing"]) if f.get("spacing") is not None else self.ex.eval(f["distance"]) / (n - 1) + # path(0) is the start of the first curve as drawn; the curves are chained from there + first = edges[0] + p0 = first.valueAt(first.FirstParameter) + if len(edges) > 1: + nxt = edges[1] + ends = [v.Point for v in nxt.Vertexes] + if min((first.valueAt(first.FirstParameter) - q).Length for q in ends) < min((first.valueAt(first.LastParameter) - q).Length for q in ends): + p0 = first.valueAt(first.LastParameter) + for k in range(1, n): + s_ = k * sp + cur, acc, pk = p0, 0.0, None + for e in edges: + fwd = (e.valueAt(e.FirstParameter) - cur).Length < 1e-6 + if acc + e.Length >= s_ - 1e-9 or e is edges[-1]: + ll = min(max(s_ - acc, 0.0), e.Length) + u = e.getParameterByLength(ll if fwd else e.Length - ll) + pk = e.valueAt(u) + break + acc += e.Length + cur = e.valueAt(e.LastParameter if fwd else e.FirstParameter) + m = App.Matrix() + m.move(pk - p0) + mats.append(m) + elif op == "mirror": + pl = f["plane"] + if pl.get("ref") in ("XY", "XZ", "YZ"): + o, n = self.planes[pl["ref"]] + else: + o, n = vec(pl.get("origin", [0, 0, 0])), vec(pl["normal"]) + n = n.normalize() + m = App.Matrix(1 - 2 * n.x * n.x, -2 * n.x * n.y, -2 * n.x * n.z, 0, + -2 * n.y * n.x, 1 - 2 * n.y * n.y, -2 * n.y * n.z, 0, + -2 * n.z * n.x, -2 * n.z * n.y, 1 - 2 * n.z * n.z, 0, + 0, 0, 0, 1) + t = o - m.multiply(o) + m.move(t) + mats.append(m) + return mats + + def replay(self, f, mats): + names = [] + for x in f.get("features", []): + x = re.sub(r" \(\d+\)$", "", x.strip()) + names.append(x) + for m in mats: + for nm in names: + rec = self.feats[nm] + if rec["kind"] == "tool": + tools = [] + for t in rec["tools"]: + tools.append(xform(t, m)) + op = rec["op"] + if op == "new": + op = "join" + tg = [n for n in rec["targets"] if n in self.bodies] or self.targets({"operation": op}) + self.apply(op, tools, tg) + elif rec["kind"] == "edges": + self.fillet(rec["f"], transform=m) + + def mirror(self, f): + mats = self.transforms_for(f) + if f.get("mirror_of") == "bodies": + for b in f["bodies"]: + s = self.bodies[b] + ms = xform(s, mats[0]) + if f.get("combine"): + self.bodies[b] = as_solid_shape(refine(s.fuse(ms))) + else: + self.set_body(b + " (mirror)", ms) + return + self.replay(f, mats) + + def combine(self, f): + tgt = f.get("target_body") or f.get("target") + tools = [self.bodies[t] for t in (f.get("tool_bodies") or f.get("tools"))] + op = f.get("operation", "join") + self.apply(op, tools, [tgt]) + if not f.get("keep_tools"): + for t in (f.get("tool_bodies") or f.get("tools")): + self.bodies.pop(t, None) + self.order.remove(t) + + def run(self): + for f in self.r["features"]: + if os.environ.get("ORACLE_DEBUG"): + print("..", f["op"], f.get("name"), [(n, round(self.bodies[n].Volume, 4), len(self.bodies[n].Solids)) for n in self.order]) + op = f["op"] + name = f.get("name") + if op == "sketch": + self.sketches[name] = Sketch(f, self.ex) + elif op in ("extrude", "revolve", "sweep", "loft", "hole"): + if op == "extrude": + tools = self.extrude_tools(f) + elif op == "revolve": + tools = self.revolve_tools(f) + elif op == "sweep": + tools = self.sweep_tools(f) + elif op == "loft": + tools = self.loft_tools(f) + else: + tools = self.hole(f) + o = "cut" if op == "hole" else f.get("operation", "new") + if o == "new": + rb = f.get("result_bodies") or [name] + self.apply("new", tools, [], rb[0]) + tg = [rb[0]] + else: + tg = self.targets(f, tools[0] if o != "join" else None) + self.apply(o, tools, tg) + self.feats[name] = {"kind": "tool", "op": o, "tools": tools, "targets": tg} + elif op in ("fillet", "chamfer"): + self.fillet(f) + self.feats[name] = {"kind": "edges", "f": f} + elif op == "shell": + self.shell(f) + elif op == "draft": + self.draft(f) + elif op == "construction_plane": + self.construction_plane(f) + elif op == "split_body": + self.split(f) + elif op == "combine": + self.combine(f) + elif op in ("rectangular_pattern", "circular_pattern", "path_pattern"): + if f.get("pattern_of") == "bodies": + raise RecipeError("body patterns not supported") + self.replay(f, self.transforms_for(f)) + elif op == "mirror": + self.mirror(f) + elif op == "thread": + pass # cosmetic thread: no geometry + else: + raise RecipeError("feature op %s not supported" % op) + + +def measure(shape): + sol = shape.Solids + vol = sum(s.Volume for s in sol) + area = sum(s.Area for s in sol) + com = V(0, 0, 0) + for s in sol: + com += s.CenterOfMass * s.Volume + com = com * (1.0 / vol) if vol else com + bb = shape.optimalBoundingBox() + return { + "body_count": len(sol), + "volume_mm3": vol, + "area_mm2": area, + "center_of_mass": [com.x, com.y, com.z], + "bbox": {"min": [bb.XMin, bb.YMin, bb.ZMin], "max": [bb.XMax, bb.YMax, bb.ZMax]}, + "valid": all(s.isValid() for s in sol), + "faces": len(shape.Faces), + } + + +def write_stl(shape, path, limit=400 * 1024): + import MeshPart + for lin in (0.005, 0.01, 0.015, 0.02): + m = MeshPart.meshFromShape(Shape=shape, LinearDeflection=lin, AngularDeflection=0.2, Relative=False) + size = 84 + 50 * m.CountFacets + if size <= limit: + m.write(path) + return {"stl_deflection_mm": lin, "stl_bytes": size, "stl_facets": m.CountFacets} + return {"stl_skipped": "over %d bytes even at 0.02 mm deflection (%d facets)" % (limit, m.CountFacets)} + + +def main(): + case = os.environ["ORACLE_CASE"] + out = os.environ["ORACLE_OUT"] + stl = os.environ.get("ORACLE_STL") or None + res = {"case": os.path.basename(case.rstrip("/"))} + try: + recipe = json.load(open(os.path.join(case, "recipe.json"))) + m = Model(recipe) + m.run() + shape = m.all_shape() + res.update(measure(shape)) + res["version"] = ".".join(App.Version()[:3]) + res["notes"] = m.notes + res["method"] = construction_note(recipe) + meta = CASE_META.get(recipe.get("id"), {}) + for k in ("compare", "tolerance", "note", "method_extra"): + if k in meta: + res[k] = meta[k] + if stl: + res.update(write_stl(shape, stl)) + except Exception as e: + res["error"] = "%s: %s" % (type(e).__name__, e) + res["trace"] = traceback.format_exc() + with open(out, "w") as fh: + json.dump(res, fh, indent=1) + + +def construction_note(recipe): + ops = [] + for f in recipe["features"]: + op = f["op"] + if op in ("sketch", "construction_plane"): + if op == "sketch" and (f.get("constraints") or f.get("dimensions")): + ops.append("Sketcher-solved sketch") + continue + word = { + "extrude": "Part extrude", + "revolve": "Part revolve", + "loft": "Part.makeLoft", + "sweep": "makePipeShell sweep", + "hole": "hole tool (cylinder/cone) cut", + "fillet": "Part makeFillet", + "chamfer": "Part makeChamfer", + "shell": "makeThickness shell", + "draft": "PartDesign Draft", + "rectangular_pattern": "pattern (tools transformed and re-applied)", + "circular_pattern": "pattern (tools transformed and re-applied)", + "path_pattern": "pattern (tools transformed and re-applied)", + "mirror": "mirror", + "combine": "boolean combine", + "split_body": "split by plane", + }.get(op, op) + if op == "extrude" and f.get("operation") in ("cut", "join", "intersect"): + word += " " + f["operation"] + if not ops or ops[-1] != word: + ops.append(word) + return "FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: " + ", ".join(ops) + + +if not os.environ.get("ORACLE_NO_MAIN"): + main() diff --git a/tools/freecad-oracle/emit.py b/tools/freecad-oracle/emit.py new file mode 100644 index 00000000..6472d9f6 --- /dev/null +++ b/tools/freecad-oracle/emit.py @@ -0,0 +1,66 @@ +# Print a build result, or write it as the case's expected.json (+ reference.stl). +# Run by run.sh under FreeCAD's interpreter (plain Python; no FreeCAD API used here). +import json +import os +import shutil +import sys + +case = os.environ["CASE"] +res = json.load(open(os.environ["OUT"])) +name = res["case"] +write = os.environ.get("WRITE") == "1" +stl = os.environ.get("STL") or "" + +def emit(): + if "error" in res: + print("%s: ERROR %s" % (name, res["error"])) + if os.environ.get("ORACLE_VERBOSE"): + print(res.get("trace", "")) + else: + c = res["center_of_mass"] + print("%s: bodies %d V %.6f A %.6f com [%.4f, %.4f, %.4f] bbox %s..%s%s%s" % ( + name, res["body_count"], res["volume_mm3"], res["area_mm2"], c[0], c[1], c[2], + [round(x, 4) for x in res["bbox"]["min"]], [round(x, 4) for x in res["bbox"]["max"]], + "" if res.get("valid", True) else " (INVALID SHAPE)", + (" notes: " + "; ".join(res["notes"])) if res.get("notes") else "")) + if write: + path = os.path.join(case, "expected.json") + if os.path.exists(path): + old = json.load(open(path)) + if str(old.get("source", "")).startswith("analytic"): + print("%s: expected.json is analytic; not overwritten" % name) + return 0 + if not res.get("valid", True): + print("%s: FreeCAD shape is invalid; not written" % name) + return 1 + method = res["method"] + if res.get("method_extra"): + method += "; " + res["method_extra"] + exp = { + "source": "freecad %s" % res["version"], + "method": method, + "body_count": res["body_count"], + "volume_mm3": res["volume_mm3"], + "area_mm2": res["area_mm2"], + "center_of_mass": res["center_of_mass"], + "bbox": res["bbox"], + } + for k in ("compare", "tolerance", "note"): + if k in res: + exp[k] = res[k] + if res.get("stl_skipped"): + exp["note"] = (exp.get("note", "") + " " if exp.get("note") else "") + "No reference.stl: " + res["stl_skipped"] + "." + with open(path, "w") as fh: + json.dump(exp, fh, indent=2) + fh.write("\n") + ref = os.path.join(case, "reference.stl") + if stl and os.path.exists(stl): + shutil.copyfile(stl, ref) + print("%s: wrote expected.json, reference.stl (%d bytes, deflection %s mm)" % (name, os.path.getsize(ref), res.get("stl_deflection_mm"))) + else: + print("%s: wrote expected.json (no STL)" % name) + return 0 + + +emit() +sys.stdout.flush() diff --git a/tools/freecad-oracle/run.sh b/tools/freecad-oracle/run.sh new file mode 100755 index 00000000..13525c29 --- /dev/null +++ b/tools/freecad-oracle/run.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Build reference cases with FreeCAD (as an external program) and print or write the results. +# +# tools/freecad-oracle/run.sh [--write] ... +# +# Without --write: prints one line per case. With --write: writes /expected.json and +# /reference.stl (refuses to overwrite an expected.json whose source is "analytic"). +# freecadcmd is found via $FREECADCMD, then `freecadcmd` on PATH, then the local AppImage. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +write=0 +cases=() +for a in "$@"; do + case "$a" in + --write) write=1 ;; + -h|--help) sed -n '2,9p' "$0"; exit 0 ;; + *) cases+=("$a") ;; + esac +done +[ ${#cases[@]} -gt 0 ] || { echo "usage: $0 [--write] ..." >&2; exit 2; } + +fc="${FREECADCMD:-}" +if [ -z "$fc" ]; then + if command -v freecadcmd >/dev/null 2>&1; then fc="$(command -v freecadcmd)" + elif command -v FreeCADCmd >/dev/null 2>&1; then fc="$(command -v FreeCADCmd)" + else fc="$here/squashfs-root/usr/bin/freecadcmd" # an AppImage extracted here (gitignored) + fi +fi +[ -x "$fc" ] || { echo "freecadcmd not found (set FREECADCMD)" >&2; exit 2; } + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +status=0 +for c in "${cases[@]}"; do + c="${c%/}" + name="$(basename "$c")" + out="$tmp/$name.json" + stl="" + [ $write = 1 ] && stl="$tmp/$name.stl" + ORACLE_CASE="$c" ORACLE_OUT="$out" ORACLE_STL="$stl" \ + "$fc" -c "exec(open('$here/build.py').read())" "$tmp/$name.log" 2>&1 || true + if [ ! -s "$out" ]; then + echo "$name: FreeCAD produced no result"; tail -5 "$tmp/$name.log"; status=1; continue + fi + WRITE=$write CASE="$c" OUT="$out" STL="$stl" "$fc" -c "exec(open('$here/emit.py').read())" &1 \ + | grep -v '^FreeCAD\|^(c) \|^$' || status=1 +done +exit $status diff --git a/tools/oracle-analytic/README.md b/tools/oracle-analytic/README.md new file mode 100644 index 00000000..af99ffd9 --- /dev/null +++ b/tools/oracle-analytic/README.md @@ -0,0 +1,19 @@ +# Analytic reference values + +Closed-form expected values for the cases in [`tests/oracle/`](../../tests/oracle/) whose shape +has one: prisms, cylinders, cones and frustums, spheres and caps, tori and solids of revolution +(Pappus), constant-radius fillets on straight edges, shells, patterns of these, and folded sheet +metal (flats plus bends as annular sectors). Each script is plain Python 3 (standard library only) +with one function per case. Running a script from the repository root rewrites the +`expected.json` of its cases with `"source": "analytic"` and a `method` string giving the formulas: + +```sh +python3 tools/oracle-analytic/cases_basic.py # 01-29: boxes, holes, revolves, sketches +python3 tools/oracle-analytic/cases_features.py # 32-66: fillets, shells, sweeps, patterns +python3 tools/oracle-analytic/cases_sheet_metal.py # 67-76: sheet metal +``` + +Where a value depends on a SolidCraft convention rather than on geometry alone (a shell's sharp +inner corner, a sheet-metal corner relief, where a flange height is measured), `method` or +`note` says which convention is used. Never change a value to match SolidCraft's output: if they +disagree, find out which one is wrong. diff --git a/tools/oracle-analytic/cases_basic.py b/tools/oracle-analytic/cases_basic.py new file mode 100644 index 00000000..aefb6a5f --- /dev/null +++ b/tools/oracle-analytic/cases_basic.py @@ -0,0 +1,560 @@ +#!/usr/bin/env python3 +"""Closed-form expected values for the basic oracle cases. + +Run from the worktree root: python3 tools/oracle-analytic/cases_basic.py [case ...] +Writes tests/oracle//expected.json with "source": "analytic". + +Every value is derived from first principles (prisms, cylinders, cones/frustums, spheres and +caps, tori via Pappus, Green's theorem over line/arc profiles) and composed by adding or +subtracting mass properties. Nothing here is measured from a CAD kernel. +Stdlib only. +""" +import json +import math +import os +import sys + +PI = math.pi +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "tests", "oracle") + + +# ---------------------------------------------------------------------------------------------- +# Mass-property composition +# ---------------------------------------------------------------------------------------------- +class MP: + """Volume and first moments; add pieces with sign +1 (material) or -1 (removed).""" + + def __init__(self): + self.v = 0.0 + self.m = [0.0, 0.0, 0.0] + + def add(self, v, c, sign=1.0): + self.v += sign * v + for i in range(3): + self.m[i] += sign * v * c[i] + return self + + def com(self): + return [self.m[i] / self.v for i in range(3)] + + +def box_mp(lo, hi): + v = (hi[0] - lo[0]) * (hi[1] - lo[1]) * (hi[2] - lo[2]) + return v, [(lo[i] + hi[i]) / 2 for i in range(3)] + + +def box_area(a, b, c): + return 2 * (a * b + b * c + c * a) + + +def frustum_v(h, r1, r2): + """Circular frustum, radii r1 (z=0) and r2 (z=h).""" + return PI * h / 3 * (r1 * r1 + r1 * r2 + r2 * r2) + + +def frustum_zc(h, r1, r2): + """Centroid height of a circular frustum above the r1 end.""" + return h * (r1 * r1 + 2 * r1 * r2 + 3 * r2 * r2) / (4 * (r1 * r1 + r1 * r2 + r2 * r2)) + + +def frustum_lat(h, r1, r2): + return PI * (r1 + r2) * math.hypot(h, r1 - r2) + + +def sq_frustum(h, a0, a1): + """Square frustum, side a0 at z=0, a1 at z=h: (V, z-centroid).""" + A0, A1 = a0 * a0, a1 * a1 + s = math.sqrt(A0 * A1) + v = h / 3 * (A0 + A1 + s) + zc = h * (A0 + 2 * s + 3 * A1) / (4 * (A0 + s + A1)) + return v, zc + + +def cap_v(R, h): + return PI * h * h * (3 * R - h) / 3 + + +# ---------------------------------------------------------------------------------------------- +# 2D region integrals by Green's theorem (lines exact; arcs by 48-point Gauss-Legendre, which +# integrates these low-degree trigonometric polynomials to machine precision). +# ---------------------------------------------------------------------------------------------- +def _gauss_legendre(n): + xs, ws = [], [] + for i in range(1, n + 1): + x = math.cos(PI * (i - 0.25) / (n + 0.5)) + for _ in range(100): + p0, p1 = 1.0, x + for k in range(2, n + 1): + p0, p1 = p1, ((2 * k - 1) * x * p1 - (k - 1) * p0) / k + dp = n * (x * p1 - p0) / (x * x - 1) + dx = p1 / dp + x -= dx + if abs(dx) < 1e-16: + break + xs.append(x) + ws.append(2 / ((1 - x * x) * dp * dp)) + return xs, ws + + +GL = _gauss_legendre(48) + + +def seg_eval(seg, t): + """Point and derivative on a segment at t in [0, 1].""" + if seg[0] == "line": + (x0, y0), (x1, y1) = seg[1], seg[2] + return x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, x1 - x0, y1 - y0 + _, (cx, cy), r, a0, a1 = seg + a = a0 + (a1 - a0) * t + da = a1 - a0 + return cx + r * math.cos(a), cy + r * math.sin(a), -r * math.sin(a) * da, r * math.cos(a) * da + + +def seg_int(seg, f): + """Integral over t of f(x, y, dx/dt, dy/dt).""" + s = 0.0 + for x, w in zip(*GL): + t = (x + 1) / 2 + s += w / 2 * f(*seg_eval(seg, t)) + return s + + +def region(segs): + """Area A, moments Sx = int x dA, Sy = int y dA, Sxy = int x*y dA of a CCW-bounded region.""" + A = sum(seg_int(s, lambda x, y, dx, dy: x * dy) for s in segs) + Sx = sum(seg_int(s, lambda x, y, dx, dy: x * x / 2 * dy) for s in segs) + Sy = sum(seg_int(s, lambda x, y, dx, dy: x * y * dy) for s in segs) + Sxy = sum(seg_int(s, lambda x, y, dx, dy: x * x * y / 2 * dy) for s in segs) + return A, Sx, Sy, Sxy + + +def seg_len(seg): + return seg_int(seg, lambda x, y, dx, dy: math.hypot(dx, dy)) + + +def seg_rev_area(seg): + """Area swept by a profile segment (x = radius) turned 360 deg about the y axis.""" + return seg_int(seg, lambda x, y, dx, dy: 2 * PI * x * math.hypot(dx, dy)) + + +def arc3(p0, pm, p1): + """Circle through three points, as an arc segment from p0 through pm to p1.""" + (ax, ay), (bx, by), (cx, cy) = p0, pm, p1 + d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) + ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d + uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d + r = math.hypot(ax - ux, ay - uy) + t0 = math.atan2(ay - uy, ax - ux) + tm = math.atan2(by - uy, bx - ux) + t1 = math.atan2(cy - uy, cx - ux) + # choose the sweep direction that passes through the mid point + ccw = (t1 - t0) % (2 * PI) + if (tm - t0) % (2 * PI) < ccw: + a1 = t0 + ccw + else: + a1 = t0 - ((t0 - t1) % (2 * PI)) + return ("arc", (ux, uy), r, t0, a1) + + +def circle_seg(c, r): + return ("arc", c, r, 0.0, 2 * PI) + + +# ---------------------------------------------------------------------------------------------- +# Output +# ---------------------------------------------------------------------------------------------- +def result(method, bodies, V, A, com, lo, hi, faces=None, note=None, **extra): + d = { + "source": "analytic", + "method": method, + "body_count": bodies, + "volume_mm3": V, + "area_mm2": A, + "center_of_mass": [c + 0.0 for c in com], + "bbox": {"min": [float(x) + 0.0 for x in lo], "max": [float(x) + 0.0 for x in hi]}, + } + if faces is not None: + d["faces"] = faces + if note: + d["note"] = note + d.update(extra) + return d + + +# ---------------------------------------------------------------------------------------------- +# Cases +# ---------------------------------------------------------------------------------------------- +def c01_box(): + a, b, c = 40.0, 30.0, 20.0 + return result("box 40 x 30 x 20 from the origin: V = abc, A = 2(ab + bc + ca)", 1, a * b * c, box_area(a, b, c), + [a / 2, b / 2, c / 2], [0, 0, 0], [a, b, c], 6) + + +def c02_cylinder(): + r, h = 15.0, 50.0 + return result("cylinder r=15 h=50 on XY: V = pi r^2 h, A = 2 pi r^2 + 2 pi r h", 1, PI * r * r * h, + 2 * PI * r * r + 2 * PI * r * h, [0, 0, h / 2], [-r, -r, 0], [r, r, h], 3) + + +def c03_box_through_hole(): + a, b, c, r = 60.0, 40.0, 20.0, 6.0 + V = a * b * c - PI * r * r * c + A = box_area(a, b, c) - 2 * PI * r * r + 2 * PI * r * c + return result("box 60x40x20 minus centred through cylinder r=6: V = abc - pi r^2 c, A = 2(ab+bc+ca) - 2 pi r^2 + 2 pi r c; " + "hole is centred so the centroid stays at the box centre", 1, V, A, [30, 20, 10], [0, 0, 0], [a, b, c], 7) + + +def _vase_profile(): + # sketch on XZ with y_dir = -Z: world (x, z) = (sx, -sy) + w = lambda p: (p[0], -p[1]) + P = [w(p) for p in [(0, 0), (20, 0), (25, -30)]] + arc = arc3(w((25, -30)), w((23, -46)), w((15, -60))) + Q = [w(p) for p in [(15, -60), (18, -80), (0, -80)]] + segs = [("line", P[0], P[1]), ("line", P[1], P[2]), arc, ("line", Q[0], Q[1]), ("line", Q[1], Q[2]), ("line", Q[2], P[0])] + return segs, arc + + +def c04_revolve_vase(): + segs, arc = _vase_profile() + A2, Sx, Sy, Sxy = region(segs) + V = 2 * PI * Sx + zc = Sxy / Sx + area = sum(seg_rev_area(s) for s in segs) + # largest radius: the line/arc endpoints, or the arc's x-extreme if it lies within the sweep + _, (cx, cz), r, a0, a1 = arc + rmax = max(25.0, 20.0, 15.0, 18.0) + lo_a, hi_a = min(a0, a1), max(a0, a1) + for k in range(-2, 3): + if lo_a <= 2 * PI * k <= hi_a: + rmax = max(rmax, cx + r) + method = ("revolve of a line/3-point-arc profile (XZ, x = radius, z = -sketch y) 360 deg about Z. Pappus/Green: " + "V = 2 pi * int x dA, z_c = int x z dA / int x dA, A = sum over profile edges of 2 pi int x ds " + "(bottom disk r20, frustum r20->25, torus zone of the arc (centre (%.6f, %.6f), R=%.6f), frustum r15->18, top disk r18)" + % (cx, cz, r)) + return result(method, 1, V, area, [0, 0, zc], [-rmax, -rmax, 0], [rmax, rmax, 80], 5) + + +def c05_taper_extrude(): + h, a0, t = 30.0, 40.0, math.radians(10) + a1 = a0 + 2 * h * math.tan(t) # positive taper offsets the CCW outer loop outward (recipe.rs -> extrude_tapered) + V, zc = sq_frustum(h, a0, a1) + side = (a0 + a1) / 2 * h / math.cos(t) + A = a0 * a0 + a1 * a1 + 4 * side + return result("square frustum: base 40 at z=0, top a1 = 40 + 2*30*tan10 at z=30 (positive taper grows outward); " + "V = h/3 (A0 + A1 + sqrt(A0 A1)), z_c = h (A0 + 2 sqrt(A0A1) + 3 A1) / (4 (A0 + sqrt(A0A1) + A1)), " + "sides = 4 trapezoids (a0+a1)/2 * h/cos(10)", 1, V, A, [0, 0, zc], [-a1 / 2, -a1 / 2, 0], [a1 / 2, a1 / 2, h], 6) + + +def c06_symmetric_extrude(): + a, b, c = 50.0, 30.0, 20.0 + return result("50 (x) by 20 (z) rectangle on XZ extruded +-15 along Y: box 50x30x20 centred at origin", 1, a * b * c, + box_area(a, b, c), [0, 0, 0], [-25, -15, -10], [25, 15, 10], 6) + + +def c07_fillet_all_edges(): + r = 3.0 + a, b, c = 40 - 2 * r, 30 - 2 * r, 20 - 2 * r # inner box swept by a ball of radius r + V = a * b * c + 2 * r * (a * b + b * c + c * a) + PI * r * r * (a + b + c) + 4 / 3 * PI * r ** 3 + A = 2 * (a * b + b * c + c * a) + 2 * PI * r * (a + b + c) + 4 * PI * r * r + return result("box 40x30x20 with every edge rounded r=3 = inner box (34x24x14) grown by a ball r (Steiner): " + "V = abc + 2r(ab+bc+ca) + pi r^2 (a+b+c) + 4/3 pi r^3, A = 2(ab+bc+ca) + 2 pi r (a+b+c) + 4 pi r^2; " + "faces 6 planes + 12 quarter cylinders + 8 sphere octants", 1, V, A, [20, 15, 10], [0, 0, 0], [40, 30, 20], 26) + + +def c08_chamfer(): + d = 2.0 + mp = MP().add(*box_mp([0, 0, 0], [40, 30, 18])) + # top slab z in [18, 20]: at depth u below the top the section is (36+2u) x (26+2u) + # V = int_0^2 (36+2u)(26+2u) du ; z-moment = int (20-u)(...) du (polynomial, exact) + f = lambda u: (36 + 2 * u) * (26 + 2 * u) + # integrate quadratic exactly (Simpson) + Vs = d / 6 * (f(0) + 4 * f(1) + f(2)) + g = lambda u: (20 - u) * f(u) # cubic: Simpson is exact + Ms = d / 6 * (g(0) + 4 * g(1) + g(2)) + mp.add(Vs, [20, 15, Ms / Vs]) + s = d * math.sqrt(2) + A = 40 * 30 + 2 * (40 + 30) * 18 + 36 * 26 + 2 * (40 + 36) / 2 * s + 2 * (30 + 26) / 2 * s + return result("box 40x30x20 with the 4 top edges chamfered 2: lower box 40x30x18 + top slab with section (36+2u)x(26+2u) at depth u " + "(V, moment by exact polynomial integration); A = bottom + sides(h=18) + top 36x26 + 4 trapezoid bevels of slant 2 sqrt2", + 1, mp.v, A, mp.com(), [0, 0, 0], [40, 30, 20], 10) + + +def c09_shell(): + t = 2.0 + mp = MP().add(*box_mp([0, 0, 0], [50, 40, 30])).add(*box_mp([t, t, t], [50 - t, 40 - t, 30]), -1) + ia, ib, ih = 50 - 2 * t, 40 - 2 * t, 30 - t + A = 50 * 40 + 2 * (50 + 40) * 30 + (50 * 40 - ia * ib) + ia * ib + 2 * (ia + ib) * ih + return result("50x40x30 box shelled inward 2 with the top open: box minus cavity [2,48]x[2,38]x[2,30]; " + "A = outer bottom + outer sides + rim + cavity floor + cavity walls", 1, mp.v, A, mp.com(), [0, 0, 0], [50, 40, 30], 11) + + +def c10_rect_pattern_holes(): + r, t = 3.0, 5.0 + n = 12 + mp = MP().add(*box_mp([0, 0, 0], [90, 70, t])) + for i in range(4): + for j in range(3): + mp.add(PI * r * r * t, [15 + 20 * i, 15 + 20 * j, t / 2], -1) + A = box_area(90, 70, t) + n * (2 * PI * r * t - 2 * PI * r * r) + return result("plate 90x70x5 minus 12 through cylinders r=3 at (15+20i, 15+20j), i<4, j<3: V = abc - 12 pi r^2 t, " + "A = 2(ab+bc+ca) + 12(2 pi r t - 2 pi r^2)", 1, mp.v, A, mp.com(), [0, 0, 0], [90, 70, t], 18) + + +def c11_circular_pattern_flange(): + R, ri, rh, h = 50.0, 15.0, 5.0, 10.0 + V = PI * (R * R - ri * ri) * h - 6 * PI * rh * rh * h + A = 2 * PI * (R * R - ri * ri) + 2 * PI * (R + ri) * h + 6 * (2 * PI * rh * h - 2 * PI * rh * rh) + return result("annulus R50/r15 h10 minus 6 through cylinders r=5 on a 40 radius: V = pi(R^2-r^2)h - 6 pi rh^2 h, " + "A = 2 pi(R^2-r^2) + 2 pi(R+r)h + 6(2 pi rh h - 2 pi rh^2); 6-fold symmetry keeps the centroid on the axis", + 1, V, A, [0, 0, h / 2], [-R, -R, 0], [R, R, h], 10) + + +def c12_mirror_feature(): + r, hb = 6.0, 15.0 + mp = MP().add(*box_mp([-30, 0, 0], [30, 30, 10])) + for x in (15, -15): + mp.add(PI * r * r * hb, [x, 15, 10 + hb / 2]) + A = box_area(60, 30, 10) + 2 * (2 * PI * r * hb) + return result("block [-30,30]x[0,30]x[0,10] + two bosses r=6 h=15 at (+-15,15) on top (mirror across YZ): " + "V = abc + 2 pi r^2 h, A = 2(ab+bc+ca) + 2 * 2 pi r h", 1, mp.v, A, mp.com(), [-30, 0, 0], [30, 30, 25], 10) + + +def c13_combine_join(): + r = 12.0 + mp = MP().add(*box_mp([0, 0, 0], [40, 40, 20])) + mp.add(PI * r * r / 2 * 20, [40 + 4 * r / (3 * PI), 20, 10]) # half cylinder x>40, z 0..20 + mp.add(PI * r * r * 20, [40, 20, 30]) # full cylinder z 20..40 + A = (1600 + PI * r * r / 2) + (1600 - PI * r * r / 2) + PI * r * r + 3 * 800 + (800 - 2 * r * 20) + PI * r * 40 + PI * r * 20 + return result("box [0,40]^2x[0,20] union cylinder r=12 at (40,20) z 0..40: V = box + half-cylinder (x>40, z<20, centroid " + "40 + 4r/3pi) + full cylinder z 20..40; A = bottom (1600 + pi r^2/2) + box top (1600 - pi r^2/2) + cyl top + " + "3 full sides + x=40 side minus 24x20 + cyl side (pi r 40 outside half + pi r 20 upper inside half)", + 1, mp.v, A, mp.com(), [0, 0, 0], [52, 40, 40], 9) + + +def c14_combine_cut(): + r = 15.0 + mp = MP().add(*box_mp([-25, -25, -20], [25, 25, 0])) + mp.add(2 / 3 * PI * r ** 3, [0, 0, -3 * r / 8], -1) + A = box_area(50, 50, 20) - PI * r * r + 2 * PI * r * r + return result("box [-25,25]^2x[-20,0] minus sphere r=15 at the origin (lower hemisphere removed): V = abc - 2/3 pi r^3, " + "hemisphere centroid z = -3r/8; A = 2(ab+bc+ca) - pi r^2 + 2 pi r^2", 1, mp.v, A, mp.com(), + [-25, -25, -20], [25, 25, 0], 7) + + +def c15_combine_intersect(): + R, a = 25.0, 20.0 + h = R - a + rc = math.sqrt(R * R - a * a) + V = 4 / 3 * PI * R ** 3 - 6 * cap_v(R, h) + A = 4 * PI * R * R - 6 * 2 * PI * R * h + 6 * PI * rc * rc + return result("cube [-20,20]^3 intersect sphere R=25: sphere minus 6 disjoint caps of height h=5 (caps cannot overlap: " + "two would need x^2+y^2 > 800 > R^2); V = 4/3 pi R^3 - 6 pi h^2 (3R-h)/3, A = 4 pi R^2 - 6 * 2 pi R h + 6 pi (R^2-20^2)", + 1, V, A, [0, 0, 0], [-a, -a, -a], [a, a, a], 7) + + +def c16_sweep(): + r, R, L1, L2 = 3.0, 20.0, 30.0, 30.0 + th = PI / 2 + ar = PI * r * r + mp = MP() + mp.add(ar * L1, [0, 0, L1 / 2]) # vertical leg z 0..30 along the Z axis + # quarter torus: bend centre C=(20, 30) in XZ, from direction (-1,0) to (0,1) about C + Vt = ar * R * th + c_eff = (R * R + r * r / 4) / R # int rho^2 dA / int rho dA over the section disk + rho = c_eff * math.sin(th / 2) / (th / 2) # sector centroid distance from the bend axis + bis = (-math.sqrt(0.5), math.sqrt(0.5)) + mp.add(Vt, [20 + rho * bis[0], 0, 30 + rho * bis[1]]) + mp.add(ar * L2, [20 + L2 / 2, 0, 50]) + A = 2 * PI * r * (L1 + R * th + L2) + 2 * ar + return result("circle r=3 swept along line 30 (+Z) + tangent R20 quarter arc + line 30 (+X): two cylinders + quarter torus " + "(Pappus: V = pi r^2 R theta, A = 2 pi r R theta; torus centroid at rho = (R^2 + r^2/4)/R * sin(theta/2)/(theta/2) " + "from the bend axis along the bisector) + 2 end disks", 1, mp.v, A, mp.com(), [-r, -r, 0], [50, r, 50 + r], 5) + + +def c18_hole_features(): + T = 15.0 + mp = MP().add(*box_mp([0, 0, 0], [60, 40, T])) + A = box_area(60, 40, T) + # counterbore at (15,20): r=7 depth 5, then r=4 through + mp.add(PI * 49 * 5, [15, 20, T - 2.5], -1) + mp.add(PI * 16 * 10, [15, 20, 5], -1) + A += -PI * 49 + 2 * PI * 7 * 5 + PI * (49 - 16) + 2 * PI * 4 * 10 - PI * 16 + # countersink at (35,20): 90 deg cone r=6 at the top to r=3 at depth 3, then r=3 through + hc = (6 - 3) / math.tan(math.radians(45)) + mp.add(frustum_v(hc, 3, 6), [35, 20, T - hc + frustum_zc(hc, 3, 6)], -1) + mp.add(PI * 9 * (T - hc), [35, 20, (T - hc) / 2], -1) + A += -PI * 36 + frustum_lat(hc, 3, 6) + 2 * PI * 3 * (T - hc) - PI * 9 + # blind drilled hole at (50,20): r=3 to depth 10 (shoulder), 118 deg point below + tip = 3 / math.tan(math.radians(59)) + zs = T - 10 + mp.add(PI * 9 * 10, [50, 20, zs + 5], -1) + mp.add(PI * 9 * tip / 3, [50, 20, zs - tip / 4], -1) + A += -PI * 9 + 2 * PI * 3 * 10 + PI * 3 * math.hypot(3, tip) + return result("plate 60x40x15 minus: counterbore (r7 x 5 deep + r4 through), countersink (90 deg cone frustum r6->r3 over 3 mm " + "+ r3 through), blind r3 hole 10 deep to the shoulder with a 118 deg cone point (height 3/tan59, centroid 1/4 up " + "from the apex); areas: removed top/bottom disks, cylinder walls 2 pi r h, cb floor annulus, cone laterals pi r s", + 1, mp.v, A, mp.com(), [0, 0, 0], [60, 40, T], 13) + + +def c19_l_bracket(): + # solved: l1 (0,0)-(60,0), l2 up 8, l3 left to x=8 (l5 is 8 long and l6 is vertical at x=0), l4 up to y=40, l5 to (0,40) + h = 20.0 + mp = MP().add(*box_mp([0, 0, 0], [60, 8, h])).add(*box_mp([0, 8, 0], [8, 40, h])) + a2 = 60 * 8 + 8 * 32 + A = 2 * a2 + (60 + 8 + 52 + 32 + 8 + 40) * h + return result("solved L: (0,0)-(60,0)-(60,8)-(8,8)-(8,40)-(0,40); area 60x8 + 8x32, extruded 20: V = A2 h, " + "A = 2 A2 + perimeter*h, centroid of the two rectangles", 1, mp.v, A, mp.com(), [0, 0, 0], [60, 40, h], 8) + + +def c20_slot(): + r, L, h = 6.0, 40.0, 5.0 + a2 = 2 * r * L + PI * r * r + per = 2 * L + 2 * PI * r + return result("obround: arc centres (0,0) and (40,0), R6, tangent horizontal lines y=+-6: A2 = 2rL + pi r^2, " + "V = A2 h, A = 2 A2 + perimeter (2L + 2 pi r) h", 1, a2 * h, 2 * a2 + per * h, [L / 2, 0, h / 2], + [-r, -r, 0], [L + r, r, h], 6) + + +def c21_draft(): + h, a0, t = 30.0, 40.0, math.radians(5) + a1 = a0 + 2 * h * math.tan(t) # pull = -Z (bottom normal); faces lean in along the pull, so the top widens + V, zc = sq_frustum(h, a0, a1) + A = a0 * a0 + a1 * a1 + 4 * (a0 + a1) / 2 * h / math.cos(t) + return result("40x40x30 box, 5 deg draft hinged on the bottom (z=0), pull -Z: square frustum 40 -> 40 + 2*30*tan5; " + "V = h/3 (A0 + A1 + sqrt(A0A1)), sides 4 trapezoids of slant h/cos5", 1, V, A, [0, 0, zc], + [-a1 / 2, -a1 / 2, 0], [a1 / 2, a1 / 2, h], 6) + + +def c22_split_body(): + r, h = 20.0, 60.0 + t = math.radians(30) + V = PI * r * r * h + A = 2 * PI * r * r + 2 * PI * r * h + 2 * PI * r * (r / math.cos(t)) + return result("cylinder r20 z -30..30 split by the plane through the X axis tilted 30 deg (z = y tan30, stays within |z|<=11.6 " + "inside the cylinder): 2 congruent halves (point symmetry), V total = pi r^2 h, A total = cylinder + 2 ellipses " + "pi r (r/cos30)", 2, V, A, [0, 0, 0], [-r, -r, -h / 2], [r, r, h / 2], 6) + + +def c24_user_parameters(): + w, d, h, rh = 60.0, 40.0, 15.0, 5.0 + V = (w * d - PI * rh * rh) * h + A = 2 * (w * d - PI * rh * rh) + 2 * (w + d) * h + 2 * PI * rh * h + return result("width 60 x depth 40 x height 15 block from the origin with a centred d10 through hole: V = (wd - pi r^2) h, " + "A = 2(wd - pi r^2) + 2(w+d)h + 2 pi r h", 1, V, A, [w / 2, d / 2, h / 2], [0, 0, 0], [w, d, h], 7) + + +def c25_sketch_triangle(): + # P = t (cos60, sin60), |P - (50,0)| = 45 -> t^2 - 50 t + 475 = 0; the drawn apex (|P| ~ 41) picks t = 25 + sqrt(150) + tt = 25 + math.sqrt(150) + P = (tt * 0.5, tt * math.sqrt(3) / 2) + h = 5.0 + a2 = 0.5 * 50 * P[1] + per = 50 + 45 + tt + return result("solved triangle (0,0), (50,0), P = t(cos60, sin60) with t = 25 + sqrt(150) from |P-(50,0)| = 45 " + "(the root nearest the drawn apex); V = (1/2 * 50 * Py) * 5, A = 2 A2 + perimeter*5, centroid = vertex mean", + 1, a2 * h, 2 * a2 + per * h, [(50 + P[0]) / 3, P[1] / 3, h / 2], [0, 0, 0], [50, P[1], h], 5) + + +def c26_sketch_belt(): + R, r, d, rh, h = 20.0, 10.0, 50.0, 4.0, 5.0 + al = math.asin((R - r) / d) + nt = (math.sin(al), math.cos(al)) + nb = (math.sin(al), -math.cos(al)) + b1, b2 = (R * nb[0], R * nb[1]), (d + r * nb[0], r * nb[1]) + t2, t1 = (d + r * nt[0], r * nt[1]), (R * nt[0], R * nt[1]) + segs = [("line", b1, b2), ("arc", (d, 0), r, -(PI / 2 - al), PI / 2 - al), ("line", t2, t1), + ("arc", (0, 0), R, PI / 2 - al, 3 * PI / 2 + al)] + A2, Sx, Sy, _ = region(segs) + per_out = sum(seg_len(s) for s in segs) + A2n = A2 - PI * rh * rh + Sxn = Sx - PI * rh * rh * d + V = A2n * h + A = 2 * A2n + (per_out + 2 * PI * rh) * h + return result("belt: convex hull of circles R20 @ (0,0) and r10 @ (50,0) (external tangents, sin a = (R-r)/d) minus the " + "d8 hole at (50,0), extruded 5 as one body; hull area = (R+r) sqrt(d^2-(R-r)^2) + R^2(pi+2a)/2 + r^2(pi-2a)/2; " + "moments by Green's theorem over the line/arc boundary", 1, V, A, [Sxn / A2n, 0, h / 2], + [-R, -R, 0], [d + r, R, h], 7) + + +def c27_sketch_trapezoid(): + a, b, H, rh, t = 60.0, 30.0, 25.0, 3.0, 5.0 + At = (a + b) / 2 * H + yt = H * (a + 2 * b) / (3 * (a + b)) + A2 = At - 2 * PI * rh * rh + yc = (At * yt - 2 * PI * rh * rh * 8) / A2 + per = a + b + 2 * math.hypot((a - b) / 2, H) + 2 * 2 * PI * rh + return result("solved trapezoid bottom (-30,0)-(30,0), top (-15,25)-(15,25) minus two d6 holes at (+-18, 8) " + "(symmetric about the axis, 36 apart, 8 above l1); trapezoid centroid y = H(a+2b)/(3(a+b)); " + "V = A2 t, A = 2 A2 + perimeter t", 1, A2 * t, 2 * A2 + per * t, [0, yc, t / 2], [-30, 0, 0], [30, H, t], 8) + + +def c29_two_sided_partial_revolve(): + # block: x -20..20, z 0..20 (XZ sketch, y_dir = -Z), extruded +10 along +Y and 25 along -Y + mp = MP().add(*box_mp([-20, -25, 0], [20, 10, 20])) + ri, ro, z0, z1 = 4.0, 12.0, 12.0, 20.0 + th = 1.5 * PI # sweeps from +X towards the sketch normal (+Y): theta in [0, 270 deg] + # removed cross-section S = annular sector theta in [0, th] minus the part above y = 10 (outside the block) + Asec = th / 2 * (ro * ro - ri * ri) + Mx_sec = (ro ** 3 - ri ** 3) / 3 * math.sin(th) + My_sec = (ro ** 3 - ri ** 3) / 3 * (1 - math.cos(th)) + dy = 10.0 + seg_a = ro * ro * math.acos(dy / ro) - dy * math.sqrt(ro * ro - dy * dy) + seg_my = 2 / 3 * (ro * ro - dy * dy) ** 1.5 + S = Asec - seg_a + Mx, My = Mx_sec, My_sec - seg_my + hz = z1 - z0 + mp.add(S * hz, [Mx / S, My / S, (z0 + z1) / 2], -1) + half_w = math.sqrt(ro * ro - dy * dy) + phi = math.asin(dy / ro) + outer_ang = th - (PI - 2 * phi) + A = box_area(40, 35, 20) - S + S - 2 * half_w * hz + ri * th * hz + ro * outer_ang * hz + 2 * (ro - ri) * hz + return result("block [-20,20]x[-25,10]x[0,20] minus the 270 deg revolve (from +X towards +Y) of the 8x8 square r 4..12, z 12..20; " + "the removed section is the annular sector minus the circular segment y > 10 (R^2 acos(d/R) - d sqrt(R^2-d^2), " + "y-moment 2/3 (R^2-d^2)^1.5) that lies outside the block; sector moments (R^3-r^3)/3 (sin, 1-cos); " + "A = box - top opening + floor - front-face notch 2 sqrt(44) x 8 + inner wall r th h + outer wall (th - pi + 2 asin(5/6)) R h + 2 end caps", + 1, mp.v, A, mp.com(), [-20, -25, 0], [20, 10, 20], 12) + + +CASES = { + "01-box": c01_box, + "02-cylinder": c02_cylinder, + "03-box-through-hole": c03_box_through_hole, + "04-revolve-vase": c04_revolve_vase, + "05-taper-extrude": c05_taper_extrude, + "06-symmetric-extrude": c06_symmetric_extrude, + "07-fillet-all-edges": c07_fillet_all_edges, + "08-chamfer": c08_chamfer, + "09-shell": c09_shell, + "10-rect-pattern-holes": c10_rect_pattern_holes, + "11-circular-pattern-flange": c11_circular_pattern_flange, + "12-mirror-feature": c12_mirror_feature, + "13-combine-join": c13_combine_join, + "14-combine-cut": c14_combine_cut, + "15-combine-intersect": c15_combine_intersect, + "16-sweep": c16_sweep, + "18-hole-features": c18_hole_features, + "19-l-bracket-constrained": c19_l_bracket, + "20-slot-constrained": c20_slot, + "21-draft": c21_draft, + "22-split-body": c22_split_body, + "24-user-parameters": c24_user_parameters, + "25-sketch-triangle": c25_sketch_triangle, + "26-sketch-belt": c26_sketch_belt, + "27-sketch-trapezoid": c27_sketch_trapezoid, + "29-two-sided-and-partial-revolve": c29_two_sided_partial_revolve, +} + + +def main(argv): + names = argv[1:] or list(CASES) + for n in names: + d = CASES[n]() + path = os.path.join(ROOT, n, "expected.json") + with open(path, "w") as f: + json.dump(d, f, indent=2) + f.write("\n") + print(f"{n}: V={d['volume_mm3']!r} A={d['area_mm2']!r} com={d['center_of_mass']}") + + +if __name__ == "__main__": + main(sys.argv) diff --git a/tools/oracle-analytic/cases_features.py b/tools/oracle-analytic/cases_features.py new file mode 100644 index 00000000..0270408c --- /dev/null +++ b/tools/oracle-analytic/cases_features.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +"""Closed-form expected values for the feature cases of tests/oracle (analytic oracle). + +Standalone, Python 3 stdlib only. Run from the worktree root: + + python3 tools/oracle-analytic/cases_features.py + +Every number is derived from the recipe's dimensions with elementary geometry: +prisms, cylinders, cones, spheres, tori, solids of revolution (Pappus / Green's theorem on +the (r, z) profile), constant fillets as swept spandrels, tubes, screw sweeps. Planar +regions bounded by lines and circular arcs are integrated exactly with Green's theorem +(Gauss-Legendre on each segment, exact to round-off for lines and short arc pieces). +""" + +import json +import math +import os + +PI = math.pi +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +OUT = os.path.join(ROOT, "tests", "oracle") + +# -------------------------------------------------------------------------------------- +# Planar regions (lines + circular arcs), Green's theorem. +# -------------------------------------------------------------------------------------- + + +def _gauss_legendre(n): + xs, ws = [], [] + for i in range(1, n + 1): + x = math.cos(PI * (i - 0.25) / (n + 0.5)) + for _ in range(100): + p0, p1 = 1.0, x + for k in range(2, n + 1): + p0, p1 = p1, ((2 * k - 1) * x * p1 - (k - 1) * p0) / k + dp = n * (x * p1 - p0) / (x * x - 1) + dx = p1 / dp + x -= dx + if abs(dx) < 1e-16: + break + xs.append(x) + ws.append(2 / ((1 - x * x) * dp * dp)) + return xs, ws + + +GL_X, GL_W = _gauss_legendre(24) + + +def L(p, q): + """Line segment p -> q.""" + return ("L", tuple(p), tuple(q)) + + +def A(c, r, a0, a1): + """Arc about centre c, radius r, from angle a0 to a1 (radians; a1 < a0 is clockwise).""" + return ("A", tuple(c), r, a0, a1) + + +def _pieces(seg): + """Parametric pieces (point(t), derivative(t)) over t in [0, 1].""" + if seg[0] == "L": + (x0, y0), (x1, y1) = seg[1], seg[2] + yield (lambda t: (x0 + (x1 - x0) * t, y0 + (y1 - y0) * t), lambda t: (x1 - x0, y1 - y0)) + else: + (cx, cy), r, a0, a1 = seg[1], seg[2], seg[3], seg[4] + n = max(1, int(math.ceil(abs(a1 - a0) / (PI / 36)))) + for k in range(n): + b0 = a0 + (a1 - a0) * k / n + b1 = a0 + (a1 - a0) * (k + 1) / n + db = b1 - b0 + yield ( + (lambda b0, db: lambda t: (cx + r * math.cos(b0 + db * t), cy + r * math.sin(b0 + db * t)))(b0, db), + (lambda b0, db: lambda t: (-r * db * math.sin(b0 + db * t), r * db * math.cos(b0 + db * t)))(b0, db), + ) + + +def region(loops): + """Integrals of a region whose boundary loops are CCW (outer) / CW (holes). + + Returns dict: A, Sx = int x dA, Sy = int y dA, Sxy = int x y dA, P = perimeter, + Px = int x ds over the boundary. + """ + acc = dict(A=0.0, Sx=0.0, Sy=0.0, Sxy=0.0, P=0.0, Px=0.0) + for loop in loops: + for seg in loop: + for pt, dpt in _pieces(seg): + for xg, wg in zip(GL_X, GL_W): + t = 0.5 * (xg + 1) + w = 0.5 * wg + x, y = pt(t) + dx, dy = dpt(t) + ds = math.hypot(dx, dy) + acc["A"] += w * x * dy + acc["Sx"] += w * x * x / 2 * dy + acc["Sy"] += w * x * y * dy + acc["Sxy"] += w * x * x * y / 2 * dy + acc["P"] += w * ds + acc["Px"] += w * x * ds + return acc + + +def poly(pts): + return [L(pts[i], pts[(i + 1) % len(pts)]) for i in range(len(pts))] + + +def reverse(loop): + out = [] + for seg in reversed(loop): + out.append(L(seg[2], seg[1]) if seg[0] == "L" else A(seg[1], seg[2], seg[4], seg[3])) + return out + + +def circle_loop(c, r, ccw=True): + return [A(c, r, 0.0, 2 * PI if ccw else -2 * PI)] + + +# -------------------------------------------------------------------------------------- +# Mass properties: (V, [Mx, My, Mz]) with composition. +# -------------------------------------------------------------------------------------- + + +class MP: + def __init__(self, v=0.0, m=(0.0, 0.0, 0.0)): + self.v = v + self.m = list(m) + + @staticmethod + def at(v, c): + return MP(v, [v * c[0], v * c[1], v * c[2]]) + + def __add__(self, o): + return MP(self.v + o.v, [a + b for a, b in zip(self.m, o.m)]) + + def __sub__(self, o): + return MP(self.v - o.v, [a - b for a, b in zip(self.m, o.m)]) + + def com(self): + return [x / self.v for x in self.m] + + +def box(x0, y0, z0, x1, y1, z1): + v = (x1 - x0) * (y1 - y0) * (z1 - z0) + return MP.at(v, [(x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2]) + + +def cyl(cx, cy, r, z0, z1): + return MP.at(PI * r * r * (z1 - z0), [cx, cy, (z0 + z1) / 2]) + + +def extrude(reg, z0, h): + """Prism of a planar region (sketch x, y = world x, y) from z0 to z0 + h.""" + return MP(reg["A"] * h, [reg["Sx"] * h, reg["Sy"] * h, reg["A"] * h * (z0 + h / 2)]) + + +def extrude_area(reg, h): + return 2 * reg["A"] + reg["P"] * h + + +def revolve(reg, cx=0.0, cy=0.0): + """Full revolution about the vertical axis through (cx, cy) of an (r, z) region (r >= 0).""" + v = 2 * PI * reg["Sx"] + return MP(v, [v * cx, v * cy, 2 * PI * reg["Sxy"]]) + + +def revolve_area(reg): + """Area of the surface swept by the whole boundary (segments on the axis add nothing).""" + return 2 * PI * reg["Px"] + + +# Spandrel of a fillet radius R: the square corner R x R minus the quarter disc. +def spandrel_area(R): + return R * R * (1 - PI / 4) + + +def spandrel_offset(R): + """Distance of the spandrel's centroid from each of the two corner faces.""" + return R * (10 - 3 * PI) / (3 * (4 - PI)) + + +# -------------------------------------------------------------------------------------- +# Output +# -------------------------------------------------------------------------------------- + + +def write(case, method, bodies, mp, area, bbox, faces=None, **extra): + d = { + "source": "analytic", + "method": method, + "body_count": bodies, + "volume_mm3": mp.v, + "area_mm2": area, + "center_of_mass": mp.com(), + "bbox": {"min": list(map(float, bbox[0])), "max": list(map(float, bbox[1]))}, + } + if faces is not None: + d["faces"] = faces + d.update(extra) + path = os.path.join(OUT, case, "expected.json") + with open(path, "w") as f: + json.dump(d, f, indent=2) + f.write("\n") + print(f"{case}: V={mp.v:.6f} A={area:.6f} com={[round(x, 6) for x in mp.com()]}") + + +# -------------------------------------------------------------------------------------- +# Cases +# -------------------------------------------------------------------------------------- + + +def c32(): + # Plate 60x60x8 centred; boss r10 z 8..23 with R3 concave base fillet and R2 convex top fillet. + # The boss with both fillets is one solid of revolution: its (r, z) profile. + prof = region([[ + L((0, 8), (13, 8)), + A((13, 11), 3, -PI / 2, -PI), # concave base fillet, (13,8) -> (10,11) + L((10, 11), (10, 21)), + A((8, 21), 2, 0.0, PI / 2), # convex top fillet, (10,21) -> (8,23) + L((8, 23), (0, 23)), + L((0, 23), (0, 8)), + ]]) + mp = box(-30, -30, 0, 30, 30, 8) + revolve(prof) + # Revolved boundary minus its bottom disc (r 0..13 at z=8, internal); plate top loses that disc. + area = (3600 + 4 * 60 * 8 + 3600 - PI * 13 ** 2) + (revolve_area(prof) - PI * 13 ** 2) + write("32-boss-plate-fillets", + "plate 60x60x8 + boss (r10, z 8..23) with R3 concave base and R2 convex top fillets taken as one " + "solid of revolution: V, z-moment and lateral area from Pappus/Green on its (r,z) profile " + "(quarter-circle fillet arcs); plate top loses the r13 disc", + 1, mp, area, [(-30, -30, 0), (30, 30, 23)], faces=10) + + +def c33(): + # Obround R8 (centres x=0, x=40), height 10. Top loop R2 fillet, bottom loop 1 mm chamfer. + R, Lst, H = 8.0, 40.0, 10.0 + rf, ch = 2.0, 1.0 + base = MP.at((2 * R * Lst + PI * R * R) * H, [Lst / 2, 0, H / 2]) + + def path_len(inset): # perimeter of the obround inset by `inset` + return 2 * Lst + 2 * PI * (R - inset) + + # Removed fillet spandrel: area R^2(1-pi/4), centroid inset d from the wall and d below the top. + d = spandrel_offset(rf) + fil = MP.at(spandrel_area(rf) * path_len(d), [Lst / 2, 0, H - d]) + # Removed chamfer triangle: area c^2/2, centroid inset c/3, height c/3. + cha = MP.at(ch * ch / 2 * path_len(ch / 3), [Lst / 2, 0, ch / 3]) + mp = base - fil - cha + top = 2 * (R - rf) * Lst + PI * (R - rf) ** 2 + bot = 2 * (R - ch) * Lst + PI * (R - ch) ** 2 + wall = (H - rf - ch) * path_len(0) + # fillet surface: quarter arc length pi*rf/2, its centroid inset rf - 2rf/pi from the wall + fsurf = PI * rf / 2 * path_len(rf - 2 * rf / PI) + csurf = math.sqrt(2) * ch * path_len(ch / 2) + area = top + bot + wall + fsurf + csurf + write("33-fillet-tangent-chain", + "obround (R8, centres 40 apart) x 10 minus R2 fillet spandrel R^2(1-pi/4) and 1 mm chamfer " + "triangle swept round the whole loop (Pappus: section x path of its centroid, inset " + "R(10-3pi)/(3(4-pi)) resp. c/3); surfaces likewise (fillet arc centroid inset R-2R/pi)", + 1, mp, area, [(-8, -8, 0), (48, 8, 10)], faces=14) + + +def c38(): + mp = cyl(0, 0, 10, 0, 50) + area = 2 * PI * 10 * 50 + 2 * PI * 100 + write("38-coincident-cylinders", + "union of r10 z0..30 and r10 z20..50 = cylinder r10 h50: V = pi r^2 h, A = 2 pi r h + 2 pi r^2", + 1, mp, area, [(-10, -10, 0), (10, 10, 50)], faces=3) + + +def c40(): + r_s, R_t, r_t = 12.0, 22.0, 4.0 + b = box(-30, -30, -10, 30, 30, 0) + hemi = MP.at(2 / 3 * PI * r_s ** 3, [0, 0, 3 * r_s / 8]) + # The sphere (r12) also pokes 2 mm out of the box bottom (z=-10): a spherical cap h=2. + h = r_s - 10.0 + cap = MP.at(PI * h * h * (3 * r_s - h) / 3, [0, 0, -3 * (2 * r_s - h) ** 2 / (4 * (3 * r_s - h))]) + # Torus centred on the box top: only its lower half lies in material. + half_torus = MP.at(PI * r_t ** 2 / 2 * 2 * PI * R_t, [0, 0, -4 * r_t / (3 * PI)]) + mp = b + hemi + cap - half_torus + area = (3600 - PI * (r_s ** 2 - 10.0 ** 2) + 4 * 60 * 10 # bottom (minus cap footprint) + sides + + 3600 - PI * r_s ** 2 - PI * ((R_t + r_t) ** 2 - (R_t - r_t) ** 2) # top + + 2 * PI * r_s ** 2 # upper hemisphere + + 2 * PI * r_s * h # cap below the box + + 2 * PI * R_t * PI * r_t) # half torus (semicircle r_t about R_t) + write("40-box-sphere-torus", + "box 60x60x10 (z -10..0) + upper hemisphere r12 + spherical cap h2 below z=-10 (V = pi h^2(3R-h)/3, " + "A = 2 pi R h) - lower half torus R22 r4 (Pappus: V = pi r^2/2 * 2 pi R, centroid -4r/3pi, " + "A = pi r * 2 pi R); top loses disc r12 and annulus 18..26, bottom loses disc r sqrt(44)", + 1, mp, area, [(-30, -30, -12), (30, 30, 12)], faces=10, + note="faces: bottom, 4 sides, top split by the groove into an annulus (r 12..18) and the outer part, " + "upper sphere, lower cap, torus") + + +def c43(): + mp = box(0, 0, 0, 35, 20, 20) + box(45, 0, 0, 80, 20, 20) + area = 2 * 2 * (35 * 20 + 20 * 20 + 35 * 20) + write("43-cut-splits-body", + "two boxes 35x20x20 (x 0..35 and 45..80): V = 2abc, A = 2*2(ab+bc+ca)", + 2, mp, area, [(0, 0, 0), (80, 20, 20)], faces=12) + + +def c44(): + # L (x 0..60, y 0..8 and x 0..8, y 0..40) x 20; shell 2 inward, top open. + # SolidCraft's shell convention (intersection join): the inner walls are the outer faces + # offset by 2 and extended until they meet, so the cavity is the L shrunk by 2 with a sharp + # corner at (6,6) opposite the reflex edge. Floor at z=2, open at z=20. + Lpts = [(0, 0), (60, 0), (60, 8), (8, 8), (8, 40), (0, 40)] + outer = region([poly(Lpts)]) + cav = region([poly([(2, 2), (58, 2), (58, 6), (6, 6), (6, 38), (2, 38)])]) + mp = extrude(outer, 0, 20) - extrude(cav, 2, 18) + area = (outer["A"] + outer["P"] * 20 # bottom + outer walls + + (outer["A"] - cav["A"]) # top rim + + cav["A"] + cav["P"] * 18) # cavity floor + inner walls + # Rounded (constant-distance, arc) join instead: cavity gains (4-pi) x 18. + rv, ra = mp.v - (4 - PI) * 18, area - (4 - PI) * 18 + write("44-shell-l-bracket", + "L prism (area 736, perimeter 200) x 20 minus cavity = L shrunk by 2 (SolidCraft's intersection-join " + "shell convention: inner faces offset by 2 and extended to meet, sharp inner corner at (6,6); area 352) " + f"from z=2 to 20. A rounded (arc) join would give V {rv:.4f}, A {ra:.4f}, 16 faces", + 1, mp, area, [(0, 0, 0), (60, 40, 20)], faces=15) + + +def c45(): + # Plate 60x60x10 centred + boss r12 z 10..30; shell 2 inward, bottom open. + # Intersection-join convention: cavity = box 56x56 z 0..8 + cylinder r10 z 8..28 (the offset + # cylinder r10 extended down to meet the offset plate top z=8, sharp circular inner edge). + solid = box(-30, -30, 0, 30, 30, 10) + cyl(0, 0, 12, 10, 30) + cav = box(-28, -28, 0, 28, 28, 8) + cyl(0, 0, 10, 8, 28) + mp = solid - cav + area = (3600 - 56 * 56 # bottom rim + + 4 * 60 * 10 # plate sides + + 3600 - PI * 144 # plate top + + 2 * PI * 12 * 20 + PI * 144 # boss side + top + + 4 * 56 * 8 # cavity side walls + + 56 * 56 - PI * 100 # cavity ceiling z=8 (outside r10) + + 2 * PI * 10 * 20 # inner cylinder r10 z8..28 + + PI * 100) # inner top disc z=28 + # Rounded (arc) join: the ring r10..12, z8..10 outside the r2 torus about (12,10) is also hollow. + ring = region([[L((10, 8), (12, 8)), A((12, 10), 2, -PI / 2, -PI), L((10, 10), (10, 8))]]) + rv = mp.v - revolve(ring).v + ra = (area - 2 * PI * 10 * 2 - PI * (144 - 100) + 2 * PI * PI * (12 - 4 / PI)) + write("45-shell-boss-plate", + "plate 60x60x10 + boss r12 h20 minus cavity (box 56x56x8 + cylinder r10 z8..28) under SolidCraft's " + "intersection-join shell convention (offset faces extended to meet: sharp circular inner edge at r10, " + f"z8). A rounded (arc) join would give V {rv:.4f}, A {ra:.4f}, 16 faces (torus round r2)", + 1, mp, area, [(-30, -30, 0), (30, 30, 30)], faces=15) + + +def c46(): + mp = box(0, 0, 0, 50, 40, 30) - box(3, 0, 3, 47, 37, 30) + area = (50 * 40 + 50 * 30 + 2 * 40 * 30 # bottom, back, two sides + + (50 * 40 - 44 * 37) + (50 * 30 - 44 * 27) # top rim, front rim + + 44 * 37 + 44 * 27 + 2 * 37 * 27) # floor, back inner, side inners + write("46-shell-two-open-faces", + "box 50x40x30 minus cavity x3..47, y0..37, z3..30 (3 mm walls, top and front open)", + 1, mp, area, [(0, 0, 0), (50, 40, 30)], faces=10) + + +def c48(): + # Sketch on XZ with y_dir = -Z: profile (r, z) = (sx, -sy). + pts = [(5, 0), (40, 0), (40, 3), (33, 8), (33, 12), (40, 17), (40, 23), (33, 28), (33, 32), + (40, 37), (40, 40), (5, 40)] + reg = region([poly(pts)]) + mp = revolve(reg) + area = revolve_area(reg) + write("48-pulley", + "revolve of the 12-segment (r,z) polygon about Z: V = 2 pi int r dA, Mz = 2 pi int r z dA, " + "A = 2 pi int r ds (Pappus)", + 1, mp, area, [(-40, -40, 0), (40, 40, 40)], faces=12) + + +def c49(): + # Coil: the circle r=2 centred at radius 20 in the meridian plane, screw-swept 3 turns, pitch 8. + Rh, a, p, n = 20.0, 2.0, 8.0, 3.0 + c = p / (2 * PI) + v = n * 2 * PI * Rh * PI * a * a # screw motion: only rotation sweeps volume + # lateral area: n * 2pi * closed-integral sqrt(r^2 + c^2 (dr/ds)^2) ds, r = R + a cos t, dr/ds = -sin t + N = 4096 + s = 0.0 + for k in range(N): + t = 2 * PI * k / N + s += math.sqrt((Rh + a * math.cos(t)) ** 2 + c * c * math.sin(t) ** 2) * a + lat = n * 2 * PI * s * 2 * PI / N + area = lat + 2 * PI * a * a + mp = MP.at(v, [0.0, 0.0, c * n * PI]) # full turns: x,y centroid on the axis; z = c * mean angle + write("49-helical-sweep", + "true helix coil (screw motion of the axial d4 section, R20, pitch 8, 3 turns): " + "V = turns * 2 pi R * pi a^2; lateral A = turns * 2 pi * int sqrt(r^2 + (p/2pi)^2 (dr/ds)^2) ds " + "(periodic trapezoid, exact to round-off) + two end discs; COM z = pitch*turns/2", + 1, mp, area, [(-22, -22, -2), (22, 22, 26)], faces=3) + + +def c50(): + r, Rb = 5.0, 5.0 + l1 = cyl(0, 0, r, 0, 20) + # quarter torus: bend centre (5,0,20), axis Y, from (0,0,20) to (5,0,25). + vb = PI * r * r * (PI / 2 * Rb) + rad_mean = (Rb * Rb + r * r / 4) / Rb # int rho^2 dA / int rho dA + off = rad_mean * (math.sin(PI / 4) / (PI / 4)) / math.sqrt(2) + bend = MP.at(vb, [5 - off, 0, 20 + off]) + l2 = MP.at(PI * r * r * 20, [15, 0, 25]) + mp = l1 + bend + l2 + Lp = 20 + PI / 2 * Rb + 20 + area = 2 * PI * r * Lp + 2 * PI * r * r + write("50-sweep-tight-bend", + "tube r5 along line 20 + R5 quarter bend + line 20: V = pi r^2 L, A = 2 pi r L + 2 pi r^2 " + "(Pappus, valid while bend radius >= r; here equal so the inner side pinches to a line); " + "bend centroid at (R^2 + r^2/4)/R * sinc(pi/4) along the bisector", + 1, mp, area, [(-5, -5, 0), (25, 5, 30)], faces=5) + + +def c52(): + disc = cyl(0, 0, 40, 0, 8) + prof = region([[ + L((0, 8), (8, 8)), + A((8, 10), 2, -PI / 2, -PI), # concave fillet R2 at the boss base + L((6, 10), (6, 18)), L((6, 18), (0, 18)), L((0, 18), (0, 8)), + ]]) + mp = disc + for k in range(6): + th = 2 * PI * k / 6 + mp = mp + revolve(prof, 28 * math.cos(th), 28 * math.sin(th)) + area = (PI * 1600 + 2 * PI * 40 * 8 + PI * 1600 + + 6 * (revolve_area(prof) - 2 * PI * 64)) # boss lateral without its base disc r8; disc top loses it too + write("52-circular-pattern-fillet", + "disc r40 h8 + 6 x (boss r6 h10 with R2 concave base fillet as a solid of revolution, Pappus/Green " + "on its (r,z) profile) at radius 28; disc top loses 6 discs r8", + 1, mp, area, [(-40, -40, 0), (40, 40, 18)], faces=21) + + +def c53(): + cx, cy = 60.0, 10.0 / 3.0 + R = 45.0 - cy + th0 = math.atan2(15 - cy, 100 - cx) + sp = 17.875031 + mp = box(0, 0, 0, 120, 60, 5) + for k in range(7): + th = th0 + k * sp / R + mp = mp - cyl(cx + R * math.cos(th), cy + R * math.sin(th), 3, 0, 5) + area = 2 * (7200 - 7 * PI * 9) + 2 * (120 + 60) * 5 + 7 * 2 * PI * 3 * 5 + write("53-path-pattern", + "plate 120x60x5 minus 7 through holes d6 at arc lengths k*17.875031 along the arc through " + "(100,15),(60,45),(20,15) (centre (60,10/3), R 125/3), translated copies", + 1, mp, area, [(0, 0, 0), (120, 60, 5)], faces=13) + + +def c54(): + # Mirrored + joined: x -30..30, y 0..40, h10, four R5 vertical corner rounds, holes d8 at (+-15,20). + r = 5.0 + outer = [ + L((-25, 0), (25, 0)), A((25, 5), r, -PI / 2, 0), L((30, 5), (30, 35)), A((25, 35), r, 0, PI / 2), + L((25, 40), (-25, 40)), A((-25, 35), r, PI / 2, PI), L((-30, 35), (-30, 5)), A((-25, 5), r, PI, 1.5 * PI), + ] + reg = region([outer, circle_loop((15, 20), 4, False), circle_loop((-15, 20), 4, False)]) + mp = extrude(reg, 0, 10) + area = extrude_area(reg, 10) + write("54-mirror-body-fillets", + "prism h10 of the 60x40 rectangle with four R5 corner rounds and two d8 holes at (+-15,20): " + "V = A h, area = 2A + perimeter*h", + 1, mp, area, [(-30, 0, 0), (30, 40, 10)], faces=12) + + +def c55(): + r, d, half = 3.4, 12.0, math.radians(59) + tip = r / math.tan(half) + mp = (box(0, 0, 0, 40, 30, 15) - cyl(20, 15, r, 15 - d, 15) + - MP.at(PI * r * r * tip / 3, [20, 15, 15 - d - tip / 4])) + area = (2 * 1200 + 2 * (40 * 15 + 30 * 15) - PI * r * r + + 2 * PI * r * d + PI * r * (r / math.sin(half))) + write("55-tapped-hole-cosmetic", + "plate 40x30x15 minus d6.8 cylinder 12 deep (to the shoulder) and 118 deg drill point cone " + "(h = r/tan59, V = pi r^2 h/3, lateral pi r * r/sin59); cosmetic thread adds no geometry", + 1, mp, area, [(0, 0, 0), (40, 30, 15)], faces=8) + + +def c57(): + r, rc, dc, T = 2.75, 5.0, 5.5, 10.0 + mp = box(0, 0, 0, 80, 50, T) + for x in (15, 40, 65): + for y in (15, 35): + mp = mp - cyl(x, y, rc, T - dc, T) - cyl(x, y, r, 0, T - dc) + area = (2 * 4000 + 2 * (80 + 50) * T + + 6 * (-PI * rc * rc - PI * r * r + 2 * PI * rc * dc + PI * (rc * rc - r * r) + 2 * PI * r * (T - dc))) + write("57-hole-pattern-sketch-points", + "plate 80x50x10 minus 6 counterbored through holes (cb d10 x 5.5, hole d5.5) at x 15/40/65, y 15/35", + 1, mp, area, [(0, 0, 0), (80, 50, 10)], faces=24) + + +def c62(): + mp = box(0, 0, 0, 60, 50, 5) + for k in range(15): + x0 = 2 + 4 * k + mp = mp + box(x0, 0, 5, x0 + 1.5, 50, 30) + area = (3000 + 2 * (60 * 5 + 50 * 5) + 3000 - 15 * 1.5 * 50 + + 15 * (2 * 50 * 25 + 2 * 1.5 * 25 + 1.5 * 50)) + write("62-heat-sink", + "base 60x50x5 + 15 fins 1.5x50x25 at x = 2 + 4k: sums of boxes; fin ends coplanar with the base ends", + 1, mp, area, [(0, 0, 0), (60, 50, 30)], faces=66, + note="faces: bottom, x=0, x=60, y=0 and y=50 (each merged with the fin ends), 16 base-top strips, 15x3 fin faces") + + +def c64(): + # Solved sketch: four R12 arcs on chord 20 (sagitta 12 - sqrt 44) alternating up/down from + # (0,0) to (80,0) tangent in a chain, closed by (80,0)-(80,-20)-(0,-20)-(0,0). + R = 12.0 + h = math.sqrt(R * R - 100) # centre distance from the chord + al = math.atan2(h, 10) # half-angle complement + loop = [ + A((10, -h), R, PI - al, al), # a1: (0,0) -> (20,0), bulging up (cw) + A((30, h), R, PI + al, 2 * PI - al), # a2: (20,0) -> (40,0), bulging down (ccw) + A((50, -h), R, PI - al, al), # a3 + A((70, h), R, PI + al, 2 * PI - al), # a4 + L((80, 0), (80, -20)), L((80, -20), (0, -20)), L((0, -20), (0, 0)), + ] + reg = region([reverse(loop)]) # traced clockwise above; reverse to CCW + mp = extrude(reg, 0, 3) + area = extrude_area(reg, 3) + write("64-sketch-tangent-arc-chain", + "solved sketch: 80x20 rectangle whose top is 4 tangent R12 arcs on 20 mm chords alternating " + "up/down (net area 1600); prism h3: V = A h, area = 2A + perimeter*h, Green's theorem for centroid", + 1, mp, area, [(0, -20, 0), (80, R - h, 3)], faces=9) + + +def c66(): + r1, r2, dctr = 12.0, 8.0, 60.0 + al = math.acos((r1 - r2) / dctr) # direction of the outer tangent normals: +-al + outer = [ + A((0, 0), r1, al, 2 * PI - al), + L((r1 * math.cos(al), -r1 * math.sin(al)), (dctr + r2 * math.cos(al), -r2 * math.sin(al))), + A((dctr, 0), r2, -al, al), + L((dctr + r2 * math.cos(al), r2 * math.sin(al)), (r1 * math.cos(al), r1 * math.sin(al))), + ] + holes = [circle_loop((0, 0), 6, False), circle_loop((60, 0), 4, False), + circle_loop((30, 4), 2, False), circle_loop((30, -4), 2, False)] + reg = region([outer] + holes) + mp = extrude(reg, 0, 6) + area = extrude_area(reg, 6) + write("66-sketch-linkage", + "solved sketch: hull of circles R12 at (0,0) and R8 at (60,0) (outer tangent lines, cos a = 4/60) " + "minus holes d12, d8 (concentric) and d4 at (30,+-4) (symmetry keeps radii equal); prism h6", + 1, mp, area, [(-12, -12, 0), (68, 12, 6)], faces=10) + + +CASES = [c32, c33, c38, c40, c43, c44, c45, c46, c48, c49, c50, c52, c53, c54, c55, c57, c62, c64, c66] + +if __name__ == "__main__": + for fn in CASES: + fn() diff --git a/tools/oracle-analytic/cases_sheet_metal.py b/tools/oracle-analytic/cases_sheet_metal.py new file mode 100644 index 00000000..c61a8240 --- /dev/null +++ b/tools/oracle-analytic/cases_sheet_metal.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""Closed-form expected values for the sheet-metal oracle cases (67..76). + +Run from the worktree root: python3 tools/oracle-analytic/cases_sheet_metal.py +It (re)writes tests/oracle//expected.json with "source": "analytic". + +Folded sheet metal is built here from first principles as flat plates (prisms of thickness t) +and bends (annular-sector prisms: inner radius R, outer R + t, angle theta), joined face to +face. The conventions that place them are SolidCraft's documented sheet-metal semantics +(crates/doc/src/sheet.rs module docs, docs/book/src/guide/sheet-metal.md): + + * Steel (mm) rule: t = 2.5, R = Thickness, K = 0.44, corner gap = Thickness. + * Edge flange, height datum "outer faces", bend position "inside": the virtual sharp (where + the outer faces would meet) lies on the picked edge's outer corner; the parent panel is + trimmed back by the setback (R + t) tan(theta/2); the flange's outer face measures + `height` from the virtual sharp (along the flange for a non-90 deg angle); the flat leg + past the bend is height - (R + t) tan(theta/2). + * Several flanges in one command meeting at a 90 deg corner: each bend stops at the trimmed + base corner; each panel runs on past its bend by (R + t) - (t + gap/sqrt 2) so the gap + between the panels' inner corners is the rule gap (corner open, no corner fill). + * A later flange on an adjacent edge trims the base under the earlier bend; that bend stops + at the trimmed base corner and the earlier panel runs on alone (an open corner relief). + * Flat hem: 180 deg bend with inner radius hem_gap = 0.01 mm toward the face it starts from + (inside), length measured from the outer face of the hem bend, leg = L - (gap + t). + * Contour flange: the sketch lines are the sheet face on the side away from the material + (material right of travel); a corner that bends toward the material has the sketch + corner as the outer virtual sharp (setback (R+t)tan), the other as inner (setback R tan). + * Unfold/refold: bend zone of flat width BA = theta (R + K t); a point s into the zone maps + to angle s / (R + K t) on the bend; past the zone, flat distance maps 1:1 to the panel. +""" +import json +import math +import os + +ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) +OUT = os.path.join(ROOT, "tests", "oracle") + +PI = math.pi +STEEL = dict(t=2.5, R=2.5, K=0.44, gap=2.5, hem_gap=0.01) + + +# ---------------------------------------------------------------- 2D section pieces +class Poly: + def __init__(self, pts): + self.pts = pts + + def props(self): + a = cx = cy = per = 0.0 + n = len(self.pts) + for i in range(n): + x0, y0 = self.pts[i] + x1, y1 = self.pts[(i + 1) % n] + c = x0 * y1 - x1 * y0 + a += c + cx += (x0 + x1) * c + cy += (y0 + y1) * c + per += math.hypot(x1 - x0, y1 - y0) + a *= 0.5 + return abs(a), (cx / (6 * a), cy / (6 * a)), per + + def box(self): + xs = [p[0] for p in self.pts] + ys = [p[1] for p in self.pts] + return (min(xs), min(ys)), (max(xs), max(ys)) + + +def Rect(u0, u1, v0, v1): + return Poly([(u0, v0), (u1, v0), (u1, v1), (u0, v1)]) + + +class Sector: + """Annular sector: centre c, radii r0 < r1, angles a0 < a1 (radians, CCW).""" + + def __init__(self, c, r0, r1, a0, a1): + self.c, self.r0, self.r1, self.a0, self.a1 = c, r0, r1, a0, a1 + + def props(self): + d = self.a1 - self.a0 + r0, r1 = self.r0, self.r1 + a = d / 2 * (r1 * r1 - r0 * r0) + rho = (2.0 / 3.0) * (r1 ** 3 - r0 ** 3) / (r1 * r1 - r0 * r0) * math.sin(d / 2) / (d / 2) + m = (self.a0 + self.a1) / 2 + cen = (self.c[0] + rho * math.cos(m), self.c[1] + rho * math.sin(m)) + per = d * (r0 + r1) + 2 * (r1 - r0) + return a, cen, per + + def box(self): + pts = [] + for r in (self.r0, self.r1): + for ang in (self.a0, self.a1): + pts.append((r * math.cos(ang), r * math.sin(ang))) + k = math.ceil(self.a0 / (PI / 2) - 1e-12) + while k * PI / 2 <= self.a1 + 1e-12: + ang = k * PI / 2 + pts.append((self.r1 * math.cos(ang), self.r1 * math.sin(ang))) + k += 1 + xs = [p[0] + self.c[0] for p in pts] + ys = [p[1] + self.c[1] for p in pts] + return (min(xs), min(ys)), (max(xs), max(ys)) + + +class Section: + """Union of pieces meeting face to face; `joints` = total length of shared interfaces.""" + + def __init__(self, pieces, joints): + self.pieces, self.joints = pieces, joints + + def props(self): + A = Su = Sv = P = 0.0 + for p in self.pieces: + a, (cu, cv), per = p.props() + A += a + Su += a * cu + Sv += a * cv + P += per + return A, (Su / A, Sv / A), P - 2 * self.joints + + def box(self): + bs = [p.box() for p in self.pieces] + return (min(b[0][0] for b in bs), min(b[0][1] for b in bs)), (max(b[1][0] for b in bs), max(b[1][1] for b in bs)) + + +def extrusion(segments, to_world): + """A solid swept along one axis w: segments = [(w0, w1, Section)], contiguous, with each + section nested in its neighbours (so a step's cap area is the area difference). + to_world(u, v, w) -> (x, y, z). Returns (V, A, com, bbox).""" + V = A = 0.0 + S = [0.0, 0.0, 0.0] + lo = [1e300] * 3 + hi = [-1e300] * 3 + areas = [] + for w0, w1, sec in segments: + a, (cu, cv), per = sec.props() + L = w1 - w0 + V += a * L + A += per * L + c = to_world(cu, cv, (w0 + w1) / 2) + for i in range(3): + S[i] += a * L * c[i] + (u0, v0), (u1, v1) = sec.box() + for u in (u0, u1): + for v in (v0, v1): + for w in (w0, w1): + p = to_world(u, v, w) + for i in range(3): + lo[i] = min(lo[i], p[i]) + hi[i] = max(hi[i], p[i]) + areas.append(a) + A += areas[0] + areas[-1] + for i in range(len(areas) - 1): + A += abs(areas[i] - areas[i + 1]) + return V, A, [s / V for s in S], (lo, hi) + + +# ---------------------------------------------------------------- 3D prisms (corner cases) +def prism(sec_piece, w0, w1, to_world): + a, (cu, cv), per = sec_piece.props() + L = w1 - w0 + c = to_world(cu, cv, (w0 + w1) / 2) + (u0, v0), (u1, v1) = sec_piece.box() + pts = [to_world(u, v, w) for u in (u0, u1) for v in (v0, v1) for w in (w0, w1)] + lo = [min(p[i] for p in pts) for i in range(3)] + hi = [max(p[i] for p in pts) for i in range(3)] + return dict(V=a * L, A=2 * a + per * L, c=c, lo=lo, hi=hi) + + +def assemble(prisms, contacts, extra_V=0.0, extra_A=0.0, extra_S=(0.0, 0.0, 0.0)): + V = sum(p["V"] for p in prisms) + extra_V + A = sum(p["A"] for p in prisms) - 2 * sum(contacts) + extra_A + com = [(sum(p["V"] * p["c"][i] for p in prisms) + extra_S[i]) / V for i in range(3)] + lo = [min(p["lo"][i] for p in prisms) for i in range(3)] + hi = [max(p["hi"][i] for p in prisms) for i in range(3)] + return V, A, com, (lo, hi) + + +# ---------------------------------------------------------------- shared shapes +def yz(u, v, w): # section in (y, z), swept along x + return (w, u, v) + + +def xz(u, v, w): # section in (x, z), swept along y + return (u, w, v) + + +def edge_flange_section(width_y, height, theta, rv, base_far=None): + """Base plate y in [0, width_y] with an edge flange on its y = 0 top edge (bend up, toward + -y), as YZ section pieces. Returns (pieces, joints, info).""" + t, R = rv["t"], rv["R"] + sb = (R + t) * math.tan(theta / 2) + leg = height - sb + zc = t + R + base = Rect(sb, width_y, 0.0, t) + bend = Sector((sb, zc), R, R + t, -PI / 2 - theta, -PI / 2) + # Panel: flat (s along -y from the bend line, z in [0, t]) turned by theta about the axis. + c, s = math.cos(theta), math.sin(theta) + + def place(sv, z): + uu, ww = sv, z - zc # uu outward (-y) from the axis, ww from the axis + ru, rw = uu * c - ww * s, uu * s + ww * c + return (sb - ru, zc + rw) + + panel = Poly([place(0, 0), place(0, t), place(leg, t), place(leg, 0)]) + return [base, bend, panel], 2 * t, dict(setback=sb, leg=leg) + + +# Cases SolidCraft fails today, with the issue that tracks each (cargo xtask oracle reports them +# as known failures and fails once they pass, so the mark is removed with the fix). +KNOWN_FAILURES = {"75-sm-bracket-holes": "#296"} + + +def write(case, d): + if case in KNOWN_FAILURES: + d["known_failure"] = KNOWN_FAILURES[case] + path = os.path.join(OUT, case, "expected.json") + with open(path, "w") as f: + json.dump(d, f, indent=2) + f.write("\n") + + +def clean(v): + """Drop float noise (e.g. 1e-15 from cos(pi/2)) on bbox coordinates.""" + r = round(v, 9) + return r if abs(v - r) < 1e-12 else v + + +def record(V, A, com, bb, method, faces=None, note=None, body_count=1): + bb = ([clean(v) + 0.0 for v in bb[0]], [clean(v) + 0.0 for v in bb[1]]) + d = { + "source": "analytic", + "method": method, + "body_count": body_count, + "volume_mm3": V, + "area_mm2": A, + "center_of_mass": com, + "bbox": {"min": bb[0], "max": bb[1]}, + } + if faces is not None: + d["faces"] = faces + if note: + d["note"] = note + return d + + +# ---------------------------------------------------------------- cases +def c67(): + t = STEEL["t"] + sec = Section([Rect(0, 60, 0, t)], 0) + V, A, com, bb = extrusion([(0, 100, sec)], yz) + return record(V, A, com, bb, "plate 100 x 60 x t, t = 2.5 (Steel (mm) rule), thickness along +Z: V = 100*60*t, A = 2(ab+bc+ca)", faces=6) + + +def single_flange(width_x, width_y, height, theta, rv, method_extra): + pieces, joints, info = edge_flange_section(width_y, height, theta, rv) + V, A, com, bb = extrusion([(0, width_x, Section(pieces, joints))], yz) + return V, A, com, bb, info + + +def c68(): + rv = STEEL + V, A, com, bb, info = single_flange(100, 60, 20, PI / 2, rv, "") + m = ("extrusion along x (100) of the YZ section: base y 5..60 x t; 90 deg bend annulus R 2.5..5 about (y 5, z 5); " + "flange y 0..2.5, z 5..20. Setback (R+t)tan45 = 5 (inside bend position, outer-face height datum: " + "outer face of flange reaches z = 20); V = L*A_sec, A = 2 A_sec + L P_sec") + return record(V, A, com, bb, m, faces=None, + note="trim/setback and height datum are SolidCraft's documented edge-flange conventions (sheet.rs add_flanges)") + + +def c70(): + rv = STEEL + th = PI / 4 + V, A, com, bb, info = single_flange(100, 60, 15, th, rv, "") + m = ("as 68 with a 45 deg bend: setback (R+t)tan22.5 = %.6f, base y %.6f..60, bend annulus 2.5..5 over 45 deg about " + "(y %.6f, z 5), flat leg 15 - setback = %.6f turned 45 deg; extrusion along x 100" % (info["setback"], info["setback"], info["setback"], info["leg"])) + return record(V, A, com, bb, m, faces=None, + note="SolidCraft convention: flange height (15) is measured along the flange's outer face from the virtual sharp at the original edge (y 0, z 0), so the outer top corner is at (y, z) = (-15 cos45, 15 sin45).") + + +def u_channel(rv, wx, wy, h): + t, R = rv["t"], rv["R"] + sb = R + t + zc = t + R + leg = h - sb + pieces = [ + Rect(sb, wy - sb, 0, t), + Sector((sb, zc), R, R + t, -PI, -PI / 2), + Rect(0, t, zc, zc + leg), + Sector((wy - sb, zc), R, R + t, -PI / 2, 0), + Rect(wy - t, wy, zc, zc + leg), + ] + return pieces, sb, leg + + +def c71(): + rv = dict(t=1.5, R=3.0, K=0.33, gap=1.5) + pieces, sb, leg = u_channel(rv, 80, 50, 20) + V, A, com, bb = extrusion([(0, 80, Section(pieces, 4 * rv["t"]))], yz) + m = ("U channel, rule t 1.5, R 3, K 0.33 (K unused folded): base y 4.5..45.5 x 1.5; two 90 deg bend annuli R 3..4.5; " + "flanges y 0..1.5 and 48.5..50, z 4.5..20; extrusion along x 80") + return record(V, A, com, bb, m, faces=None, + note="the rule edit applies to the whole sheet body (base built before it is 1.5 thick too, as the recipe's edge picks at z = 1.5 state)") + + +def c72(): + rv = STEEL + t, R = rv["t"], rv["R"] + # Sketch lines (world XZ): (0,0)->(40,0)->(40,30)->(70,30), material right of travel (+z, then -x, then +z). + pieces = [ + Rect(0, 40 - (R + t), 0, t), # x 0..35 + Sector((40 - (R + t), R + t), R, R + t, -PI / 2, 0), # centre (35, 5) + Rect(40 - t, 40, R + t, 30 - R), # x 37.5..40, z 5..27.5 + Sector((40 + R, 30 - R), R, R + t, PI / 2, PI), # centre (42.5, 27.5) + Rect(40 + R, 70, 30, 30 + t), # x 42.5..70, z 30..32.5 + ] + V, A, com, bb = extrusion([(0, 50, Section(pieces, 4 * t))], xz) + m = ("contour flange, width 50 along +Y, XZ section: plate x 0..35 z 0..2.5; bend R 2.5..5 about (35, 5) (sketch corner = " + "outer virtual sharp, setback R+t); wall x 37.5..40 z 5..27.5; bend R 2.5..5 about (42.5, 27.5) (sketch corner = inner " + "virtual sharp, setback R); plate x 42.5..70 z 30..32.5") + return record(V, A, com, bb, m, faces=None, + note="SolidCraft convention: the sketch lines are the sheet face away from the material (material right of travel); thickness grows to the material side") + + +def c73(): + rv = STEEL + t, R, g = rv["t"], rv["R"], rv["hem_gap"] + pieces, joints, info = edge_flange_section(60, 20, PI / 2, rv) + L = 8.0 + leg = L - (g + t) + hem_bend = Sector((t + g, 20.0), g, g + t, 0.0, PI) + hem_panel = Rect(t + 2 * g, 2 * t + 2 * g, 20.0 - leg, 20.0) + sec = Section(pieces + [hem_bend, hem_panel], joints + 2 * t) + V, A, com, bb = extrusion([(0, 100, sec)], yz) + m = ("68's part (height 20) plus a flat hem on the flange top edge: 180 deg bend, inner radius = hem gap 0.01, outer 2.51, " + "about (y 2.51, z 20), folded toward +y (inside); hem leg 8 - (0.01 + 2.5) = 5.49: y 2.52..5.02, z 14.51..20; " + "extrusion along x 100") + return record(V, A, com, bb, m, faces=None, + note="SolidCraft conventions: hem gap 0.01 mm (rule hem_gap), hem length measured to the outer face of the hem bend (top at z 22.51), hem folds over the flange's inner face.") + + +def c74(): + rv = STEEL + t, R, K = rv["t"], rv["R"], rv["K"] + sb = R + t + zc = t + R + leg = 20 - sb + BA = PI / 2 * (R + K * t) + # Flat: base y 5..45; y=0 side bend zone y (5-BA)..5, panel y (5-BA-15)..(5-BA). + # Slot y -12..10: base strip 5..10, whole bend, panel up to 12 - (BA - 5) from the bend. + cut_panel = -(-12 - (sb - BA)) # flat distance past the bend zone + zcut = zc + cut_panel + full = Section([ + Rect(sb, 50 - sb, 0, t), + Sector((sb, zc), R, R + t, -PI, -PI / 2), + Rect(0, t, zc, zc + leg), + Sector((50 - sb, zc), R, R + t, -PI / 2, 0), + Rect(50 - t, 50, zc, zc + leg), + ], 4 * t) + cut = Section([ + Rect(10, 50 - sb, 0, t), + Sector((50 - sb, zc), R, R + t, -PI / 2, 0), + Rect(50 - t, 50, zc, zc + leg), + Rect(0, t, zcut, zc + leg), + ], 2 * t) + V, A, com, bb = extrusion([(0, 35, full), (35, 45, cut), (45, 80, full)], yz) + m = ("U channel 80x50 (t 2.5, R 2.5, base y 5..45, flanges z 5..20) with the flat-pattern slot x 35..45, y -12..10 refolded: " + "BA = (pi/2)(R + K t) = %.9f, so the slot removes base y 5..10, the whole y=0 bend and the flange from z 5 to " + "5 + (12 - (BA - 5)) = %.9f; x-segments 0..35 / 35..45 / 45..80" % (BA, zcut)) + return record(V, A, com, bb, m, faces=None, + note="unfold/refold maps flat distance through the K-factor neutral layer (K 0.44)") + + +def c75(): + rv = STEEL + t, R = rv["t"], rv["R"] + sb = R + t + zc = t + R + leg1, leg2 = 30 - sb, 15 - sb + P = [] + P.append(prism(Rect(sb, 40, 0, t), 0, 80 - sb, yz)) # base x 0..75, y 5..40 + P.append(prism(Sector((sb, zc), R, R + t, -PI, -PI / 2), 0, 80 - sb, yz)) # bend 1, x 0..75 + P.append(prism(Rect(0, t, zc, zc + leg1), 0, 80, yz)) # flange 1, x 0..80 + P.append(prism(Sector((80 - sb, zc), R, R + t, -PI / 2, 0), sb, 40, xz)) # bend 2, y 5..40 + P.append(prism(Rect(80 - t, 80, zc, zc + leg2), sb, 40, xz)) # flange 2, y 5..40 + contacts = [(80 - sb) * t, (80 - sb) * t, (40 - sb) * t, (40 - sb) * t] + r, n = 3.0, 2 + dV = -n * PI * r * r * t + dA = n * (2 * PI * r * t - 2 * PI * r * r) + dS = (-PI * r * r * t * (25 + 55), -n * PI * r * r * t * 20, -n * PI * r * r * t * t / 2) + V, A, com, bb = assemble(P, contacts, dV, dA, dS) + m = ("base x 0..75, y 5..40, z 0..2.5 (trimmed 5 by each flange's setback); bend 1 (R 2.5..5 about y 5, z 5) x 0..75; " + "flange 1 y 0..2.5, z 5..30, x 0..80; bend 2 (R 2.5..5 about x 75, z 5) y 5..40; flange 2 x 77.5..80, z 5..15, " + "y 5..40; minus two d6 through holes at (25,20), (55,20) in the base. A = sum of prism areas - 2 x 4 joint " + "areas (75t, 75t, 35t, 35t) + hole walls - hole discs") + return record(V, A, com, bb, m, faces=None, + note="SolidCraft convention: the second flange trims the base under the first bend, so bend 1 stops at x 75 while flange 1's panel runs to x 80 (open corner relief, no fill).") + + +def c69(): + rv = STEEL + t, R, gap = rv["t"], rv["R"], rv["gap"] + sb = R + t + zc = t + R + leg = 25 - sb + ext = sb - (t + gap / math.sqrt(2)) + X, Y = 100.0, 60.0 + P = [prism(Rect(sb, Y - sb, 0, t), sb, X - sb, yz)] # base x 5..95, y 5..55 + # y = 0 and y = 60 flanges (swept along x) + P.append(prism(Sector((sb, zc), R, R + t, -PI, -PI / 2), sb, X - sb, yz)) + P.append(prism(Rect(0, t, zc, zc + leg), sb - ext, X - sb + ext, yz)) + P.append(prism(Sector((Y - sb, zc), R, R + t, -PI / 2, 0), sb, X - sb, yz)) + P.append(prism(Rect(Y - t, Y, zc, zc + leg), sb - ext, X - sb + ext, yz)) + # x = 0 and x = 100 flanges (swept along y) + P.append(prism(Sector((sb, zc), R, R + t, -PI, -PI / 2), sb, Y - sb, xz)) + P.append(prism(Rect(0, t, zc, zc + leg), sb - ext, Y - sb + ext, xz)) + P.append(prism(Sector((X - sb, zc), R, R + t, -PI / 2, 0), sb, Y - sb, xz)) + P.append(prism(Rect(X - t, X, zc, zc + leg), sb - ext, Y - sb + ext, xz)) + lx, ly = X - 2 * sb, Y - 2 * sb + contacts = [lx * t] * 4 + [ly * t] * 4 + V, A, com, bb = assemble(P, contacts) + m = ("tray: base 90 x 50 x 2.5 (each edge trimmed by the setback 5); four 90 deg bends R 2.5..5 along the trimmed edges " + "(90, 50, 90, 50 long); four walls 2.5 thick, z 5..25, each running on past its bend by " + "ext = (R+t) - (t + gap/sqrt2) = %.9f at both ends; open corners. A = sum of prisms - 2 x joints" % ext) + return record(V, A, com, bb, m, faces=46, + note="corner treatment is SolidCraft's convention (sheet.rs close_corners: panels extended until their inner corners are the rule gap = t apart; no corner fill, open corner relief below the walls)") + + +def c76(): + rv = dict(t=3.0, R=3.0, K=0.44, gap=3.0) + V, A, com, bb, info = single_flange(100, 60, 20, PI / 2, rv, "") + m = ("final rule thickness 3 (bend radius = Thickness = 3): base y 6..60 x 3; bend annulus R 3..6 about (y 6, z 6); " + "flange y 0..3, z 6..20; extrusion along x 100") + return record(V, A, com, bb, m, faces=None, + note="the rule's thickness edits regenerate the whole sheet body; only the final 3 mm state matters") + + +CASES = { + "67-sm-base-flange": c67, + "68-sm-edge-flange": c68, + "69-sm-tray": c69, + "70-sm-flange-45": c70, + "71-sm-custom-rule": c71, + "72-sm-contour-flange": c72, + "73-sm-hem": c73, + "74-sm-unfold-refold": c74, + "75-sm-bracket-holes": c75, + "76-sm-thickness-variant": c76, +} + +if __name__ == "__main__": + for case, fn in CASES.items(): + d = fn() + write(case, d) + print(case, d["volume_mm3"], d["area_mm2"], [round(c, 6) for c in d["center_of_mass"]]) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c3f488ec..358728e8 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -22,12 +22,10 @@ commands: parity [--refresh] recompute Fusion command parity in docs/parity.md (--refresh rebuilds xtask/data/fusion-catalog.tsv from plan/fusion/menu-tree.json) - oracle replay plan/fusion/oracle/*/recipe.json and compare with measure.json; - writes docs/oracle.md - step-corpus [--iges] [DIR] - import every DIR/*/part.step (default plan/fusion/oracle) and compare - with measure.json; writes docs/step-import.md (--iges: part.igs, - docs/iges-import.md) + oracle [--cli BIN] + replay the reference tests (tests/oracle/*/recipe.json), compare with each + case's expected.json and write docs/oracle.md (--cli: a solvecraft-cli + already built, instead of cargo run --release) book [--no-build] generate docs/book's command reference and screenshots, then mdbook build into target/book @@ -49,8 +47,7 @@ fn main() -> ExitCode { Some("layers") => cmd_layers(), Some("assets") => assets::run(&root()), Some("parity") => parity::run(&root(), rest.contains(&"--refresh")), - Some("oracle") => cmd_oracle(), - Some("step-corpus") => cmd_step_corpus(rest.iter().find(|a| !a.starts_with("--")).copied(), rest.contains(&"--iges")), + Some("oracle") => cmd_oracle(rest.iter().position(|a| *a == "--cli").and_then(|i| rest.get(i + 1)).copied()), Some("book") => book::run(&root(), !rest.contains(&"--no-build")), Some("licences") => licences::run(&root(), rest.contains(&"--check")), Some("test") => { @@ -134,7 +131,8 @@ fn cmd_layers() -> Result<(), String> { } } -/// Features with no Fusion part to compare with, and the tests that check them analytically. +/// Features with no reference case yet, and the tests that check them against hand-computed +/// geometry. const NO_ORACLE: [(&str, &str); 5] = [ ("Boss (draft, root fillet, hole, ribs)", "post, hole and rib volumes (`boss_with_hole_fillet_and_ribs`)"), ("Lip / Groove", "band offsets and groove clearance on a shelled box (`lip_and_groove_on_a_shelled_box`, `groove_takes_the_rule_clearance`)"), @@ -146,41 +144,76 @@ const NO_ORACLE: [(&str, &str); 5] = [ ), ]; -/// Run the CLI's oracle replay over every case directory and write docs/oracle.md. -fn cmd_oracle() -> Result<(), String> { - let dir = root().join("plan/fusion/oracle"); +/// Replay every reference case in tests/oracle, compare with its expected.json and write +/// docs/oracle.md. Fails when a case fails, unless its expected.json names the issue that +/// tracks it (`known_failure`); a known failure that passes fails too, so the mark is removed. +fn cmd_oracle(cli: Option<&str>) -> Result<(), String> { + let dir = root().join("tests/oracle"); let mut cases: Vec = std::fs::read_dir(&dir) - .map_err(|e| format!("{}: {e} (the oracle data is local, in plan/fusion/oracle)", dir.display()))? + .map_err(|e| format!("{}: {e}", dir.display()))? .flatten() .map(|e| e.path()) - .filter(|p| p.join("recipe.json").is_file() && p.join("measure.json").is_file()) + .filter(|p| p.join("recipe.json").is_file()) .collect(); cases.sort(); if cases.is_empty() { - return Err("no oracle cases with recipe.json and measure.json".into()); + return Err(format!("no cases with recipe.json in {}", dir.display())); } - let mut cmd = cargo(); - cmd.args(["run", "-q", "--release", "-p", "solvecraft-cli", "--", "oracle", "--json"]); + let mut cmd = match cli { + Some(bin) => { + let mut c = Command::new(bin); + c.current_dir(root()); + c + } + None => { + let mut c = cargo(); + c.args(["run", "-q", "--release", "-p", "solvecraft-cli", "--"]); + c + } + }; + cmd.args(["oracle", "--json"]); for c in &cases { - cmd.arg(c); + cmd.arg(c.strip_prefix(root()).unwrap_or(c)); } let out = cmd.output().map_err(|e| format!("solvecraft-cli: {e}"))?; let results: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)))?; let list = results.as_array().cloned().unwrap_or_default(); - let pass = list.iter().filter(|r| r["pass"].as_bool().unwrap_or(false)).count(); - let skipped = list.iter().filter(|r| r["skipped"].as_bool().unwrap_or(false)).count(); - let mut md = format!( - "# Fusion oracle\n\nGenerated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe and measurements kept locally in `plan/fusion/oracle/`, not committed). SolidCraft replays the recipe through its command engine and compares body count, volume and area (relative tolerance 1e-3) and face/edge/vertex counts (exact). SolidCraft counts each closed-surface patch once and ignores seam edges and vertices; when body count, volume and area pass but the raw counts differ, Fusion's counts are normalised the same way from its face, edge and vertex lists (faces cut from one cylinder, cone, sphere or torus and separated only by seams count once; an edge on that surface and no other is a seam and is dropped; a vertex left with two edges joins them) and compared exactly; the note gives both sides' raw counts.\n\n**{pass} / {} cases pass** ({skipped} need features not implemented yet).\n\n| Case | Result | Notes |\n|---|---|---|\n", - list.len() - ); + let expected = |case: &str| -> serde_json::Value { + std::fs::read_to_string(dir.join(case).join("expected.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() + }; + let (mut pass, mut missing, mut known, mut failed, mut fixed) = (0, 0, 0, Vec::new(), Vec::new()); + let mut sources: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let mut rows = String::new(); for r in &list { - let status = if r["pass"].as_bool().unwrap_or(false) { - "pass" - } else if r["skipped"].as_bool().unwrap_or(false) { - "not yet" - } else { - "FAIL" + let case = r["case"].as_str().unwrap_or(""); + let want = expected(case); + let source = want["source"].as_str().unwrap_or("none yet").to_string(); + let source_kind = source.split_whitespace().next().unwrap_or("").to_string(); + *sources.entry(if want.is_null() { "none yet".into() } else { source_kind }).or_default() += 1; + let ok = r["pass"].as_bool().unwrap_or(false); + let issue = want["known_failure"].as_str().map(str::to_string); + let status = match (ok, want.is_null(), &issue) { + (_, true, _) => { + missing += 1; + "no expected values yet".to_string() + } + (true, _, Some(i)) => { + fixed.push(format!("{case} ({i})")); + format!("pass (marked known failure {i}: remove the mark)") + } + (true, _, None) => { + pass += 1; + "pass".to_string() + } + (false, _, Some(i)) => { + known += 1; + format!("known failure ({i})") + } + (false, _, None) => { + failed.push(case.to_string()); + "**FAIL**".to_string() + } }; let mut notes: Vec = r["checks"] .as_array() @@ -189,21 +222,43 @@ fn cmd_oracle() -> Result<(), String> { .filter(|c| !c["ok"].as_bool().unwrap_or(false)) .map(|c| format!("{}: got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"])) .collect(); - if let Some(e) = r["error"].as_str() { - notes.push(e.replace('|', "/")); + if let Some(e) = r["error"].as_str().filter(|_| !want.is_null()) { + notes.push(e.to_string()); } - if let Some(n) = r["note"].as_str() { - notes.push(n.replace('|', "/")); + if let Some(n) = want["note"].as_str() { + notes.push(n.to_string()); } - md += &format!("| {} | {status} | {} |\n", r["case"].as_str().unwrap_or(""), notes.join("; ")); + let how = want["method"].as_str().unwrap_or(""); + let surface = r["checks"] + .as_array() + .and_then(|cs| cs.iter().find(|c| c["check"] == "surface_distance_mm")) + .and_then(|c| Some((c["got"]["max"].as_f64()?, c["got"]["mean"].as_f64()?, c["got"]["worst_at"].as_array()?.clone()))) + .map(|(max, mean, at)| { + let at: Vec = at.iter().filter_map(|x| x.as_f64()).map(|x| format!("{x:.1}")).collect(); + format!("{max:.3} / {mean:.4} (worst at {})", at.join(", ")) + }) + .unwrap_or_default(); + rows += &format!("| {case} | {source} | {status} | {surface} | {} | {} |\n", how.replace('|', "/"), notes.join("; ").replace('|', "/")); } - md += "\n## No oracle\n\n| Feature | Status | Tested by |\n|---|---|---|\n"; + let counts: Vec = sources.iter().map(|(k, v)| format!("{v} {k}")).collect(); + let mut md = format!( + "# Reference tests\n\nGenerated by `cargo xtask oracle`. Each case in [`tests/oracle/`](../tests/oracle/) is a part described by our own `recipe.json`. SolidCraft replays the recipe through its command engine and compares the result with `expected.json`: body count, volume and area (relative tolerance 1e-3 unless the case states another), centre of mass and bounding box (within 1e-3 of the part's size), face count where the geometry fixes it, and, where the case has a `reference.stl`, the sampled symmetric (Hausdorff) distance between the two surfaces and the principal second moments of both triangulations. Every rebuilt part is also exported as STEP, validated and read back.\n\nWhere the expected values come from (the **Source** column):\n\n- **analytic**: closed-form geometry worked out by hand (prisms, cylinders, cones, spheres, tori and Pappus' theorems, constant-radius fillets on straight edges, sheet-metal bends as annular sectors). The scripts in [`tools/oracle-analytic/`](../tools/oracle-analytic/) compute them and give the formulas.\n- **freecad <version>**: the same recipe built with FreeCAD's public Python scripting API by [`tools/freecad-oracle/`](../tools/freecad-oracle/), an independent kernel run as an external program. FreeCAD is not a dependency of SolidCraft. Where the two kernels legitimately build a different shape (for example the corner patch where fillets meet), only volume and area are compared, within the tolerance the case states.\n\nNo expected value comes from a commercial CAD product.\n\n**{pass} / {} cases pass**; sources: {}. {known} known failure(s) tracked in issues; {missing} case(s) without expected values yet.\n\n| Case | Source | Result | Surface distance mm (max / mean) | Method | Notes |\n|---|---|---|---|---|---|\n", + list.len(), + counts.join(", ") + ); + md += &rows; + md += "\n## Not covered by a reference case\n\n| Feature | Tested by |\n|---|---|\n"; for (feature, test) in NO_ORACLE { - md += &format!("| {feature} | no oracle (licence) | {test} |\n"); + md += &format!("| {feature} | {test} |\n"); } - md += "\nFusion builds these only with its Design Extension, which the oracle account lacks, so there is no Fusion part to compare with. They follow Autodesk's public descriptions of the features and are checked against hand-computed geometry; the plastic rule library values were read from Fusion's API.\n"; std::fs::write(root().join("docs/oracle.md"), md).map_err(|e| format!("docs/oracle.md: {e}"))?; - println!("oracle: {pass}/{} cases pass ({skipped} not yet supported) → docs/oracle.md", list.len()); + println!("oracle: {pass}/{} cases pass, {known} known failures, {missing} without expected values → docs/oracle.md", list.len()); + if !failed.is_empty() { + return Err(format!("{} case(s) failed: {}", failed.len(), failed.join(", "))); + } + if !fixed.is_empty() { + return Err(format!("now passing, remove `known_failure` from expected.json: {}", fixed.join(", "))); + } Ok(()) } @@ -231,108 +286,6 @@ fn wasm_check() -> Result<(Command, usize), String> { Ok((c, set.len())) } -/// Import every oracle STEP file (exported by Fusion) and compare with Fusion's measurements; -/// write docs/step-import.md. -fn cmd_step_corpus(dir: Option<&str>, iges: bool) -> Result<(), String> { - let dir = dir.map(PathBuf::from).unwrap_or_else(|| root().join("plan/fusion/oracle")); - let file = if iges { "part.igs" } else { "part.step" }; - let mut cases: Vec = std::fs::read_dir(&dir) - .map_err(|e| format!("{}: {e} (the oracle data is local, in plan/fusion/oracle)", dir.display()))? - .flatten() - .map(|e| e.path()) - .filter(|p| p.join(file).is_file() && p.join("measure.json").is_file()) - .collect(); - cases.sort(); - if cases.is_empty() { - return Err(format!("no cases with {file} and measure.json in {}", dir.display())); - } - let mut cmd = cargo(); - cmd.args(["run", "-q", "--release", "-p", "solvecraft-cli", "--", "step-corpus", "--json"]); - if iges { - cmd.arg("--iges"); - } - for c in &cases { - cmd.arg(c); - } - let out = cmd.output().map_err(|e| format!("solvecraft-cli: {e}"))?; - let results: serde_json::Value = - serde_json::from_slice(&out.stdout).map_err(|e| format!("step-corpus JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)))?; - let list = results.as_array().cloned().unwrap_or_default(); - let pass = list.iter().filter(|r| r["pass"].as_bool().unwrap_or(false)).count(); - let check = - |r: &serde_json::Value, name: &str| r["checks"].as_array().and_then(|c| c.iter().find(|c| c["check"] == name).cloned()).unwrap_or_default(); - let num = |v: &serde_json::Value| v.as_f64().map(|x| format!("{x:.3}")).unwrap_or_else(|| "–".into()); - let mut md = if iges { - format!( - "# IGES import (Fusion corpus)\n\nGenerated by `cargo xtask step-corpus --iges`. Each case is a reference part exported from Fusion as IGES 5.3 \ -(kept locally in `plan/fusion/oracle/`, never committed). SolidCraft opens `part.igs` exactly as File → Open does and compares with Fusion's \ -measurements of the part it exported: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file. \ -How IGES is read: see [STEP import](step-import.md#iges). 50-sweep-tight-bend is written by Fusion as five loose, partly degenerate surfaces \ -(seams running into slits, a ring given twice); Fusion cannot read it back as a solid either.\n\n**{pass} / {} files pass.**\n\n\ -| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes |\n|---|---|---|---|---|---|---|---|\n", - list.len() - ) - } else { - format!( - "# STEP import\n\n\ -SolidCraft reads STEP (ISO 10303-21; AP203, AP214 and AP242 geometry) with its own reader in `crates/kernel/src/step_in` \ -(written from the public standards; no third-party STEP code). A STEP file becomes an **Import** base feature on the \ -timeline: it holds the file's B-rep (the STEP text is kept in the design, so the design reopens without the file) and \ -later features — fillets, holes, cuts sketched on its faces, shells — build on its bodies.\n\n\ -- **Open**: File → Open (`doc.open`) accepts `.step`/`.stp` in any case and starts a new design; `solvecraft part.step`; \ -`solvecraft-cli eval part.step`, `solvecraft-cli run script.json --in part.step`; the MCP `open` tool.\n\ -- **Insert** into the current design: File → Insert STEP… or SOLID → INSERT (`file.insert_step {{path}}`).\n\ -- **Drag and drop** a STEP file on the window: inserted when the design has features, opened otherwise.\n\n\ -Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, \ -tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and \ -linear extrusion, trimmed surfaces; lines, circles, ellipses, B-spline curves, polylines, trimmed/surface/seam curves; \ -length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the \ -solid's name, else the product's); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined \ -transformations, and mapped items) — the product tree is kept in the feature for the components milestone. Files are \ -untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a \ -warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges.\n\n\ -## IGES\n\n\ -IGES 5.3 files (`.igs`/`.iges`) open, insert and drop the same way: `crates/kernel/src/iges_in.rs` (written from the \ -published specification) restates the file as STEP and the STEP reader reads it, so the feature holds STEP text. Read: \ -manifold solid B-reps (186) with their shells, faces, loops, edge and vertex lists; trimmed (144) and bounded (143) \ -surfaces, and bare B-spline patches, sewn into a solid by their shared edges (an open body when they do not close); \ -lines, circular arcs, ellipses, composite curves, rational B-spline curves; planes (108, 190), cylinders, cones, \ -spheres, tori, rational B-spline surfaces, surfaces of revolution and tabulated cylinders; transformation matrices; \ -units; colours (314 and the predefined numbers); body names (name properties, else labels). Not read yet: ruled and \ -offset surfaces, curves on surfaces given only in parameter space. Export (`file.export` to `.igs`) writes each body as \ -a manifold solid B-rep on the same surfaces a STEP export uses, with its name and colour.\n\n\ -## Fusion corpus\n\nGenerated by `cargo xtask step-corpus`. Each case is a part exported from Fusion as STEP (AP214; kept locally in `plan/fusion/oracle/`, never committed). SolidCraft opens `part.step` exactly as File → Open does (an Import base feature) and compares with Fusion's own measurements: body count, volume and area (relative tolerance 1e-3) and the number of faces read from the file.\n\n**{pass} / {} files pass.**\n\n| Case | Result | Bodies | Volume mm³ (got / Fusion) | Area mm² (got / Fusion) | Faces (got / Fusion) | ms | Notes |\n|---|---|---|---|---|---|---|---|\n", - list.len() - ) - }; - for r in &list { - let status = if r["pass"].as_bool().unwrap_or(false) { "pass" } else { "FAIL" }; - let (v, a, f) = (check(r, "volume_mm3"), check(r, "area_mm2"), check(r, "faces")); - let bodies: Vec = r["bodies"].as_array().into_iter().flatten().filter_map(|b| b.as_str().map(str::to_string)).collect(); - let mut notes: Vec = r["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str().map(|w| w.replace('|', "/"))).collect(); - if let Some(e) = r["error"].as_str() { - notes.push(e.replace('|', "/")); - } - md += &format!( - "| {} | {status} | {} | {} / {} | {} / {} | {} / {} | {:.0} | {} |\n", - r["case"].as_str().unwrap_or(""), - bodies.join(", "), - num(&v["got"]), - num(&v["want"]), - num(&a["got"]), - num(&a["want"]), - f["got"].as_f64().map(|x| format!("{x}")).unwrap_or_else(|| "–".into()), - f["want"].as_f64().map(|x| format!("{x}")).unwrap_or_else(|| "–".into()), - r["ms"].as_f64().unwrap_or(0.0), - notes.join("; ") - ); - } - let out_md = if iges { "docs/iges-import.md" } else { "docs/step-import.md" }; - std::fs::write(root().join(out_md), md).map_err(|e| format!("{out_md}: {e}"))?; - println!("step-corpus: {pass}/{} files pass → {out_md}", list.len()); - if pass == list.len() { Ok(()) } else { Err(format!("{} file(s) failed", list.len() - pass)) } -} - /// The quick gates first, so they fail fast; the wasm check (and doc tests) run while the test /// binaries do, which leaves cargo's build lock free. fn cmd_ci() -> Result<(), String> {