diff --git a/ROADMAP.md b/ROADMAP.md index 30c1bd1f..22ff428c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | -| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — | +| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — | | M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 | | M11 | 2D drawings | deferred | — | diff --git a/assets/locales/de.json b/assets/locales/de.json index 73b59ba0..58467f62 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -42,6 +42,7 @@ "Faces": "Flächen", "Find in Browser": "Im Browser suchen", "Flat": "Flach", + "Gap": "Spalt", "Hidden edges": "Verdeckte Kanten", "Holes": "Löcher", "Holes (edges on them)": "Löcher (ihre Kanten)", @@ -812,6 +813,9 @@ "ui.constraints_conflict": "Bedingungen konfliktreich", "ui.construct": "Konstruktion", "ui.construction": "Konstruktionslinie", + "ui.continuity_g0": "G0 Knick", + "ui.continuity_g1": "G1 Tangente", + "ui.continuity_g2": "G2 Krümmung", "ui.copy": "Kopieren", "ui.corner": "Ecke", "ui.corners": "Ecken", diff --git a/assets/locales/en.json b/assets/locales/en.json index 79d7925d..938b3274 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1056,6 +1056,9 @@ "ui.construct": "Construct", "ui.construction": "Construction", "ui.context_menu": "Context menu", + "ui.continuity_g0": "G0 crease", + "ui.continuity_g1": "G1 tangent", + "ui.continuity_g2": "G2 curvature", "ui.copy": "Copy", "ui.corner": "Corner", "ui.corners": "Corners", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 87bdba2f..2a6a35e1 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1056,6 +1056,9 @@ "ui.construct": "構築", "ui.construction": "コンストラクション", "ui.context_menu": "コンテキスト メニュー", + "ui.continuity_g0": "G0 折れ", + "ui.continuity_g1": "G1 接線", + "ui.continuity_g2": "G2 曲率", "ui.copy": "コピー", "ui.corner": "コーナー", "ui.corners": "コーナー", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index f6438fef..928f4b65 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -42,6 +42,7 @@ "Faces": "면", "Find in Browser": "브라우저에서 찾기", "Flat": "플랫", + "Gap": "틈", "Hidden edges": "숨겨진 모서리", "Holes": "구멍", "Holes (edges on them)": "구멍 (구멍의 모서리)", @@ -812,6 +813,9 @@ "ui.constraints_conflict": "구속 조건 충돌", "ui.construct": "구성", "ui.construction": "구성선", + "ui.continuity_g0": "G0 꺾임", + "ui.continuity_g1": "G1 접선", + "ui.continuity_g2": "G2 곡률", "ui.copy": "복사", "ui.corner": "코너", "ui.corners": "코너", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 053e83a7..40120ece 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1056,6 +1056,9 @@ "ui.construct": "构造", "ui.construction": "构造", "ui.context_menu": "上下文菜单", + "ui.continuity_g0": "G0 折痕", + "ui.continuity_g1": "G1 相切", + "ui.continuity_g2": "G2 曲率", "ui.copy": "复制", "ui.corner": "角", "ui.corners": "角点", diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index afe8f013..a03f7576 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -98,6 +98,12 @@ impl Tokens { bend_up: Color32::from_rgb(30, 140, 50), bend_down: Color32::from_rgb(200, 90, 20), interference: Color32::from_rgb(235, 45, 40), + continuity: [ + Color32::from_rgb(0, 158, 115), + Color32::from_rgb(0, 114, 178), + Color32::from_rgb(240, 200, 0), + Color32::from_rgb(204, 0, 140), + ], analysis: AnalysisColors::DEFAULT, icons: IconColors { sheet: Color32::from_rgb(170, 200, 240), diff --git a/crates/engine/src/cmd/sketch_inspect.rs b/crates/engine/src/cmd/sketch_inspect.rs index b387218b..10e63152 100644 --- a/crates/engine/src/cmd/sketch_inspect.rs +++ b/crates/engine/src/cmd/sketch_inspect.rs @@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "INSPECT") .icon("curvature_comb") .noundo() - .params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges]; density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"), + .params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges] | faces?: [[x,y,z] points on faces] with count?: isoparametric curves each way (default 4; combs along the face's iso grid); density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"), CommandSpec::new("inspect.minimum_radius", "Minimum Radius Analysis", minimum_radius) .at("SKETCH", "INSPECT") .icon("min_radius") @@ -113,8 +113,25 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result { combs.push((format!("edge@{},{},{}", at.x, at.y, at.z), teeth_3d(&pts))); } } + if let Some(faces) = p.get("faces") { + let faces = faces.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?; + let count = match p.get("count") { + None | Some(Value::Null) => 4, + Some(_) => { + num(p, "count").filter(|c| c.is_finite()).map(|c| c.clamp(1.0, 50.0) as usize).ok_or_else(|| bad(cmd, "`count` must be a number"))? + } + }; + for v in faces.iter().take(100) { + let at = vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?; + let (bi, f) = face_at(&st, at).ok_or_else(|| bad(cmd, "no face there"))?; + let m = st.bodies.get(bi).map(|b| b.mesh()).ok_or_else(|| bad(cmd, "that face's body is not evaluated"))?; + for (k, w) in solidcraft_doc::iso_grid(&m, f, at, count).iter().enumerate() { + combs.push((format!("face@{},{},{}#{k}", at.x, at.y, at.z), teeth_3d(w))); + } + } + } if combs.is_empty() { - return Err(bad(cmd, "give `curves` or `edges`")); + return Err(bad(cmd, "give `curves`, `edges` or `faces`")); } // One scale for all combs: the longest tooth is a fifth of the largest curve's size. let kmax = combs.iter().flat_map(|c| c.1.iter().map(|t| t.1.abs())).fold(0.0_f64, f64::max); @@ -348,6 +365,15 @@ mod tests { let r = s.execute("inspect.curvature_comb", &json!({"edges": [[58, 0, 10]]})).unwrap(); assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}"); assert!(s.execute("inspect.curvature_comb", &json!({})).is_err()); + // Combs along the side face's isocurves: rings of radius 8 and straight lines along it. + let r = s.execute("inspect.curvature_comb", &json!({"faces": [[58, 0, 5]], "count": 3})).unwrap(); + let combs = r["combs"].as_array().unwrap(); + assert!(combs.len() >= 6, "{}", combs.len()); + let rings = combs.iter().filter(|c| c["min_radius"].as_f64().is_some_and(|m| (m - 8.0).abs() < 0.5)).count(); + let straight = combs.iter().filter(|c| c["min_radius"].is_null() || c["min_radius"].as_f64().is_some_and(|m| m > 1e3)).count(); + assert!(rings >= 3 && straight >= 3, "{rings} rings, {straight} straight: {r}"); + assert!(s.execute("inspect.curvature_comb", &json!({"faces": []})).is_err()); + assert!(s.execute("inspect.curvature_comb", &json!({"faces": [[58, 0, 5]], "count": "x"})).is_err()); } #[test] diff --git a/crates/engine/src/cmd/surface_analysis.rs b/crates/engine/src/cmd/surface_analysis.rs index b0f6e456..9753f8d9 100644 --- a/crates/engine/src/cmd/surface_analysis.rs +++ b/crates/engine/src/cmd/surface_analysis.rs @@ -49,6 +49,7 @@ pub static COMMANDS: &[CommandSpec] = &[ "body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \ a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \ gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01); \ + add?: true keeps the seams already shaded and adds these (measuring an edge again replaces it); \ clear?: true turns the analysis off. → each edge's worst gap (G0), normal angle (G1) and curvature jump (G2) as \ {value, at: [x,y,z], edge_fraction}, its verdict (smooth, curvature_jump, sharp or gap) and a summary; \ the seams are shaded in the viewport", @@ -177,7 +178,7 @@ fn continuity(s: &mut Session, p: &Value) -> Result { let mut rows = Vec::new(); for seam in &report.seams { summary.count(seam.verdict); - seams.push(ContinuitySeam { points: seam.points.clone(), verdict: seam.verdict }); + seams.push(ContinuitySeam { body: body.name.clone(), edge: seam.edge, points: seam.points.clone(), verdict: seam.verdict }); rows.push(json!({ "edge": seam.edge, "length_mm": seam.length, @@ -197,7 +198,13 @@ fn continuity(s: &mut Session, p: &Value) -> Result { "unmeasured": report.unmeasured, })); } - s.continuity = seams; + if bool_(p, "add").unwrap_or(false) { + // The seams shown stay; an edge measured again takes its new reading. + s.continuity.retain(|kept| !seams.iter().any(|new| new.body == kept.body && new.edge == kept.edge)); + s.continuity.extend(seams); + } else { + s.continuity = seams; + } s.revision += 1; Ok(json!({ "analysis": "continuity", @@ -429,6 +436,27 @@ mod tests { assert_eq!(s.undo.len(), undo); } + /// `add` keeps what is shaded and adds the picked edges: an edge measured again replaces its + /// seam instead of drawing it twice, and a call without `add` starts over. + #[test] + fn continuity_add_keeps_the_seams_shown() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]]})).unwrap(); + assert_eq!(s.continuity.len(), 1); + let v = s.execute("inspect.continuity", &json!({"edges": [[0, 5, 0]], "add": true})).unwrap(); + assert_eq!(edges(&v).len(), 1, "the reply covers the edges this call measured: {v}"); + assert_eq!(s.continuity.len(), 2, "the first seam stays"); + s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]], "add": true, "samples": 5})).unwrap(); + assert_eq!(s.continuity.len(), 2, "measured again, not added twice"); + assert!(s.continuity.iter().any(|seam| seam.points.len() == 5), "with its new reading"); + assert!(s.continuity.iter().all(|seam| seam.body == "Body1"), "{:?}", s.continuity); + s.execute("inspect.continuity", &json!({"edges": [[10, 5, 0]]})).unwrap(); + assert_eq!(s.continuity.len(), 1, "without `add` the shading starts over"); + s.execute("inspect.continuity", &json!({"add": true})).unwrap(); + assert_eq!(s.continuity.len(), 12, "every edge once"); + } + /// Hostile parameters are refused with an error, never a panic. #[test] fn hostile_continuity_params_are_refused() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 99e82f61..a0c33cd1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -309,6 +309,10 @@ pub enum SurfaceAnalysis { /// sampled at, in world coordinates, and how it reads. View state only: the design is untouched. #[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct ContinuitySeam { + /// The body the seam is an edge of. + pub body: String, + /// The edge's index in the body's display mesh edge order. + pub edge: usize, /// Sample points along the seam, in world coordinates. pub points: Vec, /// The seam's worst verdict along its length. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 777f9498..41505eb2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -806,6 +806,7 @@ fn hostile_params_never_panic() { "scale", "continuity", "crown", + "faces", ]; // Commands are independent; worker threads take them one at a time (their costs vary a lot). let ids: Vec<&str> = command_specs() diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index 9069f2b7..cbb98da3 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -298,6 +298,16 @@ fn groups(t: &Tokens) -> Vec { ), group("section", &[("section_cap", t.section_cap)], &[("body", t.body)]), group("interference", &[("interference", t.interference)], &[("body", t.body)]), + group( + "continuity seams", + &[ + ("continuity[0]", t.continuity[0]), + ("continuity[1]", t.continuity[1]), + ("continuity[2]", t.continuity[2]), + ("continuity[3]", t.continuity[3]), + ], + &[("body", t.body), ("viewport_top", t.viewport_top)], + ), // The scale's three stops, unlit. Shaded (a stop at one angle to the light, the next at // another), middle and tight merge for a red-green loss (3.3 protan, 5.2 deutan) and flat // and middle for tritan (4.6). That is the scale's own fault, in Off too: #975. diff --git a/crates/ui-egui/src/sketch_continuity_tests.rs b/crates/ui-egui/src/sketch_continuity_tests.rs new file mode 100644 index 00000000..b624c8ab --- /dev/null +++ b/crates/ui-egui/src/sketch_continuity_tests.rs @@ -0,0 +1,75 @@ +//! The Continuity Analysis toolbar tool (#770): the button shades every seam, a click on an edge +//! pins its numbers, the button again turns the analysis off, and a legend names the colours. + +use super::*; + +fn marks() -> Vec { + MARKS.with(|m| m.borrow().iter().map(|(_, label)| label.clone()).collect()) +} + +/// The button measures every edge of the shown bodies and leaves the tool picking edges; a +/// click on an edge pins its class and numbers without dropping the other seams; the button +/// again clears the shading and ends the tool. Without bodies it starts nothing. +#[test] +fn the_button_shades_every_seam_pins_an_edge_and_turns_off() { + let mut h = crate::scenario::Harness::new(); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + assert!(h.app.tool.is_none() && h.app.session.continuity.is_empty(), "nothing to measure"); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + h.settle(); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + assert_eq!(h.app.session.continuity.len(), 12, "every edge of the box"); + assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("inspect.continuity")); + h.step(&json!({"click": {"world": [10, 0, 20]}})).unwrap(); + assert_eq!(marks(), ["G0 90.000\u{b0}"], "{:?}", h.app.status); + assert_eq!(h.app.session.continuity.len(), 12, "the clicked edge is measured again, not added twice"); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + assert!(h.app.session.continuity.is_empty() && h.app.tool.is_none()); + h.frame(); + assert!(marks().is_empty(), "the markers go with the tool"); +} + +/// A round's tangent seam reads G1 with its curvature jump (1 / 4 mm against the flat side). +#[test] +fn a_tangent_seam_is_labelled_with_its_curvature_jump() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + h.app.run("solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 4})).unwrap(); + h.settle(); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + // The seam where the round meets the front face (y = 0), halfway up. + h.step(&json!({"click": {"world": [16, 0, 10]}})).unwrap(); + let labels = marks(); + assert_eq!(labels.len(), 1, "{labels:?} {:?}", h.app.status); + assert!(labels[0].starts_with("G1 0.000\u{b0} \u{394}k 0.2500/mm"), "{labels:?}"); +} + +/// The legend shows while seams are shaded and goes with the analysis. +#[test] +fn the_legend_shows_while_seams_are_shaded() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + h.settle(); + assert!(crate::scenario::rect_of("continuity_legend").is_none()); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + h.frame(); + let legend = crate::scenario::rect_of("continuity_legend").expect("the legend is drawn"); + assert!(legend.height() > 4.0 * 18.0, "four rows: {legend:?}"); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + h.frame(); + assert!(crate::scenario::rect_of("continuity_legend").is_none(), "gone with the analysis"); +} + +/// While the tool runs, only what its click takes lights up under the pointer: an edge, never +/// the face around it, whose outline would paint over the seam colours. +#[test] +fn only_edges_light_up_under_the_tool() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + h.settle(); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + h.step(&json!({"move": {"world": [10, 10, 20]}})).unwrap(); + assert_eq!(h.app.viewport.hover, None, "the top face is not lit"); + h.step(&json!({"move": {"world": [10, 0, 20]}})).unwrap(); + assert!(matches!(h.app.viewport.hover, Some(Hit::Edge { .. })), "{:?}", h.app.viewport.hover); +} diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 34a14c47..a6e20fe1 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -84,6 +84,44 @@ fn analysis(app: &mut SolveApp, cmd: &str, p: Value) { } } +/// A seam from an `inspect.continuity` reply as a marker label: its class and the measure that +/// decided it (the gap, the angle between the faces' normals, or the curvature jump). +fn continuity_label(app: &SolveApp, seam: &Value) -> String { + let deg = |key: &str| seam[key]["value"].as_f64().map(|x| format!("{x:.3}\u{b0}")).unwrap_or_default(); + let jump = seam["curvature_jump_per_mm"]["value"].as_f64().map(|k| format!("\u{394}k {k:.4}/mm")).unwrap_or_default(); + match seam["verdict"].as_str().unwrap_or_default() { + "gap" => format!("Gap {}", seam["gap_mm"]["value"].as_f64().map(|g| crate::prefs::show_mm(app, g, 1)).unwrap_or_default()), + "sharp" => format!("G0 {}", deg("normal_angle_deg")), + "curvature_jump" => format!("G1 {} {jump}", deg("normal_angle_deg")), + "smooth" => format!("G2 {} {jump}", deg("normal_angle_deg")), + other => other.to_string(), + } +} + +/// The Continuity Analysis legend in the viewport's lower left corner: each class in its +/// colour, while seams are shaded. +fn continuity_legend(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + if app.session.continuity.is_empty() { + return; + } + let tk = crate::theme::Tokens::get(); + let [smooth, jump, sharp, gap] = tk.continuity; + let tr = crate::i18n::tr_label; + let rows = [(tr("G2 curvature"), smooth), (tr("G1 tangent"), jump), (tr("G0 crease"), sharp), (tr("Gap"), gap)]; + let font = egui::FontId::proportional(12.0); + let (row, pad, swatch) = (18.0, 8.0, 22.0); + let text_width = rows.iter().map(|(label, _)| painter.layout_no_wrap(label.to_string(), font.clone(), tk.text).size().x).fold(0.0, f32::max); + let size = egui::vec2(pad + swatch + 8.0 + text_width + pad, rows.len() as f32 * row + 2.0 * pad); + let frame = egui::Rect::from_min_size(proj.rect.left_bottom() + egui::vec2(16.0, -(size.y + 16.0)), size); + painter.rect_filled(frame, 4.0, tk.panel.gamma_multiply(0.9)); + crate::scenario::publish_rect("continuity_legend", frame); + for (i, (label, col)) in rows.iter().enumerate() { + let y = frame.top() + pad + row * (i as f32 + 0.5); + painter.line_segment([egui::pos2(frame.left() + pad, y), egui::pos2(frame.left() + pad + swatch, y)], Stroke::new(3.0, *col)); + painter.text(egui::pos2(frame.left() + pad + swatch + 8.0, y), egui::Align2::LEFT_CENTER, *label, font.clone(), tk.text); + } +} + /// Isocurve analysis lines. fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { if app.tool.as_ref().map(|t| t.cmd) != Some("inspect.isocurve") { @@ -104,7 +142,7 @@ fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { /// Analysis markers: a target with its label. fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { - if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "inspect.minimum_radius" | "inspect.center_of_mass")) { + if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.continuity")) { MARKS.with(|m| m.borrow_mut().clear()); return; } @@ -149,6 +187,25 @@ pub fn start_hook(app: &mut SolveApp, id: &str) -> bool { } return false; } + if id == "inspect.continuity" { + // Every edge of the shown bodies at once; the tool stays for clicking single edges. With + // seams shaded, the button turns the analysis off. + MARKS.with(|m| m.borrow_mut().clear()); + if !app.session.continuity.is_empty() { + if app.run(id, json!({"clear": true})).is_ok() { + app.set_status("Analysis off", false); + } + return false; + } + let Ok(v) = app.run(id, json!({})) else { return false }; + let summary = &v["summary"]; + let counts: Vec = [("smooth", "G2"), ("curvature_jump", "G1"), ("sharp", "G0"), ("gap", "gap")] + .iter() + .filter_map(|(key, class)| summary[*key].as_u64().filter(|n| *n > 0).map(|n| format!("{n} {class}"))) + .collect(); + app.set_status(format!("Continuity: {} (click the button again to turn it off)", counts.join(", ")), false); + return true; + } if id == "inspect.center_of_mass" { // The whole model at once; the tool stays for picking single bodies. MARKS.with(|m| m.borrow_mut().clear()); @@ -346,11 +403,21 @@ enum Mode { Steps, } +/// Whether a click with tool `id` acts on `hit`: what is under the pointer lights up only when +/// the tool takes it, so the hover shows what a click does and lights nothing over a view the +/// tool draws (Continuity Analysis's seam colours on a face the tool ignores). +pub fn takes(id: &str, hit: &Hit) -> bool { + match id { + "inspect.continuity" => matches!(hit, Hit::Edge { .. }), + _ => true, + } +} + fn mode(id: &str) -> Option { Some(match id { "sketch.project" | "sketch.intersect" | "sketch.include_3d" | "sketch.fit_curves_to_section" | "sketch.isoparametric_curve" => Mode::ModelRef, "inspect.curvature_comb" | "canvas.insert" | "canvas.decal" => Mode::ModelRef, - "inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.isocurve" => Mode::ModelRef, + "inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.isocurve" | "inspect.continuity" => Mode::ModelRef, "sketch.trim" | "sketch.extend" | "sketch.break" => Mode::CurveAt, "sketch.line.midpoint" => Mode::Points(2), "sketch.arc.tangent" => Mode::Points(2), @@ -398,6 +465,7 @@ pub fn hint(id: &str) -> Option { "inspect.minimum_radius" => "Minimum radius: click sketch curves, edges or faces", "inspect.center_of_mass" => "Center of mass of all bodies; click a body for its own", "inspect.isocurve" => "Isocurves: click faces", + "inspect.continuity" => "Continuity: click an edge for its numbers (the button again turns the colours off)", "canvas.insert" => "Canvas: click a plane or a planar face where the image's centre goes", "canvas.decal" => "Decal: click a planar face where the image's centre goes", "sketch.trim" => "Trim: click the piece of a curve to remove", @@ -596,6 +664,17 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } } + Mode::ModelRef if cmd == "inspect.continuity" => { + // The edge measured again, added to the seams shown, and its numbers pinned there. + let edge = hits.iter().filter(|h| takes(cmd, h)).find_map(|h| if let Hit::Edge { mid, .. } = h { Some(*mid) } else { None }); + if let Some(mid) = edge + && let Ok(v) = app.run(cmd, json!({"edges": [[mid.x, mid.y, mid.z]], "add": true})) + && let Some(seam) = v["bodies"][0]["edges"].get(0) + { + let label = continuity_label(app, seam); + MARKS.with(|m| m.borrow_mut().push((mid, label))); + } + } Mode::ModelRef if matches!(cmd, "inspect.minimum_radius" | "inspect.center_of_mass") => { let params = hits.iter().find_map(|h| match (cmd, h) { ("inspect.minimum_radius", Hit::SketchCurve { sketch, id, .. }) => Some(json!({"sketch": sketch, "curves": [id]})), @@ -1134,8 +1213,13 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P } marks(app, painter, proj); iso_lines(app, painter, proj); + continuity_legend(app, painter, proj); } #[cfg(test)] #[path = "sketch_corner_ui_tests.rs"] mod corner_ui_tests; + +#[cfg(test)] +#[path = "sketch_continuity_tests.rs"] +mod continuity_tests; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 101c8fc1..e85fde5c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -288,6 +288,10 @@ pub struct Tokens { pub bend_down: Color32, /// Interference: the clash fill, before its shading. pub interference: Color32, + /// Continuity Analysis seams, best to worst as `inspect.continuity` judges them: smooth (G2), + /// tangent with a curvature jump (G1), a crease (G0) and a gap. Bluish green, blue, yellow + /// and reddish purple stay apart in every colour vision mode, so no mode changes them. + pub continuity: [Color32; 4], pub analysis: AnalysisColors, pub icons: IconColors, /// The application bar's buttons: ink, fill and accent of their icons. diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 7256d223..b3297d56 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -695,7 +695,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { // Continuity analysis: each seam `inspect.continuity` measured, drawn over the model in the // colour of its worst verdict. for seam in &s.continuity { - let col = continuity_color(seam.verdict); + let col = c4(continuity_color(&tk, seam.verdict)); for w in seam.points.windows(2) { if let (Some(a), Some(b)) = (w.first(), w.get(1)) { sc.line(a.to_f32(), b.to_f32(), col, 2.6, true); @@ -706,16 +706,17 @@ fn build_scene(app: &SolveApp) -> GpuScene { sc } -/// The colour of a measured seam: green within every tolerance, yellow where the faces only -/// meet at an angle (over G1), red where they do not meet at all or the curvature jumps across -/// them (over G2, the defect no chord tolerance shows). The greens, yellows and reds are the -/// ones the shader's own analyses use (`assets/shaders/viewport.wgsl`). -fn continuity_color(verdict: solidcraft_engine::kernel::SeamVerdict) -> [u8; 4] { +/// The colour of a measured seam, one per verdict from the theme's `continuity` tokens: within +/// every tolerance (G2), tangent with a curvature jump (G1), a crease (G0), and faces that do +/// not meet. +fn continuity_color(t: &Tokens, verdict: solidcraft_engine::kernel::SeamVerdict) -> Color32 { use solidcraft_engine::kernel::SeamVerdict as V; + let [smooth, jump, sharp, gap] = t.continuity; match verdict { - V::Smooth => [51, 191, 77, 255], - V::Sharp => [242, 204, 38, 255], - V::CurvatureJump | V::Gap => [217, 56, 51, 255], + V::Smooth => smooth, + V::CurvatureJump => jump, + V::Sharp => sharp, + V::Gap => gap, } } @@ -1668,6 +1669,12 @@ pub fn hit_sel(h: &Hit) -> Option { }) } +/// What lights up under the pointer while a tool runs: the nearest hit its click takes. +fn tool_hover(app: &SolveApp, hits: &[Hit]) -> Option { + let cmd = app.tool.as_ref()?.cmd; + hits.iter().find(|h| crate::sketch_tools::takes(cmd, h)).cloned() +} + /// What a click would pick: the first hit the open dialog's active input accepts, or with no /// dialog the first hit (sketch entities only belong to the active sketch). pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> { @@ -2314,7 +2321,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // corner drawn from the origin is the fixed origin, which ends no two curves. let corner = hover.filter(|_| inside && !navigating).and_then(|p| crate::sketch_modify_ui::corner_hit(app, &proj, p)); let cand = if app.tool.is_some() { - hits.first().cloned().map(|h| (h, None)) + tool_hover(app, &hits).map(|h| (h, None)) } else if let Some((hit, sel)) = corner { Some((hit, Some(sel))) } else { @@ -3507,6 +3514,127 @@ mod tests { use super::*; use crate::navigation::*; + /// Every tangent seam Continuity Analysis reports keeps its colour while the pointer rests on + /// a face (#1247): a 60 x 40 x 20 box with its four top edges rounded (6 mm) has eight, four + /// against the sides and four against the top face. The tool lit the face under the pointer, + /// though its click takes only edges, and the face's outline covered the colours of its + /// edges. Rendered offscreen with bodies built on the CPU and evaluated on the GPU (skipped + /// without a GPU adapter); with `SOLIDCRAFT_CONTINUITY_TOP_SHOT` set, the pictures are + /// written there (`-cpu.png`, `-gpu.png`). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn continuity_colours_stay_on_the_edges_of_the_face_under_the_pointer() { + use solidcraft_engine::kernel::SeamVerdict; + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 20, "body_name": "B"})).unwrap(); + s.execute("solid.fillet", &json!({"edges": [[30, 0, 20], [30, 40, 20], [0, 20, 20], [60, 20, 20]], "radius": 6})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.fit_view(); + // The toolbar button: every seam shaded, the tool left picking edges. + app.start("inspect.continuity"); + let tangent = |app: &SolveApp| app.session.continuity.iter().filter(|seam| seam.verdict == SeamVerdict::CurvatureJump).count(); + assert_eq!(tangent(&app), 8); + const SIZE: u32 = 512; // rows of 256-byte multiples (the copy alignment) + let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); + // The pointer rests in the middle of the top face, away from its edges: the tool takes + // only edges, so nothing there lights up over the analysis. + let proj = projection(&app, rect); + let middle = proj.to_screen(Vec3::new(30.0, 20.0, 20.0)).unwrap(); + let hits = pick_cached(&mut app, &proj, middle); + assert!(matches!(hits.first(), Some(Hit::Face { .. })), "{hits:?}"); + app.viewport.hover = tool_hover(&app, &hits); + assert_eq!(app.viewport.hover, None); + let t = Tokens::get(); + let g1 = t.continuity[1]; + // Each tangent seam lies in one flat face (the top, or a side under its round): that + // face's outward normal, and the seam shows where it faces the eye (the body is convex). + let flat_normal = |e: &[Vec3]| { + let on = |f: fn(&Vec3) -> f64, at: f64| e.iter().all(|p| (f(p) - at).abs() < 1e-6); + [ + (on(|p| p.z, 20.0), Vec3::Z), + (on(|p| p.y, 0.0), -Vec3::Y), + (on(|p| p.y, 40.0), Vec3::Y), + (on(|p| p.x, 0.0), -Vec3::X), + (on(|p| p.x, 60.0), Vec3::X), + ] + .into_iter() + .find_map(|(lies, n)| lies.then_some(n)) + }; + for tess in [false, true] { + app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); + let proj = projection(&app, rect); + let e = proj.cam.eye().to_f32(); + let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]; + let Some(img) = crate::gpu::render_offscreen( + build_scene(&app).merged(), + Some(build_highlight(&app)), + proj.vp.to_f32(), + proj.cam.back().to_f32(), + eye, + SIZE, + tess.then_some(solidcraft_gpu::SHADER), + [0.0; 8], + crate::gpu::analysis_colors(&t.analysis), + ) else { + eprintln!("skipping: no GPU adapter"); + return; + }; + if let Ok(path) = std::env::var("SOLIDCRAFT_CONTINUITY_TOP_SHOT") + && let Some(png) = image::RgbaImage::from_raw(SIZE, SIZE, img.clone()) + { + png.save(path.replace(".png", if tess { "-gpu.png" } else { "-cpu.png" })).unwrap(); + } + let lit = |x: i64, y: i64| { + (0..SIZE as i64).contains(&x) + && (0..SIZE as i64).contains(&y) + && img[((y * SIZE as i64 + x) * 4) as usize..][..3].iter().zip([g1.r(), g1.g(), g1.b()]).all(|(a, b)| a.abs_diff(b) <= 12) + }; + let mut seen = 0; + for seam in app.session.continuity.iter().filter(|seam| seam.verdict == SeamVerdict::CurvatureJump) { + let (a, b) = (seam.points[0], seam.points[seam.points.len() - 1]); + let normal = flat_normal(&seam.points).unwrap_or_else(|| panic!("a tangent seam off the flat faces: {a:?}..{b:?}")); + if normal.dot(proj.cam.eye() - a.lerp(b, 0.5)) <= 0.0 { + continue; + } + seen += 1; + // In its colour a quarter, half and three quarters along. + for f in [0.25, 0.5, 0.75] { + let p = a.lerp(b, f); + let q = proj.to_screen(p).unwrap(); + let (x, y) = (q.x.round() as i64, q.y.round() as i64); + let drawn = (-2..=2).any(|dy| (-2..=2).any(|dx| lit(x + dx, y + dy))); + assert!(drawn, "tess {tess}: tangent seam {a:?}..{b:?} is not drawn in its colour at {p:?} ({x}, {y})"); + } + } + assert_eq!(seen, 6, "the four top seams and the two sides facing the eye"); + } + // On an edge, the edge lights up: what a click measures. + let proj = projection(&app, rect); + let on_edge = proj.to_screen(Vec3::new(30.0, 6.0, 20.0)).unwrap(); + let hits = pick_cached(&mut app, &proj, on_edge); + assert!(matches!(tool_hover(&app, &hits), Some(Hit::Edge { .. })), "{hits:?}"); + } + + /// Continuity Analysis draws each seam in its verdict's `continuity` token (#770): a box + /// with one edge rounded shows the round's two tangent seams (G1) and the creases (G0), and + /// nothing in the smooth or gap colours. + #[test] + fn continuity_seams_take_their_colours_from_the_theme() { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20})).unwrap(); + s.execute("solid.fillet", &json!({"edges": [[20, 20, 10]], "radius": 4})).unwrap(); + s.execute("inspect.continuity", &json!({})).unwrap(); + let app = SolveApp::new(s, crate::Services::default()); + let sc = build_scene(&app); + // An overlay line is two points, a colour and a width: 32 bytes. + let colours: Vec<[u8; 4]> = sc.overlay.as_chunks::<32>().0.iter().map(|r| [r[24], r[25], r[26], r[27]]).collect(); + let t = Tokens::get(); + let count = |c: Color32| colours.iter().filter(|x| **x == c4(c)).count(); + let [smooth, jump, sharp, gap] = t.continuity.map(count); + assert!(jump > 0 && sharp > 0, "tangent seams {jump}, creases {sharp} segments"); + assert_eq!((smooth, gap), (0, 0)); + } + /// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies /// built into triangles on the CPU: the sample design rendered both ways through the /// viewport's real pipelines, offscreen, on this machine's GPU (skipped without one).