mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-26 11:56:50 -06:00
* Keep the active layout when saving DWG/DXF The layout a file marks active (block *Paper_Space) was not kept on read, and on write *Paper_Space always went to the first tab, so every save made the first sheet active. Store it as Drawing::active_layout and write it as *Paper_Space again; tab order is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Match the active layout by name without regard to case paper_space_layouts and layout.rename now compare the active layout's name case-insensitively, as Drawing::layout() does, so an active layout stored as "s2" still resolves the "S2" tab. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Test case-insensitive active layout matching Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
406 lines
16 KiB
Rust
406 lines
16 KiB
Rust
//! The CADCraft drawing database.
|
|
//!
|
|
//! A [`Drawing`] holds the header variables, symbol tables (layers, linetypes, styles), block
|
|
//! definitions, model space and paper-space layouts. Entity collections are copy-on-write
|
|
//! ([`EntityStore`]), so a whole-drawing clone is cheap and serves as an undo snapshot.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
#![forbid(unsafe_code)]
|
|
|
|
mod array;
|
|
mod constraint;
|
|
mod entity;
|
|
mod extents;
|
|
mod gdt;
|
|
mod header;
|
|
pub mod library;
|
|
mod plotstyle;
|
|
mod plotter;
|
|
mod refedit;
|
|
mod store;
|
|
mod tables;
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
pub use array::*;
|
|
pub use cadcraft_color as color;
|
|
pub use cadcraft_geom as geom;
|
|
pub use constraint::*;
|
|
pub use entity::*;
|
|
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
|
|
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::*;
|
|
|
|
/// File data CADCraft doesn't model, kept for saving (#528).
|
|
pub mod dynblock;
|
|
pub mod foreign;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum DocError {
|
|
#[error("no such layer `{0}`")]
|
|
NoLayer(String),
|
|
#[error("no such block `{0}`")]
|
|
NoBlock(String),
|
|
#[error("no such entity {0:?}")]
|
|
NoEntity(Handle),
|
|
#[error("{0}")]
|
|
Invalid(String),
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, DocError>;
|
|
|
|
/// Which entity collection: model space or a named layout's paper space.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum Space {
|
|
#[default]
|
|
Model,
|
|
Paper(String),
|
|
}
|
|
|
|
impl Space {
|
|
/// Whether this is a layout's paper space.
|
|
pub fn is_paper(&self) -> bool {
|
|
matches!(self, Space::Paper(_))
|
|
}
|
|
|
|
/// The layout whose paper space this is; `None` for model space.
|
|
pub fn layout(&self) -> Option<&str> {
|
|
match self {
|
|
Space::Paper(n) => Some(n),
|
|
Space::Model => None,
|
|
}
|
|
}
|
|
|
|
/// The tab name (CTAB): `Model`, or the layout's name.
|
|
pub fn tab_name(&self) -> &str {
|
|
self.layout().unwrap_or("Model")
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Drawing {
|
|
pub header: Header,
|
|
/// File data CADCraft does not model, written back on save.
|
|
pub foreign: std::sync::Arc<foreign::Foreign>,
|
|
pub layers: Vec<Layer>,
|
|
pub linetypes: Vec<Linetype>,
|
|
pub text_styles: Vec<TextStyle>,
|
|
pub dim_styles: Vec<DimStyle>,
|
|
pub mleader_styles: Vec<MLeaderStyle>,
|
|
pub table_styles: Vec<TableStyle>,
|
|
/// Multiline styles (MLSTYLE); STANDARD is always there.
|
|
pub mline_styles: Vec<MLineStyle>,
|
|
pub blocks: BTreeMap<String, std::sync::Arc<Block>>,
|
|
pub model: EntityStore,
|
|
pub layouts: Vec<Layout>,
|
|
/// The layout a file marks active (its block is `*Paper_Space`, the others `*Paper_SpaceN`),
|
|
/// written active again. `None`, or a layout that no longer exists: the first by tab order.
|
|
pub active_layout: Option<String>,
|
|
pub views: Vec<NamedView>,
|
|
/// Model space viewport configurations (VPORTS): the tiles shown and named ones.
|
|
pub vports: Vec<VportConfig>,
|
|
pub ucss: Vec<Ucs>,
|
|
pub layer_states: Vec<LayerState>,
|
|
pub groups: Vec<Group>,
|
|
/// Plot style tables kept with the drawing (STYLESMANAGER); they shadow built-in tables of
|
|
/// the same name.
|
|
pub plot_style_tables: Vec<PlotStyleTable>,
|
|
/// Parametric constraints (GEOMCONSTRAINT / DIMCONSTRAINT).
|
|
pub constraints: Vec<Constraint>,
|
|
/// User parameters and parametric settings.
|
|
pub parametric: Parametric,
|
|
/// The annotation scale list (SCALELISTEDIT).
|
|
pub scales: Vec<AnnoScale>,
|
|
/// Next free handle.
|
|
pub handseed: u64,
|
|
/// Objects hidden for this session by ISOLATEOBJECTS / HIDEOBJECTS. Never written to a file:
|
|
/// a drawing opens with everything shown again.
|
|
pub hidden_objects: BTreeSet<Handle>,
|
|
/// What reading the file left out (a DWG's object records the reader couldn't decode), told
|
|
/// to the user when the drawing opens. Never written to a file.
|
|
pub read_notes: Vec<String>,
|
|
/// The in-place block reference edit in progress (REFEDIT). Never written to a file; files are
|
|
/// written from [`Drawing::committed`].
|
|
pub refedit: Option<RefEdit>,
|
|
/// Records of the file this drawing was read from that the reader could not place (a damaged
|
|
/// file), counted by the section they came from. Never written to a file.
|
|
pub skipped_records: BTreeMap<String, usize>,
|
|
}
|
|
|
|
impl Default for Drawing {
|
|
fn default() -> Self {
|
|
Drawing::new_imperial()
|
|
}
|
|
}
|
|
|
|
impl Drawing {
|
|
/// An empty drawing with the standard tables (inch-based, like acad.dwt-style defaults).
|
|
pub fn new_imperial() -> Self {
|
|
let mut d = Drawing::bare();
|
|
d.header = Header::imperial();
|
|
d
|
|
}
|
|
/// An empty metric drawing (millimetres, ISO-25 dimensions, multileaders and tables to match).
|
|
pub fn new_metric() -> Self {
|
|
let mut d = Drawing::bare();
|
|
d.header = Header::metric();
|
|
d.scales = metric_scales();
|
|
d.dim_styles.push(DimStyle::iso25());
|
|
d.mleader_styles = vec![MLeaderStyle::metric()];
|
|
d.table_styles = vec![TableStyle::metric()];
|
|
d.header.set_str("DIMSTYLE", "ISO-25");
|
|
d
|
|
}
|
|
fn bare() -> Self {
|
|
Drawing {
|
|
header: Header::default(),
|
|
foreign: std::sync::Arc::default(),
|
|
layers: vec![Layer::default(), Layer { name: "Defpoints".into(), plot: false, ..Layer::default() }],
|
|
linetypes: vec![
|
|
Linetype { name: "ByBlock".into(), description: String::new(), pattern: Vec::new() },
|
|
Linetype { name: "ByLayer".into(), description: String::new(), pattern: Vec::new() },
|
|
Linetype::continuous(),
|
|
],
|
|
text_styles: vec![TextStyle::default()],
|
|
dim_styles: vec![DimStyle::default()],
|
|
mleader_styles: vec![MLeaderStyle::default()],
|
|
table_styles: vec![TableStyle::default()],
|
|
mline_styles: vec![MLineStyle::default()],
|
|
blocks: BTreeMap::new(),
|
|
model: EntityStore::new(),
|
|
layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)],
|
|
active_layout: None,
|
|
views: Vec::new(),
|
|
vports: Vec::new(),
|
|
ucss: Vec::new(),
|
|
layer_states: Vec::new(),
|
|
groups: Vec::new(),
|
|
plot_style_tables: Vec::new(),
|
|
constraints: Vec::new(),
|
|
parametric: Parametric::default(),
|
|
scales: imperial_scales(),
|
|
handseed: 0x100,
|
|
hidden_objects: BTreeSet::new(),
|
|
read_notes: Vec::new(),
|
|
refedit: None,
|
|
skipped_records: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Allocate a fresh handle.
|
|
pub fn new_handle(&mut self) -> Handle {
|
|
let h = Handle(self.handseed);
|
|
self.handseed = self.handseed.saturating_add(1);
|
|
h
|
|
}
|
|
/// Make sure future handles don't collide with `h` (after loading a file).
|
|
pub fn bump_handseed(&mut self, h: Handle) {
|
|
if h.0 >= self.handseed {
|
|
self.handseed = h.0.saturating_add(1);
|
|
}
|
|
}
|
|
|
|
pub fn layer(&self, name: &str) -> Option<&Layer> {
|
|
self.layers.iter().find(|l| l.name.eq_ignore_ascii_case(name))
|
|
}
|
|
pub fn layer_mut(&mut self, name: &str) -> Option<&mut Layer> {
|
|
self.layers.iter_mut().find(|l| l.name.eq_ignore_ascii_case(name))
|
|
}
|
|
pub fn linetype(&self, name: &str) -> Option<&Linetype> {
|
|
self.linetypes.iter().find(|l| l.name.eq_ignore_ascii_case(name))
|
|
}
|
|
pub fn text_style(&self, name: &str) -> Option<&TextStyle> {
|
|
self.text_styles.iter().find(|l| l.name.eq_ignore_ascii_case(name))
|
|
}
|
|
pub fn dim_style(&self, name: &str) -> Option<&DimStyle> {
|
|
self.dim_styles.iter().find(|l| l.name.eq_ignore_ascii_case(name))
|
|
}
|
|
|
|
/// The DIM* variables in the header that differ from the current dimension style, as
|
|
/// [`DimStyle`] fields: AutoCAD's style overrides (set with SETVAR), which new dimensions
|
|
/// take on.
|
|
pub fn dim_overrides(&self) -> serde_json::Map<String, serde_json::Value> {
|
|
let mut out = serde_json::Map::new();
|
|
let Some(st) = self.dim_style(&self.header.str("DIMSTYLE", "Standard")) else { return out };
|
|
let base = serde_json::to_value(st).unwrap_or_default();
|
|
for (var, field) in DIMVARS {
|
|
let Some(v) = self.header.get(var).and_then(|h| serde_json::to_value(h).ok()) else { continue };
|
|
let mut cand = st.clone();
|
|
if !cand.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v)])).is_empty() {
|
|
continue;
|
|
}
|
|
let Some(cv) = serde_json::to_value(&cand).ok().and_then(|c| c.get(*field).cloned()) else { continue };
|
|
let same = match (base.get(*field), &cv) {
|
|
// Text style and arrow block names compare without case.
|
|
(Some(serde_json::Value::String(a)), serde_json::Value::String(b))
|
|
if matches!(
|
|
*field,
|
|
"textStyle"
|
|
| "arrowBlock"
|
|
| "arrowBlock1"
|
|
| "arrowBlock2"
|
|
| "leaderArrowBlock"
|
|
| "dimLinetype"
|
|
| "extLinetype1"
|
|
| "extLinetype2"
|
|
) =>
|
|
{
|
|
a.eq_ignore_ascii_case(b)
|
|
}
|
|
(b, cv) => b == Some(cv),
|
|
};
|
|
if !same {
|
|
out.insert((*field).to_string(), cv);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Set the header's DIM* variables to the current dimension style's values, clearing the
|
|
/// overrides (as making a style current or editing it does). Variables the header doesn't
|
|
/// carry stay absent; ones that can't hold the style's value are removed.
|
|
pub fn sync_dim_vars(&mut self) {
|
|
let Some(st) = self.dim_style(&self.header.str("DIMSTYLE", "Standard")) else { return };
|
|
let v = serde_json::to_value(st).unwrap_or_default();
|
|
for (var, field) in DIMVARS {
|
|
let Some(old) = self.header.get(var) else { continue };
|
|
let new = match (old, v.get(*field)) {
|
|
(HVal::Int(_), Some(serde_json::Value::Bool(b))) => Some(HVal::Int(i64::from(*b))),
|
|
(HVal::Int(_), Some(serde_json::Value::Number(n))) => {
|
|
n.as_i64().or_else(|| n.as_f64().filter(|f| f.fract() == 0.0 && f.abs() < 1e15).map(|f| f as i64)).map(HVal::Int)
|
|
}
|
|
(HVal::Real(_), Some(serde_json::Value::Number(n))) => n.as_f64().map(HVal::Real),
|
|
(HVal::Str(_), Some(serde_json::Value::String(s))) => Some(HVal::Str(s.clone())),
|
|
_ => None,
|
|
};
|
|
match new {
|
|
Some(h) => self.header.set(var, h),
|
|
None => {
|
|
self.header.vars.remove(*var);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pub fn block(&self, name: &str) -> Option<&std::sync::Arc<Block>> {
|
|
self.blocks.get(name).or_else(|| self.blocks.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))
|
|
}
|
|
pub fn layout(&self, name: &str) -> Option<&Layout> {
|
|
self.layouts.iter().find(|l| l.name.eq_ignore_ascii_case(name))
|
|
}
|
|
|
|
/// Add a layer if missing; returns whether it was created.
|
|
pub fn ensure_layer(&mut self, name: &str) -> bool {
|
|
if self.layer(name).is_some() {
|
|
return false;
|
|
}
|
|
self.layers.push(Layer::new(name));
|
|
true
|
|
}
|
|
|
|
/// The layouts in the order their paper space blocks are named in a file: the active one
|
|
/// (`*Paper_Space`) first, then the others by tab order (`*Paper_Space0`, `*Paper_Space1`, …).
|
|
pub fn paper_space_layouts(&self) -> Vec<&Layout> {
|
|
let mut ls: Vec<&Layout> = self.layouts.iter().collect();
|
|
ls.sort_by_key(|l| l.tab_order);
|
|
if let Some(i) = self.active_layout.as_ref().and_then(|a| ls.iter().position(|l| l.name.eq_ignore_ascii_case(a))) {
|
|
let active = ls.remove(i);
|
|
ls.insert(0, active);
|
|
}
|
|
ls
|
|
}
|
|
|
|
pub fn space(&self, space: &Space) -> Option<&EntityStore> {
|
|
match space {
|
|
Space::Model => Some(&self.model),
|
|
Space::Paper(n) => self.layouts.iter().find(|l| &l.name == n).map(|l| &l.entities),
|
|
}
|
|
}
|
|
pub fn space_mut(&mut self, space: &Space) -> Option<&mut EntityStore> {
|
|
match space {
|
|
Space::Model => Some(&mut self.model),
|
|
Space::Paper(n) => self.layouts.iter_mut().find(|l| &l.name == n).map(|l| &mut l.entities),
|
|
}
|
|
}
|
|
|
|
/// Find an entity in model space or any layout.
|
|
pub fn entity(&self, h: Handle) -> Option<&std::sync::Arc<Entity>> {
|
|
self.model.get(h).or_else(|| self.layouts.iter().find_map(|l| l.entities.get(h)))
|
|
}
|
|
pub fn space_of(&self, h: Handle) -> Option<Space> {
|
|
if self.model.contains(h) {
|
|
return Some(Space::Model);
|
|
}
|
|
self.layouts.iter().find(|l| l.entities.contains(h)).map(|l| Space::Paper(l.name.clone()))
|
|
}
|
|
pub fn modify_entity<F: FnOnce(&mut Entity)>(&mut self, h: Handle, f: F) -> Result<()> {
|
|
let sp = self.space_of(h).ok_or(DocError::NoEntity(h))?;
|
|
let store = self.space_mut(&sp).ok_or(DocError::NoEntity(h))?;
|
|
if store.modify(h, f) { Ok(()) } else { Err(DocError::NoEntity(h)) }
|
|
}
|
|
pub fn remove_entity(&mut self, h: Handle) -> Option<std::sync::Arc<Entity>> {
|
|
let sp = self.space_of(h)?;
|
|
self.space_mut(&sp)?.remove(h)
|
|
}
|
|
|
|
/// Add an entity to a space with a new handle, using the given common properties.
|
|
pub fn add(&mut self, space: &Space, common: Common, kind: EntityKind) -> Result<Handle> {
|
|
let h = self.new_handle();
|
|
self.ensure_layer(&common.layer);
|
|
let store = self.space_mut(space).ok_or_else(|| DocError::Invalid(format!("no such space {space:?}")))?;
|
|
store.push(Entity { handle: h, common, kind });
|
|
Ok(h)
|
|
}
|
|
|
|
/// Effective visibility of an entity (its own flag, its layer on/thawed, not hidden by object
|
|
/// isolation).
|
|
pub fn is_visible(&self, e: &Entity) -> bool {
|
|
e.common.visible && self.layer(&e.common.layer).is_none_or(Layer::visible) && !self.is_hidden_object(e.handle)
|
|
}
|
|
|
|
/// Whether ISOLATEOBJECTS / HIDEOBJECTS hid `h` for this session.
|
|
pub fn is_hidden_object(&self, h: Handle) -> bool {
|
|
!self.hidden_objects.is_empty() && self.hidden_objects.contains(&h)
|
|
}
|
|
|
|
/// Bounds of all visible entities in a space.
|
|
pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
|
|
let mut b = cadcraft_geom::Bounds2::EMPTY;
|
|
if let Some(store) = self.space(space) {
|
|
for e in store.iter() {
|
|
if self.is_visible(e) {
|
|
b = b.union(&entity_bounds(self, e, 0));
|
|
}
|
|
}
|
|
}
|
|
b
|
|
}
|
|
|
|
/// Total entity count across model and paper space.
|
|
pub fn entity_count(&self) -> usize {
|
|
self.model.len() + self.layouts.iter().map(|l| l.entities.len()).sum::<usize>()
|
|
}
|
|
|
|
/// Next unused anonymous block name with the given prefix (e.g. `*D`).
|
|
pub fn anonymous_block_name(&self, prefix: &str) -> String {
|
|
let mut i = 1usize;
|
|
loop {
|
|
let n = format!("{prefix}{i}");
|
|
if self.block(&n).is_none() || i > 1_000_000 {
|
|
return n;
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
mod anno;
|
|
pub use anno::*;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|