Continuity Analysis tool, legend and theme colours on #1642's inspect.continuity; curvature combs on faces (#770) (#1247)
Thanks @jyviko.
This commit is contained in:
1 parent
a000bd69cf
commit
0a66697da8
16 files changed
+400
-17
No files matched your search
+1
-1
@@ -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 | — |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "コーナー",
|
||||
|
||||
@@ -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": "코너",
|
||||
|
||||
@@ -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": "角点",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Value> {
|
||||
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]
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
"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() {
|
||||
|
||||
@@ -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<Vec3>,
|
||||
/// The seam's worst verdict along its length.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -298,6 +298,16 @@ fn groups(t: &Tokens) -> Vec<Group> {
|
||||
),
|
||||
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.
|
||||
|
||||
@@ -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<String> {
|
||||
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);
|
||||
}
|
||||
@@ -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<String> = [("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<Mode> {
|
||||
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<String> {
|
||||
"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;
|
||||
@@ -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.
|
||||
|
||||
+138
-10
@@ -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<Sel> {
|
||||
})
|
||||
}
|
||||
|
||||
/// What lights up under the pointer while a tool runs: the nearest hit its click takes.
|
||||
fn tool_hover(app: &SolveApp, hits: &[Hit]) -> Option<Hit> {
|
||||
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).
|
||||
|
||||
Reference in new issue
Block a user