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:
parent
f9aa1b279a
commit
9646f20b73
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
//! Dialogs: Layer Properties Manager, Drafting Settings, About, command reference.
|
||||
//! Dialogs: Drafting Settings, About, command reference, blocks; dispatches the Layer Properties
|
||||
//! Manager ([`crate::layers`]), Quick Select ([`crate::quick`]) and Parameters Manager
|
||||
//! ([`crate::parametric`]).
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use egui::{Color32, RichText, vec2};
|
||||
use serde_json::json;
|
||||
use egui::{RichText, vec2};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
mtext_editor(app, ctx);
|
||||
crate::quick::quick_properties(app, ctx);
|
||||
let Some(d) = app.ui.dialog.clone() else { return };
|
||||
let mut open = true;
|
||||
match d.as_str() {
|
||||
"layers" => layers(app, ctx, &mut open),
|
||||
"layers" => crate::layers::dialog(app, ctx, &mut open),
|
||||
"qselect" => crate::quick::qselect_dialog(app, ctx, &mut open),
|
||||
"parameters" => crate::parametric::parameters_dialog(app, ctx, &mut open),
|
||||
"dsettings" => dsettings(app, ctx, &mut open),
|
||||
"about" => about(ctx, &mut open),
|
||||
"commands" => commands(app, ctx, &mut open),
|
||||
|
|
@ -25,86 +27,6 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fn layers(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let t = Tokens::get();
|
||||
let mut action: Option<(&str, serde_json::Value)> = None;
|
||||
egui::Window::new("Layer Properties Manager").open(open).default_size(vec2(820.0, 420.0)).resizable(true).show(ctx, |ui| {
|
||||
let Ok(d) = app.session.doc() else { return };
|
||||
let cur = d.header.str("CLAYER", "0");
|
||||
ui.horizontal(|ui| {
|
||||
if icons::button(ui, Icon::LayerProps, 24.0, "New Layer", false).clicked() {
|
||||
let mut n = 1;
|
||||
while d.layer(&format!("Layer{n}")).is_some() {
|
||||
n += 1;
|
||||
}
|
||||
action = Some(("layer.new", json!({ "name": format!("Layer{n}") })));
|
||||
}
|
||||
if icons::button(ui, Icon::MakeCurrent, 24.0, "Set Current", false).clicked() {
|
||||
// handled per row
|
||||
}
|
||||
ui.label(RichText::new(format!("Current layer: {cur}")).color(t.text_dim));
|
||||
});
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("layers_grid").striped(true).num_columns(9).spacing(vec2(12.0, 4.0)).show(ui, |ui| {
|
||||
for h in ["Status", "Name", "On", "Freeze", "Lock", "Plot", "Color", "Linetype", "Lineweight"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for l in &d.layers {
|
||||
let current = l.name.eq_ignore_ascii_case(&cur);
|
||||
if ui.selectable_label(current, if current { "✔" } else { " " }).clicked() {
|
||||
action = Some(("layer.current", json!({ "name": l.name })));
|
||||
}
|
||||
ui.label(&l.name);
|
||||
if icons::button(ui, if l.on { Icon::Bulb } else { Icon::BulbOff }, 18.0, "On/Off", false).clicked() {
|
||||
action = Some(("layer.set", json!({ "name": l.name, "on": !l.on })));
|
||||
}
|
||||
if icons::button(ui, if l.frozen { Icon::Snowflake } else { Icon::Sun }, 18.0, "Freeze", false).clicked() {
|
||||
action = Some(("layer.set", json!({ "name": l.name, "frozen": !l.frozen })));
|
||||
}
|
||||
if icons::button(ui, if l.locked { Icon::Lock } else { Icon::Unlock }, 18.0, "Lock", false).clicked() {
|
||||
action = Some(("layer.set", json!({ "name": l.name, "locked": !l.locked })));
|
||||
}
|
||||
if icons::button(ui, Icon::Plot, 18.0, "Plot", !l.plot).clicked() {
|
||||
action = Some(("layer.set", json!({ "name": l.name, "plot": !l.plot })));
|
||||
}
|
||||
let rgb = l.color.resolve(Color::Index(7), Color::Index(7));
|
||||
ui.menu_button(RichText::new(format!("■ {}", l.color.name())).color(Color32::from_rgb(rgb.0, rgb.1, rgb.2)), |ui| {
|
||||
for i in 1..=9u8 {
|
||||
if ui.button(Color::Index(i).name()).clicked() {
|
||||
action = Some(("layer.set", json!({ "name": l.name, "color": i })));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.menu_button(&l.linetype, |ui| {
|
||||
for lt in &d.linetypes {
|
||||
if lt.name.eq_ignore_ascii_case("bylayer") || lt.name.eq_ignore_ascii_case("byblock") {
|
||||
continue;
|
||||
}
|
||||
if ui.button(<.name).clicked() {
|
||||
action = Some(("layer.set", json!({ "name": l.name, "linetype": lt.name })));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Load all standard linetypes").clicked() {
|
||||
action = Some(("linetype", json!({ "load": "*" })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.label(l.lineweight.name());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some((c, p)) = action {
|
||||
let _ = app.run(c, p);
|
||||
}
|
||||
}
|
||||
|
||||
fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
egui::Window::new("Drafting Settings").open(open).resizable(false).show(ctx, |ui| {
|
||||
let s = &mut app.session.settings;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 <.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(<_resp)
|
||||
.id(egui::Id::new(("lpm_lt", &name)))
|
||||
.width(240.0)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| {
|
||||
ui.label(RichText::new("Loaded linetypes").small().color(t.text_dim));
|
||||
for lt in linetypes.iter().filter(|x| !["bylayer", "byblock"].contains(&x.name.to_ascii_lowercase().as_str())) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(230.0, 20.0), Sense::click());
|
||||
if resp.hovered() || lt.name.eq_ignore_ascii_case(&l.linetype) {
|
||||
ui.painter().rect_filled(r, 2.0, t.accent.gamma_multiply(if resp.hovered() { 0.6 } else { 0.3 }));
|
||||
}
|
||||
lt_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 4.0, r.top()), vec2(50.0, r.height())), Some(lt), t.text);
|
||||
ui.painter().text(pos2(r.left() + 62.0, r.center().y), egui::Align2::LEFT_CENTER, <.name, crate::theme::body(), t.text);
|
||||
if resp.on_hover_text(<.description).clicked() {
|
||||
actions.push(("layer.set", json!({ "name": name, "linetype": lt.name })));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if !library.is_empty() {
|
||||
ui.separator();
|
||||
ui.menu_button("Load...", |ui| {
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
for n in library {
|
||||
if ui.button(n).clicked() {
|
||||
actions.push(("linetype", json!({ "load": n })));
|
||||
actions.push(("layer.set", json!({ "name": name, "linetype": n })));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui.button("Load all").clicked() {
|
||||
actions.push(("linetype", json!({ "load": "*" })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// Lineweight.
|
||||
let lw_resp = ui
|
||||
.allocate_ui(vec2(100.0, 20.0), |ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(100.0, 20.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.control_hover.gamma_multiply(0.5));
|
||||
}
|
||||
lw_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 3.0, r.top()), vec2(22.0, r.height())), l.lineweight, t.text);
|
||||
ui.painter().text(pos2(r.left() + 30.0, r.center().y), egui::Align2::LEFT_CENTER, l.lineweight.name(), crate::theme::body(), t.text);
|
||||
resp
|
||||
})
|
||||
.inner;
|
||||
egui::Popup::from_toggle_button_response(&lw_resp)
|
||||
.id(egui::Id::new(("lpm_lw", &name)))
|
||||
.width(170.0)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| {
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
let opts = std::iter::once(Lineweight::Default).chain(Lineweight::STANDARD.iter().map(|v| Lineweight::Mm100(*v)));
|
||||
for lw in opts {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(160.0, 20.0), Sense::click());
|
||||
if resp.hovered() || lw == l.lineweight {
|
||||
ui.painter().rect_filled(r, 2.0, t.accent.gamma_multiply(if resp.hovered() { 0.6 } else { 0.3 }));
|
||||
}
|
||||
lw_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 4.0, r.top()), vec2(40.0, r.height())), lw, t.text);
|
||||
ui.painter().text(pos2(r.left() + 52.0, r.center().y), egui::Align2::LEFT_CENTER, lw.name(), crate::theme::body(), t.text);
|
||||
if resp.clicked() {
|
||||
let v = match lw {
|
||||
Lineweight::Mm100(v) => json!(f64::from(v) / 100.0),
|
||||
_ => json!("Default"),
|
||||
};
|
||||
actions.push(("layer.set", json!({ "name": name, "lineweight": v })));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// Transparency (commits when the drag or edit ends).
|
||||
let tid = egui::Id::new(("lpm_tr", &name));
|
||||
let mut tr = ui.data_mut(|d| d.get_temp::<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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue