754 lines
31 KiB
Rust
754 lines
31 KiB
Rust
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing.
|
|
|
|
mod purge;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign};
|
|
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::helpers::v3;
|
|
use super::machines::{SelOutcome, SelectPhase, number};
|
|
use super::*;
|
|
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
|
|
|
mod attedit;
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
CommandSpec::new("block", "Make...", run_block)
|
|
.menu(&["Draw", "Block", "Make..."])
|
|
.alias(&["b", "bmake"])
|
|
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?}")
|
|
.interactive(|_| Ok(Box::new(BlockM::default()))),
|
|
// -BLOCK erases the selected objects once the block is defined.
|
|
CommandSpec::new("-block", "Create Block", run_dash_block)
|
|
.alias(&["-b"])
|
|
.params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description?}")
|
|
.interactive(|_| Ok(Box::new(BlockM { dash: true, ..BlockM::default() }))),
|
|
CommandSpec::new("insert", "Block...", run_insert)
|
|
.menu(&["Insert", "Block..."])
|
|
.alias(&["i", "-insert", "ddinsert"])
|
|
.params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}")
|
|
.interactive(|_| Ok(Box::new(InsertM::default()))),
|
|
CommandSpec::new("attdef", "Define Attributes...", run_attdef)
|
|
.menu(&["Draw", "Block", "Define Attributes..."])
|
|
.alias(&["att", "-attdef"])
|
|
.params("{tag, prompt?, default?, at, height?, invisible?}")
|
|
.interactive(|_| Ok(Box::new(AttdefM::default()))),
|
|
attedit::attedit_spec(),
|
|
attedit::dash_attedit_spec(),
|
|
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
|
|
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
|
|
CommandSpec::new("purge", "Purge", purge::run)
|
|
.alias(&["pu", "-purge"])
|
|
.params("{type?: all|blocks|dimstyles|groups|layers|linetypes|mleaderstyles|tablestyles|textstyles|zerolength|emptytext (default all: every named type), names?: \"A*,B\" (default *)} → purged")
|
|
.interactive(|_| Ok(Box::new(purge::PurgeM::default()))),
|
|
CommandSpec::new("blocks.list", "List Blocks", run_list).enabled(has_doc).noundo(),
|
|
CommandSpec::new("battman", "Block Attribute Manager...", run_battman)
|
|
.menu(&["Modify", "Object", "Attribute", "Block Attribute Manager..."])
|
|
.params("{name}")
|
|
.noundo(),
|
|
attribs::attdisp_spec(),
|
|
attribs::attsync_spec(),
|
|
]
|
|
}
|
|
|
|
fn valid_block_name(n: &str) -> bool {
|
|
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
|
}
|
|
|
|
/// The block an entity references, when it is a block reference.
|
|
fn referenced_block(e: &Entity) -> Option<String> {
|
|
if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }
|
|
}
|
|
|
|
/// Whether any of `ents` refers to block `name`, directly or through nested block references.
|
|
fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
|
let mut todo: Vec<String> = ents.iter().filter_map(referenced_block).collect();
|
|
let mut seen = std::collections::HashSet::new();
|
|
while let Some(b) = todo.pop() {
|
|
if b.eq_ignore_ascii_case(name) {
|
|
return true;
|
|
}
|
|
if seen.insert(b.to_ascii_uppercase())
|
|
&& let Some(blk) = d.block(&b)
|
|
{
|
|
todo.extend(blk.entities.iter().filter_map(|e| referenced_block(e)));
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Create a block from entities. Returns the insert handle when converting.
|
|
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: &str, description: &str) -> Result<Option<Handle>> {
|
|
if !valid_block_name(name) {
|
|
return Err(bad("block", "invalid block name"));
|
|
}
|
|
let d = s.doc()?;
|
|
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
|
if ents.is_empty() {
|
|
return Err(bad("block", "no objects selected"));
|
|
}
|
|
if references_block(d, &ents, name) {
|
|
return Err(bad("block", format!("Block \"{name}\" references itself.")));
|
|
}
|
|
// 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();
|
|
let mut b = Block::new(name);
|
|
b.base = v3(base);
|
|
b.description = description.to_string();
|
|
let doc = s.doc_mut()?;
|
|
for mut e in ents {
|
|
e.handle = doc.new_handle();
|
|
b.entities.push(e);
|
|
}
|
|
doc.blocks.insert(name.to_string(), Arc::new(b));
|
|
if keep != "retain" {
|
|
for h in hs {
|
|
doc.remove_entity(*h);
|
|
}
|
|
}
|
|
s.set_selection(Vec::new());
|
|
if keep == "convert" {
|
|
let h = insert(s, name, base, 1.0, 0.0, &serde_json::Map::new())?;
|
|
return Ok(Some(h));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Insert a block reference, filling attributes from `values` (else their defaults).
|
|
pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation: f64, values: &serde_json::Map<String, Value>) -> Result<Handle> {
|
|
insert_with(s, name, at, Vec2::new(scale, scale), rotation, None, values)
|
|
}
|
|
|
|
/// The block reference INSERT places: X/Y scale factors, and an optional base point (in block
|
|
/// coordinates) that lands on `at` instead of the block's own base point.
|
|
fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option<Vec2>) -> Insert {
|
|
let mut ins = Insert {
|
|
block: blk.name.clone(),
|
|
insert: v3(at),
|
|
scale: Vec3::new(scale.x, scale.y, scale.x),
|
|
rotation,
|
|
attribs: Vec::new(),
|
|
cols: 1,
|
|
rows: 1,
|
|
col_spacing: 0.0,
|
|
row_spacing: 0.0,
|
|
};
|
|
if let Some(b) = base {
|
|
let landed = ins.transform(blk.base.xy()).apply(b);
|
|
ins.insert = v3(at + (at - landed));
|
|
}
|
|
ins
|
|
}
|
|
|
|
/// [`insert`] with separate X/Y scale factors and an optional base point (see [`insert_entity`]).
|
|
pub(crate) fn insert_with(
|
|
s: &mut Session,
|
|
name: &str,
|
|
at: Vec2,
|
|
scale: Vec2,
|
|
rotation: f64,
|
|
base: Option<Vec2>,
|
|
values: &serde_json::Map<String, Value>,
|
|
) -> Result<Handle> {
|
|
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
|
let mut ins = insert_entity(&blk, at, scale, rotation, base);
|
|
let m = ins.transform(blk.base.xy());
|
|
for e in blk.entities.iter() {
|
|
if let EntityKind::AttDef(ad) = &e.kind {
|
|
if ad.constant {
|
|
continue;
|
|
}
|
|
let mut t = ad.text.clone();
|
|
let mut k = EntityKind::Text(t.clone());
|
|
k.transform(&m);
|
|
if let EntityKind::Text(tt) = k {
|
|
t = tt;
|
|
}
|
|
t.value = attedit::value_for(values, &ad.tag).unwrap_or_else(|| ad.text.value.clone());
|
|
ins.attribs.push(Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new() });
|
|
}
|
|
}
|
|
s.add_entity(EntityKind::Insert(ins))
|
|
}
|
|
|
|
fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let name = str_param(p, "name").ok_or_else(|| bad("block", "`name` is required"))?.to_string();
|
|
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
|
|
let hs = targets(s, p)?;
|
|
let keep = str_param(p, "keep").unwrap_or("convert").to_string();
|
|
let r = make_block(s, &name, base, &hs, &keep, str_param(p, "description").unwrap_or(""))?;
|
|
Ok(json!({ "block": name, "insert": r.map(|h| h.hex()) }))
|
|
}
|
|
|
|
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let p = &insert_file::json_params(s, p)?;
|
|
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
|
|
let at = point_req("insert", p, "at")?;
|
|
// `scale`: one factor, or [x, y] for different X and Y scale factors.
|
|
let scale = match p.get("scale") {
|
|
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?,
|
|
_ => {
|
|
let f = f64_or(p, "scale", 1.0);
|
|
Vec2::new(f, f)
|
|
}
|
|
};
|
|
if scale.x == 0.0 || scale.y == 0.0 {
|
|
return Err(bad("insert", "scale cannot be 0"));
|
|
}
|
|
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
|
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
|
|
let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?;
|
|
if bool_or(p, "explode", false) {
|
|
return s.execute("explode", &json!({ "handles": [h.hex()] }));
|
|
}
|
|
Ok(attedit::with_unknown_tags(s, h, &vals, json!({ "handle": h.hex() })))
|
|
}
|
|
|
|
fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, height: f64, invisible: bool) -> EntityKind {
|
|
let style = s.doc().map(|d| d.header.str("TEXTSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into());
|
|
EntityKind::AttDef(Attrib {
|
|
tag: tag.to_ascii_uppercase(),
|
|
text: Text {
|
|
insert: v3(at),
|
|
align_pt: None,
|
|
height,
|
|
value: default.into(),
|
|
rotation: 0.0,
|
|
width_factor: 1.0,
|
|
oblique: 0.0,
|
|
style,
|
|
halign: HAlign::Left,
|
|
valign: VAlign::Baseline,
|
|
},
|
|
invisible,
|
|
constant: false,
|
|
prompt: prompt.into(),
|
|
})
|
|
}
|
|
|
|
fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let tag = str_param(p, "tag").ok_or_else(|| bad("attdef", "`tag` is required"))?;
|
|
if tag.contains(' ') || tag.is_empty() {
|
|
return Err(bad("attdef", "tag must be non-empty without spaces"));
|
|
}
|
|
let at = point_req("attdef", p, "at")?;
|
|
let h = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2));
|
|
let k = attdef_kind(s, tag, str_param(p, "prompt").unwrap_or(tag), str_param(p, "default").unwrap_or(""), at, h, bool_or(p, "invisible", false));
|
|
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
|
|
}
|
|
|
|
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
|
|
let src = s.doc()?;
|
|
let mut d = cadcraft_doc::Drawing::new_imperial();
|
|
d.layers = src.layers.clone();
|
|
d.linetypes = src.linetypes.clone();
|
|
d.text_styles = src.text_styles.clone();
|
|
d.dim_styles = src.dim_styles.clone();
|
|
d.blocks = src.blocks.clone();
|
|
// The base point becomes the written drawing's origin, its insertion base: by default 0,0 for
|
|
// objects and the block's base point for a block (`base` is then in block coordinates).
|
|
let base = point_param(p, "base");
|
|
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
|
|
let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO));
|
|
let ents: Vec<Entity> = if let Some(name) = str_param(p, "name") {
|
|
let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?;
|
|
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
|
|
b.entities
|
|
.iter()
|
|
.map(|e| {
|
|
let mut e = (**e).clone();
|
|
e.kind.transform(&m);
|
|
e
|
|
})
|
|
.collect()
|
|
} else {
|
|
targets(s, p)?
|
|
.iter()
|
|
.filter_map(|h| src.entity(*h))
|
|
.map(|e| {
|
|
let mut e = (**e).clone();
|
|
e.kind.transform(&m);
|
|
e
|
|
})
|
|
.collect()
|
|
};
|
|
for mut e in ents {
|
|
e.handle = d.new_handle();
|
|
d.model.push(e);
|
|
}
|
|
let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?;
|
|
let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?;
|
|
Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() }))
|
|
}
|
|
|
|
fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let at = point_req("base", p, "at")?;
|
|
s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at)));
|
|
ok()
|
|
}
|
|
|
|
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let d = s.doc()?;
|
|
Ok(
|
|
json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description})).collect::<Vec<_>>() }),
|
|
)
|
|
}
|
|
|
|
fn run_battman(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let name = str_param(p, "name").ok_or_else(|| bad("battman", "`name` is required"))?;
|
|
let b = s.doc()?.block(name).cloned().ok_or_else(|| bad("battman", "no such block"))?;
|
|
let atts: Vec<Value> = b
|
|
.entities
|
|
.iter()
|
|
.filter_map(|e| {
|
|
if let EntityKind::AttDef(a) = &e.kind {
|
|
Some(json!({"tag": a.tag, "prompt": a.prompt, "default": a.text.value, "invisible": a.invisible}))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
Ok(json!({ "block": name, "attributes": atts }))
|
|
}
|
|
|
|
// ---------------- interactive ----------------
|
|
|
|
/// -BLOCK's JSON form: BLOCK with the selection erased unless `keep` says otherwise.
|
|
fn run_dash_block(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let mut p = p.clone();
|
|
if let Some(o) = p.as_object_mut() {
|
|
o.entry("keep").or_insert_with(|| json!("delete"));
|
|
}
|
|
run_block(s, &p)
|
|
}
|
|
|
|
/// BLOCK / -BLOCK at the command line. BLOCK converts the selection to a reference to the new
|
|
/// block (the dialog's default); -BLOCK erases it.
|
|
#[derive(Default)]
|
|
struct BlockM {
|
|
/// -BLOCK: erase the selection.
|
|
dash: bool,
|
|
/// A name already in use, waiting for the answer to "Redefine it?".
|
|
existing: Option<String>,
|
|
name: Option<String>,
|
|
base: Option<Vec2>,
|
|
sel: SelectPhase,
|
|
}
|
|
|
|
impl Interactive for BlockM {
|
|
fn name(&self) -> &'static str {
|
|
"BLOCK"
|
|
}
|
|
fn prompt(&self, _s: &Session) -> Prompt {
|
|
match (&self.name, self.base) {
|
|
(None, _) => match &self.existing {
|
|
Some(n) => Prompt::new(format!("Block \"{n}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N"),
|
|
None => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]),
|
|
},
|
|
(Some(_), None) => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["Annotative"]),
|
|
_ => self.sel.prompt(),
|
|
}
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
match (&self.name, self.base) {
|
|
(None, _) if self.existing.is_some() => {
|
|
// Yes redefines the block; No (the default) asks for another name.
|
|
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_lowercase().starts_with('y'));
|
|
self.name = self.existing.take().filter(|_| yes);
|
|
Ok(Step::Continue)
|
|
}
|
|
(None, _) => {
|
|
if let Input::Text(t) = i {
|
|
let n = t.trim().to_string();
|
|
if n == "?" {
|
|
let names: Vec<String> = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect();
|
|
s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") }));
|
|
} else if !valid_block_name(&n) {
|
|
s.echo("Invalid block name.");
|
|
} else if s.doc()?.block(&n).is_some() {
|
|
self.existing = Some(n);
|
|
} else {
|
|
self.name = Some(n);
|
|
}
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
(Some(_), None) => {
|
|
if let Input::Point(p) = i {
|
|
self.base = Some(p);
|
|
self.sel = SelectPhase::begin(s);
|
|
if self.sel.done {
|
|
let picked = self.sel.picked.clone();
|
|
return self.finish(s, &picked);
|
|
}
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
_ => match self.sel.feed(s, &i)? {
|
|
SelOutcome::More => Ok(Step::Continue),
|
|
SelOutcome::Empty => Ok(Step::Cancel),
|
|
SelOutcome::Done(hs) => self.finish(s, &hs),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BlockM {
|
|
fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
|
let (Some(name), Some(base)) = (self.name.clone(), self.base) else { return Ok(Step::Cancel) };
|
|
// A definition that can't be made (e.g. one that would reference itself) ends the command.
|
|
let keep = if self.dash { "delete" } else { "convert" };
|
|
if let Err(e) = make_block(s, &name, base, hs, keep, "") {
|
|
s.echo(e.to_string());
|
|
return Ok(Step::Done);
|
|
}
|
|
s.echo(format!("Block \"{name}\" defined with {} object(s).", hs.len()));
|
|
Ok(Step::Done)
|
|
}
|
|
}
|
|
|
|
mod insert_file;
|
|
|
|
/// Where the command-line INSERT is in its prompt sequence.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
enum InsStage {
|
|
#[default]
|
|
Name,
|
|
/// A file whose block already exists: redefine it from the file?
|
|
Redefine,
|
|
Point,
|
|
/// Basepoint: a new base point, picked on the block shown at its definition position.
|
|
BasePoint,
|
|
/// Scale preset before the insertion point.
|
|
PresetScale,
|
|
/// Rotate preset before the insertion point.
|
|
PresetRotation,
|
|
XScale,
|
|
Corner,
|
|
YScale,
|
|
Rotation,
|
|
Attribs,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct InsertM {
|
|
stage: InsStage,
|
|
name: Option<String>,
|
|
at: Option<Vec2>,
|
|
base: Option<Vec2>,
|
|
x_scale: Option<f64>,
|
|
scale: Option<Vec2>,
|
|
rotation: Option<f64>,
|
|
attdefs: Vec<(String, String, String)>,
|
|
values: serde_json::Map<String, Value>,
|
|
/// `*name`: insert the block's objects (one uniform scale factor, no attribute values).
|
|
explode: bool,
|
|
/// The file waiting for the Redefine answer.
|
|
file: Option<insert_file::FileRef>,
|
|
}
|
|
|
|
impl InsertM {
|
|
/// After the insertion point or a scale/rotation answer: the next prompt still needed.
|
|
fn advance(&mut self, s: &mut Session) -> Result<Step> {
|
|
self.stage = if self.scale.is_none() {
|
|
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
|
|
} else if self.rotation.is_none() {
|
|
InsStage::Rotation
|
|
} else {
|
|
InsStage::Attribs
|
|
};
|
|
if self.stage == InsStage::Attribs
|
|
&& self.values.len() >= self.attdefs.len()
|
|
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
|
{
|
|
let h = insert_with(s, &n, a, sc, r, self.base, &self.values)?;
|
|
if self.explode {
|
|
insert_file::explode_insert(s, h)?;
|
|
}
|
|
return Ok(Step::Done);
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
|
|
/// Block `name` chosen: collect its attribute prompts and ask for the insertion point.
|
|
fn choose(&mut self, s: &mut Session, name: &str) -> Result<()> {
|
|
let Some(b) = s.doc()?.block(name).cloned() else {
|
|
s.echo(format!("Block \"{name}\" not found."));
|
|
return Ok(());
|
|
};
|
|
self.attdefs = if self.explode {
|
|
Vec::new()
|
|
} else {
|
|
b.entities
|
|
.iter()
|
|
.filter_map(|e| {
|
|
if let EntityKind::AttDef(a) = &e.kind {
|
|
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
};
|
|
s.doc_mut()?.header.set_str("INSNAME", &b.name);
|
|
self.name = Some(b.name.clone());
|
|
self.stage = InsStage::Point;
|
|
Ok(())
|
|
}
|
|
|
|
/// Define the block from a file, then choose it; a file that can't be read is reported.
|
|
fn choose_file(&mut self, s: &mut Session, f: &insert_file::FileRef) -> Result<()> {
|
|
match f.define(s) {
|
|
Ok(name) => self.choose(s, &name),
|
|
Err(e) => {
|
|
s.echo(e.to_string());
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scale factors from an opposite corner: the X and Y distances from the insertion point.
|
|
fn corner(a: Vec2, p: Vec2) -> Result<Vec2> {
|
|
let d = p - a;
|
|
if d.x.abs() < 1e-12 || d.y.abs() < 1e-12 {
|
|
return Err(EngineError::Other("The corner must not be level with the insertion point.".into()));
|
|
}
|
|
Ok(d)
|
|
}
|
|
}
|
|
|
|
fn nonzero(t: &str) -> Result<f64> {
|
|
number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))
|
|
}
|
|
|
|
impl Interactive for InsertM {
|
|
fn name(&self) -> &'static str {
|
|
"INSERT"
|
|
}
|
|
fn prompt(&self, s: &Session) -> Prompt {
|
|
let at = self.at.unwrap_or_default();
|
|
match self.stage {
|
|
InsStage::Name => {
|
|
let last = s.doc().map(|d| d.header.str("INSNAME", "")).unwrap_or_default();
|
|
let p = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]);
|
|
if last.is_empty() { p } else { p.default(last) }
|
|
}
|
|
InsStage::Redefine => {
|
|
let name = self.file.as_ref().map(|f| f.name.clone()).unwrap_or_default();
|
|
Prompt::new(format!("Block \"{name}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N")
|
|
}
|
|
InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]),
|
|
InsStage::BasePoint => Prompt::new("Specify base point", Accept::POINT),
|
|
InsStage::PresetScale => Prompt::new("Specify scale factor for XYZ axes", Accept::NUMBER).default("1"),
|
|
InsStage::PresetRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
|
|
InsStage::XScale => {
|
|
Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at)
|
|
}
|
|
InsStage::Corner => Prompt::new("Specify opposite corner", Accept::POINT).base(at),
|
|
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
|
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
|
|
InsStage::Attribs => {
|
|
let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
|
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
|
|
}
|
|
}
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
match self.stage {
|
|
InsStage::Name => {
|
|
let n = match i {
|
|
Input::Text(t) => t.trim().to_string(),
|
|
Input::Enter => s.doc()?.header.str("INSNAME", ""),
|
|
_ => return Ok(Step::Continue),
|
|
};
|
|
if n == "?" {
|
|
let names: Vec<String> = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect();
|
|
s.echo(format!("Defined blocks: {}", names.join(", ")));
|
|
return Ok(Step::Continue);
|
|
}
|
|
// `*name`: insert exploded.
|
|
self.explode = n.starts_with('*');
|
|
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
|
|
match insert_file::file_ref(s, &n) {
|
|
Some(f) if f.ask_redefine(s) => {
|
|
self.file = Some(f);
|
|
self.stage = InsStage::Redefine;
|
|
}
|
|
Some(f) => self.choose_file(s, &f)?,
|
|
None => self.choose(s, &n)?,
|
|
}
|
|
}
|
|
InsStage::Redefine => {
|
|
// Yes defines the block from the file; No (the default) inserts the existing block.
|
|
let Some(f) = self.file.take() else { return Ok(Step::Continue) };
|
|
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_lowercase().starts_with('y'));
|
|
if yes {
|
|
self.choose_file(s, &f)?;
|
|
} else {
|
|
self.choose(s, &f.name)?;
|
|
}
|
|
}
|
|
InsStage::Point => match i {
|
|
Input::Point(p) => {
|
|
self.at = Some(p);
|
|
return self.advance(s);
|
|
}
|
|
Input::Keyword(k) if k == "Basepoint" => self.stage = InsStage::BasePoint,
|
|
Input::Keyword(k) if k == "Scale" => self.stage = InsStage::PresetScale,
|
|
Input::Keyword(k) if k == "Rotate" => self.stage = InsStage::PresetRotation,
|
|
_ => {}
|
|
},
|
|
InsStage::BasePoint => {
|
|
if let Input::Point(p) = i {
|
|
self.base = Some(p);
|
|
self.stage = InsStage::Point;
|
|
}
|
|
}
|
|
InsStage::PresetScale => {
|
|
let f = match i {
|
|
Input::Text(t) => nonzero(&t)?,
|
|
Input::Enter => 1.0,
|
|
_ => return Ok(Step::Continue),
|
|
};
|
|
self.scale = Some(Vec2::new(f, f));
|
|
// Exploded insertion asks its one scale factor after the insertion point.
|
|
if self.at.is_some() {
|
|
return self.advance(s);
|
|
}
|
|
self.stage = InsStage::Point;
|
|
}
|
|
InsStage::PresetRotation => {
|
|
self.rotation = Some(match i {
|
|
Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?,
|
|
Input::Enter => 0.0,
|
|
_ => return Ok(Step::Continue),
|
|
});
|
|
self.stage = InsStage::Point;
|
|
}
|
|
InsStage::XScale => match i {
|
|
Input::Keyword(k) if k == "Corner" => self.stage = InsStage::Corner,
|
|
Input::Point(p) => {
|
|
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
|
|
return self.advance(s);
|
|
}
|
|
Input::Text(t) => {
|
|
self.x_scale = Some(nonzero(&t)?);
|
|
self.stage = InsStage::YScale;
|
|
}
|
|
Input::Enter => {
|
|
self.x_scale = Some(1.0);
|
|
self.stage = InsStage::YScale;
|
|
}
|
|
_ => {}
|
|
},
|
|
InsStage::Corner => {
|
|
if let Input::Point(p) = i {
|
|
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
|
|
return self.advance(s);
|
|
}
|
|
}
|
|
InsStage::YScale => {
|
|
let x = self.x_scale.unwrap_or(1.0);
|
|
let y = match i {
|
|
Input::Text(t) => nonzero(&t)?,
|
|
Input::Enter => x,
|
|
_ => return Ok(Step::Continue),
|
|
};
|
|
self.scale = Some(Vec2::new(x, y));
|
|
return self.advance(s);
|
|
}
|
|
InsStage::Rotation => {
|
|
let a = self.at.unwrap_or_default();
|
|
self.rotation = Some(match i {
|
|
Input::Point(p) => a.angle_to(p),
|
|
Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?,
|
|
_ => 0.0,
|
|
});
|
|
return self.advance(s);
|
|
}
|
|
InsStage::Attribs => {
|
|
let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
|
let v = match i {
|
|
Input::Text(t) => t,
|
|
_ => default,
|
|
};
|
|
self.values.insert(tag, Value::String(v));
|
|
return self.advance(s);
|
|
}
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
|
let (Some(n), Ok(d)) = (&self.name, s.doc()) else { return Vec::new() };
|
|
let Some(b) = d.block(n) else { return Vec::new() };
|
|
// Picking a new base point: the block sits at its definition position.
|
|
if self.stage == InsStage::BasePoint {
|
|
return vec![EntityKind::Insert(insert_entity(b, b.base.xy(), Vec2::new(1.0, 1.0), 0.0, None))];
|
|
}
|
|
let at = self.at.unwrap_or(c);
|
|
let scale = self.scale.unwrap_or_else(|| match (self.stage, self.x_scale) {
|
|
(InsStage::XScale | InsStage::Corner, _) => Self::corner(at, c).unwrap_or(Vec2::new(1.0, 1.0)),
|
|
(_, Some(x)) => Vec2::new(x, x),
|
|
_ => Vec2::new(1.0, 1.0),
|
|
});
|
|
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
|
|
vec![EntityKind::Insert(insert_entity(b, at, scale, rot, self.base))]
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct AttdefM {
|
|
tag: Option<String>,
|
|
prompt: Option<String>,
|
|
default: Option<String>,
|
|
}
|
|
|
|
impl Interactive for AttdefM {
|
|
fn name(&self) -> &'static str {
|
|
"ATTDEF"
|
|
}
|
|
fn prompt(&self, _s: &Session) -> Prompt {
|
|
match (&self.tag, &self.prompt, &self.default) {
|
|
(None, ..) => Prompt::new("Enter attribute tag name", Accept::TEXT),
|
|
(Some(_), None, _) => Prompt::new("Enter attribute prompt", Accept::TEXT),
|
|
(Some(_), Some(_), None) => Prompt::new("Enter default attribute value", Accept::TEXT),
|
|
_ => Prompt::new("Specify start point of text", Accept::POINT),
|
|
}
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
let text = match &i {
|
|
Input::Text(t) => Some(t.clone()),
|
|
Input::Enter => Some(String::new()),
|
|
_ => None,
|
|
};
|
|
match (&self.tag, &self.prompt, &self.default) {
|
|
(None, ..) => {
|
|
if let Some(t) = text.filter(|t| !t.trim().is_empty() && !t.contains(' ')) {
|
|
self.tag = Some(t.to_ascii_uppercase());
|
|
}
|
|
}
|
|
(Some(_), None, _) => self.prompt = text,
|
|
(Some(_), Some(_), None) => self.default = text,
|
|
(Some(tag), Some(pr), Some(def)) => {
|
|
if let Input::Point(p) = i {
|
|
let h = s.doc()?.header.f64("TEXTSIZE", 0.2);
|
|
let k = attdef_kind(s, tag, pr, def, p, h, false);
|
|
s.add_entity(k)?;
|
|
return Ok(Step::Done);
|
|
}
|
|
}
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
}
|
|
|
|
mod attribs;
|