Fix modelling regressions and reconcile WIP UI work

This commit is contained in:
bherbruck committed 2026-10-10 15:37:00 -04:00
1 parent 49bd89de13
commit d6cdec40da
67 files changed
+1793 -260

No files matched your search

+9 -2
View File
@@ -1,6 +1,6 @@
# 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.
## 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. |
| 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) |
| 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 |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
## 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:
1. SOLID and SKETCH (now).
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
+1 -1
View File
@@ -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 std = match r.to_ascii_uppercase().as_str() {
"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])),
_ => None,
};
+18
View File
@@ -61,6 +61,7 @@ fn services() -> Services {
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file()
.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())
})),
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();
})),
}
}
+22 -1
View File
@@ -34,6 +34,14 @@ pub struct Canvas {
pub flip: bool,
#[serde(default = "yes")]
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 {
@@ -57,7 +65,7 @@ impl Canvas {
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)]
}
/// Corners in world coordinates.
/// Corners on `plane` (in whatever frame the plane is given).
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
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).
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
+1
View File
@@ -1115,6 +1115,7 @@ impl FeatureKind {
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
FeatureKind::Thread { face, .. } => vec![face],
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
_ => Vec::new(),
}
}
+90 -6
View File
@@ -471,6 +471,22 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
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 {
web_time::Instant::now()
}
@@ -514,7 +530,7 @@ impl Model {
let ctx = context_digest(doc, &vals);
for (i, f) in doc.features.iter().enumerate() {
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).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -570,7 +586,7 @@ impl Model {
continue;
}
// 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
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
{
@@ -1405,6 +1421,20 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
}
let mut tools = Vec::new();
for r in &regions {
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 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);
@@ -2288,17 +2318,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
FeatureKind::Sketch { plane, sketch } => {
let fst = state_in_frame(doc, st, f.component);
let (plane, face_warning) = match plane {
PlaneRef::Face { plane: picked, at, name } => face_plane(st, picked, *at, name.as_deref()),
other => (doc.resolve_plane_in(vals, other, 0, Some(st))?, None),
PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()),
other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
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 {
warns.insert(0, w);
}
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() {
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
}
@@ -2818,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())
}
/// 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
/// 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.
+6 -1
View File
@@ -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.
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
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).
+53 -2
View File
@@ -18,13 +18,15 @@
//! ["*"]` (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
//! "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 crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 5;
pub const FORMAT: u32 = 6;
pub fn format_string(n: u32) -> String {
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`.
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.
fn v4_to_v5(v: &mut Value) -> Result<()> {
@@ -198,6 +223,32 @@ mod tests {
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]
fn older_upgraded_newer_refused_others_rejected() {
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
+1 -1
View File
@@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
pub use document::*;
pub use eval::{
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 solvecraft_kernel as kernel;
+6
View File
@@ -34,6 +34,12 @@ impl NameCell {
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
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).
+14 -1
View File
@@ -273,7 +273,20 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
let mut warn = Vec::new();
let links = sk.links.clone();
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) => {
sk.set_link_source(&l.id, r.source);
let _ = sk.set_link_wires(&l.id, &r.wires);
+3 -1
View File
@@ -495,7 +495,9 @@ mod tests {
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
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": 999, "plane": "XY"})).is_err());
}
+1 -1
View File
@@ -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.
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
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.finish", json!({}))?;
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[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; \
flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at",
)];
+8 -3
View File
@@ -359,8 +359,10 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
}
Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) {
// A planar face of a body at this point.
let st = s.model.state();
// A planar face of a body at this point, as the active component sees the model
// (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 face_name = None;
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 s2 = (c - b).cross(p - b).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
})
}
+3
View File
@@ -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"));
}
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 id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
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,
flip: bool_(p, "flip").unwrap_or(false),
visible: true,
component,
});
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
}
+13 -1
View File
@@ -813,6 +813,12 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
let ci = sk.add_circle(cc, nr, Some(c), None)?;
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));
}
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}))
}
+13 -1
View File
@@ -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 (vals, _) = doc.param_values();
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)?;
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 {
+34 -9
View File
@@ -30,6 +30,12 @@ enum K {
Plane,
/// A body name, or a list of them.
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::*;
@@ -56,11 +62,12 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
"sketch.create" | "sketch.redefine" => &[("plane", Plane)],
"canvas.decal" => &[("face", Pick)],
"sketch.intersect" => &[("refs", Picks)],
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Pick), ("body", Body)],
"sketch.intersection_curve" => &[("a", Picks), ("b", Picks)],
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
"canvas.decal" => &[("face", Here)],
"canvas.insert" => &[("plane", HerePlane), ("at", Here)],
"sketch.project" | "sketch.intersect" | "sketch.include_3d" => &[("refs", Here), ("ref", Here)],
"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)],
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
@@ -75,6 +82,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
struct Frames {
bodies: Vec<(String, String, Mat)>,
active: Mat,
/// The frame of the sketch being edited (its component), else the active component's.
sketch: Mat,
}
fn frames(s: &Session) -> Frames {
@@ -104,7 +113,8 @@ fn frames(s: &Session) -> Frames {
(world, own, m)
})
.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)?
@@ -171,12 +181,14 @@ struct Mapper<'a> {
/// The frame of the command's first pick (its directions turn with it).
first: Option<Mat>,
changed: bool,
/// Picks go into the sketch's frame (`Here`), not the frame of the body under them.
here: bool,
}
impl Mapper<'_> {
fn pick(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
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.local_point(&m, p)
}
@@ -325,14 +337,14 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
return None;
}
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 mut out: Map<String, Value> = obj.clone();
// Picks first: their frame turns the directions.
for pass in [true, false] {
for (k, kind) in keys {
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 {
continue;
}
@@ -343,6 +355,19 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
Dir => m.dir(v),
Plane => m.plane(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 {
out.insert((*k).to_string(), n);
+103
View File
@@ -144,3 +144,106 @@ fn sketch_on_a_face_follows_a_moved_body() {
let c = sketch_point_world(&mut s, "OnTop", "p1");
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:?}");
}
+17 -2
View File
@@ -20,6 +20,21 @@ fn rel(a: f64, b: f64) -> f64 {
(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 robustness;
@@ -650,10 +665,10 @@ fn section_analysis() {
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
run(&mut s, "inspect.section", json!({"clear": true}));
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.
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)] {
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}");
+2 -2
View File
@@ -17,8 +17,8 @@ pub struct Plane {
impl Plane {
/// Top: sketch x = +X, y = +Y, normal +Z.
pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
/// Front: sketch x = +X, y = +Z, normal −Y.
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
/// Front: sketch x = +X, y = −Z, normal +Y.
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.
pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z };
+2 -2
View File
@@ -28,10 +28,10 @@ fn raycast_hits_front_face() {
#[test]
fn planes() {
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);
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));
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);
+2 -1
View File
@@ -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 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![] };
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 v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0);
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
+1 -1
View File
@@ -61,7 +61,7 @@ pub use lip::raise_band;
pub use measure::{BodyMeasure, measure};
pub use meshbody::{MAX_TRIANGLES, mesh_body};
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 polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
+10 -1
View File
@@ -15,6 +15,12 @@ pub enum BoolOp {
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
/// ray whose direction is derived from point coordinates; moving both solids (and the result
/// 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 all = ba.union(&bb);
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));
}
let (ia_idx, ib_idx, ir_idx) = (a.inside_index(), b.inside_index(), result.inside_index());
+128 -3
View File
@@ -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 tops: Vec<&mt::Face> = Vec::new();
let mut top_h = Vec::new();
let mut bottom_h = Vec::new();
for f in b.solid.face_iter() {
let surf = f.oriented_surface();
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;
}
if n.dot(d) < -1.0 + 1e-9 {
bottom_h.push(h);
lo = lo.min(h);
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.
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;
}
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 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>>> {
let size = a.size().max(b.size());
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));
}
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(&regions), area(&pa.regions), area(&pb.regions));
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))
} else {
+1 -1
View File
@@ -1208,7 +1208,7 @@ mod tests {
let m0 = measure(&c).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.
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 s = split_faces(&c, &[side], &tool).unwrap();
let m = measure(&s).unwrap();
+37 -3
View File
@@ -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 m = measure(&b).unwrap();
assert!(rel(m.volume, 250.0) < 1e-9);
// XZ normal is -Y: the body spans y in [-3, 2].
assert!((m.bbox.min.y + 3.0).abs() < 1e-6 && (m.bbox.max.y - 2.0).abs() < 1e-6, "{:?}", m.bbox);
// XZ normal is +Y: the body spans y in [-2, 3].
assert!((m.bbox.min.y + 2.0).abs() < 1e-6 && (m.bbox.max.y - 3.0).abs() < 1e-6, "{:?}", m.bbox);
}
#[test]
@@ -52,6 +52,39 @@ fn region_with_hole() {
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]
fn box_fillet_cut_volume() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
@@ -1344,7 +1377,8 @@ fn boolean_identity_survey() {
fn partial_revolve_on_the_axis() {
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![] };
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());
let v = measure(&pie).unwrap().volume;
assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}");
+2
View File
@@ -13,6 +13,7 @@ mod dims;
mod linalg;
mod link;
mod model;
mod offset;
mod profiles;
mod shape;
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 link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
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 shape::{Shape, intersections};
pub use solver::{Hold, SolveReport, SolveStatus, solve, solve_holding};
+76 -2
View File
@@ -104,6 +104,9 @@ pub struct Link {
#[serde(default)]
pub kind: LinkKind,
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.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lost: bool,
@@ -169,7 +172,7 @@ struct Layout {
curves: Vec<LCurve>,
}
#[derive(Clone, Copy, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq)]
enum LCurve {
Line(usize, usize),
Circle(usize, f64),
@@ -368,7 +371,7 @@ impl Sketch {
return Err(SketchError::TooLarge);
}
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)?;
Ok(id)
}
@@ -579,6 +582,77 @@ impl Sketch {
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.
self.drop_link_entities(id);
self.build_link(id, &lay)?;
+3
View File
@@ -467,6 +467,8 @@ pub struct Sketch {
/// Links to geometry outside the sketch (projections, intersections, includes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
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.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wires: Vec<crate::wire::Wire>,
@@ -495,6 +497,7 @@ impl Sketch {
curves: Vec::new(),
constraints: Vec::new(),
links: Vec::new(),
offsets: Vec::new(),
wires: Vec::new(),
counters: Default::default(),
view: SketchView::default(),
+116
View File
@@ -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(())
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ impl Sketch {
}
let id = if geom.is_empty() {
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
} else {
self.add_link(kind, source, geom)?
+25 -25
View File
@@ -232,41 +232,41 @@ const INDENT: f32 = 14.0;
/// One row's look.
#[derive(Default)]
struct Row<'a> {
depth: usize,
pub(crate) struct Row<'a> {
pub(crate) depth: usize,
/// Some(open) for rows that fold.
fold: Option<bool>,
pub(crate) fold: Option<bool>,
/// Some(visible) for rows with an eye.
eye: Option<bool>,
icon: &'a str,
label: &'a str,
selected: bool,
dim: bool,
color: Option<Color32>,
pub(crate) eye: Option<bool>,
pub(crate) icon: &'a str,
pub(crate) label: &'a str,
pub(crate) selected: bool,
pub(crate) dim: bool,
pub(crate) color: Option<Color32>,
/// Component rows: is it the active one?
radio: Option<bool>,
pub(crate) radio: Option<bool>,
/// Trailing badges: "lock", "pin".
badges: &'a [&'a str],
pub(crate) badges: &'a [&'a str],
}
/// What happened on a row.
#[derive(Default)]
struct RowResp {
rect: Option<Rect>,
clicked: bool,
double: bool,
secondary: Option<Pos2>,
fold: bool,
eye: bool,
radio: bool,
hovered: bool,
pub(crate) struct RowResp {
pub(crate) rect: Option<Rect>,
pub(crate) clicked: bool,
pub(crate) double: bool,
pub(crate) secondary: Option<Pos2>,
pub(crate) fold: bool,
pub(crate) eye: bool,
pub(crate) radio: bool,
pub(crate) hovered: bool,
/// 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).
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();
// 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));
@@ -449,11 +449,11 @@ fn sel_of_key(k: &str) -> Option<Sel> {
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) }
}
fn toggle(app: &mut SolveApp, key: &str, default: bool) {
pub(crate) fn toggle(app: &mut SolveApp, key: &str, default: bool) {
if default {
if !app.tree.collapsed.remove(key) {
app.tree.collapsed.insert(key.to_string());
+13 -5
View File
@@ -493,7 +493,10 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec<Item> {
let rigid = j.kind == solvecraft_engine::doc::joints::JointKind::Rigid;
vec![
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.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 })),
Item::sep(),
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.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
"ui.workspace" => {}
"ui.editJoint" | "ui.driveJoint" => {
"ui.findJoint" => {
if let Some(id) = id_of(&p, "joint") {
let d = if item.id == "ui.editJoint" {
crate::dialogs_assembly::edit_joint(app, id)
} else {
crate::dialogs_assembly::drive_joint(app, id)
crate::dialogs_assembly::find_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 {
app.tool = None;
+45 -3
View File
@@ -2691,11 +2691,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.
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 frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
let m = b.mesh();
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);
@@ -2708,10 +2747,12 @@ pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
}
/// 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 frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
let m = b.mesh();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some([a, bb, c]) = m.tri(t) else { continue };
@@ -2721,7 +2762,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> {
@@ -2756,6 +2797,7 @@ fn pt3(v: Vec3) -> Value {
/// 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.
pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dialog> {
let _refill = Refill::start();
let s = &app.session;
let f = s.doc.feature(id)?.clone();
let st = s.world_state();
+165 -60
View File
@@ -209,6 +209,38 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
}
/// 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> {
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![]))
@@ -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 (mut shown, scale) = if angle { (cur.to_degrees(), 1f64.to_degrees()) } else { (cur, 1.0) };
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));
if r.changed() {
*v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") };
}
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 } => {
@@ -826,6 +871,8 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
}
return;
}
// A joint names its two picks; a joint origin has one.
let two = matches!(k, Asm::Joint(_));
let hover = match k {
Asm::Joint(f) => Some(&mut f.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_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();
for (s, c) in &picked {
glyph(painter, proj, s, *c, app.cam.half_height());
for (n, (s, c)) in picked.iter().enumerate() {
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).
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)));
}
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,
@@ -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.
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 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) {
painter.line_segment([c, tip], Stroke::new(2.0, col));
let d = (tip - c).normalized();
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 {
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 => {
painter.circle(c, 6.0, col, 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(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.2, Color32::WHITE));
painter.circle_stroke(c, 7.0, 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.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
}
SnapKind::EdgeMid => {
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));
}
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,
));
SnapKind::EdgeMid | SnapKind::Vertex => {
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
}
}
// 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 ----
@@ -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
/// edits, drives, suppresses or deletes).
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)> =
app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect();
if joints.is_empty() {
return;
}
let fold_id = egui::Id::new("sc_browser_joints_open");
let mut open = ui.data(|d| d.get_temp::<bool>(fold_id)).unwrap_or(true);
let row = |ui: &mut egui::Ui, depth: f32, icon: &str, label: &str, dim: bool, fold: Option<bool>| -> egui::Response {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), egui::Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
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));
// Drawn like every other browser node (its fold and rows publish `fold:Joints`, `row:<name>`).
let key = "c0/joints";
let open = is_open(app, key, true);
let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Joints", ..Default::default() });
if r.fold || r.clicked {
toggle(app, key, true);
}
if !open {
return;
}
let mut dialog: Option<Dialog> = None;
for (id, name, kind, suppressed) in joints {
let resp = row(ui, depth + 1.0, "joint", &name, suppressed, None).on_hover_text(format!("{kind:?} joint"));
if resp.double_clicked() {
let r =
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);
}
// The same menus as the rest of the browser.
if resp.secondary_clicked() {
let at = resp.interact_pointer_pos().unwrap_or(resp.rect.left_bottom());
if let Some(at) = r.secondary {
crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id });
}
let _ = (kind, suppressed);
}
if let Some(d) = dialog {
app.tool = None;
@@ -1028,3 +1085,51 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
#[cfg(test)]
#[path = "dialogs_assembly_tests.rs"]
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!(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:?}");
}
+127 -8
View File
@@ -41,6 +41,10 @@ pub enum Mo {
tracks: Vec<Track>,
step: f64,
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).
shown: Option<(u64, u64)>,
saved: Option<BTreeMap<u64, Mat>>,
@@ -101,6 +105,8 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
tracks: Vec::new(),
step: 0.0,
playing: false,
looping: true,
speed: 2,
shown: None,
saved: None,
error: None,
@@ -116,6 +122,73 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
// ---- 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.
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())?;
@@ -321,7 +394,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
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");
ui.text_edit_singleline(name);
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();
return enter;
}
// Playback.
row_label(ui, "Step");
// Playback: to start, step back, play/stop, step on, to end; loop and speed.
row_label(ui, "Playback");
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;
}
ui.spacing_mut().slider_width = 220.0;
ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer());
if transport(ui, Transport::Forward, "Step on").clicked() {
*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();
if *playing {
let dt = ui.input(|i| i.stable_dt).min(0.1);
*step = (*step + f64::from(dt) * 24.0) % (f64::from(total) + 1.0);
let dt = f64::from(ui.input(|i| i.stable_dt).min(0.1));
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();
}
// 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 {
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.
#[test]
fn shown_poses_are_put_back() {
@@ -688,6 +805,8 @@ mod tests {
tracks: vec![],
step: 0.0,
playing: false,
looping: true,
speed: 2,
shown: None,
saved,
error: None,
+22 -2
View File
@@ -98,6 +98,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
/// Make tab `i` the active design.
pub fn switch(app: &mut SolveApp, i: usize) {
app.home.open = false;
if i == app.docs.active || i >= app.docs.slots.len() {
return;
}
@@ -293,6 +294,25 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value {
/// + for a new design.
pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
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 n = info.len();
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;
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 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());
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
// maximizes it.
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);
if presp.on_hover_text("New Design (Ctrl+N)").clicked() {
new_design(app);
app.home.show(app.session.revision);
}
if let Some(i) = shut {
close(app, i, false);
+53 -19
View File
@@ -1,6 +1,6 @@
//! 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
//! 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;
@@ -24,8 +24,7 @@ pub struct HomeState {
pub open: bool,
/// Recent design paths, newest first (kept in the preferences).
pub recent: Vec<String>,
/// The session revision when the page opened (a change closes it).
rev: u64,
pub opened_at: std::collections::BTreeMap<String, u64>,
thumbs: HashMap<String, Option<egui::TextureHandle>>,
samples: HashMap<usize, Session>,
/// Keys asked for and not made yet.
@@ -37,9 +36,8 @@ pub struct HomeState {
}
impl HomeState {
pub fn show(&mut self, rev: u64) {
pub fn show(&mut self, _rev: u64) {
self.open = true;
self.rev = rev;
}
/// Put a design at the top of the recent list.
@@ -47,6 +45,8 @@ impl HomeState {
self.recent.retain(|p| p != path);
self.recent.insert(0, path.to_string());
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);
}
}
@@ -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) {
app.home.recent.retain(|p| p != path);
app.set_status(format!("{path} is no longer there"), true);
return;
}
@@ -191,10 +190,6 @@ fn open_recent(app: &mut SolveApp, path: &str) {
/// The page itself (in place of the toolbar, browser and viewport).
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());
let t = Tokens::get();
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.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");
if recent.is_empty() {
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 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 folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let missing = !solvecraft_engine::io::vfs::exists(p);
let sub = if missing {
let mut sub = if missing {
"missing".to_string()
} else if p.starts_with("/opfs/") {
// The web build keeps designs in the browser's own storage.
"in this browser".to_string()
} else {
folder
p.clone()
};
if 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())
.clicked()
{
if let Some(time) = app.home.opened_at.get(p) {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time);
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();
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.
fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response {
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 hover = resp.hovered();
ui.painter().rect(
+23 -1
View File
@@ -155,6 +155,7 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> 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).
pub graphics_path: Option<String>,
}
@@ -304,6 +305,7 @@ impl SolveApp {
"browser_collapsed": self.tree.collapsed,
"browser_expanded": self.tree.expanded,
"recent": self.home.recent,
"recent_opened_at": self.home.opened_at,
"shortcut_box": self.sbox.prefs(),
"shortcuts": self.keymap.prefs(),
"toolbar": self.toolbar_custom.prefs(),
@@ -319,6 +321,7 @@ impl SolveApp {
pub fn load_prefs(&mut self, prefs: &str) {
let Ok(v) = serde_json::from_str::<Value>(prefs) else { return };
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") {
self.ui.dark = d;
}
@@ -430,6 +433,12 @@ impl SolveApp {
}
self.tool = None;
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 let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
self.insert_path(&p);
@@ -480,7 +489,20 @@ impl SolveApp {
{
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.set_status(tools::hint(id), false);
return;
+4 -2
View File
@@ -232,8 +232,10 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() {
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc.features.len(), n);
// 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();
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap();
let body = &app.session.model.state().bodies[0];
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);
assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Delete Face")), "{:?}", app.status);
assert_eq!(app.session.doc.features.len(), n);
+2 -2
View File
@@ -68,7 +68,7 @@ pub fn gpu_images(app: &SolveApp) -> Vec<GpuImage> {
.iter()
.filter(|c| c.visible && c.opacity > 0.0)
.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 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 })
@@ -105,7 +105,7 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj)
}
let (vals, _) = doc.param_values();
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 [bl, br, _, tl] = c.corners();
let (u, v) = (br - bl, tl - bl);
+9
View File
@@ -245,6 +245,15 @@ impl Harness {
}
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 out = match handle(&mut self.app, &self.ctx, &req) {
Outcome::Done(v) => v,
+53 -1
View File
@@ -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));
x += 30.0;
let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click());
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
if resp.hovered() {
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| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
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| {
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") {
crate::documents::new_design(app);
@@ -161,6 +165,24 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
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…", "") {
app.start("file.insert_step");
close = true;
@@ -308,11 +330,16 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let top = r.top() + 26.0;
let mut drop: Option<(&'static str, &'static str, Option<&'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) {
let panel: &&str = &panel;
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
let n = shown.len().max(1);
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";
// Dropped on the panel's free space: the button goes to its end.
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));
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));
if let Some((id, panel, before)) = drop {
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
+13 -23
View File
@@ -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.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.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(),
_ 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()),
@@ -142,31 +142,21 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
Kind::Dimension => {
let hits = pick(app, proj, pos);
let hit = hits.iter().find_map(|h| match h {
Hit::SketchPoint { id, .. } => Some((id.clone(), true)),
Hit::SketchCurve { id, .. } => Some((id.clone(), false)),
Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()),
_ => None,
});
if let Some((id, is_point)) = hit {
tool.picks.push(id.clone());
let st = app.session.world_state();
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();
if let Some(id) = hit {
if !tool.picks.contains(&id) && tool.picks.len() < 2 {
tool.picks.push(id);
}
} 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();
}
}
}
+1
View File
@@ -1682,6 +1682,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
app.viewport.boxsel = None;
}
crate::ref_images::show(app, ui, &painter, &proj);
crate::dialogs_assembly::origins(app, &painter, &proj);
overlays(app, &painter, &proj);
crate::dim_view::show(app, ui, &painter, &proj);
crate::sketch3d::show(app, &painter, &proj);
+95
View File
@@ -31,3 +31,98 @@ fn adhoc() {
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();
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":"$DIR/recent.solvecraft"}}},
{"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();
let path = h.dir.join("recent.solvecraft").to_string_lossy().into_owned();
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."
},
@@ -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",
"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."
},
@@ -50,6 +50,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "50"
},
@@ -40,6 +40,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "40"
},
@@ -76,6 +76,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -84,6 +92,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -99,7 +113,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -116,16 +130,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"text": "20"
@@ -199,7 +210,7 @@
"max": [
null,
null,
20
30
]
}
}
@@ -231,4 +242,4 @@
{
"shot": "s07_driven"
}
]
]
@@ -40,6 +40,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "40"
},
@@ -95,6 +95,14 @@
]
}
},
{
"click": {
"sketch": [
20,
-8
]
}
},
{
"text": "40"
},
@@ -109,6 +117,14 @@
]
}
},
{
"click": {
"sketch": [
48,
12
]
}
},
{
"text": "25"
},
@@ -123,6 +139,14 @@
]
}
},
{
"click": {
"sketch": [
30,
20
]
}
},
{
"text": "10"
},
@@ -219,4 +243,4 @@
{
"shot": "s11_done"
}
]
]
@@ -76,6 +76,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -84,6 +92,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -99,7 +113,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -116,16 +130,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"text": "20"
@@ -236,4 +247,4 @@
{
"shot": "s20_section_driven"
}
]
]
@@ -43,6 +43,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "40"
},
@@ -76,6 +76,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -84,6 +92,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -99,7 +113,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -116,16 +130,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"text": "20"
@@ -240,4 +251,4 @@
{
"shot": "s26_drive_typed"
}
]
]
@@ -102,7 +102,7 @@
"click": {
"sketch": [
52,
10
-10
]
}
},
@@ -187,7 +187,7 @@
"click": {
"sketch": [
48,
25
15
]
}
},
@@ -239,4 +239,4 @@
{
"shot": "s27_cut"
}
]
]
@@ -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."
},
@@ -82,6 +79,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -90,6 +95,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -105,7 +116,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -122,16 +133,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"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)."
},
@@ -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": "ui.set", "params": {"upAxis": "y"}},
{"call": "ui.prefs", "params": {"up_axis": "y"}},
{"call": "ui.view", "params": {"view": "home"}},
{"move": {"cube": [0, 0, 1]}},
{"expect": {"shown_handles": ["cube_hover:top"]}},
+22
View File
@@ -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.