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
This commit is contained in:
commit
ade30e9c4e
107 files changed
+4093
-373
No files matched your search
@@ -98,17 +98,29 @@ jobs:
|
|||||||
if: needs.changes.outputs.native == 'true'
|
if: needs.changes.outputs.native == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
CARGO_INCREMENTAL: "0"
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||||
with:
|
with:
|
||||||
key: native-tests
|
key: native-tests
|
||||||
|
- name: Set up compiler cache
|
||||||
|
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||||
|
with:
|
||||||
|
version: v0.16.0
|
||||||
- name: Compile tests
|
- name: Compile tests
|
||||||
run: cargo test --workspace --profile ci --locked --no-run
|
run: cargo test --workspace --profile ci --locked --no-run
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
# Every test binary at once (cargo test runs them one after another).
|
# Every test binary at once (cargo test runs them one after another).
|
||||||
run: cargo xtask test --locked
|
run: cargo xtask test --locked
|
||||||
|
- name: Compiler cache statistics
|
||||||
|
if: always()
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||||
ci:
|
ci:
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pages: write
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||||
|
|||||||
@@ -49,3 +49,4 @@ Generated-in-code assets are original and have no file to list:
|
|||||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
|
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
|
||||||
|
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||||
Generated
-2
@@ -1753,8 +1753,6 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "matext4cgmath"
|
name = "matext4cgmath"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cgmath",
|
"cgmath",
|
||||||
"katexit",
|
"katexit",
|
||||||
|
|||||||
@@ -98,3 +98,4 @@ debug = 0
|
|||||||
truck-shapeops = { path = "vendor/truck-shapeops" }
|
truck-shapeops = { path = "vendor/truck-shapeops" }
|
||||||
truck-stepio = { path = "vendor/truck-stepio" }
|
truck-stepio = { path = "vendor/truck-stepio" }
|
||||||
truck-meshalgo = { path = "vendor/truck-meshalgo" }
|
truck-meshalgo = { path = "vendor/truck-meshalgo" }
|
||||||
|
matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
# SolveCraft roadmap
|
# SolveCraft roadmap
|
||||||
|
|
||||||
Status as of **2026-10-09**. SolveCraft targets Fusion-style parametric modelling (sketch →
|
Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
|
||||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||||
|
|
||||||
## At a glance
|
## At a glance
|
||||||
@@ -10,12 +10,19 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
|||||||
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
|
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
|
||||||
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
|
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
|
||||||
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
|
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
|
||||||
| Tests | 596 (41 of them UI scenarios): solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command |
|
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||||
|
|
||||||
## Scope (owner decision, 2026-10-07)
|
## Scope (owner decision, 2026-10-07)
|
||||||
|
|
||||||
|
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||||
|
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
|
||||||
|
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||||
|
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||||
|
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
|
||||||
|
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||||
|
|
||||||
In scope, in priority order:
|
In scope, in priority order:
|
||||||
1. SOLID and SKETCH (now).
|
1. SOLID and SKETCH (now).
|
||||||
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
|
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ fn plane_param(pl: &Value) -> Value {
|
|||||||
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
|
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
|
||||||
let std = match r.to_ascii_uppercase().as_str() {
|
let std = match r.to_ascii_uppercase().as_str() {
|
||||||
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
|
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
|
||||||
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
|
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, -1.0])),
|
||||||
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
|
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ fn services() -> Services {
|
|||||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||||
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
||||||
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
||||||
|
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||||
.add_filter("All files", &["*"])
|
.add_filter("All files", &["*"])
|
||||||
.pick_file()
|
.pick_file()
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
@@ -69,6 +70,23 @@ fn services() -> Services {
|
|||||||
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
|
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
|
||||||
})),
|
})),
|
||||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||||
|
show_in_folder: Some(Box::new(|path| {
|
||||||
|
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||||
|
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
|
||||||
|
"explorer"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"open"
|
||||||
|
} else {
|
||||||
|
"xdg-open"
|
||||||
|
});
|
||||||
|
command.arg(folder);
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
command.creation_flags(0x08000000);
|
||||||
|
}
|
||||||
|
let _ = command.spawn();
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ pub struct Canvas {
|
|||||||
pub flip: bool,
|
pub flip: bool,
|
||||||
#[serde(default = "yes")]
|
#[serde(default = "yes")]
|
||||||
pub visible: bool,
|
pub visible: bool,
|
||||||
|
/// The component it belongs to (its plane is in that component's frame, so it moves with
|
||||||
|
/// the component's occurrence); 0 = the root.
|
||||||
|
#[serde(default, skip_serializing_if = "is_root")]
|
||||||
|
pub component: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_root(c: &u64) -> bool {
|
||||||
|
*c == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn half() -> f64 {
|
fn half() -> f64 {
|
||||||
@@ -57,7 +65,7 @@ impl Canvas {
|
|||||||
let f = if self.flip { -1.0 } else { 1.0 };
|
let f = if self.flip { -1.0 } else { 1.0 };
|
||||||
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
|
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
|
||||||
}
|
}
|
||||||
/// Corners in world coordinates.
|
/// Corners on `plane` (in whatever frame the plane is given).
|
||||||
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
|
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
|
||||||
self.corners().map(|p| plane.to_world(p))
|
self.corners().map(|p| plane.to_world(p))
|
||||||
}
|
}
|
||||||
@@ -66,6 +74,19 @@ impl Canvas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl crate::Document {
|
||||||
|
/// A canvas's plane in the world: resolved in its component's frame, then placed where the
|
||||||
|
/// component's occurrence puts it.
|
||||||
|
pub fn canvas_plane(&self, vals: &std::collections::BTreeMap<String, crate::expr::Value>, c: &Canvas) -> crate::Result<Plane> {
|
||||||
|
let p = self.resolve_plane(vals, &c.plane, 0)?;
|
||||||
|
let m = self.component_transform(c.component);
|
||||||
|
if crate::is_identity(&m) {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
Ok(Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)).unwrap_or(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Image kind and pixel size from the file header (PNG or JPEG).
|
/// Image kind and pixel size from the file header (PNG or JPEG).
|
||||||
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
|
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
|
||||||
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||||
|
|||||||
+64
-13
@@ -125,7 +125,8 @@ pub struct Extent {
|
|||||||
/// Optional second side distance (two-sided extrude), along the negative normal.
|
/// Optional second side distance (two-sided extrude), along the negative normal.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub distance2: Option<String>,
|
pub distance2: Option<String>,
|
||||||
/// Start offset from the sketch plane (expression).
|
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
|
||||||
|
/// direction.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub start_offset: Option<String>,
|
pub start_offset: Option<String>,
|
||||||
/// Through all bodies in the direction(s) instead of `distance`.
|
/// Through all bodies in the direction(s) instead of `distance`.
|
||||||
@@ -939,6 +940,66 @@ fn z_axis() -> Vec3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureKind {
|
impl FeatureKind {
|
||||||
|
/// 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.
|
||||||
|
pub fn input_sketches(&self) -> Vec<u64> {
|
||||||
|
use FeatureKind as K;
|
||||||
|
match self {
|
||||||
|
K::Extrude { sketch, .. }
|
||||||
|
| K::Revolve { sketch, .. }
|
||||||
|
| K::Emboss { sketch, .. }
|
||||||
|
| K::Rib { sketch, .. }
|
||||||
|
| K::SheetBase { sketch, .. }
|
||||||
|
| K::SheetContour { sketch, .. } => vec![*sketch],
|
||||||
|
K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
|
||||||
|
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
|
||||||
|
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
|
||||||
|
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
|
||||||
|
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
|
||||||
|
K::Sketch { .. }
|
||||||
|
| K::Pattern { .. }
|
||||||
|
| K::Fillet { .. }
|
||||||
|
| K::Chamfer { .. }
|
||||||
|
| K::Box { .. }
|
||||||
|
| K::Cylinder { .. }
|
||||||
|
| K::Sphere { .. }
|
||||||
|
| K::Torus { .. }
|
||||||
|
| K::Combine { .. }
|
||||||
|
| K::Mirror { .. }
|
||||||
|
| K::Thread { .. }
|
||||||
|
| K::Shell { .. }
|
||||||
|
| K::Draft { .. }
|
||||||
|
| K::ConstructionPlane { .. }
|
||||||
|
| K::Split { .. }
|
||||||
|
| K::ConstructionAxis { .. }
|
||||||
|
| K::ConstructionPoint { .. }
|
||||||
|
| K::Scale { .. }
|
||||||
|
| K::OffsetFace { .. }
|
||||||
|
| K::DeleteFace { .. }
|
||||||
|
| K::BoundingSolid { .. }
|
||||||
|
| K::ReplaceFace { .. }
|
||||||
|
| K::Align { .. }
|
||||||
|
| K::Coil { .. }
|
||||||
|
| K::SheetFlange { .. }
|
||||||
|
| K::SheetHem { .. }
|
||||||
|
| K::SheetUnfold { .. }
|
||||||
|
| K::SheetConvert { .. }
|
||||||
|
| K::Boss { .. }
|
||||||
|
| K::Lip { .. }
|
||||||
|
| K::SnapFit { .. }
|
||||||
|
| K::Rest { .. }
|
||||||
|
| K::BoundaryFill { .. }
|
||||||
|
| K::Remove { .. }
|
||||||
|
| K::Stitch { .. }
|
||||||
|
| K::Thicken { .. }
|
||||||
|
| K::SurfaceExtend { .. }
|
||||||
|
| K::SurfaceTrim { .. }
|
||||||
|
| K::Move { .. }
|
||||||
|
| K::Import { .. }
|
||||||
|
| K::MeshImport { .. } => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A sheet metal feature (its result depends on the sheet metal rules).
|
/// A sheet metal feature (its result depends on the sheet metal rules).
|
||||||
pub fn is_plastic(&self) -> bool {
|
pub fn is_plastic(&self) -> bool {
|
||||||
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
|
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
|
||||||
@@ -1115,6 +1176,7 @@ impl FeatureKind {
|
|||||||
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
|
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
|
||||||
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
|
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
|
||||||
FeatureKind::Thread { face, .. } => vec![face],
|
FeatureKind::Thread { face, .. } => vec![face],
|
||||||
|
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1619,18 +1681,7 @@ impl Document {
|
|||||||
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
|
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
|
||||||
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
|
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
|
||||||
let mut gone = vec![id];
|
let mut gone = vec![id];
|
||||||
for f in &self.features {
|
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
|
||||||
match &f.kind {
|
|
||||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
|
|
||||||
FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id),
|
|
||||||
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
|
|
||||||
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
|
|
||||||
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
|
|
||||||
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
|
|
||||||
FeatureKind::SheetFold { sketch: Some(s), .. } | FeatureKind::SplitFace { sketch: Some(s), .. } if *s == id => gone.push(f.id),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.features.retain(|f| !gone.contains(&f.id));
|
self.features.retain(|f| !gone.contains(&f.id));
|
||||||
// Configuration columns of deleted features go with them.
|
// Configuration columns of deleted features go with them.
|
||||||
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
|
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
|
||||||
|
|||||||
+97
-6
@@ -377,6 +377,10 @@ pub struct Model {
|
|||||||
empty: Arc<ModelState>,
|
empty: Arc<ModelState>,
|
||||||
/// Features recomputed by the last `evaluate`.
|
/// Features recomputed by the last `evaluate`.
|
||||||
pub last_recomputed: usize,
|
pub last_recomputed: usize,
|
||||||
|
/// Features whose result the last `evaluate` took from the cache instead of recomputing: the
|
||||||
|
/// same feature on the same inputs was evaluated before (an edit set back, undo, redo, a
|
||||||
|
/// configuration row). Their geometry did change since the previous evaluation.
|
||||||
|
pub last_restored: usize,
|
||||||
/// Results by inputs, shared with this model's copies.
|
/// Results by inputs, shared with this model's copies.
|
||||||
pub cache: Arc<std::sync::Mutex<EvalCache>>,
|
pub cache: Arc<std::sync::Mutex<EvalCache>>,
|
||||||
/// Evaluate everything from scratch (no reuse; tests compare the two).
|
/// Evaluate everything from scratch (no reuse; tests compare the two).
|
||||||
@@ -467,6 +471,22 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
|
|||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where components are placed, for a sketch that sees other components' geometry (on a face,
|
||||||
|
/// or with projections): it is resolved in its own component's view of them.
|
||||||
|
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||||
|
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
|
||||||
|
if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
for o in &doc.occurrences {
|
||||||
|
o.id.hash(&mut h);
|
||||||
|
o.transform.iter().flatten().for_each(|x| x.to_bits().hash(&mut h));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&doc.body_offsets).unwrap_or_default().hash(&mut h);
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
fn now() -> web_time::Instant {
|
fn now() -> web_time::Instant {
|
||||||
web_time::Instant::now()
|
web_time::Instant::now()
|
||||||
}
|
}
|
||||||
@@ -505,11 +525,12 @@ impl Model {
|
|||||||
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
|
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
|
||||||
let mut reuse = !self.no_cache;
|
let mut reuse = !self.no_cache;
|
||||||
let mut recomputed = 0;
|
let mut recomputed = 0;
|
||||||
|
let mut restored = 0;
|
||||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||||
let ctx = context_digest(doc, &vals);
|
let ctx = context_digest(doc, &vals);
|
||||||
for (i, f) in doc.features.iter().enumerate() {
|
for (i, f) in doc.features.iter().enumerate() {
|
||||||
let rolled_back = i >= marker;
|
let rolled_back = i >= marker;
|
||||||
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back);
|
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f);
|
||||||
// Sheet metal features also depend on the rules (and the parameters they use).
|
// Sheet metal features also depend on the rules (and the parameters they use).
|
||||||
if f.kind.is_sheet() {
|
if f.kind.is_sheet() {
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
@@ -565,10 +586,11 @@ impl Model {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// The same feature on the same inputs: its earlier result.
|
// The same feature on the same inputs: its earlier result.
|
||||||
let key = cache_key(f, fp, fingerprint(0, f, &vals, false), ctx, &state);
|
let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state);
|
||||||
if !self.no_cache
|
if !self.no_cache
|
||||||
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
|
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
|
||||||
{
|
{
|
||||||
|
restored += 1;
|
||||||
if c.error.is_none() {
|
if c.error.is_none() {
|
||||||
state = c.state.clone();
|
state = c.state.clone();
|
||||||
}
|
}
|
||||||
@@ -652,6 +674,7 @@ impl Model {
|
|||||||
}
|
}
|
||||||
self.results = out;
|
self.results = out;
|
||||||
self.last_recomputed = recomputed;
|
self.last_recomputed = recomputed;
|
||||||
|
self.last_restored = restored;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1398,6 +1421,20 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
|||||||
}
|
}
|
||||||
let mut tools = Vec::new();
|
let mut tools = Vec::new();
|
||||||
for r in ®ions {
|
for r in ®ions {
|
||||||
|
if *operation == Operation::Join {
|
||||||
|
let exact = kernel::extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?;
|
||||||
|
if exact.iter().all(|tool| {
|
||||||
|
st.bodies
|
||||||
|
.iter()
|
||||||
|
.filter(|b| targets.is_empty() || targets.contains(&b.name))
|
||||||
|
.any(|b| kernel::can_join_touching_faces(&b.body, tool))
|
||||||
|
}) {
|
||||||
|
// Preserve a whole touching face: extending into a chamfer replaces
|
||||||
|
// the sewable seam with a coincident curved intersection.
|
||||||
|
tools.extend(exact);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
|
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
|
||||||
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
|
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
|
||||||
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
|
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
|
||||||
@@ -2281,17 +2318,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
|||||||
| FeatureKind::Align { .. }
|
| FeatureKind::Align { .. }
|
||||||
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
|
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
|
||||||
FeatureKind::Sketch { plane, sketch } => {
|
FeatureKind::Sketch { plane, sketch } => {
|
||||||
|
let fst = state_in_frame(doc, st, f.component);
|
||||||
let (plane, face_warning) = match plane {
|
let (plane, face_warning) = match plane {
|
||||||
PlaneRef::Face { plane: picked, at, name } => face_plane(st, picked, *at, name.as_deref()),
|
PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()),
|
||||||
other => (doc.resolve_plane_in(vals, other, 0, Some(st))?, None),
|
other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||||
};
|
};
|
||||||
let mut sk = sketch.clone();
|
let mut sk = sketch.clone();
|
||||||
let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk);
|
let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk);
|
||||||
|
drop(fst);
|
||||||
|
sk.refresh_offsets()?;
|
||||||
if let Some(w) = face_warning {
|
if let Some(w) = face_warning {
|
||||||
warns.insert(0, w);
|
warns.insert(0, w);
|
||||||
}
|
}
|
||||||
doc.apply_dimension_values(vals, &mut sk)?;
|
doc.apply_dimension_values(vals, &mut sk)?;
|
||||||
let report = solve(&mut sk);
|
let mut report = solve(&mut sk);
|
||||||
|
if !sk.offsets.is_empty() {
|
||||||
|
// Source dimensions may have moved curves during the solve.
|
||||||
|
sk.refresh_offsets()?;
|
||||||
|
report = solve(&mut sk);
|
||||||
|
}
|
||||||
if !report.ok() {
|
if !report.ok() {
|
||||||
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||||
}
|
}
|
||||||
@@ -2811,6 +2856,52 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
|
|||||||
(2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string())
|
(2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The model as a component sees it: bodies and sketches of components placed differently from
|
||||||
|
/// it are moved into its frame (through both placements), so a sketch on another component's
|
||||||
|
/// face finds the face where it is shown and projects its edges there. The model itself when
|
||||||
|
/// every placement agrees (the usual case).
|
||||||
|
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
|
||||||
|
use std::borrow::Cow;
|
||||||
|
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
|
||||||
|
return Cow::Borrowed(st);
|
||||||
|
}
|
||||||
|
let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) };
|
||||||
|
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||||
|
let rel = |c: u64, body: Option<&str>| {
|
||||||
|
let m = crate::mat_mul(&inv, &doc.component_transform(c));
|
||||||
|
match body.and_then(|b| doc.body_offsets.get(b)) {
|
||||||
|
Some(o) => crate::mat_mul(&m, o),
|
||||||
|
None => m,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let body_m: Vec<Mat> = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect();
|
||||||
|
let sketch_m: Vec<Mat> = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect();
|
||||||
|
if body_m.iter().chain(&sketch_m).all(crate::is_identity) {
|
||||||
|
return Cow::Borrowed(st);
|
||||||
|
}
|
||||||
|
let mut out = st.clone();
|
||||||
|
for (b, m) in out.bodies.iter_mut().zip(&body_m) {
|
||||||
|
if !crate::is_identity(m)
|
||||||
|
&& let Ok(t) = kernel::transform_matrix(&b.body, *m)
|
||||||
|
{
|
||||||
|
// Moving a body keeps its faces (and their order): it keeps their names.
|
||||||
|
let names = crate::naming::face_names(b);
|
||||||
|
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
|
||||||
|
nb.component = b.component;
|
||||||
|
nb.names = crate::naming::NameCell::known(names);
|
||||||
|
*b = nb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
|
||||||
|
if !crate::is_identity(m)
|
||||||
|
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
|
||||||
|
{
|
||||||
|
s.plane = np;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cow::Owned(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// The model placed in the world: each component's bodies, sketches and threads through every
|
/// The model placed in the world: each component's bodies, sketches and threads through every
|
||||||
/// occurrence of it (bodies of the first placement keep their names; further instances get the
|
/// occurrence of it (bodies of the first placement keep their names; further instances get the
|
||||||
/// occurrence path in brackets). Without components this is the model itself.
|
/// occurrence path in brackets). Without components this is the model itself.
|
||||||
|
|||||||
@@ -613,7 +613,12 @@ fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
|
|||||||
/// The body a placed plastic feature joins: by name, else the one under the point.
|
/// The body a placed plastic feature joins: by name, else the one under the point.
|
||||||
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
|
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
|
||||||
let i = body_at(st, body, &[p])?;
|
let i = body_at(st, body, &[p])?;
|
||||||
st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into()))
|
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
|
||||||
|
let tolerance = (b.body.size() * 1e-5).max(1e-4);
|
||||||
|
if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) {
|
||||||
|
return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into()));
|
||||||
|
}
|
||||||
|
Ok(b.name.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Axes with z along `z` (and x along `x` when given).
|
/// Axes with z along `z` (and x along `x` when given).
|
||||||
|
|||||||
@@ -18,13 +18,15 @@
|
|||||||
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
|
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
|
||||||
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
|
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
|
||||||
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
|
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
|
||||||
|
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
|
||||||
|
//! explicit planes retaining their previous axes, including offset-plane expressions.
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{DocError, Document, Result};
|
use crate::{DocError, Document, Result};
|
||||||
|
|
||||||
/// The format this build writes.
|
/// The format this build writes.
|
||||||
pub const FORMAT: u32 = 5;
|
pub const FORMAT: u32 = 6;
|
||||||
|
|
||||||
pub fn format_string(n: u32) -> String {
|
pub fn format_string(n: u32) -> String {
|
||||||
format!("solvecraft/{n}")
|
format!("solvecraft/{n}")
|
||||||
@@ -37,7 +39,30 @@ pub fn version_of(s: &str) -> Option<u32> {
|
|||||||
|
|
||||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||||
const JSON_STEPS: [JsonStep; 4] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5];
|
const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
|
||||||
|
|
||||||
|
/// Old XZ references retain their exact frame, including nested offset and angled planes.
|
||||||
|
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
|
||||||
|
/// dimensions, profiles, extrude directions or parameter expressions in saved designs.
|
||||||
|
fn v5_to_v6(v: &mut Value) -> Result<()> {
|
||||||
|
fn preserve(v: &mut Value) {
|
||||||
|
match v {
|
||||||
|
Value::Object(o) => {
|
||||||
|
let old_xz = o.get("type").and_then(Value::as_str) == Some("origin")
|
||||||
|
&& o.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case("XZ") || n.eq_ignore_ascii_case("FRONT"));
|
||||||
|
if old_xz {
|
||||||
|
*v = serde_json::json!({"type": "custom", "plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}});
|
||||||
|
} else {
|
||||||
|
o.values_mut().for_each(preserve);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(a) => a.iter_mut().for_each(preserve),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preserve(v);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Features in designs with components keep reaching every body.
|
/// Features in designs with components keep reaching every body.
|
||||||
fn v4_to_v5(v: &mut Value) -> Result<()> {
|
fn v4_to_v5(v: &mut Value) -> Result<()> {
|
||||||
@@ -198,6 +223,32 @@ mod tests {
|
|||||||
assert_eq!(back, d);
|
assert_eq!(back, d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_xz_and_nested_offsets_keep_their_frame() {
|
||||||
|
let mut doc = Document::new("old XZ");
|
||||||
|
let id = doc
|
||||||
|
.add_feature(
|
||||||
|
crate::FeatureKind::ConstructionPlane {
|
||||||
|
plane: crate::PlaneRef::Offset { base: Box::new(crate::PlaneRef::Origin { name: "XZ".into() }), distance: "7 mm".into() },
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||||
|
json["format"] = Value::from("solvecraft/5");
|
||||||
|
let migrated = read(&json.to_string()).unwrap();
|
||||||
|
let crate::FeatureKind::ConstructionPlane { plane } = &migrated.feature(id).unwrap().kind else { panic!("plane") };
|
||||||
|
let (values, errors) = migrated.param_values();
|
||||||
|
assert!(errors.is_empty());
|
||||||
|
let p = migrated.resolve_plane(&values, plane, 0).unwrap();
|
||||||
|
assert_eq!(p.y, solvecraft_geom::Vec3::Z);
|
||||||
|
assert_eq!(p.normal(), -solvecraft_geom::Vec3::Y);
|
||||||
|
assert_eq!(p.origin, solvecraft_geom::Vec3::new(0.0, -7.0, 0.0));
|
||||||
|
assert_eq!(read(&migrated.to_json()).unwrap(), migrated);
|
||||||
|
let fresh = doc.resolve_plane(&values, &crate::PlaneRef::Origin { name: "XZ".into() }, 0).unwrap();
|
||||||
|
assert_eq!(fresh.normal(), solvecraft_geom::Vec3::Y);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn older_upgraded_newer_refused_others_rejected() {
|
fn older_upgraded_newer_refused_others_rejected() {
|
||||||
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
|
|||||||
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,
|
||||||
world_state,
|
state_in_frame, world_state,
|
||||||
};
|
};
|
||||||
pub use params::{ParamRow, ParamUser, cycles};
|
pub use params::{ParamRow, ParamUser, cycles};
|
||||||
pub use solvecraft_kernel as kernel;
|
pub use solvecraft_kernel as kernel;
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ impl NameCell {
|
|||||||
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
|
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
|
||||||
NameCell { origin, names: Arc::new(OnceLock::new()) }
|
NameCell { origin, names: Arc::new(OnceLock::new()) }
|
||||||
}
|
}
|
||||||
|
/// Names already known (a moved copy of a body keeps its faces' names).
|
||||||
|
pub fn known(names: Arc<Vec<String>>) -> Self {
|
||||||
|
let cell = OnceLock::new();
|
||||||
|
let _ = cell.set(names);
|
||||||
|
NameCell { origin: None, names: Arc::new(cell) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deeper chains than this are named by position only (bounded work and stack).
|
/// Deeper chains than this are named by position only (bounded work and stack).
|
||||||
|
|||||||
@@ -273,7 +273,20 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
|
|||||||
let mut warn = Vec::new();
|
let mut warn = Vec::new();
|
||||||
let links = sk.links.clone();
|
let links = sk.links.clone();
|
||||||
for l in &links {
|
for l in &links {
|
||||||
match resolve(doc, vals, st, plane, l.kind, &l.source) {
|
let mut source = l.source.clone();
|
||||||
|
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
|
||||||
|
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
|
||||||
|
*at = p;
|
||||||
|
if split {
|
||||||
|
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sk.set_link_lost(&l.id, true);
|
||||||
|
warn.push(format!("projected face `{name}` no longer exists"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match resolve(doc, vals, st, plane, l.kind, &source) {
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
sk.set_link_source(&l.id, r.source);
|
sk.set_link_source(&l.id, r.source);
|
||||||
let _ = sk.set_link_wires(&l.id, &r.wires);
|
let _ = sk.set_link_wires(&l.id, &r.wires);
|
||||||
@@ -472,7 +485,7 @@ fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
|
|||||||
let (c, n, r, closed) = circle3(pts)?;
|
let (c, n, r, closed) = circle3(pts)?;
|
||||||
let sn = plane.normal();
|
let sn = plane.normal();
|
||||||
let cosang = n.dot(sn).abs();
|
let cosang = n.dot(sn).abs();
|
||||||
if cosang > 1.0 - 1e-12 || cosang < 1e-9 {
|
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if closed {
|
if closed {
|
||||||
|
|||||||
@@ -899,7 +899,7 @@ impl SheetBody {
|
|||||||
return Err(DocError::Invalid("the hem is shorter than its bend".into()));
|
return Err(DocError::Invalid("the hem is shorter than its bend".into()));
|
||||||
}
|
}
|
||||||
// A hem folds back over the face it starts from (toward the bottom unless flipped).
|
// A hem folds back over the face it starts from (toward the bottom unless flipped).
|
||||||
let up = !top == flip;
|
let up = top != flip;
|
||||||
let up = !up;
|
let up = !up;
|
||||||
self.flanges.push(SheetFlange {
|
self.flanges.push(SheetFlange {
|
||||||
parent: panel,
|
parent: panel,
|
||||||
|
|||||||
@@ -81,6 +81,26 @@ fn parametric_plate_reevaluates() {
|
|||||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
|
||||||
|
/// the evaluation reports the features it restored (their geometry did change back).
|
||||||
|
#[test]
|
||||||
|
fn set_back_reports_restored_features() {
|
||||||
|
let (mut doc, _) = plate_doc();
|
||||||
|
let mut m = Model::new();
|
||||||
|
m.evaluate(&doc);
|
||||||
|
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
|
||||||
|
doc.set_param("width", "50 mm", None, None).unwrap();
|
||||||
|
m.evaluate(&doc);
|
||||||
|
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
|
||||||
|
doc.set_param("width", "40 mm", None, None).unwrap();
|
||||||
|
m.evaluate(&doc);
|
||||||
|
assert_eq!((m.last_recomputed, m.last_restored), (0, 2));
|
||||||
|
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
||||||
|
// No change: nothing recomputed and nothing restored.
|
||||||
|
m.evaluate(&doc);
|
||||||
|
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn box_fillet_cut_timeline() {
|
fn box_fillet_cut_timeline() {
|
||||||
let (mut doc, _) = plate_doc();
|
let (mut doc, _) = plate_doc();
|
||||||
@@ -275,3 +295,53 @@ fn parameter_cycles_are_found() {
|
|||||||
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
|
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
|
||||||
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
|
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Features that use a sketch's points or regions go with it (not only profile and path users).
|
||||||
|
#[test]
|
||||||
|
fn input_sketches_cover_points_and_patches() {
|
||||||
|
let hole: FeatureKind = serde_json::from_value(serde_json::json!({
|
||||||
|
"type": "hole", "position": [0, 0, 0], "direction": [0, 0, -1], "diameter": "3",
|
||||||
|
"points": {"sketch": 7, "ids": ["p1"]},
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(hole.input_sketches(), vec![7]);
|
||||||
|
let patch: FeatureKind = serde_json::from_value(serde_json::json!({"type": "patch", "sketch": 9})).unwrap();
|
||||||
|
assert_eq!(patch.input_sketches(), vec![9]);
|
||||||
|
let pipe: FeatureKind = serde_json::from_value(serde_json::json!({"type": "pipe", "path_sketch": 72, "path": ["l1"], "diameter": "4"})).unwrap();
|
||||||
|
assert_eq!(pipe.input_sketches(), vec![72]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch
|
||||||
|
/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the
|
||||||
|
/// arm that returns no sketches. New variants are caught by the compiler (the match has no
|
||||||
|
/// wildcard); this catches a sketch field added to an existing variant.
|
||||||
|
#[test]
|
||||||
|
fn input_sketches_lists_every_variant_with_a_sketch_field() {
|
||||||
|
let src = include_str!("document.rs");
|
||||||
|
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
|
||||||
|
let mut with_sketch = Vec::new();
|
||||||
|
let mut variant = "";
|
||||||
|
for line in body.lines() {
|
||||||
|
let t = line.trim();
|
||||||
|
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
|
||||||
|
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
|
||||||
|
let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints")))
|
||||||
|
|| ty.contains("SketchPoints")
|
||||||
|
|| ty.contains("LoftSection");
|
||||||
|
if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) {
|
||||||
|
with_sketch.push(variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
|
||||||
|
let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap();
|
||||||
|
let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap();
|
||||||
|
for v in with_sketch {
|
||||||
|
assert!(
|
||||||
|
!empty_arm.contains(&format!("K::{v} {{")),
|
||||||
|
"FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! Booleans that failed, panicked or hung, replayed from the scripts in
|
||||||
|
//! `examples/regressions/` (each script's description says what went wrong).
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::Session;
|
||||||
|
|
||||||
|
fn replay(script: &str) -> Session {
|
||||||
|
let mut s = Session::default();
|
||||||
|
let script: Value = serde_json::from_str(script).unwrap();
|
||||||
|
s.run_script(&script).unwrap();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume(s: &Session, body: &str) -> f64 {
|
||||||
|
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
|
||||||
|
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
|
||||||
|
fn slab_pocket_loft() -> (f64, f64) {
|
||||||
|
let slab = 900.0 * 480.0 * 160.0;
|
||||||
|
let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0);
|
||||||
|
(slab - 557.68 * 100.0 * 120.0, loft)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loft_cut_beside_a_round_hole() {
|
||||||
|
let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json"));
|
||||||
|
let (pocketed, loft) = slab_pocket_loft();
|
||||||
|
let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0;
|
||||||
|
let want = pocketed - loft - hole;
|
||||||
|
let got = volume(&s, "Body1");
|
||||||
|
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shallow_loft_cut_from_the_top_face() {
|
||||||
|
let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json"));
|
||||||
|
let (_, loft) = slab_pocket_loft();
|
||||||
|
let want = 900.0 * 480.0 * 160.0 - loft;
|
||||||
|
let got = volume(&s, "Body1");
|
||||||
|
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oblique_cut_through_a_small_arc_matches_a_split() {
|
||||||
|
let scripts = [
|
||||||
|
include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json"),
|
||||||
|
include_str!("../../../examples/regressions/oblique_cut_of_cylinder_hang.json"),
|
||||||
|
];
|
||||||
|
// The same bar split by the prism's tilted top: the piece above it is the cut's result.
|
||||||
|
let mut split = Session::default();
|
||||||
|
let script: Value = serde_json::from_str(scripts[0]).unwrap();
|
||||||
|
let steps = script.get("commands").and_then(Value::as_array).unwrap();
|
||||||
|
split.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
|
||||||
|
let whole = volume(&split, "Tool");
|
||||||
|
split.execute("solid.split_body", &json!({"body": "Tool", "plane": {"origin": [0, -472, 853.91], "normal": [0, 15.44, 40]}})).unwrap();
|
||||||
|
let above = volume(&split, "Tool");
|
||||||
|
assert!(above < whole - 10.0, "{above} of {whole}");
|
||||||
|
for script in scripts {
|
||||||
|
let s = replay(script);
|
||||||
|
let got = volume(&s, "Tool");
|
||||||
|
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -495,7 +495,9 @@ mod tests {
|
|||||||
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
|
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
|
||||||
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
|
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
|
||||||
let z = zmax(&mut s);
|
let z = zmax(&mut s);
|
||||||
assert!(z > 9.0, "the profile stands up on XZ: {z}");
|
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
|
||||||
|
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
|
||||||
|
assert!((ymin + 20.0).abs() < 1e-6);
|
||||||
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
|
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
|
||||||
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
|
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ use crate::{EngineError, Result, Sel, Session, Snapshot};
|
|||||||
pub static COMMANDS: &[CommandSpec] = &[
|
pub static COMMANDS: &[CommandSpec] = &[
|
||||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
|
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
|
||||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
|
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
|
||||||
|
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
|
||||||
|
.noundo()
|
||||||
|
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
|
||||||
|
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
|
||||||
|
.noundo()
|
||||||
|
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
|
||||||
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
|
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
|
||||||
CommandSpec::new("timeline.delete", "Delete", delete)
|
CommandSpec::new("timeline.delete", "Delete", delete)
|
||||||
.at("SOLID", "MODIFY")
|
.at("SOLID", "MODIFY")
|
||||||
@@ -28,8 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
|||||||
.noundo()
|
.noundo()
|
||||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
|
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
|
||||||
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
|
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
|
||||||
|
restored: features whose earlier result for the same inputs was reused",
|
||||||
|
),
|
||||||
CommandSpec::new("select.set", "Select", select_set)
|
CommandSpec::new("select.set", "Select", select_set)
|
||||||
.noundo()
|
.noundo()
|
||||||
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
|
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
|
||||||
@@ -58,6 +66,21 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
|||||||
Ok(json!({"redone": label}))
|
Ok(json!({"redone": label}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||||
|
Ok(json!({"checkpoint": s.checkpoint()}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let cmd = "edit.restore_checkpoint";
|
||||||
|
let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?;
|
||||||
|
if bool_(p, "forget") == Some(true) {
|
||||||
|
s.drop_checkpoint(id);
|
||||||
|
return Ok(json!({"checkpoint": id, "restored": false}));
|
||||||
|
}
|
||||||
|
s.restore_checkpoint(id)?;
|
||||||
|
Ok(json!({"checkpoint": id, "restored": true}))
|
||||||
|
}
|
||||||
|
|
||||||
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||||
s.model = solvecraft_doc::Model::new();
|
s.model = solvecraft_doc::Model::new();
|
||||||
s.refresh();
|
s.refresh();
|
||||||
@@ -204,7 +227,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||||
return Err(EngineError::Other(e));
|
return Err(EngineError::Other(e));
|
||||||
}
|
}
|
||||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
@@ -307,7 +330,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||||
return Err(EngineError::Other(e));
|
return Err(EngineError::Other(e));
|
||||||
}
|
}
|
||||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
|||||||
.at("SOLID", "CREATE")
|
.at("SOLID", "CREATE")
|
||||||
.icon("extrude")
|
.icon("extrude")
|
||||||
.key("E")
|
.key("E")
|
||||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
|
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
|
||||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||||
.at("SOLID", "CREATE")
|
.at("SOLID", "CREATE")
|
||||||
.icon("revolve")
|
.icon("revolve")
|
||||||
@@ -581,7 +581,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let new = format!("({old}) + ({distance})");
|
let new = format!("({old}) + ({distance})");
|
||||||
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
|
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
|
||||||
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
|
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
|
||||||
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
|
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
|
||||||
}
|
}
|
||||||
// Another curved face moves along its normal.
|
// Another curved face moves along its normal.
|
||||||
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
|
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec<Vec3> {
|
|||||||
|
|
||||||
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
|
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
|
||||||
/// the nearest.
|
/// the nearest.
|
||||||
fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option<Vec<Vec3>> {
|
fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option<usize> {
|
||||||
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||||
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
|
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
|
||||||
if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) {
|
if m.edges.get(index).is_some_and(|e| near(e) < tol) {
|
||||||
return Some(e.clone());
|
return Some(index);
|
||||||
}
|
}
|
||||||
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned()
|
m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
|
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
|
||||||
@@ -75,15 +75,23 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
|
|||||||
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
|
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
|
||||||
Sel::Edge { body, index, point } => {
|
Sel::Edge { body, index, point } => {
|
||||||
let m = st.body(body)?.mesh();
|
let m = st.body(body)?.mesh();
|
||||||
let pts = edge_points(&m, *index, *point)?;
|
let index = edge_index(&m, *index, *point)?;
|
||||||
|
let pts = m.edges.get(index)?;
|
||||||
|
let rim = m.circular_rim(index);
|
||||||
|
if rim.len() > 1 {
|
||||||
|
let pieces: Vec<&Vec<Vec3>> = rim.iter().filter_map(|&i| m.edges.get(i)).collect();
|
||||||
|
let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum();
|
||||||
|
let points = pieces.iter().flat_map(|p| densify(p)).collect();
|
||||||
|
return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len})));
|
||||||
|
}
|
||||||
let (a, b) = (*pts.first()?, *pts.last()?);
|
let (a, b) = (*pts.first()?, *pts.last()?);
|
||||||
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
|
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
|
||||||
let len = polyline_len(&pts);
|
let len = polyline_len(pts);
|
||||||
(Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len}))
|
(Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len}))
|
||||||
}
|
}
|
||||||
Sel::Face { body, index, .. } => {
|
Sel::Face { body, index, .. } => {
|
||||||
let m = st.body(body)?.mesh();
|
let m = st.body(body)?.mesh();
|
||||||
let tris = face_tris(&m, *index);
|
let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::<Vec<_>>();
|
||||||
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||||
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
|
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
|
||||||
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
|
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
|
||||||
@@ -150,10 +158,41 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
|
|||||||
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||||
let cmd = "inspect.measure";
|
let cmd = "inspect.measure";
|
||||||
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
|
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
|
||||||
if list.is_empty() || list.len() > 2 {
|
if list.is_empty() || list.len() > 1000 {
|
||||||
return Err(bad(cmd, "measure one or two items"));
|
return Err(bad(cmd, "measure one or two items"));
|
||||||
}
|
}
|
||||||
let sels: Vec<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
|
let st = s.world_state();
|
||||||
|
let mut sels = Vec::new();
|
||||||
|
for value in list {
|
||||||
|
let mut sel = serde_json::from_value::<Sel>(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||||
|
// Two kernel halves of one visible item are one measurement target.
|
||||||
|
match &mut sel {
|
||||||
|
Sel::Edge { body, index, point } => {
|
||||||
|
if let Some(b) = st.body(body) {
|
||||||
|
let m = b.mesh();
|
||||||
|
let resolved = edge_index(&m, *index, *point).unwrap_or(*index);
|
||||||
|
*index = m.circular_rim(resolved).first().copied().unwrap_or(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Sel::Face { body, index, .. } => {
|
||||||
|
if let Some(b) = st.body(body) {
|
||||||
|
*index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let duplicate = sels.iter().any(|x| match (x, &sel) {
|
||||||
|
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
|
||||||
|
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
|
||||||
|
_ => false,
|
||||||
|
});
|
||||||
|
if !duplicate {
|
||||||
|
sels.push(sel);
|
||||||
|
}
|
||||||
|
if sels.len() > 2 {
|
||||||
|
return Err(bad(cmd, "measure one or two items"));
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut described = Vec::new();
|
let mut described = Vec::new();
|
||||||
for x in &sels {
|
for x in &sels {
|
||||||
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
|
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use crate::{EngineError, Result, Session};
|
|||||||
pub static COMMANDS: &[CommandSpec] = &[
|
pub static COMMANDS: &[CommandSpec] = &[
|
||||||
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
|
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
|
||||||
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
|
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
|
||||||
favorite?: bool, new_name?: rename (references follow); or delete: name",
|
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
|
||||||
|
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
|
||||||
),
|
),
|
||||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
|
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
|
||||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
|
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
|
||||||
@@ -107,6 +108,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
out["value"] = json!(vals.get(&name).map(|v| v.v));
|
out["value"] = json!(vals.get(&name).map(|v| v.v));
|
||||||
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
|
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
|
||||||
out["recomputed"] = json!(s.model.last_recomputed);
|
out["recomputed"] = json!(s.model.last_recomputed);
|
||||||
|
out["restored"] = json!(s.model.last_restored);
|
||||||
out["errors"] = json!(failing(s));
|
out["errors"] = json!(failing(s));
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ fn width_follows_an_expression() {
|
|||||||
assert!(e.contains("expected a length, got an angle"), "{e}");
|
assert!(e.contains("expected a length, got an angle"), "{e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
|
||||||
|
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
|
||||||
|
/// for "nothing changed". timeline.edit reports the same.
|
||||||
|
#[test]
|
||||||
|
fn setting_a_parameter_back_reports_restored_features() {
|
||||||
|
let mut s = Session::default();
|
||||||
|
block(&mut s);
|
||||||
|
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
|
||||||
|
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||||
|
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
|
||||||
|
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
|
||||||
|
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||||
|
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
|
||||||
|
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||||
|
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}}));
|
||||||
|
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||||
|
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feature_inputs_are_named_and_editable() {
|
fn feature_inputs_are_named_and_editable() {
|
||||||
let mut s = Session::default();
|
let mut s = Session::default();
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
|
|||||||
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
|
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
|
||||||
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
|
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
|
||||||
run(s, "sketch.create", json!({"plane": "XZ"}))?;
|
run(s, "sketch.create", json!({"plane": "XZ"}))?;
|
||||||
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect();
|
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
|
||||||
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
|
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
|
||||||
run(s, "sketch.finish", json!({}))?;
|
run(s, "sketch.finish", json!({}))?;
|
||||||
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
|
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
|
||||||
|
|||||||
@@ -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 | {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",
|
||||||
)];
|
)];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions.
|
//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions.
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use solvecraft_doc::{FeatureKind, PlaneRef};
|
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
|
||||||
use solvecraft_geom::{Plane, Vec2, Vec3};
|
use solvecraft_geom::{Plane, Vec2, Vec3};
|
||||||
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
|
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
|||||||
.params(concat!("p0, p1: overall ends, width", connect_doc!())),
|
.params(concat!("p0, p1: overall ends, width", connect_doc!())),
|
||||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())),
|
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())),
|
||||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)",
|
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||||
),
|
),
|
||||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||||
.at("SKETCH", "CONSTRAINTS")
|
.at("SKETCH", "CONSTRAINTS")
|
||||||
@@ -359,8 +359,10 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
|||||||
}
|
}
|
||||||
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.
|
// A planar face of a body at this point, as the active component sees the model
|
||||||
let st = s.model.state();
|
// (another component's face where it is placed).
|
||||||
|
let model = s.model.state();
|
||||||
|
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
|
||||||
let mut found = None;
|
let mut found = None;
|
||||||
let mut face_name = None;
|
let mut face_name = None;
|
||||||
for b in &st.bodies {
|
for b in &st.bodies {
|
||||||
@@ -406,7 +408,10 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
|
|||||||
let s1 = (b - a).cross(p - a).dot(n);
|
let s1 = (b - a).cross(p - a).dot(n);
|
||||||
let s2 = (c - b).cross(p - b).dot(n);
|
let s2 = (c - b).cross(p - b).dot(n);
|
||||||
let s3 = (a - c).cross(p - c).dot(n);
|
let s3 = (a - c).cross(p - c).dot(n);
|
||||||
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
|
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0)
|
||||||
|
|| (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
|
||||||
|
// Exact curved boundaries can sit just outside their tessellated chords.
|
||||||
|
|| p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a)) <= tol * 0.1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1332,7 +1337,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
};
|
};
|
||||||
let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }));
|
let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }));
|
||||||
let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. }));
|
let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. }));
|
||||||
let (k, cur): (ConstraintKind, f64) = match (e0, e1) {
|
// The other reading of an angle between two lines (see below).
|
||||||
|
let mut alt: Option<(ConstraintKind, f64)> = None;
|
||||||
|
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
|
||||||
((None, Some(c)), None) if is_line(c) => {
|
((None, Some(c)), None) if is_line(c) => {
|
||||||
let (a, b) = line_pts(sk, c).unwrap_or_default();
|
let (a, b) = line_pts(sk, c).unwrap_or_default();
|
||||||
match ty.as_str() {
|
match ty.as_str() {
|
||||||
@@ -1409,7 +1416,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
ang = -ang;
|
ang = -ang;
|
||||||
std::mem::swap(&mut ka, &mut kb);
|
std::mem::swap(&mut ka, &mut kb);
|
||||||
}
|
}
|
||||||
(ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang)
|
// Two lines make two angles, θ and 180° − θ, whichever way they were
|
||||||
|
// drawn: the acute one unless the value asks for the other.
|
||||||
|
let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang);
|
||||||
|
let supplement =
|
||||||
|
(ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang);
|
||||||
|
let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) };
|
||||||
|
alt = Some(obtuse);
|
||||||
|
acute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1426,7 +1440,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
c.driven = true;
|
c.driven = true;
|
||||||
}
|
}
|
||||||
before = None;
|
before = None;
|
||||||
return Ok((String::new(), name, cur));
|
return Ok((String::new(), name, measured(is_angle, cur)));
|
||||||
}
|
}
|
||||||
let (unit, default_expr) =
|
let (unit, default_expr) =
|
||||||
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
|
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
|
||||||
@@ -1437,28 +1451,39 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if let Some(err) = errs.get(&pname) {
|
if let Some(err) = errs.get(&pname) {
|
||||||
return Err(bad(cmd, format!("value: {err}")));
|
return Err(bad(cmd, format!("value: {err}")));
|
||||||
}
|
}
|
||||||
let _ = vals;
|
// An angle between two lines dimensions whichever of its two readings is nearer the value.
|
||||||
|
if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok()))
|
||||||
|
&& (want - ca).abs() < (want - cur).abs()
|
||||||
|
{
|
||||||
|
(k, cur) = (ka, ca);
|
||||||
|
}
|
||||||
let name = k.name();
|
let name = k.name();
|
||||||
let id = sk.add_constraint(k, Some(pname.clone()))?;
|
let id = sk.add_constraint(k, Some(pname.clone()))?;
|
||||||
place_text(sk, &id, text_at);
|
place_text(sk, &id, text_at);
|
||||||
Ok((pname, name, cur))
|
Ok((pname, name, measured(is_angle, cur)))
|
||||||
})?;
|
})?;
|
||||||
// Report what the dimension measures once the sketch is solved, not the geometry before.
|
// Report what the dimension measures once the sketch is solved, not the geometry before.
|
||||||
let current = solved_measure(s, p, cmd).unwrap_or(current);
|
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, "sketch": info}));
|
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
|
||||||
}
|
}
|
||||||
if reject_redundant(before, &info, cmd).is_err() {
|
if reject_redundant(before, &info, cmd).is_err() {
|
||||||
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
|
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
|
||||||
}
|
}
|
||||||
let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||||
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
|
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dimension's measured value as reported, in the unit its expression is written in.
|
||||||
|
fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
|
||||||
|
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.
|
/// 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> {
|
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 sk = s.doc.sketch(target_sketch(s, p, cmd).ok()?).ok()?;
|
||||||
solvecraft_sketch::measure_dimension(sk, &sk.constraints.last()?.kind)
|
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
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
|
|||||||
return Err(bad(cmd, "`width` must be positive"));
|
return Err(bad(cmd, "`width` must be positive"));
|
||||||
}
|
}
|
||||||
let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0);
|
let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0);
|
||||||
|
// It belongs to the active component (its plane is in that frame) and moves with it.
|
||||||
|
let component = s.active_component;
|
||||||
let doc = s.doc_mut();
|
let doc = s.doc_mut();
|
||||||
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
|
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
|
||||||
let base = if decal { "Decal" } else { "Canvas" };
|
let base = if decal { "Decal" } else { "Canvas" };
|
||||||
@@ -74,6 +76,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
|
|||||||
opacity,
|
opacity,
|
||||||
flip: bool_(p, "flip").unwrap_or(false),
|
flip: bool_(p, "flip").unwrap_or(false),
|
||||||
visible: true,
|
visible: true,
|
||||||
|
component,
|
||||||
});
|
});
|
||||||
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
|
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,3 +221,65 @@ fn drawing_onto_a_curve_keeps_the_point_on_it() {
|
|||||||
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
|
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
|
||||||
assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}");
|
assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined
|
||||||
|
/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart
|
||||||
|
/// while the lines meet at 30°.
|
||||||
|
fn spout() -> Session {
|
||||||
|
let mut s = new_sketch();
|
||||||
|
run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]}));
|
||||||
|
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50}));
|
||||||
|
run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35}));
|
||||||
|
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"}));
|
||||||
|
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"}));
|
||||||
|
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795}));
|
||||||
|
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955}));
|
||||||
|
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068}));
|
||||||
|
run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134}));
|
||||||
|
let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020}));
|
||||||
|
assert_eq!(d["sketch"]["dof"], 1, "{d}");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The angle (degrees, 0..=90) between two lines of the active sketch.
|
||||||
|
fn line_angle(s: &Session, a: &str, b: &str) -> f64 {
|
||||||
|
let sk = sketch(s);
|
||||||
|
let dir = |l: &str| {
|
||||||
|
let c = sk.curve_index(l).unwrap();
|
||||||
|
let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap();
|
||||||
|
q - p
|
||||||
|
};
|
||||||
|
let (u, v) = (dir(a), dir(b));
|
||||||
|
let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
|
||||||
|
t.min(180.0 - t)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() {
|
||||||
|
// The riser and the outlet are not adjacent and meet at 30°; their directions are 150°
|
||||||
|
// apart. Asking for 30° used to swing the outlet round and was refused as over-constraining.
|
||||||
|
for ents in [["l1", "l3"], ["l3", "l1"]] {
|
||||||
|
let mut s = spout();
|
||||||
|
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["unit"], "deg", "{d}");
|
||||||
|
assert!((d["measured"].as_f64().unwrap() - 30.0).abs() < 1e-6, "{d}");
|
||||||
|
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.
|
||||||
|
let mut s = spout();
|
||||||
|
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"}));
|
||||||
|
assert_eq!(d["sketch"]["dof"], 0, "{d}");
|
||||||
|
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
|
||||||
|
let mut s = spout();
|
||||||
|
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
|
||||||
|
assert!((d["measured"].as_f64().unwrap() - 60.0).abs() < 1e-6, "{d}");
|
||||||
|
// Without a value the acute angle is kept as it is.
|
||||||
|
let mut s = spout();
|
||||||
|
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
|
||||||
|
let e = d["expression"].as_str().unwrap();
|
||||||
|
assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::<f64>().unwrap() - 30.3).abs() < 0.1, "{d}");
|
||||||
|
// Lengths report millimetres.
|
||||||
|
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
|
||||||
|
assert_eq!(d["unit"], "mm", "{d}");
|
||||||
|
}
|
||||||
@@ -520,7 +520,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
|
|||||||
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||||||
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||||||
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
|
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
|
||||||
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
|
if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) {
|
||||||
return Err(bad(cmd, "the lines are parallel"));
|
return Err(bad(cmd, "the lines are parallel"));
|
||||||
}
|
}
|
||||||
let d = r / half.tan();
|
let d = r / half.tan();
|
||||||
@@ -813,6 +813,12 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
let ci = sk.add_circle(cc, nr, Some(c), None)?;
|
let ci = sk.add_circle(cc, nr, Some(c), None)?;
|
||||||
made.push(ci);
|
made.push(ci);
|
||||||
|
sk.offsets.push(solvecraft_sketch::OffsetChain {
|
||||||
|
sources: vec![(ids[0].clone(), true)],
|
||||||
|
outputs: ids_of(sk, &made),
|
||||||
|
distance: r - nr,
|
||||||
|
closed: true,
|
||||||
|
});
|
||||||
return Ok((ids_of(sk, &made), cons));
|
return Ok((ids_of(sk, &made), cons));
|
||||||
}
|
}
|
||||||
let pool: Vec<usize> = if use_chain {
|
let pool: Vec<usize> = if use_chain {
|
||||||
@@ -916,7 +922,13 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok((ids_of(sk, &made), cons))
|
let outputs = ids_of(sk, &made);
|
||||||
|
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
|
||||||
|
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
|
||||||
|
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
|
||||||
|
sk.refresh_offsets()?;
|
||||||
|
}
|
||||||
|
Ok((outputs, cons))
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
|
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,8 +140,20 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
|
|||||||
let plane = sketch_plane(doc, id)?;
|
let plane = sketch_plane(doc, id)?;
|
||||||
let (vals, _) = doc.param_values();
|
let (vals, _) = doc.param_values();
|
||||||
let st = s.model.state_before(id);
|
let st = s.model.state_before(id);
|
||||||
|
// The model as the sketch's component sees it.
|
||||||
|
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||||
|
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
|
||||||
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
|
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
|
||||||
Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?)
|
let name = if let LinkSource::Face { at, .. } = &r.source {
|
||||||
|
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
|
||||||
|
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
|
||||||
|
l.face_name = name;
|
||||||
|
}
|
||||||
|
Ok(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn link_json(sk: &Sketch, link: &str) -> Value {
|
fn link_json(sk: &Sketch, link: &str) -> Value {
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ enum K {
|
|||||||
Plane,
|
Plane,
|
||||||
/// A body name, or a list of them.
|
/// A body name, or a list of them.
|
||||||
Body,
|
Body,
|
||||||
|
/// A point (or a list of picks) for the sketch being made or edited: into that sketch's
|
||||||
|
/// component frame, whichever component the geometry under it belongs to (a sketch in the
|
||||||
|
/// root on a moved component's face finds the face where it is shown).
|
||||||
|
Here,
|
||||||
|
/// A plane object for a new sketch or canvas: into the active component's frame.
|
||||||
|
HerePlane,
|
||||||
}
|
}
|
||||||
|
|
||||||
use K::*;
|
use K::*;
|
||||||
@@ -56,11 +62,12 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
|||||||
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
|
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
|
||||||
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
|
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
|
||||||
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
|
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
|
||||||
"sketch.create" | "sketch.redefine" => &[("plane", Plane)],
|
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
|
||||||
"canvas.decal" => &[("face", Pick)],
|
"canvas.decal" => &[("face", Here)],
|
||||||
"sketch.intersect" => &[("refs", Picks)],
|
"canvas.insert" => &[("plane", HerePlane), ("at", Here)],
|
||||||
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Pick), ("body", Body)],
|
"sketch.project" | "sketch.intersect" | "sketch.include_3d" => &[("refs", Here), ("ref", Here)],
|
||||||
"sketch.intersection_curve" => &[("a", Picks), ("b", Picks)],
|
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Here)],
|
||||||
|
"sketch.intersection_curve" => &[("a", Here), ("b", Here)],
|
||||||
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)],
|
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)],
|
||||||
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
|
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
|
||||||
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
|
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
|
||||||
@@ -75,6 +82,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
|||||||
struct Frames {
|
struct Frames {
|
||||||
bodies: Vec<(String, String, Mat)>,
|
bodies: Vec<(String, String, Mat)>,
|
||||||
active: Mat,
|
active: Mat,
|
||||||
|
/// The frame of the sketch being edited (its component), else the active component's.
|
||||||
|
sketch: Mat,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn frames(s: &Session) -> Frames {
|
fn frames(s: &Session) -> Frames {
|
||||||
@@ -104,7 +113,8 @@ fn frames(s: &Session) -> Frames {
|
|||||||
(world, own, m)
|
(world, own, m)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Frames { bodies, active: doc.component_transform(s.active_component) }
|
let sketch_comp = s.active_sketch.and_then(|id| doc.feature(id)).map(|f| f.component).unwrap_or(s.active_component);
|
||||||
|
Frames { bodies, active: doc.component_transform(s.active_component), sketch: doc.component_transform(sketch_comp) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Does any placement move anything (so the world differs from the components' frames)?
|
/// Does any placement move anything (so the world differs from the components' frames)?
|
||||||
@@ -171,12 +181,14 @@ struct Mapper<'a> {
|
|||||||
/// The frame of the command's first pick (its directions turn with it).
|
/// The frame of the command's first pick (its directions turn with it).
|
||||||
first: Option<Mat>,
|
first: Option<Mat>,
|
||||||
changed: bool,
|
changed: bool,
|
||||||
|
/// Picks go into the sketch's frame (`Here`), not the frame of the body under them.
|
||||||
|
here: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mapper<'_> {
|
impl Mapper<'_> {
|
||||||
fn pick(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
|
fn pick(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
|
||||||
let p = vec3(v)?;
|
let p = vec3(v)?;
|
||||||
let m = frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active);
|
let m = if self.here { self.f.sketch } else { frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active) };
|
||||||
self.first.get_or_insert(m);
|
self.first.get_or_insert(m);
|
||||||
self.local_point(&m, p)
|
self.local_point(&m, p)
|
||||||
}
|
}
|
||||||
@@ -325,14 +337,14 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let obj = params.as_object()?;
|
let obj = params.as_object()?;
|
||||||
let mut m = Mapper { s, f: frames(s), first: None, changed: false };
|
let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false };
|
||||||
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
|
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
|
||||||
let mut out: Map<String, Value> = obj.clone();
|
let mut out: Map<String, Value> = obj.clone();
|
||||||
// Picks first: their frame turns the directions.
|
// Picks first: their frame turns the directions.
|
||||||
for pass in [true, false] {
|
for pass in [true, false] {
|
||||||
for (k, kind) in keys {
|
for (k, kind) in keys {
|
||||||
let Some(v) = obj.get(*k) else { continue };
|
let Some(v) = obj.get(*k) else { continue };
|
||||||
let is_pick = matches!(kind, Pick | Picks | Plane);
|
let is_pick = matches!(kind, Pick | Picks | Plane | Here | HerePlane);
|
||||||
if is_pick != pass {
|
if is_pick != pass {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -343,6 +355,19 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
|||||||
Dir => m.dir(v),
|
Dir => m.dir(v),
|
||||||
Plane => m.plane(v),
|
Plane => m.plane(v),
|
||||||
Body => m.body(v),
|
Body => m.body(v),
|
||||||
|
Here | HerePlane => {
|
||||||
|
// A new sketch or canvas goes into the active component.
|
||||||
|
let keep = m.f.sketch;
|
||||||
|
if *kind == HerePlane {
|
||||||
|
m.f.sketch = m.f.active;
|
||||||
|
}
|
||||||
|
m.here = true;
|
||||||
|
// Body names stay: the sketch's view of the model keeps them.
|
||||||
|
let r = if *kind == HerePlane { m.plane(v) } else { m.picks(v, None) };
|
||||||
|
m.here = false;
|
||||||
|
m.f.sketch = keep;
|
||||||
|
r
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Some(n) = new {
|
if let Some(n) = new {
|
||||||
out.insert((*k).to_string(), n);
|
out.insert((*k).to_string(), n);
|
||||||
|
|||||||
@@ -144,3 +144,106 @@ fn sketch_on_a_face_follows_a_moved_body() {
|
|||||||
let c = sketch_point_world(&mut s, "OnTop", "p1");
|
let c = sketch_point_world(&mut s, "OnTop", "p1");
|
||||||
assert!((c.z - 25.0).abs() < 1e-6, "{c:?}");
|
assert!((c.z - 25.0).abs() < 1e-6, "{c:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A 1 x 1 PNG for canvases.
|
||||||
|
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
/// Canvases belong to the active component and move with its occurrence; one put on a moved
|
||||||
|
/// component's face from the root sits on the face (and stays the root's).
|
||||||
|
#[test]
|
||||||
|
fn canvases_move_with_their_component() {
|
||||||
|
let mut s = moved_part();
|
||||||
|
let part = s.active_component;
|
||||||
|
assert_ne!(part, 0);
|
||||||
|
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XY", "name": "Plan"}));
|
||||||
|
// From the root, on the moved block's top face (world).
|
||||||
|
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||||
|
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": {"face": [65, 10, 10]}, "name": "Lid", "width": 10}));
|
||||||
|
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XZ", "name": "Root"}));
|
||||||
|
let (vals, _) = s.doc.param_values();
|
||||||
|
let world = |s: &Session, name: &str| {
|
||||||
|
let c = s.doc.canvases.iter().find(|c| c.name == name).unwrap();
|
||||||
|
(c.component, s.doc.canvas_plane(&vals, c).unwrap())
|
||||||
|
};
|
||||||
|
let (c, p) = world(&s, "Plan");
|
||||||
|
assert_eq!(c, part);
|
||||||
|
assert!(near(p.origin, Vec3::new(50.0, 0.0, 0.0)), "{:?}", p.origin);
|
||||||
|
let (c, p) = world(&s, "Lid");
|
||||||
|
assert_eq!(c, 0, "the active component's");
|
||||||
|
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
|
||||||
|
let centre = p.to_world(lid.center);
|
||||||
|
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
|
||||||
|
assert_eq!(world(&s, "Root").0, 0);
|
||||||
|
// Moving the occurrence carries its canvases, not the root's.
|
||||||
|
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 40, 0]}));
|
||||||
|
let (vals, _) = s.doc.param_values();
|
||||||
|
let plan = s.doc.canvases.iter().find(|c| c.name == "Plan").unwrap();
|
||||||
|
assert!(near(s.doc.canvas_plane(&vals, plan).unwrap().origin, Vec3::new(50.0, 40.0, 0.0)));
|
||||||
|
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
|
||||||
|
let centre = s.doc.canvas_plane(&vals, lid).unwrap().to_world(lid.center);
|
||||||
|
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
|
||||||
|
let root = s.doc.canvases.iter().find(|c| c.name == "Root").unwrap();
|
||||||
|
assert!(near(s.doc.canvas_plane(&vals, root).unwrap().origin, Vec3::ZERO));
|
||||||
|
// The component survives a save and reopen.
|
||||||
|
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||||
|
assert_eq!(back.canvases, s.doc.canvases);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// World points of a sketch's curves' points (sorted).
|
||||||
|
fn sketch_world_points(s: &mut Session, sketch: &str) -> Vec<Vec3> {
|
||||||
|
let si = run(s, "sketch.inspect", json!({ "sketch": sketch }));
|
||||||
|
si["points"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let w = &p["world"];
|
||||||
|
Vec3::new(w[0].as_f64().unwrap(), w[1].as_f64().unwrap(), w[2].as_f64().unwrap())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A block "C" (40 x 30 x 20) in its own component, the root active again.
|
||||||
|
fn block_in_component() -> Session {
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "component.create", json!({"name": "C"}));
|
||||||
|
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||||
|
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's "big square": a sketch made in the root on a moved component's face lies on the
|
||||||
|
/// face where it is shown, its projected loop on the face's edges (moved and turned), and it
|
||||||
|
/// follows when the occurrence moves on.
|
||||||
|
#[test]
|
||||||
|
fn root_sketch_on_a_moved_components_face() {
|
||||||
|
let mut s = block_in_component();
|
||||||
|
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [100, 0, 0]}));
|
||||||
|
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [120, 15, 20]}, "name": "Top"}));
|
||||||
|
assert!(!r["projected"].is_null(), "the face's edges are projected: {r}");
|
||||||
|
run(&mut s, "sketch.finish", json!({}));
|
||||||
|
let pts = sketch_world_points(&mut s, "Top");
|
||||||
|
assert!(pts.len() >= 4, "{pts:?}");
|
||||||
|
// Every point but the sketch's origin (the model origin on the face plane).
|
||||||
|
for p in pts.iter().filter(|p| !near(**p, Vec3::new(0.0, 0.0, 20.0))) {
|
||||||
|
assert!((p.z - 20.0).abs() < 1e-6 && p.x > 100.0 - 1e-6 && p.x < 140.0 + 1e-6 && p.y > -1e-6 && p.y < 30.0 + 1e-6, "{p:?}");
|
||||||
|
}
|
||||||
|
for corner in [Vec3::new(100.0, 0.0, 20.0), Vec3::new(140.0, 30.0, 20.0)] {
|
||||||
|
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(s.doc.find_feature("Top").unwrap().component, 0, "the sketch is the root's");
|
||||||
|
|
||||||
|
// Turned 90 degrees about Z (around the world origin): the top face is now x -30..0, y 0..40.
|
||||||
|
let mut s = block_in_component();
|
||||||
|
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]}));
|
||||||
|
run(&mut s, "sketch.create", json!({"plane": {"face": [-15, 20, 20]}, "name": "Top"}));
|
||||||
|
run(&mut s, "sketch.finish", json!({}));
|
||||||
|
let pts = sketch_world_points(&mut s, "Top");
|
||||||
|
for corner in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(-30.0, 40.0, 20.0)] {
|
||||||
|
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
|
||||||
|
}
|
||||||
|
// Projected geometry stays on the face when the occurrence moves on (the link re-resolves).
|
||||||
|
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [0, 0, 5]}));
|
||||||
|
let pts = sketch_world_points(&mut s, "Top");
|
||||||
|
assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}");
|
||||||
|
}
|
||||||
@@ -170,6 +170,20 @@ pub struct Session {
|
|||||||
pub analysis: Option<SurfaceAnalysis>,
|
pub analysis: Option<SurfaceAnalysis>,
|
||||||
/// What is hidden or shown in the view (`browser.visibility`).
|
/// What is hidden or shown in the view (`browser.visibility`).
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
|
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
|
||||||
|
/// commands are undone however long the undo history is.
|
||||||
|
pub(crate) checkpoints: Vec<Checkpoint>,
|
||||||
|
next_checkpoint: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The design, active sketch and undo history at an `edit.checkpoint`.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct Checkpoint {
|
||||||
|
pub id: u64,
|
||||||
|
pub doc: Arc<Document>,
|
||||||
|
pub active_sketch: Option<u64>,
|
||||||
|
pub undo: Vec<Snapshot>,
|
||||||
|
pub redo: Vec<Snapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Items hidden or shown one by one (view state, not part of the design and not an undo step).
|
/// Items hidden or shown one by one (view state, not part of the design and not an undo step).
|
||||||
@@ -204,6 +218,8 @@ pub enum SurfaceAnalysis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_UNDO: usize = 200;
|
const MAX_UNDO: usize = 200;
|
||||||
|
/// Checkpoints kept (the oldest go first).
|
||||||
|
const MAX_CHECKPOINTS: usize = 16;
|
||||||
const MAX_LOG: usize = 2000;
|
const MAX_LOG: usize = 2000;
|
||||||
|
|
||||||
impl Default for Session {
|
impl Default for Session {
|
||||||
@@ -235,6 +251,8 @@ impl Session {
|
|||||||
clipboard: Vec::new(),
|
clipboard: Vec::new(),
|
||||||
section: None,
|
section: None,
|
||||||
analysis: None,
|
analysis: None,
|
||||||
|
checkpoints: Vec::new(),
|
||||||
|
next_checkpoint: 1,
|
||||||
visibility: Visibility::default(),
|
visibility: Visibility::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,6 +374,44 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remember the design, active sketch and undo history; returns the checkpoint's id.
|
||||||
|
pub fn checkpoint(&mut self) -> u64 {
|
||||||
|
let id = self.next_checkpoint;
|
||||||
|
self.next_checkpoint += 1;
|
||||||
|
self.checkpoints.push(Checkpoint {
|
||||||
|
id,
|
||||||
|
doc: self.doc.clone(),
|
||||||
|
active_sketch: self.active_sketch,
|
||||||
|
undo: self.undo.clone(),
|
||||||
|
redo: self.redo.clone(),
|
||||||
|
});
|
||||||
|
if self.checkpoints.len() > MAX_CHECKPOINTS {
|
||||||
|
self.checkpoints.remove(0);
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Go back to checkpoint `id` as if nothing since had happened (the undo history too). It
|
||||||
|
/// and later checkpoints are used up.
|
||||||
|
pub fn restore_checkpoint(&mut self, id: u64) -> Result<()> {
|
||||||
|
let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(|| EngineError::Other(format!("no checkpoint {id}")))?;
|
||||||
|
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
|
||||||
|
self.doc = c.doc;
|
||||||
|
self.active_sketch = c.active_sketch;
|
||||||
|
self.undo = c.undo;
|
||||||
|
self.redo = c.redo;
|
||||||
|
self.selection.clear();
|
||||||
|
self.refresh();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget checkpoint `id` (and later ones) without going back.
|
||||||
|
pub fn drop_checkpoint(&mut self, id: u64) {
|
||||||
|
if let Some(i) = self.checkpoints.iter().position(|c| c.id == id) {
|
||||||
|
self.checkpoints.truncate(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A scratch copy of the session (same design and model, no history or log) to try
|
/// A scratch copy of the session (same design and model, no history or log) to try
|
||||||
/// commands on without touching this one.
|
/// commands on without touching this one.
|
||||||
pub fn scratch(&self) -> Session {
|
pub fn scratch(&self) -> Session {
|
||||||
@@ -377,6 +433,8 @@ impl Session {
|
|||||||
clipboard: self.clipboard.clone(),
|
clipboard: self.clipboard.clone(),
|
||||||
section: None,
|
section: None,
|
||||||
analysis: None,
|
analysis: None,
|
||||||
|
checkpoints: Vec::new(),
|
||||||
|
next_checkpoint: 1,
|
||||||
visibility: Visibility::default(),
|
visibility: Visibility::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,6 +503,8 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod boolean_regression_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod construct_tests;
|
mod construct_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ fn rel(a: f64, b: f64) -> f64 {
|
|||||||
(a - b).abs() / b.abs().max(1e-12)
|
(a - b).abs() / b.abs().max(1e-12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_extrude_from_a_chamfered_top_face() {
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||||
|
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
|
||||||
|
run(&mut s, "solid.extrude", json!({"distance": 20}));
|
||||||
|
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
|
||||||
|
let base = volume(&mut s);
|
||||||
|
run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}));
|
||||||
|
run(&mut s, "sketch.finish", json!({}));
|
||||||
|
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||||
|
assert_eq!(s.model.state().bodies.len(), 1);
|
||||||
|
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4);
|
||||||
|
}
|
||||||
|
|
||||||
mod component_frames;
|
mod component_frames;
|
||||||
mod robustness;
|
mod robustness;
|
||||||
|
|
||||||
@@ -650,10 +665,10 @@ fn section_analysis() {
|
|||||||
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
|
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
|
||||||
run(&mut s, "inspect.section", json!({"clear": true}));
|
run(&mut s, "inspect.section", json!({"clear": true}));
|
||||||
assert!(s.section.is_none());
|
assert!(s.section.is_none());
|
||||||
// XZ's normal is -Y: an offset moves the cut to -Y, `at` places it at a world Y, and the
|
// XZ's normal is +Y: an offset moves the cut to +Y, `at` places it at a world Y, and the
|
||||||
// result says where the cut landed either way.
|
// result says where the cut landed either way.
|
||||||
let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10}));
|
let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10}));
|
||||||
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(-10.0)), "{r}");
|
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(10.0)), "{r}");
|
||||||
for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] {
|
for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] {
|
||||||
let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip}));
|
let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip}));
|
||||||
assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}");
|
assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}");
|
||||||
@@ -824,6 +839,51 @@ fn pipe_scale_offset_bounding_and_materials() {
|
|||||||
assert!(s.execute("material.assign", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err());
|
assert!(s.execute("material.assign", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: deleting a pipe's path sketch left the pipe in the timeline ("unknown sketch").
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_path_sketch_deletes_its_pipe() {
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
|
||||||
|
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
|
||||||
|
run(&mut s, "sketch.finish", json!({}));
|
||||||
|
let pipe = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": ["l1"], "diameter": 4}));
|
||||||
|
let deps = run(&mut s, "timeline.dependents", json!({"feature": "Path"}));
|
||||||
|
assert_eq!(deps["deleted_with_it"].as_array().map(Vec::len), Some(1), "{deps} (pipe {pipe})");
|
||||||
|
let r = run(&mut s, "timeline.delete", json!({"features": ["Path"]}));
|
||||||
|
assert_eq!(r["deleted"].as_array().map(Vec::len), Some(2), "{r}");
|
||||||
|
assert!(s.doc.features.is_empty(), "{:?}", s.doc.features.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||||
|
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: batches were rolled back by undoing as many steps as the undo history grew,
|
||||||
|
/// which is none once the history is full, leaving their sketches behind.
|
||||||
|
#[test]
|
||||||
|
fn checkpoint_restores_with_a_full_undo_history() {
|
||||||
|
let mut s = Session::default();
|
||||||
|
for i in 0..MAX_UNDO + 5 {
|
||||||
|
run(&mut s, "parameters.add", json!({"name": format!("p{i}"), "expression": "1 mm"}));
|
||||||
|
}
|
||||||
|
assert_eq!(s.undo.len(), MAX_UNDO);
|
||||||
|
let labels: Vec<String> = s.undo.iter().map(|u| u.label.clone()).collect();
|
||||||
|
let doc = s.doc.clone();
|
||||||
|
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
|
||||||
|
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TapHole"}));
|
||||||
|
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2}));
|
||||||
|
assert!(s.active_sketch.is_some());
|
||||||
|
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id}));
|
||||||
|
assert!(Arc::ptr_eq(&s.doc, &doc) && s.active_sketch.is_none());
|
||||||
|
assert!(s.doc.find_feature("TapHole").is_none());
|
||||||
|
assert_eq!(s.undo.iter().map(|u| u.label.clone()).collect::<Vec<_>>(), labels);
|
||||||
|
// Used up: a second restore is an error, not a jump somewhere else.
|
||||||
|
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
|
||||||
|
// Forgetting one leaves the design alone.
|
||||||
|
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
|
||||||
|
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Kept"}));
|
||||||
|
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
|
||||||
|
assert!(s.doc.find_feature("Kept").is_some());
|
||||||
|
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn components_occurrences_and_world_placement() {
|
fn components_occurrences_and_world_placement() {
|
||||||
let mut s = Session::default();
|
let mut s = Session::default();
|
||||||
|
|||||||
@@ -82,6 +82,71 @@ impl Mesh {
|
|||||||
pub fn face_edges(&self, f: u32) -> Vec<usize> {
|
pub fn face_edges(&self, f: u32) -> Vec<usize> {
|
||||||
self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect()
|
self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect()
|
||||||
}
|
}
|
||||||
|
/// Pieces of one surface joined across hidden seams, including `face` itself.
|
||||||
|
pub fn surface_patch(&self, face: usize) -> Vec<usize> {
|
||||||
|
let mut patch = vec![face];
|
||||||
|
let mut todo = vec![face];
|
||||||
|
while let Some(f) = todo.pop() {
|
||||||
|
for (ei, fs) in self.edge_faces.iter().enumerate() {
|
||||||
|
if !self.seams.get(ei).copied().unwrap_or(false) || !fs.iter().any(|x| *x as usize == f) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for &next in fs {
|
||||||
|
let next = next as usize;
|
||||||
|
if !patch.contains(&next) {
|
||||||
|
patch.push(next);
|
||||||
|
todo.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.sort_unstable();
|
||||||
|
patch
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete circular rim may be split into several kernel edges. Keep its pieces
|
||||||
|
/// together for picking, without joining other tangent curves or changing edge indices.
|
||||||
|
pub fn circular_rim(&self, edge: usize) -> Vec<usize> {
|
||||||
|
let single = vec![edge];
|
||||||
|
let Some(p) = self.edges.get(edge).filter(|p| p.len() >= 3) else { return single };
|
||||||
|
let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(p.len() / 2), p.last()) else { return single };
|
||||||
|
let (u, v) = (b - a, c - a);
|
||||||
|
let n = u.cross(v);
|
||||||
|
if !(n.len2() > u.len2() * v.len2() * 1e-12) {
|
||||||
|
return single;
|
||||||
|
}
|
||||||
|
let center = a + (v.cross(n) * u.len2() + n.cross(u) * v.len2()) / (2.0 * n.len2());
|
||||||
|
let radius = center.dist(a);
|
||||||
|
let normal = n.normalized().unwrap_or(Vec3::ZERO);
|
||||||
|
let tol = (radius * 1e-6).max(1e-9);
|
||||||
|
let mut rim = self.tangent_chain(edge, 2f64.to_radians());
|
||||||
|
if rim.len() < 2 || !radius.is_finite() {
|
||||||
|
return single;
|
||||||
|
}
|
||||||
|
// All samples must lie on this circle, and every end must join exactly one other
|
||||||
|
// piece. Open arcs, ellipses, and branching tangent chains stay separate.
|
||||||
|
for &i in &rim {
|
||||||
|
let Some(points) = self.edges.get(i) else { return single };
|
||||||
|
if points.iter().any(|q| !q.is_finite() || (q.dist(center) - radius).abs() > tol || (*q - center).dot(normal).abs() > tol) {
|
||||||
|
return single;
|
||||||
|
}
|
||||||
|
for end in [points.first(), points.last()].into_iter().flatten() {
|
||||||
|
let joins = rim
|
||||||
|
.iter()
|
||||||
|
.filter(|&&j| j != i)
|
||||||
|
.filter_map(|&j| self.edges.get(j))
|
||||||
|
.flat_map(|q| [q.first(), q.last()])
|
||||||
|
.flatten()
|
||||||
|
.filter(|q| q.dist(*end) < tol)
|
||||||
|
.count();
|
||||||
|
if joins != 1 {
|
||||||
|
return single;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rim.sort_unstable();
|
||||||
|
rim
|
||||||
|
}
|
||||||
/// The edges reached from `e` through vertices where the next edge continues smoothly
|
/// The edges reached from `e` through vertices where the next edge continues smoothly
|
||||||
/// (angle between the end tangents below `max_angle`), including `e` itself.
|
/// (angle between the end tangents below `max_angle`), including `e` itself.
|
||||||
pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec<usize> {
|
pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec<usize> {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ pub struct Plane {
|
|||||||
impl Plane {
|
impl Plane {
|
||||||
/// Top: sketch x = +X, y = +Y, normal +Z.
|
/// Top: sketch x = +X, y = +Y, normal +Z.
|
||||||
pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
|
pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
|
||||||
/// Front: sketch x = +X, y = +Z, normal −Y.
|
/// Front: sketch x = +X, y = −Z, normal +Y.
|
||||||
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
|
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::new(0.0, 0.0, -1.0) };
|
||||||
/// Right: sketch x = +Y, y = +Z, normal +X.
|
/// Right: sketch x = +Y, y = +Z, normal +X.
|
||||||
pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z };
|
pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z };
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ fn raycast_hits_front_face() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn planes() {
|
fn planes() {
|
||||||
assert_eq!(Plane::XY.normal(), Vec3::Z);
|
assert_eq!(Plane::XY.normal(), Vec3::Z);
|
||||||
assert_eq!(Plane::XZ.normal(), Vec3::new(0.0, -1.0, 0.0));
|
assert_eq!(Plane::XZ.normal(), Vec3::Y);
|
||||||
assert_eq!(Plane::YZ.normal(), Vec3::X);
|
assert_eq!(Plane::YZ.normal(), Vec3::X);
|
||||||
let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0));
|
let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0));
|
||||||
assert_eq!(p, Vec3::new(3.0, 0.0, 4.0));
|
assert_eq!(p, Vec3::new(3.0, 0.0, -4.0));
|
||||||
assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0));
|
assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0));
|
||||||
let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap();
|
let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap();
|
||||||
assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12);
|
assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12);
|
||||||
@@ -84,6 +84,46 @@ fn tangent_chains_follow_smooth_edges() {
|
|||||||
assert_eq!(m.face_edges(1), vec![1, 2]);
|
assert_eq!(m.face_edges(1), vec![1, 2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn circular_rims_group_only_closed_cocircular_edges() {
|
||||||
|
let arc = |start: f64, sweep: f64, yscale: f64| -> Vec<Vec3> {
|
||||||
|
(0..=32)
|
||||||
|
.map(|i| {
|
||||||
|
let a = start + sweep * i as f64 / 32.0;
|
||||||
|
Vec3::new(10.0 * a.cos(), yscale * 10.0 * a.sin(), 5.0)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let pi = std::f64::consts::PI;
|
||||||
|
let mut m = Mesh {
|
||||||
|
positions: vec![Vec3::new(-10.0, -10.0, 5.0), Vec3::new(10.0, 10.0, 5.0)],
|
||||||
|
edges: vec![arc(0.0, pi, 1.0), arc(2.0 * pi, -pi, 1.0)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(m.circular_rim(0), vec![0, 1]);
|
||||||
|
assert_eq!(m.circular_rim(1), vec![0, 1]);
|
||||||
|
// A hidden seam cannot form a selectable rim.
|
||||||
|
m.seams = vec![false, true];
|
||||||
|
assert_eq!(m.circular_rim(0), vec![0]);
|
||||||
|
m.seams.clear();
|
||||||
|
m.edges[1] = arc(pi, pi / 2.0, 1.0);
|
||||||
|
assert_eq!(m.circular_rim(0), vec![0]);
|
||||||
|
// A closed, smooth ellipse is not a circle.
|
||||||
|
m.edges = vec![arc(0.0, pi, 0.7), arc(pi, pi, 0.7)];
|
||||||
|
assert_eq!(m.circular_rim(0), vec![0]);
|
||||||
|
assert_eq!(m.circular_rim(99), vec![99]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn surface_patches_cross_only_hidden_seams() {
|
||||||
|
let mut m = Mesh { edge_faces: vec![vec![0, 1], vec![1, 2], vec![2, 3]], seams: vec![true, true, false], ..Default::default() };
|
||||||
|
assert_eq!(m.surface_patch(0), vec![0, 1, 2]);
|
||||||
|
assert_eq!(m.surface_patch(2), vec![0, 1, 2]);
|
||||||
|
assert_eq!(m.surface_patch(3), vec![3]);
|
||||||
|
m.seams.clear();
|
||||||
|
assert_eq!(m.surface_patch(0), vec![0]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cubic_and_conic_segments_measure_exactly() {
|
fn cubic_and_conic_segments_measure_exactly() {
|
||||||
use crate::{Loop2, Seg2, Vec2};
|
use crate::{Loop2, Seg2, Vec2};
|
||||||
|
|||||||
@@ -795,13 +795,68 @@ pub enum PathSeg {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PathSeg {
|
||||||
|
/// Unit directions of the path where the segment starts and ends.
|
||||||
|
fn tangents(&self) -> Option<(Vec3, Vec3)> {
|
||||||
|
match *self {
|
||||||
|
PathSeg::Line { a, b } => {
|
||||||
|
let t = (b - a).normalized()?;
|
||||||
|
Some((t, t))
|
||||||
|
}
|
||||||
|
PathSeg::Arc { a, center, axis, angle } => {
|
||||||
|
let k = axis.normalized()?;
|
||||||
|
let end = rotate_about(a, center, k, angle);
|
||||||
|
Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points along the segment (an arc within `tol` of its chords), without its start.
|
||||||
|
fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
|
||||||
|
match *self {
|
||||||
|
PathSeg::Line { b, .. } => vec![b],
|
||||||
|
PathSeg::Arc { a, center, axis, angle } => {
|
||||||
|
let Some(k) = axis.normalized() else { return vec![a] };
|
||||||
|
let r = (a - center).len();
|
||||||
|
let step = if r > tol { 2.0 * (1.0 - tol / r).acos() } else { angle.abs() };
|
||||||
|
let n = ((angle.abs() / step.max(1e-6)).ceil() as usize).clamp(1, 512);
|
||||||
|
(1..=n).map(|i| rotate_about(a, center, k, angle * i as f64 / n as f64)).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `p` turned by `angle` about the unit axis `k` through `center` (right hand).
|
||||||
|
fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
|
||||||
|
let v = p - center;
|
||||||
|
let (s, c) = angle.sin_cos();
|
||||||
|
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
|
||||||
|
}
|
||||||
|
|
||||||
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
|
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
|
||||||
/// perpendicular to the path (lines translate it, arcs rotate it).
|
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
|
||||||
|
/// is swept as a polyline, mitred at the corners.
|
||||||
pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body> {
|
pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body> {
|
||||||
region_ok(region)?;
|
region_ok(region)?;
|
||||||
if path.is_empty() || path.len() > 1000 {
|
if path.is_empty() || path.len() > 1000 {
|
||||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||||
}
|
}
|
||||||
|
// Segment by segment the profile only follows a path whose direction runs on across each
|
||||||
|
// joint: at a corner the next line would slide the profile within its own plane.
|
||||||
|
let ends: Vec<(Vec3, Vec3)> =
|
||||||
|
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||||
|
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
|
||||||
|
if corner {
|
||||||
|
let first = match path.first() {
|
||||||
|
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||||
|
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||||
|
};
|
||||||
|
let mut pts = vec![first];
|
||||||
|
for seg in path {
|
||||||
|
pts.extend(seg.points_after_start(1e-2));
|
||||||
|
}
|
||||||
|
return crate::sweep_path::sweep_path(plane, region, &pts);
|
||||||
|
}
|
||||||
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
||||||
let mut starts = vec![wire(plane, ®ion.outer.ccw())?];
|
let mut starts = vec![wire(plane, ®ion.outer.ccw())?];
|
||||||
for h in ®ion.holes {
|
for h in ®ion.holes {
|
||||||
|
|||||||
@@ -404,6 +404,12 @@ pub fn heal(solid: Solid, size: f64) -> Solid {
|
|||||||
|
|
||||||
/// [`heal`], keeping faces apart along lines they were split along on purpose.
|
/// [`heal`], keeping faces apart along lines they were split along on purpose.
|
||||||
pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
|
pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
|
||||||
|
// Edges a boolean made are intersection curves, which panic where they no longer
|
||||||
|
// evaluate; the solid then stays as it is.
|
||||||
|
crate::guard("heal", || Ok(heal_shells(&solid, size, keep))).ok().flatten().unwrap_or(solid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>]) -> Option<Solid> {
|
||||||
let tol = (size * 1e-7).max(1e-9);
|
let tol = (size * 1e-7).max(1e-9);
|
||||||
let mut shells = Vec::new();
|
let mut shells = Vec::new();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
@@ -428,9 +434,9 @@ pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
|
|||||||
shells.push(cur);
|
shells.push(cur);
|
||||||
}
|
}
|
||||||
if !changed {
|
if !changed {
|
||||||
return solid;
|
return None;
|
||||||
}
|
}
|
||||||
Solid::try_new(shells).unwrap_or(solid)
|
Solid::try_new(shells).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ fn a_drill_point_round_trips_through_iges() {
|
|||||||
let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap();
|
let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap();
|
||||||
let profile =
|
let profile =
|
||||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] };
|
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] };
|
||||||
let hole = crate::revolve(&Plane::XZ, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
|
let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // Positive profile heights are world +Z.
|
||||||
|
let hole = crate::revolve(&plane, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
|
||||||
let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap();
|
let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap();
|
||||||
let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0);
|
let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0);
|
||||||
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
|
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ pub use lip::raise_band;
|
|||||||
pub use measure::{BodyMeasure, measure};
|
pub use measure::{BodyMeasure, measure};
|
||||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||||
pub use offset::with_same_surface;
|
pub use offset::with_same_surface;
|
||||||
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
|
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
|
||||||
pub use polybool::{faceted_boolean, planar_boolean};
|
pub use polybool::{faceted_boolean, planar_boolean};
|
||||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
|
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
|
||||||
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
|
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
|
||||||
|
|||||||
+41
-11
@@ -15,6 +15,12 @@ pub enum BoolOp {
|
|||||||
Intersect,
|
Intersect,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a union can sew two matching planar faces without intersecting them.
|
||||||
|
/// Callers can retain the exact touching tool instead of extending it into material.
|
||||||
|
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
|
||||||
|
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
|
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
|
||||||
/// ray whose direction is derived from point coordinates; moving both solids (and the result
|
/// ray whose direction is derived from point coordinates; moving both solids (and the result
|
||||||
/// back) leaves the geometry unchanged but changes those rays.
|
/// back) leaves the geometry unchanged but changes those rays.
|
||||||
@@ -46,7 +52,10 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve
|
|||||||
let hi = ba.max.min(bb.max);
|
let hi = ba.max.min(bb.max);
|
||||||
let all = ba.union(&bb);
|
let all = ba.union(&bb);
|
||||||
let mut boxes = vec![(all, 300usize)];
|
let mut boxes = vec![(all, 300usize)];
|
||||||
if lo.x < hi.x && lo.y < hi.y && lo.z < hi.z {
|
// A touching seam can overlap by round-off only. Samples there sit on the
|
||||||
|
// operands' boundaries, where ray parity cannot classify membership reliably.
|
||||||
|
let gap = all.diagonal().max(1.0) * 1e-9;
|
||||||
|
if hi.x - lo.x > gap && hi.y - lo.y > gap && hi.z - lo.z > gap {
|
||||||
boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900));
|
boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900));
|
||||||
}
|
}
|
||||||
let (ia_idx, ib_idx, ir_idx) = (a.inside_index(), b.inside_index(), result.inside_index());
|
let (ia_idx, ib_idx, ir_idx) = (a.inside_index(), b.inside_index(), result.inside_index());
|
||||||
@@ -180,11 +189,28 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
|||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't).
|
||||||
|
fn meeting_size(a: &Body, b: &Body) -> f64 {
|
||||||
|
let bbox = |x: &Body| {
|
||||||
|
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||||||
|
for v in x.solid.vertex_iter() {
|
||||||
|
bb.add(from_p3(v.point()));
|
||||||
|
}
|
||||||
|
bb
|
||||||
|
};
|
||||||
|
let (ba, bb) = (bbox(a), bbox(b));
|
||||||
|
let s = solvecraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
|
||||||
|
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
|
||||||
|
}
|
||||||
|
|
||||||
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||||
a.require_brep("a boolean")?;
|
a.require_brep("a boolean")?;
|
||||||
b.require_brep("a boolean")?;
|
b.require_brep("a boolean")?;
|
||||||
let size = a.size().max(b.size());
|
let size = a.size().max(b.size());
|
||||||
let tol_m = size * 5e-4;
|
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
|
||||||
|
// thin part trimmed by a long one), a step from their size would facet the small faces
|
||||||
|
// there coarsely enough to fail the membership test on a correct result.
|
||||||
|
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
|
||||||
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
|
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
|
||||||
let (va, vb) = match &meshes {
|
let (va, vb) = match &meshes {
|
||||||
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
|
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
|
||||||
@@ -287,7 +313,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
|||||||
empty_votes += 1;
|
empty_votes += 1;
|
||||||
last = "empty result".into();
|
last = "empty result".into();
|
||||||
}
|
}
|
||||||
Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) {
|
Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) {
|
||||||
Ok(body) => {
|
Ok(body) => {
|
||||||
let (ok, v) = check(&body);
|
let (ok, v) = check(&body);
|
||||||
if ok {
|
if ok {
|
||||||
@@ -489,6 +515,15 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
|
|||||||
/// Split a body with a plane: the parts on the positive and the negative side of the plane
|
/// Split a body with a plane: the parts on the positive and the negative side of the plane
|
||||||
/// (only the non-empty ones).
|
/// (only the non-empty ones).
|
||||||
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
|
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for positive in [true, false] {
|
||||||
|
out.extend(half_space(body, plane, positive)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The part of a body on one side of a plane (`None` when nothing is there).
|
||||||
|
pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result<Option<Body>> {
|
||||||
body.require_brep("split")?;
|
body.require_brep("split")?;
|
||||||
let size = body.size();
|
let size = body.size();
|
||||||
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||||||
@@ -506,12 +541,7 @@ pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec
|
|||||||
]),
|
]),
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
};
|
};
|
||||||
let mut out = Vec::new();
|
let (lo, hi) = if positive { (0.0, reach) } else { (-reach, 0.0) };
|
||||||
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
|
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
|
||||||
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
|
boolean(body, &half, BoolOp::Intersect)
|
||||||
if let Some(part) = boolean(body, &half, BoolOp::Intersect)? {
|
|
||||||
out.push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
}
|
||||||
@@ -306,12 +306,118 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
|||||||
a.require_brep("a boolean")?;
|
a.require_brep("a boolean")?;
|
||||||
b.require_brep("a boolean")?;
|
b.require_brep("a boolean")?;
|
||||||
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
|
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
|
||||||
|
if let Some(r) = sliced_planar_boolean(a, b, op) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
|
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
|
||||||
};
|
};
|
||||||
let size = a.size().max(b.size());
|
let size = a.size().max(b.size());
|
||||||
polys_boolean(pa, pb, size, op, size * 1e-9)
|
polys_boolean(pa, pb, size, op, size * 1e-9)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between
|
||||||
|
/// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round
|
||||||
|
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
|
||||||
|
/// pieces outside are joined back on. `None` when no such slab exists.
|
||||||
|
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||||
|
let pb = planar_polys(b)?;
|
||||||
|
let size = a.size().max(b.size());
|
||||||
|
let tol = (a.size() * 1e-3).max(1e-3);
|
||||||
|
let infos = a.faces(tol).ok()?;
|
||||||
|
let mesh = a.tessellate(tol).ok()?;
|
||||||
|
let mut curved: HashMap<u32, solvecraft_geom::Aabb3> = HashMap::new();
|
||||||
|
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||||
|
if infos.get(*fi as usize)?.plane_normal.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bx = curved.entry(*fi).or_insert(solvecraft_geom::Aabb3::EMPTY);
|
||||||
|
for p in mesh.tri(t)? {
|
||||||
|
bx.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut tool = solvecraft_geom::Aabb3::EMPTY;
|
||||||
|
for p in pb.iter().flat_map(|p| &p.v) {
|
||||||
|
tool.add(*p);
|
||||||
|
}
|
||||||
|
let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0);
|
||||||
|
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
|
||||||
|
// Planar faces of `a` square to an axis: a cut must not land in one of their planes.
|
||||||
|
let flat: Vec<(Vec3, Vec3)> = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect();
|
||||||
|
let gap = size * 1e-4;
|
||||||
|
let place = |k: usize, near: f64, far: f64| -> Option<f64> {
|
||||||
|
let axis = *axes.get(k)?;
|
||||||
|
[0.5, 0.37, 0.63, 0.25, 0.75]
|
||||||
|
.iter()
|
||||||
|
.map(|t| near + (far - near) * t)
|
||||||
|
.find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap))
|
||||||
|
};
|
||||||
|
// An axis along which every curved face lies clear of the tool: the cuts either side.
|
||||||
|
let (k, lo, hi) = (0..3).find_map(|k| {
|
||||||
|
let (t0, t1) = (at(tool.min, k), at(tool.max, k));
|
||||||
|
let (mut below, mut above) = (None::<f64>, None::<f64>);
|
||||||
|
for bx in curved.values() {
|
||||||
|
let (c0, c1) = (at(bx.min, k), at(bx.max, k));
|
||||||
|
if c1 < t0 - gap {
|
||||||
|
below = Some(below.map_or(c1, |x| x.max(c1)));
|
||||||
|
} else if c0 > t1 + gap {
|
||||||
|
above = Some(above.map_or(c0, |x| x.min(c0)));
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lo = match below {
|
||||||
|
Some(c) => Some(place(k, t0, c)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let hi = match above {
|
||||||
|
Some(c) => Some(place(k, t1, c)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Some((k, lo, hi))
|
||||||
|
})?;
|
||||||
|
let (u, w) = match k {
|
||||||
|
0 => (Vec3::Y, Vec3::Z),
|
||||||
|
1 => (Vec3::Z, Vec3::X),
|
||||||
|
_ => (Vec3::X, Vec3::Y),
|
||||||
|
};
|
||||||
|
let axis = *axes.get(k)?;
|
||||||
|
let run = || -> Result<Option<Body>> {
|
||||||
|
let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}"));
|
||||||
|
let mut middle = a.clone();
|
||||||
|
let mut outside = Vec::new();
|
||||||
|
for (cut, keep_positive) in [(lo, true), (hi, false)] {
|
||||||
|
let Some(x) = cut else { continue };
|
||||||
|
let plane = solvecraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?;
|
||||||
|
outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?);
|
||||||
|
middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?;
|
||||||
|
}
|
||||||
|
let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?;
|
||||||
|
let r = polys_boolean(pm, pb, size, op, size * 1e-9)?;
|
||||||
|
if op == BoolOp::Intersect {
|
||||||
|
return Ok(r);
|
||||||
|
}
|
||||||
|
// The rebuild leaves the cut faces' edges split where other polygons were cut; healed,
|
||||||
|
// they match the pieces outside edge for edge.
|
||||||
|
let mut pieces: Vec<Body> = match r {
|
||||||
|
Some(m) => vec![Body::new(crate::heal::heal_keep(m.deep_copy(), size, a.split_keep()))?],
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
// Each piece outside meets the middle along its cut face (glued one shell to another).
|
||||||
|
if pieces.iter().chain(&outside).any(|x| x.solid.boundaries().len() != 1) {
|
||||||
|
return Err(fail("a piece in several parts"));
|
||||||
|
}
|
||||||
|
for o in outside {
|
||||||
|
let joined = pieces.iter().enumerate().find_map(|(i, m)| Some((i, crate::coplanar::glue(m, &o)?.ok()?)));
|
||||||
|
let (i, j) = joined.ok_or_else(|| fail("a piece does not join back"))?;
|
||||||
|
if let Some(slot) = pieces.get_mut(i) {
|
||||||
|
*slot = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::ops::join_pieces(pieces)
|
||||||
|
};
|
||||||
|
Some(run())
|
||||||
|
}
|
||||||
|
|
||||||
fn polys_boolean(pa: Vec<Poly>, pb: Vec<Poly>, size: f64, op: BoolOp, eps: f64) -> Result<Option<Body>> {
|
fn polys_boolean(pa: Vec<Poly>, pb: Vec<Poly>, size: f64, op: BoolOp, eps: f64) -> Result<Option<Body>> {
|
||||||
let polys = csg(pa, pb, op, eps)?;
|
let polys = csg(pa, pb, op, eps)?;
|
||||||
if polys.is_empty() {
|
if polys.is_empty() {
|
||||||
|
|||||||
+128
-3
@@ -77,6 +77,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
|||||||
let mut hi = f64::NEG_INFINITY;
|
let mut hi = f64::NEG_INFINITY;
|
||||||
let mut tops: Vec<&mt::Face> = Vec::new();
|
let mut tops: Vec<&mt::Face> = Vec::new();
|
||||||
let mut top_h = Vec::new();
|
let mut top_h = Vec::new();
|
||||||
|
let mut bottom_h = Vec::new();
|
||||||
for f in b.solid.face_iter() {
|
for f in b.solid.face_iter() {
|
||||||
let surf = f.oriented_surface();
|
let surf = f.oriented_surface();
|
||||||
if let mt::Surface::Plane(p) = &surf {
|
if let mt::Surface::Plane(p) = &surf {
|
||||||
@@ -90,6 +91,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if n.dot(d) < -1.0 + 1e-9 {
|
if n.dot(d) < -1.0 + 1e-9 {
|
||||||
|
bottom_h.push(h);
|
||||||
lo = lo.min(h);
|
lo = lo.min(h);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -110,7 +112,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// One height for every top (and bottom) cap.
|
// One height for every top (and bottom) cap.
|
||||||
if top_h.iter().any(|h| (h - hi).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
|
if top_h.iter().any(|h| (h - hi).abs() > tol) || bottom_h.iter().any(|h| (h - lo).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut regions = Vec::new();
|
let mut regions = Vec::new();
|
||||||
@@ -382,6 +384,129 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The boolean done on prism sections, or `None` when it doesn't apply.
|
/// The boolean done on prism sections, or `None` when it doesn't apply.
|
||||||
|
/// The pieces of a segment between the given points (those lying on it, inside it).
|
||||||
|
fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
|
||||||
|
if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut ts: Vec<f64> = pts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| {
|
||||||
|
let t = seg.closest_param(*p);
|
||||||
|
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ts.sort_by(f64::total_cmp);
|
||||||
|
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut t0 = 0.0;
|
||||||
|
for t1 in ts.into_iter().chain(std::iter::once(1.0)) {
|
||||||
|
out.push(match *seg {
|
||||||
|
Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) },
|
||||||
|
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) },
|
||||||
|
_ => return None,
|
||||||
|
});
|
||||||
|
t0 = t1;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The union of two prisms along `d` stacked end to end (or overlapping) with different
|
||||||
|
/// sections, sewn from its faces: each slab between the prisms' end levels has walls over its
|
||||||
|
/// section's outline; each level has a face up over the section below less the one above, and
|
||||||
|
/// a face down over the one above less the one below. Every outline is split where any other
|
||||||
|
/// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a
|
||||||
|
/// line or an arc.
|
||||||
|
fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option<Result<Body>> {
|
||||||
|
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
|
||||||
|
let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi];
|
||||||
|
levels.sort_by(f64::total_cmp);
|
||||||
|
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
|
||||||
|
let covers = |p: &Prism, z0: f64, z1: f64| p.lo <= z0 + tol && p.hi >= z1 - tol;
|
||||||
|
let mut slabs: Vec<Vec<Region2>> = Vec::new();
|
||||||
|
for w in levels.windows(2) {
|
||||||
|
let (z0, z1) = (*w.first()?, *w.get(1)?);
|
||||||
|
let s = match (covers(pa, z0, z1), covers(pb, z0, z1)) {
|
||||||
|
(true, true) => boolean2d(&pa.regions, &pb.regions, BoolOp::Union, tol)?,
|
||||||
|
(true, false) => pa.regions.clone(),
|
||||||
|
(false, true) => pb.regions.clone(),
|
||||||
|
(false, false) => return None,
|
||||||
|
};
|
||||||
|
slabs.push(oriented(&s));
|
||||||
|
}
|
||||||
|
// Faces at each level: up where the slab below sticks out, down where the one above does.
|
||||||
|
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
|
||||||
|
match (x.is_empty(), y.is_empty()) {
|
||||||
|
(true, _) => Some(Vec::new()),
|
||||||
|
(false, true) => Some(x.to_vec()),
|
||||||
|
(false, false) => boolean2d(x, y, BoolOp::Cut, tol).map(|r| oriented(&r)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut caps: Vec<(f64, bool, Vec<Region2>)> = Vec::new();
|
||||||
|
for (k, z) in levels.iter().enumerate() {
|
||||||
|
let below: &[Region2] = if k == 0 { &[] } else { slabs.get(k - 1)? };
|
||||||
|
let above: &[Region2] = slabs.get(k).map(Vec::as_slice).unwrap_or(&[]);
|
||||||
|
caps.push((*z, true, diff(below, above)?));
|
||||||
|
caps.push((*z, false, diff(above, below)?));
|
||||||
|
}
|
||||||
|
// Every segment end, everywhere: outlines split there.
|
||||||
|
let loops_of =
|
||||||
|
|rs: &[Region2]| -> Vec<Loop2> { rs.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.iter().cloned())).collect() };
|
||||||
|
let mut pts: Vec<Vec2> = Vec::new();
|
||||||
|
for lp in slabs.iter().flat_map(|s| loops_of(s)).chain(caps.iter().flat_map(|c| loops_of(&c.2))) {
|
||||||
|
for sg in &lp.segs {
|
||||||
|
pts.push(sg.start());
|
||||||
|
pts.push(sg.end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let at = |u: Vec2, z: f64| plane.to_world(u) + d * z;
|
||||||
|
let edge = |sg: &Seg2, z: f64| -> EdgeSpec {
|
||||||
|
match sg {
|
||||||
|
Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) },
|
||||||
|
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut faces: Vec<FaceSpec> = Vec::new();
|
||||||
|
for (i, s) in slabs.iter().enumerate() {
|
||||||
|
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
|
||||||
|
for lp in loops_of(s) {
|
||||||
|
for sg0 in &lp.segs {
|
||||||
|
for sg in split_seg(sg0, &pts, tol)? {
|
||||||
|
let (a, b) = (sg.start(), sg.end());
|
||||||
|
let lp3 = vec![
|
||||||
|
edge(&sg, z0),
|
||||||
|
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
|
||||||
|
edge(&sg.reversed(), z1),
|
||||||
|
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
|
||||||
|
];
|
||||||
|
let tangent = plane.dir_to_world(sg.derivative(0.5));
|
||||||
|
let outward = tangent.cross(d).normalized()?;
|
||||||
|
let surface = match sg {
|
||||||
|
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
|
||||||
|
_ => SurfSpec::Plane,
|
||||||
|
};
|
||||||
|
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (z, up, rs) in &caps {
|
||||||
|
for r in rs {
|
||||||
|
let mut loops = Vec::new();
|
||||||
|
for lp in std::iter::once(&r.outer).chain(&r.holes) {
|
||||||
|
let mut segs: Vec<Seg2> = Vec::new();
|
||||||
|
for sg in &lp.segs {
|
||||||
|
segs.extend(split_seg(sg, &pts, tol)?);
|
||||||
|
}
|
||||||
|
let segs: Vec<Seg2> = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() };
|
||||||
|
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::sew::sew(&faces))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||||
let size = a.size().max(b.size());
|
let size = a.size().max(b.size());
|
||||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||||
@@ -423,10 +548,10 @@ pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
|
|||||||
return Some(Ok(None));
|
return Some(Ok(None));
|
||||||
}
|
}
|
||||||
let (lo, hi) = if stacked {
|
let (lo, hi) = if stacked {
|
||||||
// Only when the sections are the same (the union adds nothing to either).
|
// Different sections (a boss hanging over a plate's edge): sewn slab by slab.
|
||||||
let (u, x, y) = (area(®ions), area(&pa.regions), area(&pb.regions));
|
let (u, x, y) = (area(®ions), area(&pa.regions), area(&pb.regions));
|
||||||
if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) {
|
if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) {
|
||||||
return None;
|
return sew_stack(&plane, d, &pa, &pb, tol).map(|r| r.map(Some));
|
||||||
}
|
}
|
||||||
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
|
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1208,7 +1208,7 @@ mod tests {
|
|||||||
let m0 = measure(&c).unwrap();
|
let m0 = measure(&c).unwrap();
|
||||||
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
|
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
|
||||||
// A horizontal line seen from the front (XZ sketch) at z = 5, swept along y.
|
// A horizontal line seen from the front (XZ sketch) at z = 5, swept along y.
|
||||||
let xz = Plane::XZ;
|
let xz = Plane { y: Vec3::Z, ..Plane::XZ };
|
||||||
let tool = SplitTool::Curves { plane: xz, curves: vec![vec![Seg2::Line { a: Vec2::new(-20.0, 5.0), b: Vec2::new(20.0, 5.0) }]] };
|
let tool = SplitTool::Curves { plane: xz, curves: vec![vec![Seg2::Line { a: Vec2::new(-20.0, 5.0), b: Vec2::new(20.0, 5.0) }]] };
|
||||||
let s = split_faces(&c, &[side], &tool).unwrap();
|
let s = split_faces(&c, &[side], &tool).unwrap();
|
||||||
let m = measure(&s).unwrap();
|
let m = measure(&s).unwrap();
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ fn extrude_on_other_planes_and_offsets() {
|
|||||||
let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap();
|
let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap();
|
||||||
let m = measure(&b).unwrap();
|
let m = measure(&b).unwrap();
|
||||||
assert!(rel(m.volume, 250.0) < 1e-9);
|
assert!(rel(m.volume, 250.0) < 1e-9);
|
||||||
// XZ normal is -Y: the body spans y in [-3, 2].
|
// XZ normal is +Y: the body spans y in [-2, 3].
|
||||||
assert!((m.bbox.min.y + 3.0).abs() < 1e-6 && (m.bbox.max.y - 2.0).abs() < 1e-6, "{:?}", m.bbox);
|
assert!((m.bbox.min.y + 2.0).abs() < 1e-6 && (m.bbox.max.y - 3.0).abs() < 1e-6, "{:?}", m.bbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -52,6 +52,39 @@ fn region_with_hole() {
|
|||||||
assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume);
|
assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn through_cut_preserves_a_larger_blind_pocket() {
|
||||||
|
let plate = box_solid(Vec3::new(-30.0, -28.0, 0.0), Vec3::new(60.0, 22.0, 8.0)).unwrap();
|
||||||
|
let pocket = cylinder(Vec3::ZERO, Vec3::Z, 11.0, 6.0).unwrap();
|
||||||
|
let through = cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 9.5, 10.0).unwrap();
|
||||||
|
let expected = 90.0 * 50.0 * 8.0 - PI * 11.0_f64.powi(2) * 6.0 - PI * 9.5_f64.powi(2) * 2.0;
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let (first, second) = if reverse { (&through, &pocket) } else { (&pocket, &through) };
|
||||||
|
let cut = boolean(&plate, first, BoolOp::Cut).unwrap().unwrap();
|
||||||
|
let result = boolean(&cut, second, BoolOp::Cut).unwrap().unwrap();
|
||||||
|
let m = measure(&result).unwrap();
|
||||||
|
assert!(rel(m.volume, expected) < 5e-4, "reverse={reverse}: {} vs {expected}", m.volume);
|
||||||
|
assert_eq!(m.merged.faces, 9);
|
||||||
|
assert!(!result.tessellate(0.01).unwrap().contains(Vec3::new(10.0, 0.0, 3.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_a_cylinder_on_its_chamfered_top() {
|
||||||
|
for seam in [0.0, crate::build::SEAM_ANGLE] {
|
||||||
|
let region = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 10.0, seam), holes: vec![] };
|
||||||
|
let base = extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
|
||||||
|
let base = chamfer(&base, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap();
|
||||||
|
let region = Region2 { outer: Loop2::circle_from(Vec2::new(0.0, -3.774758283725534e-15), 8.0, seam), holes: vec![] };
|
||||||
|
let plane = Plane { origin: Vec3::new(0.0, 0.0, 20.000000000000007), ..Plane::XY };
|
||||||
|
let tool = extrude(&plane, &[region], 0.0, 5.0).unwrap().pop().unwrap();
|
||||||
|
let expected = measure(&base).unwrap().volume + PI * 64.0 * 5.0;
|
||||||
|
let joined = boolean(&base, &tool, BoolOp::Union).unwrap().unwrap();
|
||||||
|
assert!(rel(measure(&joined).unwrap().volume, expected) < 5e-4);
|
||||||
|
assert_eq!(joined.lumps().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn box_fillet_cut_volume() {
|
fn box_fillet_cut_volume() {
|
||||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||||
@@ -225,6 +258,38 @@ fn loft_and_sweep() {
|
|||||||
assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume);
|
assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_round_a_sharp_corner_is_mitred() {
|
||||||
|
// A 2 × 2 square centred on an L of two 10 mm lines: lines alone would slide the profile
|
||||||
|
// along the second leg within its own plane (an empty, inside-out body). Mitred, the
|
||||||
|
// volume is the area times the path length.
|
||||||
|
let sq =
|
||||||
|
Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, -1.0), Vec2::new(1.0, -1.0), Vec2::new(1.0, 1.0), Vec2::new(-1.0, 1.0)]), holes: vec![] };
|
||||||
|
let path = [
|
||||||
|
PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 10.0) },
|
||||||
|
PathSeg::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(10.0, 0.0, 10.0) },
|
||||||
|
];
|
||||||
|
let b = sweep(&Plane::XY, &sq, &path).unwrap();
|
||||||
|
let m = measure(&b).unwrap();
|
||||||
|
assert!(rel(m.volume, 80.0) < 1e-9, "{}", m.volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moving_a_body_with_an_intersection_curve_edge_ends() {
|
||||||
|
// A cylinder cut by a tilted box keeps an elliptical intersection curve as an edge. Moving
|
||||||
|
// such a body scales the curve's tolerance by the matrix norm, whose root refinement cycled
|
||||||
|
// forever for about one translation in eight (this one included): the app hung.
|
||||||
|
let cyl = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 40.0).unwrap();
|
||||||
|
let block = box_solid(Vec3::new(-20.0, -20.0, 20.0), Vec3::new(20.0, 20.0, 60.0)).unwrap();
|
||||||
|
let block = crate::ops::transform(&block, Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::X, 0.4).unwrap();
|
||||||
|
let cut = boolean(&cyl, &block, BoolOp::Cut).unwrap().unwrap();
|
||||||
|
assert!(cut.solid.edge_iter().any(|e| matches!(e.curve(), truck_modeling::Curve::IntersectionCurve(_))));
|
||||||
|
let t = Vec3::new(0.6911, -0.3337, -0.6127) * 2.334;
|
||||||
|
let moved = crate::ops::transform(&cut, t, Vec3::ZERO, Vec3::Z, 0.0).unwrap();
|
||||||
|
let (v0, v1) = (measure(&cut).unwrap().volume, measure(&moved).unwrap().volume);
|
||||||
|
assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn planar_booleans_with_coincident_faces() {
|
fn planar_booleans_with_coincident_faces() {
|
||||||
// L-bracket: a wall flush with three sides of its base.
|
// L-bracket: a wall flush with three sides of its base.
|
||||||
@@ -1344,7 +1409,8 @@ fn boolean_identity_survey() {
|
|||||||
fn partial_revolve_on_the_axis() {
|
fn partial_revolve_on_the_axis() {
|
||||||
let sq =
|
let sq =
|
||||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] };
|
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] };
|
||||||
let pie = revolve(&Plane::XZ, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
|
let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // This fixture's rectangle is above z = 0.
|
||||||
|
let pie = revolve(&plane, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
|
||||||
assert!(pie.validity().is_empty(), "{:?}", pie.validity());
|
assert!(pie.validity().is_empty(), "{:?}", pie.validity());
|
||||||
let v = measure(&pie).unwrap().volume;
|
let v = measure(&pie).unwrap().volume;
|
||||||
assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}");
|
assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}");
|
||||||
|
|||||||
@@ -213,6 +213,35 @@ fn batch_stops_at_first_error_and_rolls_back() {
|
|||||||
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2);
|
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: with a full undo history a failed batch reported `undone: 0` and left its
|
||||||
|
/// sketches behind (an empty "Sketch1", two sketches named "TapHole").
|
||||||
|
#[test]
|
||||||
|
fn batch_rolls_back_with_a_full_undo_history() {
|
||||||
|
let mut s = server();
|
||||||
|
let fill: Vec<Value> =
|
||||||
|
(0..230).map(|i| json!({"command": "parameters.add", "params": {"name": format!("p{i}"), "expression": "1 mm"}})).collect();
|
||||||
|
tool(&mut s, "batch", json!({"commands": fill}));
|
||||||
|
let undo = tool(&mut s, "inspect_design", json!({"measure": false}))["undo"].clone();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let r = raw_tool(
|
||||||
|
&mut s,
|
||||||
|
"batch",
|
||||||
|
json!({"commands": [
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XY", "name": "TapHole"}},
|
||||||
|
{"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}},
|
||||||
|
{"command": "sketch.dimension", "params": {"entities": ["nope"], "value": 3}},
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(body["failed"]["index"], 2, "{body}");
|
||||||
|
assert_eq!(body["undone"], 2, "{body}");
|
||||||
|
}
|
||||||
|
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||||||
|
assert_eq!(d["sketches"].as_array().map(Vec::len), Some(0), "{d}");
|
||||||
|
assert!(d["active_sketch"].is_null(), "{d}");
|
||||||
|
assert_eq!(d["undo"], undo);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn helper_sketches_can_be_hidden() {
|
fn helper_sketches_can_be_hidden() {
|
||||||
let mut s = server();
|
let mut s = server();
|
||||||
|
|||||||
+55
-12
@@ -129,7 +129,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||||||
"set_parameter",
|
"set_parameter",
|
||||||
"Set a parameter",
|
"Set a parameter",
|
||||||
"Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \
|
"Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \
|
||||||
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed and any features that now fail.",
|
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed (`recomputed`), the features whose earlier \
|
||||||
|
result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any features that now fail.",
|
||||||
obj(
|
obj(
|
||||||
json!({
|
json!({
|
||||||
"name": string("Parameter name"),
|
"name": string("Parameter name"),
|
||||||
@@ -360,7 +361,56 @@ fn undo_depth(b: &mut dyn Backend) -> Option<u64> {
|
|||||||
b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64())
|
b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `batch`: run steps in order, stop at the first failure and (by default) undo what ran.
|
/// How a failed batch goes back to where it started.
|
||||||
|
enum Undo {
|
||||||
|
/// An engine checkpoint: the design, active sketch and undo history exactly as they were.
|
||||||
|
Checkpoint(u64),
|
||||||
|
/// An app without checkpoints: undo as many steps as the batch added (wrong once the undo
|
||||||
|
/// history is full, as its depth no longer grows).
|
||||||
|
Depth(u64),
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Undo {
|
||||||
|
fn begin(b: &mut dyn Backend) -> Undo {
|
||||||
|
match exec(b, "edit.checkpoint", json!({})).ok().and_then(|v| v["checkpoint"].as_u64()) {
|
||||||
|
Some(id) => Undo::Checkpoint(id),
|
||||||
|
None => undo_depth(b).map_or(Undo::None, Undo::Depth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Go back; the number of steps undone.
|
||||||
|
fn rollback(self, b: &mut dyn Backend, steps: usize) -> u64 {
|
||||||
|
match self {
|
||||||
|
Undo::Checkpoint(id) => match exec(b, "edit.restore_checkpoint", json!({"checkpoint": id})) {
|
||||||
|
Ok(_) => steps as u64,
|
||||||
|
Err(_) => 0,
|
||||||
|
},
|
||||||
|
Undo::Depth(before) => {
|
||||||
|
let after = undo_depth(b).unwrap_or(before);
|
||||||
|
let mut undone = 0u64;
|
||||||
|
for _ in before..after {
|
||||||
|
if exec(b, "edit.undo", json!({})).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
undone += 1;
|
||||||
|
}
|
||||||
|
undone
|
||||||
|
}
|
||||||
|
Undo::None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The batch went through: forget the checkpoint.
|
||||||
|
fn finish(self, b: &mut dyn Backend) {
|
||||||
|
if let Undo::Checkpoint(id) = self {
|
||||||
|
let _ = exec(b, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `batch`: run steps in order, stop at the first failure and (by default) go back to the
|
||||||
|
/// design as it was before the batch.
|
||||||
fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||||
let Some(steps) = a.get("commands").and_then(Value::as_array) else {
|
let Some(steps) = a.get("commands").and_then(Value::as_array) else {
|
||||||
return text_result(&json!("`commands` must be an array of {command, params}"), true);
|
return text_result(&json!("`commands` must be an array of {command, params}"), true);
|
||||||
@@ -369,7 +419,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
|||||||
return text_result(&json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true);
|
return text_result(&json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true);
|
||||||
}
|
}
|
||||||
let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true);
|
let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true);
|
||||||
let before = if rollback { undo_depth(b) } else { None };
|
let start = if rollback { Undo::begin(b) } else { Undo::None };
|
||||||
let mut results = Vec::with_capacity(steps.len());
|
let mut results = Vec::with_capacity(steps.len());
|
||||||
for (i, step) in steps.iter().enumerate() {
|
for (i, step) in steps.iter().enumerate() {
|
||||||
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str);
|
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str);
|
||||||
@@ -380,15 +430,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
|||||||
match r {
|
match r {
|
||||||
Ok(v) => results.push(v),
|
Ok(v) => results.push(v),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let mut undone = 0u64;
|
let undone = start.rollback(b, i);
|
||||||
if let (Some(before), Some(after)) = (before, rollback.then(|| undo_depth(b)).flatten()) {
|
|
||||||
for _ in before..after {
|
|
||||||
if exec(b, "edit.undo", json!({})).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
undone += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return text_result(
|
return text_result(
|
||||||
&json!({
|
&json!({
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -403,6 +445,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
start.finish(b);
|
||||||
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
|
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod dims;
|
|||||||
mod linalg;
|
mod linalg;
|
||||||
mod link;
|
mod link;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod offset;
|
||||||
mod profiles;
|
mod profiles;
|
||||||
mod shape;
|
mod shape;
|
||||||
mod solver;
|
mod solver;
|
||||||
@@ -25,6 +26,7 @@ pub use curves::{
|
|||||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
|
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
|
||||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
||||||
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
|
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
|
||||||
|
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, measure_dimension, solve, solve_holding};
|
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ pub struct Link {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub kind: LinkKind,
|
pub kind: LinkKind,
|
||||||
pub source: LinkSource,
|
pub source: LinkSource,
|
||||||
|
/// Persistent model face identity, resolved by the document before geometry refresh.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub face_name: Option<String>,
|
||||||
/// The reference could not be found on the last recompute; the geometry is the last good one.
|
/// The reference could not be found on the last recompute; the geometry is the last good one.
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub lost: bool,
|
pub lost: bool,
|
||||||
@@ -169,7 +172,7 @@ struct Layout {
|
|||||||
curves: Vec<LCurve>,
|
curves: Vec<LCurve>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
enum LCurve {
|
enum LCurve {
|
||||||
Line(usize, usize),
|
Line(usize, usize),
|
||||||
Circle(usize, f64),
|
Circle(usize, f64),
|
||||||
@@ -368,7 +371,7 @@ impl Sketch {
|
|||||||
return Err(SketchError::TooLarge);
|
return Err(SketchError::TooLarge);
|
||||||
}
|
}
|
||||||
let id = self.fresh("j");
|
let id = self.fresh("j");
|
||||||
self.links.push(Link { id: id.clone(), kind, source, lost: false });
|
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
|
||||||
self.build_link(&id, &lay)?;
|
self.build_link(&id, &lay)?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
@@ -579,6 +582,77 @@ impl Sketch {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A resized face can enumerate the same boundary in another order. Match the
|
||||||
|
// shared-point graph in normalized sketch space before discarding its identity.
|
||||||
|
if let Some(cur) = &cur
|
||||||
|
&& points.len() == lay.pts.len()
|
||||||
|
&& cur.len() == lay.curves.len()
|
||||||
|
&& points.len() <= 512
|
||||||
|
{
|
||||||
|
let old: Vec<Vec2> = points.iter().filter_map(|i| self.point(*i)).collect();
|
||||||
|
let normalized = |ps: &[Vec2]| -> Vec<Vec2> {
|
||||||
|
let lo = ps.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
|
||||||
|
let hi = ps.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
|
||||||
|
ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect()
|
||||||
|
};
|
||||||
|
let (a, b) = (normalized(&old), normalized(&lay.pts));
|
||||||
|
let mut mapping = Vec::new();
|
||||||
|
for p in &a {
|
||||||
|
let mut candidates: Vec<(usize, f64)> = b.iter().enumerate().map(|(i, q)| (i, p.dist(*q))).collect();
|
||||||
|
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
|
||||||
|
if let Some((i, d)) = candidates.first()
|
||||||
|
&& *d < 0.1
|
||||||
|
&& candidates.get(1).is_none_or(|(_, second)| *d < *second * 0.5)
|
||||||
|
&& !mapping.contains(i)
|
||||||
|
{
|
||||||
|
mapping.push(*i);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut used = Vec::new();
|
||||||
|
let mut curve_map = Vec::new();
|
||||||
|
if mapping.len() == points.len() {
|
||||||
|
for c in cur {
|
||||||
|
let mapped: Option<Vec<usize>> = curve_points(c).iter().map(|i| mapping.get(*i).copied()).collect();
|
||||||
|
let found = lay
|
||||||
|
.curves
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(i, fresh)| {
|
||||||
|
if used.contains(i) || !same_shape(c, fresh) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let fresh_pts = curve_points(fresh);
|
||||||
|
mapped.as_ref().is_some_and(|p| {
|
||||||
|
*p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::<Vec<_>>() == fresh_pts
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|(i, _)| i);
|
||||||
|
if let Some(i) = found {
|
||||||
|
used.push(i);
|
||||||
|
curve_map.push(i);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if curve_map.len() == cur.len() && mapping.len() == points.len() {
|
||||||
|
for (pi, li) in points.iter().zip(mapping) {
|
||||||
|
if let (Some(p), Some(q)) = (self.points.get_mut(*pi), lay.pts.get(li)) {
|
||||||
|
p.pos = *q;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ci, li) in curves.iter().zip(curve_map) {
|
||||||
|
if let (Some(Curve { kind: CurveKind::Circle { r, .. }, .. }), Some(LCurve::Circle(_, value))) =
|
||||||
|
(self.curves.get_mut(*ci), lay.curves.get(li))
|
||||||
|
{
|
||||||
|
*r = *value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
|
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
|
||||||
self.drop_link_entities(id);
|
self.drop_link_entities(id);
|
||||||
self.build_link(id, &lay)?;
|
self.build_link(id, &lay)?;
|
||||||
|
|||||||
@@ -467,6 +467,8 @@ pub struct Sketch {
|
|||||||
/// Links to geometry outside the sketch (projections, intersections, includes).
|
/// Links to geometry outside the sketch (projections, intersections, includes).
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub links: Vec<crate::link::Link>,
|
pub links: Vec<crate::link::Link>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub offsets: Vec<crate::offset::OffsetChain>,
|
||||||
/// 3D curves (world coordinates) carried by the sketch.
|
/// 3D curves (world coordinates) carried by the sketch.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub wires: Vec<crate::wire::Wire>,
|
pub wires: Vec<crate::wire::Wire>,
|
||||||
@@ -495,6 +497,7 @@ impl Sketch {
|
|||||||
curves: Vec::new(),
|
curves: Vec::new(),
|
||||||
constraints: Vec::new(),
|
constraints: Vec::new(),
|
||||||
links: Vec::new(),
|
links: Vec::new(),
|
||||||
|
offsets: Vec::new(),
|
||||||
wires: Vec::new(),
|
wires: Vec::new(),
|
||||||
counters: Default::default(),
|
counters: Default::default(),
|
||||||
view: SketchView::default(),
|
view: SketchView::default(),
|
||||||
@@ -695,10 +698,8 @@ impl Sketch {
|
|||||||
| Symmetric { l, .. }
|
| Symmetric { l, .. }
|
||||||
| PointLineDistance { l, .. }
|
| PointLineDistance { l, .. }
|
||||||
| LinearDiameter { l, .. } => need_line(l)?,
|
| LinearDiameter { l, .. } => need_line(l)?,
|
||||||
ArcLength { c, .. } => {
|
ArcLength { c, .. } if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) => {
|
||||||
if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) {
|
return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into()));
|
||||||
return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Smooth { a, b } => {
|
Smooth { a, b } => {
|
||||||
let ends = |i: usize| self.curves.get(i).and_then(|c| c.kind.ends());
|
let ends = |i: usize| self.curves.get(i).and_then(|c| c.kind.ends());
|
||||||
@@ -727,10 +728,8 @@ impl Sketch {
|
|||||||
return Err(SketchError::WrongKind(name(a), "tangent needs a circle or arc".into()));
|
return Err(SketchError::WrongKind(name(a), "tangent needs a circle or arc".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Equal { a, b } => {
|
Equal { a, b } if is_line(a) != is_line(b) => {
|
||||||
if is_line(a) != is_line(b) {
|
return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into()));
|
||||||
return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Coincident { p, q } if p == q => return Err(SketchError::Invalid("a point is always coincident with itself".into())),
|
Coincident { p, q } if p == q => return Err(SketchError::Invalid("a point is always coincident with itself".into())),
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! Associative offsets keep their output entity ids while their source curves move.
|
||||||
|
use crate::{CurveKind, Shape, Sketch, SketchError, intersections};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use solvecraft_geom::Vec2;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct OffsetChain {
|
||||||
|
pub sources: Vec<(String, bool)>,
|
||||||
|
pub outputs: Vec<String>,
|
||||||
|
pub distance: f64,
|
||||||
|
pub closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sketch {
|
||||||
|
pub fn refresh_offsets(&mut self) -> Result<(), SketchError> {
|
||||||
|
for offset in self.offsets.clone() {
|
||||||
|
if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() {
|
||||||
|
return Err(SketchError::Invalid("damaged offset chain".into()));
|
||||||
|
}
|
||||||
|
let bad = || SketchError::Invalid("an offset source vanished or changed shape".into());
|
||||||
|
let mut pieces = Vec::new();
|
||||||
|
for (id, forward) in &offset.sources {
|
||||||
|
let i = self.curve_index(id).ok_or_else(bad)?;
|
||||||
|
let kind = &self.curves.get(i).ok_or_else(bad)?.kind;
|
||||||
|
let piece = match *kind {
|
||||||
|
CurveKind::Line { a, b } => {
|
||||||
|
let (a, b) = (self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
|
||||||
|
let (a, b) = if *forward { (a, b) } else { (b, a) };
|
||||||
|
let n = (b - a).normalized().ok_or_else(bad)?.perp() * offset.distance;
|
||||||
|
(Shape::Line { a: a + n, b: b + n }, a + n, b + n)
|
||||||
|
}
|
||||||
|
CurveKind::Arc { c, a, b } => {
|
||||||
|
let (c, a, b) = (self.point(c).ok_or_else(bad)?, self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
|
||||||
|
let r = c.dist(a) - if *forward { offset.distance } else { -offset.distance };
|
||||||
|
if r <= 1e-9 {
|
||||||
|
return Err(SketchError::Invalid("the offset collapses an arc".into()));
|
||||||
|
}
|
||||||
|
let a = c + (a - c).normalized().ok_or_else(bad)? * r;
|
||||||
|
let b = c + (b - c).normalized().ok_or_else(bad)? * r;
|
||||||
|
let (a, b) = if *forward { (a, b) } else { (b, a) };
|
||||||
|
(Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU }, a, b)
|
||||||
|
}
|
||||||
|
CurveKind::Circle { c, r } => {
|
||||||
|
let c = self.point(c).ok_or_else(bad)?;
|
||||||
|
let r = r - offset.distance;
|
||||||
|
if r <= 1e-9 {
|
||||||
|
return Err(SketchError::Invalid("the offset collapses a circle".into()));
|
||||||
|
}
|
||||||
|
let out = offset.outputs.first().and_then(|id| self.curve_index(id)).ok_or_else(bad)?;
|
||||||
|
if let Some(crate::Curve { kind: CurveKind::Circle { r: target, .. }, .. }) = self.curves.get_mut(out) {
|
||||||
|
*target = r;
|
||||||
|
} else {
|
||||||
|
return Err(bad());
|
||||||
|
}
|
||||||
|
let _ = c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => return Err(bad()),
|
||||||
|
};
|
||||||
|
pieces.push(piece);
|
||||||
|
}
|
||||||
|
if pieces.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let joint = |i: usize, j: usize| -> Result<Vec2, SketchError> {
|
||||||
|
let (x, y) = (pieces.get(i).ok_or_else(bad)?, pieces.get(j).ok_or_else(bad)?);
|
||||||
|
if x.2.dist(y.1) < 1e-6 {
|
||||||
|
return Ok(x.2);
|
||||||
|
}
|
||||||
|
let guess = (x.2 + y.1) * 0.5;
|
||||||
|
intersections(&x.0, &y.0)
|
||||||
|
.into_iter()
|
||||||
|
.min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess)))
|
||||||
|
.ok_or_else(|| SketchError::Invalid("the offset curves no longer meet".into()))
|
||||||
|
};
|
||||||
|
let n = pieces.len();
|
||||||
|
let mut joints = vec![if offset.closed { joint(n - 1, 0)? } else { pieces.first().ok_or_else(bad)?.1 }];
|
||||||
|
for i in 0..n.saturating_sub(1) {
|
||||||
|
joints.push(joint(i, i + 1)?);
|
||||||
|
}
|
||||||
|
if !offset.closed {
|
||||||
|
joints.push(pieces.last().ok_or_else(bad)?.2);
|
||||||
|
}
|
||||||
|
for (i, id) in offset.outputs.iter().enumerate() {
|
||||||
|
let a = *joints.get(i).ok_or_else(bad)?;
|
||||||
|
let b = *joints.get(if offset.closed { (i + 1) % n } else { i + 1 }).ok_or_else(bad)?;
|
||||||
|
let shape = &pieces.get(i).ok_or_else(bad)?.0;
|
||||||
|
if let Shape::Line { a: p, b: q } = shape
|
||||||
|
&& (b - a).dot(*q - *p) <= 1e-9
|
||||||
|
{
|
||||||
|
return Err(SketchError::Invalid("the offset curves would cross".into()));
|
||||||
|
}
|
||||||
|
let ci = self.curve_index(id).ok_or_else(bad)?;
|
||||||
|
let kind = self.curves.get(ci).ok_or_else(bad)?.kind.clone();
|
||||||
|
let (pa, pb) = match kind {
|
||||||
|
CurveKind::Line { a, b } => (a, b),
|
||||||
|
CurveKind::Arc { a, b, .. } => {
|
||||||
|
if offset.sources.get(i).is_some_and(|x| x.1) {
|
||||||
|
(a, b)
|
||||||
|
} else {
|
||||||
|
(b, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(bad()),
|
||||||
|
};
|
||||||
|
for (pi, pos) in [(pa, a), (pb, b)] {
|
||||||
|
if let Some(p) = self.points.get_mut(pi) {
|
||||||
|
p.pos = pos;
|
||||||
|
p.fixed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Ve
|
|||||||
if text.chars().count() > MAX_TEXT_CHARS {
|
if text.chars().count() > MAX_TEXT_CHARS {
|
||||||
return Err(SketchError::TooLarge);
|
return Err(SketchError::TooLarge);
|
||||||
}
|
}
|
||||||
if !(height.is_finite() && height > 1e-6 && height < 1e6) || !angle.is_finite() || !at.is_finite() {
|
if !(height.is_finite() && height > 1e-6 && height < 1e6 && angle.is_finite() && at.is_finite()) {
|
||||||
return Err(SketchError::Invalid("text height must be positive".into()));
|
return Err(SketchError::Invalid("text height must be positive".into()));
|
||||||
}
|
}
|
||||||
let face = ttf_parser::Face::parse(FONT, 0).map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
|
let face = ttf_parser::Face::parse(FONT, 0).map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ impl Sketch {
|
|||||||
}
|
}
|
||||||
let id = if geom.is_empty() {
|
let id = if geom.is_empty() {
|
||||||
let id = self.fresh("j");
|
let id = self.fresh("j");
|
||||||
self.links.push(Link { id: id.clone(), kind, source, lost: false });
|
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
|
||||||
id
|
id
|
||||||
} else {
|
} else {
|
||||||
self.add_link(kind, source, geom)?
|
self.add_link(kind, source, geom)?
|
||||||
|
|||||||
@@ -232,41 +232,41 @@ const INDENT: f32 = 14.0;
|
|||||||
|
|
||||||
/// One row's look.
|
/// One row's look.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Row<'a> {
|
pub(crate) struct Row<'a> {
|
||||||
depth: usize,
|
pub(crate) depth: usize,
|
||||||
/// Some(open) for rows that fold.
|
/// Some(open) for rows that fold.
|
||||||
fold: Option<bool>,
|
pub(crate) fold: Option<bool>,
|
||||||
/// Some(visible) for rows with an eye.
|
/// Some(visible) for rows with an eye.
|
||||||
eye: Option<bool>,
|
pub(crate) eye: Option<bool>,
|
||||||
icon: &'a str,
|
pub(crate) icon: &'a str,
|
||||||
label: &'a str,
|
pub(crate) label: &'a str,
|
||||||
selected: bool,
|
pub(crate) selected: bool,
|
||||||
dim: bool,
|
pub(crate) dim: bool,
|
||||||
color: Option<Color32>,
|
pub(crate) color: Option<Color32>,
|
||||||
/// Component rows: is it the active one?
|
/// Component rows: is it the active one?
|
||||||
radio: Option<bool>,
|
pub(crate) radio: Option<bool>,
|
||||||
/// Trailing badges: "lock", "pin".
|
/// Trailing badges: "lock", "pin".
|
||||||
badges: &'a [&'a str],
|
pub(crate) badges: &'a [&'a str],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What happened on a row.
|
/// What happened on a row.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RowResp {
|
pub(crate) struct RowResp {
|
||||||
rect: Option<Rect>,
|
pub(crate) rect: Option<Rect>,
|
||||||
clicked: bool,
|
pub(crate) clicked: bool,
|
||||||
double: bool,
|
pub(crate) double: bool,
|
||||||
secondary: Option<Pos2>,
|
pub(crate) secondary: Option<Pos2>,
|
||||||
fold: bool,
|
pub(crate) fold: bool,
|
||||||
eye: bool,
|
pub(crate) eye: bool,
|
||||||
radio: bool,
|
pub(crate) radio: bool,
|
||||||
hovered: bool,
|
pub(crate) hovered: bool,
|
||||||
/// The row's response (drag and drop).
|
/// The row's response (drag and drop).
|
||||||
resp: Option<egui::Response>,
|
pub(crate) resp: Option<egui::Response>,
|
||||||
/// The press began on the fold arrow, eye or radio (not a drag of the row).
|
/// The press began on the fold arrow, eye or radio (not a drag of the row).
|
||||||
on_control: bool,
|
pub(crate) on_control: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
// Rows scrolled out of view only take their space: no widgets, no painting.
|
// Rows scrolled out of view only take their space: no widgets, no painting.
|
||||||
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H));
|
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H));
|
||||||
@@ -449,11 +449,11 @@ fn sel_of_key(k: &str) -> Option<Sel> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
|
pub(crate) fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
|
||||||
if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) }
|
if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toggle(app: &mut SolveApp, key: &str, default: bool) {
|
pub(crate) fn toggle(app: &mut SolveApp, key: &str, default: bool) {
|
||||||
if default {
|
if default {
|
||||||
if !app.tree.collapsed.remove(key) {
|
if !app.tree.collapsed.remove(key) {
|
||||||
app.tree.collapsed.insert(key.to_string());
|
app.tree.collapsed.insert(key.to_string());
|
||||||
|
|||||||
@@ -493,7 +493,10 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
|||||||
let rigid = j.kind == solvecraft_engine::doc::joints::JointKind::Rigid;
|
let rigid = j.kind == solvecraft_engine::doc::joints::JointKind::Rigid;
|
||||||
vec![
|
vec![
|
||||||
act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })),
|
act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })),
|
||||||
|
act("ui.editJoint", "Edit Joint Limits", "").with(json!({ "joint": id })).on(!rigid),
|
||||||
act("ui.driveJoint", "Drive Joint", "").with(json!({ "joint": id })).on(!rigid),
|
act("ui.driveJoint", "Drive Joint", "").with(json!({ "joint": id })).on(!rigid),
|
||||||
|
act("ui.animateJoint", "Animate Joint", "").with(json!({ "joint": id })).on(!rigid && !j.suppressed),
|
||||||
|
act("ui.findJoint", "Find in Window", "").with(json!({ "joint": id })),
|
||||||
cmd(app, "joint.edit", if j.suppressed { "Unsuppress" } else { "Suppress" }).with(json!({ "joint": id, "suppressed": !j.suppressed })),
|
cmd(app, "joint.edit", if j.suppressed { "Unsuppress" } else { "Suppress" }).with(json!({ "joint": id, "suppressed": !j.suppressed })),
|
||||||
Item::sep(),
|
Item::sep(),
|
||||||
cmd(app, "joint.delete", "Delete").key("Del").with(json!({ "joint": id })),
|
cmd(app, "joint.delete", "Delete").key("Del").with(json!({ "joint": id })),
|
||||||
@@ -792,12 +795,17 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
|||||||
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
|
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
|
||||||
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
|
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
|
||||||
"ui.workspace" => {}
|
"ui.workspace" => {}
|
||||||
"ui.editJoint" | "ui.driveJoint" => {
|
"ui.findJoint" => {
|
||||||
if let Some(id) = id_of(&p, "joint") {
|
if let Some(id) = id_of(&p, "joint") {
|
||||||
let d = if item.id == "ui.editJoint" {
|
crate::dialogs_assembly::find_joint(app, id);
|
||||||
crate::dialogs_assembly::edit_joint(app, id)
|
}
|
||||||
} else {
|
}
|
||||||
crate::dialogs_assembly::drive_joint(app, id)
|
"ui.editJoint" | "ui.driveJoint" | "ui.animateJoint" => {
|
||||||
|
if let Some(id) = id_of(&p, "joint") {
|
||||||
|
let d = match item.id.as_str() {
|
||||||
|
"ui.editJoint" => crate::dialogs_assembly::edit_joint(app, id),
|
||||||
|
"ui.animateJoint" => crate::dialogs_assembly::animate_joint(app, id),
|
||||||
|
_ => crate::dialogs_assembly::drive_joint(app, id),
|
||||||
};
|
};
|
||||||
if let Some(d) = d {
|
if let Some(d) = d {
|
||||||
app.tool = None;
|
app.tool = None;
|
||||||
|
|||||||
@@ -480,10 +480,12 @@ impl Dialog {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| vec![sel.clone()]),
|
.unwrap_or_else(|| vec![sel.clone()]),
|
||||||
|
(Kind::Measure { .. }, _) => vec![crate::viewport::measurement_selection(s, sel)],
|
||||||
|
_ if inp.multi => crate::viewport::grouped_selection(s, sel),
|
||||||
_ => vec![sel.clone()],
|
_ => vec![sel.clone()],
|
||||||
};
|
};
|
||||||
for x in picked {
|
for x in picked {
|
||||||
if !inp.items.contains(&x) {
|
if !inp.items.iter().any(|y| selection::same_item(y, &x)) {
|
||||||
inp.items.push(x);
|
inp.items.push(x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,8 +633,11 @@ impl Dialog {
|
|||||||
|
|
||||||
/// A pick in the viewport (already accepted by the active input).
|
/// A pick in the viewport (already accepted by the active input).
|
||||||
pub fn pick(&mut self, s: &Session, sel: Sel) {
|
pub fn pick(&mut self, s: &Session, sel: Sel) {
|
||||||
|
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||||
|
let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel };
|
||||||
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
|
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
|
||||||
let mut picked = vec![sel.clone()];
|
let mut picked =
|
||||||
|
if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::grouped_selection(s, &sel) } else { vec![sel.clone()] };
|
||||||
// Tangent chain: an edge brings the edges that continue it smoothly.
|
// Tangent chain: an edge brings the edges that continue it smoothly.
|
||||||
if chain
|
if chain
|
||||||
&& let Sel::Edge { body, index, .. } = &sel
|
&& let Sel::Edge { body, index, .. } = &sel
|
||||||
@@ -667,13 +672,15 @@ impl Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Box selection result: replace (or add to) the active input.
|
/// Box selection result: replace (or add to) the active input.
|
||||||
pub fn take_box(&mut self, sels: Vec<Sel>, add: bool) {
|
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
|
||||||
|
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||||||
if !add {
|
if !add {
|
||||||
inp.items.clear();
|
inp.items.clear();
|
||||||
}
|
}
|
||||||
for x in sels {
|
for x in sels {
|
||||||
if fits(inp.accept, &x) && !inp.items.contains(&x) {
|
let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x };
|
||||||
|
if fits(inp.accept, &x) && !inp.items.iter().any(|y| selection::same_item(y, &x)) {
|
||||||
inp.items.push(x);
|
inp.items.push(x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1848,7 +1855,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
Err(e) => d.error = Some(e),
|
Err(e) => d.error = Some(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !(keep && !cancel) {
|
if !keep || cancel {
|
||||||
crate::dialogs_motion::closed(app, &mut d, applied);
|
crate::dialogs_motion::closed(app, &mut d, applied);
|
||||||
}
|
}
|
||||||
if keep && !cancel {
|
if keep && !cancel {
|
||||||
@@ -2691,11 +2698,50 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// A dialog is being filled from a feature: its points are in component frames.
|
||||||
|
static REFILL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// While filling a dialog from a feature (dropped at the end).
|
||||||
|
struct Refill;
|
||||||
|
|
||||||
|
impl Refill {
|
||||||
|
fn start() -> Refill {
|
||||||
|
REFILL.with(|r| r.set(true));
|
||||||
|
Refill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Refill {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
REFILL.with(|r| r.set(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A point stored in a feature, as it shows on world body `body`: features keep their picks in
|
||||||
|
/// the frame of the body they were on, so each body's placement takes the point to the world
|
||||||
|
/// (a moved component, a pasted instance). Other points are the world already.
|
||||||
|
fn stored_on(frames: &Option<Vec<(String, solvecraft_engine::doc::Mat)>>, body: &str, p: Vec3) -> Vec3 {
|
||||||
|
frames.as_ref().and_then(|f| f.iter().find(|(n, _)| n == body)).map(|(_, m)| solvecraft_engine::doc::apply_point(m, p)).unwrap_or(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body placements while filling a dialog from a feature (when anything is placed).
|
||||||
|
fn stored_frames(s: &Session) -> Option<Vec<(String, solvecraft_engine::doc::Mat)>> {
|
||||||
|
if !REFILL.with(std::cell::Cell::get) || !solvecraft_engine::frames::placed(s) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let st = s.model.state();
|
||||||
|
Some(s.doc.body_frames(&st).into_iter().map(|(w, _, _, m)| (w, m)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// The edge of a visible body through (or nearest) a point.
|
/// The edge of a visible body through (or nearest) a point.
|
||||||
pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
pub(crate) fn edge_sel(s: &Session, p0: Vec3) -> Option<Sel> {
|
||||||
let st = s.world_state();
|
let st = s.world_state();
|
||||||
|
let frames = stored_frames(s);
|
||||||
let mut best: Option<(f64, Sel)> = None;
|
let mut best: Option<(f64, Sel)> = None;
|
||||||
for b in &st.bodies {
|
for b in &st.bodies {
|
||||||
|
let p = stored_on(&frames, &b.name, p0);
|
||||||
let m = b.mesh();
|
let m = b.mesh();
|
||||||
for (i, e) in m.edges.iter().enumerate() {
|
for (i, e) in m.edges.iter().enumerate() {
|
||||||
let d = e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
let d = e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||||
@@ -2708,10 +2754,12 @@ pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The body face containing a point (nearest triangle).
|
/// The body face containing a point (nearest triangle).
|
||||||
pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option<Sel> {
|
||||||
let st = s.world_state();
|
let st = s.world_state();
|
||||||
|
let frames = stored_frames(s);
|
||||||
let mut best: Option<(f64, Sel)> = None;
|
let mut best: Option<(f64, Sel)> = None;
|
||||||
for b in &st.bodies {
|
for b in &st.bodies {
|
||||||
|
let p = stored_on(&frames, &b.name, p0);
|
||||||
let m = b.mesh();
|
let m = b.mesh();
|
||||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||||
@@ -2721,7 +2769,7 @@ pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1)
|
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
||||||
@@ -2756,6 +2804,7 @@ fn pt3(v: Vec3) -> Value {
|
|||||||
/// 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> {
|
||||||
|
let _refill = Refill::start();
|
||||||
let s = &app.session;
|
let s = &app.session;
|
||||||
let f = s.doc.feature(id)?.clone();
|
let f = s.doc.feature(id)?.clone();
|
||||||
let st = s.world_state();
|
let st = s.world_state();
|
||||||
|
|||||||
@@ -209,6 +209,38 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The Drive Joints dialog on a joint (by id).
|
/// The Drive Joints dialog on a joint (by id).
|
||||||
|
/// Edit Joint with its motion playing (the browser's Animate Joint).
|
||||||
|
pub fn animate_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
|
||||||
|
let mut d = edit_joint(app, id)?;
|
||||||
|
if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind {
|
||||||
|
f.animate = true;
|
||||||
|
}
|
||||||
|
Some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frame the two components a joint connects (the browser's Find in Window).
|
||||||
|
pub fn find_joint(app: &mut SolveApp, id: u64) {
|
||||||
|
let s = &app.session;
|
||||||
|
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) else { return };
|
||||||
|
let occs = [j.a.occurrence, j.b.occurrence];
|
||||||
|
let model = s.model.state();
|
||||||
|
let world = s.world_state();
|
||||||
|
let mut bb = Aabb3::default();
|
||||||
|
for (name, _, path, _) in s.doc.body_frames(&model) {
|
||||||
|
// The root (occurrence 0) holds the bodies outside every occurrence.
|
||||||
|
let on = occs.iter().any(|o| if *o == 0 { path.is_empty() } else { path.contains(o) });
|
||||||
|
if on && let Some(b) = world.body(&name) {
|
||||||
|
bb = bb.union(&b.mesh().bounds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bb.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut cam = app.cam;
|
||||||
|
cam.fit(&bb);
|
||||||
|
app.animate_to(cam);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn drive_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
|
pub fn drive_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
|
||||||
let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?;
|
let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?;
|
||||||
Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint), focus: true }), vec![]))
|
Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint), focus: true }), vec![]))
|
||||||
@@ -494,12 +526,25 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
|||||||
let cur = s.doc.eval(v, unit_kind).ok().filter(|x| x.is_finite()).unwrap_or(0.0);
|
let cur = s.doc.eval(v, unit_kind).ok().filter(|x| x.is_finite()).unwrap_or(0.0);
|
||||||
let (mut shown, scale) = if angle { (cur.to_degrees(), 1f64.to_degrees()) } else { (cur, 1.0) };
|
let (mut shown, scale) = if angle { (cur.to_degrees(), 1f64.to_degrees()) } else { (cur, 1.0) };
|
||||||
row_label(ui, "");
|
row_label(ui, "");
|
||||||
ui.spacing_mut().slider_width = 124.0;
|
ui.spacing_mut().slider_width = 160.0;
|
||||||
let r = ui.add(egui::Slider::new(&mut shown, lo * scale..=hi * scale).show_value(false));
|
let r = ui.add(egui::Slider::new(&mut shown, lo * scale..=hi * scale).show_value(false));
|
||||||
if r.changed() {
|
if r.changed() {
|
||||||
*v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") };
|
*v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") };
|
||||||
}
|
}
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
// The slider's range: the joint's limits, or the default span when it has none.
|
||||||
|
let limited = j.limits.get(i).copied().flatten().is_some();
|
||||||
|
let fmt = |x: f64| {
|
||||||
|
let (x, unit) = if angle { (x.to_degrees(), "°") } else { (x, " mm") };
|
||||||
|
if x.fract().abs() < 1e-6 { format!("{x:.0}{unit}") } else { format!("{x:.1}{unit}") }
|
||||||
|
};
|
||||||
|
row_label(ui, "");
|
||||||
|
ui.label(
|
||||||
|
RichText::new(format!("{} … {}{}", fmt(lo), fmt(hi), if limited { " (limits)" } else { " (no limits)" }))
|
||||||
|
.size(11.0)
|
||||||
|
.color(if limited { t.text } else { t.text_dim }),
|
||||||
|
);
|
||||||
|
ui.end_row();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Asm::MotionLink { a, b, ia, ib, ratio, offset } => {
|
Asm::MotionLink { a, b, ia, ib, ratio, offset } => {
|
||||||
@@ -826,6 +871,8 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A joint names its two picks; a joint origin has one.
|
||||||
|
let two = matches!(k, Asm::Joint(_));
|
||||||
let hover = match k {
|
let hover = match k {
|
||||||
Asm::Joint(f) => Some(&mut f.hover),
|
Asm::Joint(f) => Some(&mut f.hover),
|
||||||
Asm::JointOrigin { hover, .. } => Some(hover),
|
Asm::JointOrigin { hover, .. } => Some(hover),
|
||||||
@@ -840,8 +887,9 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
|
|||||||
.filter(|(_, i)| i.accept == SNAPS)
|
.filter(|(_, i)| i.accept == SNAPS)
|
||||||
.filter_map(|(n, i)| i.items.first().and_then(|x| snap_of(&app.session, x)).map(|s| (s, if n == 0 { t.accent } else { t.warning })))
|
.filter_map(|(n, i)| i.items.first().and_then(|x| snap_of(&app.session, x)).map(|s| (s, if n == 0 { t.accent } else { t.warning })))
|
||||||
.collect();
|
.collect();
|
||||||
for (s, c) in &picked {
|
for (n, (s, c)) in picked.iter().enumerate() {
|
||||||
glyph(painter, proj, s, *c, app.cam.half_height());
|
let tag = format!("Component {}", n + 1);
|
||||||
|
glyph(painter, proj, s, *c, app.cam.half_height(), two.then_some(tag.as_str()));
|
||||||
}
|
}
|
||||||
// The snap under the cursor (found again only when the hovered item changes).
|
// The snap under the cursor (found again only when the hovered item changes).
|
||||||
let accepting = d.inputs.get(d.active).is_some_and(|i| i.accept == SNAPS);
|
let accepting = d.inputs.get(d.active).is_some_and(|i| i.accept == SNAPS);
|
||||||
@@ -853,7 +901,7 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
|
|||||||
*hover = Some((sel.clone(), snap_of(&app.session, &sel)));
|
*hover = Some((sel.clone(), snap_of(&app.session, &sel)));
|
||||||
}
|
}
|
||||||
if let Some((_, Some(s))) = hover.as_ref() {
|
if let Some((_, Some(s))) = hover.as_ref() {
|
||||||
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height());
|
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height(), Some(snap_name(s.kind)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => *hover = None,
|
None => *hover = None,
|
||||||
@@ -861,36 +909,70 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A snap: its kind's marker at the point and the z axis as an arrow.
|
/// A snap: its kind's marker at the point and the z axis as an arrow.
|
||||||
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64) {
|
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64, tag: Option<&str>) {
|
||||||
let Some(c) = proj.to_screen(s.at) else { return };
|
let Some(c) = proj.to_screen(s.at) else { return };
|
||||||
let len = half_height * 2.0 * 0.06;
|
let len = half_height * 2.0 * 0.06;
|
||||||
|
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
|
||||||
|
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
|
||||||
|
let white = Stroke::new(1.5, Color32::WHITE);
|
||||||
if let Some(tip) = proj.to_screen(s.at + s.z * len) {
|
if let Some(tip) = proj.to_screen(s.at + s.z * len) {
|
||||||
painter.line_segment([c, tip], Stroke::new(2.0, col));
|
|
||||||
let d = (tip - c).normalized();
|
let d = (tip - c).normalized();
|
||||||
let n = vec2(-d.y, d.x);
|
let n = vec2(-d.y, d.x);
|
||||||
painter.add(egui::Shape::convex_polygon(vec![tip, tip - d * 8.0 + n * 4.0, tip - d * 8.0 - n * 4.0], col, Stroke::NONE));
|
let head = vec![tip + d * 2.0, tip - d * 9.0 + n * 5.0, tip - d * 9.0 - n * 5.0];
|
||||||
|
painter.line_segment([c, tip], halo);
|
||||||
|
painter.add(egui::Shape::convex_polygon(head.clone(), Color32::TRANSPARENT, halo));
|
||||||
|
painter.line_segment([c, tip], Stroke::new(2.0, col));
|
||||||
|
painter.add(egui::Shape::convex_polygon(head, col, Stroke::NONE));
|
||||||
}
|
}
|
||||||
let white = Stroke::new(1.5, Color32::WHITE);
|
let shape = |fill: Color32, stroke: Stroke, grow: f32| -> egui::Shape {
|
||||||
|
match s.kind {
|
||||||
|
SnapKind::FaceCenter => egui::Shape::rect_filled(egui::Rect::from_center_size(c, vec2(12.0 + grow, 12.0 + grow)), 1.5, fill),
|
||||||
|
SnapKind::CircleCenter => egui::Shape::circle_filled(c, 7.0 + grow / 2.0, fill),
|
||||||
|
SnapKind::EdgeMid => {
|
||||||
|
let r = 8.0 + grow / 2.0;
|
||||||
|
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r * 0.93, r * 0.7), c + vec2(-r * 0.93, r * 0.7)], fill, stroke)
|
||||||
|
}
|
||||||
|
SnapKind::Vertex => {
|
||||||
|
let r = 7.5 + grow / 2.0;
|
||||||
|
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r, 0.0), c + vec2(0.0, r), c + vec2(-r, 0.0)], fill, stroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
|
||||||
|
painter.add(shape(col, Stroke::NONE, 0.0));
|
||||||
match s.kind {
|
match s.kind {
|
||||||
SnapKind::FaceCenter => {
|
SnapKind::FaceCenter => {
|
||||||
painter.rect(egui::Rect::from_center_size(c, vec2(10.0, 10.0)), 1.0, col, white, egui::StrokeKind::Middle);
|
painter.rect_stroke(egui::Rect::from_center_size(c, vec2(12.0, 12.0)), 1.5, white, egui::StrokeKind::Middle);
|
||||||
}
|
}
|
||||||
SnapKind::CircleCenter => {
|
SnapKind::CircleCenter => {
|
||||||
painter.circle(c, 6.0, col, white);
|
painter.circle_stroke(c, 7.0, white);
|
||||||
painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.2, Color32::WHITE));
|
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
|
||||||
painter.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.2, Color32::WHITE));
|
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
|
||||||
}
|
}
|
||||||
SnapKind::EdgeMid => {
|
SnapKind::EdgeMid | SnapKind::Vertex => {
|
||||||
painter.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -7.0), c + vec2(6.5, 5.0), c + vec2(-6.5, 5.0)], col, white));
|
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
|
||||||
}
|
|
||||||
SnapKind::Vertex => {
|
|
||||||
painter.add(egui::Shape::convex_polygon(
|
|
||||||
vec![c + vec2(0.0, -6.5), c + vec2(6.5, 0.0), c + vec2(0.0, 6.5), c + vec2(-6.5, 0.0)],
|
|
||||||
col,
|
|
||||||
white,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// What it snaps to (hovered), or which component it is (picked): a small label.
|
||||||
|
if let Some(tag) = tag {
|
||||||
|
let t = Tokens::get();
|
||||||
|
let font = egui::FontId::proportional(11.5);
|
||||||
|
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
|
||||||
|
let at = c + vec2(12.0, 10.0);
|
||||||
|
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
|
||||||
|
painter.rect(r, 3.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, col), egui::StrokeKind::Inside);
|
||||||
|
painter.galley(at, g, t.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a snap is called (the hover label).
|
||||||
|
fn snap_name(k: SnapKind) -> &'static str {
|
||||||
|
match k {
|
||||||
|
SnapKind::FaceCenter => "Face center",
|
||||||
|
SnapKind::CircleCenter => "Circle center",
|
||||||
|
SnapKind::EdgeMid => "Edge midpoint",
|
||||||
|
SnapKind::Vertex => "Vertex",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- timeline and browser ----
|
// ---- timeline and browser ----
|
||||||
@@ -963,61 +1045,36 @@ pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter,
|
|||||||
/// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu
|
/// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu
|
||||||
/// edits, drives, suppresses or deletes).
|
/// edits, drives, suppresses or deletes).
|
||||||
pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
||||||
let t = Tokens::get();
|
use crate::browser::{Row, draw_row, is_open, toggle};
|
||||||
let joints: Vec<(u64, String, JointKind, bool)> =
|
let joints: Vec<(u64, String, JointKind, bool)> =
|
||||||
app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect();
|
app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect();
|
||||||
if joints.is_empty() {
|
if joints.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fold_id = egui::Id::new("sc_browser_joints_open");
|
// Drawn like every other browser node (its fold and rows publish `fold:Joints`, `row:<name>`).
|
||||||
let mut open = ui.data(|d| d.get_temp::<bool>(fold_id)).unwrap_or(true);
|
let key = "c0/joints";
|
||||||
let row = |ui: &mut egui::Ui, depth: f32, icon: &str, label: &str, dim: bool, fold: Option<bool>| -> egui::Response {
|
let open = is_open(app, key, true);
|
||||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), egui::Sense::click());
|
let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Joints", ..Default::default() });
|
||||||
if resp.hovered() {
|
if r.fold || r.clicked {
|
||||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
toggle(app, key, true);
|
||||||
}
|
|
||||||
let x = r.left() + 2.0 + depth * 14.0;
|
|
||||||
let cy = r.center().y;
|
|
||||||
if let Some(o) = fold {
|
|
||||||
let c = egui::pos2(x + 6.0, cy);
|
|
||||||
let pts = if o {
|
|
||||||
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
|
|
||||||
} else {
|
|
||||||
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
|
|
||||||
};
|
|
||||||
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
|
|
||||||
}
|
|
||||||
let ir = egui::Rect::from_center_size(egui::pos2(x + 26.0, cy), vec2(16.0, 16.0));
|
|
||||||
crate::icons::paint(ui.painter(), ir, icon, if dim { t.border } else { t.icon }, t.icon_fill, t.accent);
|
|
||||||
ui.painter().text(
|
|
||||||
egui::pos2(x + 38.0, cy),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
label,
|
|
||||||
egui::FontId::proportional(13.0),
|
|
||||||
if dim { t.text_dim } else { t.text },
|
|
||||||
);
|
|
||||||
resp
|
|
||||||
};
|
|
||||||
let depth = depth as f32;
|
|
||||||
if row(ui, depth, "folder", "Joints", false, Some(open)).clicked() {
|
|
||||||
open = !open;
|
|
||||||
ui.data_mut(|d| d.insert_temp(fold_id, open));
|
|
||||||
}
|
}
|
||||||
if !open {
|
if !open {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut dialog: Option<Dialog> = None;
|
let mut dialog: Option<Dialog> = None;
|
||||||
for (id, name, kind, suppressed) in joints {
|
for (id, name, kind, suppressed) in joints {
|
||||||
let resp = row(ui, depth + 1.0, "joint", &name, suppressed, None).on_hover_text(format!("{kind:?} joint"));
|
let r =
|
||||||
if resp.double_clicked() {
|
draw_row(ui, ui.id().with(("joint", id)), &Row { depth: depth + 1, icon: "joint", label: &name, dim: suppressed, ..Default::default() });
|
||||||
|
if let Some(resp) = &r.resp {
|
||||||
|
resp.clone().on_hover_text(format!("{kind:?} joint"));
|
||||||
|
}
|
||||||
|
if r.double {
|
||||||
dialog = edit_joint(app, id);
|
dialog = edit_joint(app, id);
|
||||||
}
|
}
|
||||||
// The same menus as the rest of the browser.
|
// The same menus as the rest of the browser.
|
||||||
if resp.secondary_clicked() {
|
if let Some(at) = r.secondary {
|
||||||
let at = resp.interact_pointer_pos().unwrap_or(resp.rect.left_bottom());
|
|
||||||
crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id });
|
crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id });
|
||||||
}
|
}
|
||||||
let _ = (kind, suppressed);
|
|
||||||
}
|
}
|
||||||
if let Some(d) = dialog {
|
if let Some(d) = dialog {
|
||||||
app.tool = None;
|
app.tool = None;
|
||||||
@@ -1028,3 +1085,51 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "dialogs_assembly_tests.rs"]
|
#[path = "dialogs_assembly_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// Named joint origins in the world, for the design state they were found in.
|
||||||
|
static ORIGINS: std::cell::RefCell<Option<(String, Vec<(String, solvecraft_engine::doc::Mat)>)>> = const { std::cell::RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Named joint origins in the world: on their occurrence's component, so they move with it
|
||||||
|
/// (joint drives, free moves not captured yet, Capture Position).
|
||||||
|
pub fn origins_world(s: &Session) -> Vec<(String, solvecraft_engine::doc::Mat)> {
|
||||||
|
if s.doc.assembly.origins.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let key = format!("{}:{:?}", s.revision, s.pending_moves);
|
||||||
|
if let Some(v) = ORIGINS.with(|o| o.borrow().as_ref().filter(|(k, _)| *k == key).map(|(_, v)| v.clone())) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
let doc: std::borrow::Cow<solvecraft_engine::doc::Document> = if s.pending_moves.is_empty() {
|
||||||
|
std::borrow::Cow::Borrowed(&*s.doc)
|
||||||
|
} else {
|
||||||
|
let mut d = (*s.doc).clone();
|
||||||
|
for o in &mut d.occurrences {
|
||||||
|
if let Some(m) = s.pending_moves.get(&o.id) {
|
||||||
|
o.transform = *m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::borrow::Cow::Owned(d)
|
||||||
|
};
|
||||||
|
let st = s.model.state();
|
||||||
|
let v: Vec<_> = doc.assembly.origins.iter().filter_map(|o| doc.origin_world(&st, &o.origin).ok().map(|m| (o.name.clone(), m))).collect();
|
||||||
|
ORIGINS.with(|o| *o.borrow_mut() = Some((key, v.clone())));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Named joint origins in the viewport: a small triad where each sits.
|
||||||
|
pub fn origins(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||||
|
let t = Tokens::get();
|
||||||
|
let len = app.cam.half_height() * 2.0 * 0.04;
|
||||||
|
for (_, m) in origins_world(&app.session) {
|
||||||
|
let at = solvecraft_engine::doc::apply_point(&m, Vec3::ZERO);
|
||||||
|
let Some(c) = proj.to_screen(at) else { continue };
|
||||||
|
for (axis, col) in [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)] {
|
||||||
|
if let Some(tip) = proj.to_screen(at + solvecraft_engine::doc::apply_vector(&m, axis) * len) {
|
||||||
|
painter.line_segment([c, tip], Stroke::new(2.0, col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
painter.circle(c, 4.5, t.panel, Stroke::new(1.5, t.text));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -263,3 +263,41 @@ fn drive_value_takes_the_keyboard() {
|
|||||||
assert!((j.values[0] - 90f64.to_radians()).abs() < 1e-9, "{:?}", j.values);
|
assert!((j.values[0] - 90f64.to_radians()).abs() < 1e-9, "{:?}", j.values);
|
||||||
assert!(app.dialog.is_none());
|
assert!(app.dialog.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Named joint origins are drawn where their component is: a drive of the occurrence, or a free
|
||||||
|
/// move not captured yet, carries them along.
|
||||||
|
#[test]
|
||||||
|
fn joint_origins_follow_their_component() {
|
||||||
|
let mut app = two_boxes();
|
||||||
|
app.session.execute("joint.origin", &json!({"name": "Top of B", "face": [40, 10, 10]})).unwrap();
|
||||||
|
let at = |app: &SolveApp| {
|
||||||
|
let v = origins_world(&app.session);
|
||||||
|
let (_, m) = v.iter().find(|(n, _)| n == "Top of B").unwrap();
|
||||||
|
solvecraft_engine::doc::apply_point(m, Vec3::ZERO)
|
||||||
|
};
|
||||||
|
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 10.0)) < 1e-6, "{:?}", at(&app));
|
||||||
|
let b = occ(&app, "B");
|
||||||
|
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
|
||||||
|
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
|
||||||
|
// A drag not captured yet.
|
||||||
|
let mut m = app.session.doc.occurrence_of(app.session.doc.find_component("B").unwrap()).unwrap().transform;
|
||||||
|
m[3][0] += 5.0;
|
||||||
|
app.session.pending_moves.insert(b, m);
|
||||||
|
assert!(at(&app).dist(Vec3::new(45.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edit Feature on a moved component: the dialog shows the feature's picks where the
|
||||||
|
/// component is (its stored points are in the component's frame).
|
||||||
|
#[test]
|
||||||
|
fn edit_feature_on_a_moved_component_refills_in_the_world() {
|
||||||
|
let mut app = two_boxes();
|
||||||
|
let b = occ(&app, "B");
|
||||||
|
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
|
||||||
|
let r = app.session.execute("solid.fillet", &json!({"edges": [[40, 0, 25]], "radius": 2})).unwrap();
|
||||||
|
let id = r["feature"].as_u64().unwrap();
|
||||||
|
let d = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||||
|
let items: Vec<Sel> = d.inputs.iter().flat_map(|i| i.items.clone()).collect();
|
||||||
|
let Some(Sel::Edge { body, point, .. }) = items.first() else { panic!("{items:?}") };
|
||||||
|
assert_eq!(body, "Body2");
|
||||||
|
assert!(point.dist(Vec3::new(40.0, 0.0, 25.0)) < 1e-6, "{point:?}");
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ pub enum Mo {
|
|||||||
tracks: Vec<Track>,
|
tracks: Vec<Track>,
|
||||||
step: f64,
|
step: f64,
|
||||||
playing: bool,
|
playing: bool,
|
||||||
|
/// Playback starts over at the end (else it stops there).
|
||||||
|
looping: bool,
|
||||||
|
/// Playback speed (index into `SPEEDS`).
|
||||||
|
speed: usize,
|
||||||
/// The step the shown pose is for (and its revision).
|
/// The step the shown pose is for (and its revision).
|
||||||
shown: Option<(u64, u64)>,
|
shown: Option<(u64, u64)>,
|
||||||
saved: Option<BTreeMap<u64, Mat>>,
|
saved: Option<BTreeMap<u64, Mat>>,
|
||||||
@@ -101,6 +105,8 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
|||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
step: 0.0,
|
step: 0.0,
|
||||||
playing: false,
|
playing: false,
|
||||||
|
looping: true,
|
||||||
|
speed: 2,
|
||||||
shown: None,
|
shown: None,
|
||||||
saved: None,
|
saved: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -116,6 +122,73 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
|||||||
|
|
||||||
// ---- pure mappings (tested) ----
|
// ---- pure mappings (tested) ----
|
||||||
|
|
||||||
|
/// Playback speeds offered (times `STEPS_PER_S`).
|
||||||
|
pub const SPEEDS: [f64; 5] = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||||
|
/// Steps played per second at 1×.
|
||||||
|
const STEPS_PER_S: f64 = 24.0;
|
||||||
|
|
||||||
|
/// The next playback step: `by` steps on; at the end it starts over (looping) or stops there.
|
||||||
|
/// Returns the step and whether it still plays.
|
||||||
|
pub fn advance(step: f64, by: f64, total: u32, looping: bool) -> (f64, bool) {
|
||||||
|
let last = f64::from(total);
|
||||||
|
let next = step + by.max(0.0);
|
||||||
|
if next <= last {
|
||||||
|
(next, true)
|
||||||
|
} else if looping && last > 0.0 {
|
||||||
|
(next % (last + 1.0), true)
|
||||||
|
} else {
|
||||||
|
(last, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Transport {
|
||||||
|
Start,
|
||||||
|
Back,
|
||||||
|
Play,
|
||||||
|
Stop,
|
||||||
|
Forward,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A media button drawn in code: bars and triangles.
|
||||||
|
fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response {
|
||||||
|
let t = Tokens::get();
|
||||||
|
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 22.0), Sense::click());
|
||||||
|
let fill = if resp.hovered() { t.hover } else { t.field };
|
||||||
|
ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||||
|
let c = r.center();
|
||||||
|
let ink = if matches!(what, Transport::Play | Transport::Stop) { t.accent } else { t.icon };
|
||||||
|
let p = ui.painter();
|
||||||
|
let tri = |dir: f32, x: f32| {
|
||||||
|
egui::Shape::convex_polygon(vec![pos2(x - 4.0 * dir, c.y - 5.0), pos2(x + 4.0 * dir, c.y), pos2(x - 4.0 * dir, c.y + 5.0)], ink, Stroke::NONE)
|
||||||
|
};
|
||||||
|
let bar = |x: f32| egui::Shape::rect_filled(egui::Rect::from_center_size(pos2(x, c.y), vec2(2.0, 10.0)), 0.0, ink);
|
||||||
|
match what {
|
||||||
|
Transport::Play => drop(p.add(tri(1.0, c.x + 1.0))),
|
||||||
|
Transport::Stop => drop(p.rect_filled(egui::Rect::from_center_size(c, vec2(9.0, 9.0)), 1.0, ink)),
|
||||||
|
Transport::Back => {
|
||||||
|
p.add(bar(c.x + 4.0));
|
||||||
|
p.add(tri(-1.0, c.x - 1.0));
|
||||||
|
}
|
||||||
|
Transport::Forward => {
|
||||||
|
p.add(bar(c.x - 4.0));
|
||||||
|
p.add(tri(1.0, c.x + 1.0));
|
||||||
|
}
|
||||||
|
Transport::Start => {
|
||||||
|
p.add(bar(c.x - 6.0));
|
||||||
|
p.add(tri(-1.0, c.x - 0.5));
|
||||||
|
p.add(tri(-1.0, c.x + 5.0));
|
||||||
|
}
|
||||||
|
Transport::End => {
|
||||||
|
p.add(tri(1.0, c.x - 5.0));
|
||||||
|
p.add(tri(1.0, c.x + 0.5));
|
||||||
|
p.add(bar(c.x + 6.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.on_hover_text(tip)
|
||||||
|
}
|
||||||
|
|
||||||
/// The study command's parameters.
|
/// The study command's parameters.
|
||||||
pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result<Value, String> {
|
pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result<Value, String> {
|
||||||
let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?;
|
let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?;
|
||||||
@@ -321,7 +394,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
|||||||
ui.end_row();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Mo::MotionStudy { name, study, steps, tracks, step, playing, shown, saved, error } => {
|
Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error } => {
|
||||||
row_label(ui, "Name");
|
row_label(ui, "Name");
|
||||||
ui.text_edit_singleline(name);
|
ui.text_edit_singleline(name);
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
@@ -336,19 +409,54 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
|||||||
ui.end_row();
|
ui.end_row();
|
||||||
return enter;
|
return enter;
|
||||||
}
|
}
|
||||||
// Playback.
|
// Playback: to start, step back, play/stop, step on, to end; loop and speed.
|
||||||
row_label(ui, "Step");
|
row_label(ui, "Playback");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button(if *playing { "■" } else { "▶" }).on_hover_text(if *playing { "Stop" } else { "Play" }).clicked() {
|
let last = f64::from(total);
|
||||||
|
if transport(ui, Transport::Start, "To the start").clicked() {
|
||||||
|
*step = 0.0;
|
||||||
|
*playing = false;
|
||||||
|
}
|
||||||
|
if transport(ui, Transport::Back, "Step back").clicked() {
|
||||||
|
*step = (step.round() - 1.0).max(0.0);
|
||||||
|
*playing = false;
|
||||||
|
}
|
||||||
|
let (what, tip) = if *playing { (Transport::Stop, "Stop") } else { (Transport::Play, "Play") };
|
||||||
|
if transport(ui, what, tip).clicked() {
|
||||||
|
// Play from the start again when at the end.
|
||||||
|
if !*playing && step.round() >= last {
|
||||||
|
*step = 0.0;
|
||||||
|
}
|
||||||
*playing = !*playing;
|
*playing = !*playing;
|
||||||
}
|
}
|
||||||
ui.spacing_mut().slider_width = 220.0;
|
if transport(ui, Transport::Forward, "Step on").clicked() {
|
||||||
ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer());
|
*step = (step.round() + 1.0).min(last);
|
||||||
|
*playing = false;
|
||||||
|
}
|
||||||
|
if transport(ui, Transport::End, "To the end").clicked() {
|
||||||
|
*step = last;
|
||||||
|
*playing = false;
|
||||||
|
}
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.checkbox(looping, "Loop");
|
||||||
|
let names: Vec<String> = SPEEDS.iter().map(|x| format!("{x}×")).collect();
|
||||||
|
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||||
|
combo(ui, "ms_speed", &refs, speed);
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
row_label(ui, "Step");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().slider_width = 260.0;
|
||||||
|
if ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer().show_value(false)).dragged() {
|
||||||
|
*playing = false;
|
||||||
|
}
|
||||||
|
ui.label(RichText::new(format!("{} / {total}", step.round() as u32)).color(t.text_dim));
|
||||||
});
|
});
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
if *playing {
|
if *playing {
|
||||||
let dt = ui.input(|i| i.stable_dt).min(0.1);
|
let dt = f64::from(ui.input(|i| i.stable_dt).min(0.1));
|
||||||
*step = (*step + f64::from(dt) * 24.0) % (f64::from(total) + 1.0);
|
let fps = STEPS_PER_S * SPEEDS.get(*speed).copied().unwrap_or(1.0);
|
||||||
|
(*step, *playing) = advance(*step, dt * fps, total, *looping);
|
||||||
ui.ctx().request_repaint();
|
ui.ctx().request_repaint();
|
||||||
}
|
}
|
||||||
// Tracks: a row per keyed joint value, its keys on a strip under the steps.
|
// Tracks: a row per keyed joint value, its keys on a strip under the steps.
|
||||||
@@ -667,6 +775,15 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result<Vec<(Stri
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Playback runs on, starts over at the end when looping, else stops on the last step.
|
||||||
|
#[test]
|
||||||
|
fn playback_advances_loops_and_stops() {
|
||||||
|
assert_eq!(advance(3.0, 2.0, 10, true), (5.0, true));
|
||||||
|
assert_eq!(advance(9.5, 2.0, 10, true), (0.5, true));
|
||||||
|
assert_eq!(advance(9.5, 2.0, 10, false), (10.0, false));
|
||||||
|
assert_eq!(advance(4.0, -3.0, 10, false), (4.0, true));
|
||||||
|
}
|
||||||
|
|
||||||
/// What a dialog shows goes away when it closes; an exploded view applied with OK stays.
|
/// What a dialog shows goes away when it closes; an exploded view applied with OK stays.
|
||||||
#[test]
|
#[test]
|
||||||
fn shown_poses_are_put_back() {
|
fn shown_poses_are_put_back() {
|
||||||
@@ -688,6 +805,8 @@ mod tests {
|
|||||||
tracks: vec![],
|
tracks: vec![],
|
||||||
step: 0.0,
|
step: 0.0,
|
||||||
playing: false,
|
playing: false,
|
||||||
|
looping: true,
|
||||||
|
speed: 2,
|
||||||
shown: None,
|
shown: None,
|
||||||
saved,
|
saved,
|
||||||
error: None,
|
error: None,
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
|||||||
}
|
}
|
||||||
let tk = Tokens::get();
|
let tk = Tokens::get();
|
||||||
let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id));
|
let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id));
|
||||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p)).and_then(hit);
|
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(hit);
|
||||||
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
|
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
|
||||||
let font = FontId::proportional(12.0);
|
let font = FontId::proportional(12.0);
|
||||||
let centres = chain_centres(sk);
|
let centres = chain_centres(sk);
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
|||||||
|
|
||||||
/// Make tab `i` the active design.
|
/// Make tab `i` the active design.
|
||||||
pub fn switch(app: &mut SolveApp, i: usize) {
|
pub fn switch(app: &mut SolveApp, i: usize) {
|
||||||
|
app.home.open = false;
|
||||||
if i == app.docs.active || i >= app.docs.slots.len() {
|
if i == app.docs.active || i >= app.docs.slots.len() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -293,6 +294,25 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
|||||||
/// + for a new design.
|
/// + for a new design.
|
||||||
pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
|
let home = Rect::from_min_size(pos2(x0, r.top() + 5.0), vec2(66.0, r.height() - 5.0));
|
||||||
|
let response = ui.interact(home, ui.id().with("home_tab"), Sense::click());
|
||||||
|
ui.painter().rect_filled(
|
||||||
|
home,
|
||||||
|
5.0,
|
||||||
|
if app.home.open {
|
||||||
|
t.toolbar
|
||||||
|
} else if response.hovered() {
|
||||||
|
t.hover
|
||||||
|
} else {
|
||||||
|
Color32::from_white_alpha(10)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text });
|
||||||
|
crate::scenario::publish_handle("tab:home", home.center());
|
||||||
|
if response.clicked() {
|
||||||
|
app.home.show(app.session.revision);
|
||||||
|
}
|
||||||
|
let x0 = x0 + 70.0;
|
||||||
let info = tabs_info(app);
|
let info = tabs_info(app);
|
||||||
let n = info.len();
|
let n = info.len();
|
||||||
let (first, shown, w) = layout(n, app.docs.active, x1 - x0);
|
let (first, shown, w) = layout(n, app.docs.active, x1 - x0);
|
||||||
@@ -301,8 +321,9 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
|||||||
let mut shut: Option<usize> = None;
|
let mut shut: Option<usize> = None;
|
||||||
for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) {
|
for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) {
|
||||||
let tr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(w - 2.0, r.height() - 5.0));
|
let tr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(w - 2.0, r.height() - 5.0));
|
||||||
let active = i == app.docs.active || app.docs.slots.is_empty();
|
let active = !app.home.open && (i == app.docs.active || app.docs.slots.is_empty());
|
||||||
let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag());
|
let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag());
|
||||||
|
crate::scenario::publish_handle(&format!("tab:design:{i}"), tr.center());
|
||||||
// The tabs are part of the title bar: dragging one moves the window, a double click
|
// The tabs are part of the title bar: dragging one moves the window, a double click
|
||||||
// maximizes it.
|
// maximizes it.
|
||||||
if app.custom_titlebar || app.integrated_titlebar {
|
if app.custom_titlebar || app.integrated_titlebar {
|
||||||
@@ -389,7 +410,6 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
|||||||
ui.painter().line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], s);
|
ui.painter().line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], s);
|
||||||
if presp.on_hover_text("New Design (Ctrl+N)").clicked() {
|
if presp.on_hover_text("New Design (Ctrl+N)").clicked() {
|
||||||
new_design(app);
|
new_design(app);
|
||||||
app.home.show(app.session.revision);
|
|
||||||
}
|
}
|
||||||
if let Some(i) = shut {
|
if let Some(i) = shut {
|
||||||
close(app, i, false);
|
close(app, i, false);
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I
|
|||||||
}
|
}
|
||||||
let state: Option<State> = ui.data(|d| d.get_temp(id));
|
let state: Option<State> = ui.data(|d| d.get_temp(id));
|
||||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||||
let hovered = if state.is_none() { pointer.filter(|p| proj.rect.contains(*p)).and_then(|p| hit(&sh, p)) } else { None };
|
let hovered = if state.is_none() { pointer.filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(|p| hit(&sh, p)) } else { None };
|
||||||
let active = state.map(|s| s.handle).or(hovered);
|
let active = state.map(|s| s.handle).or(hovered);
|
||||||
let hot = |h: Handle| active == Some(h);
|
let hot = |h: Handle| active == Some(h);
|
||||||
let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { axis_color(i) };
|
let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { axis_color(i) };
|
||||||
|
|||||||
+53
-19
@@ -1,6 +1,6 @@
|
|||||||
//! The start page: New Design, Open, the recent designs and the built-in samples, each with a
|
//! The start page: New Design, Open, the recent designs and the built-in samples, each with a
|
||||||
//! picture of the design rendered from it. Shown on launch without a file and from the house
|
//! picture of the design rendered from it. Shown on launch without a file and from the house
|
||||||
//! button; it closes when a design is opened or started, or the design changes.
|
//! button or the first document tab; selecting a design returns to its view.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -24,8 +24,7 @@ pub struct HomeState {
|
|||||||
pub open: bool,
|
pub open: bool,
|
||||||
/// Recent design paths, newest first (kept in the preferences).
|
/// Recent design paths, newest first (kept in the preferences).
|
||||||
pub recent: Vec<String>,
|
pub recent: Vec<String>,
|
||||||
/// The session revision when the page opened (a change closes it).
|
pub opened_at: std::collections::BTreeMap<String, u64>,
|
||||||
rev: u64,
|
|
||||||
thumbs: HashMap<String, Option<egui::TextureHandle>>,
|
thumbs: HashMap<String, Option<egui::TextureHandle>>,
|
||||||
samples: HashMap<usize, Session>,
|
samples: HashMap<usize, Session>,
|
||||||
/// Keys asked for and not made yet.
|
/// Keys asked for and not made yet.
|
||||||
@@ -37,9 +36,8 @@ pub struct HomeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HomeState {
|
impl HomeState {
|
||||||
pub fn show(&mut self, rev: u64) {
|
pub fn show(&mut self, _rev: u64) {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
self.rev = rev;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put a design at the top of the recent list.
|
/// Put a design at the top of the recent list.
|
||||||
@@ -47,6 +45,8 @@ impl HomeState {
|
|||||||
self.recent.retain(|p| p != path);
|
self.recent.retain(|p| p != path);
|
||||||
self.recent.insert(0, path.to_string());
|
self.recent.insert(0, path.to_string());
|
||||||
self.recent.truncate(MAX_RECENT);
|
self.recent.truncate(MAX_RECENT);
|
||||||
|
self.opened_at.insert(path.to_string(), web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));
|
||||||
|
self.opened_at.retain(|p, _| self.recent.contains(p));
|
||||||
self.thumbs.remove(path);
|
self.thumbs.remove(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,9 +179,8 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_recent(app: &mut SolveApp, path: &str) {
|
pub fn open_recent(app: &mut SolveApp, path: &str) {
|
||||||
if !solvecraft_engine::io::vfs::exists(path) {
|
if !solvecraft_engine::io::vfs::exists(path) {
|
||||||
app.home.recent.retain(|p| p != path);
|
|
||||||
app.set_status(format!("{path} is no longer there"), true);
|
app.set_status(format!("{path} is no longer there"), true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -191,10 +190,6 @@ fn open_recent(app: &mut SolveApp, path: &str) {
|
|||||||
|
|
||||||
/// The page itself (in place of the toolbar, browser and viewport).
|
/// The page itself (in place of the toolbar, browser and viewport).
|
||||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||||
if app.session.revision != app.home.rev {
|
|
||||||
app.home.open = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
poll(app, ui.ctx());
|
poll(app, ui.ctx());
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let samples = solvecraft_engine::sample::samples();
|
let samples = solvecraft_engine::sample::samples();
|
||||||
@@ -261,6 +256,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(24.0);
|
ui.add_space(24.0);
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.hyperlink_to("Documentation", crate::help::DOCS_URL);
|
||||||
|
ui.hyperlink_to("Report an Issue", "https://github.com/bherbruck/solvecraft/issues/new");
|
||||||
|
ui.hyperlink_to("What's new", "https://github.com/bherbruck/solvecraft/releases/latest");
|
||||||
|
if ui.link("Keyboard Shortcuts").clicked() {
|
||||||
|
app.keymap.open = true;
|
||||||
|
app.keymap.read_only = true;
|
||||||
|
}
|
||||||
|
if ui.link("Preferences").clicked() {
|
||||||
|
crate::prefs::open_at(app, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(16.0);
|
||||||
section(ui, "Recent");
|
section(ui, "Recent");
|
||||||
if recent.is_empty() {
|
if recent.is_empty() {
|
||||||
ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim));
|
ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim));
|
||||||
@@ -269,20 +277,46 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let Some(p) = recent.get(i) else { return };
|
let Some(p) = recent.get(i) else { return };
|
||||||
let path = std::path::Path::new(p);
|
let path = std::path::Path::new(p);
|
||||||
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
|
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
|
||||||
let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
|
||||||
let missing = !solvecraft_engine::io::vfs::exists(p);
|
let missing = !solvecraft_engine::io::vfs::exists(p);
|
||||||
let sub = if missing {
|
let mut sub = if missing {
|
||||||
"missing".to_string()
|
"missing".to_string()
|
||||||
} else if p.starts_with("/opfs/") {
|
} else if p.starts_with("/opfs/") {
|
||||||
// The web build keeps designs in the browser's own storage.
|
// The web build keeps designs in the browser's own storage.
|
||||||
"in this browser".to_string()
|
"in this browser".to_string()
|
||||||
} else {
|
} else {
|
||||||
folder
|
p.clone()
|
||||||
};
|
};
|
||||||
if card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
|
if let Some(time) = app.home.opened_at.get(p) {
|
||||||
.on_hover_text(p.as_str())
|
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time);
|
||||||
.clicked()
|
let seconds = now.saturating_sub(*time);
|
||||||
{
|
let ago = if seconds < 60 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if seconds < 3600 {
|
||||||
|
format!("{} min ago", seconds / 60)
|
||||||
|
} else if seconds < 86400 {
|
||||||
|
format!("{} h ago", seconds / 3600)
|
||||||
|
} else {
|
||||||
|
format!("{} days ago", seconds / 86400)
|
||||||
|
};
|
||||||
|
sub.push_str(&format!("\nOpened {ago}"));
|
||||||
|
}
|
||||||
|
let response = card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
|
||||||
|
.on_hover_text(p.as_str());
|
||||||
|
crate::scenario::publish_handle(&format!("home:recent:{i}"), response.rect.center());
|
||||||
|
response.context_menu(|ui| {
|
||||||
|
if ui.button("Remove from list").clicked() {
|
||||||
|
app.home.recent.retain(|x| x != p);
|
||||||
|
app.home.opened_at.remove(p);
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
if !cfg!(target_arch = "wasm32") && ui.button("Show in folder").clicked() {
|
||||||
|
if let Some(show) = &app.services.show_in_folder {
|
||||||
|
show(p);
|
||||||
|
}
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if response.clicked() {
|
||||||
let p = p.clone();
|
let p = p.clone();
|
||||||
action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p)));
|
action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p)));
|
||||||
}
|
}
|
||||||
@@ -340,7 +374,7 @@ enum Pic<'a> {
|
|||||||
/// A design card: its picture (a spinner while it renders), name and a line under it.
|
/// A design card: its picture (a spinner while it renders), name and a line under it.
|
||||||
fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response {
|
fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response {
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0);
|
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 80.0);
|
||||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||||
let hover = resp.hovered();
|
let hover = resp.hovered();
|
||||||
ui.painter().rect(
|
ui.painter().rect(
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ impl Default for UiState {
|
|||||||
pub struct Services {
|
pub struct Services {
|
||||||
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
|
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
|
||||||
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
|
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
|
||||||
|
pub show_in_folder: Option<Box<dyn Fn(&str)>>,
|
||||||
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
|
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
|
||||||
pub graphics_path: Option<String>,
|
pub graphics_path: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -304,6 +305,7 @@ impl SolveApp {
|
|||||||
"browser_collapsed": self.tree.collapsed,
|
"browser_collapsed": self.tree.collapsed,
|
||||||
"browser_expanded": self.tree.expanded,
|
"browser_expanded": self.tree.expanded,
|
||||||
"recent": self.home.recent,
|
"recent": self.home.recent,
|
||||||
|
"recent_opened_at": self.home.opened_at,
|
||||||
"shortcut_box": self.sbox.prefs(),
|
"shortcut_box": self.sbox.prefs(),
|
||||||
"shortcuts": self.keymap.prefs(),
|
"shortcuts": self.keymap.prefs(),
|
||||||
"toolbar": self.toolbar_custom.prefs(),
|
"toolbar": self.toolbar_custom.prefs(),
|
||||||
@@ -319,6 +321,7 @@ impl SolveApp {
|
|||||||
pub fn load_prefs(&mut self, prefs: &str) {
|
pub fn load_prefs(&mut self, prefs: &str) {
|
||||||
let Ok(v) = serde_json::from_str::<Value>(prefs) else { return };
|
let Ok(v) = serde_json::from_str::<Value>(prefs) else { return };
|
||||||
let flag = |k: &str| v.get(k).and_then(Value::as_bool);
|
let flag = |k: &str| v.get(k).and_then(Value::as_bool);
|
||||||
|
self.home.opened_at = v.get("recent_opened_at").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or_default();
|
||||||
if let Some(d) = flag("dark") {
|
if let Some(d) = flag("dark") {
|
||||||
self.ui.dark = d;
|
self.ui.dark = d;
|
||||||
}
|
}
|
||||||
@@ -430,6 +433,12 @@ impl SolveApp {
|
|||||||
}
|
}
|
||||||
self.tool = None;
|
self.tool = None;
|
||||||
self.home.open = false;
|
self.home.open = false;
|
||||||
|
if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") {
|
||||||
|
if let Some(path) = self.services.pick_open.as_ref().and_then(|f| f()) {
|
||||||
|
let _ = self.run(id, json!({"path": path}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if matches!(id, "file.insert_step" | "file.insert_mesh") {
|
if matches!(id, "file.insert_step" | "file.insert_mesh") {
|
||||||
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
|
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
|
||||||
self.insert_path(&p);
|
self.insert_path(&p);
|
||||||
@@ -480,7 +489,20 @@ impl SolveApp {
|
|||||||
{
|
{
|
||||||
self.last_command = Some((spec.id.to_string(), spec.label.to_string()));
|
self.last_command = Some((spec.id.to_string(), spec.label.to_string()));
|
||||||
}
|
}
|
||||||
if let Some(t) = tools::Tool::for_command(id) {
|
if let Some(mut t) = tools::Tool::for_command(id) {
|
||||||
|
if id == "sketch.dimension" {
|
||||||
|
t.picks = self
|
||||||
|
.session
|
||||||
|
.selection
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| match s {
|
||||||
|
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.take(2)
|
||||||
|
.collect();
|
||||||
|
let _ = self.session.execute("select.clear", &json!({}));
|
||||||
|
}
|
||||||
self.tool = Some(t);
|
self.tool = Some(t);
|
||||||
self.set_status(tools::hint(id), false);
|
self.set_status(tools::hint(id), false);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -232,8 +232,10 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() {
|
|||||||
app.run("edit.undo", json!({})).unwrap();
|
app.run("edit.undo", json!({})).unwrap();
|
||||||
assert_eq!(app.session.doc.features.len(), n);
|
assert_eq!(app.session.doc.features.len(), n);
|
||||||
// A face its neighbours can't close (a side of the plate): refused, nothing changes.
|
// A face its neighbours can't close (a side of the plate): refused, nothing changes.
|
||||||
let b0 = app.session.model.state().bodies[0].name.clone();
|
let body = &app.session.model.state().bodies[0];
|
||||||
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap();
|
let face = body.body.faces(0.01).unwrap().into_iter().find(|f| f.plane_normal.is_some_and(|n| n.x < -0.99)).unwrap();
|
||||||
|
let b0 = body.name.clone();
|
||||||
|
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": face.index, "point": face.centroid}]})).unwrap();
|
||||||
crate::delete::delete_selection(&mut app);
|
crate::delete::delete_selection(&mut app);
|
||||||
assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Delete Face")), "{:?}", app.status);
|
assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Delete Face")), "{:?}", app.status);
|
||||||
assert_eq!(app.session.doc.features.len(), n);
|
assert_eq!(app.session.doc.features.len(), n);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ pub fn gpu_images(app: &SolveApp) -> Vec<GpuImage> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|c| c.visible && c.opacity > 0.0)
|
.filter(|c| c.visible && c.opacity > 0.0)
|
||||||
.filter_map(|c| {
|
.filter_map(|c| {
|
||||||
let plane = doc.resolve_plane(&vals, &c.plane, 0).ok()?;
|
let plane = doc.canvas_plane(&vals, c).ok()?;
|
||||||
let (_, version, size, rgba) = pixels(c)?;
|
let (_, version, size, rgba) = pixels(c)?;
|
||||||
let corners = c.world_corners(&plane).map(|p| [p.x as f32, p.y as f32, p.z as f32]);
|
let corners = c.world_corners(&plane).map(|p| [p.x as f32, p.y as f32, p.z as f32]);
|
||||||
Some(GpuImage { id: c.id, version, size, rgba, corners, opacity: c.opacity.clamp(0.0, 1.0) as f32 })
|
Some(GpuImage { id: c.id, version, size, rgba, corners, opacity: c.opacity.clamp(0.0, 1.0) as f32 })
|
||||||
@@ -105,7 +105,7 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj)
|
|||||||
}
|
}
|
||||||
let (vals, _) = doc.param_values();
|
let (vals, _) = doc.param_values();
|
||||||
for c in doc.canvases.iter().filter(|c| c.visible) {
|
for c in doc.canvases.iter().filter(|c| c.visible) {
|
||||||
let Ok(plane) = doc.resolve_plane(&vals, &c.plane, 0) else { continue };
|
let Ok(plane) = doc.canvas_plane(&vals, c) else { continue };
|
||||||
let Some(tex) = texture(ui.ctx(), c) else { continue };
|
let Some(tex) = texture(ui.ctx(), c) else { continue };
|
||||||
let [bl, br, _, tl] = c.corners();
|
let [bl, br, _, tl] = c.corners();
|
||||||
let (u, v) = (br - bl, tl - bl);
|
let (u, v) = (br - bl, tl - bl);
|
||||||
|
|||||||
@@ -245,6 +245,15 @@ impl Harness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn call(&mut self, method: &str, params: Value) -> Value {
|
pub fn call(&mut self, method: &str, params: Value) -> Value {
|
||||||
|
if method == "ui.resize"
|
||||||
|
&& let (Some(w), Some(h)) = (params["width"].as_f64(), params["height"].as_f64())
|
||||||
|
&& w.is_finite()
|
||||||
|
&& h.is_finite()
|
||||||
|
&& w > 0.0
|
||||||
|
&& h > 0.0
|
||||||
|
{
|
||||||
|
self.size = egui::vec2(w as f32, h as f32);
|
||||||
|
}
|
||||||
let (req, _rx) = ControlRequest::new(method, params);
|
let (req, _rx) = ControlRequest::new(method, params);
|
||||||
let out = match handle(&mut self.app, &self.ctx, &req) {
|
let out = match handle(&mut self.app, &self.ctx, &req) {
|
||||||
Outcome::Done(v) => v,
|
Outcome::Done(v) => v,
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ use solvecraft_engine::geom::Vec3;
|
|||||||
|
|
||||||
use crate::viewport::Hit;
|
use crate::viewport::Hit;
|
||||||
|
|
||||||
|
/// Model picks retain an on-surface point, but their identity is the body and topology index.
|
||||||
|
pub(crate) fn same_item(a: &Sel, b: &Sel) -> bool {
|
||||||
|
match (a, b) {
|
||||||
|
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
|
||||||
|
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
|
||||||
|
_ => a == b,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a selection input accepts (bit set).
|
/// What a selection input accepts (bit set).
|
||||||
pub type Accept = u16;
|
pub type Accept = u16;
|
||||||
pub const PROFILES: Accept = 1;
|
pub const PROFILES: Accept = 1;
|
||||||
@@ -73,11 +82,11 @@ impl SelInput {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A pick of something already in (or a chain fully in) takes it out.
|
// A pick of something already in (or a chain fully in) takes it out.
|
||||||
if picked.iter().all(|p| self.items.contains(p)) {
|
if picked.iter().all(|p| self.items.iter().any(|x| same_item(x, p))) {
|
||||||
self.items.retain(|x| !picked.contains(x));
|
self.items.retain(|x| !picked.iter().any(|p| same_item(x, p)));
|
||||||
} else {
|
} else {
|
||||||
for p in picked {
|
for p in picked {
|
||||||
if !self.items.contains(&p) {
|
if !self.items.iter().any(|x| same_item(x, &p)) {
|
||||||
self.items.push(p);
|
self.items.push(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1090,7 +1090,7 @@ fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
|||||||
/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box.
|
/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box.
|
||||||
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||||
SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift)));
|
SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift)));
|
||||||
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()));
|
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)));
|
||||||
snap_hint(app, painter, proj);
|
snap_hint(app, painter, proj);
|
||||||
combs(app, painter, proj);
|
combs(app, painter, proj);
|
||||||
if DEFERRED.with(|d| d.replace(false)) {
|
if DEFERRED.with(|d| d.replace(false)) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0));
|
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0));
|
||||||
x += 30.0;
|
x += 30.0;
|
||||||
let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click());
|
let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click());
|
||||||
|
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
|
||||||
if resp.hovered() {
|
if resp.hovered() {
|
||||||
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
|
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
|
||||||
}
|
}
|
||||||
@@ -148,8 +149,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||||
ui.set_min_width(190.0);
|
ui.set_min_width(190.0);
|
||||||
|
ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
|
||||||
|
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE;
|
||||||
|
ui.visuals_mut().widgets.hovered.weak_bg_fill = Tokens::get().hover;
|
||||||
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
|
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
|
||||||
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
|
ui.add(egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked()
|
||||||
};
|
};
|
||||||
if item(ui, "New Design", "Ctrl+N") {
|
if item(ui, "New Design", "Ctrl+N") {
|
||||||
crate::documents::new_design(app);
|
crate::documents::new_design(app);
|
||||||
@@ -161,6 +165,24 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
}
|
}
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
|
ui.menu_button("Open Recent", |ui| {
|
||||||
|
for path in app.home.recent.clone() {
|
||||||
|
let missing = !solvecraft_engine::io::vfs::exists(&path);
|
||||||
|
let label = format!("{path}{}", if missing { " (missing)" } else { "" });
|
||||||
|
if ui.button(label).clicked() {
|
||||||
|
crate::home::open_recent(app, &path);
|
||||||
|
close = true;
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Clear Recent").clicked() {
|
||||||
|
app.home.recent.clear();
|
||||||
|
app.home.opened_at.clear();
|
||||||
|
close = true;
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
if item(ui, "Insert STEP…", "") {
|
if item(ui, "Insert STEP…", "") {
|
||||||
app.start("file.insert_step");
|
app.start("file.insert_step");
|
||||||
close = true;
|
close = true;
|
||||||
@@ -308,11 +330,16 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let top = r.top() + 26.0;
|
let top = r.top() + 26.0;
|
||||||
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
|
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
|
||||||
let mut menu: Option<(egui::Pos2, &'static str)> = None;
|
let mut menu: Option<(egui::Pos2, &'static str)> = None;
|
||||||
|
let mut overflow = Vec::new();
|
||||||
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
|
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
|
||||||
let panel: &&str = &panel;
|
let panel: &&str = &panel;
|
||||||
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
|
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
|
||||||
let n = shown.len().max(1);
|
let n = shown.len().max(1);
|
||||||
let width = (n as f32 * 40.0).max(64.0) + 8.0;
|
let width = (n as f32 * 40.0).max(64.0) + 8.0;
|
||||||
|
if !overflow.is_empty() || px + width > r.right() - 40.0 {
|
||||||
|
overflow.push((*panel, cmds));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
|
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
|
||||||
// Dropped on the panel's free space: the button goes to its end.
|
// Dropped on the panel's free space: the button goes to its end.
|
||||||
let free = Rect::from_min_size(
|
let free = Rect::from_min_size(
|
||||||
@@ -433,6 +460,31 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border));
|
painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border));
|
||||||
px += 2.0;
|
px += 2.0;
|
||||||
}
|
}
|
||||||
|
if !overflow.is_empty() {
|
||||||
|
let at = Rect::from_min_size(pos2(px.min(r.right() - 40.0), top + 4.0), vec2(38.0, 52.0));
|
||||||
|
ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| {
|
||||||
|
let response = ui.menu_button("»", |ui| {
|
||||||
|
for (panel, cmds) in &overflow {
|
||||||
|
ui.menu_button(*panel, |ui| {
|
||||||
|
if *panel == "SELECT" {
|
||||||
|
selection_filter(app, ui);
|
||||||
|
}
|
||||||
|
for c in cmds {
|
||||||
|
let info = c.info(&app.session);
|
||||||
|
let response = ui.add_enabled(info.enabled, egui::Button::new(c.label).shortcut_text(c.shortcut.unwrap_or("")));
|
||||||
|
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), response.rect.center());
|
||||||
|
if response.clicked() {
|
||||||
|
app.start(c.id);
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
crate::scenario::publish_handle("toolbar:overflow", response.response.rect.center());
|
||||||
|
response.response.on_hover_text("More toolbar panels");
|
||||||
|
});
|
||||||
|
}
|
||||||
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
|
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
|
||||||
if let Some((id, panel, before)) = drop {
|
if let Some((id, panel, before)) = drop {
|
||||||
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
|
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
|
||||||
|
|||||||
+13
-23
@@ -80,7 +80,7 @@ pub fn hint(id: &str) -> String {
|
|||||||
"sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(),
|
"sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(),
|
||||||
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
|
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
|
||||||
"sketch.arc.center_point" => "Arc: click centre, start, then end (counter-clockwise)".into(),
|
"sketch.arc.center_point" => "Arc: click centre, start, then end (counter-clockwise)".into(),
|
||||||
"sketch.dimension" => "Dimension: pick a line, circle or arc (or two points/lines)".into(),
|
"sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(),
|
||||||
"sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(),
|
"sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(),
|
||||||
_ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(),
|
_ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(),
|
||||||
_ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()),
|
_ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()),
|
||||||
@@ -142,31 +142,21 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
|||||||
Kind::Dimension => {
|
Kind::Dimension => {
|
||||||
let hits = pick(app, proj, pos);
|
let hits = pick(app, proj, pos);
|
||||||
let hit = hits.iter().find_map(|h| match h {
|
let hit = hits.iter().find_map(|h| match h {
|
||||||
Hit::SketchPoint { id, .. } => Some((id.clone(), true)),
|
Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()),
|
||||||
Hit::SketchCurve { id, .. } => Some((id.clone(), false)),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
if let Some((id, is_point)) = hit {
|
if let Some(id) = hit {
|
||||||
tool.picks.push(id.clone());
|
if !tool.picks.contains(&id) && tool.picks.len() < 2 {
|
||||||
let st = app.session.world_state();
|
tool.picks.push(id);
|
||||||
let is_line = app
|
|
||||||
.session
|
|
||||||
.active_sketch
|
|
||||||
.and_then(|s| st.sketch(s))
|
|
||||||
.and_then(|ss| ss.sketch.curve_index(&id).and_then(|c| ss.sketch.curves.get(c).cloned()))
|
|
||||||
.map(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. }));
|
|
||||||
let ready = (tool.picks.len() == 1 && !is_point && is_line != Some(true)) || tool.picks.len() >= 2;
|
|
||||||
let single_line = tool.picks.len() == 1 && is_line == Some(true);
|
|
||||||
if ready || single_line {
|
|
||||||
// A single line gets its length right away; a second pick (line or point) replaces it.
|
|
||||||
if let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks}))
|
|
||||||
&& let Some(p) = v["param"].as_str()
|
|
||||||
{
|
|
||||||
// The value is edited in place, selected so typing replaces it.
|
|
||||||
crate::dim_view::edit_param(app, p);
|
|
||||||
}
|
|
||||||
tool.picks.clear();
|
|
||||||
}
|
}
|
||||||
|
} else if !tool.picks.is_empty()
|
||||||
|
&& let Some((at, _)) = sketch_point_at(app, proj, pos)
|
||||||
|
&& let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks, "text_at": [at.x, at.y]}))
|
||||||
|
{
|
||||||
|
if let Some(p) = v["param"].as_str() {
|
||||||
|
crate::dim_view::edit_param(app, p);
|
||||||
|
}
|
||||||
|
tool.picks.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+369
-19
@@ -1091,7 +1091,14 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
|||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let mut sc = GpuScene::default();
|
let mut sc = GpuScene::default();
|
||||||
let st = app.session.world_state();
|
let st = app.session.world_state();
|
||||||
let sel = app.highlighted();
|
let mut sel = Vec::new();
|
||||||
|
for item in app.highlighted() {
|
||||||
|
for piece in grouped_selection(&app.session, &item) {
|
||||||
|
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
|
||||||
|
sel.push(piece);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let hover = app.viewport.hover.as_ref();
|
let hover = app.viewport.hover.as_ref();
|
||||||
let size = origin_size(app.cam.half_height());
|
let size = origin_size(app.cam.half_height());
|
||||||
// Origin planes and construction planes.
|
// Origin planes and construction planes.
|
||||||
@@ -1242,9 +1249,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
|||||||
if let Some(b) = st.body(body) {
|
if let Some(b) = st.body(body) {
|
||||||
let k = t.hover_face_lift;
|
let k = t.hover_face_lift;
|
||||||
let c = body_rgb(app, body);
|
let c = body_rgb(app, body);
|
||||||
face_tris(&mut sc, &b.mesh(), *index, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
|
||||||
let m = b.mesh();
|
let m = b.mesh();
|
||||||
for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) {
|
let patch = m.surface_patch(*index);
|
||||||
|
for &f in &patch {
|
||||||
|
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||||||
|
}
|
||||||
|
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
|
||||||
if let Some(p) = m.edges.get(e)
|
if let Some(p) = m.edges.get(e)
|
||||||
&& !m.seams.get(e).copied().unwrap_or(false)
|
&& !m.seams.get(e).copied().unwrap_or(false)
|
||||||
{
|
{
|
||||||
@@ -1254,12 +1264,13 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Hit::Edge { body, index, mid }) => {
|
Some(Hit::Edge { body, index, mid }) => {
|
||||||
if let Some(b) = st.body(body)
|
if let Some(b) = st.body(body) {
|
||||||
&& let Some(e) = edge_of(&b.mesh(), *index, *mid)
|
let m = b.mesh();
|
||||||
{
|
for i in m.circular_rim(*index) {
|
||||||
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { point, .. } if point.dist(*mid) < 1e-9));
|
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i));
|
||||||
if !selected {
|
if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } {
|
||||||
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0);
|
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1286,7 +1297,7 @@ fn body_rgb(app: &SolveApp, name: &str) -> (u8, u8, u8) {
|
|||||||
(c.r(), c.g(), c.b())
|
(c.r(), c.g(), c.b())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn polyline_mid(pts: &[Vec3]) -> Vec3 {
|
pub(crate) fn polyline_mid(pts: &[Vec3]) -> Vec3 {
|
||||||
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||||
let mut acc = 0.0;
|
let mut acc = 0.0;
|
||||||
for w in pts.windows(2) {
|
for w in pts.windows(2) {
|
||||||
@@ -1360,7 +1371,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
|
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
|
||||||
// The view cube handles its own clicks; the model underneath must not see them.
|
// The view cube handles its own clicks; the model underneath must not see them.
|
||||||
let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0));
|
let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0));
|
||||||
let inside = hover.is_some_and(|p| rect.contains(p) && !cube.contains(p));
|
// Floating windows and menus own input over the model, including wheel navigation.
|
||||||
|
let inside = resp.contains_pointer() && hover.is_some_and(|p| !cube.contains(p));
|
||||||
app.viewport.mouse = hover.filter(|_| inside);
|
app.viewport.mouse = hover.filter(|_| inside);
|
||||||
// Ctrl or Alt held: drawing and dragging don't snap.
|
// Ctrl or Alt held: drawing and dragging don't snap.
|
||||||
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
|
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
|
||||||
@@ -1682,6 +1694,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
app.viewport.boxsel = None;
|
app.viewport.boxsel = None;
|
||||||
}
|
}
|
||||||
crate::ref_images::show(app, ui, &painter, &proj);
|
crate::ref_images::show(app, ui, &painter, &proj);
|
||||||
|
crate::dialogs_assembly::origins(app, &painter, &proj);
|
||||||
overlays(app, &painter, &proj);
|
overlays(app, &painter, &proj);
|
||||||
crate::dim_view::show(app, ui, &painter, &proj);
|
crate::dim_view::show(app, ui, &painter, &proj);
|
||||||
crate::sketch3d::show(app, &painter, &proj);
|
crate::sketch3d::show(app, &painter, &proj);
|
||||||
@@ -1729,12 +1742,13 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
||||||
match sel {
|
match sel {
|
||||||
Some(x) => {
|
Some(x) => {
|
||||||
let item = serde_json::to_value(&x).unwrap_or_default();
|
let items = grouped_selection(&app.session, &x);
|
||||||
if add && app.session.selection.contains(&x) {
|
if add && items.iter().all(|x| app.session.selection.iter().any(|y| crate::selection::same_item(x, y))) {
|
||||||
let rest: Vec<Sel> = app.session.selection.iter().filter(|y| **y != x).cloned().collect();
|
let rest: Vec<Sel> =
|
||||||
|
app.session.selection.iter().filter(|y| !items.iter().any(|x| crate::selection::same_item(x, y))).cloned().collect();
|
||||||
let _ = app.run("select.set", json!({ "items": rest }));
|
let _ = app.run("select.set", json!({ "items": rest }));
|
||||||
} else {
|
} else {
|
||||||
let _ = app.run("select.set", json!({"items": [item], "add": add}));
|
let _ = app.run("select.set", json!({"items": items, "add": add}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None if !add => {
|
None if !add => {
|
||||||
@@ -1744,6 +1758,75 @@ fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expand a visible rim or surface into the kernel pieces a command needs. The original
|
||||||
|
/// face pick keeps its point (used by tools such as Hole and construction geometry).
|
||||||
|
pub(crate) fn grouped_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Vec<Sel> {
|
||||||
|
let st = s.world_state();
|
||||||
|
match sel {
|
||||||
|
Sel::Edge { body, index, .. } => {
|
||||||
|
if let Some(b) = st.body(body) {
|
||||||
|
let m = b.mesh();
|
||||||
|
return m
|
||||||
|
.circular_rim(*index)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) }))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Sel::Face { body, index, .. } => {
|
||||||
|
if let Some(b) = st.body(body) {
|
||||||
|
let m = b.mesh();
|
||||||
|
let mut patch = m.surface_patch(*index);
|
||||||
|
patch.sort_by_key(|i| usize::from(i != index));
|
||||||
|
return patch
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|i| {
|
||||||
|
if i == *index {
|
||||||
|
return Some(sel.clone());
|
||||||
|
}
|
||||||
|
m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
|
||||||
|
if *f as usize != i {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let [a, b, c] = m.tri(t)?;
|
||||||
|
Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
vec![sel.clone()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure keeps one entry per visible item, rather than using its two slots for kernel halves.
|
||||||
|
pub(crate) fn measurement_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Sel {
|
||||||
|
let pieces = grouped_selection(s, sel);
|
||||||
|
let mut representative = sel.clone();
|
||||||
|
match &mut representative {
|
||||||
|
Sel::Edge { index, .. } => {
|
||||||
|
if let Some(Sel::Edge { index: i, .. }) = pieces.first() {
|
||||||
|
*index = *i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Sel::Face { index, .. } => {
|
||||||
|
if let Some(i) = pieces
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| match p {
|
||||||
|
Sel::Face { index, .. } => Some(*index),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.min()
|
||||||
|
{
|
||||||
|
*index = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
representative
|
||||||
|
}
|
||||||
|
|
||||||
/// What a box selects: sketch entities while sketching, otherwise what the open dialog's
|
/// What a box selects: sketch entities while sketching, otherwise what the open dialog's
|
||||||
/// active input takes (edges, faces or bodies), otherwise bodies.
|
/// active input takes (edges, faces or bodies), otherwise bodies.
|
||||||
fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
||||||
@@ -1793,7 +1876,9 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
|||||||
let m = b.mesh();
|
let m = b.mesh();
|
||||||
if accept & EDGES != 0 {
|
if accept & EDGES != 0 {
|
||||||
for (ei, e) in m.edges.iter().enumerate() {
|
for (ei, e) in m.edges.iter().enumerate() {
|
||||||
if !m.seams.get(ei).copied().unwrap_or(false) && bx.takes(&to2(e)) {
|
let rim = m.circular_rim(ei);
|
||||||
|
let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) };
|
||||||
|
if !m.seams.get(ei).copied().unwrap_or(false) && takes {
|
||||||
out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) });
|
out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1810,8 +1895,15 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
|||||||
.find(|(_, tf)| **tf as usize == f)
|
.find(|(_, tf)| **tf as usize == f)
|
||||||
.and_then(|(t, _)| m.tri(t))
|
.and_then(|(t, _)| m.tri(t))
|
||||||
.map(|[a, bb, c]| (a + bb + c) / 3.0);
|
.map(|[a, bb, c]| (a + bb + c) / 3.0);
|
||||||
|
let takes = bx.takes(&to2(&pts))
|
||||||
|
&& (bx.crossing
|
||||||
|
|| m.surface_patch(f).iter().all(|&face| {
|
||||||
|
m.face_edges(u32::try_from(face).unwrap_or(u32::MAX))
|
||||||
|
.iter()
|
||||||
|
.all(|&e| m.edges.get(e).is_some_and(|p| bx.takes(&to2(p))))
|
||||||
|
}));
|
||||||
if let Some(point) = point
|
if let Some(point) = point
|
||||||
&& bx.takes(&to2(&pts))
|
&& takes
|
||||||
{
|
{
|
||||||
out.push(Sel::Face { body: b.name.clone(), index: f, point });
|
out.push(Sel::Face { body: b.name.clone(), index: f, point });
|
||||||
}
|
}
|
||||||
@@ -1836,8 +1928,17 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut grouped = Vec::new();
|
||||||
|
for item in out {
|
||||||
|
for piece in grouped_selection(&app.session, &item) {
|
||||||
|
if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) {
|
||||||
|
grouped.push(piece);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let out = grouped;
|
||||||
if let Some(mut d) = app.dialog.take() {
|
if let Some(mut d) = app.dialog.take() {
|
||||||
d.take_box(out, add);
|
d.take_box(&app.session, out, add);
|
||||||
app.dialog = Some(d);
|
app.dialog = Some(d);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1986,7 +2087,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
|||||||
let (r, u, b) = cube_basis(&app.cam);
|
let (r, u, b) = cube_basis(&app.cam);
|
||||||
let (wr, wu, _) = app.cam.basis();
|
let (wr, wu, _) = app.cam.basis();
|
||||||
let painter = ui.painter_at(rect);
|
let painter = ui.painter_at(rect);
|
||||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
|
||||||
let targets = cube_targets();
|
let targets = cube_targets();
|
||||||
let shapes = cube_projected(&targets, (r, u, b), c);
|
let shapes = cube_projected(&targets, (r, u, b), c);
|
||||||
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
|
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
|
||||||
@@ -2303,6 +2404,255 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn cylinder_app() -> SolveApp {
|
||||||
|
let mut s = solvecraft_engine::Session::default();
|
||||||
|
s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap();
|
||||||
|
let mut app = SolveApp::new(s, crate::Services::default());
|
||||||
|
app.ui.show_origin = false;
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn circular_items_measure_as_whole_rims_and_surfaces() {
|
||||||
|
let mut app = cylinder_app();
|
||||||
|
let st = app.session.world_state();
|
||||||
|
let b = &st.bodies[0];
|
||||||
|
let m = b.mesh();
|
||||||
|
let edges: Vec<Sel> = m
|
||||||
|
.edges
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, p)| p.len() > 3 && p.iter().all(|q| (q.z - 20.0).abs() < 1e-6))
|
||||||
|
.map(|(index, p)| Sel::Edge { body: b.name.clone(), index, point: polyline_mid(p) })
|
||||||
|
.collect();
|
||||||
|
let result = app.session.measure_items(&edges);
|
||||||
|
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
||||||
|
assert!((result["items"][0]["length_mm"].as_f64().unwrap() - 20.0 * std::f64::consts::PI).abs() < 0.2, "{result}");
|
||||||
|
app.session.selection = edges;
|
||||||
|
let d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap();
|
||||||
|
assert_eq!(d.items().len(), 1);
|
||||||
|
|
||||||
|
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
|
||||||
|
let wall = Sel::Face { body: b.name.clone(), index: faces[0] as usize, point: Vec3::new(10.0, 0.0, 10.0) };
|
||||||
|
let wall_pieces = grouped_selection(&app.session, &wall);
|
||||||
|
let result = app.session.measure_items(&wall_pieces);
|
||||||
|
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
||||||
|
assert!((result["items"][0]["area_mm2"].as_f64().unwrap() - 400.0 * std::f64::consts::PI).abs() < 10.0, "{result}");
|
||||||
|
let mut d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap();
|
||||||
|
d.pick(&app.session, wall.clone());
|
||||||
|
assert_eq!(d.items().len(), 2, "a rim and a wall are two measurement targets");
|
||||||
|
d.pick(&app.session, Sel::Face { body: b.name.clone(), index: faces[1] as usize, point: Vec3::new(-10.0, 0.0, 10.0) });
|
||||||
|
assert_eq!(d.items().len(), 1, "the other half toggles the same wall");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn circular_rim_hover_click_and_dialog_pick_include_both_halves() {
|
||||||
|
let mut app = cylinder_app();
|
||||||
|
let st = app.session.world_state();
|
||||||
|
let b = &st.bodies[0];
|
||||||
|
let m = b.mesh();
|
||||||
|
let top = m.edges.iter().position(|e| e.len() > 3 && e.iter().all(|p| (p.z - 20.0).abs() < 1e-6)).unwrap();
|
||||||
|
let rim = m.circular_rim(top);
|
||||||
|
assert_eq!(rim.len(), 2);
|
||||||
|
let line_count: usize = rim.iter().map(|&i| m.edges[i].len() - 1).sum();
|
||||||
|
for &i in &rim {
|
||||||
|
let mid = polyline_mid(&m.edges[i]);
|
||||||
|
app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid });
|
||||||
|
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
|
||||||
|
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
|
||||||
|
select(&mut app, Some(item.clone()), false);
|
||||||
|
assert_eq!(app.session.selection.len(), 2);
|
||||||
|
// Selecting both pieces must not draw each highlight twice.
|
||||||
|
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
|
||||||
|
// Ctrl/Shift clicking either half deselects the entire rim.
|
||||||
|
select(&mut app, Some(item.clone()), true);
|
||||||
|
assert!(app.session.selection.is_empty());
|
||||||
|
let mut d = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
|
||||||
|
if let crate::dialogs::Kind::Fillet { chain, .. } = &mut d.kind {
|
||||||
|
*chain = false;
|
||||||
|
}
|
||||||
|
d.pick(&app.session, item.clone());
|
||||||
|
assert_eq!(d.items().len(), 2);
|
||||||
|
d.pick(&app.session, item);
|
||||||
|
assert!(d.items().is_empty());
|
||||||
|
}
|
||||||
|
// Screen-space picking works from either side of the rim as the model turns.
|
||||||
|
for &i in &rim {
|
||||||
|
let mid = polyline_mid(&m.edges[i]);
|
||||||
|
app.cam = app.cam.looking_from(Vec3::new(mid.x, mid.y, 20.0));
|
||||||
|
app.cam.target = Vec3::new(0.0, 0.0, 10.0);
|
||||||
|
for perspective in [false, true] {
|
||||||
|
app.ui.perspective = perspective;
|
||||||
|
let proj = projection(&app, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)));
|
||||||
|
let hits = pick(&app, &proj, proj.to_screen(mid).unwrap());
|
||||||
|
let (_, sel) = candidate(&app, &hits).unwrap();
|
||||||
|
assert!(matches!(sel, Sel::Edge { .. }), "{hits:?}");
|
||||||
|
assert_eq!(grouped_selection(&app.session, &sel).len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn curved_face_hover_selection_and_dialogs_cross_the_seam() {
|
||||||
|
let mut app = cylinder_app();
|
||||||
|
let st = app.session.world_state();
|
||||||
|
let b = &st.bodies[0];
|
||||||
|
let m = b.mesh();
|
||||||
|
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
|
||||||
|
assert_eq!(faces.len(), 2);
|
||||||
|
let triangle_count = m.tri_face.iter().filter(|f| faces.contains(f)).count();
|
||||||
|
for &f in faces {
|
||||||
|
let index = f as usize;
|
||||||
|
let t = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf == f).unwrap().0;
|
||||||
|
let [a, c, d] = m.tri(t).unwrap();
|
||||||
|
let point = (a + c + d) / 3.0;
|
||||||
|
app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point });
|
||||||
|
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
|
||||||
|
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
|
||||||
|
select(&mut app, Some(item.clone()), false);
|
||||||
|
assert_eq!(app.session.selection.len(), 2);
|
||||||
|
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
|
||||||
|
assert_eq!(app.session.selection.first(), Some(&item), "keep the original tool placement point first");
|
||||||
|
let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
|
||||||
|
assert_eq!(dialog.items().len(), 2);
|
||||||
|
dialog.pick(&app.session, item.clone());
|
||||||
|
assert!(dialog.items().is_empty());
|
||||||
|
dialog.pick(&app.session, item);
|
||||||
|
assert_eq!(dialog.items().len(), 2);
|
||||||
|
// A point elsewhere on the opposite half still toggles the whole surface off.
|
||||||
|
let other = faces.iter().find(|&&x| x != f).unwrap();
|
||||||
|
select(&mut app, Some(Sel::Face { body: b.name.clone(), index: *other as usize, point: m.positions[0] }), true);
|
||||||
|
assert!(app.session.selection.is_empty());
|
||||||
|
}
|
||||||
|
// Planar caps and box corners stay separate.
|
||||||
|
let cap = (0..b.body.face_count()).find(|&i| m.surface_patch(i).len() == 1).unwrap();
|
||||||
|
assert_eq!(m.surface_patch(cap), vec![cap]);
|
||||||
|
app.session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||||
|
let st = app.session.world_state();
|
||||||
|
let box_body = st.bodies.last().unwrap();
|
||||||
|
for i in 0..box_body.body.face_count() {
|
||||||
|
assert_eq!(box_body.mesh().surface_patch(i), vec![i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcuts_window_wheel_does_not_zoom_the_viewport() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
|
||||||
|
app.viewport.no_pixels = true;
|
||||||
|
crate::help::run(&mut app, &ctx, "shortcuts");
|
||||||
|
let mut time = 0.0;
|
||||||
|
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
|
||||||
|
time += 1.0 / 60.0;
|
||||||
|
let mut events = Vec::new();
|
||||||
|
if let Some(p) = pointer {
|
||||||
|
events.push(egui::Event::PointerMoved(p));
|
||||||
|
}
|
||||||
|
if wheel != 0.0 {
|
||||||
|
events.push(egui::Event::MouseWheel {
|
||||||
|
unit: egui::MouseWheelUnit::Point,
|
||||||
|
delta: vec2(0.0, wheel),
|
||||||
|
modifiers: egui::Modifiers::NONE,
|
||||||
|
phase: egui::TouchPhase::Move,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let _ = ctx.run_ui(
|
||||||
|
egui::RawInput {
|
||||||
|
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
|
||||||
|
time: Some(time),
|
||||||
|
events,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
|ui| {
|
||||||
|
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
|
||||||
|
crate::prefs::show(app, ui.ctx());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// Let egui establish the floating window's size and hit-test widgets.
|
||||||
|
for _ in 0..4 {
|
||||||
|
frame(&mut app, None, 0.0);
|
||||||
|
}
|
||||||
|
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||||||
|
let list = window.center() + vec2(140.0, 0.0);
|
||||||
|
let height = app.cam.half_height();
|
||||||
|
let target = app.cam.target;
|
||||||
|
frame(&mut app, Some(list), 0.0);
|
||||||
|
for n in 0..12 {
|
||||||
|
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
|
||||||
|
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
|
||||||
|
assert_eq!(app.cam.target, target);
|
||||||
|
}
|
||||||
|
// The window's non-scrolling sidebar must also shield the model.
|
||||||
|
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
|
||||||
|
assert_eq!(app.cam.half_height(), height);
|
||||||
|
// An exposed part of the viewport still accepts wheel navigation with the popup open.
|
||||||
|
let exposed = pos2(40.0, 700.0);
|
||||||
|
assert!(!window.contains(exposed));
|
||||||
|
frame(&mut app, Some(exposed), 0.0);
|
||||||
|
frame(&mut app, Some(exposed), -120.0);
|
||||||
|
assert_ne!(app.cam.half_height(), height);
|
||||||
|
// Closing the popup returns its former area to the viewport.
|
||||||
|
app.prefs_window.open = false;
|
||||||
|
for _ in 0..12 {
|
||||||
|
frame(&mut app, Some(list), 0.0);
|
||||||
|
}
|
||||||
|
let height = app.cam.half_height();
|
||||||
|
frame(&mut app, Some(list), -120.0);
|
||||||
|
assert_ne!(app.cam.half_height(), height);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn floating_dialog_blocks_viewport_hover_and_clicks() {
|
||||||
|
let mut h = crate::scenario::Harness::new();
|
||||||
|
h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}));
|
||||||
|
h.call("ui.view", json!({"view": "fit"}));
|
||||||
|
h.app.ui.pick_bodies = true;
|
||||||
|
let p = h.app.viewport.rect.unwrap().center();
|
||||||
|
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert!(h.app.viewport.hover.is_some(), "the test point must hover over the model");
|
||||||
|
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert!(!h.app.session.selection.is_empty(), "the uncovered model must be selectable");
|
||||||
|
h.app.session.selection.clear();
|
||||||
|
|
||||||
|
h.call("ui.prefs", json!({"open": "Navigation"}));
|
||||||
|
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||||||
|
assert!(window.contains(p), "the dialog must cover the model at the test point");
|
||||||
|
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert!(h.app.viewport.hover.is_none(), "the dialog must block model highlights");
|
||||||
|
assert!(h.app.viewport.mouse.is_none());
|
||||||
|
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert!(h.app.session.selection.is_empty(), "clicks in the dialog must not select the model");
|
||||||
|
|
||||||
|
h.call("ui.prefs", json!({"button": "cancel"}));
|
||||||
|
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert!(h.app.viewport.hover.is_some(), "closing the dialog must restore model highlights");
|
||||||
|
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert!(!h.app.session.selection.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn floating_dialog_blocks_view_cube_highlights_and_clicks() {
|
||||||
|
let mut h = crate::scenario::Harness::new();
|
||||||
|
h.call("ui.resize", json!({"width": 800, "height": 700}));
|
||||||
|
let p = crate::scenario::handle_at("cube:top").unwrap();
|
||||||
|
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
|
||||||
|
h.call("ui.prefs", json!({"open": "Navigation"}));
|
||||||
|
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||||||
|
assert!(window.contains(p), "the floating dialog must cover the cube target");
|
||||||
|
let camera = serde_json::to_value(h.app.cam).unwrap();
|
||||||
|
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0));
|
||||||
|
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the view cube");
|
||||||
|
h.call("ui.prefs", json!({"button": "cancel"}));
|
||||||
|
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
|
||||||
|
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||||||
|
assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn section_outlines_follow_the_cut() {
|
fn section_outlines_follow_the_cut() {
|
||||||
let mut s = solvecraft_engine::Session::default();
|
let mut s = solvecraft_engine::Session::default();
|
||||||
|
|||||||
@@ -31,3 +31,98 @@ fn adhoc() {
|
|||||||
panic!("{path}: {e}");
|
panic!("{path}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn angle_dimension_both_interactive_workflows() {
|
||||||
|
use serde_json::json;
|
||||||
|
for preselect in [false, true] {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.run(&json!([
|
||||||
|
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||||
|
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[40,0]]}}},
|
||||||
|
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[30,30]]}}},
|
||||||
|
{"call":"ui.view","params":{"view":"top"}}
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let picks = json!([
|
||||||
|
{"click":{"sketch":[20,0]}},
|
||||||
|
{"click":{"sketch":[15,15]},"shift":true}
|
||||||
|
]);
|
||||||
|
if preselect {
|
||||||
|
h.run(&picks).unwrap();
|
||||||
|
h.run(&json!([{ "key":"D" }])).unwrap();
|
||||||
|
} else {
|
||||||
|
h.run(&json!([{ "key":"D" }])).unwrap();
|
||||||
|
h.run(&picks).unwrap();
|
||||||
|
}
|
||||||
|
h.run(&json!([
|
||||||
|
{"click":{"sketch":[20,8]}},
|
||||||
|
{"text":"45"}, {"key":"Enter"},
|
||||||
|
{"expect":{"errors":0,"param":{"d1":std::f64::consts::FRAC_PI_4}}}
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||||
|
assert!(sk.constraints.iter().any(|c| matches!(c.kind, solvecraft_engine::sketch::ConstraintKind::Angle { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_tab_and_file_recent_open_designs() {
|
||||||
|
use serde_json::json;
|
||||||
|
let mut h = Harness::new();
|
||||||
|
let path = h.dir.join("recent.solvecraft").to_string_lossy().into_owned();
|
||||||
|
h.run(&json!([
|
||||||
|
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10}}},
|
||||||
|
{"call":"engine.execute","params":{"command":"file.save_as","params":{"path":path}}},
|
||||||
|
{"click":{"handle":"tab:home"}},
|
||||||
|
{"expect":{"home":true,"documents":1}},
|
||||||
|
{"click":{"handle":"tab:design:0"}},
|
||||||
|
{"expect":{"home":false,"bodies":1}},
|
||||||
|
{"click":{"handle":"tab:home"}},
|
||||||
|
{"click":{"handle":"home:recent:0"}},
|
||||||
|
{"expect":{"home":false,"bodies":1,"documents":2}},
|
||||||
|
{"click":{"handle":"appbar:folder"}},
|
||||||
|
{"widget":"Open Recent"}
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
h.run(&json!([
|
||||||
|
{"widget":path},
|
||||||
|
{"expect":{"home":false,"bodies":1,"documents":3}}
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sketch_toolbar_imports_use_file_picker() {
|
||||||
|
use serde_json::json;
|
||||||
|
let mut h = Harness::new();
|
||||||
|
let path = h.dir.join("drawing.svg");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="20mm" viewBox="0 0 40 20"><path d="M 0 0 L 40 0 L 40 20 L 0 20 Z"/></svg>"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
h.run(&json!([
|
||||||
|
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||||
|
{"choose_file":path},
|
||||||
|
{"start":"sketch.insert_svg"},
|
||||||
|
{"expect":{"errors":0,"sketch_curves":{"own":4}}}
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_toolbar_reaches_collapsed_commands() {
|
||||||
|
use serde_json::json;
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.run(&json!([
|
||||||
|
{"call":"ui.resize","params":{"width":640,"height":800}},
|
||||||
|
{"expect":{"shown_handles":["toolbar:overflow"]}},
|
||||||
|
{"click":{"handle":"toolbar:overflow"}},
|
||||||
|
{"widget":"CONSTRUCT"},
|
||||||
|
{"expect":{"shown_handles":["toolbar:construct.plane.offset"]}},
|
||||||
|
{"click":{"handle":"toolbar:construct.plane.offset"}},
|
||||||
|
{"expect":{"dialog":"OffsetPlane"}}
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"pending": "#40: offsets of projected geometry get new ids on recompute, the lid's lip fails"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"note": "Acceptance (#27): the board grows 60 \u2192 70 mm; the lid's lip (offsets of the case's projected rim) follows."
|
"note": "Acceptance (#27): the board grows 60 \u2192 70 mm; the lid's lip (offsets of the case's projected rim) follows."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -87,6 +87,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"call": "engine.execute",
|
||||||
|
"params": {
|
||||||
|
"command": "parameters.change",
|
||||||
|
"params": {
|
||||||
|
"name": "wall",
|
||||||
|
"expression": "2.4 mm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expect": {
|
||||||
|
"errors": 0,
|
||||||
|
"bodies": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"call": "engine.execute",
|
||||||
|
"params": {
|
||||||
|
"command": "parameters.change",
|
||||||
|
"params": {
|
||||||
|
"name": "height",
|
||||||
|
"expression": "30 mm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expect": {
|
||||||
|
"errors": 0,
|
||||||
|
"bodies": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"call": "engine.execute",
|
||||||
|
"params": {
|
||||||
|
"command": "parameters.change",
|
||||||
|
"params": {
|
||||||
|
"name": "height",
|
||||||
|
"expression": "25 mm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expect": {
|
||||||
|
"errors": 0,
|
||||||
|
"bodies": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"call": "engine.execute",
|
||||||
|
"params": {
|
||||||
|
"command": "parameters.change",
|
||||||
|
"params": {
|
||||||
|
"name": "wall",
|
||||||
|
"expression": "2 mm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expect": {
|
||||||
|
"errors": 0,
|
||||||
|
"bodies": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"call": "engine.execute",
|
"call": "engine.execute",
|
||||||
"params": {
|
"params": {
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"pending": "#41: a snap fit stays at its old point when its wall moves (a floating body)"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"note": "Acceptance (#27): the board grows 40 \u2192 50 mm deep; the snap fit stays on the moved front wall."
|
"note": "Acceptance (#27): the board grows 40 \u2192 50 mm deep; the snap fit stays on the moved front wall."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{"click": {"sketch": [20, -8]}},
|
||||||
{
|
{
|
||||||
"text": "50"
|
"text": "50"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{"click": {"sketch": [20, -8]}},
|
||||||
{
|
{
|
||||||
"text": "40"
|
"text": "40"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,6 +76,14 @@
|
|||||||
{
|
{
|
||||||
"key": "Enter"
|
"key": "Enter"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"call": "ui.set",
|
||||||
|
"params": {
|
||||||
|
"hiddenBodies": [
|
||||||
|
"Body1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"start": "sketch.create"
|
"start": "sketch.create"
|
||||||
},
|
},
|
||||||
@@ -84,6 +92,12 @@
|
|||||||
"plane": "XZ"
|
"plane": "XZ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"call": "ui.set",
|
||||||
|
"params": {
|
||||||
|
"hiddenBodies": []
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"start": "sketch.rectangle.two_point"
|
"start": "sketch.rectangle.two_point"
|
||||||
},
|
},
|
||||||
@@ -99,7 +113,7 @@
|
|||||||
"click": {
|
"click": {
|
||||||
"sketch": [
|
"sketch": [
|
||||||
50,
|
50,
|
||||||
10
|
-10
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -116,16 +130,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"start": "solid.extrude"
|
"call": "ui.view",
|
||||||
|
"params": {
|
||||||
|
"view": "fit"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"click": {
|
"start": "solid.extrude"
|
||||||
"world": [
|
|
||||||
40,
|
|
||||||
0,
|
|
||||||
5
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "20"
|
"text": "20"
|
||||||
@@ -199,7 +210,7 @@
|
|||||||
"max": [
|
"max": [
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
20
|
30
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{"click": {"sketch": [20, -8]}},
|
||||||
{
|
{
|
||||||
"text": "40"
|
"text": "40"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,6 +95,14 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"click": {
|
||||||
|
"sketch": [
|
||||||
|
20,
|
||||||
|
-8
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"text": "40"
|
"text": "40"
|
||||||
},
|
},
|
||||||
@@ -109,6 +117,14 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"click": {
|
||||||
|
"sketch": [
|
||||||
|
48,
|
||||||
|
12
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"text": "25"
|
"text": "25"
|
||||||
},
|
},
|
||||||
@@ -123,6 +139,14 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"click": {
|
||||||
|
"sketch": [
|
||||||
|
30,
|
||||||
|
20
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"text": "10"
|
"text": "10"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,6 +76,14 @@
|
|||||||
{
|
{
|
||||||
"key": "Enter"
|
"key": "Enter"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"call": "ui.set",
|
||||||
|
"params": {
|
||||||
|
"hiddenBodies": [
|
||||||
|
"Body1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"start": "sketch.create"
|
"start": "sketch.create"
|
||||||
},
|
},
|
||||||
@@ -84,6 +92,12 @@
|
|||||||
"plane": "XZ"
|
"plane": "XZ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"call": "ui.set",
|
||||||
|
"params": {
|
||||||
|
"hiddenBodies": []
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"start": "sketch.rectangle.two_point"
|
"start": "sketch.rectangle.two_point"
|
||||||
},
|
},
|
||||||
@@ -99,7 +113,7 @@
|
|||||||
"click": {
|
"click": {
|
||||||
"sketch": [
|
"sketch": [
|
||||||
50,
|
50,
|
||||||
10
|
-10
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -116,16 +130,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"start": "solid.extrude"
|
"call": "ui.view",
|
||||||
|
"params": {
|
||||||
|
"view": "fit"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"click": {
|
"start": "solid.extrude"
|
||||||
"world": [
|
|
||||||
40,
|
|
||||||
0,
|
|
||||||
5
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "20"
|
"text": "20"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{"click": {"sketch": [20, -8]}},
|
||||||
{
|
{
|
||||||
"text": "40"
|
"text": "40"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,6 +76,14 @@
|
|||||||
{
|
{
|
||||||
"key": "Enter"
|
"key": "Enter"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"call": "ui.set",
|
||||||
|
"params": {
|
||||||
|
"hiddenBodies": [
|
||||||
|
"Body1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"start": "sketch.create"
|
"start": "sketch.create"
|
||||||
},
|
},
|
||||||
@@ -84,6 +92,12 @@
|
|||||||
"plane": "XZ"
|
"plane": "XZ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"call": "ui.set",
|
||||||
|
"params": {
|
||||||
|
"hiddenBodies": []
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"start": "sketch.rectangle.two_point"
|
"start": "sketch.rectangle.two_point"
|
||||||
},
|
},
|
||||||
@@ -99,7 +113,7 @@
|
|||||||
"click": {
|
"click": {
|
||||||
"sketch": [
|
"sketch": [
|
||||||
50,
|
50,
|
||||||
10
|
-10
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -116,16 +130,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"start": "solid.extrude"
|
"call": "ui.view",
|
||||||
|
"params": {
|
||||||
|
"view": "fit"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"click": {
|
"start": "solid.extrude"
|
||||||
"world": [
|
|
||||||
40,
|
|
||||||
0,
|
|
||||||
5
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "20"
|
"text": "20"
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
"click": {
|
"click": {
|
||||||
"sketch": [
|
"sketch": [
|
||||||
52,
|
52,
|
||||||
10
|
-10
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
"click": {
|
"click": {
|
||||||
"sketch": [
|
"sketch": [
|
||||||
48,
|
48,
|
||||||
25
|
15
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"pending": "every chevron works; the browser has no Joints folder (solvecraft-shell)"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"note": "Browser chevrons expand and collapse every kind of node (folders, components, groups, the origin, named views, document settings). Model: two components with a body each, a joint, a sketch, an offset plane, a named view."
|
"note": "Browser chevrons expand and collapse every kind of node (folders, components, groups, the origin, named views, document settings). Model: two components with a body each, a joint, a sketch, an offset plane, a named view."
|
||||||
},
|
},
|
||||||
@@ -82,6 +79,14 @@
|
|||||||
{
|
{
|
||||||
"key": "Enter"
|
"key": "Enter"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"call": "ui.set",
|
||||||
|
"params": {
|
||||||
|
"hiddenBodies": [
|
||||||
|
"Body1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"start": "sketch.create"
|
"start": "sketch.create"
|
||||||
},
|
},
|
||||||
@@ -90,6 +95,12 @@
|
|||||||
"plane": "XZ"
|
"plane": "XZ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"call": "ui.set",
|
||||||
|
"params": {
|
||||||
|
"hiddenBodies": []
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"start": "sketch.rectangle.two_point"
|
"start": "sketch.rectangle.two_point"
|
||||||
},
|
},
|
||||||
@@ -105,7 +116,7 @@
|
|||||||
"click": {
|
"click": {
|
||||||
"sketch": [
|
"sketch": [
|
||||||
50,
|
50,
|
||||||
10
|
-10
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -122,16 +133,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"start": "solid.extrude"
|
"call": "ui.view",
|
||||||
|
"params": {
|
||||||
|
"view": "fit"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"click": {
|
"start": "solid.extrude"
|
||||||
"world": [
|
|
||||||
40,
|
|
||||||
0,
|
|
||||||
5
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "20"
|
"text": "20"
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"note": "User bug 1/4 (\"big square\"): with the root active, Create Sketch on a moved component's face puts the sketch on the face where it is shown, its projected loop on the face's edges (not at the component's own coordinates)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"call": "engine.execute",
|
||||||
|
"params": {
|
||||||
|
"command": "component.create",
|
||||||
|
"params": {
|
||||||
|
"name": "C"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"call": "engine.execute",
|
||||||
|
"params": {
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"length": 40,
|
||||||
|
"width": 30,
|
||||||
|
"height": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"call": "engine.execute",
|
||||||
|
"params": {
|
||||||
|
"command": "component.activate",
|
||||||
|
"params": {
|
||||||
|
"component": "root"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"call": "engine.execute",
|
||||||
|
"params": {
|
||||||
|
"command": "occurrence.move",
|
||||||
|
"params": {
|
||||||
|
"occurrence": "C:1",
|
||||||
|
"translate": [
|
||||||
|
100,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"call": "ui.view",
|
||||||
|
"params": {
|
||||||
|
"view": "fit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": "sketch.create"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"click": {
|
||||||
|
"world": [
|
||||||
|
120,
|
||||||
|
15,
|
||||||
|
20
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expect": {
|
||||||
|
"sketching": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expect": {
|
||||||
|
"sketch_curves": {
|
||||||
|
"linked": 4,
|
||||||
|
"own": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expect": {
|
||||||
|
"sketch_world": {
|
||||||
|
"sketch": "Sketch1",
|
||||||
|
"point": "p1",
|
||||||
|
"at": [
|
||||||
|
140,
|
||||||
|
0,
|
||||||
|
20
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"shot": "s43_root_sketch_on_moved_face"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": "sketch.finish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expect": {
|
||||||
|
"errors": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"pending": "a root-active sketch on a face of a moved component lands in body-local coordinates (solvecraft-dialogs)"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"note": "A box in a new component, the component moved +100 in x, the root active: a sketch on the box's top face projects the face's edges where the face is (x 100-140), not at the body's own coordinates (x 0-40)."
|
"note": "A box in a new component, the component moved +100 in x, the root active: a sketch on the box's top face projects the face's edges where the face is (x 100-140), not at the body's own coordinates (x 0-40)."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{"note": "With Y up (#3) the view cube turns with the world: its TOP is world +Y and clicking it looks down −Y in the standard plan orientation; FRONT looks along −Z."},
|
{"note": "With Y up (#3) the view cube turns with the world: its TOP is world +Y and clicking it looks down −Y in the standard plan orientation; FRONT looks along −Z."},
|
||||||
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}},
|
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}},
|
||||||
{"call": "ui.set", "params": {"upAxis": "y"}},
|
{"call": "ui.prefs", "params": {"up_axis": "y"}},
|
||||||
{"call": "ui.view", "params": {"view": "home"}},
|
{"call": "ui.view", "params": {"view": "home"}},
|
||||||
{"move": {"cube": [0, 0, 1]}},
|
{"move": {"cube": [0, 0, 1]}},
|
||||||
{"expect": {"shown_handles": ["cube_hover:top"]}},
|
{"expect": {"shown_handles": ["cube_hover:top"]}},
|
||||||
|
|||||||
+6
-3
@@ -54,7 +54,7 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app:
|
|||||||
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`). |
|
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`). |
|
||||||
| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. |
|
| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. |
|
||||||
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. |
|
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. |
|
||||||
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports features that now fail. |
|
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
|
||||||
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model. Bridged: the app window (default) or a model render with the app's camera. |
|
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model. Bridged: the app window (default) or a model render with the app's camera. |
|
||||||
| `export` | `path`, `format?` (step, 3mf, stl, stla, obj), `bodies?` | STEP, 3MF or mesh export. |
|
| `export` | `path`, `format?` (step, 3mf, stl, stla, obj), `bodies?` | STEP, 3MF or mesh export. |
|
||||||
| `undo`, `redo` | — | Undo / redo one change. |
|
| `undo`, `redo` | — | Undo / redo one change. |
|
||||||
@@ -69,8 +69,11 @@ Errors: an unknown tool or arguments that don't match the tool's schema are JSON
|
|||||||
`-32602` errors; an engine failure (bad command parameters, a feature that fails) is a tool
|
`-32602` errors; an engine failure (bad command parameters, a feature that fails) is a tool
|
||||||
result with `isError: true` and the engine's message, which the model can read and correct.
|
result with `isError: true` and the engine's message, which the model can read and correct.
|
||||||
|
|
||||||
`batch` rollback undoes as many steps as the batch added to the undo history; batches that
|
`batch` rollback goes back to an engine checkpoint (`edit.checkpoint`) taken before the first
|
||||||
themselves call `edit.undo`, `file.new` or `doc.open` are not rolled back exactly.
|
step: the design, the active sketch and the undo history are restored exactly as they were,
|
||||||
|
however long the history is. Batches that call `file.new` or `doc.open` get the design back,
|
||||||
|
not its file path. (An older app without checkpoints falls back to undoing as many steps as
|
||||||
|
the batch added to the undo history.)
|
||||||
|
|
||||||
## Demo: an enclosure built over MCP
|
## Demo: an enclosure built over MCP
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# WIP branch reconciliation (#6)
|
||||||
|
|
||||||
|
The branches predate the dotted command IDs. Their remaining changes were ported through
|
||||||
|
`crates/engine/src/legacy_ids.rs` and reconciled with the current implementation.
|
||||||
|
|
||||||
|
| Branches | Result |
|
||||||
|
|---|---|
|
||||||
|
| `solvecraft-sk`, `solvecraft-d2` | Ported `62d22e2` and `e0b3138`: sketches/projections see placed components; cache keys include placements; moved feature dialogs resolve world picks; Joints uses Browser rows. |
|
||||||
|
| `solvecraft-b4` | Ported `2c18dac`: canvases carry component ownership and named joint origins follow component placements. |
|
||||||
|
| `solvecraft-asm` | Ported `bc05805`: joint snap feedback, Drive limits, joint menus, and Motion Study editing/playback controls. |
|
||||||
|
| `solvecraft-shell`, `solvecraft-shell-b` | Browser chevrons, shared occurrence Move triad, toolbar customization, workspace switcher and status labels are already present on main. Their scenarios remain active; the Joints chevron scenario is now active too. |
|
||||||
|
| `solvecraft-kdev3`, `solvecraft-kdev`, `solvecraft-kdev2` | Previously landed as `67c79ec`; the old refs were already removed. |
|
||||||
|
| `solvecraft-kdev4` | The earlier robustness, shell, blends, wasm and measurement work is already present. Ported the remaining `9f98449` stacked-prism sewing and repaired curved-boundary face picking. |
|
||||||
|
| `solvecraft-params(-build)` | Previously landed as `517b7a9`. |
|
||||||
|
|
||||||
|
The source WIP refs are retained until this batch merges. Delete them only after the merged
|
||||||
|
main contains the reconciled changes and passes CI.
|
||||||
|
|
||||||
|
Regression coverage includes root sketches on translated and rotated components, subsequent
|
||||||
|
component moves, canvas save/reopen, joint origins during pending moves, moved feature dialog
|
||||||
|
refill, Browser chevrons, sample building, and the UI scenarios. `cargo xtask ci` covers native
|
||||||
|
tests, wasm, layering and asset attribution.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"description": "A loft cut from a pocket floor beside a round hole it never touches (the hole made first). Slab 900 x 480 x 160, a 557.68 x 100 pocket 120 deep, a 35 mm hole 25 deep 66 mm from the pocket, then a loft cut from a rectangle on the pocket floor (0.5 mm inside its walls) down 4 mm to an 80 x 80 square. The loft's top lies in the pocket floor, and the hole's cylinder kept the exact planar boolean from taking it: the cut failed with a kernel panic. Result: the same body as with the hole made after the loft.",
|
||||||
|
"commands": [
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XY", "offset": 690, "name": "Outline"}},
|
||||||
|
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, -480], "p1": [900, 0]}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}},
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "BowlTop"}},
|
||||||
|
{"command": "sketch.rectangle.two_point", "params": {"p0": [171.16, -151.16], "p1": [728.84, -51.16]}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.extrude", "params": {"distance": -120, "operation": "cut", "name": "BowlPocket"}},
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "TapHole"}},
|
||||||
|
{"command": "sketch.circle.center", "params": {"center": [795, -101.16], "radius": 17.5}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.extrude", "params": {"distance": -25, "operation": "cut", "name": "TapCut"}},
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XY", "offset": 730, "name": "TroughInset"}},
|
||||||
|
{"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, -150.66], "p1": [728.34, -51.66]}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XY", "offset": 726, "name": "DrainSeat"}},
|
||||||
|
{"command": "sketch.rectangle.two_point", "params": {"p0": [410, -141.16], "p1": [490, -61.16]}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.loft", "params": {"sections": [{"sketch": "TroughInset"}, {"sketch": "DrainSeat"}], "operation": "cut", "name": "TroughFall"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"description": "A thin 437 mm bar whose profile has a 3 mm concave arc, cut by a long prism whose top is tilted 21 degrees to the bar (an elliptical cut through the arc's cylinder). The check on the result meshed both bodies at a step from the 920 mm prism, which faceted the arc so coarsely that a correct cut failed the membership test. Result: the bar with its end trimmed by the tilted plane, as a split by that plane gives.",
|
||||||
|
"commands": [
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XZ", "offset": -462, "name": "Profile"}},
|
||||||
|
{"command": "sketch.line", "params": {"points": [[147.4407, -849.95], [147.4407, -851.0], [151.3952, -851.0], [151.3952, -847.4709], [150.3952, -847.4709]]}},
|
||||||
|
{"command": "sketch.arc.center_point", "params": {"center": [147.4407, -846.95], "start": "l1.start", "end": "l4.end"}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.extrude", "params": {"sketch": "Profile", "profiles": "all", "direction": "positive", "start_offset": -1, "distance": 437.52, "operation": "new", "body_name": "Tool"}},
|
||||||
|
{"command": "sketch.create", "params": {"plane": "YZ", "name": "Clip"}},
|
||||||
|
{"command": "sketch.line", "params": {"points": [[-472, 853.91], [-432, 838.47], [-432, 820], [-472, 820]], "closed": true}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.extrude", "params": {"sketch": "Clip", "profiles": "all", "start_offset": -10, "distance": 920, "operation": "new", "body_name": "ClipBody"}},
|
||||||
|
{"command": "solid.combine", "params": {"target": "Tool", "tools": ["ClipBody"], "operation": "cut", "keep_tools": true}}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"description": "The oblique cut of oblique_cut_of_cylinder.json with the prism running past the bar's end. Moving a boolean result back after a shifted retry computed a matrix norm whose root refinement never ended: the app hung at full CPU. Result: the same trimmed bar.",
|
||||||
|
"commands": [
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XZ", "offset": -462, "name": "Profile"}},
|
||||||
|
{"command": "sketch.line", "params": {"points": [[147.4407, -849.95], [147.4407, -851.0], [151.3952, -851.0], [151.3952, -847.4709], [150.3952, -847.4709]]}},
|
||||||
|
{"command": "sketch.arc.center_point", "params": {"center": [147.4407, -846.95], "start": "l1.start", "end": "l4.end"}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.extrude", "params": {"sketch": "Profile", "profiles": "all", "direction": "positive", "start_offset": -1, "distance": 437.52, "operation": "new", "body_name": "Tool"}},
|
||||||
|
{"command": "sketch.create", "params": {"plane": "YZ", "name": "Clip"}},
|
||||||
|
{"command": "sketch.line", "params": {"points": [[-480, 856.9979999999999], [-432, 838.47], [-432, 820], [-480, 820]], "closed": true}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.extrude", "params": {"sketch": "Clip", "profiles": "all", "start_offset": -10, "distance": 920, "operation": "new", "body_name": "ClipBody"}},
|
||||||
|
{"command": "solid.combine", "params": {"target": "Tool", "tools": ["ClipBody"], "operation": "cut", "keep_tools": true}}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"description": "A shallow, wide loft cut starting in a body's top face: slab 900 x 480 x 160, loft cut from a 556.68 x 99 rectangle on the top face down 4 mm to an 80 x 80 square (sides about 0.9 degrees from horizontal). Healing an intermediate boolean result panicked outside the boolean's guard and stopped every later attempt. Result: the slab less the loft, 69 003 015.31 mm³.",
|
||||||
|
"commands": [
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
|
||||||
|
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [900, 480]}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}},
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XY", "offset": 160, "name": "LoftTop"}},
|
||||||
|
{"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, 329.34], "p1": [728.34, 428.34]}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "sketch.create", "params": {"plane": "XY", "offset": 156, "name": "LoftBottom"}},
|
||||||
|
{"command": "sketch.rectangle.two_point", "params": {"p0": [410, 338.84], "p1": [490, 418.84]}},
|
||||||
|
{"command": "sketch.finish", "params": {}},
|
||||||
|
{"command": "solid.loft", "params": {"sections": [{"sketch": "LoftTop"}, {"sketch": "LoftBottom"}], "operation": "cut", "name": "LoftCut"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+12
@@ -49,3 +49,15 @@ Changes:
|
|||||||
direction (long triangles from a trim loop to the grid's far ends cut through cylinders).
|
direction (long triangles from a trim loop to the grid's far ends cut through cylinders).
|
||||||
- `lib.rs`: compiler warnings no longer fail the build. Example, test and bench targets are
|
- `lib.rs`: compiler warnings no longer fail the build. Example, test and bench targets are
|
||||||
dropped (their sources are not vendored).
|
dropped (their sources are not vendored).
|
||||||
|
|
||||||
|
## matext4cgmath 0.1.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/matext4cgmath)
|
||||||
|
|
||||||
|
truck-geometry uses it for a matrix's operator norm (an intersection curve scales its tolerance
|
||||||
|
by it whenever it is moved).
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- `solver.rs`: the Newton refinement of cubic and quartic roots stops after 100 steps. Near a
|
||||||
|
double root it could cycle forever: moving a body whose edges a boolean made (the retries
|
||||||
|
move both bodies and the result back) hung the app.
|
||||||
|
- `lib.rs`: compiler warnings no longer fail the build. Test targets are dropped (their sources
|
||||||
|
are not vendored).
|
||||||
Vendored
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||||
|
#
|
||||||
|
# When uploading crates to the registry Cargo will automatically
|
||||||
|
# "normalize" Cargo.toml files for maximal compatibility
|
||||||
|
# with all versions of Cargo and also rewrite `path` dependencies
|
||||||
|
# to registry (e.g., crates.io) dependencies.
|
||||||
|
#
|
||||||
|
# If you are reading this file be aware that the original Cargo.toml
|
||||||
|
# will likely look very different (and much more reasonable).
|
||||||
|
# See Cargo.toml.orig for the original contents.
|
||||||
|
|
||||||
|
[package]
|
||||||
|
edition = "2021"
|
||||||
|
name = "matext4cgmath"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Yoshinori Tanimura <[email protected]>"]
|
||||||
|
description = "Unofficial third-party cgmath extensions for calculate eigenvalues, operator norms and Iwasawa decomposition."
|
||||||
|
homepage = "https://github.com/ricosjp/matext4cgmath"
|
||||||
|
keywords = [
|
||||||
|
"cgmath",
|
||||||
|
"graphics",
|
||||||
|
"linear-algebra",
|
||||||
|
]
|
||||||
|
categories = ["graphics"]
|
||||||
|
license = "Apache-2.0"
|
||||||
|
repository = "https://github.com/ricosjp/matext4cgmath"
|
||||||
|
resolver = "2"
|
||||||
|
autotests = false
|
||||||
|
autoexamples = false
|
||||||
|
autobenches = false
|
||||||
|
|
||||||
|
[dependencies.cgmath]
|
||||||
|
version = "0.18.0"
|
||||||
|
|
||||||
|
[dependencies.katexit]
|
||||||
|
version = "0.1.2"
|
||||||
|
|
||||||
|
[dependencies.num-complex]
|
||||||
|
version = "0.4.0"
|
||||||
|
|
||||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user