Surface toolset: icons and planned commands (#808)
Icons for the surface toolset, and its planned commands registered and disabled with the issue that tracks each one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
dd084ec9e1
commit
c8d668108a
6 files changed
+437
-31
No files matched your search
@@ -9,6 +9,17 @@ macro_rules! created_doc {
|
||||
};
|
||||
}
|
||||
|
||||
/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built
|
||||
/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run`
|
||||
/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it.
|
||||
macro_rules! planned {
|
||||
($id:literal, $label:literal, $issue:literal) => {
|
||||
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into())))
|
||||
.enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into()))
|
||||
.planned($issue)
|
||||
};
|
||||
}
|
||||
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod component;
|
||||
@@ -89,6 +100,8 @@ pub struct CommandSpec {
|
||||
/// Reads the evaluated model (the default). A command that reads only the document and the
|
||||
/// session's history runs on a model left stale by a live gesture without evaluating it first.
|
||||
pub reads_model: bool,
|
||||
/// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it.
|
||||
pub planned: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
@@ -106,8 +119,14 @@ impl CommandSpec {
|
||||
undoable: true,
|
||||
live: None,
|
||||
reads_model: true,
|
||||
planned: None,
|
||||
}
|
||||
}
|
||||
/// Not built yet; `issue` tracks building it.
|
||||
pub const fn planned(mut self, issue: &'static str) -> Self {
|
||||
self.planned = Some(issue);
|
||||
self
|
||||
}
|
||||
/// Takes `live: true`, run by `run`.
|
||||
pub const fn live(mut self, run: LiveRun) -> Self {
|
||||
self.live = Some(run);
|
||||
@@ -161,6 +180,7 @@ impl CommandSpec {
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
planned: self.planned,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +196,8 @@ pub struct CommandInfo {
|
||||
pub params: &'static str,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub planned: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Surface commands: Patch, Stitch, Thicken and Trim (surface bodies and turning them into
|
||||
//! solids).
|
||||
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind};
|
||||
@@ -30,6 +30,54 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("trim")
|
||||
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
|
||||
planned!("surface.extrude", "Surface Extrude", "757")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_extrude")
|
||||
.params("planned: the parameters of solid.extrude, with open curve chains as well as profiles (walls only, no caps); caps?: bool (close a closed profile)"),
|
||||
planned!("surface.revolve", "Surface Revolve", "757")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_revolve")
|
||||
.params("planned: the parameters of solid.revolve, with open curve chains as well as profiles (no caps); caps?: bool"),
|
||||
planned!("surface.sweep", "Surface Sweep", "757")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
.params("planned: the parameters of solid.sweep, with an open profile chain as well as a closed one (no caps); caps?: bool"),
|
||||
planned!("surface.loft", "Surface Loft", "757")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_loft")
|
||||
.params("planned: the parameters of solid.loft, with open sections as well as closed ones (no caps); caps?: bool"),
|
||||
planned!("surface.offset", "Offset Surface", "758")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_offset")
|
||||
.params("planned: faces: [{body, point}]; distance: expr (signed, along the normals; 0 copies the faces); chain?: bool (the whole tangent chain of each pick); tolerance?: expr (B-spline fit, default 0.01 mm)"),
|
||||
planned!("surface.ruled", "Ruled Surface", "762")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("ruled")
|
||||
.params("planned: body; edges: [[x,y,z]] (points on an edge chain); distance: expr; mode?: normal|tangent|angle|direction; angle?: expr; direction?: [x,y,z]; flip?: bool; corners?: mitre|round"),
|
||||
planned!("surface.boundary", "Boundary Surface", "763")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("boundary")
|
||||
.params("planned: u: [curves], v?: [curves] (sketch curves, 3D sketch wires or {body, point} edges; 2–4 boundaries, more make a network); continuity?: [{curve, g: 0|1|2}] for edge boundaries; operation?: new|join"),
|
||||
planned!("surface.blend", "Blend Surface", "764")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_blend")
|
||||
.params("planned: from, to: {body, edges: [[x,y,z]]} (two edge chains apart); continuity?: [g0|g1|g2, g0|g1|g2] (each side); bulge?: [number, number]; align?: auto|[[x,y,z], [x,y,z]]; stitch?: bool"),
|
||||
planned!("surface.untrim", "Untrim", "761")
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("untrim")
|
||||
.params("planned: face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove); outer?: bool (restore the natural bounds); in_place?: bool (a surface body only)"),
|
||||
planned!("surface.unstitch", "Unstitch", "766")
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("unstitch")
|
||||
.params("planned: body; faces?: [[x,y,z]] (points on the faces to split off; default: every face its own surface body)"),
|
||||
planned!("surface.reverse_normal", "Reverse Normal", "766")
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("reverse_normal")
|
||||
.params("planned: body (a surface); faces?: [[x,y,z]] (default: all of them)"),
|
||||
planned!("surface.fillet", "Surface Fillet", "769")
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("surface_fillet")
|
||||
.params("planned: a, b: {body, point} (two faces of separate bodies that meet or cross; the points pick the side the ball rolls on); radius: expr; trim?: both|first|none"),
|
||||
];
|
||||
|
||||
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -129,6 +177,43 @@ mod tests {
|
||||
solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
|
||||
}
|
||||
|
||||
/// The planned surface commands are registered but refuse to run: disabled with the
|
||||
/// issue that tracks them, an error (never a panic) from every caller, and the design left
|
||||
/// as it was.
|
||||
#[test]
|
||||
fn planned_commands_refuse_and_leave_the_design_alone() {
|
||||
let planned = [
|
||||
"surface.extrude",
|
||||
"surface.revolve",
|
||||
"surface.sweep",
|
||||
"surface.loft",
|
||||
"surface.offset",
|
||||
"surface.ruled",
|
||||
"surface.boundary",
|
||||
"surface.blend",
|
||||
"surface.untrim",
|
||||
"surface.unstitch",
|
||||
"surface.reverse_normal",
|
||||
"surface.fillet",
|
||||
"inspect.continuity",
|
||||
];
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
|
||||
let before = s.doc.features.len();
|
||||
for id in planned {
|
||||
let spec = crate::find_command(id).unwrap_or_else(|| panic!("{id} is not registered"));
|
||||
let info = spec.info(&s);
|
||||
assert!(!info.enabled, "{id} must stay disabled until it is built");
|
||||
let why = info.disabled_reason.unwrap_or_default();
|
||||
assert!(why.starts_with("not supported yet (issue #"), "{id}: {why}");
|
||||
assert!(!spec.icon.is_empty() && !spec.params.is_empty(), "{id}: icon and params doc");
|
||||
assert!(s.execute(id, &json!({"body": "B"})).is_err(), "{id} ran");
|
||||
let e = (spec.run)(&mut s, &json!({})).unwrap_err().to_string();
|
||||
assert!(e.starts_with("not supported yet: ") && e.contains("(issue #"), "{id}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc.features.len(), before, "a planned command changed the design");
|
||||
}
|
||||
|
||||
/// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim
|
||||
/// that is not planar patched.
|
||||
#[test]
|
||||
|
||||
@@ -34,6 +34,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("curvature_map")
|
||||
.noundo()
|
||||
.params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"),
|
||||
planned!("inspect.continuity", "Continuity Analysis", "770")
|
||||
.at("SURFACE", "INSPECT")
|
||||
.icon("continuity")
|
||||
.noundo()
|
||||
.params("planned: body?; edges?: [[x,y,z]] (default: every edge of the body); returns each edge's worst gap (G0), normal angle (G1) and curvature jump (G2); clear?: true turns the analysis off"),
|
||||
];
|
||||
|
||||
fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
|
||||
|
||||
+284
-18
@@ -1,7 +1,32 @@
|
||||
//! Toolbar and browser icons, drawn in code (no image assets). Each icon is designed on a
|
||||
//! 24 × 24 grid and scaled to the target rectangle.
|
||||
|
||||
use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
|
||||
/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet (an opening, a blend or
|
||||
/// round strip), and the iso lines and comb teeth drawn on sheets.
|
||||
const SHEET: Color32 = Color32::from_rgb(170, 200, 240);
|
||||
const SHEET_2: Color32 = Color32::from_rgb(150, 185, 235);
|
||||
const SHEET_LIGHT: Color32 = Color32::from_rgb(214, 229, 248);
|
||||
const SHEET_LINE: Color32 = Color32::from_rgb(127, 166, 218);
|
||||
|
||||
/// `n + 1` points along the cubic Bézier curve with control points `c`.
|
||||
fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> {
|
||||
(0..=n)
|
||||
.map(|i| i as f32 / n as f32)
|
||||
.map(|t| {
|
||||
let u = 1.0 - t;
|
||||
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
|
||||
(w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `n + 1` points along an ellipse from angle `a0` to `a1` (y down: angles below the centre
|
||||
/// first).
|
||||
fn ellipse(x: f32, y: f32, rx: f32, ry: f32, a0: f32, a1: f32, n: usize) -> Vec<(f32, f32)> {
|
||||
(0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + rx * a.cos(), y + ry * a.sin())).collect()
|
||||
}
|
||||
|
||||
struct Pen<'a> {
|
||||
p: &'a Painter,
|
||||
@@ -60,6 +85,30 @@ impl Pen<'_> {
|
||||
self.poly(&side, self.fill.gamma_multiply(0.7), self.ink);
|
||||
self.poly(&top, self.fill, self.ink);
|
||||
}
|
||||
/// A cubic Bézier curve through control points `c`.
|
||||
fn curve(&self, c: [(f32, f32); 4], col: Color32) {
|
||||
self.line(&bezier(c, 16), col);
|
||||
}
|
||||
fn dashed(&self, pts: &[(f32, f32)], c: Color32) {
|
||||
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
|
||||
self.p.add(Shape::dashed_line(&v, Stroke::new(self.w, c), 2.0 * self.s(), 1.6 * self.s()));
|
||||
}
|
||||
/// Fill the strip between two polylines that run the same way, such as the front and back
|
||||
/// edges of a curved sheet. Unlike `poly`, the strip need not be convex.
|
||||
fn band(&self, a: &[(f32, f32)], b: &[(f32, f32)], fill: Color32) {
|
||||
let mut m = Mesh::default();
|
||||
for (p, q) in a.iter().zip(b) {
|
||||
m.colored_vertex(self.at(p.0, p.1), fill);
|
||||
m.colored_vertex(self.at(q.0, q.1), fill);
|
||||
}
|
||||
let n = a.len().min(b.len()) as u32;
|
||||
for i in 1..n {
|
||||
let k = 2 * i;
|
||||
m.add_triangle(k - 2, k - 1, k);
|
||||
m.add_triangle(k - 1, k + 1, k);
|
||||
}
|
||||
self.p.add(Shape::mesh(m));
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke width for the Normal/Construction/Centerline line-type icons. It is heavier than
|
||||
@@ -858,22 +907,33 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
"patch" => {
|
||||
// A wire loop with a surface spanned across it.
|
||||
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], Color32::from_rgb(170, 200, 240), ink);
|
||||
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], SHEET, ink);
|
||||
pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
|
||||
}
|
||||
"stitch" => {
|
||||
// Two surface patches sewn along their shared edge.
|
||||
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], Color32::from_rgb(170, 200, 240), ink);
|
||||
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], Color32::from_rgb(150, 185, 235), ink);
|
||||
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], SHEET, ink);
|
||||
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], SHEET_2, ink);
|
||||
for y in [6.0, 9.0, 12.0] {
|
||||
pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
|
||||
}
|
||||
}
|
||||
"thicken" => {
|
||||
// A curved sheet given thickness: its offset copy and the side walls.
|
||||
pen.arc(12.0, 20.0, 11.0, 3.6, 5.8, ink);
|
||||
pen.arc(12.0, 20.0, 7.0, 3.6, 5.8, a);
|
||||
pen.arrow((12.0, 9.0), (12.0, 13.5), a);
|
||||
// A wavy sheet (accent edges) grown downward into a slab, shaded like `iso_box`.
|
||||
let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)];
|
||||
let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)];
|
||||
let d = f.map(|(x, y)| (x, y + 6.0));
|
||||
pen.band(&bezier(b, 16), &bezier(f, 16), SHEET);
|
||||
pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85));
|
||||
pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink);
|
||||
pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink);
|
||||
pen.line(&[(17.0, 12.5), (17.0, 18.5)], ink);
|
||||
pen.line(&[(21.4, 8.9), (21.4, 14.9)], ink);
|
||||
pen.curve(d, ink);
|
||||
pen.line(&[(6.4, 8.9), (2.0, 12.5)], ink);
|
||||
pen.line(&[(21.4, 8.9), (17.0, 12.5)], a);
|
||||
pen.curve(b, a);
|
||||
pen.curve(f, a);
|
||||
}
|
||||
"extend" => {
|
||||
pen.line(&[(20.0, 3.0), (20.0, 21.0)], ink);
|
||||
@@ -896,19 +956,191 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"blend" => {
|
||||
pen.line(&[(2.0, 19.0), (8.0, 15.0)], ink);
|
||||
pen.line(&[(16.0, 9.0), (22.0, 5.0)], ink);
|
||||
let c = [(8.0_f32, 15.0_f32), (12.5, 12.0), (11.5, 12.0), (16.0, 9.0)];
|
||||
let pts: Vec<(f32, f32)> = (0..=16)
|
||||
.map(|i| i as f32 / 16.0)
|
||||
.map(|t| {
|
||||
let u = 1.0 - t;
|
||||
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
|
||||
(w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum())
|
||||
})
|
||||
.collect();
|
||||
pen.line(&pts, a);
|
||||
pen.curve([(8.0, 15.0), (12.5, 12.0), (11.5, 12.0), (16.0, 9.0)], a);
|
||||
pen.dot(8.0, 15.0, ink);
|
||||
pen.dot(16.0, 9.0, ink);
|
||||
}
|
||||
// Surface modelling: open sheets (no caps) in the sheet colours, the input or the result
|
||||
// in the accent.
|
||||
"surface_extrude" => {
|
||||
// An open profile (accent) pulled up into a sheet.
|
||||
let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)];
|
||||
let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)];
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
|
||||
pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink);
|
||||
pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink);
|
||||
pen.curve(top, ink);
|
||||
pen.curve(bottom, a);
|
||||
pen.arrow((12.0, 8.5), (12.0, 2.5), a);
|
||||
}
|
||||
"surface_revolve" => {
|
||||
// An open profile (accent) turned about the axis into a hollow band.
|
||||
let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)];
|
||||
let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)];
|
||||
pen.band(&bezier(left, 16), &bezier(right, 16), SHEET);
|
||||
let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16);
|
||||
pen.poly(&front, SHEET, Color32::TRANSPARENT);
|
||||
// A closed fill takes each point once: a repeated end point spikes `poly`'s edge.
|
||||
let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32);
|
||||
opening.pop();
|
||||
pen.poly(&opening, SHEET_LIGHT, ink);
|
||||
pen.line(&front, ink);
|
||||
pen.curve(left, ink);
|
||||
pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink);
|
||||
pen.line(&[(12.0, 20.2), (12.0, 22.5)], ink);
|
||||
pen.curve(right, a);
|
||||
}
|
||||
"surface_sweep" => {
|
||||
// A straight profile (accent) carried along a curved path: a ribbon.
|
||||
let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)];
|
||||
let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)];
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET);
|
||||
pen.curve(near, ink);
|
||||
pen.curve(far, ink);
|
||||
pen.line(&[(4.0, 13.0), (4.0, 20.0)], a);
|
||||
pen.line(&[(20.0, 9.0), (20.0, 16.0)], ink);
|
||||
}
|
||||
"surface_loft" => {
|
||||
// Two open sections (accent) joined by a sheet.
|
||||
let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)];
|
||||
let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)];
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
|
||||
pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink);
|
||||
pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink);
|
||||
pen.curve(top, a);
|
||||
pen.curve(bottom, a);
|
||||
}
|
||||
"surface_offset" => {
|
||||
// A wavy sheet and its offset copy (dashed), the distance shown by the arrow.
|
||||
let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)];
|
||||
let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)];
|
||||
pen.band(&bezier(back, 16), &bezier(front, 16), SHEET);
|
||||
pen.curve(back, ink);
|
||||
pen.curve(front, ink);
|
||||
pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink);
|
||||
pen.line(&[(21.0, 14.0), (18.0, 20.0)], ink);
|
||||
pen.dashed(&bezier(back.map(|(x, y)| (x, y - 9.0)), 16), a);
|
||||
pen.dashed(&bezier(front.map(|(x, y)| (x, y - 9.0)), 16), a);
|
||||
pen.dashed(&[(6.0, 5.0), (3.0, 11.0)], a);
|
||||
pen.dashed(&[(21.0, 5.0), (18.0, 11.0)], a);
|
||||
pen.arrow((12.0, 17.0), (12.0, 8.5), a);
|
||||
}
|
||||
"ruled" => {
|
||||
// A strip hanging off a block's edge (accent), ruled by straight lines.
|
||||
pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink);
|
||||
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], SHEET, ink);
|
||||
pen.line(&[(15.0, 9.3), (15.0, 19.3)], a);
|
||||
pen.line(&[(18.0, 6.7), (18.0, 16.7)], a);
|
||||
pen.line(&[(12.0, 12.0), (12.0, 22.0)], a);
|
||||
}
|
||||
"boundary" => {
|
||||
// Four boundary curves (accent) meeting at corners, the patch between them a Coons
|
||||
// patch with its iso lines.
|
||||
let (c00, c10, c11, c01) = ((4.0, 17.0), (10.0, 5.0), (20.0, 7.0), (17.0, 20.0));
|
||||
let left = [c00, (5.0, 12.0), (8.0, 8.0), c10];
|
||||
let top = [c10, (13.0, 4.0), (17.0, 4.0), c11];
|
||||
let right = [c01, (19.0, 16.0), (20.0, 11.0), c11];
|
||||
let bottom = [c00, (8.0, 20.0), (13.0, 21.0), c01];
|
||||
let n = 12;
|
||||
let lerp = |p: (f32, f32), q: (f32, f32), s: f32| (p.0 + (q.0 - p.0) * s, p.1 + (q.1 - p.1) * s);
|
||||
let (b, t) = (bezier(bottom, n), bezier(top, n));
|
||||
// Each row runs across the patch (u) at one height (v) up the left and right sides.
|
||||
let rows: Vec<Vec<(f32, f32)>> = bezier(left, n)
|
||||
.into_iter()
|
||||
.zip(bezier(right, n))
|
||||
.enumerate()
|
||||
.map(|(j, (lv, rv))| {
|
||||
let v = j as f32 / n as f32;
|
||||
b.iter()
|
||||
.zip(&t)
|
||||
.enumerate()
|
||||
.map(|(i, (bu, tu))| {
|
||||
let u = i as f32 / n as f32;
|
||||
let corners = lerp(lerp(c00, c01, u), lerp(c10, c11, u), v);
|
||||
let (ruled_v, ruled_u) = (lerp(*bu, *tu, v), lerp(lv, rv, u));
|
||||
(ruled_v.0 + ruled_u.0 - corners.0, ruled_v.1 + ruled_u.1 - corners.1)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) {
|
||||
pen.band(lo, hi, SHEET);
|
||||
}
|
||||
for j in [n / 3, 2 * n / 3] {
|
||||
if let Some(r) = rows.get(j) {
|
||||
pen.line(r, SHEET_LINE);
|
||||
}
|
||||
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), SHEET_LINE);
|
||||
}
|
||||
for c in [left, top, right, bottom] {
|
||||
pen.curve(c, a);
|
||||
}
|
||||
for (x, y) in [c00, c10, c11, c01] {
|
||||
pen.dot(x, y, ink);
|
||||
}
|
||||
}
|
||||
"surface_blend" => {
|
||||
// Two separate sheets at different heights bridged by a smooth strip (accent) that
|
||||
// leaves both edges tangent.
|
||||
let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)];
|
||||
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
|
||||
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], SHEET, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
|
||||
pen.curve(far, a);
|
||||
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], SHEET, ink);
|
||||
pen.curve(near, a);
|
||||
}
|
||||
"untrim" => {
|
||||
// A trimmed sheet whose hole (dashed, accent) is filled back in.
|
||||
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], SHEET, ink);
|
||||
let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32);
|
||||
pen.poly(hole.get(1..).unwrap_or_default(), SHEET_LIGHT, Color32::TRANSPARENT);
|
||||
pen.dashed(&hole, a);
|
||||
}
|
||||
"unstitch" => {
|
||||
// Two sheets pulled apart from their shared edge.
|
||||
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], SHEET, ink);
|
||||
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], SHEET_2, ink);
|
||||
pen.arrow((10.8, 11.0), (7.0, 11.0), a);
|
||||
pen.arrow((13.2, 11.0), (17.0, 11.0), a);
|
||||
}
|
||||
"reverse_normal" => {
|
||||
// A sheet with its old normal (faded) and the flipped one (accent).
|
||||
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], SHEET, ink);
|
||||
pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5));
|
||||
pen.arrow((12.0, 14.0), (12.0, 22.5), a);
|
||||
}
|
||||
"surface_fillet" => {
|
||||
// A wall sheet and a floor sheet joined by a round strip (accent edges).
|
||||
let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)];
|
||||
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
|
||||
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], SHEET_2, ink);
|
||||
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], SHEET, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
|
||||
pen.line(&[(4.5, 19.5), (8.9, 15.9)], SHEET_LINE);
|
||||
pen.curve(far, a);
|
||||
pen.curve(near, a);
|
||||
}
|
||||
"continuity" => {
|
||||
// A straight curve joining a curved one (accent) at a point, with the curved one's
|
||||
// curvature comb: teeth that grow as the curve tightens.
|
||||
pen.line(&[(2.0, 17.0), (12.0, 13.0)], ink);
|
||||
let c = [(12.0, 13.0), (16.5, 11.2), (20.0, 8.0), (21.0, 3.0)];
|
||||
let pts = bezier(c, 10);
|
||||
let mut tips = Vec::new();
|
||||
for (i, ((x, y), (x1, y1))) in pts.iter().zip(pts.iter().skip(1)).enumerate() {
|
||||
let len = (x1 - x).hypot(y1 - y).max(1e-6);
|
||||
let h = 0.8 + 4.2 * i as f32 / 10.0;
|
||||
let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h);
|
||||
if i % 2 == 0 {
|
||||
pen.line(&[(*x, *y), tip], SHEET_LINE);
|
||||
}
|
||||
tips.push(tip);
|
||||
}
|
||||
pen.line(&tips, SHEET_LINE);
|
||||
pen.curve(c, a);
|
||||
pen.dot(12.0, 13.0, a);
|
||||
}
|
||||
// Assembly: joints (accent: the joint itself).
|
||||
"joint" => {
|
||||
pen.iso_box(2.0, 13.0, 7.0, 5.0, 5.0);
|
||||
@@ -1083,6 +1315,40 @@ mod tests {
|
||||
format!("{:?}", out.shapes)
|
||||
}
|
||||
|
||||
/// Command icons not drawn yet (the fallback square) or drawn past their square, from before
|
||||
/// this check. Remove a name when its icon is fixed; never add one.
|
||||
const KNOWN_GAPS: &[&str] =
|
||||
&["cylinder", "pipe", "offset_face", "thread", "boundary_fill", "coil", "rib", "emboss", "capture", "revert", "camera", "material"];
|
||||
|
||||
/// What is wrong with the icon `name`: not drawn (the fallback square), or drawn past its
|
||||
/// square by more than a stroke width.
|
||||
fn icon_problem(name: &str) -> Option<String> {
|
||||
if drawn(name, Color32::BLACK, Color32::WHITE, Color32::BLUE) == drawn("", Color32::BLACK, Color32::WHITE, Color32::BLUE) {
|
||||
return Some("no icon drawn".into());
|
||||
}
|
||||
let side = 48.0;
|
||||
let r = Rect::from_min_size(Pos2::ZERO, vec2(side, side));
|
||||
let ctx = egui::Context::default();
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() };
|
||||
let out = ctx.run_ui(input, |ui| paint(ui.painter(), r, name, Color32::BLACK, Color32::WHITE, Color32::BLUE));
|
||||
let used = out.shapes.iter().fold(Rect::NOTHING, |b, s| b.union(s.shape.visual_bounding_rect()));
|
||||
(!r.expand(side / 24.0 * 1.4).contains_rect(used)).then(|| format!("drawn over {used:?}"))
|
||||
}
|
||||
|
||||
/// Every command's icon is drawn and stays inside its square.
|
||||
#[test]
|
||||
fn every_command_icon_is_drawn_inside_its_square() {
|
||||
let bad: Vec<String> = solidcraft_engine::command_specs()
|
||||
.into_iter()
|
||||
.filter(|c| !c.icon.is_empty() && !KNOWN_GAPS.contains(&c.icon))
|
||||
.filter_map(|c| icon_problem(c.icon).map(|why| format!("{} ({}): {why}", c.id, c.icon)))
|
||||
.collect();
|
||||
assert!(bad.is_empty(), "{}", bad.join("\n"));
|
||||
for gap in KNOWN_GAPS {
|
||||
assert!(icon_problem(gap).is_some(), "`{gap}` is fixed now: take it off KNOWN_GAPS");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Sketch Palette and the toolbar both rely on this contract. Construction and
|
||||
/// centerline must track whatever accent colour the caller passes (both callers pass the
|
||||
/// theme's construction orange) and must not change with `ink`.
|
||||
|
||||
Reference in new issue
Block a user