diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 6d9af0c..5cbf902 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -472,6 +472,9 @@ pub struct Viewport { pub locked: bool, #[serde(default)] pub frozen_layers: Vec, + /// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub layer_colors: Vec<(String, Color)>, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index f6cbcf2..7089143 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -25,6 +25,8 @@ pub struct Layer { pub transparency: u8, pub description: String, pub plot_style: String, + /// Frozen in viewports created from now on (New VP Freeze; DXF layer flag 2). + pub vp_freeze_new: bool, } impl Default for Layer { @@ -41,6 +43,7 @@ impl Default for Layer { transparency: 0, description: String::new(), plot_style: "Normal".into(), + vp_freeze_new: false, } } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b46f23c..aaf44a7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -30,10 +30,6 @@ pub fn specs() -> Vec { .params("{handles: [hex]} | {window: [[x,y],[x,y]], crossing?: bool} | {at: [x,y]} | {clear: true} | {add?: bool}") .noundo() .interactive(|_| Ok(Box::new(SelectM::default()))), - CommandSpec::new("qselect", "Quick Select...", run_qselect) - .menu(&["Edit", "Quick Select..."]) - .params("{type?: \"Line\"|..., layer?, color?}") - .noundo(), ] } @@ -184,28 +180,6 @@ fn run_select(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": sel.len(), "handles": sel.iter().map(|h| h.hex()).collect::>() })) } -fn run_qselect(s: &mut Session, p: &Value) -> Result { - let space = s.space(); - let d = s.doc()?; - let ty = str_param(p, "type").map(str::to_ascii_lowercase); - let layer = str_param(p, "layer").map(str::to_ascii_lowercase); - let color = str_param(p, "color").and_then(cadcraft_color::Color::parse); - let hs: Vec = d - .space(&space) - .map(|st| { - st.iter() - .filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t)) - .filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l)) - .filter(|e| color.is_none_or(|c| e.common.color == c)) - .map(|e| e.handle) - .collect() - }) - .unwrap_or_default(); - let n = hs.len(); - s.set_selection(hs); - Ok(json!({ "selected": n, "message": format!("{n} item(s) selected") })) -} - struct PasteM { orig: bool, } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 3d03bae..158e805 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { CommandSpec::new("layer", "Layers", run_list).menu(&["Format", "Layers"]).alias(&["la", "layers"]).noundo(), CommandSpec::new("layer.new", "New Layer", run_new).params("{name, color?, linetype?, lineweight? (mm), current?: bool}"), CommandSpec::new("layer.set", "Set Layer Properties", run_set) - .params("{name, on?, frozen?, locked?, plot?, color?, linetype?, lineweight?, transparency?, description?, newName?}"), + .params("{name, on?, frozen?, locked?, plot?, color?, linetype?, lineweight?, transparency?, description?, newVpFreeze?, newName?}"), CommandSpec::new("layer.current", "Make Current", run_current) .menu(&["Format", "Layer Tools", "Make Current"]) .alias(&["clayer"]) @@ -42,6 +42,9 @@ pub fn specs() -> Vec { CommandSpec::new("layerp", "Previous Layer", run_layerp).menu(&["Format", "Layer Tools", "Previous Layer"]), CommandSpec::new("layerstate.save", "Save Layer State", run_state_save).menu(&["Format", "Layer States Manager..."]).params("{name}"), CommandSpec::new("layerstate.restore", "Restore Layer State", run_state_restore).params("{name}"), + CommandSpec::new("layerstate.list", "List Layer States", run_state_list).params("{} → states").noundo(), + CommandSpec::new("layerstate.delete", "Delete Layer State", run_state_delete).params("{name}"), + CommandSpec::new("layerstate.rename", "Rename Layer State", run_state_rename).params("{from, to}"), ] } @@ -50,14 +53,38 @@ fn layer_json(l: &Layer, current: bool) -> Value { "name": l.name, "on": l.on, "frozen": l.frozen, "locked": l.locked, "plot": l.plot, "color": l.color.name(), "colorRgb": l.color.resolve(Color::Index(7), Color::Index(7)).hex(), "linetype": l.linetype, "lineweight": l.lineweight.name(), "transparency": l.transparency, - "description": l.description, "current": current, + "description": l.description, "current": current, "newVpFreeze": l.vp_freeze_new, }) } fn run_list(s: &mut Session, _p: &Value) -> Result { let d = s.doc()?; let cur = d.header.str("CLAYER", "0"); - Ok(json!({ "layers": d.layers.iter().map(|l| layer_json(l, l.name.eq_ignore_ascii_case(&cur))).collect::>() })) + let used = used_layers(d); + let layers: Vec = d + .layers + .iter() + .map(|l| { + let mut v = layer_json(l, l.name.eq_ignore_ascii_case(&cur)); + if let Some(o) = v.as_object_mut() { + o.insert("used".into(), json!(used.contains(&l.name.to_ascii_lowercase()))); + } + v + }) + .collect(); + Ok(json!({ "layers": layers, "states": d.layer_states.iter().map(|st| st.name.clone()).collect::>() })) +} + +/// Lower-case names of layers that have objects on them (model, layouts and block definitions). +pub fn used_layers(d: &cadcraft_doc::Drawing) -> std::collections::HashSet { + let mut set = std::collections::HashSet::new(); + let ents = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())); + for e in ents.take(5_000_000) { + if !set.contains(&e.common.layer.to_ascii_lowercase()) { + set.insert(e.common.layer.to_ascii_lowercase()); + } + } + set } fn parse_lw(v: &Value) -> Option { @@ -105,6 +132,9 @@ fn apply(l: &mut Layer, p: &Value) -> Result<()> { if let Some(t) = p.get("transparency").and_then(Value::as_u64) { l.transparency = t.min(90) as u8; } + if let Some(v) = p.get("newVpFreeze").and_then(Value::as_bool) { + l.vp_freeze_new = v; + } if let Some(d) = str_param(p, "description") { l.description = d.to_string(); } @@ -313,3 +343,69 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result { } ok() } + +fn run_state_list(s: &mut Session, _p: &Value) -> Result { + let d = s.doc()?; + let states: Vec = d.layer_states.iter().map(|st| json!({ "name": st.name, "layers": st.layers.len() })).collect(); + let msg = if states.is_empty() { + "No saved layer states.".to_string() + } else { + d.layer_states.iter().map(|st| st.name.clone()).collect::>().join(", ") + }; + Ok(json!({ "states": states, "message": msg })) +} + +fn run_state_delete(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("layerstate.delete", "`name` is required"))?; + let d = s.doc_mut()?; + if !d.layer_states.iter().any(|st| st.name == name) { + return Err(bad("layerstate.delete", "no such layer state")); + } + d.layer_states.retain(|st| st.name != name); + ok() +} + +fn run_state_rename(s: &mut Session, p: &Value) -> Result { + let from = str_param(p, "from").ok_or_else(|| bad("layerstate.rename", "`from` is required"))?; + let to = str_param(p, "to").map(str::trim).ok_or_else(|| bad("layerstate.rename", "`to` is required"))?.to_string(); + if !valid_name(&to) { + return Err(bad("layerstate.rename", "invalid name")); + } + let d = s.doc_mut()?; + if d.layer_states.iter().any(|st| st.name == to) { + return Err(bad("layerstate.rename", format!("layer state `{to}` already exists"))); + } + let st = d.layer_states.iter_mut().find(|st| st.name == from).ok_or_else(|| bad("layerstate.rename", "no such layer state"))?; + st.name = to; + ok() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn layer_states_save_list_restore_delete() { + let mut s = Session::new(); + s.execute("layer.new", &json!({ "name": "A", "color": 1 })).unwrap(); + s.execute("layerstate.save", &json!({ "name": "base" })).unwrap(); + s.execute("layer.set", &json!({ "name": "A", "on": false, "color": 3 })).unwrap(); + let l = s.execute("layerstate.list", &json!({})).unwrap(); + assert_eq!(l["states"][0]["name"], "base"); + s.execute("layerstate.restore", &json!({ "name": "base" })).unwrap(); + let a = s.doc().unwrap().layer("A").unwrap().clone(); + assert!(a.on); + assert_eq!(a.color, cadcraft_color::Color::Index(1)); + s.execute("layerstate.rename", &json!({ "from": "base", "to": "start" })).unwrap(); + assert!(s.execute("layerstate.delete", &json!({ "name": "base" })).is_err()); + s.execute("layerstate.delete", &json!({ "name": "start" })).unwrap(); + assert!(s.execute("layerstate.list", &json!({})).unwrap()["states"].as_array().unwrap().is_empty()); + // `used` flags in the layer list. + s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap(); + let v = s.execute("layer", &json!({})).unwrap(); + let used: Vec = v["layers"].as_array().unwrap().iter().map(|l| l["used"].as_bool().unwrap()).collect(); + assert!(used.contains(&true) && used.contains(&false)); + } +} diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 0830496..9fa6c76 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -49,9 +49,10 @@ pub fn specs() -> Vec { .params("{p1?, p2?, layout?}") .interactive(|_| Ok(Box::new(MviewM::new(4, false)))), CommandSpec::new("viewport.set", "Viewport Properties", run_viewport_set).params( - "{handle? (default: selected viewports), scale?: paper units per model unit | \"1:50\", viewHeight?, center?: [x,y], locked?, freeze?: [layer], thaw?: [layer]}", + "{handle? (default: selected viewports), scale?: paper units per model unit | \"1:50\", viewHeight?, center?: [x,y], locked?, freeze?: [layer], thaw?: [layer], colors?: {layer: color | null}}", ), - CommandSpec::new("vplayer", "Viewport Layer Freeze", run_viewport_set).params("{handle?, freeze?: [layer], thaw?: [layer]}"), + CommandSpec::new("vplayer", "Viewport Layer Freeze", run_viewport_set) + .params("{handle?, freeze?: [layer], thaw?: [layer], colors?: {layer: color (\"red\" | 1..255 | \"r,g,b\") | null to clear}}"), CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup) .menu(&["File", "Page Setup Manager..."]) .params( @@ -400,6 +401,8 @@ fn add_viewports(s: &mut Session, space: &Space, rect: Bounds2, count: usize, ar .unwrap_or(1) .max(1); let mut out = Vec::new(); + // Layers marked "New VP Freeze" start frozen in new viewports. + let vp_frozen: Vec = d.layers.iter().filter(|l| l.vp_freeze_new).map(|l| l.name.clone()).collect(); for r in split(rect, count, arrangement) { let (view_center, view_height) = fit_view(d, r.width(), r.height()); id = id.saturating_add(1); @@ -411,7 +414,8 @@ fn add_viewports(s: &mut Session, space: &Space, rect: Bounds2, count: usize, ar view_height, id, locked: false, - frozen_layers: Vec::new(), + frozen_layers: vp_frozen.clone(), + layer_colors: Vec::new(), }; out.push(d.add(space, common.clone(), EntityKind::Viewport(vp))?); } @@ -555,14 +559,23 @@ fn viewport_json(h: Handle, v: &Viewport) -> Value { "scale": if v.view_height > 0.0 { v.height / v.view_height } else { 0.0 }, "locked": v.locked, "frozenLayers": v.frozen_layers, + "layerColors": v.layer_colors.iter().map(|(l, c)| json!({ "layer": l, "color": c.name() })).collect::>(), }) } fn run_viewport_set(s: &mut Session, p: &Value) -> Result { let cmd = "viewport.set"; let d = s.doc()?; - let hs: Vec = + let mut hs: Vec = targets(s, p)?.into_iter().filter(|h| d.entity(*h).is_some_and(|e| matches!(&e.kind, EntityKind::Viewport(v) if v.id != 1))).collect(); + // Inside a viewport (MSPACE) the active viewport is the default. + if hs.is_empty() + && p.get("handle").is_none() + && p.get("handles").is_none() + && let Some((h, _)) = s.state()?.active_viewport() + { + hs.push(h); + } if hs.is_empty() { return Err(bad(cmd, "no viewport given (pass `handle` or select a viewport)")); } @@ -581,6 +594,28 @@ fn run_viewport_set(s: &mut Session, p: &Value) -> Result { let locked = p.get("locked").and_then(Value::as_bool); let freeze = layer_list(p, "freeze"); let thaw = layer_list(p, "thaw"); + // Per-viewport layer colours: {layer: colour} (null clears the override). + let mut colors: Vec<(String, Option)> = Vec::new(); + if let Some(m) = p.get("colors").and_then(Value::as_object) { + for (layer, c) in m.iter().take(10_000) { + let col = match c { + Value::Null => None, + Value::String(t) => Some(cadcraft_color::Color::parse(t).ok_or_else(|| bad(cmd, format!("bad colour for `{layer}`")))?), + Value::Number(n) => Some( + n.as_u64() + .and_then(|i| u8::try_from(i).ok()) + .filter(|i| *i > 0) + .map(cadcraft_color::Color::Index) + .ok_or_else(|| bad(cmd, "colour index 1..255"))?, + ), + _ => return Err(bad(cmd, "`colors` values are colours or null")), + }; + if matches!(col, Some(cadcraft_color::Color::ByLayer | cadcraft_color::Color::ByBlock)) { + return Err(bad(cmd, "a viewport layer colour must be an index or true colour")); + } + colors.push((layer.clone(), col)); + } + } let changes_view = scale.is_some() || view_height.is_some() || center.is_some(); let mut out = Vec::new(); for h in hs { @@ -610,6 +645,12 @@ fn run_viewport_set(s: &mut Session, p: &Value) -> Result { } } v.frozen_layers.retain(|x| !thaw.iter().any(|t| t.eq_ignore_ascii_case(x))); + for (layer, c) in &colors { + v.layer_colors.retain(|(n, _)| !n.eq_ignore_ascii_case(layer)); + if let Some(c) = c { + v.layer_colors.push((layer.clone(), *c)); + } + } out.push(viewport_json(h, &v)); s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::Viewport(v)).map_err(|e| bad(cmd, e.to_string()))?; } @@ -859,6 +900,13 @@ mod tests { s.execute("vplayer", &json!({"handle": h, "thaw": "walls"})).unwrap(); let v = viewports(&s, "Layout1"); assert_eq!(v[0].1.frozen_layers, vec!["0".to_string()]); + s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "red"}})).unwrap(); + assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "bylayer"}})).is_err()); + assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": [1]}})).is_err()); + let vv = viewports(&s, "Layout1"); + assert_eq!(vv[0].1.layer_colors, vec![("Walls".to_string(), cadcraft_color::Color::Index(1))]); + s.execute("vplayer", &json!({"handle": h, "colors": {"walls": null}})).unwrap(); + assert!(viewports(&s, "Layout1")[0].1.layer_colors.is_empty()); assert!((v[0].1.view_height - 30.0).abs() < 1e-9); // Layer 0 frozen in the viewport: nothing of the model shows. let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout1".into()), &cadcraft_render::Options::default()); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 369bc4d..ec136ce 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ mod layout; mod modify; mod modify2; mod props; +mod qselect; mod settings; mod table; mod utility; @@ -148,6 +149,7 @@ pub fn command_specs() -> &'static [CommandSpec] { let mut v = Vec::new(); v.extend(file::specs()); v.extend(edit::specs()); + v.extend(qselect::specs()); v.extend(view::specs()); v.extend(draw::specs()); v.extend(draw2::specs()); diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs index dfcf219..addb479 100644 --- a/crates/engine/src/cmd/modify2_tests.rs +++ b/crates/engine/src/cmd/modify2_tests.rs @@ -351,6 +351,7 @@ fn chspace_json_and_command_line() { id: 2, locked: false, frozen_layers: Vec::new(), + layer_colors: Vec::new(), }; s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Default::default(), EntityKind::Viewport(vp)).unwrap(); let r = s.execute("chspace", &json!({"handles": [l]})).unwrap(); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 930fa49..a0c36d3 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -51,7 +51,7 @@ pub fn specs() -> Vec { ] } -fn entity_props(d: &cadcraft_doc::Drawing, e: &cadcraft_doc::Entity) -> Value { +pub(super) fn entity_props(d: &cadcraft_doc::Drawing, e: &cadcraft_doc::Entity) -> Value { let mut v = json!({ "handle": e.handle.hex(), "type": e.kind.type_name(), @@ -177,6 +177,17 @@ fn run_set(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("end") { set_xy(&mut l.b, v); } + // Length / angle keep the start point and move the end point. + let (a, b) = (l.a.xy(), l.b.xy()); + let len = num("length").filter(|x| *x > 0.0 && *x < 1e15).unwrap_or_else(|| a.dist(b)); + let ang = num("angle").map(f64::to_radians).unwrap_or_else(|| a.angle_to(b)); + if num("length").is_some() || num("angle").is_some() { + let e = cadcraft_geom::Vec2::polar(a, len, ang); + if e.is_finite() { + l.b.x = e.x; + l.b.y = e.y; + } + } } EntityKind::Circle(c) => { if let Some(v) = p.get("center") { diff --git a/crates/engine/src/cmd/qselect.rs b/crates/engine/src/cmd/qselect.rs new file mode 100644 index 0000000..808952e --- /dev/null +++ b/crates/engine/src/cmd/qselect.rs @@ -0,0 +1,402 @@ +//! QSELECT: build a selection set from an object type and a property filter +//! (`property operator value`), and `qselect.info` for the Quick Select dialog. + +use cadcraft_color::Color; +use cadcraft_doc::{Drawing, Entity, Handle}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("qselect", "Quick Select...", run_qselect) + .menu(&["Edit", "Quick Select..."]) + .alias(&["qs"]) + .params( + "{type?: \"Line\"|\"Circle\"|..., property?, operator?: \"=\"|\"!=\"|\">\"|\"<\"|\"*\" (wildcard), value?, mode?: new|append|exclude, \ + applyTo?: drawing|selection, layer?, color?} → selected handles", + ) + .noundo(), + CommandSpec::new("qselect.info", "Quick Select Properties", run_info) + .params("{applyTo?: drawing|selection, type?} → object types with counts and the properties available for `type`") + .noundo(), + ] +} + +/// Most entities this command looks at (a hostile drawing can be huge). +const MAX_SCAN: usize = 2_000_000; + +/// The comparison operators QSELECT supports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Op { + Eq, + Ne, + Gt, + Lt, + Wild, +} + +impl Op { + pub fn parse(s: &str) -> Option { + Some(match s.trim().to_ascii_lowercase().as_str() { + "=" | "==" | "equals" | "eq" => Op::Eq, + "!=" | "<>" | "not equal" | "notequal" | "ne" => Op::Ne, + ">" | "greater" | "greater than" | "gt" => Op::Gt, + "<" | "less" | "less than" | "lt" => Op::Lt, + "*" | "wildcard" | "wildcard match" | "like" => Op::Wild, + _ => return None, + }) + } +} + +/// Lower-case, no spaces/underscores: `Start X` → `startx`, `arc_length` → `arclength`. +fn norm(k: &str) -> String { + k.chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').flat_map(char::to_lowercase).collect() +} + +/// Flattened scalar properties of an entity: (display name, value). +pub fn properties(d: &Drawing, e: &Entity) -> Vec<(String, Value)> { + let v = super::props::entity_props(d, e); + let mut out: Vec<(String, Value)> = Vec::new(); + let mut push = |k: &str, val: &Value| { + if matches!(val, Value::String(_) | Value::Number(_) | Value::Bool(_)) && !out.iter().any(|(n, _)| norm(n) == norm(k)) { + out.push((k.to_string(), val.clone())); + } + }; + if let Some(o) = v.as_object() { + for k in ["type", "layer", "color", "linetype", "lineweight", "ltscale", "handle", "visible"] { + if let Some(x) = o.get(k) { + push(k, x); + } + } + for (k, x) in o { + if k != "geometry" && k != "bounds" { + push(k, x); + } + } + if let Some(g) = o.get("geometry").and_then(Value::as_object) { + for (k, x) in g { + if k == "type" { + continue; + } + match x { + // Points: expose their coordinates (`centerX`, `insertY`, …). + Value::Object(p) => { + for c in ["x", "y", "z"] { + if let Some(n) = p.get(c) { + push(&format!("{k}{}", c.to_ascii_uppercase()), n); + } + } + } + _ => push(k, x), + } + } + } + } + out +} + +fn property(d: &Drawing, e: &Entity, name: &str) -> Option { + let n = norm(name); + if n == "objecttype" { + return Some(Value::String(e.kind.type_name().into())); + } + properties(d, e).into_iter().find(|(k, _)| norm(k) == n).map(|(_, v)| v) +} + +/// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit. +pub fn wildcard(pat: &str, text: &str) -> bool { + let p: Vec = pat.to_lowercase().chars().take(1024).collect(); + let t: Vec = text.to_lowercase().chars().take(4096).collect(); + // Iterative matcher with single back-track point (linear-ish, no recursion). + let (mut pi, mut ti) = (0usize, 0usize); + let mut star: Option<(usize, usize)> = None; + while ti < t.len() { + let pc = p.get(pi).copied(); + let tc = t.get(ti).copied(); + match (pc, tc) { + (Some('*'), _) => { + star = Some((pi, ti)); + pi += 1; + } + (Some('?'), Some(_)) => { + pi += 1; + ti += 1; + } + (Some('#'), Some(c)) if c.is_ascii_digit() => { + pi += 1; + ti += 1; + } + (Some(a), Some(b)) if a == b && a != '#' => { + pi += 1; + ti += 1; + } + _ => match star { + Some((sp, st)) => { + pi = sp + 1; + ti = st + 1; + star = Some((sp, st + 1)); + } + None => return false, + }, + } + } + p.iter().skip(pi).all(|c| *c == '*') +} + +fn as_num(v: &Value) -> Option { + match v { + Value::Number(n) => n.as_f64(), + Value::Bool(b) => Some(f64::from(u8::from(*b))), + Value::String(s) => crate::units::parse_distance(s.trim()).or_else(|| s.trim().parse::().ok()), + _ => None, + } + .filter(|f| f.is_finite()) +} + +fn as_text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Bool(b) => if *b { "Yes" } else { "No" }.into(), + other => other.to_string(), + } +} + +/// Does `prop` satisfy `op value`? +pub fn compare(prop: &Value, op: Op, value: &Value) -> bool { + let num = matches!(prop, Value::Number(_)).then(|| as_num(prop)).flatten().zip(as_num(value)); + let ptxt = as_text(prop); + let vtxt = as_text(value); + let same = || match num { + Some((a, b)) => (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0), + None => { + if ptxt.eq_ignore_ascii_case(vtxt.trim()) { + return true; + } + // Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`. + match (Color::parse(&ptxt), Color::parse(vtxt.trim())) { + (Some(a), Some(b)) => a == b, + _ => false, + } + } + }; + match op { + Op::Eq => same(), + Op::Ne => !same(), + Op::Gt => match num { + Some((a, b)) => a > b, + None => ptxt.to_lowercase() > vtxt.to_lowercase(), + }, + Op::Lt => match num { + Some((a, b)) => a < b, + None => ptxt.to_lowercase() < vtxt.to_lowercase(), + }, + Op::Wild => wildcard(vtxt.trim(), &ptxt), + } +} + +fn type_matches(e: &Entity, ty: Option<&str>) -> bool { + match ty { + None => true, + Some(t) => { + let t = t.trim(); + t.is_empty() + || t == "*" + || t.eq_ignore_ascii_case("multiple") + || t.eq_ignore_ascii_case("all") + || e.kind.type_name().eq_ignore_ascii_case(t) + || e.kind.dxf_name().eq_ignore_ascii_case(t) + } + } +} + +/// The candidate objects: the whole edit space, or the current selection. +fn candidates(s: &Session, p: &Value) -> Result> { + let apply = str_param(p, "applyTo").unwrap_or("drawing").to_ascii_lowercase(); + if apply.starts_with("sel") || apply == "current" { + return Ok(s.selection()); + } + let space = s.space(); + Ok(s.doc()?.space(&space).map(|st| st.iter().take(MAX_SCAN).map(|e| e.handle).collect()).unwrap_or_default()) +} + +fn run_qselect(s: &mut Session, p: &Value) -> Result { + let cmd = "qselect"; + let ty = str_param(p, "type"); + let op = match str_param(p, "operator") { + Some(o) => Some(Op::parse(o).ok_or_else(|| bad(cmd, format!("unknown operator `{o}` (use =, !=, >, <, *)")))?), + None => None, + }; + let prop = str_param(p, "property").map(str::to_string); + let value = p.get("value").cloned(); + if prop.is_some() && value.is_none() && !matches!(op, Some(Op::Wild)) { + return Err(bad(cmd, "`value` is required with `property`")); + } + let mode = str_param(p, "mode").unwrap_or("new").to_ascii_lowercase(); + if !["new", "append", "exclude", "include"].contains(&mode.as_str()) { + return Err(bad(cmd, format!("unknown mode `{mode}` (use new, append or exclude)"))); + } + let layer = str_param(p, "layer"); + let color = p.get("color").filter(|c| !c.is_null()); + let cands = candidates(s, p)?; + let d = s.doc()?; + let mut matched = Vec::new(); + let mut excluded = Vec::new(); + for h in &cands { + let Some(e) = d.entity(*h) else { continue }; + if !type_matches(e, ty) { + continue; + } + let mut ok = true; + if let Some(l) = layer { + ok &= e.common.layer.eq_ignore_ascii_case(l); + } + if let Some(c) = color { + ok &= compare(&Value::String(e.common.color.name()), Op::Eq, c); + } + if let Some(pr) = &prop { + let v = value.clone().unwrap_or(Value::String("*".into())); + ok &= match property(d, e, pr) { + Some(pv) => compare(&pv, op.unwrap_or(Op::Eq), &v), + // A property the object doesn't have never matches (`!=` included, like QSELECT). + None => false, + }; + } + if ok { + matched.push(*h); + } else { + excluded.push(*h); + } + } + let sel = match mode.as_str() { + "exclude" => excluded, + "append" => { + let mut cur = s.selection(); + for h in matched { + if !cur.contains(&h) { + cur.push(h); + } + } + cur + } + _ => matched, + }; + s.set_selection(sel); + let sel = s.selection(); + let n = sel.len(); + Ok(json!({ "selected": n, "handles": sel.iter().map(|h| h.hex()).collect::>(), "message": format!("{n} item(s) selected") })) +} + +fn run_info(s: &mut Session, p: &Value) -> Result { + let cands = candidates(s, p)?; + let ty = str_param(p, "type"); + let d = s.doc()?; + let mut types: Vec<(String, usize)> = Vec::new(); + let mut props: Vec = Vec::new(); + for h in &cands { + let Some(e) = d.entity(*h) else { continue }; + let tn = e.kind.type_name(); + match types.iter_mut().find(|(n, _)| n == tn) { + Some((_, c)) => *c += 1, + None => types.push((tn.to_string(), 1)), + } + if type_matches(e, ty) && props.len() < 200 { + for (k, _) in properties(d, e) { + if k != "handle" && !props.contains(&k) { + props.push(k); + } + } + } + } + types.sort(); + Ok(json!({ + "count": cands.len(), + "types": types.iter().map(|(n, c)| json!({ "type": n, "count": c })).collect::>(), + "properties": props, + "operators": ["=", "!=", ">", "<", "*"], + })) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("layer.new", &json!({ "name": "Walls" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [3, 4]] })).unwrap(); + s.execute("circle", &json!({ "center": [0, 0], "radius": 2 })).unwrap(); + s.execute("circle", &json!({ "center": [5, 5], "radius": 7 })).unwrap(); + let h = s.doc().unwrap().model.iter().last().unwrap().handle.hex(); + s.execute("properties.set", &json!({ "handles": [h], "layer": "Walls", "color": 1 })).unwrap(); + s.set_selection(Vec::new()); + s + } + + fn n(v: &Value) -> u64 { + v["selected"].as_u64().unwrap() + } + + #[test] + fn type_and_property_filters() { + let mut s = session(); + assert_eq!(n(&s.execute("qselect", &json!({ "type": "Line" })).unwrap()), 2); + assert_eq!(n(&s.execute("qselect", &json!({ "type": "Circle", "property": "radius", "operator": ">", "value": 3 })).unwrap()), 1); + assert_eq!(n(&s.execute("qselect", &json!({ "type": "Circle", "property": "Radius", "operator": "<", "value": "3" })).unwrap()), 1); + assert_eq!(n(&s.execute("qselect", &json!({ "type": "Line", "property": "length", "operator": "=", "value": 5 })).unwrap()), 1); + assert_eq!(n(&s.execute("qselect", &json!({ "property": "layer", "operator": "=", "value": "walls" })).unwrap()), 1); + assert_eq!(n(&s.execute("qselect", &json!({ "property": "layer", "operator": "!=", "value": "Walls" })).unwrap()), 3); + assert_eq!(n(&s.execute("qselect", &json!({ "property": "layer", "operator": "*", "value": "W*s" })).unwrap()), 1); + assert_eq!(n(&s.execute("qselect", &json!({ "property": "color", "operator": "=", "value": "red" })).unwrap()), 1); + assert_eq!(n(&s.execute("qselect", &json!({ "property": "color", "operator": "=", "value": 1 })).unwrap()), 1); + assert_eq!(n(&s.execute("qselect", &json!({ "property": "centerX", "operator": "=", "value": 5 })).unwrap()), 1); + // Back-compatible short form. + assert_eq!(n(&s.execute("qselect", &json!({ "layer": "Walls" })).unwrap()), 1); + } + + #[test] + fn modes_append_exclude_and_scope() { + let mut s = session(); + s.execute("qselect", &json!({ "type": "Circle" })).unwrap(); + assert_eq!( + n(&s.execute("qselect", &json!({ "type": "Line", "property": "length", "operator": ">", "value": 6, "mode": "append" })).unwrap()), + 3 + ); + // Exclude: everything that does NOT match. + assert_eq!(n(&s.execute("qselect", &json!({ "property": "layer", "operator": "=", "value": "Walls", "mode": "exclude" })).unwrap()), 3); + // Apply to the current selection only. + let r = s.execute("qselect", &json!({ "applyTo": "selection", "type": "Line" })).unwrap(); + assert_eq!(n(&r), 2); + let info = s.execute("qselect.info", &json!({ "type": "Circle" })).unwrap(); + assert_eq!(info["types"].as_array().unwrap().len(), 2); + assert!(info["properties"].as_array().unwrap().iter().any(|p| p == "radius")); + } + + #[test] + fn hostile_params_are_errors_not_panics() { + let mut s = session(); + assert!(s.execute("qselect", &json!({ "operator": "~~" })).is_err()); + assert!(s.execute("qselect", &json!({ "mode": "sideways" })).is_err()); + assert!(s.execute("qselect", &json!({ "property": "radius" })).is_err()); + for v in [json!(null), json!([1, 2]), json!({"a": 1}), json!(f64::MAX), json!("*".repeat(5000))] { + let _ = s.execute("qselect", &json!({ "property": "radius", "operator": "*", "value": v })); + let _ = s.execute("qselect", &json!({ "property": v, "operator": ">", "value": v, "type": v })); + } + } + + #[test] + fn wildcards() { + assert!(wildcard("W*", "walls")); + assert!(wildcard("*", "")); + assert!(wildcard("a?c", "abc")); + assert!(wildcard("L#", "L7")); + assert!(!wildcard("L#", "Lx")); + assert!(!wildcard("a*d", "abc")); + assert!(wildcard("*b*", "abc")); + assert!(!wildcard(&"*a".repeat(500), &"b".repeat(4000))); + } +} diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index 27dc603..8fbcb50 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -65,6 +65,11 @@ pub fn specs() -> Vec { .params("{on?: bool}") .enabled(always) .noundo(), + CommandSpec::new("qpmode", "Quick Properties", |s, p| toggle(s, p, |st| &mut st.qpmode, "Quick Properties")) + .params("{on?: bool}") + .enabled(always) + .noundo() + .transparent(), CommandSpec::new("selectioncycling", "Selection Cycling", |s, p| toggle(s, p, |st| &mut st.selection_cycling, "Selection Cycling")) .params("{on?: bool}") .enabled(always) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c50d67b..1f6dbc9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -236,6 +236,8 @@ pub struct Settings { pub isodraft: bool, pub annoallvisible: bool, pub annoautoscale: bool, + /// QPMODE: show the Quick Properties palette when objects are selected. + pub qpmode: bool, } impl Default for Settings { @@ -264,6 +266,7 @@ impl Default for Settings { isodraft: false, annoallvisible: true, annoautoscale: false, + qpmode: false, } } } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index a5fa0d9..5a3994b 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -16,6 +16,7 @@ const SESSION_VARS: &[&str] = &[ "GRIDMAJOR", "DYNMODE", "LWDISPLAY", + "QPMODE", "PICKBOX", "APERTURE", "PICKFIRST", @@ -40,6 +41,7 @@ pub fn get(s: &Session, name: &str) -> Option { "GRIDMAJOR" => json!(st.gridmajor), "DYNMODE" => json!(i32::from(st.dynmode)), "LWDISPLAY" => json!(i32::from(st.lwdisplay)), + "QPMODE" => json!(i32::from(st.qpmode)), "PICKBOX" => json!(st.pickbox), "APERTURE" => json!(st.aperture), "PICKFIRST" => json!(i32::from(st.pickfirst)), @@ -80,6 +82,10 @@ pub fn get(s: &Session, name: &str) -> Option { fn as_bool(v: &Value) -> Option { v.as_bool().or_else(|| v.as_i64().map(|i| i != 0)).or_else(|| v.as_str().and_then(|s| s.trim().parse::().ok()).map(|i| i != 0)) } +/// QPMODE is -1/0 (off), 1 or 2 (on) in AutoCAD; booleans work too. +fn as_i(v: &Value) -> Option { + v.as_i64().or_else(|| v.as_bool().map(i64::from)).or_else(|| v.as_str().and_then(|s| s.trim().parse::().ok())) +} fn as_f64(v: &Value) -> Option { v.as_f64().or_else(|| v.as_str().and_then(crate::units::parse_distance)).filter(|f| f.is_finite()) } @@ -103,6 +109,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "GRIDMAJOR" => st.gridmajor = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0) as u32, "DYNMODE" => st.dynmode = as_bool(v).ok_or_else(bad)?, "LWDISPLAY" => st.lwdisplay = as_bool(v).ok_or_else(bad)?, + "QPMODE" => st.qpmode = as_i(v).ok_or_else(bad)? > 0, "PICKBOX" => st.pickbox = as_f64(v).ok_or_else(bad)?.clamp(0.0, 50.0), "APERTURE" => st.aperture = as_f64(v).ok_or_else(bad)?.clamp(1.0, 50.0), "PICKFIRST" => st.pickfirst = as_bool(v).ok_or_else(bad)?, diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index fe0f696..423334c 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -288,6 +288,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { id: t.i(69).unwrap_or(0) as u32, locked: t.i(90).unwrap_or(0) & 16384 != 0, frozen_layers: Vec::new(), + layer_colors: Vec::new(), }), "WIPEOUT" => { let o = t.p(10); @@ -590,6 +591,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, block_records: &mut HashMap= 0, frozen: flags & 1 != 0, locked: flags & 4 != 0, + vp_freeze_new: flags & 2 != 0, plot: t.i(290).unwrap_or(1) != 0, ..Layer::default() }; diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index da52743..f0427b8 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -715,7 +715,7 @@ pub fn write(d: &Drawing) -> String { for l in &d.layers { record_head(&mut w, "LAYER", &th, "AcDbLayerTableRecord"); w.s(2, &l.name); - w.i(70, i64::from(l.frozen) | if l.locked { 4 } else { 0 }); + w.i(70, i64::from(l.frozen) | if l.vp_freeze_new { 2 } else { 0 } | if l.locked { 4 } else { 0 }); let aci = match l.color { Color::True(rgb) => i64::from(cadcraft_color::nearest_aci(rgb)), c => i64::from(c.to_aci()).clamp(1, 255), diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index aacc617..1b3607f 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -306,6 +306,7 @@ fn pdf_layout_one_to_one_with_viewport() { id: 2, locked: false, frozen_layers: Vec::new(), + layer_colors: Vec::new(), }), ) .unwrap(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3bce2bf..4f8f73e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -109,6 +109,18 @@ struct Ctx<'a> { top: Handle, /// Layers frozen in the viewport being drawn. frozen: &'a [String], + /// Per-viewport layer colour overrides of the viewport being drawn. + vp_colors: &'a [(String, Color)], +} + +impl Ctx<'_> { + /// A layer's colour, honouring the viewport's override. + fn layer_color(&self, name: &str) -> Option { + if let Some((_, c)) = self.vp_colors.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)) { + return Some(*c); + } + self.d.layer(name).map(|l| l.color) + } } struct Builder<'a> { @@ -189,7 +201,7 @@ pub fn build_plot(d: &Drawing, space: &Space, opts: &Options) -> DisplayList { build_space(d, space, opts, true) } -fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String]) -> Ctx<'a> { +fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_colors: &'a [(String, Color)]) -> Ctx<'a> { Ctx { d, xf, @@ -200,6 +212,7 @@ fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String]) -> C depth: 0, top, frozen, + vp_colors, } } @@ -219,7 +232,7 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di } continue; } - entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[]), e); + entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); } } b.list @@ -235,7 +248,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor return; } // Border (on the viewport's layer; layer off hides only the border). - entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[]), e); + entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); let center = vp.center.xy(); let ok = |v: f64| v.is_finite() && v > 0.0; if !(ok(vp.width) && ok(vp.height) && ok(vp.view_height) && center.is_finite() && vp.view_center.is_finite()) { @@ -262,7 +275,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor if matches!(me.kind, EntityKind::Viewport(_)) { continue; } - entity(&mut sub, &top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers), me); + entity(&mut sub, &top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors), me); } append_clipped(&mut b.list, &sub.list, &rect); } @@ -325,7 +338,7 @@ fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { pub fn build_entities<'a, I: IntoIterator>(d: &Drawing, ents: I, opts: &Options) -> DisplayList { let mut b = Builder { list: DisplayList::default(), opts, plotting: false }; for e in ents { - entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[]), e); + entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); } b.list } @@ -338,7 +351,7 @@ fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Rgb, f32, Option layer.map(|l| l.lineweight).unwrap_or(Lineweight::Default), @@ -753,7 +766,7 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> { let layer = if e.common.layer == "0" { ctx.block_layer.clone() } else { Some(e.common.layer.clone()) }; let color = match e.common.color { Color::ByBlock => ctx.block_color, - Color::ByLayer => ctx.d.layer(layer.as_deref().unwrap_or("0")).map(|l| l.color).unwrap_or(Color::Index(7)), + Color::ByLayer => ctx.layer_color(layer.as_deref().unwrap_or("0")).unwrap_or(Color::Index(7)), c => c, }; let lw = match e.common.lineweight { @@ -776,6 +789,7 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> { depth: ctx.depth + 1, top: ctx.top, frozen: ctx.frozen, + vp_colors: ctx.vp_colors, } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index fa60891..b5e5fda 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -124,7 +124,17 @@ fn layout_with_viewport(vp: cadcraft_doc::Viewport) -> Drawing { } fn vp(center: Vec2, w: f64, h: f64, view_center: Vec2, view_height: f64, id: u32) -> cadcraft_doc::Viewport { - cadcraft_doc::Viewport { center: center.to3(0.0), width: w, height: h, view_center, view_height, id, locked: false, frozen_layers: Vec::new() } + cadcraft_doc::Viewport { + center: center.to3(0.0), + width: w, + height: h, + view_center, + view_height, + id, + locked: false, + frozen_layers: Vec::new(), + layer_colors: Vec::new(), + } } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 67b0124..38311c4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -104,6 +104,8 @@ pub struct CanvasState { pub build_ms: f64, pub draw_ms: f64, hover_at: Option, + /// Constraint glyphs for the parametric overlay (cached per drawing revision). + pub param: crate::parametric::Cache, } fn color32(c: Rgb) -> Color32 { @@ -665,6 +667,19 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { draw_highlight(&painter, &xf, list, &sel, t.selection, 1.5, true); } app.canvas.draw_ms = crate::now_ms() - t0; + // Constraint bars and dynamic dimensional constraints (model space, or inside a viewport). + if sheet.is_none() || active_vp.is_some() { + let clip = match &active_vp { + Some(vp) => Rect::from_two_pos( + xf_paper.to_screen(Vec2::new(vp.center.x - vp.width / 2.0, vp.center.y + vp.height / 2.0)), + xf_paper.to_screen(Vec2::new(vp.center.x + vp.width / 2.0, vp.center.y - vp.height / 2.0)), + ) + .intersect(rect), + None => rect, + }; + let p = painter.with_clip_rect(clip); + crate::parametric::draw_overlay(app, ui, &p, &xf); + } // Grips on selected objects (when idle). if app.session.running.is_none() && let Ok(st) = app.session.state() diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 6d6d35b..6df219f 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -246,6 +246,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { (Icon::Lineweight, s.lwdisplay, "lwdisplay", "Show/Hide Lineweight"), (Icon::Transparency, s.transparency_display, "transparencydisplay", "Show/Hide Transparency"), (Icon::DynInput, s.dynmode, "dynmode", "Dynamic Input (F12)"), + (Icon::QuickProps, s.qpmode, "qpmode", "Quick Properties"), (Icon::Annotation, s.annoallvisible, "ui.noop", "Annotation Visibility"), (Icon::Workspace, false, "ui.noop", "Workspace Switching"), (Icon::Gear, false, "ui.dialog.dsettings", "Drafting Settings"), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d9a1f76..77522d6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,19 +1,21 @@ -//! Dialogs: Layer Properties Manager, Drafting Settings, About, command reference. +//! Dialogs: Drafting Settings, About, command reference, blocks; dispatches the Layer Properties +//! Manager ([`crate::layers`]), Quick Select ([`crate::quick`]) and Parameters Manager +//! ([`crate::parametric`]). -use cadcraft_color::Color; -use egui::{Color32, RichText, vec2}; -use serde_json::json; +use egui::{RichText, vec2}; use crate::CadApp; -use crate::icons::{self, Icon}; use crate::theme::Tokens; pub fn show(app: &mut CadApp, ctx: &egui::Context) { mtext_editor(app, ctx); + crate::quick::quick_properties(app, ctx); let Some(d) = app.ui.dialog.clone() else { return }; let mut open = true; match d.as_str() { - "layers" => layers(app, ctx, &mut open), + "layers" => crate::layers::dialog(app, ctx, &mut open), + "qselect" => crate::quick::qselect_dialog(app, ctx, &mut open), + "parameters" => crate::parametric::parameters_dialog(app, ctx, &mut open), "dsettings" => dsettings(app, ctx, &mut open), "about" => about(ctx, &mut open), "commands" => commands(app, ctx, &mut open), @@ -25,86 +27,6 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { } } -fn layers(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - let t = Tokens::get(); - let mut action: Option<(&str, serde_json::Value)> = None; - egui::Window::new("Layer Properties Manager").open(open).default_size(vec2(820.0, 420.0)).resizable(true).show(ctx, |ui| { - let Ok(d) = app.session.doc() else { return }; - let cur = d.header.str("CLAYER", "0"); - ui.horizontal(|ui| { - if icons::button(ui, Icon::LayerProps, 24.0, "New Layer", false).clicked() { - let mut n = 1; - while d.layer(&format!("Layer{n}")).is_some() { - n += 1; - } - action = Some(("layer.new", json!({ "name": format!("Layer{n}") }))); - } - if icons::button(ui, Icon::MakeCurrent, 24.0, "Set Current", false).clicked() { - // handled per row - } - ui.label(RichText::new(format!("Current layer: {cur}")).color(t.text_dim)); - }); - ui.separator(); - egui::ScrollArea::vertical().show(ui, |ui| { - egui::Grid::new("layers_grid").striped(true).num_columns(9).spacing(vec2(12.0, 4.0)).show(ui, |ui| { - for h in ["Status", "Name", "On", "Freeze", "Lock", "Plot", "Color", "Linetype", "Lineweight"] { - ui.label(RichText::new(h).strong()); - } - ui.end_row(); - for l in &d.layers { - let current = l.name.eq_ignore_ascii_case(&cur); - if ui.selectable_label(current, if current { "✔" } else { " " }).clicked() { - action = Some(("layer.current", json!({ "name": l.name }))); - } - ui.label(&l.name); - if icons::button(ui, if l.on { Icon::Bulb } else { Icon::BulbOff }, 18.0, "On/Off", false).clicked() { - action = Some(("layer.set", json!({ "name": l.name, "on": !l.on }))); - } - if icons::button(ui, if l.frozen { Icon::Snowflake } else { Icon::Sun }, 18.0, "Freeze", false).clicked() { - action = Some(("layer.set", json!({ "name": l.name, "frozen": !l.frozen }))); - } - if icons::button(ui, if l.locked { Icon::Lock } else { Icon::Unlock }, 18.0, "Lock", false).clicked() { - action = Some(("layer.set", json!({ "name": l.name, "locked": !l.locked }))); - } - if icons::button(ui, Icon::Plot, 18.0, "Plot", !l.plot).clicked() { - action = Some(("layer.set", json!({ "name": l.name, "plot": !l.plot }))); - } - let rgb = l.color.resolve(Color::Index(7), Color::Index(7)); - ui.menu_button(RichText::new(format!("■ {}", l.color.name())).color(Color32::from_rgb(rgb.0, rgb.1, rgb.2)), |ui| { - for i in 1..=9u8 { - if ui.button(Color::Index(i).name()).clicked() { - action = Some(("layer.set", json!({ "name": l.name, "color": i }))); - ui.close(); - } - } - }); - ui.menu_button(&l.linetype, |ui| { - for lt in &d.linetypes { - if lt.name.eq_ignore_ascii_case("bylayer") || lt.name.eq_ignore_ascii_case("byblock") { - continue; - } - if ui.button(<.name).clicked() { - action = Some(("layer.set", json!({ "name": l.name, "linetype": lt.name }))); - ui.close(); - } - } - ui.separator(); - if ui.button("Load all standard linetypes").clicked() { - action = Some(("linetype", json!({ "load": "*" }))); - ui.close(); - } - }); - ui.label(l.lineweight.name()); - ui.end_row(); - } - }); - }); - }); - if let Some((c, p)) = action { - let _ = app.run(c, p); - } -} - fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { egui::Window::new("Drafting Settings").open(open).resizable(false).show(ctx, |ui| { let s = &mut app.session.settings; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 9fc7e5c..5958964 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -113,6 +113,7 @@ pub enum Icon { Transparency, Isodraft, DynInput, + QuickProps, Gear, Annotation, Workspace, @@ -849,6 +850,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.c(12.0, 12.0, 8.0, b); pen.c(12.0, 12.0, 4.0, a); } + QuickProps => { + // A small palette window with property rows and a pointer. + pen.rect(3.0, 4.0, 15.0, 13.0, b); + pen.l(3.0, 7.0, 18.0, 7.0); + for y in [10.0, 13.0] { + pen.l(5.0, y, 9.0, y); + pen.la(11.0, y, 16.0, y); + } + pen.poly(&[(15.0, 14.0), (15.0, 22.0), (17.0, 20.0), (19.5, 22.5), (20.5, 21.5), (18.0, 19.0), (21.0, 18.5)], true, b); + } Equal => { pen.l(5.0, 9.0, 19.0, 9.0); pen.la(5.0, 15.0, 19.0, 15.0); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs new file mode 100644 index 0000000..8c68e2a --- /dev/null +++ b/crates/ui-egui/src/layers.rs @@ -0,0 +1,695 @@ +//! The Layer Properties Manager (LAYER): filter tree, layer table with inline editing, colour / +//! linetype / lineweight pickers, layer states and, inside a layout viewport, the VP Freeze and +//! VP Color columns. Every change runs an engine command (`layer.*`, `layerstate.*`, `vplayer`, +//! `linetype`). + +use cadcraft_color::{Color, aci_rgb}; +use cadcraft_doc::{Handle, Layer, Lineweight}; +use egui::{Color32, Rect, RichText, Sense, Stroke, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::CadApp; +use crate::icons::{self, Icon}; +use crate::theme::Tokens; + +/// Dialog-local UI state (kept in egui memory between frames). +#[derive(Clone, Debug, Default)] +struct LpmState { + /// 0 = All, 1 = All Used Layers. + filter: u8, + search: String, + selected: Option, + /// (layer, buffer) while renaming. + renaming: Option<(String, String)>, + /// (layer, buffer) while editing the description. + describing: Option<(String, String)>, + sort_desc: bool, + state_name: String, + state_sel: Option, +} + +fn rgb32(c: Color) -> Color32 { + let r = c.resolve(Color::Index(7), Color::Index(7)); + Color32::from_rgb(r.0, r.1, r.2) +} + +/// A colour swatch. +pub fn swatch(p: &egui::Painter, r: Rect, c: Color) { + p.rect_filled(r, 1.0, rgb32(c)); + p.rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(25)), egui::StrokeKind::Inside); +} + +/// A colour button (swatch + name) that opens the Select Color popup: the AutoCAD Color Index +/// palette and a true-colour editor. Returns the picked colour. `by` adds ByLayer/ByBlock. +pub fn color_button(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool, width: f32) -> Option { + let t = Tokens::get(); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 20.0), Sense::click()); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, 2.0, t.control_hover.gamma_multiply(0.5)); + } + swatch(p, Rect::from_min_size(pos2(rect.left() + 3.0, rect.center().y - 6.0), vec2(12.0, 12.0)), current); + p.text(pos2(rect.left() + 20.0, rect.center().y), egui::Align2::LEFT_CENTER, current.name(), crate::theme::body(), t.text); + let mut out = None; + egui::Popup::from_toggle_button_response(&resp).id(id).width(300.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + out = color_palette(ui, id, current, by); + if out.is_some() { + ui.close(); + } + }); + out +} + +/// The palette body (index colours + true colour). +pub fn color_palette(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool) -> Option { + let t = Tokens::get(); + let tab_id = id.with("tab"); + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(if matches!(current, Color::True(_)) { 1 } else { 0 }); + let mut out = None; + ui.horizontal(|ui| { + for (i, l) in ["Index Color", "True Color"].iter().enumerate() { + if ui.selectable_label(tab == i as u8, *l).clicked() { + tab = i as u8; + } + } + }); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + let cell = |ui: &mut egui::Ui, i: u8, size: f32, out: &mut Option| { + let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); + let c = aci_rgb(i); + ui.painter().rect_filled(r.shrink(0.5), 0.0, Color32::from_rgb(c.0, c.1, c.2)); + let sel = current == Color::Index(i); + if sel || resp.hovered() { + ui.painter().rect_stroke(r, 0.0, Stroke::new(if sel { 2.0 } else { 1.0 }, Color32::WHITE), egui::StrokeKind::Inside); + } + if resp.on_hover_text(format!("Index color: {i}")).clicked() { + *out = Some(Color::Index(i)); + } + }; + if tab == 0 { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + // The 240 graded colours: 24 hues × 10 shades. + for shade in [0u8, 2, 4, 6, 8, 1, 3, 5, 7, 9] { + ui.horizontal(|ui| { + for hue in 0..24u8 { + cell(ui, 10 + hue * 10 + shade, 11.0, &mut out); + } + }); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + for i in 1..=9u8 { + cell(ui, i, 18.0, &mut out); + ui.add_space(2.0); + } + ui.add_space(10.0); + for i in 250..=255u8 { + cell(ui, i, 18.0, &mut out); + ui.add_space(2.0); + } + }); + ui.spacing_mut().item_spacing = vec2(6.0, 4.0); + ui.add_space(6.0); + if by { + ui.horizontal(|ui| { + if ui.button("ByLayer").clicked() { + out = Some(Color::ByLayer); + } + if ui.button("ByBlock").clicked() { + out = Some(Color::ByBlock); + } + }); + } + ui.label(RichText::new(format!("Current: {}", current.name())).small().color(t.text_dim)); + } else { + let rid = id.with("rgb"); + let start = current.resolve(Color::Index(7), Color::Index(7)); + let mut c = ui.data_mut(|d| d.get_temp::<[u8; 3]>(rid)).unwrap_or([start.0, start.1, start.2]); + let mut c32 = Color32::from_rgb(c[0], c[1], c[2]); + egui::color_picker::color_picker_color32(ui, &mut c32, egui::color_picker::Alpha::Opaque); + c = [c32.r(), c32.g(), c32.b()]; + ui.horizontal(|ui| { + for (i, l) in ["R", "G", "B"].iter().enumerate() { + ui.label(*l); + if let Some(v) = c.get_mut(i) { + ui.add(egui::DragValue::new(v).range(0..=255)); + } + } + }); + ui.data_mut(|d| d.insert_temp(rid, c)); + if ui.button("Apply true color").clicked() { + out = Some(Color::True(cadcraft_color::Rgb(c[0], c[1], c[2]))); + ui.data_mut(|d| d.remove::<[u8; 3]>(rid)); + } + } + out +} + +/// A small horizontal bar showing a lineweight. +fn lw_sample(p: &egui::Painter, r: Rect, lw: Lineweight, c: Color32) { + let w = match lw { + Lineweight::Mm100(v) => (f32::from(v) / 100.0 * 3.0).clamp(1.0, 7.0), + _ => 1.0, + }; + p.line_segment([pos2(r.left(), r.center().y), pos2(r.right(), r.center().y)], Stroke::new(w, c)); +} + +/// A linetype preview drawn from its own dash pattern. +fn lt_sample(p: &egui::Painter, r: Rect, lt: Option<&cadcraft_doc::Linetype>, c: Color32) { + let y = r.center().y; + let Some(lt) = lt.filter(|l| !l.pattern.is_empty() && l.pattern.iter().any(|e| e.length.abs() > 1e-9)) else { + p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(1.0, c)); + return; + }; + let total: f64 = lt.pattern.iter().map(|e| e.length.abs()).sum::().max(1e-9); + let k = f64::from(r.width()) / 2.0 / total; + let mut x = f64::from(r.left()); + let mut guard = 0; + while x < f64::from(r.right()) && guard < 200 { + for e in <.pattern { + let l = e.length.abs() * k; + let x1 = (x + l).min(f64::from(r.right())); + if e.length > 0.0 { + p.line_segment([pos2(x as f32, y), pos2(x1 as f32, y)], Stroke::new(1.0, c)); + } else if e.length == 0.0 { + p.circle_filled(pos2(x as f32, y), 0.8, c); + } + x = x1.max(x + 1.0); + guard += 1; + } + } +} + +/// Status column: a green check for the current layer, a sheet for the others. +fn status_icon(p: &egui::Painter, r: Rect, current: bool, used: bool) { + if current { + let g = Color32::from_rgb(0x4c, 0xc2, 0x5a); + p.line_segment([pos2(r.left() + 3.0, r.center().y), pos2(r.left() + 7.0, r.bottom() - 4.0)], Stroke::new(2.0, g)); + p.line_segment([pos2(r.left() + 7.0, r.bottom() - 4.0), pos2(r.right() - 3.0, r.top() + 3.0)], Stroke::new(2.0, g)); + } else { + let c = if used { Color32::from_rgb(0xc8, 0xcc, 0xd2) } else { Color32::from_rgb(0x80, 0x86, 0x90) }; + let pts = vec![ + pos2(r.left() + 2.0, r.center().y + 1.0), + pos2(r.center().x, r.top() + 4.0), + pos2(r.right() - 2.0, r.center().y - 1.0), + pos2(r.center().x, r.bottom() - 3.0), + ]; + p.add(egui::Shape::closed_line(pts, Stroke::new(1.2, c))); + } +} + +fn natural_lower(s: &str) -> String { + s.to_lowercase() +} + +/// The Layer Properties Manager window. +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let t = Tokens::get(); + let sid = egui::Id::new("lpm_state"); + let mut st: LpmState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); + let mut actions: Vec<(&str, Value)> = Vec::new(); + let Ok(dst) = app.session.state() else { + *open = false; + return; + }; + let d = &dst.doc; + let cur = d.header.str("CLAYER", "0"); + let used = used_layers(d); + let vp: Option<(Handle, cadcraft_doc::Viewport)> = dst.active_viewport(); + let linetypes: Vec = d.linetypes.clone(); + let library: Vec = cadcraft_doc::library::standard_linetypes().into_iter().map(|l| l.name).filter(|n| d.linetype(n).is_none()).collect(); + let states: Vec = d.layer_states.iter().map(|s| s.name.clone()).collect(); + let mut layers: Vec = d.layers.clone(); + layers.sort_by_key(|l| natural_lower(&l.name)); + if st.sort_desc { + layers.reverse(); + } + let total = layers.len(); + let q = st.search.to_lowercase(); + let shown: Vec = layers + .into_iter() + .filter(|l| st.filter == 0 || used.contains(&l.name.to_ascii_lowercase())) + .filter(|l| q.is_empty() || crate_wild(&q, &l.name.to_lowercase())) + .collect(); + let title = match &vp { + Some(_) => "Layer Properties Manager — current viewport", + None => "Layer Properties Manager", + }; + egui::Window::new(title).id(egui::Id::new("lpm_window")).open(open).default_size(vec2(1180.0, 440.0)).min_width(640.0).resizable(true).show( + ctx, + |ui| { + // ----- tool row ----- + ui.horizontal(|ui| { + if icons::button(ui, Icon::Plus, 24.0, "New Layer", false).clicked() { + let mut n = 1; + while d.layer(&format!("Layer{n}")).is_some() { + n += 1; + } + let name = format!("Layer{n}"); + actions.push(("layer.new", json!({ "name": name }))); + st.renaming = Some((name.clone(), name.clone())); + st.selected = Some(name); + } + if icons::button(ui, Icon::Close, 24.0, "Delete Layer", false).clicked() + && let Some(s) = &st.selected + { + actions.push(("layer.delete", json!({ "name": s }))); + } + if icons::button(ui, Icon::MakeCurrent, 24.0, "Set Current", false).clicked() + && let Some(s) = &st.selected + { + actions.push(("layer.current", json!({ "name": s }))); + } + ui.separator(); + // Layer states. + ui.label("Layer State"); + let label = st.state_sel.clone().unwrap_or_else(|| "Unsaved Layer State".into()); + egui::ComboBox::from_id_salt("lpm_states").selected_text(label).width(170.0).show_ui(ui, |ui| { + for s in &states { + if ui.selectable_label(st.state_sel.as_deref() == Some(s), s).clicked() { + st.state_sel = Some(s.clone()); + } + } + if states.is_empty() { + ui.label(RichText::new("No saved layer states").color(t.text_dim)); + } + }); + let has = st.state_sel.is_some(); + if ui.add_enabled(has, egui::Button::new("Restore")).clicked() + && let Some(s) = &st.state_sel + { + actions.push(("layerstate.restore", json!({ "name": s }))); + } + if ui.add_enabled(has, egui::Button::new("Delete")).clicked() + && let Some(s) = st.state_sel.take() + { + actions.push(("layerstate.delete", json!({ "name": s }))); + } + ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text("New state name").desired_width(120.0)); + if ui.add_enabled(!st.state_name.trim().is_empty(), egui::Button::new("Save State")).clicked() { + let n = st.state_name.trim().to_string(); + actions.push(("layerstate.save", json!({ "name": n }))); + st.state_sel = Some(n); + st.state_name.clear(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::TextEdit::singleline(&mut st.search).hint_text("Search for layer").desired_width(160.0)); + icons::paint( + ui.painter(), + Rect::from_center_size(ui.cursor().right_center() - vec2(8.0, 0.0), vec2(14.0, 14.0)), + Icon::Search, + false, + ); + ui.add_space(18.0); + }); + }); + ui.separator(); + // A fixed-height table: the window keeps its size instead of growing to the screen. + let avail = 330.0; + ui.horizontal_top(|ui| { + // ----- filter tree ----- + ui.allocate_ui_with_layout(vec2(150.0, avail), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_width(150.0); + ui.label(RichText::new("Filters").small().color(t.text_dim)); + for (i, l) in ["All", "All Used Layers"].iter().enumerate() { + ui.horizontal(|ui| { + ui.add_space(if i == 0 { 0.0 } else { 14.0 }); + if ui.selectable_label(st.filter == i as u8, *l).clicked() { + st.filter = i as u8; + } + }); + } + ui.add_space(8.0); + if let Some((h, _)) = &vp { + ui.label(RichText::new(format!("Viewport {}", h.hex())).small().color(t.text_dim)); + } + }); + ui.separator(); + // ----- the table ----- + ui.vertical(|ui| { + egui::ScrollArea::both().id_salt("lpm_table").auto_shrink([false, false]).max_height(avail).show(ui, |ui| { + let mut cols: Vec<&str> = + vec!["Status", "Name", "On", "Freeze", "Lock", "Plot", "Color", "Linetype", "Lineweight", "Transparency"]; + if vp.is_some() { + cols.extend(["VP Freeze", "VP Color"]); + } + cols.extend(["New VP Freeze", "Description"]); + egui::Grid::new("lpm_grid").striped(true).num_columns(cols.len()).spacing(vec2(10.0, 3.0)).min_row_height(22.0).show( + ui, + |ui| { + for h in &cols { + if *h == "Name" { + let arrow = if st.sort_desc { " ▼" } else { " ▲" }; + if ui.add(egui::Label::new(RichText::new(format!("Name{arrow}")).strong()).sense(Sense::click())).clicked() { + st.sort_desc = !st.sort_desc; + } + } else { + ui.label(RichText::new(*h).strong()); + } + } + ui.end_row(); + for l in &shown { + row(ui, l, &cur, &used, vp.as_ref(), &linetypes, &library, &mut st, &mut actions); + ui.end_row(); + } + }, + ); + }); + }); + }); + ui.separator(); + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("All: {} layer(s) displayed of {} total. Current layer: {}", shown.len(), total, cur)) + .small() + .color(t.text_dim), + ); + }); + }, + ); + ctx.data_mut(|dd| dd.insert_temp(sid, st)); + for (c, p) in actions { + let _ = app.run(c, p); + } +} + +#[allow(clippy::too_many_arguments)] +fn row( + ui: &mut egui::Ui, + l: &Layer, + cur: &str, + used: &std::collections::HashSet, + vp: Option<&(Handle, cadcraft_doc::Viewport)>, + linetypes: &[cadcraft_doc::Linetype], + library: &[String], + st: &mut LpmState, + actions: &mut Vec<(&'static str, Value)>, +) { + let t = Tokens::get(); + let name = l.name.clone(); + let current = name.eq_ignore_ascii_case(cur); + let is_used = used.contains(&name.to_ascii_lowercase()); + let selected = st.selected.as_deref().is_some_and(|s| s.eq_ignore_ascii_case(&name)); + // Status. + let (sr, sresp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click()); + if selected { + ui.painter().rect_filled(sr, 2.0, t.accent.gamma_multiply(0.35)); + } + status_icon(ui.painter(), sr.shrink(2.0), current, is_used); + let tip = if current { + "Current layer" + } else if is_used { + "Layer contains objects (double-click to make current)" + } else { + "Layer is empty (double-click to make current)" + }; + if sresp.on_hover_text(tip).double_clicked() { + actions.push(("layer.current", json!({ "name": name }))); + } + // Name (double-click or F2-style rename). + match st.renaming.clone() { + Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { + let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::new(("lpm_rename", &name)))); + if !r.has_focus() && !r.lost_focus() { + r.request_focus(); + } + if r.lost_focus() { + let cancel = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if !cancel && buf.trim() != name && !buf.trim().is_empty() { + actions.push(("layer.set", json!({ "name": name, "newName": buf.trim() }))); + st.selected = Some(buf.trim().to_string()); + } + st.renaming = None; + } else { + st.renaming = Some((n, buf)); + } + } + _ => { + let (nr, r) = ui.allocate_exact_size(vec2(150.0, 20.0), Sense::click()); + if selected { + ui.painter().rect_filled(nr, 2.0, t.accent.gamma_multiply(0.45)); + } else if r.hovered() { + ui.painter().rect_filled(nr, 2.0, t.control_hover.gamma_multiply(0.5)); + } + ui.painter().text(pos2(nr.left() + 4.0, nr.center().y), egui::Align2::LEFT_CENTER, &name, crate::theme::body(), t.text); + if r.clicked() { + st.selected = Some(name.clone()); + } + if r.double_clicked() && name != "0" && !name.eq_ignore_ascii_case("Defpoints") { + st.renaming = Some((name.clone(), name.clone())); + } + r.context_menu(|ui| { + if ui.button("Set Current").clicked() { + actions.push(("layer.current", json!({ "name": name }))); + ui.close(); + } + if ui.button("Rename Layer").clicked() { + st.renaming = Some((name.clone(), name.clone())); + ui.close(); + } + if ui.button("Delete Layer").clicked() { + actions.push(("layer.delete", json!({ "name": name }))); + ui.close(); + } + }); + } + } + // On / Freeze / Lock / Plot. + if icons::button(ui, if l.on { Icon::Bulb } else { Icon::BulbOff }, 20.0, "On/Off", false).clicked() { + actions.push(("layer.set", json!({ "name": name, "on": !l.on }))); + } + if icons::button(ui, if l.frozen { Icon::Snowflake } else { Icon::Sun }, 20.0, "Freeze/Thaw in all viewports", false).clicked() { + actions.push(("layer.set", json!({ "name": name, "frozen": !l.frozen }))); + } + if icons::button(ui, if l.locked { Icon::Lock } else { Icon::Unlock }, 20.0, "Lock/Unlock", false).clicked() { + actions.push(("layer.set", json!({ "name": name, "locked": !l.locked }))); + } + let pr = icons::button(ui, Icon::Plot, 20.0, if l.plot { "Plot (click: do not plot)" } else { "Do not plot (click: plot)" }, false); + if !l.plot { + let r = pr.rect.shrink(3.0); + ui.painter().line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.6, Color32::from_rgb(0xe0, 0x50, 0x50))); + } + if pr.clicked() { + actions.push(("layer.set", json!({ "name": name, "plot": !l.plot }))); + } + // Color. + if let Some(c) = color_button(ui, egui::Id::new(("lpm_color", &name)), l.color, false, 96.0) { + actions.push(("layer.set", json!({ "name": name, "color": c.name() }))); + } + // Linetype. + let lt_resp = ui + .allocate_ui(vec2(130.0, 20.0), |ui| { + let (r, resp) = ui.allocate_exact_size(vec2(130.0, 20.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.control_hover.gamma_multiply(0.5)); + } + lt_sample( + ui.painter(), + Rect::from_min_size(pos2(r.left() + 3.0, r.top()), vec2(30.0, r.height())), + linetypes.iter().find(|x| x.name.eq_ignore_ascii_case(&l.linetype)), + t.text, + ); + ui.painter().text(pos2(r.left() + 38.0, r.center().y), egui::Align2::LEFT_CENTER, &l.linetype, crate::theme::body(), t.text); + resp + }) + .inner; + egui::Popup::from_toggle_button_response(<_resp) + .id(egui::Id::new(("lpm_lt", &name))) + .width(240.0) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.label(RichText::new("Loaded linetypes").small().color(t.text_dim)); + for lt in linetypes.iter().filter(|x| !["bylayer", "byblock"].contains(&x.name.to_ascii_lowercase().as_str())) { + let (r, resp) = ui.allocate_exact_size(vec2(230.0, 20.0), Sense::click()); + if resp.hovered() || lt.name.eq_ignore_ascii_case(&l.linetype) { + ui.painter().rect_filled(r, 2.0, t.accent.gamma_multiply(if resp.hovered() { 0.6 } else { 0.3 })); + } + lt_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 4.0, r.top()), vec2(50.0, r.height())), Some(lt), t.text); + ui.painter().text(pos2(r.left() + 62.0, r.center().y), egui::Align2::LEFT_CENTER, <.name, crate::theme::body(), t.text); + if resp.on_hover_text(<.description).clicked() { + actions.push(("layer.set", json!({ "name": name, "linetype": lt.name }))); + ui.close(); + } + } + if !library.is_empty() { + ui.separator(); + ui.menu_button("Load...", |ui| { + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for n in library { + if ui.button(n).clicked() { + actions.push(("linetype", json!({ "load": n }))); + actions.push(("layer.set", json!({ "name": name, "linetype": n }))); + ui.close(); + } + } + }); + ui.separator(); + if ui.button("Load all").clicked() { + actions.push(("linetype", json!({ "load": "*" }))); + ui.close(); + } + }); + } + }); + // Lineweight. + let lw_resp = ui + .allocate_ui(vec2(100.0, 20.0), |ui| { + let (r, resp) = ui.allocate_exact_size(vec2(100.0, 20.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.control_hover.gamma_multiply(0.5)); + } + lw_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 3.0, r.top()), vec2(22.0, r.height())), l.lineweight, t.text); + ui.painter().text(pos2(r.left() + 30.0, r.center().y), egui::Align2::LEFT_CENTER, l.lineweight.name(), crate::theme::body(), t.text); + resp + }) + .inner; + egui::Popup::from_toggle_button_response(&lw_resp) + .id(egui::Id::new(("lpm_lw", &name))) + .width(170.0) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + let opts = std::iter::once(Lineweight::Default).chain(Lineweight::STANDARD.iter().map(|v| Lineweight::Mm100(*v))); + for lw in opts { + let (r, resp) = ui.allocate_exact_size(vec2(160.0, 20.0), Sense::click()); + if resp.hovered() || lw == l.lineweight { + ui.painter().rect_filled(r, 2.0, t.accent.gamma_multiply(if resp.hovered() { 0.6 } else { 0.3 })); + } + lw_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 4.0, r.top()), vec2(40.0, r.height())), lw, t.text); + ui.painter().text(pos2(r.left() + 52.0, r.center().y), egui::Align2::LEFT_CENTER, lw.name(), crate::theme::body(), t.text); + if resp.clicked() { + let v = match lw { + Lineweight::Mm100(v) => json!(f64::from(v) / 100.0), + _ => json!("Default"), + }; + actions.push(("layer.set", json!({ "name": name, "lineweight": v }))); + ui.close(); + } + } + }); + }); + // Transparency (commits when the drag or edit ends). + let tid = egui::Id::new(("lpm_tr", &name)); + let mut tr = ui.data_mut(|d| d.get_temp::(tid)).unwrap_or(l.transparency); + let r = ui.add(egui::DragValue::new(&mut tr).range(0..=90).speed(0.5)); + if r.dragged() || r.has_focus() { + ui.data_mut(|d| d.insert_temp(tid, tr)); + } + if (r.drag_stopped() || r.lost_focus()) && tr != l.transparency { + actions.push(("layer.set", json!({ "name": name, "transparency": tr }))); + ui.data_mut(|d| d.remove::(tid)); + } else if !r.dragged() && !r.has_focus() { + ui.data_mut(|d| d.remove::(tid)); + } + // VP Freeze / VP Color (inside a layout viewport). + if let Some((h, v)) = vp { + let vf = v.frozen_layers.iter().any(|f| f.eq_ignore_ascii_case(&name)); + if icons::button(ui, if vf { Icon::Snowflake } else { Icon::Sun }, 20.0, "Freeze/Thaw in the current viewport", false).clicked() { + let key = if vf { "thaw" } else { "freeze" }; + actions.push(("vplayer", json!({ "handle": h.hex(), key: [name] }))); + } + let vc = v.layer_colors.iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)).map(|(_, c)| *c); + ui.horizontal(|ui| { + if let Some(c) = color_button(ui, egui::Id::new(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) { + actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): c.name() } }))); + } + if vc.is_some() && ui.small_button("×").on_hover_text("Remove the viewport override").clicked() { + actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): Value::Null } }))); + } + }); + } + // New VP Freeze. + let nvf = icons::button(ui, if l.vp_freeze_new { Icon::Snowflake } else { Icon::Sun }, 20.0, "Freeze in new viewports", false); + { + // A small "new" mark distinguishes it from the Freeze column. + let r = nvf.rect; + ui.painter().rect_filled(Rect::from_min_size(r.right_bottom() - vec2(7.0, 7.0), vec2(6.0, 6.0)), 1.0, t.icon_accent); + } + if nvf.clicked() { + actions.push(("layer.set", json!({ "name": name, "newVpFreeze": !l.vp_freeze_new }))); + } + // Description (double-click to edit). + match st.describing.clone() { + Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { + let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(200.0).id(egui::Id::new(("lpm_desc", &name)))); + if !r.has_focus() && !r.lost_focus() { + r.request_focus(); + } + if r.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && buf != l.description { + actions.push(("layer.set", json!({ "name": name, "description": buf }))); + } + st.describing = None; + } else { + st.describing = Some((n, buf)); + } + } + _ => { + let text = if l.description.is_empty() { RichText::new("—").color(t.text_faint) } else { RichText::new(&l.description) }; + let r = ui.add_sized([200.0, 20.0], egui::Label::new(text).sense(Sense::click()).truncate()); + if r.on_hover_text("Double-click to edit the description").double_clicked() { + st.describing = Some((name.clone(), l.description.clone())); + } + } + } +} + +/// Lower-case names of layers with objects on them. +fn used_layers(d: &cadcraft_doc::Drawing) -> std::collections::HashSet { + let mut set = std::collections::HashSet::new(); + let ents = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())); + for e in ents.take(5_000_000) { + if !set.contains(&e.common.layer.to_ascii_lowercase()) { + set.insert(e.common.layer.to_ascii_lowercase()); + } + } + set +} + +/// Layer search: plain text matches anywhere; `*` / `?` are wildcards. +fn crate_wild(q: &str, name: &str) -> bool { + if !q.contains('*') && !q.contains('?') { + return name.contains(q); + } + wild(q.as_bytes(), name.as_bytes()) +} + +fn wild(p: &[u8], t: &[u8]) -> bool { + let (mut pi, mut ti, mut star, mut mark) = (0usize, 0usize, None::, 0usize); + while ti < t.len() { + match p.get(pi) { + Some(b'*') => { + star = Some(pi); + pi += 1; + mark = ti; + } + Some(&c) if c == b'?' || Some(&c) == t.get(ti) => { + pi += 1; + ti += 1; + } + _ => match star { + Some(s) => { + pi = s + 1; + mark += 1; + ti = mark; + } + None => return false, + }, + } + } + p.iter().skip(pi).all(|c| *c == b'*') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layer_search() { + assert!(crate_wild("wal", "walls")); + assert!(crate_wild("w*s", "walls")); + assert!(crate_wild("?alls", "walls")); + assert!(!crate_wild("x*", "walls")); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d7665a8..5a353a7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,8 +16,11 @@ pub mod control; pub mod dialogs; pub mod gpu; pub mod icons; +pub mod layers; pub mod menus; pub mod palettes; +pub mod parametric; +pub mod quick; pub mod theme; use std::sync::mpsc::Receiver; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f8417a7..d648f48 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -27,6 +27,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.start", "Start", &["Window", "Start"], None), ("ui.dialog.layers", "Layer Properties Manager", &["Window", "Layers"], None), ("ui.dialog.blocks", "Blocks", &["Window", "Blocks"], None), + ("ui.dialog.qselect", "Quick Select...", &[], None), + ("ui.dialog.parameters", "Parameters Manager", &["Window", "Parameters Manager"], None), ("ui.dialog.dsettings", "Drafting Settings...", &[], None), ("ui.dialog.about", "About CADCraft", &["Help", "About CADCraft"], None), ("ui.dialog.commands", "Command Reference", &["Help", "CADCraft Help"], Some("F1")), @@ -118,7 +120,13 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option { + "ui.dialog.layers" + | "ui.dialog.blocks" + | "ui.dialog.dsettings" + | "ui.dialog.about" + | "ui.dialog.commands" + | "ui.dialog.qselect" + | "ui.dialog.parameters" => { app.ui.dialog = Some(id.trim_start_matches("ui.dialog.").to_string()); Ok(Value::Null) } @@ -131,10 +139,25 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option Ok(Value::Null), - "layer" if params.is_null() => { + "layer" | "la" | "layers" if params.is_null() => { app.ui.dialog = Some("layers".into()); Ok(Value::Null) } + // Typed or menu-invoked (no parameters) these open their dialogs; JSON calls run the command. + "qselect" | "qs" if params.is_null() => { + app.ui.dialog = Some("qselect".into()); + Ok(Value::Null) + } + "parameters" | "par" if params.is_null() => { + app.ui.dialog = Some("parameters".into()); + Ok(Value::Null) + } + "parametersclose" if params.is_null() => { + if app.ui.dialog.as_deref() == Some("parameters") { + app.ui.dialog = None; + } + Ok(Value::Null) + } _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/parametric.rs b/crates/ui-egui/src/parametric.rs new file mode 100644 index 0000000..8efc836 --- /dev/null +++ b/crates/ui-egui/src/parametric.rs @@ -0,0 +1,602 @@ +//! Parametric display: geometric constraint bars and dynamic dimensional constraints drawn over +//! the canvas (from the engine's `constraints.inspect`), inline editing of a dimensional +//! constraint's expression, and the Parameters Manager. +//! +//! Every edit runs an engine command (`parameters {name, expr}` / `{delete}`); this module only +//! draws and collects input. + +use cadcraft_doc::{ConstraintKind, DistAxis, Drawing, EntityKind, GeomRef, Handle, Sub}; +use cadcraft_geom::Vec2; +use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::CadApp; +use crate::canvas::Xf; +use crate::theme::Tokens; + +/// One constraint as reported by `constraints.inspect` (`glyphs`). +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Glyph { + pub id: u32, + pub kind: String, + pub dimensional: bool, + pub handles: Vec, + pub anchors: Vec, + pub name: String, + pub expr: String, + pub value: Option, + pub satisfied: bool, +} + +/// Per-drawing-revision cache of the constraint glyphs (inspect is not free on big drawings). +#[derive(Default)] +pub struct Cache { + key: Option<(u64, u64, usize)>, + glyphs: Vec, + /// The dimensional constraint whose expression is being edited: (name, buffer, screen pos). + editing: Option<(String, String, Pos2)>, +} + +fn doc_key(app: &CadApp) -> Option<(u64, u64, usize)> { + let st = app.session.state().ok()?; + Some((st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize)) +} + +fn refresh(app: &mut CadApp) { + let key = doc_key(app); + if key.is_none() || app.canvas.param.key == key { + return; + } + app.canvas.param.key = key; + app.canvas.param.glyphs.clear(); + if app.session.doc().is_ok_and(|d| d.constraints.is_empty()) { + return; + } + if let Ok(v) = app.session.execute("constraints.inspect", &json!({})) + && let Some(g) = v.get("glyphs") + { + app.canvas.param.glyphs = serde_json::from_value(g.clone()).unwrap_or_default(); + } +} + +// ---------------- geometry of constraint references ---------------- + +fn ent_kind(d: &Drawing, h: Handle) -> Option<&EntityKind> { + d.entity(h).map(|e| &e.kind) +} + +fn poly_vertex(p: &cadcraft_doc::LwPolyline, i: u32) -> Option { + p.vertices.get(usize::try_from(i).ok()?).map(|v| v.p) +} + +fn ref_point(d: &Drawing, r: &GeomRef) -> Option { + let k = ent_kind(d, r.handle)?; + match (k, r.sub) { + (EntityKind::Line(l), Sub::Start) => Some(l.a.xy()), + (EntityKind::Line(l), Sub::End) => Some(l.b.xy()), + (EntityKind::Line(l), Sub::Mid) => Some(l.a.xy().mid(l.b.xy())), + (EntityKind::Arc(a), s) => { + let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); + match s { + Sub::Start => Some(g.start_point()), + Sub::End => Some(g.end_point()), + Sub::Mid => Some(g.mid_point()), + _ => Some(g.center), + } + } + (EntityKind::Circle(c), _) => Some(c.center.xy()), + (EntityKind::Point(p), _) => Some(p.p.xy()), + (EntityKind::LwPolyline(p), Sub::Vertex(i)) => poly_vertex(p, i), + _ => ref_line(d, r).map(|(a, b)| a.mid(b)), + } +} + +fn ref_line(d: &Drawing, r: &GeomRef) -> Option<(Vec2, Vec2)> { + match (ent_kind(d, r.handle)?, r.sub) { + (EntityKind::Line(l), Sub::Whole) => Some((l.a.xy(), l.b.xy())), + (EntityKind::LwPolyline(p), Sub::Segment(i)) => { + let n = p.vertices.len(); + let a = poly_vertex(p, i)?; + let j = usize::try_from(i).ok()?.checked_add(1)?; + let b = if j < n { + p.vertices.get(j)?.p + } else if p.closed { + p.vertices.first()?.p + } else { + return None; + }; + Some((a, b)) + } + _ => None, + } +} + +fn ref_circle(d: &Drawing, r: &GeomRef) -> Option<(Vec2, f64)> { + match ent_kind(d, r.handle)? { + EntityKind::Circle(c) => Some((c.center.xy(), c.radius)), + EntityKind::Arc(a) => Some((a.center.xy(), a.radius)), + _ => None, + } +} + +// ---------------- glyph drawing ---------------- + +/// Draw a constraint type's glyph into `r` (code-drawn; no image assets). +pub fn paint_glyph(p: &egui::Painter, r: Rect, kind: &str, c: Color32) { + let s = Stroke::new(1.4, c); + let at = |x: f32, y: f32| pos2(r.left() + r.width() * x / 16.0, r.top() + r.height() * y / 16.0); + let k = r.width() / 16.0; + match kind { + "Coincident" => { + p.line_segment([at(2.0, 13.0), at(8.0, 8.0)], s); + p.line_segment([at(8.0, 8.0), at(14.0, 12.0)], s); + p.circle_filled(at(8.0, 8.0), 2.4 * k, c); + } + "Parallel" => { + p.line_segment([at(3.0, 13.0), at(9.0, 3.0)], s); + p.line_segment([at(7.0, 13.0), at(13.0, 3.0)], s); + } + "Perpendicular" => { + p.line_segment([at(8.0, 3.0), at(8.0, 13.0)], s); + p.line_segment([at(2.5, 13.0), at(13.5, 13.0)], s); + } + "Horizontal" => { + p.line_segment([at(2.0, 8.0), at(14.0, 8.0)], s); + p.line_segment([at(2.0, 5.0), at(2.0, 11.0)], s); + p.line_segment([at(14.0, 5.0), at(14.0, 11.0)], s); + } + "Vertical" => { + p.line_segment([at(8.0, 2.0), at(8.0, 14.0)], s); + p.line_segment([at(5.0, 2.0), at(11.0, 2.0)], s); + p.line_segment([at(5.0, 14.0), at(11.0, 14.0)], s); + } + "Tangent" => { + p.circle_stroke(at(8.0, 10.0), 4.0 * k, s); + p.line_segment([at(1.5, 5.6), at(14.5, 5.6)], s); + } + "Smooth" => { + let pts: Vec = (0..=12) + .map(|i| { + let t = i as f32 / 12.0; + at(2.0 + 12.0 * t, 8.0 - 4.5 * (t * std::f32::consts::TAU).sin()) + }) + .collect(); + p.add(Shape::line(pts, s)); + } + "Collinear" => { + p.line_segment([at(2.0, 13.0), at(14.0, 3.0)], s); + for (x, y) in [(4.0, 11.3), (8.0, 8.0), (12.0, 4.7)] { + p.circle_filled(at(x, y), 1.6 * k, c); + } + } + "Concentric" => { + p.circle_stroke(at(8.0, 8.0), 6.0 * k, s); + p.circle_stroke(at(8.0, 8.0), 2.6 * k, s); + } + "Equal" => { + p.line_segment([at(3.0, 6.0), at(13.0, 6.0)], Stroke::new(1.6, c)); + p.line_segment([at(3.0, 10.5), at(13.0, 10.5)], Stroke::new(1.6, c)); + } + "Symmetric" => { + for y in [2.0, 6.0, 10.0] { + p.line_segment([at(8.0, y), at(8.0, y + 2.4)], s); + } + p.add(Shape::convex_polygon(vec![at(6.5, 4.0), at(6.5, 12.0), at(1.5, 8.0)], c, Stroke::NONE)); + p.add(Shape::convex_polygon(vec![at(9.5, 4.0), at(14.5, 8.0), at(9.5, 12.0)], c, Stroke::NONE)); + } + "Fix" => lock(p, r, c), + _ => { + p.circle_stroke(r.center(), 5.0 * k, s); + } + } +} + +/// A small padlock. +pub fn lock(p: &egui::Painter, r: Rect, c: Color32) { + let k = r.width() / 16.0; + let body = Rect::from_min_max(pos2(r.left() + 3.5 * k, r.top() + 7.0 * k), pos2(r.left() + 12.5 * k, r.top() + 14.0 * k)); + p.rect_filled(body, 1.0, c); + let pts: Vec = (0..=10) + .map(|i| { + let a = std::f32::consts::PI * i as f32 / 10.0; + pos2(r.left() + (8.0 + 3.0 * a.cos()) * k, r.top() + (7.0 - 4.0 * a.sin()) * k) + }) + .collect(); + p.add(Shape::line(pts, Stroke::new(1.4 * k.max(0.8), c))); +} + +fn arrow(p: &egui::Painter, tip: Pos2, from: Pos2, c: Color32) { + let d = tip - from; + let l = d.length().max(1e-3); + let u = d / l; + let n = vec2(-u.y, u.x); + p.add(Shape::convex_polygon(vec![tip, tip - u * 8.0 + n * 2.5, tip - u * 8.0 - n * 2.5], c, Stroke::NONE)); +} + +// ---------------- the canvas overlay ---------------- + +/// What the overlay asked for this frame. +enum Act { + Edit(String, String, Pos2), +} + +/// Draw constraint bars and dynamic constraints. `xf` maps model space to the screen. +pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter, xf: &Xf) { + refresh(app); + let Ok(d) = app.session.doc() else { return }; + if d.constraints.is_empty() { + app.canvas.param.editing = None; + return; + } + let t = Tokens::get(); + let set = &d.parametric.settings; + let mut act: Option = None; + let glyphs = &app.canvas.param.glyphs; + let (lu, lp) = (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4)); + // ----- dynamic (dimensional) constraints ----- + let dim_c = Color32::from_rgb(0x9f, 0xb6, 0xd6); + let off_px: f32 = 26.0; + let off = f64::from(off_px) / xf.scale.max(1e-300); + for c in d.constraints.iter().filter(|c| c.kind.is_dimensional()).take(2000) { + let shown = set.dims_visible != c.refs.iter().any(|r| set.dim_exceptions.contains(&r.handle)); + if !shown { + continue; + } + let g = glyphs.iter().find(|g| g.id == c.id); + let value = g.and_then(|g| g.value); + // A plain number shows the formatted value; an expression shows itself (`d2=d1/2`). + let label_value = match (c.expr.trim().parse::().is_ok(), value) { + (true, Some(v)) if c.kind == ConstraintKind::Angular => format!("{}°", trim_num(v, 2)), + (true, Some(v)) => cadcraft_engine::units::format_distance(v, lu, lp), + _ => c.expr.clone(), + }; + let label = format!("{}={}", c.name, label_value); + let ok = g.is_none_or(|g| g.satisfied); + let col = if ok { dim_c } else { Color32::from_rgb(0xff, 0x8a, 0x6a) }; + let st = Stroke::new(1.0, col); + // Where the label goes (screen) and the dimension graphics. + let mut label_at: Option = None; + match c.kind { + ConstraintKind::Distance(axis) => { + let (p1, p2) = match (c.refs.first(), c.refs.get(1)) { + (Some(a), Some(b)) => match (ref_point(d, a), ref_point(d, b)) { + (Some(p), Some(q)) => (p, q), + _ => continue, + }, + (Some(a), None) => match ref_line(d, a) { + Some(l) => l, + None => continue, + }, + _ => continue, + }; + let (q1, q2) = match axis { + DistAxis::Horizontal => { + let y = p1.y.max(p2.y) + off; + (Vec2::new(p1.x, y), Vec2::new(p2.x, y)) + } + DistAxis::Vertical => { + let x = p1.x.max(p2.x) + off; + (Vec2::new(x, p1.y), Vec2::new(x, p2.y)) + } + DistAxis::Aligned => { + let dir = p2 - p1; + if dir.len() < 1e-12 { + continue; + } + let mut n = dir.normalized().perp(); + // Keep the dimension above/left of the object. + if n.y < -1e-9 || (n.y.abs() <= 1e-9 && n.x > 0.0) { + n = n * -1.0; + } + (p1 + n * off, p2 + n * off) + } + }; + let (s1, s2, e1, e2) = (xf.to_screen(p1), xf.to_screen(p2), xf.to_screen(q1), xf.to_screen(q2)); + for (a, b) in [(s1, e1), (s2, e2)] { + let v = b - a; + let ext = if v.length() > 1e-3 { b + v.normalized() * 4.0 } else { b }; + p_line(painter, a, ext, st); + } + p_line(painter, e1, e2, st); + if (e2 - e1).length() > 18.0 { + arrow(painter, e1, e2, col); + arrow(painter, e2, e1, col); + } + label_at = Some(e1 + (e2 - e1) * 0.5); + } + ConstraintKind::Radius | ConstraintKind::Diameter => { + let Some((cen, r)) = c.refs.first().and_then(|r| ref_circle(d, r)) else { continue }; + let u = Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2); + let edge = xf.to_screen(cen + u * r); + let start = if c.kind == ConstraintKind::Diameter { xf.to_screen(cen - u * r) } else { xf.to_screen(cen) }; + p_line(painter, start, edge, st); + arrow(painter, edge, start, col); + if c.kind == ConstraintKind::Diameter { + arrow(painter, start, edge, col); + } + label_at = Some(edge + vec2(18.0, -12.0)); + } + ConstraintKind::Angular => { + let (Some(l1), Some(l2)) = (c.refs.first().and_then(|r| ref_line(d, r)), c.refs.get(1).and_then(|r| ref_line(d, r))) else { + continue; + }; + let Some(o) = intersect(l1, l2) else { continue }; + let far = |l: (Vec2, Vec2)| if l.0.dist(o) > l.1.dist(o) { l.0 } else { l.1 }; + let (a1, a2) = ((far(l1) - o).angle(), (far(l2) - o).angle()); + let mut sweep = a2 - a1; + while sweep > std::f64::consts::PI { + sweep -= std::f64::consts::TAU; + } + while sweep < -std::f64::consts::PI { + sweep += std::f64::consts::TAU; + } + let rad = off * 1.6; + let pts: Vec = (0..=24).map(|i| xf.to_screen(Vec2::polar(o, rad, a1 + sweep * f64::from(i) / 24.0))).collect(); + if let (Some(&f), Some(&l)) = (pts.first(), pts.last()) { + painter.add(Shape::line(pts.clone(), st)); + if let (Some(&f2), Some(&l2)) = (pts.get(2), pts.get(pts.len().saturating_sub(3))) { + arrow(painter, f, f2, col); + arrow(painter, l, l2, col); + } + } + label_at = Some(xf.to_screen(Vec2::polar(o, rad * 1.35, a1 + sweep / 2.0))); + } + _ => {} + } + let Some(at) = label_at else { continue }; + if !xf.rect.expand(40.0).contains(at) { + continue; + } + // The label: lock icon + `name=value`. + let galley = painter.layout_no_wrap(label.clone(), crate::theme::small(), col); + let size = galley.size() + vec2(22.0, 4.0); + let r = Rect::from_center_size(at, size); + painter.rect_filled(r, 2.0, t.canvas.gamma_multiply(0.85)); + lock(painter, Rect::from_min_size(r.min + vec2(3.0, (r.height() - 13.0) / 2.0), vec2(13.0, 13.0)), col); + painter.galley(r.min + vec2(19.0, 2.0), galley, col); + let resp = ui.interact(r, ui.id().with(("dimcon", c.id)), Sense::click()); + if resp.double_clicked() { + act = Some(Act::Edit(c.name.clone(), c.expr.clone(), r.left_center())); + } + resp.on_hover_text(format!("{} constraint {} = {} (double-click to edit)", c.kind.name(), c.name, c.expr)); + } + // ----- geometric constraint bars ----- + let mut bars: Vec<(Handle, Vec2, Vec<(&str, u32)>)> = Vec::new(); + let mut dots: Vec = Vec::new(); + for g in glyphs.iter().filter(|g| !g.dimensional).take(5000) { + let hs: Vec = g.handles.iter().filter_map(|h| Handle::parse_hex(h)).collect(); + if g.kind == "Coincident" { + if let Some(a) = g.anchors.first() + && hs.iter().any(|h| set.bars_visible != set.bar_exceptions.contains(h)) + { + dots.push(*a); + } + continue; + } + let aligned = g.anchors.len() == hs.len(); + let mut seen: Vec = Vec::new(); + for (i, h) in hs.iter().enumerate() { + if seen.contains(h) || set.bars_visible == set.bar_exceptions.contains(h) { + continue; + } + seen.push(*h); + let anchor = if aligned { g.anchors.get(i) } else { g.anchors.first() }; + let Some(anchor) = anchor.copied() else { continue }; + match bars.iter_mut().find(|b| b.0 == *h) { + Some(b) => b.2.push((g.kind.as_str(), g.id)), + None => bars.push((*h, anchor, vec![(g.kind.as_str(), g.id)])), + } + } + } + let alpha = 1.0 - f32::from(set.bar_transparency.min(90)) / 100.0 * 0.75; + for a in &dots { + let sp = xf.to_screen(*a); + if xf.rect.contains(sp) { + let r = Rect::from_center_size(sp, vec2(6.0, 6.0)); + painter.rect_filled(r, 0.0, Color32::from_rgb(0x3d, 0x8b, 0xfd).gamma_multiply(alpha.max(0.5))); + } + } + let cell = 18.0; + let mut placed: Vec = Vec::new(); + for (h, anchor, kinds) in &bars { + let sp = xf.to_screen(*anchor); + if !xf.rect.contains(sp) { + continue; + } + let n = kinds.len().min(16) as f32; + let mut r = Rect::from_min_size(sp + vec2(8.0, 8.0), vec2(n * cell + 4.0, cell + 4.0)); + for _ in 0..12 { + if placed.iter().any(|p| p.intersects(r)) { + r = r.translate(vec2(0.0, cell + 6.0)); + } else { + break; + } + } + placed.push(r); + let fill = Color32::from_rgb(0xb9, 0xbe, 0xc6).gamma_multiply(alpha); + painter.rect_filled(r, 4.0, fill); + painter.rect_stroke(r, 4.0, Stroke::new(1.0, Color32::from_rgb(0x6e, 0x75, 0x80).gamma_multiply(alpha)), egui::StrokeKind::Inside); + for (i, (kind, id)) in kinds.iter().take(16).enumerate() { + let cr = Rect::from_min_size(r.min + vec2(2.0 + i as f32 * cell, 2.0), vec2(cell, cell)); + let resp = ui.interact(cr, ui.id().with(("cbar", h.0, *id)), Sense::hover()); + if resp.hovered() { + painter.rect_filled(cr.shrink(1.0), 3.0, Color32::from_rgb(0xf2, 0xf4, 0xf7)); + } + paint_glyph(painter, cr.shrink(2.0), kind, Color32::from_rgb(0x26, 0x2b, 0x33)); + resp.on_hover_text(format!("{kind} (constraint {id})")); + } + } + let _ = act_take(app, act, ui); +} + +fn act_take(app: &mut CadApp, act: Option, ui: &mut egui::Ui) -> Option<()> { + if let Some(Act::Edit(name, expr, at)) = act { + app.canvas.param.editing = Some((name, expr, at)); + } + // The inline expression editor. + let (name, mut buf, at) = app.canvas.param.editing.clone()?; + let mut done: Option = None; + egui::Area::new(egui::Id::new("dimcon_edit")).order(egui::Order::Foreground).fixed_pos(at - vec2(0.0, 12.0)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(format!("{name} =")); + let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0).id(egui::Id::new("dimcon_edit_te"))); + if !r.has_focus() && !r.lost_focus() { + r.request_focus(); + } + if r.lost_focus() { + done = Some(ui.input(|i| i.key_pressed(egui::Key::Enter))); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + done = Some(false); + } + }); + }); + }); + match done { + Some(true) => { + app.canvas.param.editing = None; + let _ = app.run("parameters", json!({ "name": name, "expr": buf.trim() })); + } + Some(false) => app.canvas.param.editing = None, + None => app.canvas.param.editing = Some((name, buf, at)), + } + Some(()) +} + +fn p_line(p: &egui::Painter, a: Pos2, b: Pos2, s: Stroke) { + p.line_segment([a, b], s); +} + +fn trim_num(v: f64, prec: usize) -> String { + let s = format!("{v:.prec$}"); + if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } +} + +fn intersect(l1: (Vec2, Vec2), l2: (Vec2, Vec2)) -> Option { + let d1 = l1.1 - l1.0; + let d2 = l2.1 - l2.0; + let den = d1.x * d2.y - d1.y * d2.x; + if den.abs() < 1e-12 * d1.len().max(1e-12) * d2.len().max(1e-12) { + return None; + } + let w = l2.0 - l1.0; + let t = (w.x * d2.y - w.y * d2.x) / den; + let p = l1.0 + d1 * t; + p.is_finite().then_some(p) +} + +// ---------------- Parameters Manager ---------------- + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct Row { + name: String, + expr: String, + value: Option, + error: Option, + kind: String, + description: String, +} + +/// The Parameters Manager (PARAMETERS): name / expression / value; edit, add, delete. +pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let t = Tokens::get(); + let rows: Vec = app + .session + .execute("parameters", &json!({})) + .ok() + .and_then(|v| v.get("parameters").cloned()) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + let filter_id = egui::Id::new("par_filter"); + let mut filter = ctx.data_mut(|d| d.get_temp::(filter_id)).unwrap_or_default(); + let mut action: Option = None; + egui::Window::new("Parameters Manager").open(open).default_size(vec2(560.0, 300.0)).resizable(true).show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button("ƒx New User Parameter").on_hover_text("Creates a user parameter").clicked() { + let mut n = 1; + while rows.iter().any(|r| r.name == format!("user{n}")) { + n += 1; + } + action = Some(json!({ "name": format!("user{n}"), "expr": "1" })); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::TextEdit::singleline(&mut filter).hint_text("Search for parameter").desired_width(170.0)); + }); + }); + ui.separator(); + let f = filter.to_ascii_lowercase(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + egui::Grid::new("par_grid").striped(true).num_columns(5).spacing(vec2(12.0, 4.0)).min_col_width(60.0).show(ui, |ui| { + for h in ["Name", "Expression", "Value", "Type", ""] { + ui.label(egui::RichText::new(h).strong()); + } + ui.end_row(); + for (kind, title) in [("dimensional", "Dimensional Constraint Parameters"), ("user", "User Parameters")] { + let group: Vec<&Row> = + rows.iter().filter(|r| r.kind == kind && (f.is_empty() || r.name.to_ascii_lowercase().contains(&f))).collect(); + ui.label(egui::RichText::new(title).color(t.text_dim).small()); + ui.end_row(); + for r in group { + ui.label(&r.name); + let id = egui::Id::new(("par_expr", &r.name)); + if let Some(v) = expr_field(ui, id, &r.expr) { + action = Some(json!({ "name": r.name, "expr": v })); + } + match (&r.value, &r.error) { + (Some(v), _) => ui.label(trim_num(*v, 4)), + (None, Some(e)) => ui.label(egui::RichText::new("error").color(Color32::from_rgb(0xff, 0x8a, 0x6a))).on_hover_text(e), + _ => ui.label(""), + }; + ui.label(egui::RichText::new(if kind == "user" { "User" } else { r.description.as_str() }).color(t.text_dim)); + if kind == "user" { + if ui.small_button("Delete").clicked() { + action = Some(json!({ "delete": r.name })); + } + } else { + ui.label(""); + } + ui.end_row(); + } + } + }); + }); + }); + ctx.data_mut(|d| d.insert_temp(filter_id, filter)); + if let Some(p) = action { + let _ = app.run("parameters", p); + } +} + +/// A text field committing on Enter / focus loss when changed. +pub fn expr_field(ui: &mut egui::Ui, id: egui::Id, value: &str) -> Option { + let mut buf = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| value.to_string()); + let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(150.0)); + if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + (resp.lost_focus() && buf.trim() != value && !buf.trim().is_empty()).then(|| buf.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_intersection() { + let p = intersect((Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)), (Vec2::new(5.0, -1.0), Vec2::new(5.0, 3.0))); + assert!(p.is_some_and(|p| (p.x - 5.0).abs() < 1e-9 && p.y.abs() < 1e-9)); + assert!(intersect((Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0)), (Vec2::new(0.0, 1.0), Vec2::new(1.0, 1.0))).is_none()); + } + + #[test] + fn number_trimming() { + assert_eq!(trim_num(12.5, 4), "12.5"); + assert_eq!(trim_num(45.0, 2), "45"); + } +} diff --git a/crates/ui-egui/src/quick.rs b/crates/ui-egui/src/quick.rs new file mode 100644 index 0000000..29797b7 --- /dev/null +++ b/crates/ui-egui/src/quick.rs @@ -0,0 +1,417 @@ +//! Quick Select (QSELECT dialog → the engine `qselect` command) and the Quick Properties +//! palette shown near the cursor while objects are selected and QPMODE is on. + +use cadcraft_doc::{EntityKind, Handle}; +use egui::{Pos2, RichText, vec2}; +use serde_json::{Value, json}; + +use crate::CadApp; +use crate::theme::Tokens; + +// ---------------- QSELECT ---------------- + +#[derive(Clone, Debug)] +struct QsState { + apply_selection: bool, + ty: String, + property: String, + operator: String, + value: String, + exclude: bool, + append: bool, + /// (doc key, applyTo, type) → info reply. + info_key: Option<(usize, bool, String)>, + info: Value, +} + +impl Default for QsState { + fn default() -> Self { + QsState { + apply_selection: false, + ty: "Multiple".into(), + property: "color".into(), + operator: "=".into(), + value: String::new(), + exclude: false, + append: false, + info_key: None, + info: Value::Null, + } + } +} + +const OPERATORS: &[(&str, &str)] = + &[("=", "= Equals"), ("!=", "<> Not Equal"), (">", "> Greater than"), ("<", "< Less than"), ("*", "* Wildcard Match")]; + +/// Friendly label for a property key (`centerX` → `Center X`). +fn pretty(k: &str) -> String { + let mut out = String::new(); + for (i, c) in k.chars().enumerate() { + if i == 0 { + out.extend(c.to_uppercase()); + } else if c.is_uppercase() { + out.push(' '); + out.push(c); + } else { + out.push(c); + } + } + out +} + +pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let t = Tokens::get(); + let sid = egui::Id::new("qselect_state"); + let mut st: QsState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); + let key = app.session.state().map(|s| std::sync::Arc::as_ptr(&s.doc) as usize).unwrap_or(0); + let want = (key, st.apply_selection, st.ty.clone()); + if st.info_key.as_ref() != Some(&want) { + let ty = if st.ty == "Multiple" { Value::Null } else { json!(st.ty) }; + st.info = app + .session + .execute("qselect.info", &json!({ "applyTo": if st.apply_selection { "selection" } else { "drawing" }, "type": ty })) + .unwrap_or(Value::Null); + st.info_key = Some(want); + } + let has_sel = !app.session.selection().is_empty(); + let mut run: Option = None; + let mut close = false; + egui::Window::new("Quick Select").open(open).collapsible(false).resizable(false).default_width(380.0).show(ctx, |ui| { + egui::Grid::new("qs_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label("Apply to:"); + egui::ComboBox::from_id_salt("qs_apply") + .width(220.0) + .selected_text(if st.apply_selection { "Current selection" } else { "Entire drawing" }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut st.apply_selection, false, "Entire drawing"); + ui.add_enabled_ui(has_sel, |ui| { + ui.selectable_value(&mut st.apply_selection, true, "Current selection"); + }); + }); + ui.end_row(); + ui.label("Object type:"); + let types: Vec<(String, u64)> = st + .info + .get("types") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| Some((v.get("type")?.as_str()?.to_string(), v.get("count").and_then(Value::as_u64).unwrap_or(0)))) + .collect() + }) + .unwrap_or_default(); + egui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(st.ty.clone()).show_ui(ui, |ui| { + ui.selectable_value(&mut st.ty, "Multiple".to_string(), "Multiple"); + for (n, c) in &types { + ui.selectable_value(&mut st.ty, n.clone(), format!("{n} ({c})")); + } + }); + ui.end_row(); + ui.label("Properties:"); + let props: Vec = st + .info + .get("properties") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + egui::Frame::NONE.fill(t.chrome_dark).corner_radius(3).inner_margin(4).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + ui.set_width(212.0); + ui.vertical(|ui| { + for p in &props { + if ui.selectable_label(st.property == *p, pretty(p)).clicked() { + st.property = p.clone(); + } + } + }); + }); + }); + ui.end_row(); + ui.label("Operator:"); + let op_label = OPERATORS.iter().find(|o| o.0 == st.operator).map(|o| o.1).unwrap_or("= Equals"); + egui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(op_label).show_ui(ui, |ui| { + for (o, l) in OPERATORS { + ui.selectable_value(&mut st.operator, o.to_string(), *l); + } + }); + ui.end_row(); + ui.label("Value:"); + let choices: Vec = match st.property.as_str() { + "color" => { + ["ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White"].iter().map(|s| s.to_string()).collect() + } + "layer" => app.session.doc().map(|d| d.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), + "linetype" => app.session.doc().map(|d| d.linetypes.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), + "type" => types.iter().map(|(n, _)| n.clone()).collect(), + _ => Vec::new(), + }; + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })); + if !choices.is_empty() { + ui.menu_button("▼", |ui| { + egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { + for c in &choices { + if ui.button(c).clicked() { + st.value = c.clone(); + ui.close(); + } + } + }); + }); + } + }); + ui.end_row(); + }); + ui.separator(); + ui.label("How to apply:"); + ui.radio_value(&mut st.exclude, false, "Include in new selection set"); + ui.radio_value(&mut st.exclude, true, "Exclude from new selection set"); + ui.checkbox(&mut st.append, "Append to current selection set"); + ui.separator(); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(" OK ").clicked() { + let mode = if st.exclude { + "exclude" + } else if st.append { + "append" + } else { + "new" + }; + let mut p = json!({ + "applyTo": if st.apply_selection { "selection" } else { "drawing" }, + "mode": mode, + }); + if let Some(o) = p.as_object_mut() { + if st.ty != "Multiple" { + o.insert("type".into(), json!(st.ty)); + } + if !st.property.is_empty() && (!st.value.trim().is_empty() || st.operator == "*") { + o.insert("property".into(), json!(st.property)); + o.insert("operator".into(), json!(st.operator)); + let v = st.value.trim(); + o.insert("value".into(), v.parse::().map(|f| json!(f)).unwrap_or_else(|_| json!(v))); + } + } + run = Some(p); + close = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + }); + ctx.data_mut(|d| d.insert_temp(sid, st)); + if let Some(p) = run { + let _ = app.run("qselect", p); + } + if close { + *open = false; + } +} + +// ---------------- Quick Properties ---------------- + +#[derive(Clone, Debug, Default)] +struct QpState { + sel: Vec, + at: Option, +} + +fn num_row(ui: &mut egui::Ui, label: &str, id: egui::Id, v: f64, set: &mut Option<(String, Value)>, key: &str) { + ui.label(RichText::new(label).color(Tokens::get().text_dim)); + let s = format!("{v:.4}"); + if let Some(n) = crate::parametric::expr_field(ui, id, &s) + && let Some(f) = cadcraft_engine::units::parse_distance(&n).or_else(|| n.parse::().ok()).filter(|f| f.is_finite()) + { + *set = Some((key.to_string(), json!(f))); + } + ui.end_row(); +} + +fn ro_row(ui: &mut egui::Ui, label: &str, v: String) { + ui.label(RichText::new(label).color(Tokens::get().text_dim)); + ui.label(v); + ui.end_row(); +} + +/// The Quick Properties palette, near where the selection was made. +pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { + let sid = egui::Id::new("qp_state"); + let sel = app.session.selection(); + let active = app.session.settings.qpmode + && !sel.is_empty() + && app.session.running.is_none() + && app.canvas.hot_grip.is_none() + && !app.ui.start_tab + && app.ui.dialog.as_deref() != Some("qselect"); + let mut st: QpState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); + if !active { + if !st.sel.is_empty() { + ctx.data_mut(|d| d.insert_temp(sid, QpState::default())); + } + return; + } + if st.sel != sel || st.at.is_none() { + let hp = ctx.input(|i| i.pointer.hover_pos()); + let r = app.canvas.rect.unwrap_or(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let p = hp.filter(|p| r.contains(*p)).unwrap_or(r.center()) + vec2(28.0, 28.0); + // Keep it on the canvas. + let p = Pos2::new(p.x.min(r.right() - 270.0).max(r.left()), p.y.min(r.bottom() - 200.0).max(r.top())); + st.sel = sel.clone(); + st.at = Some(p); + } + let Some(at) = st.at else { return }; + let Ok(d) = app.session.doc() else { return }; + let ents: Vec<_> = sel.iter().filter_map(|h| d.entity(*h)).collect(); + let Some(first) = ents.first() else { return }; + let ty = first.kind.type_name(); + let same_type = ents.iter().all(|e| e.kind.type_name() == ty); + let title = if ents.len() == 1 { + ty.to_string() + } else if same_type { + format!("{ty} ({})", ents.len()) + } else { + format!("All ({})", ents.len()) + }; + let same = |f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String { + let v0 = ents.first().map(|e| f(e)).unwrap_or_default(); + if ents.iter().all(|e| f(e) == v0) { v0 } else { "*VARIES*".into() } + }; + let color = ents.first().map(|e| e.common.color).filter(|c| ents.iter().all(|e| e.common.color == *c)); + let layer = same(&|e| e.common.layer.clone()); + let linetype = same(&|e| e.common.linetype.clone()); + let layers: Vec = d.layers.iter().map(|l| l.name.clone()).collect(); + let linetypes: Vec = d.linetypes.iter().map(|l| l.name.clone()).collect(); + let kind = if ents.len() == 1 { Some(first.kind.clone()) } else { None }; + let ids: Vec = sel.iter().map(|h| h.hex()).collect(); + let mut set: Option<(String, Value)> = None; + let mut close = false; + egui::Window::new(title) + .id(egui::Id::new(("quick_properties", &st.sel))) + .default_pos(at) + .collapsible(false) + .resizable(false) + .title_bar(true) + .default_width(250.0) + .show(ctx, |ui| { + egui::Grid::new("qp_grid").num_columns(2).spacing(vec2(10.0, 4.0)).min_col_width(80.0).show(ui, |ui| { + ui.label(RichText::new("Color").color(Tokens::get().text_dim)); + let cur = color.unwrap_or(cadcraft_color::Color::ByLayer); + if let Some(c) = crate::layers::color_button(ui, egui::Id::new(("qp_color", &ids)), cur, true, 150.0) { + set = Some(("color".into(), json!(c.name()))); + } + ui.end_row(); + ui.label(RichText::new("Layer").color(Tokens::get().text_dim)); + egui::ComboBox::from_id_salt("qp_layer").width(150.0).selected_text(layer.clone()).show_ui(ui, |ui| { + for l in &layers { + if ui.selectable_label(*l == layer, l).clicked() { + set = Some(("layer".into(), json!(l))); + } + } + }); + ui.end_row(); + ui.label(RichText::new("Linetype").color(Tokens::get().text_dim)); + egui::ComboBox::from_id_salt("qp_lt").width(150.0).selected_text(linetype.clone()).show_ui(ui, |ui| { + for l in std::iter::once("ByLayer".to_string()) + .chain(std::iter::once("ByBlock".to_string())) + .chain(linetypes.iter().filter(|l| !["ByLayer", "ByBlock"].contains(&l.as_str())).cloned()) + { + if ui.selectable_label(l == linetype, &l).clicked() { + set = Some(("linetype".into(), json!(l))); + } + } + }); + ui.end_row(); + let id = |k: &str| egui::Id::new(("qp", k, &ids)); + match &kind { + Some(EntityKind::Line(l)) => { + let dl = l.b.xy() - l.a.xy(); + num_row(ui, "Length", id("len"), dl.len(), &mut set, "length"); + num_row(ui, "Angle", id("ang"), dl.angle().to_degrees(), &mut set, "angle"); + } + Some(EntityKind::Circle(c)) => { + num_row(ui, "Center X", id("cx"), c.center.x, &mut set, "cx"); + num_row(ui, "Center Y", id("cy"), c.center.y, &mut set, "cy"); + num_row(ui, "Radius", id("r"), c.radius, &mut set, "radius"); + num_row(ui, "Diameter", id("d"), c.radius * 2.0, &mut set, "diameter"); + ro_row(ui, "Area", format!("{:.4}", c.radius * c.radius * std::f64::consts::PI)); + if let Some((k, v)) = &set { + if k == "cx" { + set = Some(("center".into(), json!([v, c.center.y]))); + } else if k == "cy" { + set = Some(("center".into(), json!([c.center.x, v]))); + } + } + } + Some(EntityKind::Arc(a)) => { + num_row(ui, "Radius", id("r"), a.radius, &mut set, "radius"); + num_row(ui, "Start angle", id("sa"), a.start.to_degrees(), &mut set, "startAngle"); + num_row(ui, "End angle", id("ea"), a.end.to_degrees(), &mut set, "endAngle"); + } + Some(EntityKind::LwPolyline(p)) => { + let g = cadcraft_geom::Polyline { vertices: p.vertices.clone(), closed: p.closed }; + ro_row(ui, "Length", format!("{:.4}", g.len())); + if p.closed { + ro_row(ui, "Area", format!("{:.4}", g.area().abs())); + } + num_row(ui, "Global width", id("w"), p.const_width, &mut set, "width"); + } + Some(EntityKind::Text(tx)) => { + ui.label(RichText::new("Contents").color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value) { + set = Some(("text".into(), json!(v))); + } + ui.end_row(); + num_row(ui, "Height", id("h"), tx.height, &mut set, "height"); + num_row(ui, "Rotation", id("rot"), tx.rotation.to_degrees(), &mut set, "rotation"); + } + Some(EntityKind::MText(tx)) => { + ui.label(RichText::new("Contents").color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents) { + set = Some(("text".into(), json!(v))); + } + ui.end_row(); + num_row(ui, "Text height", id("h"), tx.height, &mut set, "height"); + } + Some(EntityKind::Insert(i)) => { + ro_row(ui, "Name", i.block.clone()); + num_row(ui, "Rotation", id("rot"), i.rotation.to_degrees(), &mut set, "rotation"); + num_row(ui, "Scale", id("sc"), i.scale.x, &mut set, "scale"); + } + Some(EntityKind::Dimension(dm)) => { + ui.label(RichText::new("Text override").color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text) { + set = Some(("textOverride".into(), json!(v))); + } + ui.end_row(); + } + _ => {} + } + }); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Close").on_hover_text("Turn Quick Properties off (QPMODE)").clicked() { + close = true; + } + }); + }); + }); + ctx.data_mut(|dd| dd.insert_temp(sid, st)); + if let Some((k, v)) = set { + let _ = app.run("properties.set", json!({ "handles": ids, k: v })); + } + if close { + let _ = app.run("qpmode", json!({ "on": false })); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn pretty_names() { + assert_eq!(super::pretty("centerX"), "Center X"); + assert_eq!(super::pretty("layer"), "Layer"); + } +}