diff --git a/ROADMAP.md b/ROADMAP.md index 6ce4186..19226dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence. **Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate). Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks -can't be edited (no block editor, REFEDIT or xrefs); plots ignore plot styles; and a DWG saved by +can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by CADCraft drops everything it doesn't model and has never been checked in AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta is ≈ 600–930 h away. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9d57324..d063a83 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -15,6 +15,7 @@ mod header; pub mod library; mod plotstyle; mod plotter; +mod refedit; mod store; mod tables; @@ -30,6 +31,7 @@ pub use gdt::*; pub use header::{HVal, Header}; pub use plotstyle::*; pub use plotter::*; +pub use refedit::{RefEdit, WorkItem}; pub use store::EntityStore; pub use tables::*; @@ -95,6 +97,9 @@ pub struct Drawing { /// Objects hidden for this session by ISOLATEOBJECTS / HIDEOBJECTS. Never written to a file: /// a drawing opens with everything shown again. pub hidden_objects: BTreeSet, + /// The in-place block reference edit in progress (REFEDIT). Never written to a file; files are + /// written from [`Drawing::committed`]. + pub refedit: Option, } impl Default for Drawing { @@ -148,6 +153,7 @@ impl Drawing { scales: imperial_scales(), handseed: 0x100, hidden_objects: BTreeSet::new(), + refedit: None, } } diff --git a/crates/doc/src/refedit.rs b/crates/doc/src/refedit.rs new file mode 100644 index 0000000..eb09273 --- /dev/null +++ b/crates/doc/src/refedit.rs @@ -0,0 +1,83 @@ +//! In-place editing of a block reference (REFEDIT / REFSET / REFCLOSE): the state kept with the +//! drawing while it lasts. +//! +//! The reference being edited is hidden and the block's objects stand in the drawing as ordinary +//! objects (the *working set*, transformed to where the reference shows them), the rest of the +//! block as faded copies (*ghosts*) that cannot be edited. REFCLOSE writes the working set back +//! into the block definition. The state lives in the [`Drawing`] itself, so undo and redo move +//! the working copies and the state together and can never leave one without the other. + +use std::borrow::Cow; + +use cadcraft_geom::Mat3; + +use crate::{Drawing, Entity, Handle, Space}; + +/// One object of the working set. +#[derive(Clone, Debug, PartialEq)] +pub struct WorkItem { + /// The object in the drawing (a copy of the block object, or a drawing object added to the set). + pub copy: Handle, + /// The block object it was copied from; `None` for an object added from the drawing. + pub source: Option, +} + +/// An active in-place edit of a block reference. Never written to a file. +#[derive(Clone, Debug, PartialEq)] +pub struct RefEdit { + /// The block being edited. + pub block: String, + /// The reference that was picked (hidden while editing). + pub insert: Handle, + /// Whether the reference was visible before it was hidden. + pub insert_was_visible: bool, + /// The space the reference is in. + pub space: Space, + /// Block coordinates to drawing coordinates, through the picked reference. + pub to_world: Mat3, + /// The working set. + pub items: Vec, + /// The rest of the block as faded copies: (copy, block object). + pub ghosts: Vec<(Handle, Handle)>, + /// Block objects taken out of the working set with their edits (block coordinates, the block + /// object's handle). + pub released: Vec, +} + +impl RefEdit { + /// Whether `h` is a working set object. + pub fn is_working(&self, h: Handle) -> bool { + self.items.iter().any(|i| i.copy == h) + } + /// Whether `h` is something the edit manages (working set, ghost, the hidden reference). + pub fn owns(&self, h: Handle) -> bool { + h == self.insert || self.is_working(h) || self.ghosts.iter().any(|(g, _)| *g == h) + } +} + +impl Drawing { + /// The drawing as REFCLOSE Discard would leave it: the working copies and ghosts gone, the + /// reference shown, the block untouched. Borrowed when no edit is active. Files are written + /// from this, so a saved or autosaved drawing never holds half an edit. + pub fn committed(&self) -> Cow<'_, Drawing> { + let Some(r) = &self.refedit else { return Cow::Borrowed(self) }; + let mut d = self.clone(); + d.end_refedit_discard(r); + Cow::Owned(d) + } + + /// Remove the edit's working copies and ghosts, show the reference again and clear the state. + pub fn end_refedit_discard(&mut self, r: &RefEdit) { + for i in &r.items { + if i.source.is_some() { + self.remove_entity(i.copy); + } + } + for (g, _) in &r.ghosts { + self.remove_entity(*g); + } + let vis = r.insert_was_visible; + let _ = self.modify_entity(r.insert, |e| e.common.visible = vis); + self.refedit = None; + } +} diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 99d9ba2..e02a9ff 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -15,6 +15,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St mod attedit; mod extract; +pub(crate) mod refedit; pub fn specs() -> Vec { vec![ @@ -55,6 +56,7 @@ pub fn specs() -> Vec { ] .into_iter() .chain(xref::specs()) + .chain(refedit::specs()) .collect() } @@ -97,6 +99,9 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], if references_block(d, &ents, name) { return Err(bad("block", format!("Block \"{name}\" references itself."))); } + if d.refedit.as_ref().is_some_and(|r| r.block.eq_ignore_ascii_case(name)) { + return Err(bad("block", format!("Block \"{name}\" is being edited in place; finish with REFCLOSE first."))); + } // Block names are case-insensitive: redefining keeps the existing definition's name. let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string()); let name = name.as_str(); diff --git a/crates/engine/src/cmd/blocks/refedit.rs b/crates/engine/src/cmd/blocks/refedit.rs new file mode 100644 index 0000000..0a61122 --- /dev/null +++ b/crates/engine/src/cmd/blocks/refedit.rs @@ -0,0 +1,469 @@ +//! REFEDIT, REFSET and REFCLOSE: edit a block reference in place. +//! +//! REFEDIT hides the picked reference and puts the block's objects (all, or the ones named) into +//! the drawing where the reference shows them: the *working set*, ordinary objects that every +//! command edits. The rest of the block stands there as faded copies, and everything else in the +//! drawing is locked ([`crate::cmd::curves::is_locked`], enforced by [`after_command`]). Objects +//! drawn while editing join the working set. REFSET adds objects to or removes them from it. +//! REFCLOSE writes the working set back into the block definition, so every reference to the +//! block shows the change, or discards the edit. +//! +//! The state is [`Drawing::refedit`]: it travels with undo, is never written to a file (files +//! are written from [`Drawing::committed`]) and ends with the drawing it belongs to. +//! +//! Not yet: external references (their source drawing would have to be written back), nested +//! references picked at a depth, annotative scale contexts and attribute synchronisation. + +use std::collections::HashSet; +use std::sync::Arc; + +use cadcraft_doc::{Drawing, Entity, EntityStore, Handle, RefEdit, Transparency, WorkItem}; +use cadcraft_geom::Mat3; +use serde_json::{Value, json}; + +use super::super::curves::handle_value; +use super::super::machines::{SelOutcome, SelectPhase, SelectRun}; +use super::references_block; +use crate::cmd::{CommandSpec, bad, bool_or, has_doc, str_param, targets}; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +/// Transparency of the faded copies of the block's other objects. +const GHOST_FADE: u8 = 70; + +pub(super) fn specs() -> Vec { + vec![ + CommandSpec::new("refedit", "Edit Reference In-Place", run_refedit) + .menu(&["Tools", "Xref and Block In-place Editing", "Edit Reference In-Place"]) + .params("{handle? | handles? | name? (a block reference, default the selection), objects?: [block object handles] (default all)} → {block, insert, working: [handles], faded}") + .enabled(has_doc) + .interactive(|_| Ok(Box::new(SelectRun::single("refedit", "REFEDIT", "Select reference")))), + CommandSpec::new("refset", "Working Set", run_refset) + .menu(&["Tools", "Xref and Block In-place Editing", "Add to Working Set"]) + .params("{add?: [handles], remove?: [handles]} (or {handles, mode?: \"add\"|\"remove\"}); with neither: lists the working set → {working: [handles], added, removed}") + .enabled(editing) + .interactive(|_| Ok(Box::new(RefsetM::default()))), + CommandSpec::new("refclose", "Save Reference Edits", run_refclose) + .menu(&["Tools", "Xref and Block In-place Editing", "Save Reference Edits"]) + .params("{option?: \"save\"|\"discard\" | save?: bool (default true)} → {block, saved, objects}") + .enabled(editing) + .interactive(|_| Ok(Box::new(RefcloseM))), + ] +} + +fn editing(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.doc().is_ok_and(|d| d.refedit.is_some()) { Ok(()) } else { Err("no block reference is being edited (REFEDIT)".into()) } +} + +fn active(s: &Session, cmd: &str) -> Result { + s.doc()?.refedit.clone().ok_or_else(|| bad(cmd, "no block reference is being edited (REFEDIT)")) +} + +/// A block object as the drawing shows it through the edited reference. +fn to_host(to_world: &Mat3, e: &Entity, h: Handle, ghost: bool) -> Entity { + let mut c = e.clone(); + c.handle = h; + c.kind.transform(to_world); + if ghost { + c.common.transparency = Transparency::Percent(GHOST_FADE); + } + c +} + +/// A drawing object as it belongs in the block, with the block object's handle `h`. +fn to_block(inv: &Mat3, e: &Entity, h: Handle) -> Entity { + let mut c = e.clone(); + c.handle = h; + c.kind.transform(inv); + c +} + +fn put(d: &mut Drawing, r: &RefEdit, e: Entity) -> Result<()> { + d.ensure_layer(&e.common.layer); + d.space_mut(&r.space).ok_or_else(|| bad("refedit", "the space of the reference is gone"))?.push(e); + Ok(()) +} + +fn hexes(hs: impl IntoIterator) -> Vec { + hs.into_iter().map(|h| h.hex()).collect() +} + +// ---------------- REFEDIT ---------------- + +fn run_refedit(s: &mut Session, p: &Value) -> Result { + if s.doc()?.refedit.is_some() { + return Err(bad("refedit", "a reference is already being edited; finish with REFCLOSE")); + } + let d = s.doc()?; + let picked = match str_param(p, "name") { + Some(n) => d + .model + .iter() + .chain(d.layouts.iter().flat_map(|l| l.entities.iter())) + .find(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(n))) + .map(|e| e.handle), + None => targets(s, p)?.into_iter().find(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, cadcraft_doc::EntityKind::Insert(_)))), + }; + let ih = picked.ok_or_else(|| bad("refedit", "select a block reference"))?; + let (ins, space) = match d.entity(ih).map(|e| &e.kind) { + Some(cadcraft_doc::EntityKind::Insert(i)) => (i.clone(), d.space_of(ih).ok_or_else(|| bad("refedit", "the reference is in no space"))?), + _ => return Err(bad("refedit", "select a block reference")), + }; + let blk = d.block(&ins.block).cloned().ok_or_else(|| bad("refedit", format!("block \"{}\" not found", ins.block)))?; + if blk.xref_path.is_some() { + return Err(bad( + "refedit", + format!( + "\"{}\" is an external reference: editing it in place would write its source drawing, which is not available yet. Open it with XOPEN, edit and save it, then reload.", + blk.name + ), + )); + } + if blk.name.starts_with('*') || blk.array.is_some() { + return Err(bad("refedit", format!("\"{}\" is generated geometry and can't be edited in place", blk.name))); + } + let to_world = ins.transform(blk.base.xy()); + let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs()); + let finite = [to_world.a, to_world.b, to_world.c, to_world.d, to_world.e, to_world.f].iter().all(|v| v.is_finite()); + if !finite || to_world.det().abs() < 1e-12 || (sx - sy).abs() > 1e-9 * sx.max(sy) { + return Err(bad("refedit", "the reference must be scaled equally in X and Y (and not flat) to be edited in place")); + } + let chosen: Option> = match p.get("objects") { + None | Some(Value::Null) => None, + Some(Value::String(a)) if a.eq_ignore_ascii_case("all") => None, + Some(Value::Array(a)) => { + let mut set = HashSet::new(); + for v in a { + let h = handle_value(v).ok_or_else(|| bad("refedit", format!("`objects`: {v} is not an object handle")))?; + if blk.entities.get(h).is_none() { + return Err(bad("refedit", format!("object {} is not in block \"{}\"", h.hex(), blk.name))); + } + set.insert(h); + } + if set.is_empty() { + return Err(bad("refedit", "`objects` is empty")); + } + Some(set) + } + Some(v) => return Err(bad("refedit", format!("`objects` must be \"all\" or an array of handles, got {v}"))), + }; + let was_visible = d.entity(ih).is_some_and(|e| e.common.visible); + let mut r = RefEdit { + block: blk.name.clone(), + insert: ih, + insert_was_visible: was_visible, + space, + to_world, + items: Vec::new(), + ghosts: Vec::new(), + released: Vec::new(), + }; + let doc = s.doc_mut()?; + for e in blk.entities.iter() { + let working = chosen.as_ref().is_none_or(|c| c.contains(&e.handle)); + let h = doc.new_handle(); + put(doc, &r, to_host(&to_world, e, h, !working))?; + if working { + r.items.push(WorkItem { copy: h, source: Some(e.handle) }); + } else { + r.ghosts.push((h, e.handle)); + } + } + doc.modify_entity(ih, |e| e.common.visible = false).map_err(|e| bad("refedit", e.to_string()))?; + let out = json!({ "block": r.block, "insert": ih.hex(), "working": hexes(r.items.iter().map(|i| i.copy)), "faded": r.ghosts.len() }); + let sel: Vec = r.items.iter().map(|i| i.copy).collect(); + doc.refedit = Some(r); + s.set_selection(sel); + Ok(out) +} + +// ---------------- REFSET ---------------- + +fn handle_list(p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(Vec::new()), + Some(Value::Array(a)) => { + a.iter().map(|v| handle_value(v).ok_or_else(|| bad("refset", format!("`{key}`: {v} is not an object handle")))).collect() + } + Some(v) => Err(bad("refset", format!("`{key}` must be an array of object handles, got {v}"))), + } +} + +fn run_refset(s: &mut Session, p: &Value) -> Result { + let (mut add, mut remove) = (handle_list(p, "add")?, handle_list(p, "remove")?); + let hs = handle_list(p, "handles")?; + if str_param(p, "mode").is_some_and(|m| m.to_ascii_lowercase().starts_with('r')) { + remove.extend(hs) + } else { + add.extend(hs) + } + apply_refset(s, &add, &remove) +} + +fn apply_refset(s: &mut Session, add: &[Handle], remove: &[Handle]) -> Result { + let mut r = active(s, "refset")?; + let inv = r.to_world.inverse().ok_or_else(|| bad("refset", "the reference can't be inverted"))?; + let d = s.doc()?; + for h in add.iter().chain(remove) { + if d.entity(*h).is_none() { + return Err(bad("refset", format!("no object {}", h.hex()))); + } + } + if let Some(h) = remove.iter().find(|h| !r.is_working(**h)) { + return Err(bad("refset", format!("object {} is not in the working set", h.hex()))); + } + let blk = d.block(&r.block).cloned().ok_or_else(|| bad("refset", format!("block \"{}\" is gone; REFCLOSE Discard", r.block)))?; + let (mut added, mut removed) = (0usize, 0usize); + let doc = s.doc_mut()?; + for h in add { + if *h == r.insert || r.is_working(*h) { + continue; + } + if let Some(pos) = r.ghosts.iter().position(|(g, _)| g == h) { + // A faded block object joins the working set (with the edits it had before). + let (g, src) = r.ghosts.remove(pos); + doc.remove_entity(g); + let cur = match r.released.iter().position(|e| e.handle == src) { + Some(i) => Some(r.released.remove(i)), + None => blk.entities.get(src).map(|e| (**e).clone()), + }; + if let Some(cur) = cur { + let c = doc.new_handle(); + put(doc, &r, to_host(&r.to_world, &cur, c, false))?; + r.items.push(WorkItem { copy: c, source: Some(src) }); + added += 1; + } + } else { + r.items.push(WorkItem { copy: *h, source: None }); + added += 1; + } + } + for h in remove { + let Some(pos) = r.items.iter().position(|i| i.copy == *h) else { continue }; + let item = r.items.remove(pos); + if let Some(src) = item.source + && let Some(e) = doc.entity(*h).map(|e| (**e).clone()) + { + // Back to the block, edits kept, shown faded. + let kept = to_block(&inv, &e, src); + doc.remove_entity(*h); + let g = doc.new_handle(); + put(doc, &r, to_host(&r.to_world, &kept, g, true))?; + r.ghosts.push((g, src)); + r.released.push(kept); + } + removed += 1; + } + let working = hexes(r.items.iter().map(|i| i.copy)); + doc.refedit = Some(r); + Ok(json!({ "working": working, "added": added, "removed": removed })) +} + +// ---------------- REFCLOSE ---------------- + +fn run_refclose(s: &mut Session, p: &Value) -> Result { + let save = match str_param(p, "option") { + Some(o) if o.to_ascii_lowercase().starts_with('d') => false, + Some(o) if o.to_ascii_lowercase().starts_with('s') => true, + Some(o) => return Err(bad("refclose", format!("unknown option \"{o}\" (save or discard)"))), + None => bool_or(p, "save", true), + }; + close(s, save) +} + +fn close(s: &mut Session, save: bool) -> Result { + let r = active(s, "refclose")?; + // Work on a copy: the drawing changes only when everything went through. + let mut nd: Drawing = s.doc()?.clone(); + let mut objects = 0; + if save { + objects = commit_block(&mut nd, &r)?; + } + nd.end_refedit_discard(&r); + *s.doc_mut()? = nd; + s.set_selection(Vec::new()); + Ok(json!({ "block": r.block, "saved": save, "objects": objects })) +} + +/// Replace the block's objects by the working set (and move the drawing objects added to it +/// into the block). Returns the number of objects the block has now. +fn commit_block(nd: &mut Drawing, r: &RefEdit) -> Result { + let blk = nd.block(&r.block).cloned().ok_or_else(|| bad("refclose", format!("block \"{}\" is gone; use REFCLOSE Discard", r.block)))?; + let inv = r.to_world.inverse().ok_or_else(|| bad("refclose", "the reference can't be inverted; use REFCLOSE Discard"))?; + let mut ents: Vec = Vec::new(); + for e in blk.entities.iter() { + match r.items.iter().find(|i| i.source == Some(e.handle)) { + // A working object the user erased leaves the block. + Some(i) => { + if let Some(c) = nd.entity(i.copy) { + ents.push(to_block(&inv, c, e.handle)); + } + } + None => match r.released.iter().find(|x| x.handle == e.handle) { + Some(x) => ents.push(x.clone()), + None => ents.push((**e).clone()), + }, + } + } + let mut moved = Vec::new(); + for i in r.items.iter().filter(|i| i.source.is_none()) { + if let Some(c) = nd.entity(i.copy).map(|c| (**c).clone()) { + let h = nd.new_handle(); + ents.push(to_block(&inv, &c, h)); + moved.push(i.copy); + } + } + if references_block(nd, &ents, &r.block) { + return Err(bad("refclose", format!("Block \"{}\" would reference itself; remove that reference or use REFCLOSE Discard.", r.block))); + } + let n = ents.len(); + let mut store = EntityStore::new(); + for e in ents { + store.push(e); + } + let key = nd.blocks.keys().find(|k| k.eq_ignore_ascii_case(&r.block)).cloned().unwrap_or_else(|| r.block.clone()); + let mut nb = (*blk).clone(); + nb.entities = store; + nd.blocks.insert(key, Arc::new(nb)); + for h in moved { + nd.remove_entity(h); + } + Ok(n) +} + +// ---------------- while editing ---------------- + +/// After a command ran: objects it created join the working set, and a command that changed +/// anything outside the working set is undone (returns the reason). +pub(crate) fn after_command(s: &mut Session, before: &Arc, id: &str) -> Option { + if matches!(id, "refedit" | "refset" | "refclose") || !crate::cmd::find_command(id).is_some_and(|c| c.undoable) { + return None; + } + let rb = before.refedit.as_ref()?; + let st = s.state_mut().ok()?; + if Arc::ptr_eq(before, &st.doc) { + return None; + } + let ra = st.doc.refedit.as_ref().filter(|ra| ra.insert == rb.insert)?; + let owned: HashSet = rb.items.iter().map(|i| i.copy).chain(rb.ghosts.iter().map(|g| g.0)).chain([rb.insert]).collect(); + let stores = |d: &Drawing| std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).cloned().collect::>(); + let touched = stores(before) + .iter() + .any(|store| store.iter().any(|e| !owned.contains(&e.handle) && !st.doc.entity(e.handle).is_some_and(|a| Arc::ptr_eq(a, e)))); + if touched { + st.doc = before.clone(); + return Some("Only the working set can be edited during REFEDIT (REFSET adds objects, REFCLOSE ends the edit).".into()); + } + let seed = before.handseed; + let mut new: Vec = Vec::new(); + for store in stores(&st.doc) { + new.extend(store.iter().map(|e| e.handle).filter(|h| h.0 >= seed && !ra.owns(*h))); + } + if !new.is_empty() + && let Some(r) = Arc::make_mut(&mut st.doc).refedit.as_mut() + { + r.items.extend(new.into_iter().map(|copy| WorkItem { copy, source: None })); + } + None +} + +/// Whether `h` may not be edited because a reference is being edited and it is not in the working set. +pub(crate) fn locked(d: &Drawing, h: Handle) -> bool { + d.refedit.as_ref().is_some_and(|r| !r.is_working(h)) +} + +// ---------------- interactive ---------------- + +#[derive(Default)] +enum RStage { + #[default] + Mode, + Pick(bool), +} + +#[derive(Default)] +struct RefsetM { + stage: RStage, + sel: SelectPhase, +} + +impl Interactive for RefsetM { + fn name(&self) -> &'static str { + "REFSET" + } + fn begin(&mut self, s: &mut Session) -> Result { + active(s, "refset").map(|_| Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + RStage::Mode => { + let mut p = Prompt::new("Enter an option [Add/Remove] ", Accept::TEXT); + p.keywords = vec!["Add".into(), "Remove".into()]; + p.default = Some("Add".into()); + p + } + RStage::Pick(remove) => { + let mut p = self.sel.prompt(); + p.message = format!("Select objects to {}", if remove { "remove" } else { "add" }); + p + } + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.stage { + RStage::Mode => { + let remove = match i { + Input::Keyword(k) | Input::Text(k) => k.to_ascii_lowercase().starts_with('r'), + Input::Enter => false, + _ => return Ok(Step::Cancel), + }; + self.sel = SelectPhase::begin(s); + self.stage = RStage::Pick(remove); + if self.sel.done { + let hs = self.sel.picked.clone(); + return self.finish(s, remove, &hs); + } + Ok(Step::Continue) + } + RStage::Pick(remove) => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => self.finish(s, remove, &hs), + }, + } + } +} + +impl RefsetM { + fn finish(&mut self, s: &mut Session, remove: bool, hs: &[Handle]) -> Result { + let r = if remove { apply_refset(s, &[], hs) } else { apply_refset(s, hs, &[]) }?; + s.echo(format!("{} object(s) in the working set.", r.get("working").and_then(Value::as_array).map_or(0, Vec::len))); + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +struct RefcloseM; + +impl Interactive for RefcloseM { + fn name(&self) -> &'static str { + "REFCLOSE" + } + fn prompt(&self, _s: &Session) -> Prompt { + let mut p = Prompt::new("Enter option [Save/Discard reference changes] ", Accept::TEXT); + p.keywords = vec!["Save".into(), "Discard".into()]; + p.default = Some("Save".into()); + p + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let save = match i { + Input::Keyword(k) | Input::Text(k) => !k.to_ascii_lowercase().starts_with('d'), + Input::Enter => true, + _ => return Ok(Step::Cancel), + }; + let r = close(s, save)?; + s.echo(if save { "Reference changes saved." } else { "Reference changes discarded." }); + let _ = r; + Ok(Step::Done) + } +} diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs index 3e0079a..df4ce2d 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -36,9 +36,9 @@ pub fn handle_param(p: &serde_json::Value, key: &str) -> Option { p.get(key).and_then(handle_value) } -/// Is the entity on a locked layer? +/// Is the entity on a locked layer, or outside the working set while a block reference is edited in place (REFEDIT)? pub fn is_locked(s: &Session, h: Handle) -> bool { - s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked)) + s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked) || super::blocks::refedit::locked(d, h)) } // ---------------- arc constructions ---------------- diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 41718c5..6502e6d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -343,6 +343,9 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result { /// Save to `path` with `opts`. A drawing file then keeps the version and binary choice: the /// version goes into the drawing's `$ACADVER`, so later saves (QSAVE) write it again. fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result { + if s.doc()?.refedit.is_some() { + return Err(bad(cmd, "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving.")); + } let bytes = write_bytes(s, cmd, path, opts)?; let n = commit_save(s, path, &bytes, store)?; if is_drawing_file(path) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2ae1ae7..d6a936a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,7 +5,7 @@ pub(crate) mod annoscale; mod annotate; mod array; mod audit; -mod blocks; +pub(crate) mod blocks; pub mod clipboard; pub mod constraints; mod draw; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1a1394a..25a6597 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -821,6 +821,11 @@ impl Session { return Err(EngineError::Internal(id.into(), msg)); } }; + let violation = before.as_ref().and_then(|b| cmd::blocks::refedit::after_command(self, &b.0, spec.id)); + let result = match (violation, result) { + (Some(m), Ok(_)) => Err(EngineError::Other(m)), + (_, r) => r, + }; cmd::groups::after_command(self, before.as_ref().map(|b| &b.0)); cmd::constraints::after_command(self, before.as_ref().map(|b| &b.0), spec.undoable && result.is_ok()); assoc::after_command(self, before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2)); @@ -1160,6 +1165,9 @@ impl Session { self.select_mode = None; let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command"); let _ = cancelled; + if let Some(m) = cmd::blocks::refedit::after_command(self, &run.before, &run.id) { + self.echo(m); + } cmd::groups::after_command(self, Some(&run.before)); cmd::constraints::after_command(self, Some(&run.before), true); assoc::after_command(self, Some(&run.before), None); diff --git a/crates/engine/tests/refedit.rs b/crates/engine/tests/refedit.rs new file mode 100644 index 0000000..df3aa5e --- /dev/null +++ b/crates/engine/tests/refedit.rs @@ -0,0 +1,293 @@ +//! REFEDIT, REFSET and REFCLOSE: editing a block reference in place (#813). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Common, Drawing, EntityKind, Handle, Insert, Space}; +use cadcraft_engine::geom::Vec3; +use serde_json::{Value, json}; + +fn h(v: &Value) -> String { + v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_string() +} + +fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: f64, rot_deg: f64) -> String { + let kind = EntityKind::Insert(Insert { + block: block.into(), + insert: Vec3::new(at.0, at.1, 0.0), + scale: Vec3::new(scale, scale, scale), + rotation: rot_deg.to_radians(), + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() +} + +/// Block B = a line (0,0)-(10,0) and a circle at (20,0) r2; references A (at 100,0) and C +/// (at 0,50, scaled 2, rotated 90 degrees). +fn setup() -> (Session, String, String) { + let mut s = Session::new(); + let l = h(&s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap()); + let c = h(&s.execute("circle", &json!({ "center": [20, 0], "radius": 2 })).unwrap()); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l, c], "keep": "delete" })).unwrap(); + let a = insert(&mut s, "B", (100.0, 0.0), 1.0, 0.0); + let c = insert(&mut s, "B", (0.0, 50.0), 2.0, 90.0); + (s, a, c) +} + +fn block_lines(d: &Drawing, name: &str) -> Vec<(Vec3, Vec3)> { + d.block(name).unwrap().entities.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some((l.a, l.b)) } else { None }).collect() +} + +fn handles(v: &Value) -> Vec { + v["working"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect() +} + +fn model_len(s: &Session) -> usize { + s.doc().unwrap().model.len() +} + +#[test] +fn edit_and_save_updates_every_reference() { + let (mut s, a, c) = setup(); + let before = model_len(&s); + let r = s.execute("refedit", &json!({ "handle": a })).unwrap(); + let work = handles(&r); + assert_eq!(work.len(), 2); + // The reference is hidden, the block's objects stand where it showed them. + let d = s.doc().unwrap(); + assert!(!d.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible); + assert_eq!(model_len(&s), before + 2); + let line = work.iter().find_map(|w| match &d.entity(Handle::parse_hex(w).unwrap()).unwrap().kind { + EntityKind::Line(l) => Some((w.clone(), l.clone())), + _ => None, + }); + let (lh, l) = line.unwrap(); + assert!((l.a.x - 100.0).abs() < 1e-9 && (l.b.x - 110.0).abs() < 1e-9); + // Edit with normal commands: stretch it up by 5 and draw a new line. + s.execute("move", &json!({ "handles": [lh], "delta": [0, 5] })).unwrap(); + s.execute("line", &json!({ "points": [[100, 20], [110, 20]] })).unwrap(); + // The new line joined the working set. + let rs = s.execute("refset", &json!({})).unwrap(); + assert_eq!(rs["working"].as_array().unwrap().len(), 3); + let out = s.execute("refclose", &json!({})).unwrap(); + assert_eq!(out["objects"], 3); + let d = s.doc().unwrap(); + assert!(d.refedit.is_none()); + assert!(d.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible); + assert_eq!(model_len(&s), before, "working copies are gone, the block holds the objects"); + let lines = block_lines(d, "B"); + assert_eq!(lines.len(), 2); + assert!(lines.iter().any(|(p, q)| (p.y - 5.0).abs() < 1e-9 && (q.x - 10.0).abs() < 1e-9)); + assert!(lines.iter().any(|(p, q)| (p.y - 20.0).abs() < 1e-9 && (p.x - 0.0).abs() < 1e-9 && (q.x - 10.0).abs() < 1e-9)); + // The second reference shows the change through its own transform. + let _ = c; + assert!(s.doc().unwrap().block("B").unwrap().entities.len() == 3); +} + +#[test] +fn discard_leaves_everything_as_it_was() { + let (mut s, a, _) = setup(); + let before = s.doc().unwrap().clone(); + let r = s.execute("refedit", &json!({ "handle": a })).unwrap(); + s.execute("erase", &json!({ "handles": handles(&r) })).unwrap(); + s.execute("refclose", &json!({ "option": "discard" })).unwrap(); + let d = s.doc().unwrap(); + assert!(d.refedit.is_none()); + assert_eq!(d.block("B").unwrap().entities.len(), 2); + assert_eq!(d.model.len(), before.model.len()); + assert!(d.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible); +} + +#[test] +fn erasing_a_working_object_removes_it_from_the_block() { + let (mut s, a, _) = setup(); + let r = s.execute("refedit", &json!({ "handle": a })).unwrap(); + let w = handles(&r); + s.execute("erase", &json!({ "handles": [w[0]] })).unwrap(); + s.execute("refclose", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().block("B").unwrap().entities.len(), 1); +} + +#[test] +fn objects_outside_the_working_set_cannot_be_edited() { + let (mut s, a, c) = setup(); + let other = h(&s.execute("line", &json!({ "points": [[300, 0], [310, 0]] })).unwrap()); + s.execute("refedit", &json!({ "handle": a })).unwrap(); + let before = s.doc().unwrap().clone(); + let _ = s.execute("erase", &json!({ "handles": [other.clone(), c.clone()] })); + let _ = s.execute("move", &json!({ "handles": [other.clone()], "delta": [1, 1] })); + let d = s.doc().unwrap(); + assert!(d.entity(Handle::parse_hex(&other).unwrap()).is_some()); + assert!(d.entity(Handle::parse_hex(&c).unwrap()).is_some()); + assert_eq!(d.model.len(), before.model.len()); + // Starting another edit, or saving, is refused. + assert!(s.execute("refedit", &json!({ "handle": c })).is_err()); + assert!(s.execute("save", &json!({ "path": "/nonexistent-dir/x.dxf" })).is_err()); +} + +#[test] +fn refset_adds_and_removes_objects() { + let (mut s, a, _) = setup(); + let other = h(&s.execute("line", &json!({ "points": [[300, 0], [310, 0]] })).unwrap()); + // Edit only the circle: the line is a faded copy. + let (circle_src, line_src) = { + let b = s.doc().unwrap().block("B").unwrap(); + let c = b.entities.iter().find(|e| matches!(e.kind, EntityKind::Circle(_))).unwrap().handle; + let l = b.entities.iter().find(|e| matches!(e.kind, EntityKind::Line(_))).unwrap().handle; + (c.hex(), l.hex()) + }; + let r = s.execute("refedit", &json!({ "handle": a, "objects": [circle_src] })).unwrap(); + assert_eq!(r["faded"], 1); + assert_eq!(handles(&r).len(), 1); + // Add the host line: it goes into the block at close. Remove the circle: it stays in the block. + let rs = s.execute("refset", &json!({ "add": [other], "remove": handles(&r) })).unwrap(); + assert_eq!(rs["added"], 1); + assert_eq!(rs["removed"], 1); + s.execute("refclose", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let b = d.block("B").unwrap(); + assert_eq!(b.entities.len(), 3); + assert!(b.entities.get(Handle::parse_hex(&line_src).unwrap()).is_some()); + assert!(d.entity(Handle::parse_hex(&other).unwrap()).is_none(), "moved into the block"); + // 300,0 relative to the reference at 100,0 + assert!(block_lines(d, "B").iter().any(|(p, q)| (p.x - 200.0).abs() < 1e-9 && (q.x - 210.0).abs() < 1e-9)); + assert!(s.execute("refset", &json!({ "add": ["FFFF"] })).is_err(), "no edit active"); +} + +#[test] +fn a_block_that_would_contain_itself_is_refused_and_the_edit_survives() { + let (mut s, a, _) = setup(); + s.execute("refedit", &json!({ "handle": a })).unwrap(); + // A reference to B drawn while editing B. + let nested = insert(&mut s, "B", (500.0, 0.0), 1.0, 0.0); + let _ = nested; + // inserted through the doc API: add it to the working set by hand + s.execute("refset", &json!({ "add": [nested] })).unwrap(); + assert!(s.execute("refclose", &json!({})).is_err()); + assert!(s.doc().unwrap().refedit.is_some(), "the edit is still active"); + assert_eq!(s.doc().unwrap().block("B").unwrap().entities.len(), 2); + s.execute("refclose", &json!({ "option": "discard" })).unwrap(); + assert!(s.doc().unwrap().refedit.is_none()); +} + +#[test] +fn undo_takes_the_edit_back_with_its_state() { + let (mut s, a, _) = setup(); + let n = model_len(&s); + s.execute("refedit", &json!({ "handle": a })).unwrap(); + s.undo().unwrap(); + let d = s.doc().unwrap(); + assert!(d.refedit.is_none()); + assert_eq!(d.model.len(), n); + assert!(d.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible); + s.redo().unwrap(); + assert!(s.doc().unwrap().refedit.is_some()); +} + +#[test] +fn files_never_hold_half_an_edit() { + let (mut s, a, _) = setup(); + s.execute("refedit", &json!({ "handle": a })).unwrap(); + let d = s.doc().unwrap(); + let committed = d.committed(); + assert!(committed.refedit.is_none()); + assert_eq!(committed.model.len(), 2); + assert!(committed.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible); + let dxf = cadcraft_io::write_dxf(d); + let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap(); + assert_eq!(back.model.len(), 2); +} + +#[test] +fn hostile_requests_never_panic() { + let (mut s, a, _) = setup(); + for p in [ + json!({}), + json!({ "handle": "ZZZ" }), + json!({ "handle": 1e300 }), + json!({ "name": "nope" }), + json!({ "handle": a, "objects": "x" }), + json!({ "handle": a, "objects": [] }), + json!({ "handle": a, "objects": ["FFFFFFFF"] }), + json!({ "handle": a, "objects": [null, {}] }), + ] { + assert!(s.execute("refedit", &p).is_err(), "{p}"); + } + for cmd in ["refset", "refclose"] { + assert!(s.execute(cmd, &json!({})).is_err()); + } + // A reference with zero scale, a non-uniform one and a non-finite one can't be edited. + let z = insert(&mut s, "B", (0.0, 0.0), 0.0, 0.0); + assert!(s.execute("refedit", &json!({ "handle": z })).is_err()); + let nan = insert(&mut s, "B", (f64::NAN, 0.0), 1.0, 0.0); + assert!(s.execute("refedit", &json!({ "handle": nan })).is_err()); + s.execute("refedit", &json!({ "handle": a })).unwrap(); + for p in [json!({ "add": "x" }), json!({ "remove": ["ZZ"] }), json!({ "add": [null] }), json!({ "handles": 3 }), json!({ "remove": ["FFFFFFF"] })] + { + assert!(s.execute("refset", &p).is_err(), "{p}"); + } + assert!(s.execute("refclose", &json!({ "option": "maybe" })).is_err()); + assert!(s.doc().unwrap().refedit.is_some()); +} + +#[test] +fn a_block_that_references_itself_in_a_file_does_not_hang() { + let (mut s, a, _) = setup(); + // Hostile drawing: B contains a reference to itself. + let kind = EntityKind::Insert(Insert { + block: "B".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: vec![], + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }); + let d = s.doc_mut().unwrap(); + let hh = d.new_handle(); + let mut b = (**d.block("B").unwrap()).clone(); + b.entities.push(cadcraft_engine::doc::Entity::new(hh, kind)); + d.blocks.insert("B".into(), std::sync::Arc::new(b)); + s.execute("refedit", &json!({ "handle": a })).unwrap(); + assert!(s.execute("refclose", &json!({})).is_err()); + s.execute("refclose", &json!({ "option": "discard" })).unwrap(); +} + +#[test] +fn interactive_refedit_refset_and_refclose() { + use cadcraft_engine::Input; + let (mut s, a, _) = setup(); + let other = h(&s.execute("line", &json!({ "points": [[300, 0], [310, 0]] })).unwrap()); + s.start("refedit").unwrap(); + s.input(Input::Pick(vec![Handle::parse_hex(&a).unwrap()])).unwrap(); + assert!(s.running.is_none() && s.doc().unwrap().refedit.is_some()); + s.start("refset").unwrap(); + s.input(Input::Keyword("Add".into())).unwrap(); + s.input(Input::Pick(vec![Handle::parse_hex(&other).unwrap()])).unwrap(); + s.input(Input::Enter).unwrap(); + assert_eq!(s.doc().unwrap().refedit.as_ref().unwrap().items.len(), 3); + s.start("refclose").unwrap(); + s.input(Input::Keyword("Save".into())).unwrap(); + assert!(s.doc().unwrap().refedit.is_none()); + assert_eq!(s.doc().unwrap().block("B").unwrap().entities.len(), 3); +} + +#[test] +fn xrefs_are_refused_with_a_message() { + let mut s = Session::new(); + s.doc_mut().unwrap().blocks.insert( + "X".into(), + std::sync::Arc::new({ + let mut b = cadcraft_engine::doc::Block::new("X"); + b.xref_path = Some("x.dxf".into()); + b + }), + ); + let x = insert(&mut s, "X", (0.0, 0.0), 1.0, 0.0); + let e = s.execute("refedit", &json!({ "handle": x })).unwrap_err(); + assert!(e.to_string().contains("external reference"), "{e}"); +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index f70474a..8857959 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1461,6 +1461,9 @@ pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String { /// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the /// file ends up in: `version`, or the DWG version the bridge writes) has. fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { + // An in-place block edit in progress is not written: the drawing as before it. + let committed = d.committed(); + let d: &Drawing = &committed; // Structural handles start above every entity handle. let mut w = W { t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new() }; // Pre-allocate handles for block records. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 29d93ef..b9e4c5b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -796,6 +796,11 @@ Select first object Vyberte první objekt Select first object or Vyberte první objekt nebo Select first point Vyberte první bod + Add to Working Set Přidat do pracovní sady + Edit Reference In-Place Upravit referenci na místě + Save Reference Edits Uložit úpravy reference + Working Set Pracovní sada + Xref and Block In-place Editing Úpravy xref a bloků na místě Select nested objects to copy or Vyberte vnořené objekty ke kopírování nebo Select object Vyberte objekt Select object to dimension Vyberte objekt ke kótování diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 38354c5..5bbce32 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -673,6 +673,11 @@ Select an object to change or Wählen Sie ein zu änderndes Objekt oder Select an object to measure or Wählen Sie ein zu messendes Objekt oder Select an object. Wählen Sie ein Objekt. + Add to Working Set Zum Arbeitssatz hinzufügen + Edit Reference In-Place Referenz an Ort und Stelle bearbeiten + Save Reference Edits Referenzänderungen speichern + Working Set Arbeitssatz + Xref and Block In-place Editing Xref- und Block-Bearbeitung am Ort Select an open curve. Wählen Sie eine offene Kurve. Select an open line, arc, polyline, ellipse arc or spline. Wählen Sie eine offene Linie, einen Bogen, eine Polylinie, einen Ellipsenbogen oder einen Spline. Select arc or circle Wählen Sie einen Bogen oder Kreis diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b20196b..2d676e2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -813,6 +813,11 @@ Relative Relativas Remove Leader Eliminar directriz Remove the viewport override Eliminar modificación de la ventana gráfica + Add to Working Set Añadir al conjunto de trabajo + Edit Reference In-Place Editar referencia in situ + Save Reference Edits Guardar cambios de la referencia + Working Set Conjunto de trabajo + Xref and Block In-place Editing Edición in situ de xrefs y bloques Rename Dimension Style Renombrar estilo de cota Rename Layer Renombrar capa Rename Layer State Renombrar estado de capas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 82b2258..eb0d9a1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -658,6 +658,11 @@ Select a line, arc, circle, polyline, ellipse or spline as the path. Sélectionner une ligne, un arc, un cercle, une polyligne, une ellipse ou une spline comme trajectoire. Select a line, circle, arc or polyline segment. Sélectionner une ligne, un cercle, un arc ou un segment de polyligne. Select a line. Sélectionner une ligne. + Add to Working Set Ajouter au jeu de travail + Edit Reference In-Place Modifier la référence sur place + Save Reference Edits Enregistrer les modifications de la référence + Working Set Jeu de travail + Xref and Block In-place Editing Édition sur place des xrefs et des blocs Select a polyline. Sélectionner une polyligne. Select a text, mtext, attribute definition or dimension. Sélectionner un texte, un texte multiligne, une définition d'attribut ou une cote. Select an annotation object Sélectionner un objet d'annotation diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 3d28984..5f258b2 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -657,6 +657,11 @@ Select a line or an arc. Selecione uma linha ou um arco. Select a line or polyline segment Selecione uma linha ou segmento de polilinha Select a line, arc or open polyline. Selecione uma linha, arco ou polilinha aberta. + Add to Working Set Adicionar ao conjunto de trabalho + Edit Reference In-Place Editar referência no local + Save Reference Edits Salvar alterações da referência + Working Set Conjunto de trabalho + Xref and Block In-place Editing Edição no local de xrefs e blocos Select a line, arc, circle, polyline, ellipse or spline as the path. Selecione uma linha, arco, círculo, polilinha, elipse ou spline como trajetória. Select a line, circle, arc or polyline segment. Selecione uma linha, círculo, arco ou segmento de polilinha. Select a line. Selecione uma linha. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 90a60b0..24cdf07 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -658,6 +658,11 @@ Select a line, arc, circle, polyline, ellipse or spline as the path. Выберите отрезок, дугу, круг, полилинию, эллипс или сплайн в качестве траектории. Select a line, circle, arc or polyline segment. Выберите отрезок, круг, дугу или сегмент полилинии. Select a line. Выберите отрезок. + Add to Working Set Добавить в рабочий набор + Edit Reference In-Place Редактировать ссылку на месте + Save Reference Edits Сохранить изменения ссылки + Working Set Рабочий набор + Xref and Block In-place Editing Редактирование xref и блоков на месте Select a polyline. Выберите полилинию. Select a text, mtext, attribute definition or dimension. Выберите текст, многострочный текст, определение атрибута или размер. Select an annotation object Выберите аннотационный объект diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 51f7f48..55d8869 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -817,6 +817,11 @@ Select objects to array. Виберіть об'єкти для масиву. Select objects to stretch by crossing-window or crossing-polygon... Виберіть об'єкти для розтягування січною рамкою або січним багатокутником... Select objects: Виберіть об'єкти: + Add to Working Set Додати до робочого набору + Edit Reference In-Place Редагувати посилання на місці + Save Reference Edits Зберегти зміни посилання + Working Set Робочий набір + Xref and Block In-place Editing Редагування xref і блоків на місці Select one polyline (Multiple is available through the JSON form). Виберіть одну полілінію (Multiple доступно через JSON). Select path curve Виберіть криву траєкторії Select point or object Виберіть точку або об'єкт diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 2fd92e5..e1d691b 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -667,6 +667,11 @@ Select an object 选择对象 Select an object to change or Select an object to change or Select an object to measure or Select an object to measure or + Add to Working Set 添加到工作集 + Edit Reference In-Place 就地编辑参照 + Save Reference Edits 保存参照编辑 + Working Set 工作集 + Xref and Block In-place Editing 外部参照和块的就地编辑 Select an object. 选择一个对象。 Select an open curve. 选择一条开曲线。 Select an open line, arc, polyline, ellipse arc or spline. 选择开放的直线、圆弧、多段线、椭圆弧或样条曲线。 @@ -1011,4 +1016,4 @@ Reverse zoom direction 反转缩放方向 Calibrate 校准 Move sideways to pan; push/pull or twist to zoom. 向侧面移动以平移;推拉或扭转以缩放。 - Left device button: zoom extents. Right device button: calibrate. 左侧设备按钮:范围缩放。右侧设备按钮:校准。 \ No newline at end of file + Left device button: zoom extents. Right device button: calibrate. 左侧设备按钮:范围缩放。右侧设备按钮:校准。 diff --git a/docs/gaps.md b/docs/gaps.md index 0a69b26..ded5466 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -25,7 +25,7 @@ do them first. | # | Gap | Evidence | User impact | Est. | |---|---|---|---|---| | F1 | **[alpha blocker]** Prompt options that still print "not available yet": STRETCH polygon and fence selection (crossing window only), -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/modify.rs`, `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 2–4 | -| F2 | XREF editing in place and clipping: REFEDIT and XCLIP are missing. XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | +| F2 | XREF editing in place and clipping: XCLIP is missing. REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | | F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no AUDITCTL `.adt` log and no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 3–5 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 |