Plot styles: named styles (STB), PLOTSTYLE, STYLESMANAGER and table files (part of #410) (#418)

Layers and objects get a PlotStyle property (ByLayer/ByBlock/name) applied
through named tables (built-in default.stb and monochrome.stb, our own
definitions). DXF keeps the names in the ACAD_PLOTSTYLENAME dictionary of
ACDBPLACEHOLDER objects with group 390 on layers and objects; tables kept in
the drawing travel in a CADCRAFT_PLOTSTYLES XRECORD. PLOTSTYLE sets an object's
or the current (CPLOTSTYLE) style; STYLESMANAGER lists, creates, edits, deletes,
loads and saves tables in our own JSON format (docs/plot-styles.md). A Plot
Style Manager dialog edits a table's styles and the layout's table; the
Properties palette shows the current plot style and table.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 01:21:24 +02:00 committed by GitHub
parent 58a349772e
commit fc4220e9b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1399 additions and 49 deletions

View File

@ -94,6 +94,9 @@ pub struct Common {
pub visible: bool,
pub thickness: f64,
pub extrusion: Vec3,
/// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot
/// style table.
pub plot_style: String,
}
impl Default for Common {
@ -108,6 +111,7 @@ impl Default for Common {
visible: true,
thickness: 0.0,
extrusion: Vec3::Z,
plot_style: "ByLayer".into(),
}
}
}

View File

@ -66,6 +66,9 @@ pub struct Drawing {
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.
@ -115,6 +118,7 @@ impl Drawing {
ucss: Vec::new(),
layer_states: Vec::new(),
groups: Vec::new(),
plot_style_tables: Vec::new(),
constraints: Vec::new(),
parametric: Parametric::default(),
handseed: 0x100,

View File

@ -7,7 +7,12 @@
//!
//! CADCraft's built-in tables are our own definitions, not copies of any vendor's files:
//! [`DEFAULT_CTB`] keeps the object's properties, [`MONOCHROME_CTB`] prints everything black
//! and [`GRAYSCALE_CTB`] prints every colour as the grey of its luminance.
//! and [`GRAYSCALE_CTB`] prints every colour as the grey of its luminance; the named
//! [`DEFAULT_STB`] offers `Normal`, `Black`, `Screened 50%` and `Thick`, and
//! [`MONOCHROME_STB`] prints `Normal` black.
//!
//! Tables are saved as files in our own JSON format (see `docs/plot-styles.md`):
//! [`PlotStyleTable::to_file_text`] and [`PlotStyleTable::from_file_text`].
use cadcraft_color::Rgb;
use serde::{Deserialize, Serialize};
@ -19,8 +24,24 @@ pub const MONOCHROME_CTB: &str = "monochrome.ctb";
/// Built-in colour-dependent table that prints every colour as a grey of its luminance.
pub const GRAYSCALE_CTB: &str = "grayscale.ctb";
/// Built-in named table: `Normal` (object properties), `Black`, `Screened 50%` and `Thick`.
pub const DEFAULT_STB: &str = "default.stb";
/// Built-in named table whose `Normal` style prints black.
pub const MONOCHROME_STB: &str = "monochrome.stb";
/// The names of the built-in tables.
pub const BUILTIN_PLOT_STYLE_TABLES: &[&str] = &[DEFAULT_CTB, MONOCHROME_CTB, GRAYSCALE_CTB];
pub const BUILTIN_PLOT_STYLE_TABLES: &[&str] = &[DEFAULT_CTB, MONOCHROME_CTB, GRAYSCALE_CTB, DEFAULT_STB, MONOCHROME_STB];
/// The style every named table has, which keeps the object's properties unless redefined.
pub const NORMAL_STYLE: &str = "Normal";
/// Most styles a named table may hold.
pub const MAX_NAMED_STYLES: usize = 1024;
/// Largest table file we read, in bytes.
pub const MAX_TABLE_FILE: usize = 4 << 20;
/// Format marker and version of our table files.
const FILE_MARKER: &str = "cadcraftPlotStyleTable";
const FILE_VERSION: u64 = 1;
/// Number of styles in a colour-dependent table (one per index colour 1..=255).
pub const CTB_STYLES: usize = 255;
@ -66,27 +87,76 @@ pub enum FillStyle {
VerticalBars,
}
/// One plot style.
/// One plot style. Serialized fields left at "use the object's" are omitted.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PlotStyle {
pub name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub description: String,
/// Output colour; `None` uses the object's colour.
/// Output colour (`"#rrggbb"` in JSON); `None` uses the object's colour.
#[serde(with = "rgb_hex", skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
/// Print the colour as the grey of its luminance.
#[serde(skip_serializing_if = "is_false")]
pub grayscale: bool,
/// Ink intensity in percent, 0 (white) to 100 (full colour).
#[serde(skip_serializing_if = "is_full")]
pub screening: u8,
/// Linetype name; `None` uses the object's linetype.
#[serde(skip_serializing_if = "Option::is_none")]
pub linetype: Option<String>,
/// Lineweight in hundredths of a millimetre (0..=211); `None` uses the object's lineweight.
#[serde(skip_serializing_if = "Option::is_none")]
pub lineweight: Option<u16>,
#[serde(skip_serializing_if = "is_default")]
pub end: EndStyle,
#[serde(skip_serializing_if = "is_default")]
pub join: JoinStyle,
#[serde(skip_serializing_if = "is_default")]
pub fill: FillStyle,
}
fn is_false(b: &bool) -> bool {
!*b
}
fn is_full(s: &u8) -> bool {
*s >= 100
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
/// `Option<Rgb>` as `"#rrggbb"` (also read from `[r, g, b]`).
mod rgb_hex {
use cadcraft_color::Rgb;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(c: &Option<Rgb>, s: S) -> Result<S::Ok, S::Error> {
match c {
Some(c) => s.serialize_str(&c.hex()),
None => s.serialize_none(),
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Any {
Hex(String),
Triple(u8, u8, u8),
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Rgb>, D::Error> {
Ok(match Option::<Any>::deserialize(d)? {
None => None,
Some(Any::Triple(r, g, b)) => Some(Rgb(r, g, b)),
Some(Any::Hex(h)) => {
Some(Rgb::parse_hex(h.trim().trim_start_matches('#')).ok_or_else(|| serde::de::Error::custom("colour must be \"#rrggbb\""))?)
}
})
}
}
impl Default for PlotStyle {
fn default() -> Self {
PlotStyle::object("")
@ -167,11 +237,39 @@ impl PlotStyleTable {
PlotStyleTable { name: name.into(), description: description.into(), kind: PlotStyleKind::ColorDependent, styles }
}
/// A built-in table by name (case-insensitive; the extension may be left out).
/// A named table holding `Normal` (object properties) followed by `styles`.
pub fn named(name: &str, description: &str, styles: Vec<PlotStyle>) -> PlotStyleTable {
let mut all = vec![PlotStyle::object(NORMAL_STYLE)];
all.extend(styles);
PlotStyleTable { name: name.into(), description: description.into(), kind: PlotStyleKind::Named, styles: all }.sanitized()
}
/// A built-in table by name (case-insensitive; a `.ctb` extension may be left out).
pub fn builtin(name: &str) -> Option<PlotStyleTable> {
let n = name.trim().to_ascii_lowercase();
let n = if n.ends_with(".ctb") { n } else { format!("{n}.ctb") };
let n = if n.ends_with(".ctb") || n.ends_with(".stb") { n } else { format!("{n}.ctb") };
let black = Some(Rgb(0, 0, 0));
match n.as_str() {
DEFAULT_STB => Some(PlotStyleTable::named(
DEFAULT_STB,
"Normal keeps object properties; Black, Screened 50% and Thick",
vec![
PlotStyle { name: "Black".into(), color: black, ..PlotStyle::default() },
PlotStyle { name: "Screened 50%".into(), screening: 50, ..PlotStyle::default() },
PlotStyle { name: "Thick".into(), lineweight: Some(50), ..PlotStyle::default() },
],
)),
MONOCHROME_STB => {
let mut t = PlotStyleTable::named(
MONOCHROME_STB,
"Normal prints black; Screened 50% prints grey",
vec![PlotStyle { name: "Screened 50%".into(), color: black, screening: 50, ..PlotStyle::default() }],
);
if let Some(n) = t.styles.first_mut() {
n.color = black;
}
Some(t)
}
DEFAULT_CTB => Some(PlotStyleTable::color_dependent(DEFAULT_CTB, "Object colours, linetypes and lineweights", |_| None)),
MONOCHROME_CTB => Some(PlotStyleTable::color_dependent(MONOCHROME_CTB, "Every colour prints black", |_| {
Some(PlotStyle { color: Some(Rgb(0, 0, 0)), ..PlotStyle::default() })
@ -200,6 +298,78 @@ impl PlotStyleTable {
pub fn by_name(&self, name: &str) -> Option<&PlotStyle> {
self.styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))
}
/// The table with hostile or inconsistent values fixed: screening at most 100 %,
/// lineweights at most 2.11 mm, names at most 255 characters; a colour-dependent table has
/// exactly 255 styles named `Color_1`..`Color_255`; a named table has unique, non-empty
/// style names (at most [`MAX_NAMED_STYLES`]) starting with `Normal`.
pub fn sanitized(mut self) -> PlotStyleTable {
let cut = |s: &str| s.chars().take(255).collect::<String>();
self.name = cut(self.name.trim());
self.description = cut(&self.description);
for s in &mut self.styles {
s.name = cut(s.name.trim());
s.description = cut(&s.description);
s.screening = s.screening.min(100);
s.lineweight = s.lineweight.map(|w| w.min(211));
s.linetype = s.linetype.take().map(|l| cut(l.trim())).filter(|l| !l.is_empty());
}
match self.kind {
PlotStyleKind::ColorDependent => {
self.styles.truncate(CTB_STYLES);
while self.styles.len() < CTB_STYLES {
self.styles.push(PlotStyle::object(""));
}
for (i, s) in self.styles.iter_mut().enumerate() {
s.name = format!("Color_{}", i + 1);
}
}
PlotStyleKind::Named => {
let mut seen = std::collections::HashSet::new();
self.styles.retain(|s| !s.name.is_empty() && seen.insert(s.name.to_lowercase()));
self.styles.truncate(MAX_NAMED_STYLES);
match self.styles.iter().position(|s| s.name.eq_ignore_ascii_case(NORMAL_STYLE)) {
Some(0) => {}
Some(i) => {
let n = self.styles.remove(i);
self.styles.insert(0, n);
}
None => {
self.styles.insert(0, PlotStyle::object(NORMAL_STYLE));
self.styles.truncate(MAX_NAMED_STYLES);
}
}
if let Some(n) = self.styles.first_mut() {
n.name = NORMAL_STYLE.into();
}
}
}
self
}
/// The table as a file in our own format: pretty-printed JSON with a format marker.
pub fn to_file_text(&self) -> String {
let mut v = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
if let Some(o) = v.as_object_mut() {
o.insert(FILE_MARKER.into(), FILE_VERSION.into());
}
serde_json::to_string_pretty(&v).unwrap_or_default()
}
/// Read a table file written by [`PlotStyleTable::to_file_text`] (sanitized).
pub fn from_file_text(text: &str) -> std::result::Result<PlotStyleTable, String> {
if text.len() > MAX_TABLE_FILE {
return Err("the plot style table file is too large".into());
}
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("not a CADCraft plot style table: {e}"))?;
match v.get(FILE_MARKER).and_then(serde_json::Value::as_u64) {
Some(FILE_VERSION) => {}
Some(n) => return Err(format!("plot style table format version {n} is newer than this CADCraft reads")),
None => return Err(format!("not a CADCraft plot style table (no `{FILE_MARKER}`)")),
}
let t: PlotStyleTable = serde_json::from_value(v).map_err(|e| format!("bad plot style table: {e}"))?;
Ok(t.sanitized())
}
}
/// Whether a page setup's table name means "no plot style table".
@ -208,13 +378,27 @@ pub fn no_plot_style_table(name: &str) -> bool {
n.is_empty() || n.eq_ignore_ascii_case("none")
}
/// The plot style table a page setup names, if it is one we know; `None` for no table
/// (empty or "None") and for tables we don't have.
pub fn plot_style_table(_d: &crate::Drawing, name: &str) -> Option<PlotStyleTable> {
/// The plot style table a page setup names, if it is one we know: the drawing's own tables
/// first, then the built-in ones. `None` for no table (empty or "None") and for tables we don't
/// have.
pub fn plot_style_table(d: &crate::Drawing, name: &str) -> Option<PlotStyleTable> {
if no_plot_style_table(name) {
return None;
}
PlotStyleTable::builtin(name)
let n = name.trim();
d.plot_style_tables.iter().find(|t| t.name.eq_ignore_ascii_case(n)).cloned().or_else(|| PlotStyleTable::builtin(n))
}
/// The names of every table a drawing can use: its own, then the built-in ones it doesn't
/// shadow.
pub fn plot_style_table_names(d: &crate::Drawing) -> Vec<String> {
let mut v: Vec<String> = d.plot_style_tables.iter().map(|t| t.name.clone()).collect();
for b in BUILTIN_PLOT_STYLE_TABLES {
if !v.iter().any(|n| n.eq_ignore_ascii_case(b)) {
v.push((*b).to_string());
}
}
v
}
#[cfg(test)]
@ -223,9 +407,9 @@ mod tests {
#[test]
fn builtins_are_complete_color_dependent_tables() {
for n in BUILTIN_PLOT_STYLE_TABLES {
for n in [DEFAULT_CTB, MONOCHROME_CTB, GRAYSCALE_CTB] {
let t = PlotStyleTable::builtin(n).unwrap();
assert_eq!(&t.name, n);
assert_eq!(t.name, n);
assert!(t.is_color_dependent());
assert_eq!(t.styles.len(), CTB_STYLES);
assert_eq!(t.by_color(1).unwrap().name, "Color_1");
@ -251,6 +435,63 @@ mod tests {
assert!(PlotStyle::default().keeps_color() && !half.keeps_color());
}
#[test]
fn named_builtins_and_sanitizing() {
let t = PlotStyleTable::builtin("default.stb").unwrap();
assert!(!t.is_color_dependent());
assert_eq!(t.styles.first().unwrap().name, NORMAL_STYLE);
assert!(t.by_name("black").is_some() && t.by_color(1).is_none());
assert_eq!(PlotStyleTable::builtin(MONOCHROME_STB).unwrap().by_name("Normal").unwrap().color, Some(Rgb(0, 0, 0)));
// Hostile values are clamped; names fixed.
let bad = PlotStyleTable {
name: " x.stb ".into(),
kind: PlotStyleKind::Named,
styles: vec![
PlotStyle { name: "A".into(), screening: 250, lineweight: Some(9999), ..PlotStyle::default() },
PlotStyle { name: "a".into(), ..PlotStyle::default() },
PlotStyle { name: " ".into(), ..PlotStyle::default() },
],
..PlotStyleTable::default()
}
.sanitized();
assert_eq!(bad.name, "x.stb");
assert_eq!(bad.styles.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Normal", "A"]);
assert_eq!((bad.styles[1].screening, bad.styles[1].lineweight), (100, Some(211)));
let short = PlotStyleTable { styles: vec![PlotStyle::object("zz")], ..PlotStyleTable::default() }.sanitized();
assert_eq!(short.styles.len(), CTB_STYLES);
assert_eq!(short.styles[0].name, "Color_1");
}
#[test]
fn file_format_round_trip() {
for name in BUILTIN_PLOT_STYLE_TABLES {
let t = PlotStyleTable::builtin(name).unwrap();
let text = t.to_file_text();
assert!(text.contains("\"cadcraftPlotStyleTable\": 1"));
assert_eq!(PlotStyleTable::from_file_text(&text).unwrap(), t, "{name}");
}
let mono = PlotStyleTable::builtin(MONOCHROME_CTB).unwrap().to_file_text();
assert!(mono.contains("\"color\": \"#000000\""), "colours as hex");
assert!(!mono.contains("screening"), "defaults are left out");
assert!(PlotStyleTable::from_file_text("{}").is_err());
assert!(PlotStyleTable::from_file_text("{\"cadcraftPlotStyleTable\": 9}").is_err());
assert!(PlotStyleTable::from_file_text("not json").is_err());
let rgb: PlotStyle = serde_json::from_value(serde_json::json!({"name": "x", "color": [1, 2, 3]})).unwrap();
assert_eq!(rgb.color, Some(Rgb(1, 2, 3)));
assert!(serde_json::from_value::<PlotStyle>(serde_json::json!({"color": "#zz0000"})).is_err());
}
#[test]
fn drawing_tables_shadow_builtins() {
let mut d = crate::Drawing::new_metric();
assert!(plot_style_table(&d, "monochrome.ctb").unwrap().by_color(1).unwrap().color.is_some());
d.plot_style_tables.push(PlotStyleTable::color_dependent("Monochrome.ctb", "", |_| None));
assert!(plot_style_table(&d, "monochrome.ctb").unwrap().by_color(1).unwrap().color.is_none());
let names = plot_style_table_names(&d);
assert_eq!(names.iter().filter(|n| n.eq_ignore_ascii_case("monochrome.ctb")).count(), 1);
assert!(names.contains(&"default.stb".to_string()));
}
#[test]
fn json_round_trip_with_defaults() {
let t = PlotStyleTable::builtin(GRAYSCALE_CTB).unwrap();

View File

@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::new(prompts::LayerM::default()))),
CommandSpec::new("layer.new", "New Layer", run_new).params("{name, color?, linetype?, lineweight? (mm), current?: bool}"),
CommandSpec::new("layer.set", "Set Layer Properties", run_set)
.params("{name, on?, frozen?, locked?, plot?, color?, linetype?, lineweight?, transparency?, description?, newVpFreeze?, newName?}"),
.params("{name, on?, frozen?, locked?, plot?, color?, linetype?, lineweight?, transparency?, description?, newVpFreeze?, plotStyle? (named plot style), newName?}"),
CommandSpec::new("layer.current", "Make Current", run_current)
.menu(&["Format", "Layer Tools", "Make Current"])
.alias(&["clayer"])
@ -73,7 +73,7 @@ fn layer_json(l: &Layer, current: bool) -> Value {
"name": l.name, "on": l.on, "frozen": l.frozen, "locked": l.locked, "plot": l.plot,
"color": l.color.name(), "colorRgb": l.color.resolve(Color::Index(7), Color::Index(7)).hex(),
"linetype": l.linetype, "lineweight": l.lineweight.name(), "transparency": l.transparency,
"description": l.description, "current": current, "newVpFreeze": l.vp_freeze_new,
"description": l.description, "current": current, "newVpFreeze": l.vp_freeze_new, "plotStyle": l.plot_style,
})
}
@ -132,6 +132,12 @@ fn apply(l: &mut Layer, p: &Value) -> Result<()> {
if let Some(v) = p.get("plot").and_then(Value::as_bool) {
l.plot = v;
}
if let Some(v) = str_param(p, "plotStyle").map(str::trim) {
if v.is_empty() || v.eq_ignore_ascii_case("bylayer") || v.eq_ignore_ascii_case("byblock") {
return Err(bad("layer", "a layer's plot style is a style name (such as Normal)"));
}
l.plot_style = v.chars().take(255).collect();
}
if let Some(c) = p.get("color") {
let c = c
.as_str()

View File

@ -12,6 +12,7 @@ use super::file::{base64_encode, io};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod plotstyle;
mod prompts;
pub fn specs() -> Vec<CommandSpec> {
@ -79,6 +80,9 @@ pub fn specs() -> Vec<CommandSpec> {
.noundo(),
CommandSpec::new("pspace", "Paper Space", run_pspace).alias(&["ps"]).noundo(),
]
.into_iter()
.chain(plotstyle::specs())
.collect()
}
// ---------- model space through viewports ----------

View File

@ -0,0 +1,347 @@
//! Plot styles: PLOTSTYLE (an object's or the current named plot style) and STYLESMANAGER (the
//! plot style tables: list, view, create, edit, delete, load and save in our own file format).
//!
//! Tables the drawing keeps live in `Drawing::plot_style_tables` and are saved with it; they
//! shadow built-in tables of the same name. Editing a built-in table stores an edited copy in
//! the drawing.
use cadcraft_doc::{Drawing, PlotStyle, PlotStyleKind, PlotStyleTable};
use serde_json::{Value, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("plotstyle", "Plot Style...", run_plotstyle)
.menu(&["Format", "Plot Style..."])
.params(
"{name?: \"ByLayer\"|\"ByBlock\"|style name, handles?|handle? (default: the selection; nothing selected sets the current plot style for new objects, CPLOTSTYLE)} (no name: reports the current style and the named styles)",
),
CommandSpec::new("stylesmanager", "Plot Style Manager...", run_stylesmanager)
.menu(&["File", "Plot Styles..."])
.params(
"{action?: list (default)|get|new|set|delete|load|save, name?: table (\"x.ctb\"/\"x.stb\"), kind?: colorDependent|named (new), from?: table to copy (new), description?, style?: {name, color?: \"#rrggbb\"|null, grayscale?, screening?: 0-100, linetype?: name|null, lineweight?: mm|null, end?, join?, fill?, description?} (set), removeStyle?: name (set, named tables), path? (load/save; save without one returns `text`), text? (load)}",
),
]
}
// ---------- PLOTSTYLE ----------
/// The style names an object can be given: those of the named tables the layouts use, else of
/// every named table the drawing can use.
fn named_styles(d: &Drawing) -> Vec<String> {
let mut used: Vec<PlotStyleTable> =
d.layouts.iter().filter_map(|l| cadcraft_doc::plot_style_table(d, &l.page.plot_style_table)).filter(|t| !t.is_color_dependent()).collect();
if used.is_empty() {
used = cadcraft_doc::plot_style_table_names(d)
.iter()
.filter_map(|n| cadcraft_doc::plot_style_table(d, n))
.filter(|t| !t.is_color_dependent())
.collect();
}
let mut out: Vec<String> = Vec::new();
for s in used.iter().flat_map(|t| t.styles.iter()) {
if !out.iter().any(|n| n.eq_ignore_ascii_case(&s.name)) {
out.push(s.name.clone());
}
}
out
}
/// A plot style property value: `ByLayer`, `ByBlock` or a style name (at most 255 characters).
fn style_value(cmd: &str, v: &str) -> Result<String> {
let v = v.trim();
if v.is_empty() {
return Err(bad(cmd, "the plot style name is empty"));
}
Ok(if v.eq_ignore_ascii_case("bylayer") {
"ByLayer".into()
} else if v.eq_ignore_ascii_case("byblock") {
"ByBlock".into()
} else {
v.chars().take(255).collect()
})
}
fn run_plotstyle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plotstyle";
let Some(name) = p.get("name") else {
let d = s.doc()?;
return Ok(json!({ "current": d.header.str("CPLOTSTYLE", "ByLayer"), "styles": named_styles(d) }));
};
let name = style_value(cmd, name.as_str().ok_or_else(|| bad(cmd, "`name` must be a plot style name"))?)?;
let handles = targets(s, p)?;
if handles.is_empty() {
s.doc_mut()?.header.set_str("CPLOTSTYLE", &name);
return Ok(json!({ "current": name }));
}
let mut changed = 0usize;
for h in &handles {
if super::super::curves::is_locked(s, *h) {
continue;
}
let d = s.doc_mut()?;
if d.entity(*h).is_none() {
return Err(bad(cmd, format!("no object {}", h.hex())));
}
d.modify_entity(*h, |e| e.common.plot_style = name.clone())?;
changed += 1;
}
Ok(json!({ "changed": changed, "plotStyle": name }))
}
// ---------- STYLESMANAGER ----------
fn table_summary(d: &Drawing, t: &PlotStyleTable) -> Value {
let in_drawing = d.plot_style_tables.iter().any(|x| x.name.eq_ignore_ascii_case(&t.name));
json!({
"name": t.name,
"description": t.description,
"kind": if t.is_color_dependent() { "colorDependent" } else { "named" },
"styles": t.styles.len(),
"inDrawing": in_drawing,
"builtin": PlotStyleTable::builtin(&t.name).is_some() && !in_drawing,
})
}
fn table_name(cmd: &str, p: &Value) -> Result<String> {
let n = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` (the table) is required"))?;
Ok(n.chars().take(255).collect())
}
/// The table `name` as the drawing sees it.
fn find_table(cmd: &str, d: &Drawing, name: &str) -> Result<PlotStyleTable> {
cadcraft_doc::plot_style_table(d, name).ok_or_else(|| bad(cmd, format!("no plot style table `{name}`")))
}
/// Store `t` in the drawing, replacing a table of the same name.
fn store(d: &mut Drawing, t: PlotStyleTable) {
match d.plot_style_tables.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&t.name)) {
Some(x) => *x = t,
None => d.plot_style_tables.push(t),
}
}
/// Apply the fields present in `v` to style `st`.
fn edit_style(cmd: &str, st: &mut PlotStyle, v: &Value) -> Result<()> {
let opt_str = |k: &str| -> Result<Option<Option<String>>> {
match v.get(k) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(s)) if s.trim().is_empty() || s.eq_ignore_ascii_case("object") => Ok(Some(None)),
Some(Value::String(s)) => Ok(Some(Some(s.trim().chars().take(255).collect()))),
Some(_) => Err(bad(cmd, format!("`{k}` must be a string or null"))),
}
};
if let Some(c) = opt_str("color")? {
st.color = match c {
None => None,
Some(h) => {
Some(cadcraft_color::Rgb::parse_hex(h.trim_start_matches('#')).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or null"))?)
}
};
}
if let Some(lt) = opt_str("linetype")? {
st.linetype = lt;
}
if let Some(d) = opt_str("description")? {
st.description = d.unwrap_or_default();
}
if let Some(g) = v.get("grayscale") {
st.grayscale = g.as_bool().ok_or_else(|| bad(cmd, "`grayscale` must be true or false"))?;
}
if let Some(sc) = v.get("screening") {
let n = sc.as_f64().filter(|n| n.is_finite() && (0.0..=100.0).contains(n)).ok_or_else(|| bad(cmd, "`screening` must be 0 to 100"))?;
st.screening = n.round() as u8;
}
match v.get("lineweight") {
None => {}
Some(Value::Null) => st.lineweight = None,
Some(Value::String(s)) if s.eq_ignore_ascii_case("object") => st.lineweight = None,
Some(w) => {
let mm = w
.as_f64()
.filter(|n| n.is_finite() && (0.0..=2.11).contains(n))
.ok_or_else(|| bad(cmd, "`lineweight` must be 0 to 2.11 mm or null"))?;
st.lineweight = Some((mm * 100.0).round() as u16);
}
}
fn field<T: serde::de::DeserializeOwned>(cmd: &str, v: &Value, k: &str, msg: &str) -> Result<Option<T>> {
v.get(k).map(|x| serde_json::from_value(x.clone()).map_err(|_| bad(cmd, msg))).transpose()
}
if let Some(e) = field(cmd, v, "end", "`end` must be object, butt, square, round or diamond")? {
st.end = e;
}
if let Some(j) = field(cmd, v, "join", "`join` must be object, miter, bevel, round or diamond")? {
st.join = j;
}
if let Some(f) = field(
cmd,
v,
"fill",
"`fill` must be object, solid, checkerboard, crosshatch, diamonds, horizontalBars, slantLeft, slantRight, squareDots or verticalBars",
)? {
st.fill = f;
}
Ok(())
}
fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "stylesmanager";
let action = str_param(p, "action").unwrap_or("list").to_ascii_lowercase();
match action.as_str() {
"list" => {
let d = s.doc()?;
let tables: Vec<Value> = cadcraft_doc::plot_style_table_names(d)
.iter()
.filter_map(|n| cadcraft_doc::plot_style_table(d, n))
.map(|t| table_summary(d, &t))
.collect();
Ok(json!({ "tables": tables }))
}
"get" => {
let d = s.doc()?;
let t = find_table(cmd, d, &table_name(cmd, p)?)?;
let mut v = serde_json::to_value(&t).unwrap_or(Value::Null);
if let (Some(o), Value::Object(sum)) = (v.as_object_mut(), table_summary(d, &t)) {
o.insert("inDrawing".into(), sum.get("inDrawing").cloned().unwrap_or(Value::Null));
o.insert("builtin".into(), sum.get("builtin").cloned().unwrap_or(Value::Null));
}
Ok(v)
}
"new" => {
let mut name = table_name(cmd, p)?;
let d = s.doc()?;
let mut t = match str_param(p, "from") {
Some(from) => find_table(cmd, d, from)?,
None => {
let named = match str_param(p, "kind").map(str::to_ascii_lowercase).as_deref() {
None if name.to_ascii_lowercase().ends_with(".stb") => true,
None | Some("colordependent" | "ctb") => false,
Some("named" | "stb") => true,
Some(_) => return Err(bad(cmd, "`kind` must be colorDependent or named")),
};
if named { PlotStyleTable::named("", "", Vec::new()) } else { PlotStyleTable::color_dependent("", "", |_| None) }
}
};
let ext = if t.is_color_dependent() { ".ctb" } else { ".stb" };
let lower = name.to_ascii_lowercase();
if !lower.ends_with(ext) {
if lower.ends_with(".ctb") || lower.ends_with(".stb") {
return Err(bad(cmd, format!("a {} table's name ends in {ext}", if ext == ".ctb" { "colour-dependent" } else { "named" })));
}
name.push_str(ext);
}
if d.plot_style_tables.iter().any(|x| x.name.eq_ignore_ascii_case(&name)) {
return Err(bad(cmd, format!("plot style table `{name}` already exists")));
}
t.name = name;
if let Some(desc) = str_param(p, "description") {
t.description = desc.chars().take(255).collect();
}
let t = t.sanitized();
let out = table_summary(d, &t);
store(s.doc_mut()?, t);
Ok(out)
}
"set" => {
let name = table_name(cmd, p)?;
let mut t = find_table(cmd, s.doc()?, &name)?;
if let Some(desc) = str_param(p, "description") {
t.description = desc.chars().take(255).collect();
}
if let Some(rm) = str_param(p, "removeStyle") {
if t.is_color_dependent() {
return Err(bad(cmd, "a colour-dependent table always has 255 styles"));
}
if rm.trim().eq_ignore_ascii_case(cadcraft_doc::NORMAL_STYLE) {
return Err(bad(cmd, "the Normal style can't be removed"));
}
let before = t.styles.len();
t.styles.retain(|x| !x.name.eq_ignore_ascii_case(rm.trim()));
if t.styles.len() == before {
return Err(bad(cmd, format!("`{name}` has no style `{rm}`")));
}
}
if let Some(v) = p.get("style") {
let sname = v.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty());
let sname = sname.ok_or_else(|| bad(cmd, "`style.name` is required"))?;
let idx = match t.styles.iter().position(|x| x.name.eq_ignore_ascii_case(sname)) {
Some(i) => i,
None if t.kind == PlotStyleKind::Named => {
if t.styles.len() >= cadcraft_doc::MAX_NAMED_STYLES {
return Err(bad(cmd, "the table has too many styles"));
}
t.styles.push(PlotStyle::object(&sname.chars().take(255).collect::<String>()));
t.styles.len() - 1
}
None => return Err(bad(cmd, format!("`{name}` has no style `{sname}` (colour-dependent styles are Color_1 to Color_255)"))),
};
if let Some(st) = t.styles.get_mut(idx) {
edit_style(cmd, st, v)?;
}
}
let t = t.sanitized();
let out = serde_json::to_value(&t).unwrap_or(Value::Null);
store(s.doc_mut()?, t);
Ok(out)
}
"delete" => {
let name = table_name(cmd, p)?;
let d = s.doc_mut()?;
let before = d.plot_style_tables.len();
d.plot_style_tables.retain(|t| !t.name.eq_ignore_ascii_case(&name));
if d.plot_style_tables.len() == before {
return Err(bad(cmd, format!("the drawing keeps no plot style table `{name}` (built-in tables can't be deleted)")));
}
Ok(json!({ "deleted": name }))
}
"load" => {
let text = match (str_param(p, "text"), str_param(p, "path")) {
(Some(t), _) => t.to_string(),
(None, Some(_path)) => {
#[cfg(not(target_arch = "wasm32"))]
{
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
}
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
}
#[cfg(target_arch = "wasm32")]
return Err(bad(cmd, "files can't be read here: pass `text`"));
}
(None, None) => return Err(bad(cmd, "`path` or `text` is required")),
};
let mut t = PlotStyleTable::from_file_text(&text).map_err(|e| bad(cmd, e))?;
if let Some(n) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
t.name = n.chars().take(255).collect();
}
if t.name.is_empty() {
return Err(bad(cmd, "the table has no name: pass `name`"));
}
let out = table_summary(s.doc()?, &t);
store(s.doc_mut()?, t);
Ok(out)
}
"save" => {
let name = table_name(cmd, p)?;
let text = find_table(cmd, s.doc()?, &name)?.to_file_text();
match str_param(p, "path") {
None => Ok(json!({ "name": name, "text": text })),
Some(_path) => {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{_path}.cadcraft-tmp");
std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
Ok(json!({ "name": name, "path": _path, "bytes": text.len() }))
}
#[cfg(target_arch = "wasm32")]
Err(bad(cmd, "files can't be written here: leave out `path` to get `text`"))
}
}
}
other => Err(bad(cmd, format!("unknown action `{other}` (list, get, new, set, delete, load, save)"))),
}
}

View File

@ -601,6 +601,7 @@ impl Session {
linetype: h.str("CELTYPE", "ByLayer"),
lineweight: cadcraft_doc::Lineweight::from_dxf(h.i64("CELWEIGHT", -1) as i16),
ltscale: h.f64("CELTSCALE", 1.0),
plot_style: h.str("CPLOTSTYLE", "ByLayer"),
..Common::default()
}
}

View File

@ -18,3 +18,89 @@ fn pagesetup_sets_table_and_display_flag() {
s.execute("pagesetup", &json!({"layout": "Layout1", "displayPlotStyles": false})).unwrap();
assert!(!s.doc().unwrap().layout("Layout1").unwrap().page.show_plot_styles);
}
#[test]
fn plotstyle_sets_objects_and_the_current_style() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
let handle = s.doc().unwrap().model.last().unwrap().handle;
let h = handle.hex();
// No name: a report.
let r = s.execute("plotstyle", &json!({})).unwrap();
assert_eq!(r["current"], "ByLayer");
assert!(r["styles"].as_array().unwrap().iter().any(|v| v == "Normal"));
// An object's style.
let r = s.execute("plotstyle", &json!({"name": "Black", "handles": [h]})).unwrap();
assert_eq!(r["changed"], 1);
assert_eq!(s.doc().unwrap().entity(handle).unwrap().common.plot_style, "Black");
// Nothing selected: the current style for new objects.
s.execute("select", &json!({"handles": []})).ok();
s.execute("plotstyle", &json!({"name": "byblock"})).unwrap();
s.execute("line", &json!({"points": [[0, 1], [10, 1]]})).unwrap();
let h2 = s.doc().unwrap().model.last().unwrap().handle;
assert_eq!(s.doc().unwrap().entity(h2).unwrap().common.plot_style, "ByBlock");
assert!(s.execute("plotstyle", &json!({"name": " "})).is_err());
assert!(s.execute("plotstyle", &json!({"name": 5})).is_err());
// A layer's plot style.
s.execute("layer.set", &json!({"name": "0", "plotStyle": "Thick"})).unwrap();
assert_eq!(s.doc().unwrap().layer("0").unwrap().plot_style, "Thick");
assert!(s.execute("layer.set", &json!({"name": "0", "plotStyle": "ByLayer"})).is_err());
}
#[test]
fn stylesmanager_creates_edits_saves_and_loads_tables() {
let mut s = Session::new();
let list = s.execute("stylesmanager", &json!({})).unwrap();
let names: Vec<&str> = list["tables"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).collect();
assert!(names.contains(&"monochrome.ctb") && names.contains(&"default.stb"), "{names:?}");
// A new named table gets its extension and Normal.
let r = s.execute("stylesmanager", &json!({"action": "new", "name": "office", "kind": "named"})).unwrap();
assert_eq!(r["name"], "office.stb");
let r = s
.execute(
"stylesmanager",
&json!({"action": "set", "name": "office.stb", "style": {"name": "Pen 5", "color": "#00ff00", "screening": 40, "lineweight": 0.35, "linetype": "HIDDEN", "end": "round"}}),
)
.unwrap();
let pen = r["styles"].as_array().unwrap().iter().find(|v| v["name"] == "Pen 5").unwrap().clone();
assert_eq!(
(pen["color"].as_str(), pen["screening"].as_u64(), pen["lineweight"].as_u64(), pen["end"].as_str()),
(Some("#00ff00"), Some(40), Some(35), Some("round"))
);
// Bad edits are refused.
for bad in [
json!({"name": "Pen 5", "screening": 101}),
json!({"name": "Pen 5", "color": "green"}),
json!({"name": "Pen 5", "lineweight": 9}),
json!({"name": "Pen 5", "join": "zigzag"}),
] {
assert!(s.execute("stylesmanager", &json!({"action": "set", "name": "office.stb", "style": bad})).is_err(), "{bad}");
}
assert!(s.execute("stylesmanager", &json!({"action": "set", "name": "office.stb", "removeStyle": "Normal"})).is_err());
// Editing a built-in keeps a copy in the drawing; colour-dependent styles are Color_n.
s.execute("stylesmanager", &json!({"action": "set", "name": "monochrome.ctb", "style": {"name": "Color_1", "color": null}})).unwrap();
assert_eq!(s.doc().unwrap().plot_style_tables.len(), 2);
assert!(s.execute("stylesmanager", &json!({"action": "set", "name": "monochrome.ctb", "style": {"name": "Pen"}})).is_err());
// Save as text, delete, load back.
let text = s.execute("stylesmanager", &json!({"action": "save", "name": "office.stb"})).unwrap()["text"].as_str().unwrap().to_string();
s.execute("stylesmanager", &json!({"action": "delete", "name": "office.stb"})).unwrap();
assert!(s.execute("stylesmanager", &json!({"action": "get", "name": "office.stb"})).is_err());
s.execute("stylesmanager", &json!({"action": "load", "text": text})).unwrap();
let got = s.execute("stylesmanager", &json!({"action": "get", "name": "office.stb"})).unwrap();
assert_eq!(got["inDrawing"], true);
assert!(got["styles"].as_array().unwrap().iter().any(|v| v["name"] == "Pen 5"));
assert!(s.execute("stylesmanager", &json!({"action": "load", "text": "{\"x\": 1}"})).is_err());
assert!(s.execute("stylesmanager", &json!({"action": "delete", "name": "grayscale.ctb"})).is_err(), "built-ins stay");
assert!(s.execute("stylesmanager", &json!({"action": "explode"})).is_err());
#[cfg(not(target_arch = "wasm32"))]
{
let path = std::env::temp_dir().join(format!("cadcraft-pstyle-{}.stb.json", std::process::id()));
let p = path.to_string_lossy().to_string();
s.execute("stylesmanager", &json!({"action": "save", "name": "office.stb", "path": p})).unwrap();
s.execute("stylesmanager", &json!({"action": "load", "path": p, "name": "copy.stb"})).unwrap();
assert!(s.execute("stylesmanager", &json!({"action": "get", "name": "copy.stb"})).is_ok());
let _ = std::fs::remove_file(&path);
}
// Undo takes a table edit back.
s.execute("undo", &json!({})).unwrap();
}

View File

@ -5,13 +5,13 @@
//! constraints).
//!
//! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the
//! named-object-dictionary entries `CADCRAFT_CONSTRAINTS` and `CADCRAFT_LAYERSTATES`
//! (XRECORDs). Other readers keep or ignore them.
//! named-object-dictionary entries `CADCRAFT_CONSTRAINTS`, `CADCRAFT_LAYERSTATES` and
//! `CADCRAFT_PLOTSTYLES` (XRECORDs). Other readers keep or ignore them.
use std::collections::HashMap;
use cadcraft_color::Color;
use cadcraft_doc::{AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric};
use cadcraft_doc::{AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable};
use cadcraft_dxf::Tag;
use cadcraft_render::Arrowhead;
use serde::{Deserialize, Serialize};
@ -23,6 +23,14 @@ pub(crate) const APP: &str = "CADCRAFT";
pub(crate) const CONSTRAINTS_KEY: &str = "CADCRAFT_CONSTRAINTS";
/// Named-object-dictionary key of the saved layer states XRECORD.
pub(crate) const LAYER_STATES_KEY: &str = "CADCRAFT_LAYERSTATES";
/// Named-object-dictionary key of the drawing's plot style tables XRECORD.
pub(crate) const PLOT_STYLES_KEY: &str = "CADCRAFT_PLOTSTYLES";
/// Named-object-dictionary key of the plot style names dictionary (DXF Reference): its entries
/// name `ACDBPLACEHOLDER` objects, and layers and objects point at them with group 390.
pub(crate) const PLOT_STYLE_NAMES_KEY: &str = "ACAD_PLOTSTYLENAME";
/// Most plot style tables and plot style names read from a file.
pub(crate) const MAX_PLOT_STYLE_TABLES: usize = 1000;
pub(crate) const MAX_PLOT_STYLE_NAMES: usize = 100_000;
/// Caps for hostile input.
pub(crate) const MAX_XDATA_ITEMS: usize = 4096;
@ -522,6 +530,36 @@ pub(crate) fn parse_layer_states(text: &str) -> Option<Vec<LayerState>> {
Some(p.states)
}
/// The drawing's plot style tables stored in the `CADCRAFT_PLOTSTYLES` XRECORD.
#[derive(Serialize, Deserialize)]
struct PlotStylesPayload {
version: u32,
#[serde(default)]
tables: Vec<PlotStyleTable>,
}
/// JSON chunks for the plot style tables XRECORD, or `None` when the drawing keeps none.
pub(crate) fn plot_style_chunks(tables: &[PlotStyleTable]) -> Option<Vec<String>> {
if tables.is_empty() {
return None;
}
let json = serde_json::to_string(&PlotStylesPayload { version: 1, tables: tables.to_vec() }).ok()?;
Some(json_chunks(&json))
}
/// Parse the plot style tables XRECORD text (tables sanitized); `None` when it is not a
/// payload we understand.
pub(crate) fn parse_plot_styles(text: &str) -> Option<Vec<PlotStyleTable>> {
if text.len() > MAX_PAYLOAD {
return None;
}
let p: PlotStylesPayload = serde_json::from_str(text).ok()?;
if p.version != 1 || p.tables.len() > MAX_PLOT_STYLE_TABLES {
return None;
}
Some(p.tables.into_iter().map(PlotStyleTable::sanitized).filter(|t| !t.name.is_empty()).collect())
}
/// Parse the constraint XRECORD text; `None` when it is not a payload we understand.
pub(crate) fn parse_constraints(text: &str) -> Option<(Vec<Constraint>, Parametric)> {
if text.len() > MAX_PAYLOAD {

View File

@ -810,6 +810,62 @@ struct Rx {
table_style_fix: Vec<(Handle, String)>,
/// Upper-case xref block name → the external drawing's path (BLOCK group 1).
xref_paths: HashMap<String, String>,
/// Plot style name placeholder handle (upper case) → plot style name (`ACAD_PLOTSTYLENAME`).
pstyles: HashMap<String, String>,
}
/// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the
/// named object dictionary (the first object), by placeholder handle.
fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap<String, String> {
let mut out = HashMap::new();
let Some(objects) = secs.iter().find(|s| s.name == "OBJECTS") else { return out };
let recs = records(&objects.tags);
let entries = |tags: &[Tag]| {
let mut v: Vec<(String, String)> = Vec::new();
let mut name: Option<String> = None;
for t in tags {
match t.code {
3 => name = Some(t.str()),
350 | 360 => {
if let Some(n) = name.take() {
v.push((n, t.str().trim().to_ascii_uppercase()));
}
}
_ => {}
}
}
v
};
let Some((_, root)) = recs.iter().find(|(k, _)| k == "DICTIONARY") else { return out };
let Some((_, dict)) = entries(root).into_iter().find(|(n, _)| n.eq_ignore_ascii_case(crate::dxf_ext::PLOT_STYLE_NAMES_KEY)) else {
return out;
};
let is_dict = |k: &str| k == "DICTIONARY" || k == "ACDBDICTIONARYWDFLT";
let Some((_, tags)) = recs.iter().find(|(k, tg)| is_dict(k) && T(tg).s(5).is_some_and(|h| h.trim().eq_ignore_ascii_case(&dict))) else {
return out;
};
for (n, h) in entries(tags).into_iter().take(crate::dxf_ext::MAX_PLOT_STYLE_NAMES) {
if !n.trim().is_empty() {
out.insert(h, n.chars().take(255).collect());
}
}
out
}
/// The named plot style an entity record points at (group 390 among its AcDbEntity groups).
fn entity_plot_style(tags: &[Tag], rx: &Rx) -> Option<String> {
let mut in_entity = false;
for t in tags {
if t.code == 100 {
if in_entity {
return None;
}
in_entity = t.str() == "AcDbEntity";
} else if t.code == 390 && in_entity {
return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned();
}
}
None
}
/// Dimension overrides and associativity, table references: data of an entity record that
@ -851,6 +907,11 @@ fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing, rx: &mut Rx) ->
let paper = t.i(67) == Some(1);
let handle = t.s(5).and_then(|h| Handle::parse_hex(&h));
let mut kindent = entity(kind, tags);
if let Some((c, _)) = kindent.as_mut()
&& let Some(n) = entity_plot_style(tags, rx)
{
c.plot_style = n;
}
// More entities from this record (the faces of a polyface or polygon mesh).
let mut extra: Vec<EntityKind> = Vec::new();
i += 1;
@ -1131,6 +1192,9 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
if let Some(tc) = t.i(420) {
l.color = Color::True(Rgb::from_u32(tc as u32));
}
if let Some(n) = t.s(390).and_then(|h| rx.pstyles.get(&h.trim().to_ascii_uppercase())) {
l.plot_style = n.clone();
}
let alpha = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_TRANSPARENCY_APP).iter().find(|x| x.code == 1071);
if let Some(pct) = alpha.and_then(|x| crate::dxf_ext::transparency_from_dxf(x.i64())) {
l.transparency = pct;
@ -1296,7 +1360,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
}
let mut d = Drawing::new_imperial();
d.layouts.clear();
let mut rx = Rx::default();
let mut rx = Rx { pstyles: plot_style_names(&secs), ..Rx::default() };
let mut blocks: Vec<(String, Vec3, String, Vec<(Option<String>, bool, Entity)>, bool)> = Vec::new();
let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new();
let mut entities = Vec::new();
@ -1547,6 +1611,10 @@ impl Objects {
if let Some(states) = self.xrecord_text(crate::dxf_ext::LAYER_STATES_KEY).and_then(|t| crate::dxf_ext::parse_layer_states(&t)) {
d.layer_states = states;
}
// The drawing's plot style tables.
if let Some(tables) = self.xrecord_text(crate::dxf_ext::PLOT_STYLES_KEY).and_then(|t| crate::dxf_ext::parse_plot_styles(&t)) {
d.plot_style_tables = tables;
}
// Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries).
for (h, tags) in &self.mleader_styles {
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };

View File

@ -12,6 +12,8 @@ use crate::dxf_ext::{self, DimVal, K};
struct W {
t: Vec<Tag>,
next: u64,
/// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects).
pstyles: HashMap<String, String>,
}
impl W {
@ -145,7 +147,7 @@ fn annotative_xdata(w: &mut W) {
/// Override (`ACAD` DSTYLE) and associativity (`CADCRAFT` ASSOC) xdata of a dimension.
fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) {
let mut ov = W { t: Vec::new(), next: 0 };
let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new() };
let mut sah = false;
for (k, v) in &dm.overrides {
let Some(field) = DimStyle::field_name(k) else { continue };
@ -292,6 +294,10 @@ fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, ass
if let Some(v) = transparency_440(e.common.transparency) {
w.i(440, v);
}
// A named plot style (not ByLayer/ByBlock): its placeholder object.
if let Some(h) = w.pstyles.get(&e.common.plot_style.trim().to_ascii_uppercase()).cloned() {
w.s(390, h);
}
if !subclass.is_empty() {
w.s(100, subclass);
}
@ -1222,7 +1228,7 @@ fn record_head(w: &mut W, kind: &str, owner: &str, subclass: &str) -> String {
/// Write a drawing as ASCII DXF.
pub fn write(d: &Drawing) -> String {
// Structural handles start above every entity handle.
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100) + 0x1000 };
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100) + 0x1000, pstyles: HashMap::new() };
// Pre-allocate handles for block records.
let mut paper_layouts: Vec<&Layout> = d.layouts.iter().collect();
paper_layouts.sort_by_key(|l| l.tab_order);
@ -1348,6 +1354,22 @@ pub fn write(d: &Drawing) -> String {
if let Some(chunks) = dxf_ext::layer_state_chunks(&d.layer_states) {
xrecords.push((dxf_ext::LAYER_STATES_KEY, w.h(), chunks));
}
if let Some(chunks) = dxf_ext::plot_style_chunks(&d.plot_style_tables) {
xrecords.push((dxf_ext::PLOT_STYLES_KEY, w.h(), chunks));
}
// Plot style names used by layers and objects (Normal always), each a placeholder object.
let pstyle_dict = w.h();
let mut pstyle_names: Vec<String> = vec![cadcraft_doc::NORMAL_STYLE.to_string()];
let named = d.layers.iter().map(|l| l.plot_style.as_str()).chain(every.iter().map(|e| e.common.plot_style.as_str()));
for n in named {
let n = n.trim();
let logical = n.is_empty() || n.eq_ignore_ascii_case("bylayer") || n.eq_ignore_ascii_case("byblock");
if !logical && pstyle_names.len() < dxf_ext::MAX_PLOT_STYLE_NAMES && !pstyle_names.iter().any(|x| x.eq_ignore_ascii_case(n)) {
pstyle_names.push(n.to_string());
}
}
let pstyles: Vec<(String, String)> = pstyle_names.into_iter().map(|n| (n, w.h())).collect();
w.pstyles = pstyles.iter().map(|(n, h)| (n.to_ascii_uppercase(), h.clone())).collect();
let model_layout = w.h();
let layout_handles: Vec<String> = ps_brs.iter().map(|_| w.h()).collect();
@ -1379,7 +1401,12 @@ pub fn write(d: &Drawing) -> String {
// ---------------- CLASSES ----------------
w.s(0, "SECTION");
w.s(2, "CLASSES");
let mut classes = vec![("TABLESTYLE", "AcDbTableStyle", 4095, false), ("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false)];
let mut classes = vec![
("TABLESTYLE", "AcDbTableStyle", 4095, false),
("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false),
("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", 0, false),
("ACDBPLACEHOLDER", "AcDbPlaceHolder", 0, false),
];
if !cx.tables.is_empty() {
classes.push(("ACAD_TABLE", "AcDbTable", 1025, true));
}
@ -1487,6 +1514,10 @@ pub fn write(d: &Drawing) -> String {
w.i(290, 0);
}
w.i(370, i64::from(l.lineweight.to_dxf()));
let normal = w.pstyles.get(&cadcraft_doc::NORMAL_STYLE.to_ascii_uppercase()).cloned();
if let Some(h) = w.pstyles.get(&l.plot_style.trim().to_ascii_uppercase()).cloned().or(normal) {
w.s(390, h);
}
if l.transparency > 0 {
w.s(1001, dxf_ext::LAYER_TRANSPARENCY_APP);
w.i(1071, dxf_ext::transparency_to_dxf(l.transparency));
@ -1770,6 +1801,8 @@ pub fn write(d: &Drawing) -> String {
w.s(350, layout_dict.clone());
w.s(3, "ACAD_MLEADERSTYLE");
w.s(350, mleader_dict.clone());
w.s(3, dxf_ext::PLOT_STYLE_NAMES_KEY);
w.s(350, pstyle_dict.clone());
w.s(3, "ACAD_TABLESTYLE");
w.s(350, table_style_dict.clone());
for (key, h, _) in &xrecords {
@ -1815,7 +1848,27 @@ pub fn write(d: &Drawing) -> String {
for (s, (_, h)) in table_styles.iter().zip(&cx.table_styles) {
table_style_obj(&mut w, s, h, &table_style_dict);
}
// Parametric constraints and parameters, saved layer states (CADCraft data).
// Plot style names: a dictionary with a default (Normal) of placeholder objects.
w.s(0, "ACDBDICTIONARYWDFLT");
w.s(5, pstyle_dict.clone());
w.group("ACAD_REACTORS", 330, &[&root_dict]);
w.s(330, root_dict.clone());
w.s(100, "AcDbDictionary");
w.i(281, 1);
for (n, h) in &pstyles {
w.s(3, n);
w.s(350, h);
}
w.s(100, "AcDbDictionaryWithDefault");
w.s(340, pstyles.first().map(|(_, h)| h.clone()).unwrap_or_default());
for (_, h) in &pstyles {
w.s(0, "ACDBPLACEHOLDER");
w.s(5, h);
w.group("ACAD_REACTORS", 330, &[&pstyle_dict]);
w.s(330, pstyle_dict.clone());
}
// Parametric constraints and parameters, saved layer states, plot style tables (CADCraft
// data).
for (_, h, chunks) in &xrecords {
w.s(0, "XRECORD");
w.s(5, h);

View File

@ -60,3 +60,46 @@ fn page_setup_table_and_display_flag_round_trip() {
assert!(page.show_plot_styles);
assert!(!read_dxf(write_dxf(&layout_with_red_line()).as_bytes()).unwrap().layout("Layout1").unwrap().page.show_plot_styles);
}
#[test]
fn named_plot_styles_round_trip_through_the_plot_style_dictionary() {
use cadcraft_doc::{Layer, PlotStyle, PlotStyleTable};
let mut d = layout_with_red_line();
d.layers.push(Layer { plot_style: "Black".into(), ..Layer::new("Walls") });
let line = |y: f64| EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(1.0, y, 0.0) });
let named = d.add(&Space::Model, Common { plot_style: "Thick".into(), ..Default::default() }, line(0.0)).unwrap();
let by_layer = d.add(&Space::Model, Common::default(), line(1.0)).unwrap();
let mut pens = PlotStyleTable::named("pens.stb", "ours", vec![PlotStyle { name: "Thick".into(), lineweight: Some(70), ..PlotStyle::default() }]);
pens.description = "office pens".into();
d.plot_style_tables.push(pens.clone());
let text = write_dxf(&d);
assert!(text.contains("ACAD_PLOTSTYLENAME") && text.contains("ACDBPLACEHOLDER") && text.contains("ACDBDICTIONARYWDFLT"));
assert!(text.contains("CADCRAFT_PLOTSTYLES"));
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.layer("Walls").unwrap().plot_style, "Black");
assert_eq!(back.layer("0").unwrap().plot_style, "Normal");
assert_eq!(back.entity(named).unwrap().common.plot_style, "Thick");
assert_eq!(back.entity(by_layer).unwrap().common.plot_style, "ByLayer");
assert_eq!(back.plot_style_tables, vec![pens]);
// A drawing without named styles still writes the dictionary with Normal, and no tables.
let plain = write_dxf(&Drawing::new_metric());
assert!(plain.contains("ACAD_PLOTSTYLENAME") && !plain.contains("CADCRAFT_PLOTSTYLES"));
}
#[test]
fn pdf_applies_a_named_table_from_the_drawing() {
use cadcraft_doc::{PlotStyle, PlotStyleTable};
let mut d = layout_with_red_line();
let blue = PlotStyleTable::named(
"blue.stb",
"",
vec![PlotStyle { name: "Blue".into(), color: Some(cadcraft_color::Rgb(0, 0, 255)), ..PlotStyle::default() }],
);
d.plot_style_tables.push(blue);
let space = Space::Paper("Layout1".into());
let h = d.space(&space).unwrap().iter().next().unwrap().handle;
d.modify_entity(h, |e| e.common.plot_style = "Blue".into()).unwrap();
d.layouts[0].page.plot_style_table = "blue.stb".into();
let text = pdf_text(&d, json!({}));
assert!(text.contains("0 0 1 RG") && !text.contains("1 0 0 RG"), "{text}");
}

View File

@ -171,6 +171,8 @@ struct Ctx<'a> {
/// Extra linetype scale for model space seen through a viewport: 1 / viewport scale when
/// `PSLTSCALE` is on (dashes keep their paper-space length at any viewport scale), else 1.
lt_factor: f64,
/// Named plot style (table index) of the block reference being expanded, for `ByBlock`.
block_pstyle: Option<usize>,
}
impl Ctx<'_> {
@ -190,6 +192,8 @@ struct Builder<'a> {
plotting: bool,
/// The plot style table drawn with.
styles: Option<&'a plotstyle::Styler>,
/// Named plot style (table index) of the entity being drawn.
named: Option<usize>,
}
/// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy
@ -202,7 +206,7 @@ impl Builder<'_> {
/// `color` and `lw` as the plot style table prints them.
fn styled(&self, color: Ink, lw: f32) -> (Ink, f32) {
match self.styles {
Some(s) => s.apply(color, lw, self.opts.lineweights),
Some(s) => s.apply(color, self.named, lw, self.opts.lineweights),
None => (color, lw),
}
}
@ -298,6 +302,7 @@ fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_c
frozen,
vp_colors,
lt_factor: 1.0,
block_pstyle: None,
}
}
@ -312,7 +317,7 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
_ => None,
});
let styler = table.map(|t| plotstyle::Styler::new(d, t));
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref() };
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None };
if let Space::Paper(name) = space {
b.list.sheet = paper::sheet(d, name);
}
@ -378,7 +383,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor
let mhalf = half / s;
// Model window with slack: entity bounds are approximate (text, dimensions).
let win = Bounds2::new(vp.view_center - mhalf, vp.view_center + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles };
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None };
// PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in
// paper space, whatever the viewport scale.
let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 };
@ -463,7 +468,7 @@ pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents:
} else {
opts
};
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None };
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None };
for e in ents {
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
@ -513,12 +518,20 @@ fn default_lineweight(d: &Drawing) -> f32 {
}
fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
// The entity's named plot style, for everything it draws (block contents set their own).
let saved = b.named;
b.named = b.styles.and_then(|s| s.named_index(ctx, &e.common));
entity_parts(b, ctx, e);
b.named = saved;
}
fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
let (rgb, lw, lt, ltscale, visible) = resolve(ctx, e, b.plotting);
if !visible {
return;
}
// A plot style's linetype replaces the object's.
let lt = match b.styles.and_then(|s| s.linetype(rgb)) {
let lt = match b.styles.and_then(|s| s.linetype(rgb, b.named)) {
Some(over) => over,
None => lt,
};
@ -569,7 +582,7 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
if let Some(blk) = dm.block.as_ref().and_then(|n| ctx.d.block(n))
&& ctx.depth < cadcraft_doc::MAX_BLOCK_DEPTH
{
let sub = sub_ctx(ctx, e, Mat3::IDENTITY);
let sub = Ctx { block_pstyle: b.named, ..sub_ctx(ctx, e, Mat3::IDENTITY) };
for be in blk.entities.iter() {
if !b.expand() {
break;
@ -973,6 +986,7 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> {
frozen: ctx.frozen,
vp_colors: ctx.vp_colors,
lt_factor: ctx.lt_factor,
block_pstyle: ctx.block_pstyle,
}
}
@ -990,7 +1004,7 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg
}
let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation);
let m = Mat3::translate(off).then_before(ins.transform(blk.base.xy()));
let sub = sub_ctx(ctx, e, m);
let sub = Ctx { block_pstyle: b.named, ..sub_ctx(ctx, e, m) };
for be in blk.entities.iter() {
// Constant/visible attribute definitions inside blocks are not drawn; attribs are.
if matches!(be.kind, EntityKind::AttDef(_)) {

View File

@ -2,14 +2,17 @@
//!
//! Plots (PDF) and layouts shown with "Display plot styles" (screen, PNG and SVG) draw each
//! object with the plot style that applies to it. In a colour-dependent table that is the style
//! of the object's index colour; a true colour uses the style of its nearest index colour. The
//! of the object's index colour; a true colour uses the style of its nearest index colour. In a
//! named table it is the object's PlotStyle property: a style name, `ByLayer` (the layer's
//! style; layer 0 inside a block takes the reference's layer) or `ByBlock` (the block
//! reference's); names the table lacks print as `Normal`. The
//! style replaces or keeps the colour, turns it grey and screens it (toward the white paper),
//! and replaces the linetype and lineweight. Colour 7 prints black, as on paper.
use cadcraft_color::{Rgb, nearest_aci};
use cadcraft_doc::{Drawing, Linetype, PlotStyle, PlotStyleTable};
use cadcraft_doc::{Common, Drawing, Linetype, PlotStyle, PlotStyleTable};
use crate::Ink;
use crate::{Ctx, Ink};
/// A table ready for the builder: styles plus their resolved linetype overrides.
pub(crate) struct Styler {
@ -40,24 +43,48 @@ impl Styler {
Styler { table, linetypes }
}
/// Index of the style that applies to `ink`.
fn index(&self, ink: Ink) -> Option<usize> {
if !self.table.is_color_dependent() {
/// Whether styles are picked by name.
pub(crate) fn is_named(&self) -> bool {
!self.table.is_color_dependent()
}
/// The named style an object with properties `c` prints with (index into the table).
pub(crate) fn named_index(&self, ctx: &Ctx, c: &Common) -> Option<usize> {
if !self.is_named() {
return None;
}
let layer_name = if c.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { c.layer.as_str() };
let by_name = |n: &str| self.table.styles.iter().position(|s| s.name.eq_ignore_ascii_case(n.trim()));
let normal = || by_name(cadcraft_doc::NORMAL_STYLE);
let p = c.plot_style.trim();
if p.is_empty() || p.eq_ignore_ascii_case("bylayer") {
ctx.d.layer(layer_name).and_then(|l| by_name(&l.plot_style)).or_else(normal)
} else if p.eq_ignore_ascii_case("byblock") {
ctx.block_pstyle.or_else(normal)
} else {
by_name(p).or_else(normal)
}
}
/// Index of the style that applies to `ink` (colour-dependent tables) or the object's
/// named style `named`.
fn index(&self, ink: Ink, named: Option<usize>) -> Option<usize> {
if self.is_named() {
return named.filter(|i| *i < self.table.styles.len());
}
let aci = if ink.aci == 0 { nearest_aci(ink.rgb) } else { ink.aci };
let i = usize::from(aci).checked_sub(1)?;
(i < self.table.styles.len()).then_some(i)
}
fn style(&self, ink: Ink) -> Option<&PlotStyle> {
self.table.styles.get(self.index(ink)?)
fn style(&self, ink: Ink, named: Option<usize>) -> Option<&PlotStyle> {
self.table.styles.get(self.index(ink, named)?)
}
/// `ink` and lineweight `lw` (mm) as the plot style prints them. `lineweights`: lineweights
/// are drawn at all (a style's lineweight replaces the object's only then).
pub(crate) fn apply(&self, ink: Ink, lw: f32, lineweights: bool) -> (Ink, f32) {
let Some(s) = self.style(ink) else { return (ink, lw) };
pub(crate) fn apply(&self, ink: Ink, named: Option<usize>, lw: f32, lineweights: bool) -> (Ink, f32) {
let Some(s) = self.style(ink, named) else { return (ink, lw) };
let lw = match s.lineweight {
Some(w) if lineweights => f32::from(w.min(211)) / 100.0,
_ => lw,
@ -72,7 +99,7 @@ impl Styler {
/// The linetype override of the style that applies to `ink`: `None` keeps the object's,
/// `Some(None)` draws continuous.
pub(crate) fn linetype(&self, ink: Ink) -> Option<Option<Linetype>> {
self.linetypes.get(self.index(ink)?)?.clone()
pub(crate) fn linetype(&self, ink: Ink, named: Option<usize>) -> Option<Option<Linetype>> {
self.linetypes.get(self.index(ink, named)?)?.clone()
}
}

View File

@ -115,3 +115,45 @@ fn hostile_tables_do_not_panic() {
let named = PlotStyleTable { kind: PlotStyleKind::Named, styles: vec![PlotStyle::object("Normal")], ..PlotStyleTable::default() };
assert_eq!(build_plot(&d, &Space::Model, &with_table(named)).prims.len(), 4);
}
#[test]
fn named_styles_by_layer_by_block_and_name() {
use cadcraft_doc::{Block, Entity, Handle, Insert, Layer};
let mut d = Drawing::new_metric();
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), plot_style: "Black".into(), ..Layer::new("Walls") });
let red = |plot_style: &str| Common { layer: "Walls".into(), plot_style: plot_style.into(), ..Default::default() };
d.add(&Space::Model, red("ByLayer"), line(0.0)).unwrap();
d.add(&Space::Model, red("Normal"), line(1.0)).unwrap();
d.add(&Space::Model, red("Screened 50%"), line(2.0)).unwrap();
d.add(&Space::Model, red("No Such Style"), line(3.0)).unwrap();
// A ByBlock line inside a block whose reference is "Thick".
let mut blk = Block::new("B");
blk.entities.push(Entity {
common: Common { color: Color::Index(1), plot_style: "ByBlock".into(), ..Default::default() },
..Entity::new(Handle(1), line(0.0))
});
d.blocks.insert("B".into(), Arc::new(blk));
let ins = EntityKind::Insert(Insert {
block: "B".into(),
insert: Vec3::new(0.0, 4.0, 0.0),
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,
});
d.add(&Space::Model, Common { plot_style: "Thick".into(), ..Default::default() }, ins).unwrap();
let l = build_plot(&d, &Space::Model, &with_table(PlotStyleTable::builtin("default.stb").unwrap()));
let got: Vec<(Rgb, f32)> = l.prims.iter().map(|p| (p.display_rgb(Rgb(255, 255, 255)), p.lw)).collect();
let red_rgb = Rgb(255, 0, 0);
assert_eq!(
got,
[(BLACK, 0.25), (red_rgb, 0.25), (Rgb(255, 128, 128), 0.25), (red_rgb, 0.25), (red_rgb, 0.5)],
"ByLayer → Black, Normal, Screened 50%, unknown → Normal, ByBlock → the reference's Thick"
);
// A colour-dependent table ignores the property.
let ctb = build_plot(&d, &Space::Model, &with_table(PlotStyleTable::builtin("default.ctb").unwrap()));
assert!(ctb.prims.iter().all(|p| p.color == red_rgb));
}

View File

@ -23,6 +23,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
"about" => about(ctx, &mut open),
"commands" => commands(app, ctx, &mut open),
"blocks" => blocks(app, ctx, &mut open),
"stylesmanager" => crate::plotstyles::dialog(app, ctx, &mut open),
other => crate::managers::dialog(app, ctx, other, &mut open),
}
if !open {

View File

@ -524,7 +524,29 @@
Plot style Стиль друку
Plot style attached to Стиль друку застосовано до
Plot style table Таблиця стилів друку
Plot Style... Стиль друку...
Plot Style Manager Диспетчер стилів друку
Plot Style Manager... Диспетчер стилів друку...
Plot Styles... Стилі друку...
New CTB Нова CTB
New STB Нова STB
New style Новий стиль
Use for this layout Використати для цього аркуша
Display plot styles Показувати стилі друку
No plot style tables. Немає таблиць стилів друку.
A built-in table: editing it keeps an edited copy in the drawing. Вбудована таблиця: зміни зберігаються як її копія в кресленні.
Keep an editable copy of this table in the drawing Зберегти в кресленні копію цієї таблиці для редагування
Layout {name}: {table} Аркуш {name}: {table}
Grayscale Відтінки сірого
Screening Інтенсивність
Use object linetype Тип лінії об'єкта
Use object lineweight Товщина лінії об'єкта
Remove Вилучити
Add Додати
Open a drawing first. Спершу відкрийте креслення.
Plot table type Тип таблиці друку
Color-dependent Залежна від кольору
Named Іменована
Plus/minus Плюс/мінус
Point Точка
Point Filters Фільтри точок

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -26,6 +26,7 @@ pub mod managers;
pub mod menus;
pub mod palettes;
pub mod parametric;
pub mod plotstyles;
pub mod quick;
pub mod theme;
pub mod viewcube;

View File

@ -61,6 +61,9 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
if let Some(r) = crate::managers::route(app, id, params) {
return Some(r);
}
if let Some(r) = crate::plotstyles::route(app, id, params) {
return Some(r);
}
let toggle = |b: &mut bool, p: &Value| {
*b = p.get("on").and_then(Value::as_bool).unwrap_or(!*b);
};

View File

@ -696,10 +696,39 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
act("setvar", json!({ "name": "TEXTSIZE", "value": f }));
}
});
prop_row(ui, "Plot style", |ui| unavailable(ui, "ByColor"));
prop_row(ui, "Plot style table", |ui| unavailable(ui, "None"));
prop_row(ui, "Plot style attached to", |ui| read_only(ui, "Model"));
prop_row(ui, "Plot table type", |ui| read_only(ui, "Not available"));
// The current layout's plot style table; named tables give the current plot style.
let layout = match app.session.layout_space() {
cadcraft_doc::Space::Paper(n) => d.layout(&n).map(|l| (n.clone(), l.page.plot_style_table.clone())),
cadcraft_doc::Space::Model => None,
};
let table = layout.as_ref().and_then(|(_, t)| cadcraft_doc::plot_style_table(d, t));
match table.as_ref().filter(|t| !t.is_color_dependent()) {
Some(t) => prop_row(ui, "Plot style", |ui| {
let items = ["ByLayer", "ByBlock"].map(String::from).into_iter().chain(t.styles.iter().map(|s| s.name.clone()));
if let Some(n) = pick_menu(ui, &h.str("CPLOTSTYLE", "ByLayer"), items) {
act("plotstyle", json!({ "name": n }));
}
}),
None => prop_row(ui, "Plot style", |ui| read_only(ui, "ByColor")),
}
match &layout {
Some((name, current)) => prop_row(ui, "Plot style table", |ui| {
let shown = if current.trim().is_empty() { "None".to_string() } else { current.clone() };
let items = std::iter::once("None".to_string()).chain(cadcraft_doc::plot_style_table_names(d));
if let Some(n) = pick_menu(ui, &shown, items) {
act("pagesetup", json!({ "layout": name, "plotStyleTable": n }));
}
}),
None => prop_row(ui, "Plot style table", |ui| unavailable(ui, "None")),
}
let attached = layout.as_ref().map(|(n, _)| n.clone()).unwrap_or_else(|| "Model".into());
prop_row(ui, "Plot style attached to", |ui| read_only(ui, &attached));
let kind = match &table {
Some(t) if t.is_color_dependent() => "Color-dependent",
Some(_) => "Named",
None => "Not available",
};
prop_row(ui, "Plot table type", |ui| read_only(ui, kind));
if let Some((c, p)) = action {
let _ = app.run(c, p);
}

View File

@ -0,0 +1,210 @@
//! Plot Style Manager (STYLESMANAGER): the plot style tables the drawing can use, a table's
//! styles in an editable grid, and the current layout's table and "Display plot styles".
//!
//! The dialog reads the drawing and changes it only through STYLESMANAGER and PAGESETUP
//! (`app.run`), so every edit is undoable and scriptable.
use egui::{RichText, vec2};
use serde_json::{Value, json};
use crate::CadApp;
use crate::theme::Tokens;
/// Open the dialog for a typed or menu-invoked STYLESMANAGER (no parameters).
pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if !params.is_null() || id != "stylesmanager" {
return None;
}
app.ui.dialog = Some("stylesmanager".into());
Some(Ok(Value::Null))
}
fn temp<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, id: &str) -> Option<T> {
ctx.data_mut(|d| d.get_temp::<T>(egui::Id::new(id)))
}
fn set_temp<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, id: &str, v: T) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v));
}
/// Lineweights offered for a style, in millimetres.
const LINEWEIGHTS: [f64; 12] = [0.0, 0.09, 0.13, 0.18, 0.25, 0.3, 0.35, 0.5, 0.7, 1.0, 1.4, 2.11];
/// A command to run after the dialog is drawn.
type Action = Option<(&'static str, Value)>;
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let action = window(app, ctx, open);
if let Some((cmd, p)) = action {
// Errors are echoed on the command line by `run`.
let _ = app.run(cmd, p);
}
}
fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
let t = Tokens::get();
let title = crate::tl!("Plot Style Manager");
let Ok(d) = app.session.doc() else {
egui::Window::new(title).open(open).collapsible(false).resizable(false).show(ctx, |ui| {
ui.label(crate::tl!("Open a drawing first."));
});
return None;
};
let names = cadcraft_doc::plot_style_table_names(d);
let layout = match app.session.layout_space() {
cadcraft_doc::Space::Paper(n) => d.layout(&n).map(|l| (n.clone(), l.page.plot_style_table.clone(), l.page.show_plot_styles)),
cadcraft_doc::Space::Model => None,
};
let mut sel = temp::<String>(ctx, "pstyle_sel")
.filter(|s| names.iter().any(|n| n.eq_ignore_ascii_case(s)))
.or_else(|| layout.as_ref().map(|l| l.1.clone()).filter(|n| names.iter().any(|x| x.eq_ignore_ascii_case(n))))
.or_else(|| names.first().cloned())
.unwrap_or_default();
let table = cadcraft_doc::plot_style_table(d, &sel);
let in_drawing = d.plot_style_tables.iter().any(|x| x.name.eq_ignore_ascii_case(&sel));
let linetypes: Vec<String> =
d.linetypes.iter().map(|l| l.name.clone()).filter(|n| !n.eq_ignore_ascii_case("ByLayer") && !n.eq_ignore_ascii_case("ByBlock")).collect();
let mut new_style = temp::<String>(ctx, "pstyle_new").unwrap_or_default();
let mut action: Action = None;
let set = |table: &str, style: Value| Some(("stylesmanager", json!({ "action": "set", "name": table, "style": style })));
egui::Window::new(title).id(egui::Id::new("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(crate::tl!("Plot style table"));
egui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| {
for n in &names {
if ui.selectable_label(n.eq_ignore_ascii_case(&sel), n).clicked() {
sel = n.clone();
}
}
});
if ui.button(crate::tl!("Copy")).on_hover_text(crate::tl!("Keep an editable copy of this table in the drawing")).clicked() {
let stem = sel.rsplit_once('.').map(|(s, _)| s).unwrap_or(&sel);
let copy = format!("{stem}-copy");
action = Some(("stylesmanager", json!({ "action": "new", "name": copy, "from": sel })));
}
if ui.button(crate::tl!("New CTB")).clicked() {
action = Some(("stylesmanager", json!({ "action": "new", "name": "new.ctb" })));
}
if ui.button(crate::tl!("New STB")).clicked() {
action = Some(("stylesmanager", json!({ "action": "new", "name": "new.stb" })));
}
if ui.add_enabled(in_drawing, egui::Button::new(crate::tl!("Delete"))).clicked() {
action = Some(("stylesmanager", json!({ "action": "delete", "name": sel })));
}
});
if let Some((name, current, show)) = &layout {
ui.horizontal(|ui| {
ui.label(
RichText::new(crate::tf!("Layout {name}: {table}", name = name, table = if current.is_empty() { "None" } else { current }))
.color(t.text_dim),
);
if ui.add_enabled(!current.eq_ignore_ascii_case(&sel), egui::Button::new(crate::tl!("Use for this layout"))).clicked() {
action = Some(("pagesetup", json!({ "layout": name, "plotStyleTable": sel })));
}
let mut s = *show;
if ui.checkbox(&mut s, crate::tl!("Display plot styles")).changed() {
action = Some(("pagesetup", json!({ "layout": name, "displayPlotStyles": s })));
}
});
}
ui.separator();
let Some(table) = &table else {
ui.label(crate::tl!("No plot style tables."));
return;
};
let named = !table.is_color_dependent();
ui.label(RichText::new(&table.description).color(t.text_dim));
if !in_drawing {
ui.label(RichText::new(crate::tl!("A built-in table: editing it keeps an edited copy in the drawing.")).color(t.text_faint));
}
egui::ScrollArea::vertical().id_salt("pstyle_rows").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("pstyle_grid").striped(true).num_columns(if named { 7 } else { 6 }).spacing(vec2(12.0, 4.0)).show(ui, |ui| {
for h in [
crate::tl!("Plot style"),
crate::tl!("Color"),
crate::tl!("Grayscale"),
crate::tl!("Screening"),
crate::tl!("Linetype"),
crate::tl!("Lineweight"),
] {
ui.label(RichText::new(h).strong());
}
if named {
ui.label("");
}
ui.end_row();
for st in &table.styles {
let style = |k: &str, v: Value| json!({ "name": st.name, k: v });
ui.label(&st.name);
ui.horizontal(|ui| {
let mut object = st.color.is_none();
if ui.checkbox(&mut object, crate::tl!("Object")).changed() {
action = set(&table.name, style("color", if object { Value::Null } else { json!("#000000") }));
}
if let Some(c) = st.color {
let mut rgb = [c.0, c.1, c.2];
if ui.color_edit_button_srgb(&mut rgb).changed() {
action = set(&table.name, style("color", json!(cadcraft_color::Rgb(rgb[0], rgb[1], rgb[2]).hex())));
}
}
});
let mut gray = st.grayscale;
if ui.checkbox(&mut gray, "").changed() {
action = set(&table.name, style("grayscale", json!(gray)));
}
let mut screen = st.screening.min(100);
if ui.add(egui::DragValue::new(&mut screen).range(0..=100).suffix(" %")).changed() {
action = set(&table.name, style("screening", json!(screen)));
}
let lt = st.linetype.clone().unwrap_or_else(|| crate::tl!("Use object linetype").to_string());
egui::ComboBox::from_id_salt(("pstyle_lt", &st.name)).selected_text(lt).width(140.0).show_ui(ui, |ui| {
if ui.selectable_label(st.linetype.is_none(), crate::tl!("Use object linetype")).clicked() {
action = set(&table.name, style("linetype", Value::Null));
}
for n in &linetypes {
if ui.selectable_label(st.linetype.as_deref().is_some_and(|x| x.eq_ignore_ascii_case(n)), n).clicked() {
action = set(&table.name, style("linetype", json!(n)));
}
}
});
let lw = st
.lineweight
.map(|w| format!("{:.2} mm", f64::from(w) / 100.0))
.unwrap_or_else(|| crate::tl!("Use object lineweight").to_string());
egui::ComboBox::from_id_salt(("pstyle_lw", &st.name)).selected_text(lw).width(140.0).show_ui(ui, |ui| {
if ui.selectable_label(st.lineweight.is_none(), crate::tl!("Use object lineweight")).clicked() {
action = set(&table.name, style("lineweight", Value::Null));
}
for mm in LINEWEIGHTS {
let hundredths = (mm * 100.0).round() as u16;
if ui.selectable_label(st.lineweight == Some(hundredths), format!("{mm:.2} mm")).clicked() {
action = set(&table.name, style("lineweight", json!(mm)));
}
}
});
if named {
let normal = st.name.eq_ignore_ascii_case(cadcraft_doc::NORMAL_STYLE);
if ui.add_enabled(!normal, egui::Button::new(crate::tl!("Remove"))).clicked() {
action = Some(("stylesmanager", json!({ "action": "set", "name": table.name, "removeStyle": st.name })));
}
}
ui.end_row();
}
});
});
if named {
ui.separator();
ui.horizontal(|ui| {
ui.label(crate::tl!("New style"));
ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0));
if ui.add_enabled(!new_style.trim().is_empty(), egui::Button::new(crate::tl!("Add"))).clicked() {
action = set(&table.name, json!({ "name": new_style.trim() }));
new_style.clear();
}
});
}
});
set_temp(ctx, "pstyle_sel", sel);
set_temp(ctx, "pstyle_new", new_style);
action
}

View File

@ -107,7 +107,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr
| WMF, EPS, ACIS SAT, STL, FBX, IGES/STEP (Windows), 3DS | various | no; most are 3D and wait for M11 | 20–40 |
| SHX fonts (`.shx`, big fonts) | read | **not parsed**: our stroke font or FONTALT stands in, so text metrics differ from AutoCAD | 8–12 |
| Linetype (`.lin`) and hatch pattern (`.pat`) files | load user files | own built-in library only; user files can't be loaded | 3–5 |
| Plot styles (`.ctb`, `.stb`), plotter configs (`.pc3`), `.pmp` | yes | no | 8–12 |
| Plot styles (`.ctb`, `.stb`), plotter configs (`.pc3`), `.pmp` | yes | plot style tables in our own JSON format ([plot-styles.md](plot-styles.md)) and kept in the drawing; binary `.ctb`/`.stb` not read (no public spec); no `.pc3`/`.pmp` | 6–10 |
| Scripts (`.scr`) | SCRIPT | script text through the CLI, MCP and control channel; no SCRIPT command reading a `.scr` file | 1–2 |
| Aliases (`.pgp`), CUI(x), workspaces | yes | no | 6–10 |
| AutoLISP (`.lsp`, `.fas`, `.vlx`), `.dll`/`.arx` plug-ins | yes | no | see [gaps.md](gaps.md) (M12) |

View File

@ -41,7 +41,7 @@ do them first.
| F11 | Tables: no cell styles, formulas, data links, table breaking | `cmd/table.rs` | Medium | 10–15 |
| F12 | Annotative scaling: the flag is stored, nothing scales; no scale list, no "Annotative Object Scale" commands | 4 uncovered menu items | Medium-high in layouts | 10–15 |
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); gradients render as a flat colour; associativity not updated when the boundary moves; origin always 0,0; island style always normal; no gap tolerance, separate hatches, hatch dialog, MPolygon | `doc/src/library.rs`, `render/lib.rs:430-437`, `cmd/hatch.rs:198,201` | Medium | 15–25 |
| F14 | **[alpha blocker]** Named plot styles (STB, PlotStyle property) and plot style table editing/files missing (colour-dependent tables apply to PDF plots and "Display plot styles" layouts since #410: built-in `default`/`monochrome`/`grayscale.ctb`); no plot preview, plotter devices, PUBLISH/batch plot; Print opens no plot dialog (#160, PR #200) | `palettes.rs:666` | High for sheet output | 20–30 |
| F14 | **[alpha blocker]** No plot preview, plotter devices, PUBLISH/batch plot; Print opens no plot dialog (#160, PR #200). Plot styles (CTB and STB, [plot-styles.md](plot-styles.md)) apply since #410; their end/join/fill styles aren't applied and binary `.ctb`/`.stb` files can't be read | `palettes.rs:666` | High for sheet output | 15–25 |
| F15 | Viewports: no polygonal, object or clipped viewports; no viewport scale control in the UI | menu items uncovered | Medium | 6–10 |
| F16 | Groups (GROUP, GROUPEDIT, PICKSTYLE) missing; ACAD_GROUP written empty | `dxf_write.rs` | Medium | 4–6 |
| F17 | Attributes: BATTMAN lists only; no ATTSYNC, EATTEXT, global ATTEDIT | `blocks.rs:300` | Medium | 6–10 |

106
docs/plot-styles.md Normal file
View File

@ -0,0 +1,106 @@
# Plot styles
A plot style table decides how objects print. CADCraft applies it to PDF plots (PLOT,
EXPORTPDF and anything that plots through them). It also applies it to a layout shown with
**Display plot styles** on: on screen and in the layout's PNG and SVG exports.
## Tables
| Kind | Picks the style by | Styles |
|---|---|---|
| Colour-dependent (`.ctb`) | the object's index colour (a true colour uses its nearest index colour) | exactly 255, `Color_1` … `Color_255` |
| Named (`.stb`) | the object's **PlotStyle** property | `Normal` first, then any names |
A style can set these:
| Field | Meaning | "Use object" value |
|---|---|---|
| `color` | output colour, `"#rrggbb"` | absent / `null` |
| `grayscale` | print the colour as the grey of its luminance (Rec. 709 weights) | `false` |
| `screening` | ink intensity 0–100 %, mixing toward the white paper | `100` |
| `linetype` | linetype name (loaded, or from the built-in library; `Continuous` draws solid) | absent / `null` |
| `lineweight` | hundredths of a millimetre, 0–211; used when lineweights are plotted | absent / `null` |
| `end`, `join`, `fill` | line end, line join and area fill style | `object` |
Colour 7 prints black. `end`, `join` and `fill` are stored but not applied yet.
### Built-in tables
These are CADCraft's own definitions, not copies of any vendor's files.
| Table | What it does |
|---|---|
| `default.ctb` | keeps every object property |
| `monochrome.ctb` | prints every colour black |
| `grayscale.ctb` | prints every colour as the grey of its luminance |
| `default.stb` | `Normal` (object properties), `Black`, `Screened 50%`, `Thick` (0.50 mm) |
| `monochrome.stb` | `Normal` prints black; `Screened 50%` prints black at 50 % |
A table kept in the drawing shadows a built-in table of the same name.
## Named plot styles
Layers have a plot style name (default `Normal`). Objects have `ByLayer` (the default), `ByBlock`
or a style name. `ByLayer` takes the layer's style. On layer 0 inside a block, that is the block
reference's layer. `ByBlock` takes the block reference's style. A name the table doesn't have
prints as `Normal`. A colour-dependent table ignores the property.
## Commands
- `pagesetup {layout, plotStyleTable, displayPlotStyles}` sets the layout's table and the
Display plot styles flag. Without a layout, it reports the built-in tables in `plotStyleTables`.
- `plot` / `exportpdf {plotStyleTable}` replaces the page setup's table for one plot. `"None"`
plots object colours.
- `plotstyle {name, handles?}` sets the objects' plot style. With nothing selected, it sets the
current plot style for new objects (`CPLOTSTYLE`). Without `name`, it reports the current
style and the named styles.
- `layer.set {name, plotStyle}` sets a layer's plot style.
- `stylesmanager {action, …}` manages the tables: `list`, `get`, `new` (`kind`, `from`), `set`
(`style: {name, …fields}`, `removeStyle`, `description`), `delete`, `load` (`path` or `text`)
and `save` (`path`, or returns `text`). Typed or chosen from **File ▸ Plot Styles...**, it
opens the Plot Style Manager dialog.
## Table files
Tables are saved in our own format: UTF-8 JSON with a format marker. Fields left at "use the
object's" are omitted.
```json
{
"cadcraftPlotStyleTable": 1,
"name": "office.stb",
"description": "Office pens",
"kind": "named",
"styles": [
{ "name": "Normal" },
{ "name": "Pen 5", "color": "#000000", "screening": 40, "lineweight": 35, "linetype": "HIDDEN", "end": "round" }
]
}
```
- `kind` is `colorDependent` or `named`.
- `end` is one of `object`, `butt`, `square`, `round` or `diamond`.
- `join` is one of `object`, `miter`, `bevel`, `round` or `diamond`.
- `fill` is one of `object`, `solid`, `checkerboard`, `crosshatch`, `diamonds`, `horizontalBars`,
`slantLeft`, `slantRight`, `squareDots` or `verticalBars`.
- `color` may also be written `[r, g, b]`.
Reading fixes hostile values:
- Screening is capped at 100 and lineweights at 211.
- A colour-dependent table always gets 255 styles named `Color_n`.
- A named table gets unique style names (at most 1024), with `Normal` first.
- Files over 4 MB are refused.
Binary `.ctb`/`.stb` files are not read. We haven't found a public specification of that format.
## In DXF files
- The page setup's table is PLOTSETTINGS group 7, and Display plot styles is plot layout flag 2
(group 70).
- Plot style names follow the DXF Reference. The named object dictionary entry
`ACAD_PLOTSTYLENAME` is an `ACDBDICTIONARYWDFLT` whose entries name `ACDBPLACEHOLDER` objects
(default `Normal`). Layers and objects point at them with group 390. An object without a 390
is `ByLayer`. `ByBlock` isn't written, so it reads back as `ByLayer`.
- Tables kept in the drawing travel in the `CADCRAFT_PLOTSTYLES` XRECORD (JSON) of the named
object dictionary.

View File

@ -35,7 +35,7 @@ the alpha checklist. Main platform: macOS.
| Draw and modify precisely with object snaps and tracking | **partial, blocking** | Running snaps, polar, ortho and coordinate entry work; object snap tracking, extension and parallel snaps are toggles with no behaviour; no FROM/M2P/TK or typed snap overrides; TRIM/EXTEND standard mode, OFFSET Erase/Layer/Multiple, ROTATE Reference print "not available yet"; ellipse/spline intersections approximate ([ui-parity.md](ui-parity.md), [gaps.md](gaps.md) U1, U3, U4, F1) | 35–50 |
| Dimension and annotate | partial, not blocking | All core DIM types, associativity, DIMSTYLE variables (43 of ~80), TEXT/MTEXT, MLEADER, tables work; no DIMSTYLE dialog, MTEXT editor not in place, MLEADER thin (F7, F9, F10). A drafter can dimension a sheet end to end | 0 for the gate (beta work) |
| Use blocks (insert library blocks, edit them, attributes, xrefs) | **partial, blocking** | Static BLOCK/INSERT/attributes work; no block editor (BEDIT) or REFEDIT, so a block can't be edited in place; no xrefs; dynamic blocks from other drawings lose their behaviour (F2, F3) | 35–55 |
| Layers, layouts and plotting a sheet | **partial, blocking** | Layers, layouts, viewports, page setups and PDF output work; plot styles (CTB/STB) aren't applied, so office-standard monochrome/lineweight plots come out wrong; no plot preview or plot dialog (#160); printers/plotters only via PDF (F14, H3) | 15–25 |
| Layers, layouts and plotting a sheet | **partial, blocking** | Layers, layouts, viewports, page setups, PDF output and plot styles (CTB/STB, #410) work; no plot preview or plot dialog (#160); printers/plotters only via PDF (F14, H3) | 15–25 |
| Save DWG that AutoCAD opens without loss, and reopen others' DWGs | **no** | Saving drops every entity and object we don't model (Unknown, IMAGE, WIPEOUT, gradients, groups, views, foreign xdata); MULTILEADER becomes LEADER+MTEXT; DWG always written as R2000; never checked in AutoCAD; no real-file tests; a 10 MB DWG failed after ~2 min (#57) (FF1–FF6) | 40–60 + owner (real-file corpus, AutoCAD check) |
| **To alpha** | | | **≈ 125–190** |

View File

@ -155,7 +155,7 @@ presence: an option that prints "not available yet" doesn't count.
| Leaders, tables, fields | 4% | 25% | 25–35 | basic MLEADER and TABLE; leader options ignored, no MLEADEREDIT/ALIGN/COLLECT, no block content; no formulas, data links, FIELD | [gaps](gaps.md#features) |
| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; associativity flag not acted on; no origin, gap tolerance, separate hatches, hatch dialog | [gaps](gaps.md#features) |
| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; no BEDIT, dynamic blocks, xrefs, REFEDIT, XCLIP, GROUP, ATTSYNC, EATTEXT, DesignCenter | [gaps](gaps.md#features) |
| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, rectangular viewports, per-VP layers, page setups, PDF; no plot styles (CTB/STB), plot preview, devices, PUBLISH, polygonal/object viewports, annotative scales | [gaps](gaps.md#features) |
| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, rectangular viewports, per-VP layers, page setups, PDF, plot styles (CTB/STB); no plot preview, devices, PUBLISH, polygonal/object viewports, annotative scales | [gaps](gaps.md#features) |
| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT; no autosave/recovery, AUDIT, RECOVER, OPTIONS, SPELL, QuickCalc, DWG Compare, ETRANSMIT | [gaps](gaps.md#features) |
| Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; no DOF report, no G2 Smooth, no ellipse/spline geometry, no inference | [geometry-parity](geometry-parity.md#constraint-solver--50) |
| 3D modelling, visualisation, rendering | 5% | 2% | 300–450 | no UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights or rendering; 150 of the 491 menu items | [gaps](gaps.md#features) |