Layer Properties Manager, QSELECT + Quick Properties, constraint display, Parameters Manager

- Layer Properties Manager: filter tree (All / All Used Layers) with search, sortable table
  (Status, Name, On, Freeze, Lock, Plot, Color, Linetype, Lineweight, Transparency,
  New VP Freeze, Description; VP Freeze / VP Color inside an MSPACE viewport), inline
  rename and description, ACI + true-colour picker, linetype picker with Load from the
  library, lineweight picker, layer states save/restore/delete.
- Engine: qselect rewritten ({type, property, operator =,!=,>,<,*, value, mode new|append|
  exclude, applyTo}) + qselect.info; layerstate.list/delete/rename; layer list reports
  used layers and states; Layer.vp_freeze_new (DXF flag 2, applied by MVIEW) and
  layer.set newVpFreeze; per-viewport layer colours (Viewport.layer_colors, vplayer
  colors, honoured by the renderer); vplayer defaults to the active viewport; QPMODE
  setting/sysvar/toggle; properties.set length/angle for lines.
- Canvas: constraint bars with code-drawn glyphs and coincident markers, dynamic
  dimensional constraints (aligned/horizontal/vertical/radius/diameter/angular) with
  name=value labels and lock icon, honouring CONSTRAINTBAR/DCDISPLAY; cached per
  drawing revision; double-click a label to edit its expression (parameters).
- Quick Properties palette near the selection (status-bar toggle), Quick Select dialog,
  Parameters Manager (edit, add user parameter, delete).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 02:11:43 -04:00
parent f9aa1b279a
commit 9646f20b73
26 changed files with 2402 additions and 131 deletions

View File

@ -472,6 +472,9 @@ pub struct Viewport {
pub locked: bool,
#[serde(default)]
pub frozen_layers: Vec<String>,
/// 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")]

View File

@ -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,
}
}
}

View File

@ -30,10 +30,6 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Value> {
Ok(json!({ "selected": sel.len(), "handles": sel.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
fn run_qselect(s: &mut Session, p: &Value) -> Result<Value> {
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<Handle> = 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,
}

View File

@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
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> {
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<Value> {
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::<Vec<_>>() }))
let used = used_layers(d);
let layers: Vec<Value> = 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::<Vec<_>>() }))
}
/// 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<String> {
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<Lineweight> {
@ -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<Value> {
}
ok()
}
fn run_state_list(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
let states: Vec<Value> = 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::<Vec<_>>().join(", ")
};
Ok(json!({ "states": states, "message": msg }))
}
fn run_state_delete(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<bool> = v["layers"].as_array().unwrap().iter().map(|l| l["used"].as_bool().unwrap()).collect();
assert!(used.contains(&true) && used.contains(&false));
}
}

View File

@ -49,9 +49,10 @@ pub fn specs() -> Vec<CommandSpec> {
.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<String> = 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::<Vec<_>>(),
})
}
fn run_viewport_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "viewport.set";
let d = s.doc()?;
let hs: Vec<Handle> =
let mut hs: Vec<Handle> =
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<Value> {
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<cadcraft_color::Color>)> = 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<Value> {
}
}
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());

View File

@ -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());

View File

@ -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();

View File

@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
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<Value> {
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") {

View File

@ -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<CommandSpec> {
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<Op> {
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<Value> {
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<char> = pat.to_lowercase().chars().take(1024).collect();
let t: Vec<char> = 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<f64> {
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::<f64>().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<Vec<Handle>> {
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<Value> {
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::<Vec<_>>(), "message": format!("{n} item(s) selected") }))
}
fn run_info(s: &mut Session, p: &Value) -> Result<Value> {
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<String> = 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::<Vec<_>>(),
"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)));
}
}

View File

@ -65,6 +65,11 @@ pub fn specs() -> Vec<CommandSpec> {
.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)

View File

@ -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,
}
}
}

View File

@ -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<Value> {
"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<Value> {
fn as_bool(v: &Value) -> Option<bool> {
v.as_bool().or_else(|| v.as_i64().map(|i| i != 0)).or_else(|| v.as_str().and_then(|s| s.trim().parse::<i64>().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<i64> {
v.as_i64().or_else(|| v.as_bool().map(i64::from)).or_else(|| v.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
}
fn as_f64(v: &Value) -> Option<f64> {
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)?,

View File

@ -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<String, Str
on: aci >= 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()
};

View File

@ -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),

View File

@ -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();

View File

@ -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<Color> {
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<Item = &'a Entity>>(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<cadcraft_
let visible = e.common.visible
&& layer.is_none_or(|l| l.visible() && (!plotting || l.plot))
&& !ctx.frozen.iter().any(|f| f.eq_ignore_ascii_case(layer_name));
let layer_color = layer.map(|l| l.color).unwrap_or(Color::Index(7));
let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7));
let rgb = e.common.color.resolve(layer_color, ctx.block_color);
let lw = match e.common.lineweight {
Lineweight::ByLayer => 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,
}
}

View File

@ -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]

View File

@ -104,6 +104,8 @@ pub struct CanvasState {
pub build_ms: f64,
pub draw_ms: f64,
hover_at: Option<Pos2>,
/// 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()

View File

@ -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"),

View File

@ -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(&lt.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;

View File

@ -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);

View File

@ -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<String>,
/// (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<String>,
}
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<Color> {
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<Color> {
let t = Tokens::get();
let tab_id = id.with("tab");
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(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<Color>| {
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::<f64>().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 &lt.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<cadcraft_doc::Linetype> = d.linetypes.clone();
let library: Vec<String> = cadcraft_doc::library::standard_linetypes().into_iter().map(|l| l.name).filter(|n| d.linetype(n).is_none()).collect();
let states: Vec<String> = d.layer_states.iter().map(|s| s.name.clone()).collect();
let mut layers: Vec<Layer> = 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<Layer> = 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<String>,
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(&lt_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, &lt.name, crate::theme::body(), t.text);
if resp.on_hover_text(&lt.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::<u8>(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::<u8>(tid));
} else if !r.dragged() && !r.has_focus() {
ui.data_mut(|d| d.remove::<u8>(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<String> {
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::<usize>, 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"));
}
}

View File

@ -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;

View File

@ -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<Resu
app.ui.start_tab = true;
Ok(Value::Null)
}
"ui.dialog.layers" | "ui.dialog.blocks" | "ui.dialog.dsettings" | "ui.dialog.about" | "ui.dialog.commands" => {
"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<Resu
Ok(Value::Null)
}
"ui.noop" => 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)

View File

@ -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<String>,
pub anchors: Vec<Vec2>,
pub name: String,
pub expr: String,
pub value: Option<f64>,
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<Glyph>,
/// 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<Vec2> {
p.vertices.get(usize::try_from(i).ok()?).map(|v| v.p)
}
fn ref_point(d: &Drawing, r: &GeomRef) -> Option<Vec2> {
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<Pos2> = (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<Pos2> = (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<Act> = 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::<f64>().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<Pos2> = 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<Pos2> = (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<Vec2> = Vec::new();
for g in glyphs.iter().filter(|g| !g.dimensional).take(5000) {
let hs: Vec<Handle> = 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<Handle> = 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<Rect> = 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<Act>, 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<bool> = 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<Vec2> {
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<f64>,
error: Option<String>,
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<Row> = 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::<String>(filter_id)).unwrap_or_default();
let mut action: Option<Value> = 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<String> {
let mut buf = ui.data_mut(|d| d.get_temp::<String>(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::<String>(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");
}
}

417
crates/ui-egui/src/quick.rs Normal file
View File

@ -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<Value> = 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<String> = 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<String> = 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::<f64>().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<Handle>,
at: Option<Pos2>,
}
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::<f64>().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<String> = d.layers.iter().map(|l| l.name.clone()).collect();
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
let kind = if ents.len() == 1 { Some(first.kind.clone()) } else { None };
let ids: Vec<String> = 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");
}
}