Compare commits

...
14 Commits
Author SHA1 Message Date
bherbruck 683d45c28d Release SolveCraft 0.1.5 2026-10-10 19:34:26 -04:00
bherbruck 13dd4566ce Merge pull request #102 from CypherNaught-0x/fix/chamfer-sloped-rims
Fix chamfers on sloped end faces and enforce rim clearance
2026-10-10 19:28:48 -04:00
CypherNaugh_0x e575bded0e Fix construction-plane references in mirror, section, and sketches (#99)
Accept construction planes by name or feature ID, preserve parametric mirror references, and reject invalid plane inputs. Retain both mirror-plane and primitive-point regression test modules after resolving the conflict with main.

Validation: cargo xtask ci passed, including 282 engine tests, 138 UI tests, 62 UI scenarios, and WebAssembly.
2026-10-10 19:20:51 -04:00
bherbruck 1a1838c114 Merge pull request #106 from N3MI-DG/view_cube_label_size
View cube: larger face labels
2026-10-10 19:15:10 -04:00
N3MI-DGandClaude Opus 5.5 ff1453f868 View cube face labels are larger: one font size for all six, the widest (BOTTOM) spanning 90% of its face, instead of a fixed 9.5 px that left the names small on the cube. Tests: BOTTOM spans 90% of the face seen straight on, and every label stays within its face from any direction
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:06:05 +11:00
bherbruck e3ee1b359e Fix cubic input-name repair when loading large designs 2026-10-10 19:04:19 -04:00
CypherNaugh_0x 15bb64c2ab Reject chamfer extensions beyond trimmed rim faces 2026-10-11 00:50:20 +02:00
bherbruck feaed2e3f6 Merge pull request #97 from CypherNaught-0x/fix/dimension-measured-after-solve
sketch.dimension reports the measured value after solving
2026-10-10 18:49:25 -04:00
CypherNaugh_0xandClaude Haiku 5.5 f33efa2b6e The timeline scrolls sideways when its features don't fit the window: with more features than the window is wide, the features past the right edge were clipped and could not be reached
Cause: the feature buttons are placed by absolute x coordinates and take no layout space, so nothing in the panel could scroll. The playback controls and the feature strip shared one row with no scroll area.

Fix: the feature strip (after the playback controls) is a horizontal ScrollArea. The content width is allocated from the drawn features and joints so the scroll range matches them. Clicks, drags, the history marker, and context menus work inside the scrolled strip; the playback controls stay fixed.

Tests: cargo xtask ci passes. Checked by hand in the app with a long feature list. Not covered: auto-scroll while dragging a feature or the marker past the visible edge.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-10 18:37:48 -04:00
CypherNaugh_0x c61b77613a solid.cylinder, box, sphere, torus and coil place by expressions: base, corner and center take numbers or length expressions per coordinate ("basin_width/2", "10 mm") and follow parameter changes, renames and component frames; before, a point that was not three plain numbers (or an unreadable axis) was silently replaced by the origin (or Z). The features now store the point as expressions (plain numbers still saved as numbers, so existing designs read and write the same) and evaluate it on every recompute; an unreadable or out-of-range point or axis is refused with an error. Tests: primitive points take expressions, follow parameters, renames and a moved component, and bad points are refused 2026-10-10 18:37:22 -04:00
CypherNaugh_0x 9c6f9410d2 Fix equal-distance chamfers on sloped end faces 2026-10-11 00:30:28 +02:00
bherbruck 31dadcee63 Add nightly prereleases at midnight Eastern, skipping unchanged commits 2026-10-10 18:26:06 -04:00
CypherNaugh_0x ade30e9c4e Merge origin/main (with #86): sketch.dimension reports the solved value in the dimension's unit; the spout test expects the solved 30 and 60 degrees 2026-10-11 00:13:51 +02:00
CypherNaugh_0x 9c09e82cff sketch.dimension reports measured on the solved sketch: it reported the value the geometry had before the new dimension was solved, so dimensioning a 99.84 mm trough line to 100 answered measured: 99.84 and looked like the dimension had not taken. The command now measures the new dimension again once the sketch is solved (driven dimensions too), through a new solvecraft_sketch::measure_dimension that shares the solver's own measuring. Tests: lengths, a driven distance and a diameter report their solved values 2026-10-11 00:01:19 +02:00
32 changed files with 1310 additions and 325 deletions

No files matched your search

+39 -1
View File
@@ -3,6 +3,7 @@
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version # Push to `release` to build packages and create/update a DRAFT GitHub Release. The version
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes # comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases. # the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet. # A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
# #
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux # Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
@@ -15,6 +16,9 @@
name: Release name: Release
on: on:
schedule:
- cron: '0 0 * * *'
timezone: America/New_York
push: push:
branches: [release] branches: [release]
workflow_dispatch: workflow_dispatch:
@@ -39,17 +43,30 @@ jobs:
version: version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
build: ${{ steps.changed.outputs.build }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }} date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }} prerelease: ${{ steps.v.outputs.prerelease }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: changed
name: Skip unchanged nightly builds
env:
GH_TOKEN: ${{ github.token }}
run: bash packaging/nightly.sh
- id: v - id: v
if: steps.changed.outputs.build == 'true'
name: Resolve version name: Resolve version
env: env:
REQUEST_DRAFT: ${{ inputs.draft || false }} REQUEST_DRAFT: ${{ inputs.draft || false }}
run: | run: |
. packaging/env.sh . packaging/env.sh
if [ "$GITHUB_EVENT_NAME" = schedule ]; then
# Unique SemVer without committing daily version bumps to main.
VERSION="${VERSION%%-*}-nightly.$(TZ=America/New_York date +%Y%m%d).$GITHUB_RUN_NUMBER.$GITHUB_RUN_ATTEMPT"
fi
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1" echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
exit 1 exit 1
@@ -65,6 +82,8 @@ jobs:
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the # The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own. # Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons: icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -85,6 +104,7 @@ jobs:
linux: linux:
name: Linux ${{ matrix.arch }} name: Linux ${{ matrix.arch }}
needs: version needs: version
if: needs.version.outputs.build == 'true'
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35. # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
strategy: strategy:
@@ -135,6 +155,7 @@ jobs:
flatpak: flatpak:
name: Flatpak ${{ matrix.arch }} name: Flatpak ${{ matrix.arch }}
needs: [version, linux] needs: [version, linux]
if: needs.version.outputs.build == 'true'
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder than 22.04 ships. # build here. 24.04 for a newer flatpak-builder than 22.04 ships.
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
@@ -172,6 +193,7 @@ jobs:
freebsd: freebsd:
name: FreeBSD x86_64 name: FreeBSD x86_64
needs: version needs: version
if: needs.version.outputs.build == 'true'
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM. # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 180 timeout-minutes: 180
@@ -212,6 +234,7 @@ jobs:
macos: macos:
name: macOS universal name: macOS universal
needs: version needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-14 runs-on: macos-14
env: env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }} SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
@@ -235,6 +258,7 @@ jobs:
windows: windows:
name: Windows ${{ matrix.arch }} name: Windows ${{ matrix.arch }}
needs: [version, icons] needs: [version, icons]
if: needs.version.outputs.build == 'true'
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -277,6 +301,7 @@ jobs:
web: web:
name: Web (wasm) name: Web (wasm)
needs: version needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }} SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
@@ -302,8 +327,9 @@ jobs:
if-no-files-found: error if-no-files-found: error
release: release:
name: Draft GitHub Release or dry-run artifacts name: GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, freebsd, macos, windows, web] needs: [version, linux, flatpak, freebsd, macos, windows, web]
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -323,6 +349,18 @@ jobs:
run: | run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt) (cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
- name: Publish nightly pre-release
if: github.event_name == 'schedule'
working-directory: artifacts
run: |
tag="v$VERSION"
sha256sum --check SHA256SUMS.txt
# Stage all packages before publishing. Published tags are never replaced.
gh release create "$tag" --draft --prerelease --latest=false \
--title "SolveCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
--notes-file ../notes.md --generate-notes -- *
gh release edit "$tag" --draft=false --prerelease --latest=false
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
- name: Create or update the draft release - name: Create or update the draft release
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft) if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
working-directory: artifacts working-directory: artifacts
Generated
+13 -13
View File
@@ -3057,7 +3057,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft" name = "solvecraft"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-cli" name = "solvecraft-cli"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"solvecraft-engine", "solvecraft-engine",
@@ -3080,7 +3080,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-doc" name = "solvecraft-doc"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3094,7 +3094,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-engine" name = "solvecraft-engine"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3110,7 +3110,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-geom" name = "solvecraft-geom"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -3118,7 +3118,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-io" name = "solvecraft-io"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"quick-xml 0.38.4", "quick-xml 0.38.4",
"solvecraft-doc", "solvecraft-doc",
@@ -3130,7 +3130,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-kernel" name = "solvecraft-kernel"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3145,7 +3145,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-mcp" name = "solvecraft-mcp"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"solvecraft-engine", "solvecraft-engine",
@@ -3153,7 +3153,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-render" name = "solvecraft-render"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"image", "image",
"serde", "serde",
@@ -3162,7 +3162,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-sketch" name = "solvecraft-sketch"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -3173,7 +3173,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-ui-egui" name = "solvecraft-ui-egui"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"egui", "egui",
"egui-wgpu", "egui-wgpu",
@@ -3187,7 +3187,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-web" name = "solvecraft-web"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -4486,7 +4486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"serde_json", "serde_json",
] ]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"] exclude = ["vendor"]
[workspace.package] [workspace.package]
version = "0.1.4" version = "0.1.5"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.90" rust-version = "1.90"
+2 -1
View File
@@ -66,7 +66,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge, - Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors, chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square; neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
+34
View File
@@ -3,6 +3,7 @@
//! ```text //! ```text
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] //! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON //! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
//! solvecraft-cli bench-open <design.solvecraft> [--parse-only] loading timings as JSON lines
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…] //! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solvecraft-cli exec <command> [json-params] run one command on an empty design //! solvecraft-cli exec <command> [json-params] run one command on an empty design
//! solvecraft-cli commands the command registry as JSON //! solvecraft-cli commands the command registry as JSON
@@ -31,6 +32,7 @@ const USAGE: &str = "usage:
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]... solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE) solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
solvecraft-cli bench-open <design.solvecraft> [--parse-only]
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right] solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solvecraft-cli exec <command> [json-params] solvecraft-cli exec <command> [json-params]
solvecraft-cli commands solvecraft-cli commands
@@ -74,6 +76,7 @@ fn main() -> ExitCode {
Some("save-stress") => cmd_save_stress(&args[1..]), Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md). // Hidden: time recomputes after parameter edits (docs/perf.md).
Some("bench-edit") => cmd_bench_edit(&args[1..]), Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("bench-open") => cmd_bench_open(&args[1..]),
Some("--version" | "-V") => { Some("--version" | "-V") => {
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION")); println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(()) Ok(())
@@ -351,3 +354,34 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
println!("{}", pretty(&json!(out))); println!("{}", pretty(&json!(out)));
Ok(()) Ok(())
} }
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft> [--parse-only]")?;
let t = std::time::Instant::now();
let len = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
if len > solvecraft_engine::io::MAX_DESIGN_BYTES as u64 {
return Err(format!("{path}: file too large"));
}
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
let t = std::time::Instant::now();
let doc = solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
if args.iter().any(|a| a == "--parse-only") {
return Ok(());
}
let t = std::time::Instant::now();
let s = Session::new(doc);
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
for r in &s.model.results {
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
}
let t = std::time::Instant::now();
let st = s.world_state();
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
Ok(())
}
+37 -4
View File
@@ -164,6 +164,39 @@ fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) {
g.map_points(&mut |p: &mut Vec3| *p = apply_point(m, *p), &mut |pl: &mut PlaneRef| map_plane_ref(m, pl)); g.map_points(&mut |p: &mut Vec3| *p = apply_point(m, *p), &mut |pl: &mut PlaneRef| map_plane_ref(m, pl));
} }
/// Map a point given as expressions by `m`: numbers stay numbers; a coordinate that is an
/// expression stays one (`m · (x, y, z) + t`, written out), so it still follows its parameters.
pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
let num = |e: &String| e.trim().parse::<f64>().ok().filter(|v| v.is_finite());
if let [Some(x), Some(y), Some(z)] = [num(&p[0]), num(&p[1]), num(&p[2])] {
*p = crate::point_expr(apply_point(m, Vec3::new(x, y, z)));
return;
}
let old = p.clone();
for (row, out) in p.iter_mut().enumerate() {
let mut constant = m[3][row];
let mut terms: Vec<String> = Vec::new();
for (col, e) in old.iter().enumerate() {
let c = m[col][row];
if c.abs() < 1e-12 {
continue;
}
match num(e) {
Some(v) => constant += c * v,
None if (c - 1.0).abs() < 1e-12 => terms.push(format!("({e})")),
None => terms.push(format!("{c} * ({e})")),
}
}
let mut e = terms.join(" + ");
if terms.is_empty() {
e = format!("{constant} mm");
} else if constant.abs() >= 1e-12 {
e = format!("{e} {} {} mm", if constant < 0.0 { '-' } else { '+' }, constant.abs());
}
*out = e;
}
}
impl FeatureKind { impl FeatureKind {
/// Map the feature's world geometry (points, directions, explicit planes) by `m`. /// Map the feature's world geometry (points, directions, explicit planes) by `m`.
pub fn transform_geometry(&mut self, m: &Mat) { pub fn transform_geometry(&mut self, m: &Mat) {
@@ -207,12 +240,12 @@ impl FeatureKind {
map_plane_ref(m, neutral); map_plane_ref(m, neutral);
dir(pull); dir(pull);
} }
FeatureKind::Box { corner, .. } => pt(corner), FeatureKind::Box { corner, .. } => map_point_expr(m, corner),
FeatureKind::Cylinder { base, axis, .. } => { FeatureKind::Cylinder { base, axis, .. } => {
pt(base); map_point_expr(m, base);
dir(axis); dir(axis);
} }
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => pt(center), FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => map_point_expr(m, center),
FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => { FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => {
dir(dir1); dir(dir1);
if let Some(d) = dir2 { if let Some(d) = dir2 {
@@ -259,7 +292,7 @@ impl FeatureKind {
} }
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {} FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
FeatureKind::Coil { base, axis, .. } => { FeatureKind::Coil { base, axis, .. } => {
pt(base); map_point_expr(m, base);
dir(axis); dir(axis);
} }
FeatureKind::ReplaceFace { faces, target, .. } => { FeatureKind::ReplaceFace { faces, target, .. } => {
+67 -10
View File
@@ -226,7 +226,8 @@ pub enum FeatureKind {
body: Option<String>, body: Option<String>,
}, },
Box { Box {
corner: Vec3, #[serde(with = "point_expr")]
corner: PointExpr,
length: String, length: String,
width: String, width: String,
height: String, height: String,
@@ -234,7 +235,8 @@ pub enum FeatureKind {
operation: Operation, operation: Operation,
}, },
Cylinder { Cylinder {
base: Vec3, #[serde(with = "point_expr")]
base: PointExpr,
#[serde(default = "z_axis")] #[serde(default = "z_axis")]
axis: Vec3, axis: Vec3,
radius: String, radius: String,
@@ -243,13 +245,15 @@ pub enum FeatureKind {
operation: Operation, operation: Operation,
}, },
Sphere { Sphere {
center: Vec3, #[serde(with = "point_expr")]
center: PointExpr,
radius: String, radius: String,
#[serde(default)] #[serde(default)]
operation: Operation, operation: Operation,
}, },
Torus { Torus {
center: Vec3, #[serde(with = "point_expr")]
center: PointExpr,
major: String, major: String,
minor: String, minor: String,
#[serde(default)] #[serde(default)]
@@ -482,7 +486,8 @@ pub enum FeatureKind {
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising /// A helical coil: a circular or square section swept `turns` times about `axis`, rising
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside). /// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
Coil { Coil {
base: Vec3, #[serde(with = "point_expr")]
base: PointExpr,
#[serde(default = "z_axis")] #[serde(default = "z_axis")]
axis: Vec3, axis: Vec3,
diameter: String, diameter: String,
@@ -939,6 +944,45 @@ fn z_axis() -> Vec3 {
Vec3::Z Vec3::Z
} }
/// A point whose coordinates are length expressions (`["basin_width / 2", "10 mm", "0"]`), so
/// it follows parameter changes. Primitives are placed by one.
pub type PointExpr = [String; 3];
/// A point as expressions (plain numbers, mm).
pub fn point_expr(p: Vec3) -> PointExpr {
[format!("{}", p.x), format!("{}", p.y), format!("{}", p.z)]
}
/// Stored as `[x, y, z]`: plain numbers as numbers (as points were before they took
/// expressions, so such designs read and write the same), anything else as a string.
mod point_expr {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Coord {
Num(f64),
Expr(String),
}
pub fn serialize<S: Serializer>(p: &super::PointExpr, s: S) -> Result<S::Ok, S::Error> {
let c = |e: &String| match e.parse::<f64>() {
Ok(v) if v.is_finite() && format!("{v}") == *e => Coord::Num(v),
_ => Coord::Expr(e.clone()),
};
[c(&p[0]), c(&p[1]), c(&p[2])].serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<super::PointExpr, D::Error> {
let c = |x: Coord| match x {
Coord::Num(v) => format!("{v}"),
Coord::Expr(e) => e,
};
let [x, y, z] = <[Coord; 3]>::deserialize(d)?;
Ok([c(x), c(y), c(z)])
}
}
impl FeatureKind { impl FeatureKind {
/// Sketches the feature is built from (profiles, paths, points): deleting one of them /// Sketches the feature is built from (profiles, paths, points): deleting one of them
/// deletes the feature. Every kind is listed, so a new one has to say what it uses. /// deletes the feature. Every kind is listed, so a new one has to say what it uses.
@@ -1228,10 +1272,22 @@ impl FeatureKind {
v.extend(distance2.iter().map(String::as_str)); v.extend(distance2.iter().map(String::as_str));
v.extend(angle.iter().map(String::as_str)); v.extend(angle.iter().map(String::as_str));
} }
FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]), FeatureKind::Box { length, width, height, corner, .. } => {
FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]), v.extend([length.as_str(), width, height]);
FeatureKind::Sphere { radius, .. } => v.push(radius), v.extend(corner.iter().map(String::as_str));
FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]), }
FeatureKind::Cylinder { radius, height, base, .. } => {
v.extend([radius.as_str(), height]);
v.extend(base.iter().map(String::as_str));
}
FeatureKind::Sphere { radius, center, .. } => {
v.push(radius);
v.extend(center.iter().map(String::as_str));
}
FeatureKind::Torus { major, minor, center, .. } => {
v.extend([major.as_str(), minor]);
v.extend(center.iter().map(String::as_str));
}
FeatureKind::Combine { .. } => {} FeatureKind::Combine { .. } => {}
FeatureKind::Pattern { pattern, .. } => match pattern { FeatureKind::Pattern { pattern, .. } => match pattern {
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => { PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
@@ -1308,9 +1364,10 @@ impl FeatureKind {
v.extend(gap.iter().map(String::as_str)); v.extend(gap.iter().map(String::as_str));
} }
FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {} FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {}
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => { FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => {
v.extend([diameter.as_str(), pitch, turns, section_size]); v.extend([diameter.as_str(), pitch, turns, section_size]);
v.extend(start_angle.iter().map(String::as_str)); v.extend(start_angle.iter().map(String::as_str));
v.extend(base.iter().map(String::as_str));
} }
FeatureKind::Rib { thickness, depth, .. } => { FeatureKind::Rib { thickness, depth, .. } => {
v.push(thickness); v.push(thickness);
+25 -9
View File
@@ -684,6 +684,13 @@ pub(crate) const DESIGN_LEN: &str = "\u{1}design_len";
/// An expression's value. A length that comes out unit-less without being a bare number (a /// An expression's value. A length that comes out unit-less without being a bare number (a
/// unit-less parameter used as a length) is in the design's units. /// unit-less parameter used as a length) is in the design's units.
/// A point given as length expressions.
fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
let [x, y, z] = p;
let v = Vec3::new(val(vals, x, Kind::Length)?, val(vals, y, Kind::Length)?, val(vals, z, Kind::Length)?);
if [v.x, v.y, v.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { Ok(v) } else { Err(DocError::Invalid("the position is out of range".into())) }
}
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> { fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0); let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) { if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
@@ -1537,6 +1544,7 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
Ok(vec![kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?]) Ok(vec![kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?])
} }
FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => { FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => {
let base = &point(vals, base)?;
let k = axis.normalized().ok_or_else(|| DocError::Invalid("coil axis".into()))?; let k = axis.normalized().ok_or_else(|| DocError::Invalid("coil axis".into()))?;
let (d, p, n, s) = ( let (d, p, n, s) = (
val(vals, diameter, Kind::Length)?, val(vals, diameter, Kind::Length)?,
@@ -1624,14 +1632,15 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
} }
FeatureKind::Box { corner, length, width, height, .. } => { FeatureKind::Box { corner, length, width, height, .. } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
Ok(vec![kernel::box_solid(*corner, *corner + s)?]) let corner = point(vals, corner)?;
Ok(vec![kernel::box_solid(corner, corner + s)?])
} }
FeatureKind::Cylinder { base, axis, radius, height, .. } => { FeatureKind::Cylinder { base, axis, radius, height, .. } => {
Ok(vec![kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?]) Ok(vec![kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?])
} }
FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(*center, val(vals, radius, Kind::Length)?)?]), FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?]),
FeatureKind::Torus { center, major, minor, .. } => { FeatureKind::Torus { center, major, minor, .. } => {
Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]) Ok(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
} }
FeatureKind::Hole { diameter, depth, hole, to, .. } => { FeatureKind::Hole { diameter, depth, hole, to, .. } => {
let (spots, dir) = hole_spots(st, f)?; let (spots, dir) = hole_spots(st, f)?;
@@ -2689,19 +2698,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
} }
FeatureKind::Box { corner, length, width, height, operation } => { FeatureKind::Box { corner, length, width, height, operation } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let b = kernel::box_solid(*corner, *corner + s)?; let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
apply_op(st, f, vec![b], *operation, &[]) apply_op(st, f, vec![b], *operation, &[])
} }
FeatureKind::Cylinder { base, axis, radius, height, operation } => { FeatureKind::Cylinder { base, axis, radius, height, operation } => {
let b = kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?; let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
apply_op(st, f, vec![b], *operation, &[]) apply_op(st, f, vec![b], *operation, &[])
} }
FeatureKind::Sphere { center, radius, operation } => { FeatureKind::Sphere { center, radius, operation } => {
let b = kernel::sphere(*center, val(vals, radius, Kind::Length)?)?; let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?;
apply_op(st, f, vec![b], *operation, &[]) apply_op(st, f, vec![b], *operation, &[])
} }
FeatureKind::Torus { center, major, minor, operation } => { FeatureKind::Torus { center, major, minor, operation } => {
let b = kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?; let b = kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?;
apply_op(st, f, vec![b], *operation, &[]) apply_op(st, f, vec![b], *operation, &[])
} }
FeatureKind::Combine { target, tools, operation, keep_tools } => { FeatureKind::Combine { target, tools, operation, keep_tools } => {
@@ -2970,7 +2980,13 @@ mod chain_tests {
fn tangent_chain_follows_smooth_edges() { fn tangent_chain_follows_smooth_edges() {
let mut doc = Document::new("T"); let mut doc = Document::new("T");
doc.add_feature( doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "20".into(), operation: Operation::NewBody }, FeatureKind::Box {
corner: crate::point_expr(Vec3::ZERO),
length: "40".into(),
width: "30".into(),
height: "20".into(),
operation: Operation::NewBody,
},
None, None,
) )
.unwrap(); .unwrap();
+65 -4
View File
@@ -197,10 +197,7 @@ fn rigid(m: &crate::Mat) -> bool {
/// Feature input names and component occurrences, which format 1 files may predate. /// Feature input names and component occurrences, which format 1 files may predate.
fn normalise(d: &mut Document) -> Result<()> { fn normalise(d: &mut Document) -> Result<()> {
let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect(); d.name_all_feature_inputs();
for id in ids {
d.name_feature_inputs(id);
}
let missing: Vec<(u64, u64)> = let missing: Vec<(u64, u64)> =
d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect(); d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect();
for (c, p) in missing { for (c, p) in missing {
@@ -213,6 +210,70 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
let mut d = Document::new("input repair");
d.set_param("d2", "1", None, None).unwrap();
for i in 0..5 {
d.add_feature(
crate::FeatureKind::Box {
corner: ["0".into(), "0".into(), "0".into()],
length: "10".into(),
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
},
Some(&format!("Box{i}")),
)
.unwrap();
}
d.features[0].param_names = vec!["".into(), "custom".into(), "".into(), "d1".into(), "d2".into()];
d.features[1].param_names.clear();
d.features[2].param_names = vec!["d7".into(), "".into(), "d9".into()];
d.features[3].param_names = vec!["d7".into(), "d11".into(), "d12".into(), "d1".into()];
d.features[4].param_names.clear();
let mut expected = d.clone();
for id in expected.features.iter().map(|f| f.id).collect::<Vec<_>>() {
expected.name_feature_inputs(id);
}
d.name_all_feature_inputs();
assert_eq!(d, expected);
assert_eq!(read(&d.to_json()).unwrap(), d);
}
#[test]
fn large_unnamed_timeline_loads_with_unique_inputs() {
let mut d = Document::new("large input repair");
let id = d
.add_feature(
crate::FeatureKind::Box {
corner: ["0".into(), "0".into(), "0".into()],
length: "10".into(),
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
},
None,
)
.unwrap();
let mut f = d.feature(id).unwrap().clone();
f.param_names.clear();
d.features = (1..=5000)
.map(|id| {
let mut f = f.clone();
f.id = id;
f
})
.collect();
d.next_id = 5001;
let back = read(&d.to_json()).unwrap();
let names: Vec<_> = back.features.iter().flat_map(|f| &f.param_names).collect();
assert_eq!(names.len(), 15000);
assert_eq!(names.first().unwrap().as_str(), "d1");
assert_eq!(names.last().unwrap().as_str(), "d15000");
assert_eq!(names.iter().collect::<std::collections::BTreeSet<_>>().len(), names.len());
}
#[test] #[test]
fn versions() { fn versions() {
assert_eq!(version_of("solvecraft/1"), Some(1)); assert_eq!(version_of("solvecraft/1"), Some(1));
+1 -1
View File
@@ -25,7 +25,7 @@ mod project3d;
pub use project3d::iso_grid; pub use project3d::iso_grid;
pub mod sheet; pub mod sheet;
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid}; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid};
pub use document::*; pub use document::*;
pub use eval::{ pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices, EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
+85
View File
@@ -276,6 +276,32 @@ impl FeatureKind {
v v
} }
/// A primitive's placement point (length expressions). Its coordinates are not inputs:
/// they get no parameter names and keep bare numbers in mm whatever the design's units, as
/// every other position does; renames and users still find them.
pub fn point_expr_mut(&mut self) -> Option<&mut crate::PointExpr> {
match self {
FeatureKind::Box { corner: p, .. }
| FeatureKind::Cylinder { base: p, .. }
| FeatureKind::Sphere { center: p, .. }
| FeatureKind::Torus { center: p, .. }
| FeatureKind::Coil { base: p, .. } => Some(p),
_ => None,
}
}
/// Read-only view of [`FeatureKind::point_expr_mut`].
pub fn point_expr(&self) -> Option<&crate::PointExpr> {
match self {
FeatureKind::Box { corner: p, .. }
| FeatureKind::Cylinder { base: p, .. }
| FeatureKind::Sphere { center: p, .. }
| FeatureKind::Torus { center: p, .. }
| FeatureKind::Coil { base: p, .. } => Some(p),
_ => None,
}
}
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind). /// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> { pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
// Imports have no inputs (and their STEP text is large: don't clone it). // Imports have no inputs (and their STEP text is large: don't clone it).
@@ -372,6 +398,55 @@ impl Document {
} }
} }
/// Repair a loaded timeline in one pass. Reserve even later features' names, just as
/// `name_feature_inputs` does, without restarting a document scan for every `dN` candidate.
pub(crate) fn name_all_feature_inputs(&mut self) {
let mut taken: BTreeMap<String, usize> = BTreeMap::new();
for name in self.params.iter().map(|p| &p.name).chain(self.features.iter().flat_map(|f| &f.param_names)) {
*taken.entry(name.clone()).or_default() += 1;
}
let mut next = 1usize;
for f in &mut self.features {
let n = f.kind.inputs().len();
for k in 0..n {
if f.param_names.get(k).is_some_and(|name| !name.is_empty()) {
continue;
}
let name = loop {
let name = format!("d{next}");
next += 1;
if !taken.contains_key(&name) {
break name;
}
};
if f.param_names.len() <= k {
f.param_names.resize(k + 1, String::new());
}
// Empty slots cannot collide with a generated name.
if let Some(slot) = f.param_names.get_mut(k) {
*slot = name.clone();
}
taken.insert(name, 1);
}
// Match the single-feature repair: surplus names become available only after
// this feature has been repaired. Counts preserve names shared with parameters.
for name in f.param_names.drain(n..) {
if let Some(count) = taken.get_mut(&name) {
*count -= 1;
if *count == 0 {
taken.remove(&name);
if let Some(i) = name.strip_prefix('d').and_then(|s| s.parse::<usize>().ok())
&& name == format!("d{i}")
&& i > 0
{
next = next.min(i);
}
}
}
}
}
}
/// Change a parameter: a stored one, or a feature input by its name. /// Change a parameter: a stored one, or a feature input by its name.
/// A typed length that is a bare number means the design's units: it gets that unit written /// A typed length that is a bare number means the design's units: it gets that unit written
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged. /// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
@@ -655,6 +730,9 @@ impl Document {
} }
} }
} }
if f.kind.point_expr().is_some_and(|p| p.iter().any(|e| expr::references(e).iter().any(|r| r == name))) {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: "Position".into() });
}
if let FeatureKind::Sketch { sketch, .. } = &f.kind { if let FeatureKind::Sketch { sketch, .. } = &f.kind {
for c in &sketch.constraints { for c in &sketch.constraints {
if c.param.as_deref() == Some(name) { if c.param.as_deref() == Some(name) {
@@ -718,6 +796,13 @@ impl Document {
*x = new.to_string(); *x = new.to_string();
} }
} }
for e in f.kind.point_expr_mut().into_iter().flatten() {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
for (_, e, _) in f.kind.inputs_mut() { for (_, e, _) in f.kind.inputs_mut() {
let r = expr::rename_reference(e, old, new); let r = expr::rename_reference(e, old, new);
if r != *e { if r != *e {
+15 -3
View File
@@ -165,7 +165,13 @@ fn errors_are_reported_not_fatal() {
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None) doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None)
.unwrap(); .unwrap();
doc.add_feature( doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody }, FeatureKind::Box {
corner: point_expr(Vec3::ZERO),
length: "nope".into(),
width: "1".into(),
height: "1".into(),
operation: Operation::NewBody,
},
None, None,
) )
.unwrap(); .unwrap();
@@ -202,13 +208,19 @@ fn params_and_serialization() {
fn primitives_and_combine() { fn primitives_and_combine() {
let mut doc = Document::new("p"); let mut doc = Document::new("p");
doc.add_feature( doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "10".into(), height: "10".into(), operation: Operation::NewBody }, FeatureKind::Box {
corner: point_expr(Vec3::ZERO),
length: "10".into(),
width: "10".into(),
height: "10".into(),
operation: Operation::NewBody,
},
None, None,
) )
.unwrap(); .unwrap();
doc.add_feature( doc.add_feature(
FeatureKind::Cylinder { FeatureKind::Cylinder {
base: Vec3::new(5.0, 5.0, 0.0), base: point_expr(Vec3::new(5.0, 5.0, 0.0)),
axis: Vec3::Z, axis: Vec3::Z,
radius: "2".into(), radius: "2".into(),
height: "20".into(), height: "20".into(),
+76 -36
View File
@@ -1,7 +1,7 @@
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move. //! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
use serde_json::{Value, json}; use serde_json::{Value, json};
use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, ProfileSel, expr::Kind}; use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
use solvecraft_geom::Vec3; use solvecraft_geom::Vec3;
use super::CommandSpec; use super::CommandSpec;
@@ -23,10 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("sweep") .icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"), .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"), CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"), CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"), CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"), CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"), CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
CommandSpec::new("solid.fillet", "Fillet", fillet) CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY") .at("SOLID", "MODIFY")
.icon("fillet") .icon("fillet")
@@ -46,7 +46,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("pattern_rect") .icon("pattern_rect")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"), .params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"), CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ|construction plane name or id|{origin, normal}|{origin, x_dir, y_dir} (a construction plane keeps the mirror following it)"),
CommandSpec::new("solid.hole", "Hole", hole) CommandSpec::new("solid.hole", "Hole", hole)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("hole") .icon("hole")
@@ -484,19 +484,59 @@ fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
} }
} }
/// A placement point: `[x, y, z]` (or `{x, y, z}`) of numbers (mm) or length expressions;
/// the origin when it is not given. Anything else is refused (never read as the origin).
pub(super) fn point_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<PointExpr>> {
let Some(v) = p.get(key) else { return Ok(None) };
let want = || bad(cmd, format!("`{key}` must be a point [x, y, z] of numbers or length expressions"));
let coords: Vec<&Value> = match v {
Value::Array(a) if a.len() == 3 => a.iter().collect(),
Value::Object(o) => ["x", "y", "z"].iter().map(|k| o.get(*k)).collect::<Option<Vec<_>>>().ok_or_else(want)?,
_ => return Err(want()),
};
let mut out = PointExpr::default();
for (slot, (c, axis)) in out.iter_mut().zip(coords.into_iter().zip(["x", "y", "z"])) {
let e = expr(&json!({ "v": c }), "v").ok_or_else(want)?;
let x = s.doc.eval(&e, Kind::Length).map_err(|err| bad(cmd, format!("{key} {axis}: {err}")))?;
if !(x.is_finite() && x.abs() < 1e9) {
return Err(bad(cmd, format!("{key} {axis} is out of range")));
}
*slot = e;
}
Ok(Some(out))
}
/// An axis direction: X|Y|Z or a non-zero `[x, y, z]`; `Z` when it is not given.
pub(super) fn axis_dir_param(p: &Value, cmd: &str) -> Result<Vec3> {
match p.get("axis") {
None => Ok(Vec3::Z),
Some(Value::String(a)) => match a.trim().to_ascii_uppercase().as_str() {
"X" => Ok(Vec3::X),
"Y" => Ok(Vec3::Y),
"Z" => Ok(Vec3::Z),
_ => Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")),
},
Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]")),
}
}
fn prim_box(s: &mut Session, p: &Value) -> Result<Value> { fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.box"; let cmd = "solid.box";
let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?); let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?);
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] { for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?; check_expr(s, e, Kind::Length, cmd, n)?;
} }
let corner = match (p.get("corner").and_then(vec3), p.get("center").and_then(vec3)) { let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
(Some(c), _) => c, (Some(c), _) => c,
(None, Some(c)) => { // The corner half a size back from the centre (numbers stay numbers).
let v = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0); (None, Some([cx, cy, cz])) => {
c - Vec3::new(v(&l), v(&w), v(&h)) * 0.5 let back = |c: String, size: &String| match (c.parse::<f64>(), size.parse::<f64>()) {
(Ok(a), Ok(b)) => format!("{}", a - b / 2.0),
_ => format!("({c}) - ({size}) / 2"),
};
[back(cx, &l), back(cy, &w), back(cz, &h)]
} }
_ => Vec3::ZERO, _ => point_expr(Vec3::ZERO),
}; };
add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? }) add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? })
} }
@@ -507,8 +547,8 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
let height = req_expr(cmd, p, "height")?; let height = req_expr(cmd, p, "height")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?; check_expr(s, &radius, Kind::Length, cmd, "radius")?;
check_expr(s, &height, Kind::Length, cmd, "height")?; check_expr(s, &height, Kind::Length, cmd, "height")?;
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO); let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z); let axis = axis_dir_param(p, cmd)?;
add_feature(s, p, FeatureKind::Cylinder { base, axis, radius, height, operation: operation(p, cmd)? }) add_feature(s, p, FeatureKind::Cylinder { base, axis, radius, height, operation: operation(p, cmd)? })
} }
@@ -516,7 +556,7 @@ fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sphere"; let cmd = "solid.sphere";
let radius = radius_expr(p, cmd)?; let radius = radius_expr(p, cmd)?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?; check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO); let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? }) add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? })
} }
@@ -525,7 +565,7 @@ fn prim_torus(s: &mut Session, p: &Value) -> Result<Value> {
let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?); let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?);
check_expr(s, &major, Kind::Length, cmd, "major")?; check_expr(s, &major, Kind::Length, cmd, "major")?;
check_expr(s, &minor, Kind::Length, cmd, "minor")?; check_expr(s, &minor, Kind::Length, cmd, "minor")?;
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO); let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
add_feature(s, p, FeatureKind::Torus { center, major, minor, operation: operation(p, cmd)? }) add_feature(s, p, FeatureKind::Torus { center, major, minor, operation: operation(p, cmd)? })
} }
@@ -829,35 +869,35 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.mirror"; let cmd = "solid.mirror";
let bodies = string_list(p, "bodies"); let bodies = string_list(p, "bodies");
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() }; let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = match p.get("plane") { let plane = plane_param(s, p.get("plane"), cmd)?;
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => solvecraft_doc::PlaneRef::Origin { name: n.to_ascii_uppercase() },
Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
(Some(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y),
(_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n),
_ => None,
};
solvecraft_doc::PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? }
}
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
};
let combine = bool_(p, "combine").unwrap_or(false); let combine = bool_(p, "combine").unwrap_or(false);
add_feature(s, p, FeatureKind::Mirror { features, plane, bodies, combine }) add_feature(s, p, FeatureKind::Mirror { features, plane, bodies, combine })
} }
/// A plane reference from a parameter: an origin plane, a construction plane name, or an /// A plane reference from a parameter: an origin plane, a construction plane (by name or
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. /// feature id; kept by name, so the feature follows the plane when it changes), or an
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. Every command that takes a
/// plane reads it here.
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> { pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
use solvecraft_doc::PlaneRef; use solvecraft_doc::PlaneRef;
const WANT: &str = "XY, XZ, YZ, a construction plane name or id, or {origin, normal}";
let construction = |r: &str| match s.doc.find_feature(r) {
Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: f.name.clone() }),
Some(f) => Err(bad(cmd, format!("`{}` is not a construction plane (a plane is {WANT})", f.name))),
None => Err(bad(cmd, format!("no plane `{r}` (a plane is {WANT})"))),
};
match v { match v {
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => Ok(PlaneRef::Origin { name: n.to_ascii_uppercase() }), Some(Value::String(n)) if solvecraft_geom::Plane::named(n.trim()).is_some() => Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() }),
Some(Value::String(n)) => match s.doc.find_feature(n).map(|f| &f.kind) { Some(Value::String(n)) => construction(n.trim()),
Some(FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: n.clone() }), Some(Value::Number(id)) => match id.as_u64() {
_ => Err(bad(cmd, format!("no plane `{n}`"))), Some(id) => construction(&id.to_string()),
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
}, },
Some(o @ Value::Object(_)) => { Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); let origin = match o.get("origin") {
None => Vec3::ZERO,
Some(x) => vec3(x).ok_or_else(|| bad(cmd, "the plane's `origin` must be a point [x, y, z]"))?,
};
let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) { let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
(Some(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y), (Some(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y),
(_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n), (_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n),
@@ -865,7 +905,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
}; };
Ok(PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? }) Ok(PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? })
} }
_ => Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or {origin, normal}")), _ => Err(bad(cmd, format!("`plane` must be {WANT}"))),
} }
} }
+4 -13
View File
@@ -6,7 +6,7 @@ use solvecraft_doc::expr::Kind;
use solvecraft_geom::Vec3; use solvecraft_geom::Vec3;
use super::CommandSpec; use super::CommandSpec;
use super::features::{add_feature, check_expr, feature_sketch, plane_param, profiles}; use super::features::{add_feature, axis_dir_param, check_expr, feature_sketch, plane_param, point_param, profiles};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3}; use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{Result, Session}; use crate::{Result, Session};
@@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
), ),
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params( CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
"diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \ "diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \
base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?", base?: [x,y,z] (numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
), ),
CommandSpec::new("solid.rib", "Rib", rib) CommandSpec::new("solid.rib", "Rib", rib)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
@@ -43,17 +43,8 @@ pub static COMMANDS: &[CommandSpec] = &[
fn coil(s: &mut Session, p: &Value) -> Result<Value> { fn coil(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.coil"; let cmd = "solid.coil";
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO); let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| solvecraft_doc::point_expr(Vec3::ZERO));
let axis = match p.get("axis") { let axis = axis_dir_param(p, cmd)?;
None => Vec3::Z,
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
"X" => Vec3::X,
"Y" => Vec3::Y,
"Z" => Vec3::Z,
_ => return Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")),
},
Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]"))?,
};
let diameter = req_expr(cmd, p, "diameter")?; let diameter = req_expr(cmd, p, "diameter")?;
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?; check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
let turns = expr(p, "revolutions").or_else(|| expr(p, "turns")); let turns = expr(p, "revolutions").or_else(|| expr(p, "turns"));
+17 -4
View File
@@ -10,7 +10,7 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params( &[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params(
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: +Y, YZ: +X); \ "plane: XY|XZ|YZ | construction plane name or id | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: +Y, YZ: +X); \
at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \ at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \
flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at", flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at",
)]; )];
@@ -24,18 +24,31 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
} }
// A named plane's world axis: which component of a point lies along its normal. // A named plane's world axis: which component of a point lies along its normal.
let (origin, normal, axis) = match p.get("plane") { let (origin, normal, axis) = match p.get("plane") {
Some(Value::String(n)) => { Some(Value::String(n)) if Plane::named(n.trim()).is_some() => {
let pl = Plane::named(n).ok_or_else(|| bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ)")))?; let pl = Plane::named(n.trim()).ok_or_else(|| bad(cmd, format!("unknown plane `{n}`")))?;
let normal = pl.normal(); let normal = pl.normal();
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5); let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
(pl.origin, normal, axis) (pl.origin, normal, axis)
} }
// A construction plane (name or id): where it is now.
Some(v @ (Value::String(_) | Value::Number(_))) => {
let r = super::features::plane_param(s, Some(v), cmd)?;
let solvecraft_doc::PlaneRef::Construction { name } = &r else { return Err(bad(cmd, "`plane` must be a plane")) };
let st = s.model.state();
let pl = st
.construct
.iter()
.find(|c| &c.name == name)
.and_then(|c| if let solvecraft_doc::construct::ConstructGeom::Plane(pl) = &c.geom { Some(*pl) } else { None })
.ok_or_else(|| bad(cmd, format!("construction plane `{name}` is not built (rolled back, suppressed or failed)")))?;
(pl.origin, pl.normal(), None)
}
Some(o @ Value::Object(_)) => { Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let n = o.get("normal").and_then(vec3).and_then(|n| n.normalized()).ok_or_else(|| bad(cmd, "the plane needs a non-zero `normal`"))?; let n = o.get("normal").and_then(vec3).and_then(|n| n.normalized()).ok_or_else(|| bad(cmd, "the plane needs a non-zero `normal`"))?;
(origin, n, None) (origin, n, None)
} }
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")), _ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or id, or {origin, normal}")),
}; };
let length = |key: &str| -> Result<Option<f64>> { let length = |key: &str| -> Result<Option<f64>> {
match expr(p, key) { match expr(p, key) {
+13 -11
View File
@@ -20,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch) CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("sketch") .icon("sketch")
.params("plane: XY|XZ|YZ | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"), .params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"), CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(), CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line) CommandSpec::new("sketch.line", "Line", draw_line)
@@ -348,15 +348,8 @@ pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> { pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
let base = match p.get("plane") { let base = match p.get("plane") {
None => PlaneRef::Origin { name: "XY".into() }, None => PlaneRef::Origin { name: "XY".into() },
Some(Value::String(n)) => { // An origin plane, or a construction plane by name or id.
if Plane::named(n).is_some() { Some(v @ (Value::String(_) | Value::Number(_))) => super::features::plane_param(s, Some(v), cmd)?,
PlaneRef::Origin { name: n.to_ascii_uppercase() }
} else if matches!(s.doc.find_feature(n).map(|f| &f.kind), Some(FeatureKind::ConstructionPlane { .. })) {
PlaneRef::Construction { name: n.clone() }
} else {
return Err(bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ or a construction plane)")));
}
}
Some(v @ Value::Object(o)) => { Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) { if let Some(fp) = o.get("face").and_then(vec3) {
// A planar face of a body at this point, as the active component sees the model // A planar face of a body at this point, as the active component sees the model
@@ -388,7 +381,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
PlaneRef::Custom { plane: Plane::new(origin, x, y).ok_or_else(|| bad(cmd, "degenerate plane"))? } PlaneRef::Custom { plane: Plane::new(origin, x, y).ok_or_else(|| bad(cmd, "degenerate plane"))? }
} }
} }
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or an object")), _ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or id, or an object")),
}; };
Ok(match expr(p, "offset") { Ok(match expr(p, "offset") {
Some(d) => PlaneRef::Offset { base: Box::new(base), distance: d }, Some(d) => PlaneRef::Offset { base: Box::new(base), distance: d },
@@ -1462,6 +1455,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
place_text(sk, &id, text_at); place_text(sk, &id, text_at);
Ok((pname, name, measured(is_angle, cur))) Ok((pname, name, measured(is_angle, cur)))
})?; })?;
// Report what the dimension measures once the sketch is solved, not the geometry before.
let current = solved_measure(s, p, cmd).unwrap_or(current);
if driven { if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info})); return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
} }
@@ -1477,6 +1472,13 @@ fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") } if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") }
} }
/// What the dimension just added (the sketch's last constraint) measures on the solved sketch.
fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static str)> {
let sk = s.doc.sketch(target_sketch(s, p, cmd).ok()?).ok()?;
let k = &sk.constraints.last()?.kind;
Some(measured(k.is_angle(), solvecraft_sketch::measure_dimension(sk, k)?))
}
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the /// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
/// dimension so it follows the geometry. /// dimension so it follows the geometry.
fn place_text(sk: &mut Sketch, id: &str, at: Option<Vec2>) { fn place_text(sk: &mut Sketch, id: &str, at: Option<Vec2>) {
@@ -48,6 +48,26 @@ fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
assert_eq!(g["fully_constrained"], true); assert_eq!(g["fully_constrained"], true);
} }
/// `measured` is the dimension on the solved sketch: a trough drawn 99.84 long and dimensioned
/// 100 reports 100, not the length it had before solving.
#[test]
fn a_dimension_reports_the_solved_value() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [99.84, 40.2]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 100}));
assert!((d["measured"].as_f64().unwrap() - 100.0).abs() < 1e-6, "{d}");
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": "40 mm"}));
assert!((d["measured"].as_f64().unwrap() - 40.0).abs() < 1e-6, "{d}");
// A driven dimension measures the solved geometry too.
let d = run(&mut s, "sketch.dimension", json!({"entities": ["p1", "p3"], "driven": true}));
assert!((d["measured"].as_f64().unwrap() - 100.0_f64.hypot(40.0)).abs() < 1e-6, "{d}");
let c = run(&mut s, "sketch.circle.center", json!({"center": [50, 20], "radius": 7.3}));
let c = ids(&c["curves"])[0].clone();
let d = run(&mut s, "sketch.dimension", json!({"entities": [c], "value": 15}));
assert!((d["measured"].as_f64().unwrap() - 15.0).abs() < 1e-6, "{d}");
}
#[test] #[test]
fn curvature_polygon_arc_length_linear_diameter() { fn curvature_polygon_arc_length_linear_diameter() {
let mut s = new_sketch(); let mut s = new_sketch();
@@ -243,7 +263,7 @@ fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degree
let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"})); let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}"); assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert_eq!(d["unit"], "deg", "{d}"); assert_eq!(d["unit"], "deg", "{d}");
assert!((d["measured"].as_f64().unwrap() - 30.3).abs() < 0.1, "{d}"); assert!((d["measured"].as_f64().unwrap() - 30.0).abs() < 1e-6, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6); assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
} }
// The other reading still works, and the adjacent top line measures 60° the same way. // The other reading still works, and the adjacent top line measures 60° the same way.
@@ -253,7 +273,7 @@ fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degree
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6); assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
let mut s = spout(); let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"})); let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
assert!((d["measured"].as_f64().unwrap() - 59.74).abs() < 0.01, "{d}"); assert!((d["measured"].as_f64().unwrap() - 60.0).abs() < 1e-6, "{d}");
// Without a value the acute angle is kept as it is. // Without a value the acute angle is kept as it is.
let mut s = spout(); let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]})); let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
+20 -2
View File
@@ -10,7 +10,7 @@
//! without moved components). //! without moved components).
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, mat_inverse}; use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse};
use solvecraft_geom::Vec3; use solvecraft_geom::Vec3;
use crate::Session; use crate::Session;
@@ -203,11 +203,29 @@ impl Mapper<'_> {
} }
fn make(&mut self, v: &Value) -> Option<Value> { fn make(&mut self, v: &Value) -> Option<Value> {
let p = vec3(v)?;
let m = self.f.active; let m = self.f.active;
let Some(p) = vec3(v) else { return self.make_expr(&m, v) };
self.local_point(&m, p) self.local_point(&m, p)
} }
/// A point with expression coordinates (`["w / 2", 0, 0]`): mapped as expressions.
fn make_expr(&mut self, m: &Mat, v: &Value) -> Option<Value> {
let a = v.as_array().filter(|a| a.len() == 3)?;
let c = |i: usize| match a.get(i)? {
Value::Number(n) => n.as_f64().filter(|x| x.is_finite()).map(|x| format!("{x}")),
Value::String(e) => Some(e.clone()),
_ => None,
};
let mut p = [c(0)?, c(1)?, c(2)?];
if is_identity(m) {
return None;
}
let inv = mat_inverse(m)?;
map_point_expr(&inv, &mut p);
self.changed = true;
Some(serde_json::json!(p))
}
fn dir(&mut self, v: &Value) -> Option<Value> { fn dir(&mut self, v: &Value) -> Option<Value> {
let d = vec3(v)?; let d = vec3(v)?;
let m = self.first.unwrap_or(self.f.active); let m = self.first.unwrap_or(self.f.active);
+31
View File
@@ -36,6 +36,8 @@ fn join_extrude_from_a_chamfered_top_face() {
} }
mod component_frames; mod component_frames;
mod mirror_planes;
mod primitive_points;
mod robustness; mod robustness;
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole. /// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
@@ -1648,6 +1650,35 @@ fn unitless_parameters_used_as_lengths_take_the_design_units() {
assert!((max(0) - 50.8).abs() < 1e-9 && (max(1) - 50.8).abs() < 1e-9 && (max(2) - 10.0).abs() < 1e-9, "{m}"); assert!((max(0) - 50.8).abs() < 1e-9 && (max(1) - 50.8).abs() < 1e-9 && (max(2) - 10.0).abs() < 1e-9, "{m}");
} }
/// A chamfer on a tapered pocket survives parameter edits and undo/redo.
#[test]
fn chamfer_sloped_basin_rim_recomputes() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30}));
for (name, z, p0, p1) in [("Rim", 30, [20, 4], [80, 50]), ("Floor", 10, [30, 20], [70, 40])] {
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": z, "name": name}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
run(&mut s, "sketch.finish", json!({}));
}
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Rim"}, {"sketch": "Floor"}], "operation": "cut"}));
run(&mut s, "parameters.add", json!({"name": "rim_chamfer", "expression": "1 mm"}));
let base = volume(&mut s);
run(&mut s, "solid.chamfer", json!({"edges": [[50, 4, 30]], "distance": "rim_chamfer"}));
let expected = |size: f64| {
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
let drop = size / (1.0 + wall_slope.powi(2)).sqrt();
base - 0.5 * size * drop * (60.0 - drop / 3.0)
};
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
let edited = run(&mut s, "parameters.change", json!({"name": "rim_chamfer", "expression": "2 mm"}));
assert_eq!(edited["errors"].as_array().unwrap().len(), 0, "{edited}");
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
run(&mut s, "edit.undo", json!({}));
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
run(&mut s, "edit.redo", json!({}));
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
}
/// Chamfers with two distances or a distance and an angle. /// Chamfers with two distances or a distance and an angle.
#[test] #[test]
fn unequal_chamfers() { fn unequal_chamfers() {
+65
View File
@@ -0,0 +1,65 @@
//! Commands that take a plane accept construction planes by name or id, like the origin planes
//! (user report: `solid.mirror` refused a construction plane although its docs list plane
//! names).
use serde_json::json;
use super::run;
use crate::*;
fn min_x(s: &Session, body: &str) -> f64 {
let b = s.model.state().body(body).map(|b| b.body.clone()).unwrap();
solvecraft_kernel::measure(&b).unwrap().bbox.min.x
}
#[test]
fn mirror_across_a_construction_plane_follows_it() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"}));
let pl = run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": "w / 2", "name": "Mid"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Ledger"}));
let before = s.model.state().bodies.len();
// By name: the copy lands at x = 90…100.
run(&mut s, "solid.mirror", json!({"bodies": ["Ledger"], "plane": "Mid"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 1);
let copy = st.bodies.last().map(|b| b.name.clone()).unwrap();
assert!((min_x(&s, &copy) - 90.0).abs() < 1e-6, "{}", min_x(&s, &copy));
// Parametric: moving the plane moves the copy.
run(&mut s, "parameters.change", json!({"name": "w", "expression": "60 mm"}));
assert!((min_x(&s, &copy) - 50.0).abs() < 1e-6, "{}", min_x(&s, &copy));
// By id, and case-insensitively by name.
let id = pl["feature"].as_u64().unwrap();
run(&mut s, "solid.mirror", json!({"bodies": ["Ledger"], "plane": id.to_string()}));
run(&mut s, "solid.mirror", json!({"bodies": ["Ledger"], "plane": "mid"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 3);
for b in st.bodies.iter().skip(before) {
assert!((min_x(&s, &b.name) - 50.0).abs() < 1e-6, "{}", b.name);
}
}
#[test]
fn unknown_mirror_planes_are_refused_with_the_choices() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let e = s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": "Nope"})).unwrap_err().to_string();
assert!(e.contains("Nope"), "{e}");
let e = s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": 5})).unwrap_err().to_string();
assert!(e.contains("construction plane"), "{e}");
// A plane object with an unreadable origin is refused, not put at the world origin.
assert!(s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": {"origin": ["x", 0, 0], "normal": [1, 0, 0]}})).is_err());
// A feature that is not a plane is no plane.
assert!(s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": "Box1"})).is_err());
assert_eq!(s.model.state().bodies.len(), 1);
}
/// Section Analysis cuts along a construction plane too.
#[test]
fn section_by_a_construction_plane() {
let mut s = Session::default();
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 7, "name": "Cut"}));
let r = run(&mut s, "inspect.section", json!({"plane": "Cut"}));
assert_eq!(r["section"]["origin"][2].as_f64(), Some(7.0), "{r}");
assert!(s.execute("inspect.section", &json!({"plane": "Nope"})).is_err());
}
+121
View File
@@ -0,0 +1,121 @@
//! Primitive placement points (`base`, `corner`, `center`) take expressions like their sizes do,
//! follow parameter changes, and are refused when they cannot be read (user report: a cylinder
//! with `base: ["basin_width/2", …]` silently landed at the origin).
use serde_json::{Value, json};
use super::run;
use crate::*;
fn bbox(s: &Session, body: &str) -> ([f64; 3], [f64; 3]) {
let b = s.model.state().body(body).map(|b| b.body.clone()).unwrap();
let m = solvecraft_kernel::measure(&b).unwrap();
([m.bbox.min.x, m.bbox.min.y, m.bbox.min.z], [m.bbox.max.x, m.bbox.max.y, m.bbox.max.z])
}
fn near(a: [f64; 3], b: [f64; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3)
}
#[test]
fn cylinder_base_expressions_follow_parameters() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "basin_width", "expression": "100 mm"}));
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": ["basin_width/2", "10 mm", "-2"], "body_name": "Pin"}));
let (lo, hi) = bbox(&s, "Pin");
assert!(near(lo, [45.0, 5.0, -2.0]) && near(hi, [55.0, 15.0, 8.0]), "{lo:?} {hi:?}");
// A parameter change moves it on recompute.
run(&mut s, "parameters.change", json!({"name": "basin_width", "expression": "60 mm"}));
let (lo, _) = bbox(&s, "Pin");
assert!(near(lo, [25.0, 5.0, -2.0]), "{lo:?}");
// And it survives a save and reopen.
let text = s.doc.to_json();
assert!(text.contains("basin_width/2"), "{text}");
let s2 = Session::new(solvecraft_doc::Document::from_json(&text).unwrap());
let (lo, _) = bbox(&s2, "Pin");
assert!(near(lo, [25.0, 5.0, -2.0]), "{lo:?}");
}
#[test]
fn primitive_points_take_expressions() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "40 mm"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": ["w", 0, 0], "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": "w", "width": 10, "height": 10, "center": [0, "w", 0], "body_name": "B"}));
run(&mut s, "solid.sphere", json!({"radius": 2, "center": ["w * 2", 0, 0], "body_name": "C"}));
run(&mut s, "solid.torus", json!({"major": 5, "minor": 1, "center": [0, 0, "w"], "body_name": "D"}));
run(&mut s, "solid.coil", json!({"diameter": 10, "revolutions": 2, "pitch": 5, "section_size": 1, "base": ["-w", 0, 0], "body_name": "E"}));
assert!(near(bbox(&s, "A").0, [40.0, 0.0, 0.0]));
assert!(near(bbox(&s, "B").0, [-20.0, 35.0, -5.0]), "{:?}", bbox(&s, "B"));
assert!(near(bbox(&s, "C").0, [78.0, -2.0, -2.0]));
assert!(near(bbox(&s, "D").0, [-6.0, -6.0, 39.0]));
assert!((bbox(&s, "E").0[0] + 45.5).abs() < 0.2, "{:?}", bbox(&s, "E"));
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
assert!(near(bbox(&s, "A").0, [20.0, 0.0, 0.0]));
assert!(near(bbox(&s, "B").0, [-10.0, 15.0, -5.0]), "{:?}", bbox(&s, "B"));
assert!(near(bbox(&s, "C").0, [38.0, -2.0, -2.0]));
assert!(near(bbox(&s, "D").0, [-6.0, -6.0, 19.0]));
assert!((bbox(&s, "E").0[0] + 25.5).abs() < 0.2, "{:?}", bbox(&s, "E"));
}
/// Points and axes that cannot be read are refused, never silently put at the origin.
#[test]
fn unreadable_primitive_points_are_refused() {
let mut s = Session::default();
let cases: Vec<(&str, Value)> = vec![
("solid.cylinder", json!({"radius": 5, "height": 10, "base": ["nope/2", 0, 0]})),
("solid.cylinder", json!({"radius": 5, "height": 10, "base": [1, 2]})),
("solid.cylinder", json!({"radius": 5, "height": 10, "base": "XY"})),
("solid.cylinder", json!({"radius": 5, "height": 10, "base": [1, 2, "5 deg"]})),
("solid.cylinder", json!({"radius": 5, "height": 10, "axis": ["x", 0, 1]})),
("solid.cylinder", json!({"radius": 5, "height": 10, "axis": [0, 0, 0]})),
("solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0, null, 0]})),
("solid.box", json!({"length": 1, "width": 1, "height": 1, "center": {"x": 1}})),
("solid.sphere", json!({"radius": 1, "center": ["a +", 0, 0]})),
("solid.torus", json!({"major": 5, "minor": 1, "center": [1e300, 0, 0]})),
("solid.coil", json!({"diameter": 10, "revolutions": 2, "pitch": 5, "section_size": 1, "base": [true, 0, 0]})),
];
for (cmd, p) in cases {
let before = s.doc.features.len();
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p} should be refused");
assert_eq!(s.doc.features.len(), before, "{cmd} {p} added a feature");
}
}
/// Renaming a parameter follows into placement points; deleting it is refused while a point
/// uses it. Plain-number points are stored as numbers, as before.
#[test]
fn point_parameters_rename_and_store() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "off", "expression": "30 mm"}));
run(&mut s, "solid.sphere", json!({"radius": 2, "center": ["off", 0, 0], "body_name": "S"}));
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [1.5, -2, 0], "body_name": "B"}));
assert!(s.execute("parameters.delete", &json!({"name": "off"})).is_err());
run(&mut s, "parameters.rename", json!({"name": "off", "new_name": "shift"}));
run(&mut s, "parameters.change", json!({"name": "shift", "expression": "10 mm"}));
assert!(near(bbox(&s, "S").0, [8.0, -2.0, -2.0]), "{:?}", bbox(&s, "S"));
let v: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
let feats = v["features"].as_array().unwrap();
assert_eq!(feats[0]["center"], json!(["shift", 0.0, 0.0]), "{v}");
assert_eq!(feats[1]["corner"], json!([1.5, -2.0, 0.0]), "{v}");
}
/// A primitive placed by an expression in a moved component lands in that component's frame
/// and keeps following its parameter.
#[test]
fn point_expressions_in_a_moved_component() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "x0", "expression": "10 mm"}));
let c = run(&mut s, "component.create", json!({"name": "Arm"}));
let (cid, occ) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap());
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 50, 0]}));
run(&mut s, "component.activate", json!({"component": cid}));
run(&mut s, "solid.cylinder", json!({"radius": 1, "height": 2, "base": ["x0", 60, 0], "body_name": "Pin"}));
let wlo = |s: &Session| s.world_state().body("Pin").unwrap().mesh().bounds().min;
let lo = wlo(&s);
assert!((lo.x - 9.0).abs() < 1e-3 && (lo.y - 59.0).abs() < 1e-3, "{lo:?}");
run(&mut s, "parameters.change", json!({"name": "x0", "expression": "20 mm"}));
let lo = wlo(&s);
assert!((lo.x - 19.0).abs() < 1e-3 && (lo.y - 59.0).abs() < 1e-3, "{lo:?}");
}
+22 -5
View File
@@ -425,13 +425,19 @@ pub const MAX_STL_TRIANGLES: usize = 20_000_000;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use solvecraft_doc::{FeatureKind, Model, Operation}; use solvecraft_doc::{FeatureKind, Model, Operation, point_expr};
use solvecraft_geom::Vec3; use solvecraft_geom::Vec3;
fn model() -> (Document, Model) { fn model() -> (Document, Model) {
let mut doc = Document::new("t"); let mut doc = Document::new("t");
doc.add_feature( doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "20".into(), height: "30".into(), operation: Operation::NewBody }, FeatureKind::Box {
corner: point_expr(Vec3::ZERO),
length: "10".into(),
width: "20".into(),
height: "30".into(),
operation: Operation::NewBody,
},
None, None,
) )
.unwrap(); .unwrap();
@@ -558,7 +564,13 @@ mod tests {
fn threemf_round_trip() { fn threemf_round_trip() {
let mut doc = Document::new("t"); let mut doc = Document::new("t");
doc.add_feature( doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "10".into(), operation: Operation::NewBody }, FeatureKind::Box {
corner: point_expr(Vec3::ZERO),
length: "40".into(),
width: "30".into(),
height: "10".into(),
operation: Operation::NewBody,
},
None, None,
) )
.unwrap(); .unwrap();
@@ -569,7 +581,7 @@ mod tests {
.unwrap(); .unwrap();
doc.add_feature( doc.add_feature(
FeatureKind::Cylinder { FeatureKind::Cylinder {
base: Vec3::new(80.0, 0.0, 0.0), base: point_expr(Vec3::new(80.0, 0.0, 0.0)),
axis: Vec3::Z, axis: Vec3::Z,
radius: "6".into(), radius: "6".into(),
height: "15".into(), height: "15".into(),
@@ -579,7 +591,12 @@ mod tests {
) )
.unwrap(); .unwrap();
doc.add_feature( doc.add_feature(
FeatureKind::Torus { center: Vec3::new(0.0, 80.0, 0.0), major: "12".into(), minor: "3".into(), operation: Operation::NewBody }, FeatureKind::Torus {
center: point_expr(Vec3::new(0.0, 80.0, 0.0)),
major: "12".into(),
minor: "3".into(),
operation: Operation::NewBody,
},
None, None,
) )
.unwrap(); .unwrap();
+144 -12
View File
@@ -1,8 +1,8 @@
//! Edge fillets and chamfers as a local B-rep operation. //! Edge fillets and chamfers as a local B-rep operation.
//! //!
//! Supported: a straight, convex edge between two planar faces whose two end vertices each //! Supported locally: a straight edge between two planar faces whose two end vertices each
//! touch exactly one more planar face perpendicular to the edge (the common case for edges of //! touch exactly one more planar face. Fillets require perpendicular end faces; chamfers
//! extruded and box-like parts). The two side faces are trimmed back, the end faces get an arc //! intersect their offset rails with the end planes, including sloped walls. The end faces get an arc
//! (fillet) or a line (chamfer) at the corner, and a cylindrical (or planar) face is inserted. //! (fillet) or a line (chamfer) at the corner, and a cylindrical (or planar) face is inserted.
//! The result is exact. Edges meeting at an already blended corner, curved edges and other //! The result is exact. Edges meeting at an already blended corner, curved edges and other
//! configurations are reported as not supported yet. //! configurations are reported as not supported yet.
@@ -131,6 +131,60 @@ fn setback(r: f64, phi: f64, shape: Shape) -> f64 {
} }
} }
/// An extended corner edge must stay in the trimmed side face, not just its infinite
/// supporting plane. Reject crossings and contact with any boundary after the old corner,
/// including holes. Curved boundaries are sampled more finely than the clearance tolerance.
fn chamfer_extension_fits(face: &mt::Face, p: Vec3, q: Vec3, tol: f64) -> bool {
use mt::{BoundedCurve, ParameterDivision1D};
// truck requires a curve sampling tolerance of at least 1e-6. Keep the
// boundary clearance four times larger than the chord approximation error.
let tol = tol.max(4e-6);
let (Some(x), Some(n)) = ((q - p).normalized(), plane_normal(face)) else { return false };
let y = n.cross(x);
let len = p.dist(q);
if !len.is_finite() || len <= tol {
return false;
}
let mut inside = false;
for edge in face.edge_iter() {
let points = if matches!(edge.curve(), mt::Curve::Line(_)) {
vec![edge.front().point(), edge.back().point()]
} else {
let curve = edge.curve();
curve.parameter_division(curve.range_tuple(), tol * 0.25).1
};
if points.len() < 2 || points.len() > 100_000 {
return false;
}
for pair in points.windows(2) {
let [a, b] = pair else { return false };
let a = from_p3(*a) - p;
let b = from_p3(*b) - p;
let (ax, ay, bx, by) = (a.dot(x), a.dot(y), b.dot(x), b.dot(y));
if ![ax, ay, bx, by].iter().all(|v| v.is_finite()) {
return false;
}
// Contact/overlap with the extension, except its original endpoint.
if (ay.abs() <= tol && by.abs() <= tol && ax.max(bx) > tol && ax.min(bx) <= len + tol)
|| [(ax, ay), (bx, by)].iter().any(|(u, v)| *u > tol && *u <= len + tol && v.abs() <= tol)
{
return false;
}
if (ay > 0.0) != (by > 0.0) {
let hit = ax - ay * (bx - ax) / (by - ay);
if hit > tol && hit <= len + tol {
return false;
}
// Even-odd containment of q over all loops (outer boundary and holes).
if hit > len {
inside = !inside;
}
}
}
}
inside
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn blend_geometry( fn blend_geometry(
solid: &Solid, solid: &Solid,
@@ -153,6 +207,8 @@ fn blend_geometry(
g: usize, g: usize,
e1: mt::Edge, e1: mt::Edge,
e2: mt::Edge, e2: mt::Edge,
a: Vec3,
b: Vec3,
} }
let mut ends: Vec<End> = Vec::new(); let mut ends: Vec<End> = Vec::new();
for (v, p) in [(&v0, p0), (&v1, p1)] { for (v, p) in [(&v0, p0), (&v1, p1)] {
@@ -161,9 +217,19 @@ fn blend_geometry(
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) }; let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?; let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?; let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
if ng.dot(d).abs() < 1.0 - 1e-9 { if shape == Shape::Round && ng.dot(d).abs() < 1.0 - 1e-9 {
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge")); return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
} }
if ng.dot(d).abs() < 1e-9 {
return Err(unsupported("an end face parallel to the edge"));
}
// Each rail is parallel to the original edge and set back by s in its side
// face. Slide its endpoint along d until it lies on the actual end plane.
let endpoint = |t: Vec3| {
let q = p + t * s;
if shape == Shape::Flat { q - d * (ng.dot(t) * s / ng.dot(d)) } else { q }
};
let (a, b) = (endpoint(t1), endpoint(t2));
// Edges at v shared by g with f1 and with f2. // Edges at v shared by g with f1 and with f2.
let shared = |fi: usize| -> Result<mt::Edge> { let shared = |fi: usize| -> Result<mt::Edge> {
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
@@ -172,26 +238,46 @@ fn blend_geometry(
.ok_or_else(|| unsupported("unexpected corner topology")) .ok_or_else(|| unsupported("unexpected corner topology"))
}; };
let (e1, e2) = (shared(i1)?, shared(i2)?); let (e1, e2) = (shared(i1)?, shared(i2)?);
for (e, t) in [(&e1, t1), (&e2, t2)] { for (e, q, t, fi) in [(&e1, a, t1, i1), (&e2, b, t2, i2)] {
if !matches!(e.curve(), mt::Curve::Line(_)) { if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the edges at the corners must be straight")); return Err(unsupported("the edges at the corners must be straight"));
} }
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) }; let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
let along = (other - p).dot(t); let delta = other - p;
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol { if shape == Shape::Round {
// Fillets retain the square-end check: the original corner edge
// must run in the setback direction and have enough length left.
let along = delta.dot(t);
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
continue;
}
let len = delta.len();
let dir = delta.normalized().ok_or_else(|| unsupported("zero-length corner edge"))?;
let along = (q - p).dot(dir);
// An inner rim extends the neighbouring rim edge away from its other
// vertex. This is valid only within the trimmed side face.
if (q - p - dir * along).len() > tol * 100.0 + 1e-7
|| along >= len - tol
|| (along < 0.0 && !faces.get(fi).is_some_and(|f| chamfer_extension_fits(f, p, q, tol)))
{
return Err(unsupported("the blend is larger than the neighbouring faces")); return Err(unsupported("the blend is larger than the neighbouring faces"));
} }
} }
ends.push(End { v: v.clone(), g, e1, e2 }); ends.push(End { v: v.clone(), g, e1, e2, a, b });
} }
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) }; let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
if end0.g == end1.g { if end0.g == end1.g {
return Err(unsupported("both ends touch the same face")); return Err(unsupported("both ends touch the same face"));
} }
let a0 = builder::vertex(p3(p0 + t1 * s)); if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
let b0 = builder::vertex(p3(p0 + t2 * s)); return Err(unsupported("the blend is larger than the neighbouring faces"));
let a1 = builder::vertex(p3(p1 + t1 * s)); }
let b1 = builder::vertex(p3(p1 + t2 * s)); let a0 = builder::vertex(p3(end0.a));
let b0 = builder::vertex(p3(end0.b));
let a1 = builder::vertex(p3(end1.a));
let b1 = builder::vertex(p3(end1.b));
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from // Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
// both faces (inside the material for a convex edge, outside for a concave one); the arc's // both faces (inside the material for a convex edge, outside for a concave one); the arc's
// middle is the point nearest the old edge. // middle is the point nearest the old edge.
@@ -861,3 +947,49 @@ fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, b
let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] }; let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] };
crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into())) crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))
} }
#[cfg(test)]
mod tests {
use super::*;
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
#[test]
fn chamfer_extension_cannot_cross_a_hole_and_reenter_the_face() {
let rectangle = |a, b, c, d| Loop2::polygon(&[Vec2::new(a, b), Vec2::new(c, b), Vec2::new(c, d), Vec2::new(a, d)]);
let region =
Region2 { outer: rectangle(0.0, 0.0, 10.0, 10.0), holes: vec![rectangle(4.0, 4.0, 6.0, 6.0), Loop2::circle(Vec2::new(4.0, 1.5), 0.25)] };
for plane in [Plane::XY, Plane::XZ] {
let face = crate::build::face(&plane, &region).unwrap();
let p = plane.to_world(Vec2::new(4.0, 4.0));
let fits = |y| chamfer_extension_fits(&face, p, plane.to_world(Vec2::new(4.0, y)), 1e-6);
assert!(fits(2.0));
assert!(!fits(1.5)); // endpoint in the circular hole
assert!(!fits(0.5)); // endpoint in material, but the extension crossed the hole
assert!(!fits(-1e8)); // an arbitrarily long slide cannot escape the face
}
}
#[test]
fn local_chamfer_intersects_sloped_end_planes() {
for slope in [-0.5, 0.5] {
let region = Region2 {
outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(40.0, 0.0), Vec2::new(40.0 - slope * 20.0, 20.0), Vec2::new(slope * 20.0, 20.0)]),
holes: vec![],
};
let body = crate::extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
let pick = [Vec3::new(20.0, 0.0, 20.0)];
let result = blend(&body, &pick, 2.0, Shape::Flat, "chamfer").unwrap();
let removed = 2.0 * (40.0 - 2.0 * slope * 2.0 / 3.0);
let actual = crate::measure(&body).unwrap().volume - crate::measure(&result).unwrap().volume;
assert!((actual - removed).abs() < 1e-6, "{actual} vs {removed}");
assert!(result.validity().is_empty());
// The new corner vertices lie on both original end planes.
for point in [Vec3::new(slope * 2.0, 2.0, 20.0), Vec3::new(40.0 - slope * 2.0, 2.0, 20.0)] {
assert!(result.solid.vertex_iter().any(|v| vtx(&v).dist(point) < 1e-8));
}
assert!(blend(&body, &pick, 25.0, Shape::Flat, "chamfer").is_err());
// Oblique fillet caps need elliptical trims, which this local path does not build.
assert!(blend(&body, &pick, 2.0, Shape::Round, "fillet").is_err());
}
}
}
+44
View File
@@ -435,6 +435,50 @@ fn chamfer_top_loop_mitres() {
assert_eq!(m.merged.faces, 10); assert_eq!(m.merged.faces, 10);
} }
#[test]
fn chamfer_sloped_basin_rim() {
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let pocket =
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 4.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))]).unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let result = chamfer(&basin, &[Vec3::new(50.0, 4.0, 30.0)], 2.0).unwrap();
// The removed triangular section has area s² sin(phi)/2. Its length varies
// linearly between the sloped end walls, so its centroid gives the mean length.
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
let drop = 2.0 / (1.0 + wall_slope.powi(2)).sqrt();
let removed = drop * (60.0 - drop / 3.0);
let before = measure(&basin).unwrap();
let after = measure(&result).unwrap();
assert!(rel(after.volume, before.volume - removed) < 1e-8, "{} vs {}", after.volume, before.volume - removed);
assert_eq!(after.merged.faces, before.merged.faces + 1);
assert_eq!(result.lumps().unwrap().len(), 1);
assert!(result.validity().is_empty());
let mesh = result.tessellate(0.01).unwrap();
assert!(!mesh.contains(Vec3::new(50.0, 3.5, 29.9)));
assert!(mesh.contains(Vec3::new(50.0, 3.5, 28.0)));
}
#[test]
fn chamfer_wider_than_sloped_basin_rim_is_refused() {
for (margin, distance) in [(1.0, 2.0), (1.0, 3.0), (0.5, 4.0), (1.0, 1.0)] {
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let pocket =
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, margin, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))])
.unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let before = measure(&basin).unwrap();
let error = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], distance).unwrap_err();
assert!(error.to_string().contains("larger than the neighbouring faces"), "{margin}, {distance}: {error}");
assert_eq!(measure(&basin).unwrap().volume, before.volume);
// A smaller chamfer on the same thin rim is still supported.
let smaller = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], margin * 0.5).unwrap();
assert!(smaller.validity().is_empty());
assert!(measure(&smaller).unwrap().volume < before.volume);
}
}
#[test] #[test]
#[ignore] #[ignore]
fn debug_chamfer_cut() { fn debug_chamfer_cut() {
+1 -1
View File
@@ -29,7 +29,7 @@ pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, Sk
pub use offset::OffsetChain; pub use offset::OffsetChain;
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
pub use shape::{Shape, intersections}; pub use shape::{Shape, intersections};
pub use solver::{Hold, SolveReport, SolveStatus, solve, solve_holding}; pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
pub use text::{MAX_TEXT_CHARS, text_geometry}; pub use text::{MAX_TEXT_CHARS, text_geometry};
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline}; pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline};
+7
View File
@@ -456,6 +456,13 @@ fn measure(s: &State, k: &ConstraintKind) -> Option<f64> {
}) })
} }
/// What a dimension measures on the sketch as it stands (mm, radians for angles); `None` for a
/// constraint that is not a dimension.
pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0 };
measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite())
}
/// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known /// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known
/// touch point (better conditioned than the distance form when an end point is on the curve). /// touch point (better conditioned than the distance form when an end point is on the curve).
enum Block<'a> { enum Block<'a> {
+9 -4
View File
@@ -2801,6 +2801,11 @@ fn pt3(v: Vec3) -> Value {
json!([v.x, v.y, v.z]) json!([v.x, v.y, v.z])
} }
/// A point of expressions: numbers as numbers, expressions as strings.
fn pt_expr(p: &solvecraft_engine::doc::PointExpr) -> Value {
Value::Array(p.iter().map(|e| e.parse::<f64>().ok().filter(|x| x.is_finite()).map_or_else(|| json!(e), |x| json!(x))).collect())
}
/// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just /// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just
/// before the feature, so its references show on the geometry they refer to. /// before the feature, so its references show on the geometry they refer to.
pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dialog> { pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dialog> {
@@ -3008,7 +3013,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())], fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())],
operation: op_index(operation), operation: op_index(operation),
}; };
d.extra.insert("corner".into(), pt3(*corner)); d.extra.insert("corner".into(), pt_expr(corner));
d d
} }
FeatureKind::Cylinder { base, axis, radius, height, operation } => { FeatureKind::Cylinder { base, axis, radius, height, operation } => {
@@ -3018,14 +3023,14 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
fields: vec![("radius", radius.clone()), ("height", height.clone())], fields: vec![("radius", radius.clone()), ("height", height.clone())],
operation: op_index(operation), operation: op_index(operation),
}; };
d.extra.insert("base".into(), pt3(*base)); d.extra.insert("base".into(), pt_expr(base));
d.extra.insert("axis".into(), pt3(*axis)); d.extra.insert("axis".into(), pt3(*axis));
d d
} }
FeatureKind::Sphere { center, radius, operation } => { FeatureKind::Sphere { center, radius, operation } => {
let mut d = start("solid.sphere")?; let mut d = start("solid.sphere")?;
d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) }; d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) };
d.extra.insert("center".into(), pt3(*center)); d.extra.insert("center".into(), pt_expr(center));
d d
} }
FeatureKind::Torus { center, major, minor, operation } => { FeatureKind::Torus { center, major, minor, operation } => {
@@ -3035,7 +3040,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
fields: vec![("major", major.clone()), ("minor", minor.clone())], fields: vec![("major", major.clone()), ("minor", minor.clone())],
operation: op_index(operation), operation: op_index(operation),
}; };
d.extra.insert("center".into(), pt3(*center)); d.extra.insert("center".into(), pt_expr(center));
d d
} }
FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => { FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => {
+209 -182
View File
@@ -1,6 +1,6 @@
//! The timeline (bottom): features in order, the rollback marker, errors and warnings. //! The timeline (bottom): features in order, the rollback marker, errors and warnings.
use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; use egui::{Color32, Rect, Sense, Stroke, UiBuilder, pos2, vec2};
use serde_json::json; use serde_json::json;
use solvecraft_engine::doc::FeatureKind; use solvecraft_engine::doc::FeatureKind;
@@ -106,195 +106,222 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
x += 24.0; x += 24.0;
} }
x += 14.0; x += 14.0;
let feats: Vec<_> = app // The features scroll horizontally in the space right of the playback controls.
.session let strip = Rect::from_min_max(pos2(x, r.top()), r.right_bottom());
.doc ui.scope_builder(UiBuilder::new().max_rect(strip), |ui| {
.features egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
.iter() let p = ui.painter().clone();
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone())) let mut x = ui.max_rect().left();
.collect(); let origin = x;
// The feature whose body is under the cursor in the viewport. let feats: Vec<_> = app
let st = app.session.world_state(); .session
let from_view = match &app.viewport.hover { .doc
Some(crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. }) => { .features
st.body(body).map(|b| b.feature) .iter()
} .map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone()))
_ => None, .collect();
}; // The feature whose body is under the cursor in the viewport.
let drag_id = ui.id().with("tl_drag"); let st = app.session.world_state();
let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id)); let from_view = match &app.viewport.hover {
let pointer = ui.input(|i| i.pointer.hover_pos()); Some(
let mut xs: Vec<f32> = Vec::new(); crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. },
let mut hovered: Option<u64> = None; ) => st.body(body).map(|b| b.feature),
let mut edit: Option<u64> = None; _ => None,
let mut marker_rect: Option<Rect> = None; };
for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() { let drag_id = ui.id().with("tl_drag");
if i == marker { let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id));
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0))); let pointer = ui.input(|i| i.pointer.hover_pos());
x += 10.0; let mut xs: Vec<f32> = Vec::new();
} let mut hovered: Option<u64> = None;
xs.push(x); let mut edit: Option<u64> = None;
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0)); let mut marker_rect: Option<Rect> = None;
crate::scenario::publish_handle(&format!("timeline:{name}"), br.center()); for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() {
let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag()); if i == marker {
let res = app.session.model.result(*id); marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
let err = res.and_then(|r| r.error.clone()); x += 10.0;
let warn = res.and_then(|r| r.warning.clone());
let rolled = i >= marker;
let is_it = |s: &solvecraft_engine::Sel| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id);
let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it));
if resp.hovered() {
hovered = Some(*id);
}
let bg = if err.is_some() {
t.timeline_item_error
} else if sel {
t.accent_soft
} else if resp.hovered() || from_view == Some(*id) {
t.hover
} else {
t.timeline_item
};
let edge = if err.is_some() {
t.error
} else if from_view == Some(*id) {
t.accent
} else {
t.border
};
p.rect(br, 3.0, bg, Stroke::new(1.0, edge), egui::StrokeKind::Inside);
let ink = if rolled || *suppressed { t.border } else { t.icon };
icons::paint(&p, br.shrink(4.0), icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent });
if *suppressed {
p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim));
}
if warn.is_some() && err.is_none() {
icons::paint(&p, Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), "warning", t.icon, t.warning, t.warning);
}
let mut tip = format!("{name} ({ty})");
if let Some(e) = &err {
tip += &format!("\nError: {e}");
}
if let Some(w) = &warn {
tip += &format!("\nWarning: {w}");
}
if let Some(r) = res {
tip += &format!("\n{:.1} ms", r.ms);
}
tip += "\nDouble-click to edit · drag to reorder · right-click for more";
if resp.drag_started() {
drag = Some(Drag::Feature(*id));
}
let resp = resp.on_hover_text(tip);
if resp.clicked() {
// A dialog input that takes features (pattern objects) gets the click.
let takes = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::FEATURES != 0);
if takes {
if let Some(mut d) = app.dialog.take() {
d.pick(&app.session, solvecraft_engine::Sel::Feature { id: *id });
app.dialog = Some(d);
} }
} else { xs.push(x);
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0));
crate::scenario::publish_handle(&format!("timeline:{name}"), br.center());
let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag());
let res = app.session.model.result(*id);
let err = res.and_then(|r| r.error.clone());
let warn = res.and_then(|r| r.warning.clone());
let rolled = i >= marker;
let is_it = |s: &solvecraft_engine::Sel| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id);
let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it));
if resp.hovered() {
hovered = Some(*id);
}
let bg = if err.is_some() {
t.timeline_item_error
} else if sel {
t.accent_soft
} else if resp.hovered() || from_view == Some(*id) {
t.hover
} else {
t.timeline_item
};
let edge = if err.is_some() {
t.error
} else if from_view == Some(*id) {
t.accent
} else {
t.border
};
p.rect(br, 3.0, bg, Stroke::new(1.0, edge), egui::StrokeKind::Inside);
let ink = if rolled || *suppressed { t.border } else { t.icon };
icons::paint(
&p,
br.shrink(4.0),
icon,
ink,
if rolled { t.panel_header } else { t.icon_fill },
if rolled { t.border } else { t.accent },
);
if *suppressed {
p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim));
}
if warn.is_some() && err.is_none() {
icons::paint(
&p,
Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)),
"warning",
t.icon,
t.warning,
t.warning,
);
}
let mut tip = format!("{name} ({ty})");
if let Some(e) = &err {
tip += &format!("\nError: {e}");
}
if let Some(w) = &warn {
tip += &format!("\nWarning: {w}");
}
if let Some(r) = res {
tip += &format!("\n{:.1} ms", r.ms);
}
tip += "\nDouble-click to edit · drag to reorder · right-click for more";
if resp.drag_started() {
drag = Some(Drag::Feature(*id));
}
let resp = resp.on_hover_text(tip);
if resp.clicked() {
// A dialog input that takes features (pattern objects) gets the click.
let takes = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::FEATURES != 0);
if takes {
if let Some(mut d) = app.dialog.take() {
d.pick(&app.session, solvecraft_engine::Sel::Feature { id: *id });
app.dialog = Some(d);
}
} else {
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]}));
}
}
if resp.double_clicked() {
edit = Some(*id);
}
let sketch_of = match kind {
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => {
Some(*sketch)
}
_ => None,
};
resp.context_menu(|ui| {
if ui.button("Edit Feature").clicked() {
edit = Some(*id);
ui.close();
}
if let Some(sk) = sketch_of
&& ui.button("Edit Profile Sketch").clicked()
{
app.edit_sketch(sk);
ui.close();
}
if ui.button("Rename").clicked() {
app.dialog = Some(crate::dialogs::Dialog::rename(*id, name));
ui.close();
}
if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() {
let _ = app.run("timeline.suppress", json!({"feature": id}));
ui.close();
}
if ui.button("Roll History Marker Here").clicked() {
let _ = app.run("timeline.roll_to", json!({ "feature": id }));
ui.close();
}
if ui.button("Find in Browser").clicked() {
find_in_browser(app, *id);
ui.close();
}
ui.separator();
if ui.button("Delete").clicked() {
delete_feature(app, *id);
ui.close();
}
});
x += 32.0;
} }
} let end = x;
if resp.double_clicked() { if marker >= feats.len() {
edit = Some(*id); marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
}
let sketch_of = match kind {
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => Some(*sketch),
_ => None,
};
resp.context_menu(|ui| {
if ui.button("Edit Feature").clicked() {
edit = Some(*id);
ui.close();
} }
if let Some(sk) = sketch_of // The history marker: drag it to roll the model back or forward.
&& ui.button("Edit Profile Sketch").clicked() if let Some(mr) = marker_rect {
let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.id().with("tl_marker"), Sense::drag());
if resp.drag_started() {
drag = Some(Drag::Marker);
}
let col = if resp.hovered() || drag == Some(Drag::Marker) { t.accent } else { t.timeline_marker };
p.rect_filled(mr, 1.0, col);
resp.on_hover_text("History marker: drag to roll the model back; new features go here");
}
let content_end = crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r);
// The scroll range follows the drawn content (the buttons don't take layout space themselves).
ui.allocate_exact_size(vec2((content_end - origin).max(0.0), r.height()), Sense::hover());
// Drag feedback, and the drop.
if let (Some(dg), Some(pp)) = (drag, pointer) {
let slot = slot_at(&xs, end, pp.x);
let sx = xs.get(slot).copied().unwrap_or(end) - 4.0;
p.line_segment(
[pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)],
Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }),
);
}
let released = ui.input(|i| i.pointer.any_released());
// (Taking the drag ends it even when the pointer has left the window.)
if released
&& let Some(dg) = drag.take()
&& let Some(pp) = pointer
{ {
app.edit_sketch(sk); let slot = slot_at(&xs, end, pp.x);
ui.close(); match dg {
Drag::Marker => {
let _ = app.run("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
}
Drag::Feature(id) => {
let from = app.session.doc.feature_index(id).unwrap_or(0);
// Dropping after itself means the slot shifts by one.
let to = if slot > from { slot - 1 } else { slot };
if to != from {
let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to }));
}
}
}
} }
if ui.button("Rename").clicked() { ui.data_mut(|d| match drag {
app.dialog = Some(crate::dialogs::Dialog::rename(*id, name)); Some(dg) => {
ui.close(); d.insert_temp(drag_id, dg);
} }
if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() { None => d.remove::<Drag>(drag_id),
let _ = app.run("timeline.suppress", json!({"feature": id})); });
ui.close(); app.viewport.hover_feature = hovered;
} if let Some(id) = edit {
if ui.button("Roll History Marker Here").clicked() { app.edit_feature(id);
let _ = app.run("timeline.roll_to", json!({ "feature": id }));
ui.close();
}
if ui.button("Find in Browser").clicked() {
find_in_browser(app, *id);
ui.close();
}
ui.separator();
if ui.button("Delete").clicked() {
delete_feature(app, *id);
ui.close();
} }
}); });
x += 32.0;
}
let end = x;
if marker >= feats.len() {
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
}
// The history marker: drag it to roll the model back or forward.
if let Some(mr) = marker_rect {
let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.id().with("tl_marker"), Sense::drag());
if resp.drag_started() {
drag = Some(Drag::Marker);
}
let col = if resp.hovered() || drag == Some(Drag::Marker) { t.accent } else { t.timeline_marker };
p.rect_filled(mr, 1.0, col);
resp.on_hover_text("History marker: drag to roll the model back; new features go here");
}
crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r);
// Drag feedback, and the drop.
if let (Some(dg), Some(pp)) = (drag, pointer) {
let slot = slot_at(&xs, end, pp.x);
let sx = xs.get(slot).copied().unwrap_or(end) - 4.0;
p.line_segment(
[pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)],
Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }),
);
}
let released = ui.input(|i| i.pointer.any_released());
// (Taking the drag ends it even when the pointer has left the window.)
if released
&& let Some(dg) = drag.take()
&& let Some(pp) = pointer
{
let slot = slot_at(&xs, end, pp.x);
match dg {
Drag::Marker => {
let _ = app.run("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
}
Drag::Feature(id) => {
let from = app.session.doc.feature_index(id).unwrap_or(0);
// Dropping after itself means the slot shifts by one.
let to = if slot > from { slot - 1 } else { slot };
if to != from {
let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to }));
}
}
}
}
ui.data_mut(|d| match drag {
Some(dg) => {
d.insert_temp(drag_id, dg);
}
None => d.remove::<Drag>(drag_id),
}); });
app.viewport.hover_feature = hovered;
if let Some(id) = edit {
app.edit_feature(id);
}
}); });
} }
+44 -6
View File
@@ -2102,6 +2102,8 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32); let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32);
painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2))); painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
} }
let labels: Vec<String> = targets.iter().filter(|tg| !tg.corner).map(|tg| tg.name.to_uppercase()).collect();
let size = cube_label_size(ui.ctx(), &labels);
// Back to front: the faces and facets turned toward the camera. // Back to front: the faces and facets turned toward the camera.
for (i, pts) in shapes.iter().rev() { for (i, pts) in shapes.iter().rev() {
let tg = &targets[*i]; let tg = &targets[*i];
@@ -2117,7 +2119,8 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
// Fades out as the face turns edge-on. // Fades out as the face turns edge-on.
let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32; let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32;
if !tg.corner && ink > 0.05 { if !tg.corner && ink > 0.05 {
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), n, (r, u), c, Color32::from_rgb(40, 44, 52).gamma_multiply(ink))); let color = Color32::from_rgb(40, 44, 52).gamma_multiply(ink);
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), size, n, (r, u), c, color));
} }
} }
crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some()))); crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some())));
@@ -2208,10 +2211,24 @@ fn cube_label_axes(n: Vec3) -> (Vec3, Vec3) {
if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) } if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) }
} }
/// A face's label printed on the face: laid out flat, then mapped through the cube's projection, /// How much of a face the widest face label spans.
/// so it turns and foreshortens with the face. const CUBE_LABEL_WIDTH: f32 = 0.9;
fn cube_label(ctx: &egui::Context, text: &str, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(9.5), color, f32::INFINITY)); /// The face labels' font size: one size for all, the widest of `labels` spanning
/// `CUBE_LABEL_WIDTH` of a face.
fn cube_label_size(ctx: &egui::Context, labels: &[String]) -> f32 {
let probe = 10.0;
let widest = labels
.iter()
.map(|s| ctx.fonts_mut(|f| f.layout_no_wrap(s.clone(), FontId::proportional(probe), Color32::WHITE).size().x))
.fold(0.0, f32::max);
if widest > 0.0 { probe * CUBE_LABEL_WIDTH * 2.0 * CUBE_SCALE as f32 / widest } else { probe }
}
/// A face's label printed on the face at `size`: laid out flat, then mapped through the cube's
/// projection, so it turns and foreshortens with the face.
fn cube_label(ctx: &egui::Context, text: &str, size: f32, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(size), color, f32::INFINITY));
let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() }; let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() };
let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]); let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]);
let mut mesh = egui::Mesh::default(); let mut mesh = egui::Mesh::default();
@@ -2826,6 +2843,26 @@ mod tests {
} }
} }
fn face_labels(targets: &[CubeTarget]) -> Vec<String> {
targets.iter().filter(|t| !t.corner).map(|t| t.name.to_uppercase()).collect()
}
/// The widest label spans 90% of its face, looking straight at it.
#[test]
fn view_cube_widest_label_spans_most_of_its_face() {
let ctx = egui::Context::default();
let mut span = 0.0f32;
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let size = cube_label_size(ui.ctx(), &face_labels(&cube_targets()));
let front = Vec3::new(0.0, -1.0, 0.0);
let (r, u, _) = basis_from(front);
let pts = drawn_points(vec![cube_label(ui.ctx(), "BOTTOM", size, front, (r, u), pos2(100.0, 100.0), Color32::BLACK)]);
let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x)));
span = (hi - lo) / (2.0 * CUBE_SCALE as f32);
});
assert!((span - CUBE_LABEL_WIDTH).abs() < 0.05, "BOTTOM spans {span} of the face");
}
/// From any direction, each label is printed within its face. /// From any direction, each label is printed within its face.
#[test] #[test]
fn view_cube_labels_stay_on_their_faces() { fn view_cube_labels_stay_on_their_faces() {
@@ -2833,6 +2870,7 @@ mod tests {
let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0); let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| { let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let targets = cube_targets(); let targets = cube_targets();
let size = cube_label_size(ui.ctx(), &face_labels(&targets));
let c = pos2(100.0, 100.0); let c = pos2(100.0, 100.0);
for dir in orbit_directions().step_by(7) { for dir in orbit_directions().step_by(7) {
let (r, u, b) = basis_from(dir); let (r, u, b) = basis_from(dir);
@@ -2841,7 +2879,7 @@ mod tests {
if tg.corner { if tg.corner {
continue; continue;
} }
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), tg.normal(), (r, u), c, Color32::BLACK)]) { for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), size, tg.normal(), (r, u), c, Color32::BLACK)]) {
let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) }; let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) };
worst = worst.max(outside); worst = worst.max(outside);
drawn += 1; drawn += 1;
+32
View File
@@ -1,3 +1,35 @@
# Design loading
`solvecraft-cli bench-open design.solvecraft` reports JSON lines for file reading,
JSON decoding and migrations (including input-name repair), timeline evaluation, individual
features, and world placement/display meshing. `--parse-only` stops before geometry evaluation.
The parse timer includes document validation and repair, not just the JSON parser.
Windows x64, optimized `ci` profile, measured during other builds:
| Design | Parse/repair | Evaluate | Display meshes |
|---|---:|---:|---:|
| 19-feature enclosure, 34 KB | 1.2 ms | 14.9 s | 6 ms |
| 25-feature assembly, 15.2 MB, 2,990 bodies | 21 ms | 12.5 s | 2.1 s |
| Generated 1,000 independent boxes, 185 KB | 5 ms | 0.82 s | 0.15 s |
| Generated 5,000 boxes in five grids, 10 features | 0.45 ms | 17.6 s | 0.81 s |
The enclosures spend most of evaluation in joins and cuts, not parsing. Designs save feature
definitions; opening them rebuilds their geometry. Those times remain after the repair fix.
A separate generated timeline with missing input names reproduced minutes in the reader:
| Timeline (geometry rolled back) | Parse/repair before | After |
|---|---:|---:|
| 2,000 boxes, 6,000 unnamed inputs | 78.1 s | 10–20 ms |
| 5,000 boxes, 15,000 unnamed inputs | interrupted after 131 s, still repairing | 26–46 ms |
Previously, each missing input restarted the `d1`, `d2`, … search, scanning the document for
every candidate. This approaches cubic work as unnamed inputs accumulate. Loading now reserves
names once and assigns missing names with an advancing cursor. Existing names, names on later
features, parameter collisions and reuse of surplus names retain the previous behavior.
Regression tests compare the old and bulk repairs and load a 5,000-feature unnamed timeline.
# Viewport performance # Viewport performance
Measured with the control channel (`ui.inspect` `frame_ms`: CPU time of one UI frame, which Measured with the control channel (`ui.inspect` `frame_ms`: CPU time of one UI frame, which
+21
View File
@@ -6,6 +6,27 @@ GitHub Release. Publishing that verified draft is a separate operation. Pushing
pushing a tag does not start the release workflow. There is no automatic commit-message-based pushing a tag does not start the release workflow. There is no automatic commit-message-based
version bump and no patch release for every merge. version bump and no patch release for every merge.
## Nightly prereleases
`release.yml` also runs at midnight in `America/New_York` (Eastern time, following daylight
saving time). GitHub may start scheduled runs later when its runners are busy.
Before building, it compares the exact scheduled `main` commit with the tag of the most
recently published release, including prereleases. If the commits match, all packaging and
publication jobs are skipped: no new version, tag, or release is created. Drafts do not count
as published releases, so a failed build or publication can be retried the following night.
Changed commits use the existing package build and validation jobs. Only after all jobs
succeed, the workflow stages the assets in a draft and publishes a prerelease named
`v<workspace-version>-nightly.<YYYYMMDD>.<run-number>.<run-attempt>` with checksums and generated
notes. Any existing workspace prerelease suffix is replaced by the nightly suffix. Dates
use Eastern time; package versions are overridden without changing Cargo.toml or Cargo.lock,
so the binaries' embedded Cargo version remains the workspace version. Nightlies do not
replace the latest stable release or advance the `release` branch. The normal deliberate
release process below continues to create drafts for separate verification and publication.
Nightly publication uses `GITHUB_TOKEN`, so it does not trigger the downstream Flatpak Pages
deployment. The stable Flatpak repository is not automatically updated with nightly builds.
## Choose and prepare the version ## Choose and prepare the version
1. Fetch `origin` and its tags. Inspect the previous published release and the changes since 1. Fetch `origin` and its tags. Inspect the previous published release and the changes since
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Decide before building or assigning a nightly version. Checkout must include all tags.
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" != schedule ]; then
echo 'build=true' >>"$GITHUB_OUTPUT"
exit 0
fi
# /releases/latest excludes prereleases. Include every published release instead,
# including nightlies, and sort by publication time across all API pages.
latest_tag="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
--jq '[.[][] | select(.draft == false and .published_at != null)] | sort_by(.published_at) | last | .tag_name // empty')"
if [ -n "$latest_tag" ]; then
# Resolve the tag itself, not target_commitish (which can be a moving branch).
released_sha="$(git rev-parse --verify "refs/tags/$latest_tag^{commit}")"
if [ "$released_sha" = "$GITHUB_SHA" ]; then
echo 'build=false' >>"$GITHUB_OUTPUT"
echo "### Nightly skipped: no new commits since $latest_tag" >>"$GITHUB_STEP_SUMMARY"
exit 0
fi
fi
echo 'build=true' >>"$GITHUB_OUTPUT"