mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-30 13:56:46 -06:00
* REFEDIT, REFSET and REFCLOSE: edit block references in place (#813) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Move REFEDIT translation lines into the middle of the catalogs (avoids PR merge conflicts) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
470 lines
19 KiB
Rust
470 lines
19 KiB
Rust
//! 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<CommandSpec> {
|
|
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<RefEdit> {
|
|
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<Item = Handle>) -> Vec<String> {
|
|
hs.into_iter().map(|h| h.hex()).collect()
|
|
}
|
|
|
|
// ---------------- REFEDIT ----------------
|
|
|
|
fn run_refedit(s: &mut Session, p: &Value) -> Result<Value> {
|
|
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<HashSet<Handle>> = 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<Handle> = 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<Vec<Handle>> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<usize> {
|
|
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<Entity> = 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<Drawing>, id: &str) -> Option<String> {
|
|
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<Handle> = 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::<Vec<_>>();
|
|
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<Handle> = 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<Step> {
|
|
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] <Add>", 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<Step> {
|
|
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<Step> {
|
|
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] <Save>", 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<Step> {
|
|
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)
|
|
}
|
|
}
|