Merge pull request #108 from storytold/pr/m4-dxf-import
M4.93: DXF import
This commit is contained in:
commit
6017846ef6
|
|
@ -0,0 +1,218 @@
|
|||
//! DXF import: an ASCII DXF drawing of any version as a document.
|
||||
//!
|
||||
//! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N
|
||||
//! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred
|
||||
//! or with the drawing's origin on the artboard's bottom-left corner. Each DXF layer holding art
|
||||
//! becomes a layer (hidden, frozen, locked and non-plotting layers keep that), or all art goes on
|
||||
//! one. Lines, polylines with arc segments, circles, arcs, ellipses, splines (NURBS), solid
|
||||
//! hatches and solids become paths; text and multiline text point type; named blocks symbols
|
||||
//! (anonymous ones, such as dimensions, groups of their art). Colours (indexed and true colour),
|
||||
//! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve
|
||||
//! them. What can't come in is listed in the warnings.
|
||||
//!
|
||||
//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts.
|
||||
//! - `entity`: entities to items (geometry and properties).
|
||||
//! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths.
|
||||
//! - `text`: text values to plain text.
|
||||
//! - `build`: items to the document.
|
||||
|
||||
mod build;
|
||||
mod curve;
|
||||
mod entity;
|
||||
mod reader;
|
||||
mod text;
|
||||
|
||||
use vectorcraft_doc::{Document, Unit};
|
||||
|
||||
pub use reader::BINARY_SENTINEL;
|
||||
|
||||
use crate::{DxfVersion, INSUNITS, SCALE_RANGE};
|
||||
|
||||
/// Letter, the box Fit to Artboard fills unless told otherwise (points).
|
||||
pub const FIT_TO: (f64, f64) = (612.0, 792.0);
|
||||
|
||||
/// Why a binary DXF can't be opened.
|
||||
pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that";
|
||||
|
||||
/// How a drawing comes in.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ImportOptions {
|
||||
/// The layout (any case): `Model` (`None`, the default) or a paper layout's name.
|
||||
pub layout: Option<String>,
|
||||
/// Scale the art to fit `fit_to` (its larger side along the art's), instead of the ratio.
|
||||
pub fit: bool,
|
||||
/// The artboard Fit fills (points, `width × height`).
|
||||
pub fit_to: (f64, f64),
|
||||
/// The ratio: 1 `unit` of the art is `scale` drawing units. `None`: the drawing's own unit
|
||||
/// at 1:1 (millimetres for unitless metric drawings, inches for imperial ones).
|
||||
pub unit: Option<Unit>,
|
||||
pub scale: Option<f64>,
|
||||
/// Lineweights scale with the art (else they keep their printed widths).
|
||||
pub scale_lineweights: bool,
|
||||
/// The art's centre on the artboard's (else the drawing's origin, or fitted art's
|
||||
/// bottom-left corner, on the artboard's bottom-left corner).
|
||||
pub center: bool,
|
||||
/// All art on one layer.
|
||||
pub merge_layers: bool,
|
||||
}
|
||||
|
||||
impl Default for ImportOptions {
|
||||
fn default() -> Self {
|
||||
Self { layout: None, fit: false, fit_to: FIT_TO, unit: None, scale: None, scale_lineweights: false, center: true, merge_layers: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// An imported drawing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Imported {
|
||||
pub document: Document,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a drawing holds, for the import options.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DxfInfo {
|
||||
/// The DXF version (`2018`, `R12`…; the `$ACADVER` code when it's none of the known ones).
|
||||
pub version: String,
|
||||
/// The drawing unit's name (`Millimeters`, `Unitless`…).
|
||||
pub units: String,
|
||||
/// `Model`, then the paper layouts.
|
||||
pub layouts: Vec<String>,
|
||||
/// The layer table's layers.
|
||||
pub layers: Vec<String>,
|
||||
/// The default ratio: 1 `unit` = `scale` drawing units.
|
||||
pub unit: Unit,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// Does `bytes` look like a DXF file (ASCII or binary)?
|
||||
pub fn is_dxf(bytes: &[u8]) -> bool {
|
||||
if bytes.starts_with(reader::BINARY_SENTINEL) {
|
||||
return true;
|
||||
}
|
||||
let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(1024)).unwrap_or(bytes));
|
||||
let mut lines = head.trim_start_matches('\u{feff}').lines().map(str::trim).skip_while(|l| l.is_empty());
|
||||
// Comments (999) may come first; then the first section.
|
||||
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
|
||||
match code {
|
||||
"999" => continue,
|
||||
"0" => return value == "SECTION",
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused).
|
||||
fn text(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, String> {
|
||||
if bytes.starts_with(reader::BINARY_SENTINEL) {
|
||||
return Err(BINARY.into());
|
||||
}
|
||||
if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") {
|
||||
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into());
|
||||
}
|
||||
Ok(reader::decode(bytes))
|
||||
}
|
||||
|
||||
/// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it.
|
||||
fn default_ratio(h: &reader::Header) -> (Unit, f64) {
|
||||
if let Some((_, u)) = INSUNITS.iter().find(|(c, _)| i64::from(*c) == h.insunits) {
|
||||
return (*u, 1.0);
|
||||
}
|
||||
match build::insunits_points(h.insunits) {
|
||||
Some(pt) => (Unit::Millimeters, Unit::Millimeters.points() / pt),
|
||||
None if h.measurement == Some(0) => (Unit::Inches, 1.0),
|
||||
None => (Unit::Millimeters, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of `$INSUNITS` code `code`.
|
||||
fn units_name(code: i64) -> &'static str {
|
||||
const NAMES: [&str; 22] = [
|
||||
"Unitless",
|
||||
"Inches",
|
||||
"Feet",
|
||||
"Miles",
|
||||
"Millimeters",
|
||||
"Centimeters",
|
||||
"Meters",
|
||||
"Kilometers",
|
||||
"Microinches",
|
||||
"Mils",
|
||||
"Yards",
|
||||
"Angstroms",
|
||||
"Nanometers",
|
||||
"Microns",
|
||||
"Decimeters",
|
||||
"Decameters",
|
||||
"Hectometers",
|
||||
"Gigameters",
|
||||
"Astronomical units",
|
||||
"Light years",
|
||||
"Parsecs",
|
||||
"US survey feet",
|
||||
];
|
||||
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
|
||||
}
|
||||
|
||||
/// Read what `bytes` holds: its version, units, layouts and layers.
|
||||
pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
|
||||
let text = text(bytes)?;
|
||||
let (pairs, _) = reader::pairs(&text)?;
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
if d.sections == 0 {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
let h = &d.header;
|
||||
let (unit, scale) = default_ratio(h);
|
||||
let version = DxfVersion::ALL.into_iter().find(|v| v.acadver() == h.acadver).map_or_else(|| h.acadver.clone(), |v| v.id().to_string());
|
||||
Ok(DxfInfo {
|
||||
version,
|
||||
units: units_name(h.insunits).into(),
|
||||
layouts: d.layouts.iter().map(|l| l.name.clone()).collect(),
|
||||
layers: d.layers.iter().map(|l| l.name.clone()).collect(),
|
||||
unit,
|
||||
scale,
|
||||
})
|
||||
}
|
||||
|
||||
/// Import an ASCII DXF drawing with `o`.
|
||||
pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
|
||||
let (w, h) = o.fit_to;
|
||||
if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) {
|
||||
return Err(format!("the artboard to fit must be a positive size up to {} pt", build::MAX_EXTENT));
|
||||
}
|
||||
if let Some(s) = o.scale
|
||||
&& !(s.is_finite() && SCALE_RANGE.contains(&s))
|
||||
{
|
||||
return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end()));
|
||||
}
|
||||
let text = text(bytes)?;
|
||||
let (pairs, damaged) = reader::pairs(&text)?;
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
if d.sections == 0 {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
let layout = match &o.layout {
|
||||
None => d.layouts.first(),
|
||||
Some(name) => d.layout(name),
|
||||
};
|
||||
let Some(layout) = layout else {
|
||||
let names: Vec<&str> = d.layouts.iter().map(|l| l.name.as_str()).collect();
|
||||
return Err(format!("the drawing has no layout `{}` (it has {})", o.layout.as_deref().unwrap_or_default(), names.join(", ")));
|
||||
};
|
||||
let (unit, scale) = default_ratio(&d.header);
|
||||
let natural = unit.points() / scale;
|
||||
let ratio = match (o.unit, o.scale) {
|
||||
(None, None) => natural,
|
||||
(u, s) => u.unwrap_or(unit).points() / s.unwrap_or(1.0),
|
||||
};
|
||||
let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?;
|
||||
document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) };
|
||||
warnings.extend(damaged);
|
||||
Ok(Imported { document, warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
//! Drawing items to a document: the scale and position the options ask for, one layer per DXF
|
||||
//! layer that holds art (or one in all), named blocks as symbols (anonymous ones, such as
|
||||
//! dimensions, as groups), and colours, lineweights, linetypes and transparency resolved through
|
||||
//! layers and blocks.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::BezPath;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAYER_COLORS, LayerColor, LineCap, Node, NodeKind, Symbol, TextObject};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
|
||||
|
||||
use super::ImportOptions;
|
||||
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, TextItem};
|
||||
use super::reader::{Drawing, LayoutDef};
|
||||
use crate::{CAP_HEIGHT, MAX_NEST};
|
||||
|
||||
/// Points per millimetre.
|
||||
const PT_PER_MM: f64 = 72.0 / 25.4;
|
||||
/// The stroke weight of lineweight 0 (CAD apps draw it one device pixel wide), in points.
|
||||
const THINNEST: f64 = 0.25;
|
||||
/// The most objects an import makes.
|
||||
const MAX_NODES: usize = 2_000_000;
|
||||
/// How far from the origin art may lie (points): the canvas.
|
||||
pub(crate) const MAX_EXTENT: f64 = 4.0e6;
|
||||
|
||||
const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are left out";
|
||||
const XREFS: &str = "blocks referencing other drawings (external references) are left out";
|
||||
const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out";
|
||||
|
||||
/// What a block's art takes from the insert that places it: its colour, lineweight, linetype
|
||||
/// and opacity where the art says "by block", and its layer for art on layer 0.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||
struct Inherit {
|
||||
color: Option<[u8; 3]>,
|
||||
lineweight: Option<i32>,
|
||||
linetype: Option<String>,
|
||||
alpha: Option<u8>,
|
||||
layer: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct Build<'d, 'a> {
|
||||
d: &'d Drawing<'a>,
|
||||
o: &'d ImportOptions,
|
||||
conv: Converter,
|
||||
doc: Document,
|
||||
/// Block items by upper-case name.
|
||||
blocks: HashMap<String, Rc<Vec<Item>>>,
|
||||
bounds: HashMap<String, Option<Rect>>,
|
||||
sensitive: HashMap<String, bool>,
|
||||
/// Symbol names by block and what it inherits (`None`: the block has no art).
|
||||
symbols: HashMap<(String, Inherit), Option<String>>,
|
||||
/// Blocks being drawn, outermost first.
|
||||
stack: Vec<String>,
|
||||
/// Document space per drawing unit, and its symbols' (no translation).
|
||||
map: Affine,
|
||||
symbol_map: Affine,
|
||||
/// Lineweights are multiplied by this.
|
||||
lw_scale: f64,
|
||||
nodes: usize,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
|
||||
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
let m = 72.0 / 0.0254;
|
||||
Some(match code {
|
||||
1 => 72.0,
|
||||
2 => 864.0,
|
||||
3 => 72.0 * 63_360.0,
|
||||
4 => PT_PER_MM,
|
||||
5 => PT_PER_MM * 10.0,
|
||||
6 => m,
|
||||
7 => m * 1e3,
|
||||
8 => 72e-6,
|
||||
9 => 72e-3,
|
||||
10 => 2592.0,
|
||||
11 => m * 1e-10,
|
||||
12 => m * 1e-9,
|
||||
13 => m * 1e-6,
|
||||
14 => m * 0.1,
|
||||
15 => m * 10.0,
|
||||
16 => m * 100.0,
|
||||
17 => m * 1e9,
|
||||
18 => m * 1.495_978_707e11,
|
||||
19 => m * 9.460_730_472_580_8e15,
|
||||
20 => m * 3.085_677_581e16,
|
||||
21 => m * 1200.0 / 3937.0,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl<'d, 'a> Build<'d, 'a> {
|
||||
pub fn new(d: &'d Drawing<'a>, o: &'d ImportOptions) -> Self {
|
||||
Self {
|
||||
d,
|
||||
o,
|
||||
conv: Converter::default(),
|
||||
doc: Document::new(1.0, 1.0),
|
||||
blocks: HashMap::new(),
|
||||
bounds: HashMap::new(),
|
||||
sensitive: HashMap::new(),
|
||||
symbols: HashMap::new(),
|
||||
stack: vec![],
|
||||
map: Affine::IDENTITY,
|
||||
symbol_map: Affine::IDENTITY,
|
||||
lw_scale: 1.0,
|
||||
nodes: 0,
|
||||
warnings: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Import `layout`; `natural` and `ratio` are points per drawing unit in the drawing's own
|
||||
/// unit and at the scale asked for (fitting computes its own).
|
||||
pub fn run(mut self, layout: &LayoutDef, natural: f64, ratio: f64) -> Result<(Document, Vec<String>), String> {
|
||||
let items = self.conv.items(&self.d.layout_records(layout));
|
||||
let bounds = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, 0)));
|
||||
let (k, size, offset) = self.placement(bounds, natural, ratio)?;
|
||||
self.map = Affine::new([k, 0.0, 0.0, -k, offset.x, offset.y]);
|
||||
self.symbol_map = Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]);
|
||||
self.lw_scale = if self.o.scale_lineweights { k / natural } else { 1.0 };
|
||||
let layers = self.layers(&items);
|
||||
let Self { mut doc, conv, warnings: notes, .. } = self;
|
||||
if !layers.is_empty() {
|
||||
doc.layers = layers;
|
||||
}
|
||||
if let Some(a) = doc.artboards.first_mut() {
|
||||
a.rect = Rect::from_origin_size(Point::ZERO, size);
|
||||
}
|
||||
doc.fix_next_id();
|
||||
let mut warnings = conv.warnings;
|
||||
warnings.extend(notes);
|
||||
if !conv.skipped.is_empty() {
|
||||
let list: Vec<String> = conv.skipped.iter().map(|(k, n)| format!("{n} {k}")).collect();
|
||||
warnings.push(format!("entities of these types are left out: {}", list.join(", ")));
|
||||
}
|
||||
if bounds.is_none() {
|
||||
warnings.push(format!("the {} layout has no art", layout.name));
|
||||
}
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
|
||||
/// Points per drawing unit, the artboard's size, and where the drawing's origin goes.
|
||||
fn placement(&self, bounds: Option<Rect>, natural: f64, ratio: f64) -> Result<(f64, vectorcraft_geom::Size, Point), String> {
|
||||
let b = bounds.unwrap_or(Rect::ZERO);
|
||||
let k = if self.o.fit {
|
||||
let (w, h) = self.fit_box(b);
|
||||
let ratio = |a: f64, b: f64| (b > 1e-12).then(|| a / b);
|
||||
[ratio(w, b.width()), ratio(h, b.height())].into_iter().flatten().reduce(f64::min).unwrap_or(natural)
|
||||
} else {
|
||||
ratio
|
||||
};
|
||||
if !(k.is_finite() && k > 0.0) {
|
||||
return Err("the scale is not a usable number".into());
|
||||
}
|
||||
let size = if self.o.fit {
|
||||
let (w, h) = self.fit_box(b);
|
||||
vectorcraft_geom::Size::new(w, h)
|
||||
} else {
|
||||
vectorcraft_geom::Size::new((b.width() * k).max(1.0), (b.height() * k).max(1.0))
|
||||
};
|
||||
// Centred: the art's centre on the artboard's; else the drawing's origin (fitted art: its
|
||||
// bottom-left corner) on the artboard's bottom-left corner.
|
||||
let offset = if self.o.center {
|
||||
Point::new(size.width / 2.0 - b.center().x * k, size.height / 2.0 + b.center().y * k)
|
||||
} else if self.o.fit {
|
||||
Point::new(-b.x0 * k, size.height + b.y0 * k)
|
||||
} else {
|
||||
Point::new(0.0, size.height)
|
||||
};
|
||||
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)].map(|p| Point::new(p.x * k + offset.x, -p.y * k + offset.y));
|
||||
let fits = corners.iter().all(|p| p.x.abs() <= MAX_EXTENT && p.y.abs() <= MAX_EXTENT) && size.width.max(size.height) <= MAX_EXTENT;
|
||||
if !fits {
|
||||
let (w, h) = (b.width() * k, b.height() * k);
|
||||
return Err(format!(
|
||||
"at this scale the drawing is {w:.0} × {h:.0} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard"
|
||||
));
|
||||
}
|
||||
Ok((k, size, offset))
|
||||
}
|
||||
|
||||
/// The box Fit to Artboard fills, turned to the art's orientation.
|
||||
fn fit_box(&self, b: Rect) -> (f64, f64) {
|
||||
let (w, h) = self.o.fit_to;
|
||||
if (b.width() > b.height()) == (w > h) { (w, h) } else { (h, w) }
|
||||
}
|
||||
|
||||
// ---------- blocks ----------
|
||||
|
||||
/// A block's items (read once).
|
||||
fn block(&mut self, name: &str) -> Option<Rc<Vec<Item>>> {
|
||||
if let Some(b) = self.blocks.get(name) {
|
||||
return Some(b.clone());
|
||||
}
|
||||
let def = self.d.blocks.get(name)?;
|
||||
if def.xref {
|
||||
self.warn(XREFS);
|
||||
return None;
|
||||
}
|
||||
let items = Rc::new(self.conv.items(&def.records));
|
||||
self.blocks.insert(name.to_string(), items.clone());
|
||||
Some(items)
|
||||
}
|
||||
|
||||
/// Enter block `name` `depth` deep; false (with a warning) when too deep or inside itself.
|
||||
fn enter(&mut self, name: &str, depth: u32) -> bool {
|
||||
if depth >= MAX_NEST || self.stack.iter().any(|s| s == name) {
|
||||
self.warn(NESTED);
|
||||
return false;
|
||||
}
|
||||
self.stack.push(name.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// A block's bounds in its coordinates less its base point.
|
||||
fn block_bounds(&mut self, name: &str, depth: u32) -> Option<Rect> {
|
||||
if let Some(b) = self.bounds.get(name) {
|
||||
return *b;
|
||||
}
|
||||
let items = self.block(name)?;
|
||||
if !self.enter(name, depth) {
|
||||
return None;
|
||||
}
|
||||
let b = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, depth + 1)));
|
||||
self.stack.pop();
|
||||
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
|
||||
let b = b.map(|r| r - base.to_vec2());
|
||||
self.bounds.insert(name.to_string(), b);
|
||||
b
|
||||
}
|
||||
|
||||
/// An item's bounds in world coordinates (type: an estimate from its height and length).
|
||||
fn item_bounds(&mut self, it: &Item, depth: u32) -> Option<Rect> {
|
||||
match &it.geom {
|
||||
Geom::Path { bp, .. } => super::curve::bounds(bp),
|
||||
Geom::Text(t) => {
|
||||
let lines = t.text.lines().count().max(1) as f64;
|
||||
let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64;
|
||||
let w = longest * t.height * 0.9 * t.width;
|
||||
let x0 = match t.justify {
|
||||
vectorcraft_doc::Justify::Center => -w / 2.0,
|
||||
vectorcraft_doc::Justify::Right => -w,
|
||||
_ => 0.0,
|
||||
};
|
||||
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + t.height * 0.3;
|
||||
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, t.height)))
|
||||
}
|
||||
Geom::Insert(ins) => {
|
||||
let b = self.block_bounds(&ins.block, depth);
|
||||
let cells = b.into_iter().flat_map(|b| ins.cells.iter().map(move |m| m.transform_rect_bbox(b)));
|
||||
let attribs: Vec<Option<Rect>> = ins.attribs.iter().map(|a| self.item_bounds(a, depth)).collect();
|
||||
cells.map(Some).chain(attribs).fold(None, vectorcraft_geom::union_opt)
|
||||
}
|
||||
}
|
||||
.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))
|
||||
}
|
||||
|
||||
/// Does a block's look depend on its insert (art "by block", or on layer 0 "by layer")?
|
||||
fn is_sensitive(&mut self, name: &str, depth: u32) -> bool {
|
||||
if let Some(s) = self.sensitive.get(name) {
|
||||
return *s;
|
||||
}
|
||||
let Some(items) = self.block(name) else { return false };
|
||||
if !self.enter(name, depth) {
|
||||
return false;
|
||||
}
|
||||
let mut s = false;
|
||||
for it in items.iter() {
|
||||
let p = &it.props;
|
||||
let on_zero = p.layer == "0";
|
||||
s |= p.color == Col::ByBlock || p.lineweight == Lw::ByBlock || p.linetype == "BYBLOCK" || p.alpha == Alpha::ByBlock;
|
||||
s |= on_zero && (p.color == Col::ByLayer || p.lineweight == Lw::ByLayer || p.linetype == "BYLAYER");
|
||||
if let Geom::Insert(ins) = &it.geom {
|
||||
s |= !ins.attribs.is_empty() || self.is_sensitive(&ins.block, depth + 1);
|
||||
}
|
||||
if s {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.stack.pop();
|
||||
self.sensitive.insert(name.to_string(), s);
|
||||
s
|
||||
}
|
||||
|
||||
// ---------- properties ----------
|
||||
|
||||
/// The layer art on layer 0 takes from its block, else its own.
|
||||
fn layer_name<'p>(&self, p: &'p Props, inh: &'p Inherit) -> &'p str {
|
||||
match &inh.layer {
|
||||
Some(l) if p.layer == "0" => l,
|
||||
_ => &p.layer,
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb(&self, p: &Props, inh: &Inherit) -> [u8; 3] {
|
||||
match p.color {
|
||||
Col::Rgb(c) => c,
|
||||
Col::ByBlock => inh.color.unwrap_or([0; 3]),
|
||||
Col::ByLayer => self.d.layer(self.layer_name(p, inh)).map_or([0; 3], |l| l.rgb),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lineweight in hundredths of a millimetre.
|
||||
fn lineweight(&self, p: &Props, inh: &Inherit) -> i32 {
|
||||
let default = self.d.header.lwdefault;
|
||||
match p.lineweight {
|
||||
Lw::Hundredths(v) => v,
|
||||
Lw::Default => default,
|
||||
Lw::ByBlock => inh.lineweight.unwrap_or(default),
|
||||
Lw::ByLayer => self.d.layer(self.layer_name(p, inh)).map(|l| l.lineweight).filter(|v| *v >= 0).unwrap_or(default),
|
||||
}
|
||||
}
|
||||
|
||||
/// The linetype name (upper case) after "by layer" and "by block".
|
||||
fn linetype(&self, p: &Props, inh: &Inherit) -> String {
|
||||
match p.linetype.as_str() {
|
||||
"BYLAYER" => self.d.layer(self.layer_name(p, inh)).map_or_else(|| "CONTINUOUS".into(), |l| l.linetype.clone()),
|
||||
"BYBLOCK" => inh.linetype.clone().unwrap_or_else(|| "CONTINUOUS".into()),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn opacity(&self, p: &Props, inh: &Inherit) -> f32 {
|
||||
match p.alpha {
|
||||
Alpha::Opacity(a) => a,
|
||||
Alpha::ByBlock => inh.alpha.map_or(1.0, |a| f32::from(a) / 255.0),
|
||||
Alpha::ByLayer => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a block placed by an insert with `p` inherits.
|
||||
fn inherit(&self, p: &Props, inh: &Inherit) -> Inherit {
|
||||
Inherit {
|
||||
color: Some(self.rgb(p, inh)),
|
||||
lineweight: Some(self.lineweight(p, inh)),
|
||||
linetype: Some(self.linetype(p, inh)),
|
||||
alpha: Some((self.opacity(p, inh) * 255.0).round() as u8),
|
||||
layer: Some(self.layer_name(p, inh).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A dash pattern in points for `linetype` drawn `scale` points per drawing unit.
|
||||
fn dash(&self, linetype: &str, p: &Props, scale: f64) -> Option<(Dash, bool)> {
|
||||
let pattern = self.d.linetypes.get(linetype)?;
|
||||
let k = self.d.header.ltscale * p.ltscale * scale;
|
||||
// Dashes (positive), gaps (negative) and dots (0), merged into dash, gap, dash, gap…
|
||||
let mut out: Vec<f64> = vec![];
|
||||
for v in pattern {
|
||||
let dash = *v >= 0.0;
|
||||
let len = v.abs() * k;
|
||||
match out.len().is_multiple_of(2) {
|
||||
expects_dash if expects_dash == dash => out.push(len),
|
||||
_ if out.is_empty() => out.extend([0.0, len]),
|
||||
_ => {
|
||||
if let Some(last) = out.last_mut() {
|
||||
*last += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !out.len().is_multiple_of(2) {
|
||||
out.push(0.0);
|
||||
}
|
||||
let dots = out.iter().step_by(2).any(|d| *d <= 1e-9);
|
||||
let d = Dash { pattern: out, offset: 0.0, align_corners: false };
|
||||
(d.is_dashed() && d.pattern.iter().skip(1).step_by(2).any(|g| *g > 1e-9) && d.pattern.iter().all(|v| v.is_finite())).then_some((d, dots))
|
||||
}
|
||||
|
||||
// ---------- art ----------
|
||||
|
||||
/// One doc layer per DXF layer holding art (in the order art first uses them), or one for all.
|
||||
fn layers(&mut self, items: &[Item]) -> Vec<Arc<Node>> {
|
||||
let mut layers: Vec<(String, Vec<Arc<Node>>)> = vec![];
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let top = Inherit::default();
|
||||
let map = self.map;
|
||||
for it in items {
|
||||
let Some(node) = self.item(it, map, &top, 0) else { continue };
|
||||
let name = if self.o.merge_layers { "" } else { it.props.layer.as_str() };
|
||||
let i = *index.entry(name.to_uppercase()).or_insert_with(|| {
|
||||
layers.push((name.to_string(), vec![]));
|
||||
layers.len() - 1
|
||||
});
|
||||
if let Some((_, children)) = layers.get_mut(i) {
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
}
|
||||
layers
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, children))| {
|
||||
let id = self.doc.alloc_id();
|
||||
let color = LayerColor::Preset((i % LAYER_COLORS.len()) as u8);
|
||||
let label = if name.is_empty() { "Layer 1" } else { &name };
|
||||
let mut l = Node::layer(id, label, color);
|
||||
if let Some(def) = self.d.layer(&name) {
|
||||
l.visible = !def.hidden;
|
||||
l.locked = def.locked;
|
||||
if let NodeKind::Layer { printable, .. } = &mut l.kind {
|
||||
*printable = def.plot;
|
||||
}
|
||||
}
|
||||
if let Some(c) = l.children_mut() {
|
||||
*c = children;
|
||||
}
|
||||
Arc::new(l)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One item as an object, drawn by `map` (world coordinates to its parent's space).
|
||||
fn item(&mut self, it: &Item, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
|
||||
if self.nodes >= MAX_NODES {
|
||||
self.warn(TOO_MANY);
|
||||
return None;
|
||||
}
|
||||
let mut node = match &it.geom {
|
||||
Geom::Path { bp, fill, width } => self.path(bp, *fill, *width, &it.props, map, inh)?,
|
||||
Geom::Text(t) => self.text(t, &it.props, map, inh)?,
|
||||
Geom::Insert(ins) => return self.insert(ins, &it.props, map, inh, depth),
|
||||
};
|
||||
node.opacity = self.opacity(&it.props, inh);
|
||||
self.nodes += 1;
|
||||
Some(node)
|
||||
}
|
||||
|
||||
fn path(&mut self, bp: &BezPath, fill: bool, width: f64, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
|
||||
let mut bp = bp.clone();
|
||||
bp.apply_affine(map);
|
||||
super::curve::bounds(&bp)?;
|
||||
let paint = Paint::solid(rgb_color(self.rgb(p, inh)));
|
||||
let scale = map.determinant().abs().sqrt();
|
||||
let appearance = if fill {
|
||||
Appearance::basic(paint, Paint::None, 0.0)
|
||||
} else {
|
||||
let lw = self.lineweight(p, inh);
|
||||
let w = if width > 0.0 {
|
||||
width * scale
|
||||
} else if lw == 0 {
|
||||
THINNEST
|
||||
} else {
|
||||
f64::from(lw) / 100.0 * PT_PER_MM * self.lw_scale
|
||||
};
|
||||
let mut ap = Appearance::basic(Paint::None, paint, w);
|
||||
if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut()
|
||||
&& let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale)
|
||||
{
|
||||
s.dash = Some(dash);
|
||||
if dots {
|
||||
s.cap = LineCap::Round;
|
||||
}
|
||||
}
|
||||
ap
|
||||
};
|
||||
let rule = if fill { FillRule::EvenOdd } else { FillRule::NonZero };
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false };
|
||||
let mut n = if path.subpaths.len() > 1 {
|
||||
// Several loops (a hatch with islands) paint as one compound path.
|
||||
let children = path
|
||||
.subpaths
|
||||
.into_iter()
|
||||
.map(|sp| {
|
||||
let mut c = Node::new(self.doc.alloc_id(), kind(PathData::single(sp)));
|
||||
c.appearance = appearance.clone();
|
||||
Arc::new(c)
|
||||
})
|
||||
.collect();
|
||||
Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule })
|
||||
} else {
|
||||
Node::new(self.doc.alloc_id(), kind(path))
|
||||
};
|
||||
n.appearance = appearance;
|
||||
Some(n)
|
||||
}
|
||||
|
||||
fn text(&mut self, t: &TextItem, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
|
||||
// Text space is y-down points; the item's is y-up drawing units.
|
||||
let m = map * t.xf * Affine::FLIP_Y;
|
||||
let s = m.determinant().abs().sqrt();
|
||||
if !(s > 1e-12 && s.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let family = t.family.clone().or_else(|| self.d.styles.get(&t.style).filter(|f| !f.is_empty()).cloned());
|
||||
let default = CharStyle::default();
|
||||
let style = CharStyle {
|
||||
font_family: family.unwrap_or(default.font_family.clone()),
|
||||
size: t.height / CAP_HEIGHT * s,
|
||||
h_scale: t.width * 100.0,
|
||||
leading: t.leading.map(|l| l * s),
|
||||
fill: Paint::solid(rgb_color(self.rgb(p, inh))),
|
||||
..default
|
||||
};
|
||||
let mut obj = TextObject::point(Point::ZERO, &t.text, style);
|
||||
obj.xf = m * Affine::scale(1.0 / s);
|
||||
obj.para.justify = t.justify;
|
||||
Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))))
|
||||
}
|
||||
|
||||
/// An insert: a symbol instance per copy (anonymous blocks: a group of their art), grouped
|
||||
/// with its attributes.
|
||||
fn insert(&mut self, ins: &InsertItem, p: &Props, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
|
||||
let child = self.inherit(p, inh);
|
||||
let base = self.d.blocks.get(&ins.block).map_or(Point::ZERO, |b| b.base);
|
||||
let unbase = Affine::translate(-base.to_vec2());
|
||||
let anonymous = ins.block.starts_with('*');
|
||||
let symbol = if anonymous { None } else { self.symbol(&ins.block, &child, depth) };
|
||||
let mut parts = vec![];
|
||||
for cell in &ins.cells {
|
||||
let node = match &symbol {
|
||||
Some(name) => {
|
||||
let xf = map * *cell * self.symbol_map.inverse();
|
||||
self.nodes += 1;
|
||||
Some(Node::new(self.doc.alloc_id(), NodeKind::SymbolInstance { symbol: name.clone(), xf }))
|
||||
}
|
||||
None if anonymous => self.art(&ins.block, map * *cell * unbase, &child, depth),
|
||||
None => None,
|
||||
};
|
||||
parts.extend(node);
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
parts.extend(self.item(a, map, &child, depth));
|
||||
}
|
||||
let mut node = match parts.len() {
|
||||
0 => return None,
|
||||
1 => parts.pop()?,
|
||||
_ => Node::group(self.doc.alloc_id(), parts.into_iter().map(Arc::new).collect()),
|
||||
};
|
||||
node.opacity = self.opacity(p, inh);
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// The art of block `name` drawn by `map`, as one object.
|
||||
fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
|
||||
let items = self.block(name)?;
|
||||
if !self.enter(name, depth) {
|
||||
return None;
|
||||
}
|
||||
let mut nodes = vec![];
|
||||
for it in items.iter() {
|
||||
// Art on a hidden layer stays hidden inside blocks.
|
||||
if self.d.layer(self.layer_name(&it.props, inh)).is_some_and(|l| l.hidden) && it.props.layer != "0" {
|
||||
continue;
|
||||
}
|
||||
nodes.extend(self.item(it, map, inh, depth + 1));
|
||||
}
|
||||
self.stack.pop();
|
||||
match nodes.len() {
|
||||
0 => None,
|
||||
1 => nodes.pop(),
|
||||
_ => Some(Node::group(self.doc.alloc_id(), nodes.into_iter().map(Arc::new).collect())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol for block `name` placed with `inh` (made once per look).
|
||||
fn symbol(&mut self, name: &str, inh: &Inherit, depth: u32) -> Option<String> {
|
||||
let inh = if self.is_sensitive(name, depth) { inh.clone() } else { Inherit::default() };
|
||||
let key = (name.to_string(), inh);
|
||||
if let Some(s) = self.symbols.get(&key) {
|
||||
return s.clone();
|
||||
}
|
||||
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
|
||||
let map = self.symbol_map * Affine::translate(-base.to_vec2());
|
||||
let art = self.art(name, map, &key.1, depth);
|
||||
let made = art.map(|art| {
|
||||
let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str());
|
||||
let symbol = unique(label, |n| self.doc.symbols.iter().any(|s| s.name == n));
|
||||
self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) });
|
||||
symbol
|
||||
});
|
||||
self.symbols.insert(key, made.clone());
|
||||
made
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_color([r, g, b]: [u8; 3]) -> Color {
|
||||
Color::rgb8(r, g, b)
|
||||
}
|
||||
|
||||
/// `base`, or `base 2`, `base 3`… when taken.
|
||||
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
if !taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
//! CAD curves as Bézier paths: polylines with bulges (arc segments), circular and elliptical
|
||||
//! arcs, and splines (NURBS of any degree, rational or not, or a curve through fit points).
|
||||
//!
|
||||
//! Curves are built in drawing units with tolerances relative to their own size, so they keep
|
||||
//! their accuracy at any scale the drawing is imported at.
|
||||
|
||||
use kurbo::{Arc, BezPath, Shape, Vec2};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
/// Arcs are approximated to within this fraction of their radius.
|
||||
const ARC_TOLERANCE: f64 = 1e-6;
|
||||
/// Splines are approximated to within this fraction of their control polygon's size.
|
||||
const SPLINE_TOLERANCE: f64 = 1e-6;
|
||||
/// The most Bézier pieces one spline span may become.
|
||||
const MAX_DEPTH: u32 = 12;
|
||||
/// The most path elements one spline may become (beyond, its pieces aren't split further).
|
||||
const MAX_ELEMENTS: usize = 200_000;
|
||||
/// The highest spline degree read.
|
||||
const MAX_DEGREE: usize = 11;
|
||||
|
||||
/// A polyline vertex: its point and the bulge of the segment that starts there (the tangent of a
|
||||
/// quarter of the arc's angle; positive turns counter-clockwise).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Vertex {
|
||||
pub p: Point,
|
||||
pub bulge: f64,
|
||||
}
|
||||
|
||||
/// A polyline: straight segments, and arcs where a vertex has a bulge.
|
||||
pub(crate) fn polyline(verts: &[Vertex], closed: bool) -> Option<BezPath> {
|
||||
let first = verts.first()?;
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(first.p);
|
||||
let n = verts.len();
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { break };
|
||||
bulge_to(&mut bp, a.p, b.p, a.bulge);
|
||||
}
|
||||
if closed {
|
||||
bp.close_path();
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// A segment from `a` (the current point) to `b`, an arc when `bulge` isn't 0.
|
||||
fn bulge_to(bp: &mut BezPath, a: Point, b: Point, bulge: f64) {
|
||||
let chord = b - a;
|
||||
let len = chord.hypot();
|
||||
if bulge.abs() < 1e-12 || len < 1e-12 || !bulge.is_finite() {
|
||||
bp.line_to(b);
|
||||
return;
|
||||
}
|
||||
// The included angle is 4·atan(bulge); the centre lies off the chord's middle.
|
||||
let sweep = 4.0 * bulge.atan();
|
||||
let radius = len / (2.0 * (sweep / 2.0).sin()).abs();
|
||||
let normal = Vec2::new(-chord.y, chord.x) / len;
|
||||
let offset = (radius * radius - len * len / 4.0).max(0.0).sqrt();
|
||||
// A counter-clockwise arc under a half turn has its centre left of the chord.
|
||||
let side = if (bulge > 0.0) == (bulge.abs() < 1.0) { 1.0 } else { -1.0 };
|
||||
let centre = a.midpoint(b) + normal * offset * side;
|
||||
let start = (a - centre).atan2();
|
||||
arc_to(bp, centre, radius, start, sweep);
|
||||
}
|
||||
|
||||
/// An arc of a circle from angle `start` (radians) through `sweep` (positive: counter-clockwise),
|
||||
/// from the current point.
|
||||
fn arc_to(bp: &mut BezPath, centre: Point, radius: f64, start: f64, sweep: f64) {
|
||||
let arc = Arc::new(centre, (radius, radius), start, sweep, 0.0);
|
||||
for el in arc.append_iter((radius * ARC_TOLERANCE).max(1e-12)) {
|
||||
bp.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
/// A circular arc from `start` through `sweep` radians as its own subpath; a whole circle closes.
|
||||
pub(crate) fn arc(centre: Point, radius: f64, start: f64, sweep: f64) -> Option<BezPath> {
|
||||
if !(radius > 0.0 && radius.is_finite() && sweep.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let full = sweep.abs() >= std::f64::consts::TAU - 1e-9;
|
||||
let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(centre + Vec2::from_angle(start) * radius);
|
||||
arc_to(&mut bp, centre, radius, start, sweep);
|
||||
if full {
|
||||
bp.close_path();
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// An elliptical arc: the unit circle's arc from `start` through `sweep` radians mapped by `frame`
|
||||
/// (its columns are the major and minor semi-axes, its translation the centre).
|
||||
pub(crate) fn ellipse(frame: Affine, start: f64, sweep: f64) -> Option<BezPath> {
|
||||
let [a, b, c, d, _, _] = frame.as_coeffs();
|
||||
let size = a.hypot(b).max(c.hypot(d));
|
||||
if !(size > 1e-12 && size.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let full = sweep.abs() >= std::f64::consts::TAU - 1e-9;
|
||||
let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
|
||||
let unit = Arc::new(Point::ZERO, (1.0, 1.0), start, sweep, 0.0);
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(Point::ZERO + Vec2::from_angle(start));
|
||||
for el in unit.append_iter(ARC_TOLERANCE) {
|
||||
bp.push(el);
|
||||
}
|
||||
if full {
|
||||
bp.close_path();
|
||||
}
|
||||
bp.apply_affine(frame);
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// A point with a weight, in homogeneous form (`x·w, y·w, w`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct H {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
}
|
||||
|
||||
impl H {
|
||||
fn lerp(self, o: Self, t: f64) -> Self {
|
||||
Self { x: self.x + (o.x - self.x) * t, y: self.y + (o.y - self.y) * t, w: self.w + (o.w - self.w) * t }
|
||||
}
|
||||
fn point(self) -> Option<Point> {
|
||||
(self.w.abs() > 1e-300).then(|| Point::new(self.x / self.w, self.y / self.w)).filter(|p| p.x.is_finite() && p.y.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
/// A NURBS curve of `degree` with `knots`, control points `ctrl` and their `weights` (none:
|
||||
/// all 1) as Bézier pieces: exact for polynomial curves up to degree 3, else within
|
||||
/// [`SPLINE_TOLERANCE`] of the control polygon's size.
|
||||
pub(crate) fn nurbs(degree: usize, knots: &[f64], ctrl: &[Point], weights: Option<&[f64]>) -> Option<BezPath> {
|
||||
let n = ctrl.len();
|
||||
if !(1..=MAX_DEGREE).contains(°ree) || n <= degree || knots.len() != n + degree + 1 {
|
||||
return None;
|
||||
}
|
||||
if knots.windows(2).any(|w| w[1] < w[0]) {
|
||||
return None;
|
||||
}
|
||||
let weight = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0);
|
||||
let hs: Vec<H> = ctrl.iter().enumerate().map(|(i, p)| H { x: p.x * weight(i), y: p.y * weight(i), w: weight(i) }).collect();
|
||||
let rational = (0..n).any(|i| (weight(i) - weight(0)).abs() > 1e-12);
|
||||
let (lo, hi) = ctrl.iter().fold((Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)), |(lo, hi), p| {
|
||||
(Point::new(lo.x.min(p.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y)))
|
||||
});
|
||||
let tol = ((hi - lo).hypot() * SPLINE_TOLERANCE).max(1e-12);
|
||||
let mut bp = BezPath::new();
|
||||
// Each knot span of the domain is one polynomial piece: its Bézier points are blossoms.
|
||||
for span in degree..n {
|
||||
let (Some(&t0), Some(&t1)) = (knots.get(span), knots.get(span + 1)) else { continue };
|
||||
if t1 - t0 <= 1e-12 * (1.0 + t0.abs()) {
|
||||
continue;
|
||||
}
|
||||
let bez: Vec<H> = (0..=degree)
|
||||
.map(|j| {
|
||||
let args: Vec<f64> = (0..degree).map(|k| if k < degree - j { t0 } else { t1 }).collect();
|
||||
blossom(degree, knots, &hs, span, &args)
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let start = bez.first().and_then(|h| h.point())?;
|
||||
if bp.elements().is_empty() {
|
||||
bp.move_to(start);
|
||||
}
|
||||
let pts: Option<Vec<Point>> = if rational { None } else { bez.iter().map(|h| h.point()).collect() };
|
||||
match pts.as_deref() {
|
||||
Some([_, p]) => bp.line_to(*p),
|
||||
Some([_, q, p]) => bp.quad_to(*q, *p),
|
||||
Some([_, a, b, p]) => bp.curve_to(*a, *b, *p),
|
||||
_ => fit(&mut bp, &bez, 0.0, 1.0, tol, 0)?,
|
||||
}
|
||||
}
|
||||
(bp.elements().len() > 1).then_some(bp)
|
||||
}
|
||||
|
||||
/// The blossom of span `span` at `args` (de Boor's algorithm with one parameter per level).
|
||||
fn blossom(degree: usize, knots: &[f64], ctrl: &[H], span: usize, args: &[f64]) -> Option<H> {
|
||||
let mut d: Vec<H> = ctrl.get(span - degree..=span)?.to_vec();
|
||||
for r in 1..=degree {
|
||||
let u = *args.get(r - 1)?;
|
||||
for j in (r..=degree).rev() {
|
||||
let i = span - degree + j;
|
||||
let (a, b) = (*knots.get(i)?, *knots.get(i + degree + 1 - r)?);
|
||||
let alpha = if b - a > 1e-300 { (u - a) / (b - a) } else { 0.0 };
|
||||
let prev = *d.get(j - 1)?;
|
||||
let cur = d.get_mut(j)?;
|
||||
*cur = prev.lerp(*cur, alpha);
|
||||
}
|
||||
}
|
||||
d.get(degree).copied()
|
||||
}
|
||||
|
||||
/// A (rational) Bézier of any degree at `t`, with its derivative.
|
||||
fn eval(bez: &[H], t: f64) -> Option<(Point, Vec2)> {
|
||||
let mut pts = bez.to_vec();
|
||||
let n = pts.len().checked_sub(1)?;
|
||||
// Down to the two points whose difference gives the tangent.
|
||||
for level in (1..=n).rev() {
|
||||
if level == 1 {
|
||||
let (a, b) = (*pts.first()?, *pts.get(1)?);
|
||||
let p = a.lerp(b, t);
|
||||
let pt = p.point()?;
|
||||
// d/dt (X/W) = (X'·W − X·W') / W², with X' = n·(b − a).
|
||||
let k = n as f64;
|
||||
let (dx, dy, dw) = (k * (b.x - a.x), k * (b.y - a.y), k * (b.w - a.w));
|
||||
let w2 = p.w * p.w;
|
||||
let tangent = Vec2::new((dx * p.w - p.x * dw) / w2, (dy * p.w - p.y * dw) / w2);
|
||||
return Some((pt, tangent));
|
||||
}
|
||||
for i in 0..level {
|
||||
let next = *pts.get(i + 1)?;
|
||||
let cur = pts.get_mut(i)?;
|
||||
*cur = cur.lerp(next, t);
|
||||
}
|
||||
}
|
||||
// Degree 0: a point.
|
||||
Some((pts.first()?.point()?, Vec2::ZERO))
|
||||
}
|
||||
|
||||
/// Cubic pieces through the curve `bez` from `t0` to `t1`, each matching its ends and their
|
||||
/// tangents, split until within `tol` of the curve.
|
||||
fn fit(bp: &mut BezPath, bez: &[H], t0: f64, t1: f64, tol: f64, depth: u32) -> Option<()> {
|
||||
let (p0, d0) = eval(bez, t0)?;
|
||||
let (p3, d3) = eval(bez, t1)?;
|
||||
let h = (t1 - t0) / 3.0;
|
||||
let (c1, c2) = (p0 + d0 * h, p3 - d3 * h);
|
||||
let cubic = kurbo::CubicBez::new(p0, c1, c2, p3);
|
||||
let close = (1..8).all(|i| {
|
||||
let s = f64::from(i) / 8.0;
|
||||
eval(bez, t0 + (t1 - t0) * s).is_some_and(|(p, _)| kurbo::ParamCurve::eval(&cubic, s).distance(p) <= tol)
|
||||
});
|
||||
if close || depth >= MAX_DEPTH || bp.elements().len() >= MAX_ELEMENTS {
|
||||
if [c1, c2].iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
|
||||
bp.curve_to(c1, c2, p3);
|
||||
} else {
|
||||
bp.line_to(p3);
|
||||
}
|
||||
return Some(());
|
||||
}
|
||||
let mid = (t0 + t1) / 2.0;
|
||||
fit(bp, bez, t0, mid, tol, depth + 1)?;
|
||||
fit(bp, bez, mid, t1, tol, depth + 1)
|
||||
}
|
||||
|
||||
/// A smooth curve through `pts` (a spline given by fit points only): Catmull-Rom tangents.
|
||||
pub(crate) fn through(pts: &[Point], closed: bool) -> Option<BezPath> {
|
||||
let n = pts.len();
|
||||
let first = *pts.first()?;
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
let at = |i: isize| -> Point {
|
||||
let len = n as isize;
|
||||
let j = if closed { i.rem_euclid(len) } else { i.clamp(0, len - 1) };
|
||||
pts.get(j as usize).copied().unwrap_or(first)
|
||||
};
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(first);
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs as isize {
|
||||
let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
|
||||
bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2);
|
||||
}
|
||||
if closed {
|
||||
bp.close_path();
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// `bp`'s bounds, when it has any finite extent.
|
||||
pub(crate) fn bounds(bp: &BezPath) -> Option<vectorcraft_geom::Rect> {
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let r = bp.bounding_box();
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kurbo::PathEl;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The largest distance from `centre` minus `r` over points along `bp`.
|
||||
fn radial_error(bp: &BezPath, centre: Point, r: f64) -> f64 {
|
||||
let mut worst: f64 = 0.0;
|
||||
for seg in bp.segments() {
|
||||
for i in 0..=32 {
|
||||
let p = kurbo::ParamCurve::eval(&seg, f64::from(i) / 32.0);
|
||||
worst = worst.max((p.distance(centre) - r).abs());
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bulge_of_one_is_a_half_circle_on_the_right_of_its_chord() {
|
||||
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: 1.0 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
|
||||
let bp = polyline(&verts, false).unwrap();
|
||||
assert!(radial_error(&bp, Point::new(5.0, 0.0), 5.0) < 1e-4);
|
||||
// Counter-clockwise from (0, 0) to (10, 0) passes below the chord.
|
||||
let b = bp.bounding_box();
|
||||
assert!((b.y0 + 5.0).abs() < 1e-3 && b.y1.abs() < 1e-9, "{b:?}");
|
||||
// A negative bulge turns the other way; a small one is a shallow arc.
|
||||
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: -0.25 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
|
||||
let bp = polyline(&verts, false).unwrap();
|
||||
// Sagitta = bulge · chord / 2; radius = chord (1 + bulge²) / (4 |bulge|).
|
||||
let b = bp.bounding_box();
|
||||
assert!((b.y1 - 1.25).abs() < 1e-4 && b.y0.abs() < 1e-9, "{b:?}");
|
||||
let r = 10.0 * (1.0 + 0.0625) / 1.0;
|
||||
assert!(radial_error(&bp, Point::new(5.0, 1.25 - r), r) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bulge_over_one_is_the_major_arc() {
|
||||
let b = (3.0 * std::f64::consts::PI / 8.0).tan(); // a 270° arc
|
||||
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: b }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
|
||||
let bp = polyline(&verts, false).unwrap();
|
||||
let r = 10.0 / (2.0 * (135f64).to_radians().sin());
|
||||
// Counter-clockwise, so the centre and most of the circle lie right of the chord.
|
||||
let centre = Point::new(5.0, -5.0);
|
||||
assert!(radial_error(&bp, centre, r) < 1e-4);
|
||||
assert!(bp.bounding_box().y0 < -12.0, "most of the circle lies below the chord");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rational_quadratic_circle_stays_on_the_circle() {
|
||||
// A whole circle of radius 100 as nine weighted control points (four quarter arcs).
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let ctrl = [(1., 0.), (1., 1.), (0., 1.), (-1., 1.), (-1., 0.), (-1., -1.), (0., -1.), (1., -1.), (1., 0.)]
|
||||
.map(|(x, y)| Point::new(x * 100.0, y * 100.0));
|
||||
let weights = [1.0, w, 1.0, w, 1.0, w, 1.0, w, 1.0];
|
||||
let knots = [0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 4.0];
|
||||
let bp = nurbs(2, &knots, &ctrl, Some(&weights)).unwrap();
|
||||
let err = radial_error(&bp, Point::ZERO, 100.0);
|
||||
assert!(err < 0.01, "error {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polynomial_splines_become_exact_beziers() {
|
||||
// A clamped cubic with one interior knot: two cubic pieces meeting at its blossom.
|
||||
let ctrl = [(0., 0.), (10., 20.), (30., 20.), (40., 0.), (50., -10.)].map(|(x, y)| Point::new(x, y));
|
||||
let knots = [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0];
|
||||
let bp = nurbs(3, &knots, &ctrl, None).unwrap();
|
||||
assert_eq!(bp.elements().iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2);
|
||||
assert!(matches!(bp.elements().first(), Some(PathEl::MoveTo(p)) if *p == Point::ZERO));
|
||||
let end = match bp.elements().last() {
|
||||
Some(PathEl::CurveTo(_, _, p)) => *p,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(end, Point::new(50.0, -10.0));
|
||||
// Bad knot vectors are refused rather than read.
|
||||
assert!(nurbs(3, &knots[1..], &ctrl, None).is_none());
|
||||
assert!(nurbs(3, &[0.0, 0.0, 0.0, 0.0, 2.0, 1.0, 2.0, 2.0, 2.0], &ctrl, None).is_none());
|
||||
assert!(nurbs(0, &knots, &ctrl, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_high_degree_spline_is_fitted_within_tolerance() {
|
||||
// Degree 5 Bézier (one span) of a known polynomial: compare with direct evaluation.
|
||||
let ctrl: Vec<Point> = (0..6).map(|i| Point::new(f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 30.0 })).collect();
|
||||
let knots = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
|
||||
let bp = nurbs(5, &knots, &ctrl, None).unwrap();
|
||||
let hs: Vec<H> = ctrl.iter().map(|p| H { x: p.x, y: p.y, w: 1.0 }).collect();
|
||||
for i in 0..=20 {
|
||||
let (p, _) = eval(&hs, f64::from(i) / 20.0).unwrap();
|
||||
let near = bp.segments().map(|s| kurbo::ParamCurveNearest::nearest(&s, p, 1e-9).distance_sq.sqrt()).fold(f64::MAX, f64::min);
|
||||
assert!(near < 0.01, "error {near}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_points_make_a_smooth_curve_through_them() {
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)];
|
||||
let bp = through(&pts, false).unwrap();
|
||||
let ends: Vec<Point> = bp.elements().iter().filter_map(|e| if let PathEl::CurveTo(_, _, p) = e { Some(*p) } else { None }).collect();
|
||||
assert_eq!(ends, [Point::new(10.0, 10.0), Point::new(20.0, 0.0)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,659 @@
|
|||
//! Entities to drawing items: their geometry in world coordinates (drawing units, y up) and the
|
||||
//! properties they take from their layer or block (resolved when the items become art).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use kurbo::{BezPath, PathEl};
|
||||
use vectorcraft_doc::Justify;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::curve::{self, Vertex};
|
||||
use super::reader::{Groups, Pair, Record, groups, number};
|
||||
use crate::aci::aci_rgb;
|
||||
|
||||
/// How far below the baseline the lowest descender reaches, as a fraction of the cap height.
|
||||
const DESCENT: f64 = 0.3;
|
||||
/// Baseline-to-baseline distance of multiline text at line spacing 1, in cap heights.
|
||||
const LINE_SPACING: f64 = 5.0 / 3.0;
|
||||
/// The most copies one array insert (MINSERT) makes.
|
||||
const MAX_CELLS: i64 = 10_000;
|
||||
/// Art farther than this from the origin (drawing units) is damaged data, left out.
|
||||
const MAX_COORD: f64 = 1e12;
|
||||
|
||||
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
|
||||
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
|
||||
match u8::try_from(i) {
|
||||
Ok(7) | Err(_) => [0; 3],
|
||||
Ok(i) => aci_rgb(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The true colour (group 420, `0xRRGGBB`) of an entity or layer.
|
||||
pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> {
|
||||
let v = g.int(420)?;
|
||||
let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes();
|
||||
Some([r, gr, b])
|
||||
}
|
||||
|
||||
/// An entity's colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Col {
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Rgb([u8; 3]),
|
||||
}
|
||||
|
||||
/// An entity's lineweight.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Lw {
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Default,
|
||||
/// Hundredths of a millimetre.
|
||||
Hundredths(i32),
|
||||
}
|
||||
|
||||
/// An entity's opacity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Alpha {
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Opacity(f32),
|
||||
}
|
||||
|
||||
/// What an entity takes from its layer or block unless it sets it itself.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Props {
|
||||
pub layer: String,
|
||||
pub color: Col,
|
||||
pub lineweight: Lw,
|
||||
/// The linetype name in upper case (`BYLAYER`, `BYBLOCK`, `CONTINUOUS` or a table entry).
|
||||
pub linetype: String,
|
||||
pub ltscale: f64,
|
||||
pub alpha: Alpha,
|
||||
}
|
||||
|
||||
impl Props {
|
||||
fn read(g: &[Pair<'_>]) -> Self {
|
||||
let color = match (true_color(g), g.int(62)) {
|
||||
(Some(rgb), _) => Col::Rgb(rgb),
|
||||
(None, Some(0)) => Col::ByBlock,
|
||||
(None, None | Some(256..)) => Col::ByLayer,
|
||||
(None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())),
|
||||
};
|
||||
let lineweight = match g.int(370) {
|
||||
Some(-2) => Lw::ByBlock,
|
||||
Some(-3) => Lw::Default,
|
||||
Some(v @ 0..=211) => Lw::Hundredths(v as i32),
|
||||
_ => Lw::ByLayer,
|
||||
};
|
||||
let alpha = match g.int(440) {
|
||||
Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0),
|
||||
Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock,
|
||||
_ => Alpha::ByLayer,
|
||||
};
|
||||
Self {
|
||||
layer: g.name(8).unwrap_or("0").to_string(),
|
||||
color,
|
||||
lineweight,
|
||||
linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(),
|
||||
ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0),
|
||||
alpha,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line of text.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TextItem {
|
||||
pub text: String,
|
||||
/// Cap height, in drawing units.
|
||||
pub height: f64,
|
||||
/// Text space (the first baseline's start at the origin, x along it, y up; drawing units) to
|
||||
/// world coordinates.
|
||||
pub xf: Affine,
|
||||
/// Width factor (1: as designed).
|
||||
pub width: f64,
|
||||
pub justify: Justify,
|
||||
/// The text style (upper case).
|
||||
pub style: String,
|
||||
/// A font family the text names itself (multiline text's font changes).
|
||||
pub family: Option<String>,
|
||||
/// Baseline-to-baseline distance (drawing units), for several lines.
|
||||
pub leading: Option<f64>,
|
||||
}
|
||||
|
||||
/// A block reference: one transform per copy (an array insert makes several), each from the
|
||||
/// block's coordinates (less its base point) to world coordinates.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct InsertItem {
|
||||
/// The block (upper case).
|
||||
pub block: String,
|
||||
pub cells: Vec<Affine>,
|
||||
/// Its attributes' text.
|
||||
pub attribs: Vec<Item>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Geom {
|
||||
/// A path in world coordinates; `fill`: a filled area (hatch, solid), else a line of
|
||||
/// `width` drawing units (0: the lineweight).
|
||||
Path {
|
||||
bp: BezPath,
|
||||
fill: bool,
|
||||
width: f64,
|
||||
},
|
||||
Text(TextItem),
|
||||
Insert(InsertItem),
|
||||
}
|
||||
|
||||
/// An entity as art: its geometry and its properties.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Item {
|
||||
pub props: Props,
|
||||
pub geom: Geom,
|
||||
}
|
||||
|
||||
/// Turns entity records into items, noting what it leaves out.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Converter {
|
||||
/// Entity types left out, with how many.
|
||||
pub skipped: BTreeMap<String, usize>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
const PATTERN_HATCH: &str = "pattern hatches come in as their boundaries";
|
||||
const GRADIENT_HATCH: &str = "gradient hatches are filled with their first colour";
|
||||
const MESHES: &str = "3D polygon meshes and polyface meshes are left out";
|
||||
const BIG_ARRAY: &str = "array inserts are limited to 10,000 copies each";
|
||||
const FAR_AWAY: &str = "entities with coordinates out of range are left out";
|
||||
|
||||
impl Converter {
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn skip(&mut self, kind: &str) {
|
||||
*self.skipped.entry(kind.to_string()).or_default() += 1;
|
||||
}
|
||||
|
||||
/// The items of `records` (an entity section or a block), in drawing order.
|
||||
pub fn items(&mut self, records: &[Record<'_>]) -> Vec<Item> {
|
||||
let mut out = Vec::new();
|
||||
for group in groups(records) {
|
||||
let Some(head) = group.first() else { continue };
|
||||
// Invisible entities.
|
||||
if head.g.int(60) == Some(1) {
|
||||
continue;
|
||||
}
|
||||
match self.geom(head, group) {
|
||||
Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY),
|
||||
Some(geom) => out.push(Item { props: Props::read(head.g), geom }),
|
||||
None if is_known(head.kind) => {}
|
||||
None => self.skip(head.kind),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option<Geom> {
|
||||
let g = head.g;
|
||||
let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 });
|
||||
match head.kind {
|
||||
"LINE" => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(g.point(10));
|
||||
bp.line_to(g.point(11));
|
||||
path(bp)
|
||||
}
|
||||
"CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))),
|
||||
"ARC" => {
|
||||
let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0));
|
||||
let sweep = ccw_sweep(start, end);
|
||||
path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g)))
|
||||
}
|
||||
"ELLIPSE" => path(ellipse(g)?),
|
||||
"LWPOLYLINE" => lwpolyline(g),
|
||||
"POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])),
|
||||
"SPLINE" => path(spline(g)?),
|
||||
"SOLID" | "TRACE" => {
|
||||
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
|
||||
// The corners zigzag: the outline runs 1, 2, 4, 3.
|
||||
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 })
|
||||
}
|
||||
"3DFACE" => {
|
||||
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
|
||||
path(polygon(&[a, b, c, d]))
|
||||
}
|
||||
"HATCH" => self.hatch(g),
|
||||
"TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text),
|
||||
"MTEXT" => mtext(g).map(Geom::Text),
|
||||
"INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])),
|
||||
// A dimension draws the anonymous block of its lines, arrows and text.
|
||||
"DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
|
||||
let flags = g.int_or(70, 0);
|
||||
if flags & (16 | 64) != 0 {
|
||||
self.warn(MESHES);
|
||||
return None;
|
||||
}
|
||||
// Spline frame control points and polyface faces aren't on the line.
|
||||
let verts: Vec<Vertex> = rest
|
||||
.iter()
|
||||
.filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0)
|
||||
.map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) })
|
||||
.collect();
|
||||
let bp = curve::polyline(&verts, flags & 1 != 0)?;
|
||||
// 3D polylines are in world coordinates; 2D ones in their plane's.
|
||||
let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) };
|
||||
Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) })
|
||||
}
|
||||
|
||||
fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
|
||||
let block = g.name(2)?.to_uppercase();
|
||||
let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0));
|
||||
if sx.abs() < 1e-12 || sy.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians());
|
||||
let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1));
|
||||
let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0));
|
||||
let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS {
|
||||
self.warn(BIG_ARRAY);
|
||||
(cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows))
|
||||
} else {
|
||||
(cols, rows)
|
||||
};
|
||||
let cells = (0..rows)
|
||||
.flat_map(|r| (0..cols).map(move |c| (r, c)))
|
||||
.map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy))
|
||||
.collect();
|
||||
let attribs = self.items(rest);
|
||||
Some(Geom::Insert(InsertItem { block, cells, attribs }))
|
||||
}
|
||||
|
||||
fn hatch(&mut self, g: &[Pair<'_>]) -> Option<Geom> {
|
||||
let start = g.iter().position(|p| p.code == 91)?;
|
||||
let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize;
|
||||
let mut c = Cursor { g, i: start + 1 };
|
||||
let mut bp = BezPath::new();
|
||||
for _ in 0..count {
|
||||
let Some(path) = boundary(&mut c) else { break };
|
||||
bp.extend(path);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bp = transformed(bp, ocs(g));
|
||||
let solid = g.int_or(70, 0) == 1;
|
||||
if g.int_or(450, 0) == 1 {
|
||||
self.warn(GRADIENT_HATCH);
|
||||
} else if !solid {
|
||||
self.warn(PATTERN_HATCH);
|
||||
return Some(Geom::Path { bp, fill: false, width: 0.0 });
|
||||
}
|
||||
Some(Geom::Path { bp, fill: true, width: 0.0 })
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the geometry within [`MAX_COORD`] of the origin?
|
||||
fn in_range(g: &Geom) -> bool {
|
||||
let near = |p: Point| p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD;
|
||||
let origin = |m: &Affine| near(m.translation().to_point());
|
||||
match g {
|
||||
Geom::Path { bp, width, .. } => {
|
||||
curve::bounds(bp).is_some_and(|b| near(Point::new(b.x0, b.y0)) && near(Point::new(b.x1, b.y1))) && *width <= MAX_COORD
|
||||
}
|
||||
Geom::Text(t) => origin(&t.xf) && t.height <= MAX_COORD,
|
||||
Geom::Insert(i) => i.cells.iter().all(|m| origin(m) && m.as_coeffs().iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity types that are read but draw nothing themselves (or were reported when read).
|
||||
fn is_known(kind: &str) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
"LINE"
|
||||
| "CIRCLE"
|
||||
| "ARC"
|
||||
| "ELLIPSE"
|
||||
| "LWPOLYLINE"
|
||||
| "POLYLINE"
|
||||
| "SPLINE"
|
||||
| "SOLID"
|
||||
| "TRACE"
|
||||
| "3DFACE"
|
||||
| "HATCH"
|
||||
| "TEXT"
|
||||
| "ATTRIB"
|
||||
| "MTEXT"
|
||||
| "INSERT"
|
||||
| "DIMENSION"
|
||||
| "ATTDEF"
|
||||
| "SEQEND"
|
||||
| "VERTEX"
|
||||
)
|
||||
}
|
||||
|
||||
/// The sweep from `start` to `end` degrees counter-clockwise, in (0, 360].
|
||||
fn ccw_sweep(start: f64, end: f64) -> f64 {
|
||||
let s = (end - start).rem_euclid(360.0);
|
||||
if s < 1e-9 { 360.0 } else { s }
|
||||
}
|
||||
|
||||
fn transformed(mut bp: BezPath, m: Affine) -> BezPath {
|
||||
if m != Affine::IDENTITY {
|
||||
bp.apply_affine(m);
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
fn polygon(pts: &[Point]) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
|
||||
}
|
||||
bp.close_path();
|
||||
bp
|
||||
}
|
||||
|
||||
/// The extrusion direction (210, 220, 230) of a planar entity, normalised.
|
||||
fn extrusion(g: &[Pair<'_>]) -> [f64; 3] {
|
||||
let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] }
|
||||
}
|
||||
|
||||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
fn unit(v: [f64; 3]) -> [f64; 3] {
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if len > 1e-12 { v.map(|x| x / len) } else { v }
|
||||
}
|
||||
|
||||
/// A planar entity's coordinate system seen from above: its x and y axes in world space by the
|
||||
/// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it
|
||||
/// mirrors x).
|
||||
fn ocs(g: &[Pair<'_>]) -> Affine {
|
||||
let n = extrusion(g);
|
||||
if n[2] > 1.0 - 1e-12 {
|
||||
return Affine::IDENTITY;
|
||||
}
|
||||
let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) });
|
||||
let ay = unit(cross(n, ax));
|
||||
Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0])
|
||||
}
|
||||
|
||||
fn ellipse(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let centre = g.point(10);
|
||||
let major = g.point(11).to_vec2();
|
||||
let ratio = g.num_or(40, 1.0);
|
||||
if ratio <= 0.0 || major.hypot() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// The minor axis is the major one turned a quarter about the extrusion direction.
|
||||
let n = extrusion(g);
|
||||
let m = cross(n, [major.x, major.y, 0.0]);
|
||||
let minor = Vec2::new(m[0], m[1]) * ratio;
|
||||
let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU));
|
||||
let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep)
|
||||
}
|
||||
|
||||
fn lwpolyline(g: &[Pair<'_>]) -> Option<Geom> {
|
||||
let mut verts: Vec<Vertex> = Vec::new();
|
||||
let (mut widths, mut uniform) = (None, true);
|
||||
for p in g {
|
||||
match p.code {
|
||||
10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }),
|
||||
20 => verts.last_mut()?.p.y = number(p.value)?,
|
||||
42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0),
|
||||
40 | 41 => {
|
||||
let w = number(p.value).unwrap_or(0.0);
|
||||
uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12);
|
||||
widths = Some(w);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g));
|
||||
let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0);
|
||||
Some(Geom::Path { bp, fill: false, width })
|
||||
}
|
||||
|
||||
/// The points at `code` (x) and `code + 10` (y), in order.
|
||||
fn points(g: &[Pair<'_>], code: i32) -> Option<Vec<Point>> {
|
||||
let mut out: Vec<Point> = Vec::new();
|
||||
for p in g {
|
||||
if p.code == code {
|
||||
out.push(Point::new(number(p.value)?, 0.0));
|
||||
} else if p.code == code + 10 {
|
||||
out.last_mut()?.y = number(p.value)?;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let flags = g.int_or(70, 0);
|
||||
let ctrl = points(g, 10)?;
|
||||
let degree = usize::try_from(g.int_or(71, 3)).ok()?;
|
||||
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
|
||||
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
|
||||
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
|
||||
curve::nurbs(degree, &knots, &ctrl, weights).or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
|
||||
}
|
||||
|
||||
/// Reads a hatch's boundary groups in order.
|
||||
struct Cursor<'g, 'a> {
|
||||
g: &'g [Pair<'a>],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl Cursor<'_, '_> {
|
||||
/// The next group's value when it has `code` (consumed).
|
||||
fn opt(&mut self, code: i32) -> Option<&str> {
|
||||
let p = self.g.get(self.i).filter(|p| p.code == code)?;
|
||||
self.i += 1;
|
||||
Some(p.value)
|
||||
}
|
||||
fn num(&mut self, code: i32) -> Option<f64> {
|
||||
self.opt(code).and_then(number)
|
||||
}
|
||||
fn count(&mut self, code: i32) -> Option<usize> {
|
||||
self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize)
|
||||
}
|
||||
fn point(&mut self, code: i32) -> Option<Point> {
|
||||
Some(Point::new(self.num(code)?, self.num(code + 10)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// One hatch boundary loop, closed.
|
||||
fn boundary(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
|
||||
let flags = c.num(92)? as i64;
|
||||
let mut bp = BezPath::new();
|
||||
if flags & 2 != 0 {
|
||||
let bulges = c.num(72).unwrap_or(0.0) != 0.0;
|
||||
let _closed = c.opt(73);
|
||||
let n = c.count(93)?;
|
||||
let mut verts = Vec::with_capacity(n.min(4096));
|
||||
for _ in 0..n {
|
||||
let p = c.point(10)?;
|
||||
let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 };
|
||||
verts.push(Vertex { p, bulge });
|
||||
}
|
||||
bp = curve::polyline(&verts, true)?;
|
||||
} else {
|
||||
let n = c.count(93)?;
|
||||
for _ in 0..n {
|
||||
let edge = edge(c)?;
|
||||
append(&mut bp, &edge);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
bp.close_path();
|
||||
}
|
||||
// The boundary objects it was made from.
|
||||
if let Some(n) = c.count(97) {
|
||||
for _ in 0..n {
|
||||
c.opt(330);
|
||||
}
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// One edge of a hatch boundary.
|
||||
fn edge(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
|
||||
match c.num(72)? as i64 {
|
||||
1 => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(c.point(10)?);
|
||||
bp.line_to(c.point(11)?);
|
||||
Some(bp)
|
||||
}
|
||||
2 => {
|
||||
let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?);
|
||||
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
|
||||
let sweep = ccw_sweep(start, end);
|
||||
// Clockwise arcs have their angles measured clockwise.
|
||||
let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) };
|
||||
curve::arc(centre, r, start.to_radians(), sweep.to_radians())
|
||||
}
|
||||
3 => {
|
||||
let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?);
|
||||
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
|
||||
let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 };
|
||||
let sweep = ccw_sweep(start, end).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep)
|
||||
}
|
||||
4 => {
|
||||
let degree = c.count(94)?;
|
||||
let rational = c.num(73).unwrap_or(0.0) != 0.0;
|
||||
let _periodic = c.opt(74);
|
||||
let (nk, nc) = (c.count(95)?, c.count(96)?);
|
||||
let knots: Vec<f64> = (0..nk).map(|_| c.num(40)).collect::<Option<_>>()?;
|
||||
let mut ctrl = Vec::with_capacity(nc.min(4096));
|
||||
let mut weights = Vec::with_capacity(nc.min(4096));
|
||||
for _ in 0..nc {
|
||||
ctrl.push(c.point(10)?);
|
||||
weights.push(c.num(42).unwrap_or(1.0));
|
||||
}
|
||||
let fit: Vec<Point> = match c.count(97) {
|
||||
Some(n) => (0..n).map(|_| c.point(11)).collect::<Option<_>>()?,
|
||||
None => vec![],
|
||||
};
|
||||
// Start and end tangents.
|
||||
for code in [12, 13] {
|
||||
c.point(code);
|
||||
}
|
||||
curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `seg` continuing `bp`: joined by a line when it starts elsewhere.
|
||||
fn append(bp: &mut BezPath, seg: &BezPath) {
|
||||
for el in seg.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
let here = bp.elements().last().and_then(|e| e.end_point());
|
||||
match here {
|
||||
None => bp.move_to(p),
|
||||
Some(q) if q.distance(p) > 1e-9 => bp.line_to(p),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
PathEl::ClosePath => {}
|
||||
other => bp.push(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The baseline offset of vertical alignment `v` (0 baseline, 1 bottom, 2 middle, 3 top).
|
||||
fn baseline(v: i64, height: f64) -> f64 {
|
||||
match v {
|
||||
1 => height * DESCENT,
|
||||
2 => -height / 2.0,
|
||||
3 => -height,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
|
||||
if attrib && g.int_or(70, 0) & 1 != 0 {
|
||||
return None;
|
||||
}
|
||||
let s = super::text::plain(g.raw(1)?);
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let height = g.num(40).filter(|h| *h > 0.0)?;
|
||||
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
|
||||
let (p1, p2) = (g.point(10), g.point_opt(11));
|
||||
let mut angle = g.num_or(50, 0.0).to_radians();
|
||||
// Aligned and fit text run from the first point to the second.
|
||||
let anchor = match (h, p2) {
|
||||
(3 | 5, Some(p)) => {
|
||||
angle = (p - p1).atan2();
|
||||
p1
|
||||
}
|
||||
(0, _) if v == 0 => p1,
|
||||
(_, Some(p)) => p,
|
||||
_ => p1,
|
||||
};
|
||||
let justify = match h {
|
||||
1 | 4 => Justify::Center,
|
||||
2 => Justify::Right,
|
||||
_ => Justify::Left,
|
||||
};
|
||||
let v = if h == 4 { 2 } else { v };
|
||||
let flags = g.int_or(71, 0);
|
||||
let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 });
|
||||
let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
|
||||
Some(TextItem {
|
||||
text: s,
|
||||
height,
|
||||
xf,
|
||||
width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0),
|
||||
justify,
|
||||
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
|
||||
family: None,
|
||||
leading: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
|
||||
// The text comes in 250-character chunks (3) before the last (1).
|
||||
let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect();
|
||||
let (s, family) = super::text::mtext(&raw);
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let height = g.num(40).filter(|h| *h > 0.0)?;
|
||||
let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0);
|
||||
let lines = s.lines().count().max(1) as f64;
|
||||
let attach = g.int_or(71, 1).clamp(1, 9) - 1;
|
||||
let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default();
|
||||
// The first baseline below the attachment point: under the top, around the middle, above
|
||||
// the bottom of the lines.
|
||||
let total = height + (lines - 1.0) * leading;
|
||||
let first = match attach / 3 {
|
||||
0 => -height,
|
||||
1 => total / 2.0 - height,
|
||||
_ => (lines - 1.0) * leading,
|
||||
};
|
||||
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
|
||||
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
|
||||
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
|
||||
Some(TextItem { text: s, height, xf, width: 1.0, justify, style: g.name(7).unwrap_or("STANDARD").to_uppercase(), family, leading: Some(leading) })
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its
|
||||
//! sections (header variables; the layer, linetype, text style and block record tables; the
|
||||
//! blocks; the entities; the layouts).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
/// The most group-code pairs a drawing may hold (about 300 MB of DXF).
|
||||
const MAX_PAIRS: usize = 20_000_000;
|
||||
|
||||
/// The first bytes of a binary DXF file (its format's signature).
|
||||
pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00";
|
||||
|
||||
/// One group: its code and its value as written.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Pair<'a> {
|
||||
pub code: i32,
|
||||
pub value: &'a str,
|
||||
}
|
||||
|
||||
/// Values of a run of pairs by group code.
|
||||
pub(crate) trait Groups<'a> {
|
||||
/// The value of the first `code`, as written.
|
||||
fn raw(&self, code: i32) -> Option<&'a str>;
|
||||
/// The first `code` as a name (trimmed, never empty).
|
||||
fn name(&self, code: i32) -> Option<&'a str> {
|
||||
self.raw(code).map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
/// The first `code` as a finite number.
|
||||
fn num(&self, code: i32) -> Option<f64> {
|
||||
self.raw(code).and_then(number)
|
||||
}
|
||||
fn num_or(&self, code: i32, default: f64) -> f64 {
|
||||
self.num(code).unwrap_or(default)
|
||||
}
|
||||
/// The first `code` as a whole number.
|
||||
fn int(&self, code: i32) -> Option<i64> {
|
||||
self.raw(code).and_then(integer)
|
||||
}
|
||||
fn int_or(&self, code: i32, default: i64) -> i64 {
|
||||
self.int(code).unwrap_or(default)
|
||||
}
|
||||
/// The point at `code` (x) and `code + 10` (y); 0 for what is missing.
|
||||
fn point(&self, code: i32) -> Point {
|
||||
Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0))
|
||||
}
|
||||
/// [`Groups::point`] when its x is given.
|
||||
fn point_opt(&self, code: i32) -> Option<Point> {
|
||||
self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Groups<'a> for [Pair<'a>] {
|
||||
fn raw(&self, code: i32) -> Option<&'a str> {
|
||||
self.iter().find(|p| p.code == code).map(|p| p.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A finite number (`1e999`, `NaN` and junk are none).
|
||||
pub(crate) fn number(s: &str) -> Option<f64> {
|
||||
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// A whole number; some writers put integers as `1.0`.
|
||||
pub(crate) fn integer(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15).map(|v| v as i64))
|
||||
}
|
||||
|
||||
/// One object: its type (the value of its `0` group) and the groups up to the next object.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Record<'a> {
|
||||
pub kind: &'a str,
|
||||
pub g: &'a [Pair<'a>],
|
||||
}
|
||||
|
||||
/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions
|
||||
/// write (a byte order mark is dropped).
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> {
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte of the Windows Western code page.
|
||||
fn windows_1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
|
||||
'˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
|
||||
];
|
||||
match b {
|
||||
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
|
||||
_ => char::from(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the
|
||||
/// reading: what came before is kept and the second value says where the file is damaged.
|
||||
pub(crate) fn pairs(text: &str) -> Result<(Vec<Pair<'_>>, Option<String>), String> {
|
||||
let mut out = Vec::new();
|
||||
let mut lines = text.lines().enumerate();
|
||||
let mut damaged = None;
|
||||
while let Some((n, code)) = lines.next() {
|
||||
let c = code.trim();
|
||||
if c.is_empty() && out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(code) = c.parse::<i32>() else {
|
||||
damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1));
|
||||
break;
|
||||
};
|
||||
let value = lines.next().map_or("", |(_, v)| v);
|
||||
if code == 0 && value.trim() == "EOF" {
|
||||
break;
|
||||
}
|
||||
if out.len() >= MAX_PAIRS {
|
||||
return Err("the drawing is too large to open".into());
|
||||
}
|
||||
out.push(Pair { code, value });
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
Ok((out, damaged))
|
||||
}
|
||||
|
||||
/// The objects in `pairs`: each from a `0` group up to the next.
|
||||
fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec<Record<'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
for (i, p) in pairs.iter().enumerate() {
|
||||
if p.code == 0 {
|
||||
if let Some(s) = start {
|
||||
out.extend(record(pairs, s, i));
|
||||
}
|
||||
start = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
out.extend(record(pairs, s, pairs.len()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option<Record<'a>> {
|
||||
let kind = pairs.get(start)?.value.trim();
|
||||
Some(Record { kind, g: pairs.get(start + 1..end)? })
|
||||
}
|
||||
|
||||
/// The header variables the import reads.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Header {
|
||||
/// `$ACADVER` (`AC1032`…).
|
||||
pub acadver: String,
|
||||
/// `$INSUNITS`: the drawing unit (0: unitless).
|
||||
pub insunits: i64,
|
||||
/// `$MEASUREMENT`: 0 imperial, 1 metric.
|
||||
pub measurement: Option<i64>,
|
||||
/// `$LTSCALE`: the global linetype scale.
|
||||
pub ltscale: f64,
|
||||
/// `$LWDEFAULT`: the default lineweight (hundredths of a millimetre).
|
||||
pub lwdefault: i32,
|
||||
}
|
||||
|
||||
/// A layer of the LAYER table.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct LayerDef {
|
||||
pub name: String,
|
||||
pub rgb: [u8; 3],
|
||||
/// Switched off (a negative colour) or frozen.
|
||||
pub hidden: bool,
|
||||
pub locked: bool,
|
||||
pub plot: bool,
|
||||
/// Its linetype (upper case).
|
||||
pub linetype: String,
|
||||
/// Hundredths of a millimetre; negative: the default.
|
||||
pub lineweight: i32,
|
||||
}
|
||||
|
||||
/// A block definition.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BlockDef<'a> {
|
||||
pub name: String,
|
||||
pub base: Point,
|
||||
pub records: Vec<Record<'a>>,
|
||||
/// A reference to another drawing (its art isn't in this file).
|
||||
pub xref: bool,
|
||||
}
|
||||
|
||||
/// A layout: model space, or a paper layout with the block holding its art.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct LayoutDef {
|
||||
pub name: String,
|
||||
/// The block of a paper layout (upper case); `None` for model space.
|
||||
pub block: Option<String>,
|
||||
}
|
||||
|
||||
/// The name of model space.
|
||||
pub(crate) const MODEL: &str = "Model";
|
||||
const MODEL_BLOCK: &str = "*MODEL_SPACE";
|
||||
const PAPER_BLOCK: &str = "*PAPER_SPACE";
|
||||
|
||||
/// A drawing as the import reads it.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Drawing<'a> {
|
||||
pub header: Header,
|
||||
pub layers: Vec<LayerDef>,
|
||||
/// Index into `layers` by upper-case name.
|
||||
layer_index: HashMap<String, usize>,
|
||||
/// Linetype patterns by upper-case name: dash (positive), gap (negative), dot (0) lengths.
|
||||
pub linetypes: HashMap<String, Vec<f64>>,
|
||||
/// The font family of each text style (upper-case name; empty: a CAD font).
|
||||
pub styles: HashMap<String, String>,
|
||||
/// Blocks by upper-case name.
|
||||
pub blocks: HashMap<String, BlockDef<'a>>,
|
||||
/// The ENTITIES section.
|
||||
pub entities: Vec<Record<'a>>,
|
||||
/// Model space first, then the paper layouts in tab order.
|
||||
pub layouts: Vec<LayoutDef>,
|
||||
/// How many sections the file has (none: not a drawing).
|
||||
pub sections: usize,
|
||||
}
|
||||
|
||||
impl<'a> Drawing<'a> {
|
||||
/// Read the sections of `pairs`.
|
||||
pub fn read(pairs: &'a [Pair<'a>]) -> Self {
|
||||
let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() };
|
||||
let all = records(pairs);
|
||||
let mut section: Option<&str> = None;
|
||||
let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]);
|
||||
for r in all {
|
||||
match (r.kind, section) {
|
||||
("SECTION", _) => {
|
||||
d.sections += 1;
|
||||
section = r.g.name(2);
|
||||
if section == Some("HEADER") {
|
||||
d.header = header(r.g);
|
||||
}
|
||||
}
|
||||
("ENDSEC", _) => section = None,
|
||||
(_, Some("TABLES")) => tables.push(r),
|
||||
(_, Some("BLOCKS")) => blocks.push(r),
|
||||
(_, Some("ENTITIES")) => d.entities.push(r),
|
||||
(_, Some("OBJECTS")) => objects.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut block_records = HashMap::new();
|
||||
let mut table = "";
|
||||
for r in &tables {
|
||||
match r.kind {
|
||||
"TABLE" => table = r.g.name(2).unwrap_or(""),
|
||||
"LAYER" if table == "LAYER" => {
|
||||
if let Some(l) = layer(r.g) {
|
||||
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
"LTYPE" if table == "LTYPE" => {
|
||||
if let Some(name) = r.g.name(2) {
|
||||
let pattern: Vec<f64> = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect();
|
||||
d.linetypes.insert(name.to_uppercase(), pattern);
|
||||
}
|
||||
}
|
||||
"STYLE" if table == "STYLE" => {
|
||||
if let Some(name) = r.g.name(2) {
|
||||
d.styles.insert(name.to_uppercase(), font_family(r.g));
|
||||
}
|
||||
}
|
||||
"BLOCK_RECORD" if table == "BLOCK_RECORD" => {
|
||||
if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) {
|
||||
block_records.insert(h.to_uppercase(), name.to_uppercase());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
d.read_blocks(&blocks, &mut block_records);
|
||||
d.layouts = layouts(&objects, &block_records, &d.entities);
|
||||
d
|
||||
}
|
||||
|
||||
fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap<String, String>) {
|
||||
let mut current: Option<BlockDef<'a>> = None;
|
||||
for r in records {
|
||||
match r.kind {
|
||||
"BLOCK" => {
|
||||
let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string();
|
||||
// R13+ blocks name their block record: layouts find their block through it.
|
||||
if let Some(owner) = r.g.name(330) {
|
||||
block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase());
|
||||
}
|
||||
let xref = r.g.int_or(70, 0) & 4 != 0;
|
||||
current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref });
|
||||
}
|
||||
"ENDBLK" => {
|
||||
if let Some(b) = current.take().filter(|b| !b.name.is_empty()) {
|
||||
self.blocks.insert(b.name.to_uppercase(), b);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(b) = &mut current {
|
||||
b.records.push(*r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout named `name` (any case; `model` is model space).
|
||||
pub fn layout(&self, name: &str) -> Option<&LayoutDef> {
|
||||
self.layouts.iter().find(|l| l.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// The entity records of `layout`.
|
||||
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<Record<'a>> {
|
||||
let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default();
|
||||
// The active paper layout keeps its art in the ENTITIES section, flagged paper space.
|
||||
let (block_name, paper) = match &layout.block {
|
||||
None => (MODEL_BLOCK, false),
|
||||
Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true),
|
||||
Some(b) => return block(b),
|
||||
};
|
||||
let mut out: Vec<Record<'a>> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect();
|
||||
out.extend(block(block_name));
|
||||
out
|
||||
}
|
||||
|
||||
/// The layer named `name` (any case).
|
||||
pub fn layer(&self, name: &str) -> Option<&LayerDef> {
|
||||
self.layer_index.get(&name.to_uppercase()).and_then(|i| self.layers.get(*i))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is an entity (its first record) in paper space?
|
||||
fn in_paper_space(group: &[Record<'_>]) -> bool {
|
||||
group.first().is_some_and(|r| r.g.int(67) == Some(1))
|
||||
}
|
||||
|
||||
/// The entities of `records`, each with the records that belong to it (a polyline's vertices,
|
||||
/// an insert's attributes, and the SEQEND closing them).
|
||||
pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator<Item = &'r [Record<'a>]> {
|
||||
let mut i = 0;
|
||||
std::iter::from_fn(move || {
|
||||
let start = i;
|
||||
let head = records.get(start)?;
|
||||
i += 1;
|
||||
if matches!(head.kind, "POLYLINE" | "INSERT") {
|
||||
while let Some(r) = records.get(i) {
|
||||
match r.kind {
|
||||
"VERTEX" | "ATTRIB" => i += 1,
|
||||
"SEQEND" => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
records.get(start..i)
|
||||
})
|
||||
}
|
||||
|
||||
fn header(g: &[Pair<'_>]) -> Header {
|
||||
let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() };
|
||||
let mut var = "";
|
||||
for p in g {
|
||||
if p.code == 9 {
|
||||
var = p.value.trim();
|
||||
continue;
|
||||
}
|
||||
match (var, p.code) {
|
||||
("$ACADVER", 1) => h.acadver = p.value.trim().to_string(),
|
||||
("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0),
|
||||
("$MEASUREMENT", 70) => h.measurement = integer(p.value),
|
||||
("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0),
|
||||
("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn layer(g: &[Pair<'_>]) -> Option<LayerDef> {
|
||||
let name = g.name(2)?.to_string();
|
||||
let aci = g.int_or(62, 7);
|
||||
let flags = g.int_or(70, 0);
|
||||
let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs()));
|
||||
Some(LayerDef {
|
||||
// CAD apps never plot this layer of dimension points.
|
||||
plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"),
|
||||
name,
|
||||
rgb,
|
||||
hidden: aci < 0 || flags & 1 != 0,
|
||||
locked: flags & 4 != 0,
|
||||
linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(),
|
||||
lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3),
|
||||
})
|
||||
}
|
||||
|
||||
/// A text style's font family: the one 2000-and-later files name, else the font file's name
|
||||
/// (TrueType); CAD fonts (`.shx`) have none.
|
||||
fn font_family(g: &[Pair<'_>]) -> String {
|
||||
if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD")
|
||||
&& let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000))
|
||||
{
|
||||
return f.to_string();
|
||||
}
|
||||
let file = g.name(3).unwrap_or("");
|
||||
let path = std::path::Path::new(file);
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
|
||||
/// objects but with paper-space art has one paper layout.
|
||||
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
|
||||
let mut paper: Vec<(i64, LayoutDef)> = vec![];
|
||||
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
|
||||
// The layout's own groups follow its subclass marker.
|
||||
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
|
||||
let Some(name) = own.name(1) else { continue };
|
||||
let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())).cloned();
|
||||
if name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) {
|
||||
continue;
|
||||
}
|
||||
let Some(block) = block else { continue };
|
||||
if !paper.iter().any(|(_, l)| l.name.eq_ignore_ascii_case(name)) {
|
||||
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) }));
|
||||
}
|
||||
}
|
||||
paper.sort_by_key(|(order, _)| *order);
|
||||
if paper.is_empty() && groups(entities).any(in_paper_space) {
|
||||
paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) }));
|
||||
}
|
||||
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
|
||||
}
|
||||
|
|
@ -0,0 +1,708 @@
|
|||
//! DXF import: a fixture per entity, bulge and spline accuracy, blocks as symbols, layers,
|
||||
//! colours, lineweights, layouts, the options, damaged and binary files, and drawings the export
|
||||
//! wrote read back.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::ParamCurve;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Justify, Node, NodeKind, TextObject, Unit};
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A DXF file from `(code, value)` pairs written one per line.
|
||||
fn file(pairs: &[(i32, &str)]) -> Vec<u8> {
|
||||
let mut s = String::new();
|
||||
for (c, v) in pairs {
|
||||
s.push_str(&format!("{c:>3}\n{v}\n"));
|
||||
}
|
||||
s.push_str(" 0\nEOF\n");
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// A drawing with `header` variables, `tables`, `blocks` and `entities` (each a run of pairs).
|
||||
fn drawing(header: &[(i32, &str)], tables: &[(i32, &str)], blocks: &[(i32, &str)], entities: &[(i32, &str)]) -> Vec<u8> {
|
||||
let mut p: Vec<(i32, &str)> = vec![(0, "SECTION"), (2, "HEADER")];
|
||||
p.extend_from_slice(header);
|
||||
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "TABLES")]);
|
||||
p.extend_from_slice(tables);
|
||||
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "BLOCKS")]);
|
||||
p.extend_from_slice(blocks);
|
||||
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")]);
|
||||
p.extend_from_slice(entities);
|
||||
p.push((0, "ENDSEC"));
|
||||
file(&p)
|
||||
}
|
||||
|
||||
fn entities(e: &[(i32, &str)]) -> Vec<u8> {
|
||||
drawing(&[], &[], &[], e)
|
||||
}
|
||||
|
||||
/// Points at 1:1, not centred: drawing (x, y) lands at document (x, artboard height − y).
|
||||
fn points() -> ImportOptions {
|
||||
ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }
|
||||
}
|
||||
|
||||
fn open(bytes: &[u8], o: &ImportOptions) -> Imported {
|
||||
import(bytes, o).unwrap()
|
||||
}
|
||||
|
||||
/// Every leaf object, in paint order.
|
||||
fn leaves(doc: &Document) -> Vec<Node> {
|
||||
let mut out = vec![];
|
||||
doc.walk(|n| {
|
||||
if n.children().is_none() {
|
||||
out.push(n.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The artboard's height (document y = height − drawing y).
|
||||
fn height(doc: &Document) -> f64 {
|
||||
doc.artboards[0].rect.height()
|
||||
}
|
||||
|
||||
fn path_points(n: &Node) -> Vec<Point> {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path: {:?}", n.kind) };
|
||||
path.to_bezpath().segments().flat_map(|s| (0..=16).map(move |i| s.eval(f64::from(i) / 16.0))).collect()
|
||||
}
|
||||
|
||||
fn bounds(n: &Node) -> Rect {
|
||||
n.geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
fn close(a: Rect, b: Rect, tol: f64) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
fn rgba(p: Option<&Paint>) -> [u8; 4] {
|
||||
match p {
|
||||
Some(Paint::Solid { color, .. }) => color.to_rgba8(1.0),
|
||||
other => panic!("not a solid paint: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_rgb(n: &Node) -> [u8; 4] {
|
||||
rgba(n.appearance.stroke().map(|s| &s.paint))
|
||||
}
|
||||
|
||||
fn fill_rgb(n: &Node) -> [u8; 4] {
|
||||
rgba(n.appearance.fill().map(|f| &f.paint))
|
||||
}
|
||||
|
||||
fn text_of(n: &Node) -> &TextObject {
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not text: {:?}", n.kind) };
|
||||
t
|
||||
}
|
||||
|
||||
const LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "10"), (20, "20"), (11, "110"), (21, "70")];
|
||||
|
||||
#[test]
|
||||
fn a_line_comes_in_at_the_drawings_unit_and_origin() {
|
||||
let doc = open(&entities(LINE), &points()).document;
|
||||
let [line] = &leaves(&doc)[..] else { panic!() };
|
||||
let board = doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), (100.0, 50.0), "the artboard is the art's size");
|
||||
// Not centred: the origin sits on the artboard's bottom-left corner.
|
||||
let pts = path_points(line);
|
||||
assert!(pts.first().unwrap().distance(Point::new(10.0, 50.0 - 20.0)) < 1e-9, "{pts:?}");
|
||||
assert!(pts.last().unwrap().distance(Point::new(110.0, 50.0 - 70.0)) < 1e-9);
|
||||
assert_eq!(stroke_rgb(line), [0, 0, 0, 255], "colour 7 is black on paper");
|
||||
let w = line.appearance.stroke().unwrap().width;
|
||||
assert!((w - 0.25 * 72.0 / 25.4).abs() < 1e-9, "the default lineweight is 0.25 mm: {w}");
|
||||
assert!(line.appearance.fill().is_none_or(|f| f.paint.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_choose_the_default_scale() {
|
||||
// Millimetres at 1:1: 100 units are 100 mm.
|
||||
let mm = drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], LINE);
|
||||
let doc = open(&mm, &ImportOptions::default()).document;
|
||||
assert!((doc.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
|
||||
assert_eq!(doc.units, Unit::Millimeters);
|
||||
// Inches; microns (1 mm = 1000 units); unitless imperial drawings read inches.
|
||||
let inch = open(&drawing(&[(9, "$INSUNITS"), (70, "1")], &[], &[], LINE), &ImportOptions::default()).document;
|
||||
assert!((inch.artboards[0].rect.width() - 7200.0).abs() < 1e-6);
|
||||
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
|
||||
assert!((um.scale - 1000.0).abs() < 1e-6);
|
||||
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
|
||||
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
|
||||
let o = ImportOptions { unit: Some(Unit::Centimeters), scale: Some(10.0), ..ImportOptions::default() };
|
||||
let cm = open(&mm, &o).document;
|
||||
assert!((cm.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
|
||||
assert_eq!(cm.units, Unit::Centimeters);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circles_arcs_and_ellipses() {
|
||||
let e = [
|
||||
(0, "CIRCLE"),
|
||||
(10, "50"),
|
||||
(20, "50"),
|
||||
(40, "25"),
|
||||
(0, "ARC"),
|
||||
(10, "200"),
|
||||
(20, "50"),
|
||||
(40, "20"),
|
||||
(50, "0"),
|
||||
(51, "90"),
|
||||
(0, "ELLIPSE"),
|
||||
(10, "400"),
|
||||
(20, "50"),
|
||||
(11, "40"),
|
||||
(21, "0"),
|
||||
(40, "0.5"),
|
||||
(41, "0"),
|
||||
(42, "6.283185307179586"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let h = height(&doc);
|
||||
let flip = |r: Rect| Rect::new(r.x0, h - r.y1, r.x1, h - r.y0);
|
||||
let [circle, arc, ellipse] = &leaves(&doc)[..] else { panic!() };
|
||||
assert!(close(bounds(circle), flip(Rect::new(25.0, 25.0, 75.0, 75.0)), 1e-3), "{:?}", bounds(circle));
|
||||
for p in path_points(circle) {
|
||||
assert!((p.distance(Point::new(50.0, h - 50.0)) - 25.0).abs() < 1e-4);
|
||||
}
|
||||
// A quarter arc counter-clockwise from 0° to 90°.
|
||||
assert!(close(bounds(arc), flip(Rect::new(200.0, 50.0, 220.0, 70.0)), 1e-3), "{:?}", bounds(arc));
|
||||
assert!(close(bounds(ellipse), flip(Rect::new(360.0, 30.0, 440.0, 70.0)), 1e-3), "{:?}", bounds(ellipse));
|
||||
let NodeKind::Path { path, .. } = &circle.kind else { panic!() };
|
||||
assert!(path.is_closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_bulges_are_exact_arcs() {
|
||||
// A slot: two straight sides and two half circles (bulge 1) of radius 10.
|
||||
let e = [
|
||||
(0, "LWPOLYLINE"),
|
||||
(90, "4"),
|
||||
(70, "1"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(10, "100"),
|
||||
(20, "0"),
|
||||
(42, "1"),
|
||||
(10, "100"),
|
||||
(20, "20"),
|
||||
(10, "0"),
|
||||
(20, "20"),
|
||||
(42, "1"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [slot] = &leaves(&doc)[..] else { panic!() };
|
||||
let h = height(&doc);
|
||||
// The arcs bulge outward, so the art is 120 wide.
|
||||
assert!(close(bounds(slot), Rect::new(-10.0, h - 20.0, 110.0, h), 1e-4), "{:?}", bounds(slot));
|
||||
for p in path_points(slot).into_iter().filter(|p| p.x > 100.0) {
|
||||
let err = (p.distance(Point::new(100.0, h - 10.0)) - 10.0).abs();
|
||||
assert!(err < 1e-4, "bulge error {err}");
|
||||
}
|
||||
// Old-style polylines read their vertices and bulges too.
|
||||
let e = [
|
||||
(0, "POLYLINE"),
|
||||
(66, "1"),
|
||||
(70, "0"),
|
||||
(0, "VERTEX"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(42, "-1"),
|
||||
(0, "VERTEX"),
|
||||
(10, "20"),
|
||||
(20, "0"),
|
||||
(0, "SEQEND"),
|
||||
(0, "LINE"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "0"),
|
||||
(21, "-30"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [arc, _] = &leaves(&doc)[..] else { panic!() };
|
||||
// Clockwise from (0, 0) to (20, 0): over the chord, 10 high.
|
||||
let b = bounds(arc);
|
||||
assert!((b.width() - 20.0).abs() < 1e-4 && (b.height() - 10.0).abs() < 1e-4, "{b:?}");
|
||||
assert!((b.y1 - height(&doc)).abs() < 1e-4, "the arc's chord is at y 0: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nurbs_splines_stay_within_a_hundredth() {
|
||||
// A rational quadratic circle of radius 100: nine weighted control points.
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2.to_string();
|
||||
let mut e: Vec<(i32, String)> = vec![(0, "SPLINE".into()), (70, "12".into()), (71, "2".into()), (72, "12".into()), (73, "9".into())];
|
||||
for k in ["0", "0", "0", "1", "1", "2", "2", "3", "3", "4", "4", "4"] {
|
||||
e.push((40, k.into()));
|
||||
}
|
||||
for i in 0..9 {
|
||||
e.push((41, if i % 2 == 0 { "1".into() } else { w.clone() }));
|
||||
}
|
||||
for (x, y) in [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0)] {
|
||||
e.extend([(10, (200 + x * 100).to_string()), (20, (200 + y * 100).to_string()), (30, "0".into())]);
|
||||
}
|
||||
let e: Vec<(i32, &str)> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [circle] = &leaves(&doc)[..] else { panic!() };
|
||||
let h = height(&doc);
|
||||
let worst = path_points(circle).iter().map(|p| (p.distance(Point::new(200.0, h - 200.0)) - 100.0).abs()).fold(0.0, f64::max);
|
||||
assert!(worst < 0.01, "NURBS error {worst}");
|
||||
// A spline given by fit points alone passes through them.
|
||||
let e = [(0, "SPLINE"), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [s] = &leaves(&doc)[..] else { panic!() };
|
||||
let h = height(&doc);
|
||||
assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9));
|
||||
}
|
||||
|
||||
/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc).
|
||||
fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> {
|
||||
vec![
|
||||
(0, "HATCH"),
|
||||
(62, "1"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(30, "0"),
|
||||
(2, if solid == "1" { "SOLID" } else { "ANSI31" }),
|
||||
(70, solid),
|
||||
(71, "0"),
|
||||
(91, "2"),
|
||||
(92, "3"),
|
||||
(72, "0"),
|
||||
(73, "1"),
|
||||
(93, "4"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(10, "100"),
|
||||
(20, "0"),
|
||||
(10, "100"),
|
||||
(20, "100"),
|
||||
(10, "0"),
|
||||
(20, "100"),
|
||||
(97, "0"),
|
||||
(92, "0"),
|
||||
(93, "1"),
|
||||
(72, "2"),
|
||||
(10, "50"),
|
||||
(20, "50"),
|
||||
(40, "20"),
|
||||
(50, "0"),
|
||||
(51, "360"),
|
||||
(73, "1"),
|
||||
(97, "0"),
|
||||
(75, "0"),
|
||||
(76, "1"),
|
||||
(98, "0"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_hatches_fill_and_pattern_hatches_outline() {
|
||||
let r = open(&entities(&hatch("1")), &points());
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let compound = r.document.layers[0].children().unwrap()[0].clone();
|
||||
let NodeKind::Compound { children, rule } = &compound.kind else { panic!("{:?}", compound.kind) };
|
||||
assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd), "the hole is a second loop");
|
||||
assert_eq!(fill_rgb(&compound), [255, 0, 0, 255]);
|
||||
assert!(compound.appearance.stroke().is_none_or(|s| s.paint.is_none()));
|
||||
assert_eq!(bounds(&compound).area(), 10_000.0);
|
||||
let r = open(&entities(&hatch("0")), &points());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("pattern hatches")), "{:?}", r.warnings);
|
||||
let outline = r.document.layers[0].children().unwrap()[0].clone();
|
||||
assert_eq!(stroke_rgb(&outline), [255, 0, 0, 255]);
|
||||
// Solids fill their corners in zigzag order.
|
||||
let e = [(0, "SOLID"), (10, "0"), (20, "0"), (11, "10"), (21, "0"), (12, "0"), (22, "10"), (13, "10"), (23, "10")];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [square] = &leaves(&doc)[..] else { panic!() };
|
||||
assert_eq!(bounds(square).area(), 100.0);
|
||||
assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_multiline_text() {
|
||||
let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Notes"), (3, "DejaVuSans.ttf"), (0, "ENDTAB")];
|
||||
let e = [
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "10"),
|
||||
(40, "7"),
|
||||
(1, "Room %%c20"),
|
||||
(50, "90"),
|
||||
(7, "Notes"),
|
||||
(0, "MTEXT"),
|
||||
(10, "100"),
|
||||
(20, "100"),
|
||||
(40, "3.5"),
|
||||
(71, "2"),
|
||||
(3, "{\\fArial|b0;First}"),
|
||||
(1, "\\PSecond"),
|
||||
];
|
||||
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
|
||||
let [single, multi] = &leaves(&doc)[..] else { panic!() };
|
||||
let h = height(&doc);
|
||||
let t = text_of(single);
|
||||
assert_eq!(t.plain_text(), "Room ⌀20");
|
||||
let st = t.first_style();
|
||||
assert!((st.size - 10.0).abs() < 1e-9, "a 7-unit cap height is 10 pt type: {}", st.size);
|
||||
assert_eq!(st.font_family, "DejaVuSans");
|
||||
// Turned a quarter counter-clockwise, its baseline starting at the insertion point.
|
||||
let origin = t.xf * Point::ZERO;
|
||||
assert!(origin.distance(Point::new(10.0, h - 10.0)) < 1e-9, "{origin:?}");
|
||||
let along = t.xf * Point::new(1.0, 0.0) - origin;
|
||||
assert!(along.x.abs() < 1e-9 && (along.y + 1.0).abs() < 1e-9, "{along:?}");
|
||||
let m = text_of(multi);
|
||||
assert_eq!(m.plain_text(), "First\nSecond");
|
||||
assert_eq!(m.para.justify, Justify::Center, "attached top centre");
|
||||
assert_eq!(m.first_style().font_family, "Arial");
|
||||
// Top attachment: the first baseline one cap height below the insertion point.
|
||||
let o = m.xf * Point::ZERO;
|
||||
assert!(o.distance(Point::new(100.0, h - 100.0 + 3.5)) < 1e-9, "{o:?}");
|
||||
let leading = m.first_style().leading.unwrap();
|
||||
assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}");
|
||||
}
|
||||
|
||||
/// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`.
|
||||
fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec<u8> {
|
||||
let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")];
|
||||
blocks.extend_from_slice(block_art);
|
||||
blocks.push((0, "ENDBLK"));
|
||||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Doors"), (70, "0"), (62, "5"), (6, "CONTINUOUS"), (0, "ENDTAB")];
|
||||
drawing(&[], &tables, &blocks, inserts)
|
||||
}
|
||||
|
||||
/// A red line 10 units along x from the base point, on layer 0.
|
||||
const DOOR_LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "1"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||||
|
||||
#[test]
|
||||
fn inserts_become_symbol_instances_with_their_transform() {
|
||||
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
|
||||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||||
assert_eq!(doc.symbols.len(), 1);
|
||||
assert_eq!(doc.symbols[0].name, "Door");
|
||||
let [inst] = &leaves(&doc)[..] else { panic!() };
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { panic!("{:?}", inst.kind) };
|
||||
assert_eq!(symbol, "Door");
|
||||
assert_eq!(doc.layers[0].name.as_deref(), Some("Doors"));
|
||||
// Scaled 2 and turned a quarter: from the insertion point 20 units up.
|
||||
let art = &doc.symbols[0].art;
|
||||
let pts: Vec<Point> = path_points(art).iter().map(|p| *xf * *p).collect();
|
||||
let h = height(&doc);
|
||||
let (a, b) = (pts.first().unwrap(), pts.last().unwrap());
|
||||
assert!(a.distance(Point::new(100.0, h - 50.0)) < 1e-9, "{a:?}");
|
||||
assert!(b.distance(Point::new(100.0, h - 70.0)) < 1e-9, "{b:?}");
|
||||
assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn by_block_colours_make_a_symbol_per_look() {
|
||||
// The block's line takes the insert's colour: red and green make two symbols; a third red
|
||||
// insert reuses the first.
|
||||
let by_block: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||||
let inserts = [
|
||||
(0, "INSERT"),
|
||||
(2, "Door"),
|
||||
(62, "1"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(0, "INSERT"),
|
||||
(2, "Door"),
|
||||
(62, "3"),
|
||||
(10, "50"),
|
||||
(20, "0"),
|
||||
(0, "INSERT"),
|
||||
(2, "Door"),
|
||||
(62, "1"),
|
||||
(10, "100"),
|
||||
(20, "0"),
|
||||
];
|
||||
let doc = open(&with_door(by_block, &inserts), &points()).document;
|
||||
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["Door", "Door 2"]);
|
||||
assert_eq!(stroke_rgb(&doc.symbols[0].art), [255, 0, 0, 255]);
|
||||
assert_eq!(stroke_rgb(&doc.symbols[1].art), [0, 255, 0, 255]);
|
||||
assert_eq!(leaves(&doc).len(), 3);
|
||||
// Layer 0 art takes the insert's layer's colour.
|
||||
let on_zero: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||||
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "0"), (20, "0"), (0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "40"), (20, "0")];
|
||||
let doc = open(&with_door(on_zero, &inserts), &points()).document;
|
||||
assert_eq!(doc.symbols.len(), 1);
|
||||
assert_eq!(stroke_rgb(&doc.symbols[0].art), [0, 0, 255, 255], "layer Doors is blue");
|
||||
// Art with its own colour shares one symbol, whatever the inserts' colours.
|
||||
let inserts = [(0, "INSERT"), (2, "Door"), (62, "1"), (10, "0"), (20, "0"), (0, "INSERT"), (2, "Door"), (62, "3"), (10, "40"), (20, "0")];
|
||||
let doc = open(&with_door(&[(0, "LINE"), (8, "Doors"), (62, "4"), (10, "5"), (20, "5"), (11, "15"), (21, "5")], &inserts), &points()).document;
|
||||
assert_eq!(doc.symbols.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_inserts_attributes_and_dimensions() {
|
||||
let inserts = [
|
||||
(0, "INSERT"),
|
||||
(66, "1"),
|
||||
(2, "Door"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(70, "3"),
|
||||
(71, "2"),
|
||||
(44, "20"),
|
||||
(45, "30"),
|
||||
(0, "ATTRIB"),
|
||||
(10, "0"),
|
||||
(20, "-10"),
|
||||
(40, "2"),
|
||||
(1, "D-01"),
|
||||
(2, "TAG"),
|
||||
(70, "0"),
|
||||
(0, "ATTRIB"),
|
||||
(10, "0"),
|
||||
(20, "-20"),
|
||||
(40, "2"),
|
||||
(1, "hidden"),
|
||||
(2, "NOTE"),
|
||||
(70, "1"),
|
||||
(0, "SEQEND"),
|
||||
];
|
||||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||||
let all = leaves(&doc);
|
||||
let instances: Vec<Point> =
|
||||
all.iter().filter_map(|n| if let NodeKind::SymbolInstance { xf, .. } = &n.kind { Some(*xf * Point::ZERO) } else { None }).collect();
|
||||
assert_eq!(instances.len(), 6, "3 columns × 2 rows");
|
||||
let xs: std::collections::BTreeSet<i64> = instances.iter().map(|p| p.x.round() as i64).collect();
|
||||
assert_eq!(xs.len(), 3, "columns 20 apart: {instances:?}");
|
||||
let texts: Vec<String> = all.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["D-01"], "invisible attributes stay out");
|
||||
// A dimension draws its anonymous block as art, not a symbol.
|
||||
let blocks = [(0, "BLOCK"), (2, "*D1"), (70, "1"), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "50"), (21, "0"), (0, "ENDBLK")];
|
||||
let dims = [(0, "DIMENSION"), (2, "*D1"), (10, "0"), (20, "0")];
|
||||
let doc = open(&drawing(&[], &[], &blocks, &dims), &points()).document;
|
||||
assert!(doc.symbols.is_empty());
|
||||
assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_inside_themselves_are_left_out_without_hanging() {
|
||||
let blocks = [
|
||||
(0, "BLOCK"),
|
||||
(2, "Loop"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(0, "LINE"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "10"),
|
||||
(21, "0"),
|
||||
(0, "INSERT"),
|
||||
(2, "Loop"),
|
||||
(10, "20"),
|
||||
(20, "0"),
|
||||
(0, "ENDBLK"),
|
||||
];
|
||||
let r = open(&drawing(&[], &[], &blocks, &[(0, "INSERT"), (2, "Loop"), (10, "0"), (20, "0")]), &points());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("inside themselves")), "{:?}", r.warnings);
|
||||
assert_eq!(r.document.symbols.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_keep_their_state_colour_lineweight_and_linetype() {
|
||||
let tables = [
|
||||
(0, "TABLE"),
|
||||
(2, "LTYPE"),
|
||||
(0, "LTYPE"),
|
||||
(2, "DASHED"),
|
||||
(73, "2"),
|
||||
(40, "15"),
|
||||
(49, "10"),
|
||||
(49, "-5"),
|
||||
(0, "ENDTAB"),
|
||||
(0, "TABLE"),
|
||||
(2, "LAYER"),
|
||||
(0, "LAYER"),
|
||||
(2, "Hidden"),
|
||||
(70, "0"),
|
||||
(62, "-3"),
|
||||
(6, "DASHED"),
|
||||
(370, "50"),
|
||||
(0, "LAYER"),
|
||||
(2, "Frozen"),
|
||||
(70, "1"),
|
||||
(62, "2"),
|
||||
(0, "LAYER"),
|
||||
(2, "Locked"),
|
||||
(70, "4"),
|
||||
(62, "1"),
|
||||
(420, "16744448"),
|
||||
(290, "0"),
|
||||
(0, "ENDTAB"),
|
||||
];
|
||||
let line = |layer: &'static str| [(0, "LINE"), (8, layer), (10, "0"), (20, "0"), (11, "100"), (21, "0")];
|
||||
let e: Vec<(i32, &str)> = [line("Hidden"), line("Frozen"), line("Locked"), line("Unlisted")].concat();
|
||||
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
|
||||
let names: Vec<&str> = doc.layers.iter().filter_map(|l| l.name.as_deref()).collect();
|
||||
assert_eq!(names, ["Hidden", "Frozen", "Locked", "Unlisted"]);
|
||||
let [hidden, frozen, locked, plain] = &doc.layers[..] else { panic!() };
|
||||
assert!(!hidden.visible && !frozen.visible && locked.visible && plain.visible);
|
||||
assert!(locked.locked && !hidden.locked);
|
||||
assert!(matches!(locked.kind, NodeKind::Layer { printable: false, .. }));
|
||||
let first = |l: &Arc<Node>| l.children().unwrap()[0].clone();
|
||||
// Colour 3 (green) and a 0.5 mm dashed line from the layer.
|
||||
let h = first(hidden);
|
||||
assert_eq!(stroke_rgb(&h), [0, 255, 0, 255]);
|
||||
let st = h.appearance.stroke().unwrap();
|
||||
assert!((st.width - 0.5 * 72.0 / 25.4).abs() < 1e-9);
|
||||
assert_eq!(st.dash.as_ref().unwrap().pattern, [10.0, 5.0]);
|
||||
assert_eq!(stroke_rgb(&first(frozen)), [255, 255, 0, 255]);
|
||||
assert_eq!(stroke_rgb(&first(locked)), [255, 128, 0, 255], "true colour wins over the index");
|
||||
// Merge Layers: one layer.
|
||||
let merged = open(&drawing(&[], &tables, &[], &e), &ImportOptions { merge_layers: true, ..points() }).document;
|
||||
assert_eq!(merged.layers.len(), 1);
|
||||
assert_eq!(merged.layers[0].children().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_colours_lineweights_and_transparency() {
|
||||
let e = [
|
||||
(0, "LINE"),
|
||||
(62, "30"),
|
||||
(370, "100"),
|
||||
(440, "33554559"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "10"),
|
||||
(21, "0"),
|
||||
(0, "LINE"),
|
||||
(62, "5"),
|
||||
(420, "65280"),
|
||||
(370, "0"),
|
||||
(10, "0"),
|
||||
(20, "10"),
|
||||
(11, "10"),
|
||||
(21, "10"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [a, b] = &leaves(&doc)[..] else { panic!() };
|
||||
assert_eq!(stroke_rgb(a), [255, 127, 0, 255], "index 30 is orange");
|
||||
assert!((a.appearance.stroke().unwrap().width - 72.0 / 25.4).abs() < 1e-9, "1 mm");
|
||||
assert!((a.opacity - 127.0 / 255.0).abs() < 1e-6);
|
||||
assert_eq!(stroke_rgb(b), [0, 255, 0, 255]);
|
||||
assert_eq!(b.appearance.stroke().unwrap().width, 0.25, "lineweight 0 is the thinnest line");
|
||||
// Scale Lineweights: at 1 pt = 10 units (a tenth of 1:1 in millimetres) they shrink too.
|
||||
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(10.0), scale_lineweights: true, ..ImportOptions::default() };
|
||||
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
|
||||
let w = leaves(&doc)[0].appearance.stroke().unwrap().width;
|
||||
let k = 0.1 / (72.0 / 25.4);
|
||||
assert!((w - 72.0 / 25.4 * k).abs() < 1e-9, "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_centre_place_the_art_on_the_artboard() {
|
||||
let doc = open(&entities(LINE), &ImportOptions { fit: true, ..ImportOptions::default() }).document;
|
||||
let board = doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), (792.0, 612.0), "landscape letter for wide art");
|
||||
let b = bounds(&leaves(&doc)[0]);
|
||||
assert!((b.width() - 792.0).abs() < 1e-6 && (b.center().y - 306.0).abs() < 1e-6, "{b:?}");
|
||||
let o = ImportOptions { fit: true, fit_to: (200.0, 100.0), center: false, ..ImportOptions::default() };
|
||||
let b = bounds(&leaves(&open(&entities(LINE), &o).document)[0]);
|
||||
assert!(close(b, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-6), "bottom-left: {b:?}");
|
||||
// Centred at a ratio, the artboard is the art's bounds.
|
||||
let doc = open(&entities(LINE), &ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), ..ImportOptions::default() }).document;
|
||||
assert!(close(bounds(&leaves(&doc)[0]), doc.artboards[0].rect, 1e-9));
|
||||
// Art too large for the canvas at the scale asked for says so.
|
||||
let o = ImportOptions { unit: Some(Unit::Meters), scale: Some(0.001), ..ImportOptions::default() };
|
||||
let e = import(&entities(LINE), &o).unwrap_err();
|
||||
assert!(e.contains("canvas"), "{e}");
|
||||
let e = import(&entities(LINE), &ImportOptions { scale: Some(-1.0), ..ImportOptions::default() }).unwrap_err();
|
||||
assert!(e.contains("scale"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_layouts() {
|
||||
let mut e = LINE.to_vec();
|
||||
e.extend([(0, "CIRCLE"), (67, "1"), (10, "0"), (20, "0"), (40, "5")]);
|
||||
let bytes = entities(&e);
|
||||
assert_eq!(info(&bytes).unwrap().layouts, ["Model", "Layout1"]);
|
||||
let model = leaves(&open(&bytes, &points()).document);
|
||||
assert_eq!(model.len(), 1);
|
||||
assert_eq!(bounds(&model[0]).width(), 100.0, "model space holds the line");
|
||||
let paper = leaves(&open(&bytes, &ImportOptions { layout: Some("layout1".into()), ..points() }).document);
|
||||
assert_eq!(bounds(&paper[0]).width(), 10.0, "the paper layout holds the circle");
|
||||
let e = import(&bytes, &ImportOptions { layout: Some("Sheet".into()), ..points() }).unwrap_err();
|
||||
assert!(e.contains("Model, Layout1"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_dwg_damaged_and_foreign_files() {
|
||||
let mut binary = BINARY_SENTINEL.to_vec();
|
||||
binary.extend([0u8; 40]);
|
||||
assert!(is_dxf(&binary));
|
||||
let e = import(&binary, &ImportOptions::default()).unwrap_err();
|
||||
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
|
||||
let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err();
|
||||
assert!(e.contains("DWG"), "{e}");
|
||||
for junk in [&b""[..], b"hello", b"<svg/>", b"999\nnote\n"] {
|
||||
assert!(import(junk, &ImportOptions::default()).is_err(), "{junk:?}");
|
||||
assert!(!is_dxf(junk), "{junk:?}");
|
||||
}
|
||||
assert!(is_dxf(b"999\nwritten by hand\n 0\nSECTION\n 2\nHEADER\n"));
|
||||
// A file cut off in the middle keeps what came before.
|
||||
let mut cut = entities(LINE);
|
||||
cut.truncate(cut.len() - 30);
|
||||
cut.extend(b"\nnot a code\n");
|
||||
let r = open(&cut, &points());
|
||||
assert_eq!(leaves(&r.document).len(), 1);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("damaged")), "{:?}", r.warnings);
|
||||
// Hostile numbers are dropped; unknown entities are listed.
|
||||
let e =
|
||||
[(0, "CIRCLE"), (10, "1e999"), (20, "NaN"), (40, "1e300"), (0, "XLINE"), (10, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "1")];
|
||||
let r = open(&entities(&e), &points());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("1 XLINE")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawings_the_export_writes_read_back() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let id = doc.alloc_id();
|
||||
let rect = shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0));
|
||||
let blue = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 255)), 2.0);
|
||||
doc.insert(Some(layer), 0, Node::path(id, rect, blue)).unwrap();
|
||||
let id = doc.alloc_id();
|
||||
let circle = shapes::ellipse(Rect::new(140.0, 20.0, 180.0, 60.0));
|
||||
doc.insert(Some(layer), 1, Node::path(id, circle, Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0))).unwrap();
|
||||
for version in [crate::DxfVersion::R12, crate::DxfVersion::R2000, crate::DxfVersion::R2018] {
|
||||
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
|
||||
let out = crate::export(&doc, &opts).unwrap();
|
||||
let back = open(&out.bytes, &points());
|
||||
let art = leaves(&back.document);
|
||||
// Same place on the artboard (the export's origin is the artboard's bottom-left).
|
||||
let shift = Affine::translate((0.0, 100.0 - height(&back.document)));
|
||||
let all = art.iter().filter_map(|n| n.geometric_bounds()).map(|b| shift.transform_rect_bbox(b)).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!(close(all, Rect::new(20.0, 20.0, 180.0, 70.0), 0.1), "{version:?}: {all:?}");
|
||||
let stroked = art.iter().find(|n| n.appearance.stroke().is_some_and(|s| !s.paint.is_none())).unwrap();
|
||||
assert_eq!(stroke_rgb(stroked), [0, 0, 255, 255], "{version:?}");
|
||||
if version >= crate::DxfVersion::R2000 {
|
||||
assert!(art.iter().any(|n| n.appearance.fill().is_some_and(|f| !f.paint.is_none())), "{version:?}: the fill comes back as a hatch");
|
||||
let w = stroked.appearance.stroke().unwrap().width;
|
||||
assert!((w - 2.0).abs() < 0.2, "{version:?}: lineweight {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_layers_layouts_and_version() {
|
||||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
|
||||
let i = info(&drawing(&[(9, "$ACADVER"), (1, "AC1015"), (9, "$INSUNITS"), (70, "6")], &tables, &[], LINE)).unwrap();
|
||||
assert_eq!(i.version, "2000");
|
||||
assert_eq!(i.units, "Meters");
|
||||
assert_eq!((i.unit, i.scale), (Unit::Meters, 1.0));
|
||||
assert_eq!(i.layers, ["0", "Walls"]);
|
||||
assert_eq!(i.layouts, ["Model"]);
|
||||
assert!(info(b"not a drawing").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_extruded_along_minus_z_are_mirrored() {
|
||||
let e = [(0, "CIRCLE"), (10, "50"), (20, "0"), (40, "10"), (210, "0"), (220, "0"), (230, "-1")];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let b = bounds(&leaves(&doc)[0]);
|
||||
assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}");
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
//! CAD text values as plain text: control codes (`%%d`), Unicode escapes (`\U+00B0`), caret
|
||||
//! escapes (`^I`) and, in multiline text, the formatting codes (`\P` paragraph breaks, `{\fArial;…}`
|
||||
//! font changes, `\S1/2;` stacked fractions…).
|
||||
|
||||
/// A single-line text value (TEXT, ATTRIB) as plain text.
|
||||
pub(crate) fn plain(s: &str) -> String {
|
||||
percent_codes(&escapes(s))
|
||||
}
|
||||
|
||||
/// A multiline text value (MTEXT) as plain text with `\n` between paragraphs, and the font family
|
||||
/// its first font change names.
|
||||
pub(crate) fn mtext(s: &str) -> (String, Option<String>) {
|
||||
let s = escapes(s);
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut family = None;
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'{' | '}' => {}
|
||||
'\\' => match chars.next() {
|
||||
Some('P' | 'X' | 'N') => out.push('\n'),
|
||||
Some('~') => out.push('\u{A0}'),
|
||||
Some(c @ ('\\' | '{' | '}')) => out.push(c),
|
||||
Some('L' | 'l' | 'O' | 'o' | 'K' | 'k') => {}
|
||||
Some('S') => {
|
||||
// A stacked fraction `\Snum^den;` (or / or #): written as num/den.
|
||||
let arg = until_semicolon(&mut chars);
|
||||
out.push_str(&arg.replace(['^', '#'], "/"));
|
||||
}
|
||||
Some(f @ ('f' | 'F')) => {
|
||||
let arg = until_semicolon(&mut chars);
|
||||
if family.is_none() && f == 'f' {
|
||||
family = arg.split('|').next().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
}
|
||||
}
|
||||
// Height, colour, tracking, oblique, width, alignment, paragraph: settings with
|
||||
// an argument.
|
||||
Some('H' | 'C' | 'c' | 'T' | 'Q' | 'W' | 'A' | 'p') => {
|
||||
until_semicolon(&mut chars);
|
||||
}
|
||||
Some(other) => {
|
||||
out.push('\\');
|
||||
out.push(other);
|
||||
}
|
||||
None => out.push('\\'),
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
(percent_codes(&out), family)
|
||||
}
|
||||
|
||||
/// The characters up to the next `;` (dropped).
|
||||
fn until_semicolon(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
|
||||
let mut arg = String::new();
|
||||
for c in chars.by_ref() {
|
||||
if c == ';' {
|
||||
break;
|
||||
}
|
||||
arg.push(c);
|
||||
}
|
||||
arg
|
||||
}
|
||||
|
||||
/// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page)
|
||||
/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped.
|
||||
fn escapes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
let after = rest.get(c.len_utf8()..).unwrap_or("");
|
||||
if c == '\\'
|
||||
&& let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4))
|
||||
&& let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
|
||||
{
|
||||
out.push(ch);
|
||||
rest = after.get(6..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if c == '^'
|
||||
&& let Some(next) = after.chars().next()
|
||||
{
|
||||
match next {
|
||||
' ' => out.push('^'),
|
||||
'I' => out.push('\t'),
|
||||
_ => {}
|
||||
}
|
||||
rest = after.get(next.len_utf8()..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if !c.is_control() || c == '\t' {
|
||||
out.push(c);
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `%%` control codes: `%%d` degree, `%%p` plus-minus, `%%c` diameter, `%%%` percent, `%%nnn` a
|
||||
/// character code; the underline, overline and strike-through toggles (`%%u`, `%%o`, `%%k`) are
|
||||
/// dropped.
|
||||
fn percent_codes(s: &str) -> String {
|
||||
if !s.contains("%%") {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("%%") {
|
||||
out.push_str(rest.get(..i).unwrap_or(""));
|
||||
let code = rest.get(i + 2..).unwrap_or("");
|
||||
let mut skip = 1;
|
||||
match code.chars().next().map(|c| c.to_ascii_lowercase()) {
|
||||
Some('d') => out.push('°'),
|
||||
Some('p') => out.push('±'),
|
||||
Some('c') => out.push('⌀'),
|
||||
Some('%') => out.push('%'),
|
||||
Some('u' | 'o' | 'k') => {}
|
||||
Some(c) if c.is_ascii_digit() => {
|
||||
let digits: String = code.chars().take(3).take_while(char::is_ascii_digit).collect();
|
||||
skip = digits.len();
|
||||
out.extend(digits.parse::<u32>().ok().and_then(char::from_u32).filter(|c| !c.is_control()));
|
||||
}
|
||||
_ => {
|
||||
out.push_str("%%");
|
||||
skip = 0;
|
||||
}
|
||||
}
|
||||
rest = code.get(skip..).unwrap_or("");
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_codes_and_escapes() {
|
||||
assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under");
|
||||
assert_eq!(plain("caf\\U+00E9 ^ x^I^Jy"), "café ^x\ty", "`^ ` is a caret");
|
||||
assert_eq!(plain("%%065%%066"), "AB");
|
||||
assert_eq!(plain("50%% off"), "50%% off", "an unknown code stays");
|
||||
assert_eq!(plain("\\U+"), "\\U+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtext_formatting_is_dropped() {
|
||||
let (t, family) = mtext("{\\fArial|b1|i0|c0|p34;Title}\\P\\H2.5x;Line \\C1;two\\~\\S1/2;\\Pa\\\\b {\\Lunder}");
|
||||
assert_eq!(t, "Title\nLine two\u{A0}1/2\na\\b under");
|
||||
assert_eq!(family.as_deref(), Some("Arial"));
|
||||
// Carets are written "^ " (a caret before another character is a control character).
|
||||
assert_eq!(mtext("x\\S3^ 4;").0, "x3/4");
|
||||
assert_eq!(mtext("x\\S3#4;").0, "x3/4");
|
||||
assert_eq!(mtext("trailing\\").0, "trailing\\");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
//! CAD interchange: a hand-written ASCII DXF writer, versions R12 to 2018.
|
||||
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader.
|
||||
//!
|
||||
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
|
||||
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
|
||||
|
|
@ -10,11 +10,15 @@
|
|||
//! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns,
|
||||
//! clipping) is approximated or left out, and each such loss comes back as a warning.
|
||||
//!
|
||||
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
|
||||
//!
|
||||
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
|
||||
//! - `writer`: group codes, handles and the file's sections, tables and objects.
|
||||
//! - `scene`: the document walk that turns objects into entities.
|
||||
//! - `import`: the reader, entities to art, blocks to symbols.
|
||||
|
||||
mod aci;
|
||||
pub mod import;
|
||||
mod scene;
|
||||
mod writer;
|
||||
|
||||
|
|
@ -22,6 +26,21 @@ use vectorcraft_doc::{Document, Unit};
|
|||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use aci::{aci_rgb, nearest_aci};
|
||||
pub use import::{DxfInfo, ImportOptions, Imported, import, info, is_dxf};
|
||||
|
||||
/// How deep symbols, blocks and brush art may nest in one another (deeper art is left out).
|
||||
const MAX_NEST: u32 = 8;
|
||||
/// Cap height as a fraction of the type size: the height of CAD text is that of its capitals.
|
||||
const CAP_HEIGHT: f64 = 0.7;
|
||||
|
||||
/// The `$INSUNITS` codes of the units documents use (feet and inches write feet).
|
||||
const INSUNITS: [(u8, Unit); 7] =
|
||||
[(1, Unit::Inches), (2, Unit::Feet), (2, Unit::FeetInches), (4, Unit::Millimeters), (5, Unit::Centimeters), (6, Unit::Meters), (10, Unit::Yards)];
|
||||
|
||||
/// The `$INSUNITS` code of `unit` (none for points, picas and pixels).
|
||||
fn insunits_code(unit: Unit) -> Option<u8> {
|
||||
INSUNITS.iter().find(|(_, u)| *u == unit).map(|(c, _)| *c)
|
||||
}
|
||||
|
||||
/// The DXF version a file is written for (the `$ACADVER` it declares).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
|
|
|
|||
|
|
@ -13,15 +13,11 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
|||
|
||||
use crate::aci::nearest_aci;
|
||||
use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed};
|
||||
use crate::{ColorDepth, DxfImage, DxfOptions, DxfOutput, Preserve, RasterFormat};
|
||||
use crate::{CAP_HEIGHT, ColorDepth, DxfImage, DxfOptions, DxfOutput, MAX_NEST, Preserve, RasterFormat};
|
||||
|
||||
/// Curves are flattened to within this distance (points) where DXF needs straight segments
|
||||
/// (hatch boundaries, R12 polylines).
|
||||
const FLATNESS: f64 = 0.05;
|
||||
/// How deep symbols and brush art may nest in one another (deeper art is left out).
|
||||
const MAX_NEST: u32 = 8;
|
||||
/// Cap height as a fraction of the type size: the height of CAD text is that of its capitals.
|
||||
const CAP_HEIGHT: f64 = 0.7;
|
||||
/// Longest text value older DXF versions read.
|
||||
const MAX_TEXT: usize = 250;
|
||||
/// The lineweights DXF knows (hundredths of a millimetre).
|
||||
|
|
@ -227,15 +223,7 @@ impl<'a> Scene<'a> {
|
|||
let r = self.o.region;
|
||||
let limits = [0.0, 0.0, r.width() * self.k, r.height() * self.k];
|
||||
let one = (self.o.scale - 1.0).abs() < 1e-12;
|
||||
let insunits = match self.o.unit {
|
||||
Unit::Inches if one => 1,
|
||||
Unit::Feet | Unit::FeetInches if one => 2,
|
||||
Unit::Millimeters if one => 4,
|
||||
Unit::Centimeters if one => 5,
|
||||
Unit::Meters if one => 6,
|
||||
Unit::Yards if one => 10,
|
||||
_ => 0,
|
||||
};
|
||||
let insunits = if one { crate::insunits_code(self.o.unit).unwrap_or(0) } else { 0 };
|
||||
let drawing = Drawing {
|
||||
version: self.o.version,
|
||||
layers: self.layers,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
//! Opening and placing DXF drawings: the `dxf` options `document.open` and `file.place` read
|
||||
//! (layout, scale or fit, lineweights, centring, merged layers) and `document.dxfInfo` (layouts,
|
||||
//! layers, units) for the DXF Import Options dialog.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_cad::ImportOptions;
|
||||
use vectorcraft_doc::{Document, Unit};
|
||||
|
||||
use super::super::*;
|
||||
use super::{err, source};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "document.dxfInfo",
|
||||
"DXF Info",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name?, dataBase64} what a DXF drawing holds → {version (2018, R12…), units (its drawing unit: Millimeters, Inches, Unitless…), layouts: [\"Model\", paper layouts…], layers: [name…], unit, scale (the default ratio: 1 unit = scale drawing units, the drawing at 1:1)}; a binary DXF or a DWG answers an error saying to save it as ASCII DXF",
|
||||
always,
|
||||
dxf_info
|
||||
)]
|
||||
}
|
||||
|
||||
/// The `dxf` params, as they come.
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct Params {
|
||||
layout: Option<String>,
|
||||
fit: Option<bool>,
|
||||
fit_to: Option<[f64; 2]>,
|
||||
unit: Option<String>,
|
||||
scale: Option<f64>,
|
||||
scale_lineweights: Option<bool>,
|
||||
center: Option<bool>,
|
||||
merge_layers: Option<bool>,
|
||||
}
|
||||
|
||||
/// The DXF import options in `p` (the `dxf` object of `document.open` and `file.place`).
|
||||
pub(super) fn options(cmd: &str, p: Option<&Value>) -> Result<ImportOptions> {
|
||||
let d = ImportOptions::default();
|
||||
let Some(p) = p.filter(|v| !v.is_null()) else { return Ok(d) };
|
||||
if !p.is_object() {
|
||||
return Err(bad(cmd, "dxf must be an object of DXF import options"));
|
||||
}
|
||||
let q = Params::deserialize(p).map_err(|e| bad(cmd, format!("dxf: {e}")))?;
|
||||
let unit = match q.unit.as_deref() {
|
||||
None => None,
|
||||
Some(u) => Some(Unit::named(u).ok_or_else(|| bad(cmd, format!("dxf unit `{u}`: a unit such as mm, cm, in or pt")))?),
|
||||
};
|
||||
if let Some(s) = q.scale
|
||||
&& !(s.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&s))
|
||||
{
|
||||
return Err(bad(cmd, format!("dxf scale must be a positive number up to {}, not {s}", vectorcraft_cad::SCALE_RANGE.end())));
|
||||
}
|
||||
let fit_to = match q.fit_to {
|
||||
None => d.fit_to,
|
||||
Some([w, h]) if w > 0.0 && h > 0.0 && w.max(h) <= crate::MAX_COORD => (w, h),
|
||||
Some(_) => return Err(bad(cmd, "dxf fitTo must be [width, height], positive")),
|
||||
};
|
||||
Ok(ImportOptions {
|
||||
layout: q.layout.filter(|l| !l.trim().is_empty()),
|
||||
fit: q.fit.unwrap_or(d.fit),
|
||||
fit_to,
|
||||
unit,
|
||||
scale: q.scale,
|
||||
scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights),
|
||||
center: q.center.unwrap_or(d.center),
|
||||
merge_layers: q.merge_layers.unwrap_or(d.merge_layers),
|
||||
})
|
||||
}
|
||||
|
||||
/// Import a DXF drawing → the document and import notes.
|
||||
pub(super) fn import(bytes: &[u8], o: &ImportOptions) -> Result<(Document, Vec<String>)> {
|
||||
let r = vectorcraft_cad::import(bytes, o).map_err(err)?;
|
||||
Ok((r.document, r.warnings))
|
||||
}
|
||||
|
||||
const INFO: &str = "document.dxfInfo";
|
||||
|
||||
fn dxf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = source(p, INFO)?.bytes;
|
||||
let i = vectorcraft_cad::info(&bytes).map_err(err)?;
|
||||
Ok(json!({
|
||||
"version": i.version,
|
||||
"units": i.units,
|
||||
"layouts": i.layouts,
|
||||
"layers": i.layers,
|
||||
"unit": i.unit.label(),
|
||||
"scale": i.scale,
|
||||
}))
|
||||
}
|
||||
|
|
@ -59,6 +59,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
|||
// .ai and .ait files are PDF inside; the extension keeps the template meaning.
|
||||
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("pdf"));
|
||||
}
|
||||
if vectorcraft_cad::is_dxf(bytes) {
|
||||
return format("dxf");
|
||||
}
|
||||
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
|
||||
return Some(f);
|
||||
}
|
||||
|
|
@ -116,7 +119,7 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
|||
load_with(name, bytes, &LoadOptions::default())
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password).
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
// PostScript (.ai saved in an older format or without PDF compatibility, .eps) says so, by
|
||||
// any name.
|
||||
|
|
@ -152,6 +155,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
.map(|e| (e.intact, move || Some(e.data)));
|
||||
restore_or_import(editing, || super::pdfimport::import(bytes, opts))?
|
||||
}
|
||||
"dxf" => {
|
||||
let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?;
|
||||
(doc, warnings, false)
|
||||
}
|
||||
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open,
|
||||
//! save, export and serialize by path or bytes (base64), the format table, and `command.batch`.
|
||||
//!
|
||||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images).
|
||||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images).
|
||||
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
|
||||
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
|
||||
|
|
@ -13,12 +13,14 @@
|
|||
//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`.
|
||||
//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT).
|
||||
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
|
||||
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
|
||||
//!
|
||||
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
|
||||
//! agents query `document.formats`.
|
||||
|
||||
mod batch;
|
||||
pub mod dxf;
|
||||
pub mod dxfimport;
|
||||
mod encode;
|
||||
pub mod eps;
|
||||
mod export;
|
||||
|
|
@ -70,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
|
@ -433,7 +435,7 @@ pub const FORMATS: &[Format] = &[
|
|||
},
|
||||
Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS },
|
||||
Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS },
|
||||
Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: false, write: true, raster: false, options: dxf::OPTIONS },
|
||||
Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: true, write: true, raster: false, options: dxf::OPTIONS },
|
||||
Format {
|
||||
id: "eps",
|
||||
label: "EPS",
|
||||
|
|
@ -448,14 +450,32 @@ pub const FORMATS: &[Format] = &[
|
|||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
pub const OPEN_EXTS: &[&str] =
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate"];
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "dxf"];
|
||||
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] =
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "txt"];
|
||||
pub const PLACE_EXTS: &[&str] = &[
|
||||
"vectorcraft",
|
||||
"drawcraft",
|
||||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"ai",
|
||||
"ait",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"vctemplate",
|
||||
"dxf",
|
||||
"txt",
|
||||
];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
|
|
@ -731,3 +751,6 @@ mod tests_svgimport;
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests_eps;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_dxfimport;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use super::super::*;
|
|||
use super::{err, source};
|
||||
|
||||
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text,
|
||||
/// layers) are ignored by other formats.
|
||||
/// layers) and the DXF ones are ignored by other formats.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LoadOptions {
|
||||
/// 1-based pages to import, such as `"2-3, 5"`; `None` = every page.
|
||||
|
|
@ -27,18 +27,28 @@ pub struct LoadOptions {
|
|||
/// A PDF's optional content groups become layers (else one layer per page, without the art
|
||||
/// that is off).
|
||||
pub layers: bool,
|
||||
/// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]).
|
||||
pub dxf: vectorcraft_cad::ImportOptions,
|
||||
}
|
||||
|
||||
impl Default for LoadOptions {
|
||||
fn default() -> Self {
|
||||
Self { pages: None, crop: CropTo::default(), password: None, color_mode: None, text_as: TextAs::default(), layers: true }
|
||||
Self {
|
||||
pages: None,
|
||||
crop: CropTo::default(),
|
||||
password: None,
|
||||
color_mode: None,
|
||||
text_as: TextAs::default(),
|
||||
layers: true,
|
||||
dxf: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LoadOptions {
|
||||
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
|
||||
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`),
|
||||
/// `textAs` (`text` | `outlines`) and `layers` (true | false).
|
||||
/// `textAs` (`text` | `outlines`), `layers` (true | false) and `dxf` (the DXF import options).
|
||||
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
|
||||
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
|
||||
None => None,
|
||||
|
|
@ -60,7 +70,8 @@ impl LoadOptions {
|
|||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
|
||||
};
|
||||
Ok(Self { pages, crop, password, color_mode, text_as, layers })
|
||||
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
|
||||
Ok(Self { pages, crop, password, color_mode, text_as, layers, dxf })
|
||||
}
|
||||
|
||||
/// Does the document read only part of the file, or read it differently (a page range, another
|
||||
|
|
|
|||
|
|
@ -150,7 +150,8 @@ fn formats_list_dxf_and_what_can_not_be_written() {
|
|||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let dxf = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "dxf").unwrap().clone();
|
||||
assert_eq!((dxf["read"].as_bool(), dxf["write"].as_bool()), (Some(false), Some(true)));
|
||||
// Read too since DXF import (tests_dxfimport).
|
||||
assert_eq!((dxf["read"].as_bool(), dxf["write"].as_bool()), (Some(true), Some(true)));
|
||||
for o in [
|
||||
"version",
|
||||
"unit",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
//! DXF through the commands: `document.open` with the DXF options, `document.dxfInfo`,
|
||||
//! File → Place of a drawing (fitted, centred or at its origin), the format table, binary files,
|
||||
//! and a drawing `document.export` wrote opened again.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
vectorcraft_format::base64_encode(bytes)
|
||||
}
|
||||
|
||||
/// A millimetre drawing: a 200 × 100 rectangle on layer Walls, a circle on layer Notes, a block
|
||||
/// "Mark" (a short line) inserted twice, and a paper layout holding a line.
|
||||
fn plan() -> Vec<u8> {
|
||||
let pairs: &[(i32, &str)] = &[
|
||||
(0, "SECTION"),
|
||||
(2, "HEADER"),
|
||||
(9, "$ACADVER"),
|
||||
(1, "AC1015"),
|
||||
(9, "$INSUNITS"),
|
||||
(70, "4"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "TABLES"),
|
||||
(0, "TABLE"),
|
||||
(2, "LAYER"),
|
||||
(0, "LAYER"),
|
||||
(2, "Walls"),
|
||||
(70, "0"),
|
||||
(62, "1"),
|
||||
(0, "LAYER"),
|
||||
(2, "Notes"),
|
||||
(70, "4"),
|
||||
(62, "5"),
|
||||
(0, "ENDTAB"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "BLOCKS"),
|
||||
(0, "BLOCK"),
|
||||
(2, "Mark"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(0, "LINE"),
|
||||
(8, "0"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "10"),
|
||||
(21, "0"),
|
||||
(0, "ENDBLK"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "ENTITIES"),
|
||||
(0, "LWPOLYLINE"),
|
||||
(8, "Walls"),
|
||||
(90, "4"),
|
||||
(70, "1"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(10, "200"),
|
||||
(20, "0"),
|
||||
(10, "200"),
|
||||
(20, "100"),
|
||||
(10, "0"),
|
||||
(20, "100"),
|
||||
(0, "CIRCLE"),
|
||||
(8, "Notes"),
|
||||
(10, "100"),
|
||||
(20, "50"),
|
||||
(40, "20"),
|
||||
(0, "INSERT"),
|
||||
(8, "Walls"),
|
||||
(2, "Mark"),
|
||||
(10, "20"),
|
||||
(20, "20"),
|
||||
(0, "INSERT"),
|
||||
(8, "Walls"),
|
||||
(2, "Mark"),
|
||||
(10, "150"),
|
||||
(20, "20"),
|
||||
(0, "LINE"),
|
||||
(67, "1"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "50"),
|
||||
(21, "0"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
];
|
||||
pairs.iter().map(|(c, v)| format!("{c:>3}\n{v}\n")).collect::<String>().into_bytes()
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result<Value> {
|
||||
let mut p = json!({"name": "plan.dxf", "dataBase64": b64(bytes)});
|
||||
if let (Some(p), Value::Object(e)) = (p.as_object_mut(), extra) {
|
||||
p.extend(e);
|
||||
}
|
||||
s.execute("document.open", &p)
|
||||
}
|
||||
|
||||
fn layer_names(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().doc.layers.iter().filter_map(|l| l.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn leaves(s: &Session) -> Vec<Node> {
|
||||
let mut out = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.children().is_none() {
|
||||
out.push(n.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
const MM: f64 = 72.0 / 25.4;
|
||||
|
||||
#[test]
|
||||
fn document_open_reads_a_dxf_drawing() {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, &plan(), json!({})).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf")));
|
||||
assert_eq!(layer_names(&s), ["Walls", "Notes"]);
|
||||
let st = s.doc().unwrap();
|
||||
// Millimetres at 1:1, the artboard the art's size.
|
||||
let board = st.doc.artboards[0].rect;
|
||||
assert!((board.width() - 200.0 * MM).abs() < 1e-6 && (board.height() - 100.0 * MM).abs() < 1e-6, "{board:?}");
|
||||
assert_eq!(st.doc.units, vectorcraft_doc::Unit::Millimeters);
|
||||
assert_eq!(st.doc.symbols.len(), 1, "the block is a symbol");
|
||||
assert_eq!(leaves(&s).iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count(), 2);
|
||||
assert!(st.doc.layers[1].locked, "Notes is locked");
|
||||
// Saving asks for a name: DXF opens as artwork, it isn't written back.
|
||||
assert!(st.path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_open_takes_the_dxf_options() {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &plan(), json!({"dxf": {"fit": true, "mergeLayers": true}})).unwrap();
|
||||
assert_eq!(layer_names(&s), ["Layer 1"]);
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), (792.0, 612.0), "fitted to a landscape letter page");
|
||||
open(&mut s, &plan(), json!({"dxf": {"unit": "cm", "scale": 1}})).unwrap();
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert!((board.width() - 200.0 * MM * 10.0).abs() < 1e-6, "1 cm = 1 unit: {board:?}");
|
||||
open(&mut s, &plan(), json!({"dxf": {"layout": "Layout1"}})).unwrap();
|
||||
assert_eq!(leaves(&s).len(), 1, "the paper layout's line");
|
||||
for bad in [json!({"dxf": {"unit": "furlong"}}), json!({"dxf": {"scale": 0}}), json!({"dxf": {"layout": "Sheet 9"}}), json!({"dxf": 3})] {
|
||||
assert!(open(&mut s, &plan(), bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_info_lists_layouts_layers_and_units() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&plan())})).unwrap();
|
||||
assert_eq!(r["version"], "2000");
|
||||
assert_eq!(r["units"], "Millimeters");
|
||||
assert_eq!(r["layouts"], json!(["Model", "Layout1"]));
|
||||
assert_eq!(r["layers"], json!(["Walls", "Notes"]));
|
||||
assert_eq!((r["unit"].as_str(), r["scale"].as_f64()), (Some("Millimeters"), Some(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_dxf_says_to_save_it_as_ascii() {
|
||||
let mut s = Session::new();
|
||||
let mut bytes = vectorcraft_cad::import::BINARY_SENTINEL.to_vec();
|
||||
bytes.extend([0u8; 64]);
|
||||
let e = open(&mut s, &bytes, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
|
||||
let e = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&bytes)})).unwrap_err().to_string();
|
||||
assert!(e.contains("binary DXF"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_is_readable_and_detected_by_content() {
|
||||
let f = format("dxf").unwrap();
|
||||
assert!(f.read && f.write);
|
||||
assert!(OPEN_EXTS.contains(&"dxf") && PLACE_EXTS.contains(&"dxf"));
|
||||
assert_eq!(detect("drawing.txt", &plan()).map(|f| f.id), Some("dxf"), "the content tells");
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
assert!(r["readable"].as_array().unwrap().contains(&json!("dxf")));
|
||||
}
|
||||
|
||||
/// A document of one 600 × 400 artboard.
|
||||
fn page() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn placed(s: &mut Session, dxf: Value) -> (Value, Rect) {
|
||||
let mut p = json!({"name": "plan.dxf", "dataBase64": b64(&plan())});
|
||||
if !dxf.is_null() {
|
||||
p["dxf"] = dxf;
|
||||
}
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("file.place", &p).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let id = vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
(r, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_place_puts_a_drawing_in_as_one_group() {
|
||||
let mut s = page();
|
||||
let (r, b) = placed(&mut s, Value::Null);
|
||||
assert_eq!(r["format"], "dxf");
|
||||
let st = s.doc().unwrap();
|
||||
let id = vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap());
|
||||
assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Group { clip: false, .. }));
|
||||
assert_eq!(st.doc.symbols.len(), 1, "its symbol joins the document");
|
||||
// Centred on the artboard at 1:1.
|
||||
assert!((b.center().x - 300.0).abs() < 0.5 && (b.center().y - 200.0).abs() < 0.5, "{b:?}");
|
||||
assert!((b.width() - 200.0 * MM).abs() < 1.0, "{b:?}");
|
||||
// Fit: the artboard's size.
|
||||
let (_, b) = placed(&mut s, json!({"fit": true}));
|
||||
assert!((b.width() - 600.0).abs() < 1.0 && (b.center().y - 200.0).abs() < 0.5, "{b:?}");
|
||||
// Not centred: the drawing's origin on the artboard's bottom-left corner.
|
||||
let (_, b) = placed(&mut s, json!({"center": false, "unit": "pt", "scale": 1}));
|
||||
assert!((b.x0 - 0.0).abs() < 0.5 && (b.y1 - 400.0).abs() < 0.5, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawing_export_writes_opens_again() {
|
||||
let mut s = page();
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 350, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "unit": "pt"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, &bytes, json!({"dxf": {"unit": "pt", "scale": 1, "center": false}})).unwrap();
|
||||
assert_eq!(r["format"], "dxf");
|
||||
let all: Vec<Rect> = leaves(&s).iter().filter_map(|n| n.geometric_bounds()).collect();
|
||||
assert_eq!(all.len(), 2, "{all:?}");
|
||||
let h = s.doc().unwrap().doc.artboards[0].rect.height();
|
||||
// The export's origin is the artboard's bottom-left: the art keeps its place above it.
|
||||
let union = all.iter().copied().reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!((union.width() - 350.0).abs() < 2.0 && (union.y1 - h + 200.0).abs() < 2.0, "{union:?} on {h}");
|
||||
}
|
||||
|
|
@ -232,6 +232,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(slices::specs());
|
||||
v.extend(print::specs());
|
||||
v.extend(recovery::specs());
|
||||
v.extend(fileio::dxfimport::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
//! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution
|
||||
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with
|
||||
//! `link` ([`super::links`]).
|
||||
//! - An SVG becomes one group of its art.
|
||||
//! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options
|
||||
//! (`dxf`, [`fileio::dxfimport`]).
|
||||
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
|
||||
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
|
||||
//! (`crop: "bounding"`).
|
||||
|
|
@ -47,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
|
|
@ -74,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Load Place Cursor",
|
||||
[],
|
||||
None,
|
||||
"{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}",
|
||||
"{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, dxf?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}",
|
||||
has_doc,
|
||||
queue
|
||||
),
|
||||
|
|
@ -107,6 +108,9 @@ struct Loaded {
|
|||
warnings: Vec<String>,
|
||||
/// The link to a raster image's file (for Link), when read from a path.
|
||||
link: Option<LinkInfo>,
|
||||
/// The artboard a DXF drawing fitted to an artboard, or not centred, was laid out on: it lands
|
||||
/// on the target artboard the same way.
|
||||
board: Option<Rect>,
|
||||
}
|
||||
|
||||
/// Points per pixel of a raster image at the resolution it declares (72 ppi when none).
|
||||
|
|
@ -115,8 +119,9 @@ pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
|
|||
(72.0 / x, 72.0 / y)
|
||||
}
|
||||
|
||||
/// The file `p` names, read into placeable art.
|
||||
fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
||||
/// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board`
|
||||
/// (unless the params name their own box).
|
||||
fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
let src = fileio::source(p, cmd)?;
|
||||
let name = fileio::file_name(src.name);
|
||||
if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) {
|
||||
|
|
@ -124,7 +129,7 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
|||
if text.trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
}
|
||||
return Ok(Loaded { name, format: &text::FORMAT, art: Art::Text(text), natural: TEXT_FRAME, warnings: vec![], link: None });
|
||||
return Ok(Loaded { name, format: &text::FORMAT, art: Art::Text(text), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
}
|
||||
let format = fileio::detect(src.name, &src.bytes)
|
||||
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
|
|
@ -132,7 +137,12 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
|||
None => 1,
|
||||
Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))? as usize,
|
||||
};
|
||||
let opts = fileio::LoadOptions::from_params(cmd, p)?;
|
||||
let mut opts = fileio::LoadOptions::from_params(cmd, p)?;
|
||||
if let Some(b) = board
|
||||
&& p.get("dxf").and_then(|d| d.get("fitTo")).is_none()
|
||||
{
|
||||
opts.dxf.fit_to = (b.width(), b.height());
|
||||
}
|
||||
let pdf = matches!(format.id, "pdf" | "ai" | "ait");
|
||||
if !pdf && !matches!(opts.crop, CropTo::Crop | CropTo::Bounding) {
|
||||
return Err(bad(
|
||||
|
|
@ -146,8 +156,9 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
|||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy);
|
||||
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link });
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link, board: None });
|
||||
}
|
||||
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
|
||||
let (mut doc, warnings) = match format.id {
|
||||
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
|
||||
// bounds, below).
|
||||
|
|
@ -156,15 +167,18 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
|||
fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))?
|
||||
}
|
||||
_ => {
|
||||
let mut l = fileio::load(src.name, &src.bytes)?;
|
||||
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
|
||||
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
|
||||
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
|
||||
// Linked images (a native document's) show their files, found from its folder.
|
||||
super::links::resolve(&mut l.doc, src.path, false);
|
||||
(l.doc, l.warnings)
|
||||
}
|
||||
};
|
||||
doc.drop_edit_modes();
|
||||
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz") {
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
|
||||
(art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), None)
|
||||
} else {
|
||||
// The page placed is a PDF import's only one.
|
||||
|
|
@ -184,7 +198,7 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
|||
};
|
||||
let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())));
|
||||
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
||||
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None })
|
||||
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
|
||||
}
|
||||
|
||||
/// A visible, non-template layer.
|
||||
|
|
@ -301,7 +315,8 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
(true, [id]) => Some(*id),
|
||||
(true, _) => return Err(bad(PLACE, "replace: select the one object to replace")),
|
||||
};
|
||||
let mut loaded = load(p, PLACE)?;
|
||||
let target = board_at(&st.doc, at);
|
||||
let mut loaded = load(p, PLACE, target)?;
|
||||
let old_node = old.and_then(|id| st.doc.node(id));
|
||||
// Type reflows rather than scales: its frame takes the size it is placed at.
|
||||
let is_text = matches!(loaded.art, Art::Text(_));
|
||||
|
|
@ -317,6 +332,8 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let m = match (old_node, rect) {
|
||||
(Some(o), _) => replace_xf(&st.doc, o, natural, !is_text),
|
||||
(None, Some(r)) => fit(natural, r),
|
||||
// Its artboard's bottom-left corner on the target's.
|
||||
(None, None) if let (Some(src), Some(to)) = (loaded.board, target) => Affine::translate((to.x0 - src.x0, to.y1 - src.y1)),
|
||||
(None, None) => {
|
||||
let c = at.or_else(|| st.doc.artboards.first().map(|a| a.rect.center())).unwrap_or_default();
|
||||
Affine::translate(c - natural.center())
|
||||
|
|
@ -353,10 +370,15 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
)
|
||||
}
|
||||
|
||||
/// The artboard under `at`, else the first.
|
||||
fn board_at(d: &Document, at: Option<Point>) -> Option<Rect> {
|
||||
at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map(|a| a.rect)
|
||||
}
|
||||
|
||||
/// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a
|
||||
/// 36 pt margin.
|
||||
fn text_frame(d: &Document, at: Option<Point>) -> Rect {
|
||||
let board = at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map_or(TEXT_FRAME, |a| a.rect);
|
||||
let board = board_at(d, at).unwrap_or(TEXT_FRAME);
|
||||
Rect::from_center_size(board.center(), ((board.width() - 72.0).max(72.0), (board.height() - 72.0).max(72.0)))
|
||||
}
|
||||
|
||||
|
|
@ -402,12 +424,12 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let thumbnail = p.get("thumbnail").and_then(Value::as_f64);
|
||||
let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]);
|
||||
for mut q in queued_files(p)? {
|
||||
for k in ["link", "template", "page", "crop", "text"] {
|
||||
for k in ["link", "template", "page", "crop", "text", "dxf"] {
|
||||
if let Some(v) = p.get(k) {
|
||||
q[k] = v.clone();
|
||||
}
|
||||
}
|
||||
match load(&q, QUEUE) {
|
||||
match load(&q, QUEUE, None) {
|
||||
Ok(l) => {
|
||||
let (w, h) = (l.natural.width(), l.natural.height());
|
||||
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
|
||||
|
|
@ -465,7 +487,7 @@ fn thumbnail_png(l: Loaded, px: f64) -> Result<String> {
|
|||
}
|
||||
|
||||
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut l = load(p, "file.place.info")?;
|
||||
let mut l = load(p, "file.place.info", None)?;
|
||||
let natural = l.natural;
|
||||
let mut out = json!({
|
||||
"name": l.name,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG and PDF input,
|
||||
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
|
||||
//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset
|
||||
//! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions),
|
||||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
|
|
@ -884,3 +884,110 @@ proptest! {
|
|||
r.map_err(|msg| TestCaseError::fail(format!("{password}: panicked: {msg}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DXF ----------
|
||||
|
||||
/// The rich document exported as DXF (2018: hatches, splines, text, images; R12: polylines).
|
||||
fn rich_dxf(version: &str) -> Vec<u8> {
|
||||
vectorcraft_engine::cmd::fileio::encode(&rich_doc(), "dxf", &json!({"version": version, "preserve": "editability"})).unwrap()
|
||||
}
|
||||
|
||||
/// Group-code pairs as DXF text.
|
||||
fn dxf_text(pairs: &[(i32, String)]) -> String {
|
||||
pairs.iter().map(|(c, v)| format!("{c:>3}\n{v}\n")).collect()
|
||||
}
|
||||
|
||||
/// One entity: a type and a run of groups with hostile numbers and names.
|
||||
fn arb_dxf_entity() -> impl Strategy<Value = Vec<(i32, String)>> {
|
||||
let kind = prop::sample::select(vec![
|
||||
"LINE",
|
||||
"CIRCLE",
|
||||
"ARC",
|
||||
"ELLIPSE",
|
||||
"LWPOLYLINE",
|
||||
"POLYLINE",
|
||||
"VERTEX",
|
||||
"SEQEND",
|
||||
"SPLINE",
|
||||
"SOLID",
|
||||
"3DFACE",
|
||||
"HATCH",
|
||||
"TEXT",
|
||||
"MTEXT",
|
||||
"INSERT",
|
||||
"ATTRIB",
|
||||
"DIMENSION",
|
||||
"VIEWPORT",
|
||||
]);
|
||||
let code = prop::sample::select(vec![
|
||||
2, 6, 7, 8, 10, 11, 12, 13, 20, 21, 22, 23, 40, 41, 42, 43, 44, 45, 48, 50, 51, 60, 62, 66, 67, 70, 71, 72, 73, 74, 75, 76, 90, 91, 92, 93,
|
||||
94, 95, 96, 97, 210, 220, 230, 370, 420, 440, 450,
|
||||
]);
|
||||
let value = prop_oneof![arb_num(), Just("B".to_string()), Just("*U1".to_string()), Just(r"{\fA;x}\P%%c^".to_string())];
|
||||
(kind, prop::collection::vec((code, value), 0..24)).prop_map(|(k, g)| std::iter::once((0, k.to_string())).chain(g).collect())
|
||||
}
|
||||
|
||||
/// A drawing whose block "B" holds `block` (it may insert itself) and whose entities are `body`.
|
||||
fn hostile_dxf(block: &[Vec<(i32, String)>], body: &[Vec<(i32, String)>]) -> Vec<u8> {
|
||||
let head: Vec<(i32, String)> =
|
||||
[(0, "SECTION"), (2, "BLOCKS"), (0, "BLOCK"), (2, "B"), (10, "0"), (20, "0")].iter().map(|(c, v)| (*c, v.to_string())).collect();
|
||||
let mid: Vec<(i32, String)> = [(0, "ENDBLK"), (0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")].iter().map(|(c, v)| (*c, v.to_string())).collect();
|
||||
let tail = vec![(0, "ENDSEC".to_string()), (0, "EOF".to_string())];
|
||||
let all: Vec<(i32, String)> = head.into_iter().chain(block.concat()).chain(mid).chain(body.concat()).chain(tail).collect();
|
||||
dxf_text(&all).into_bytes()
|
||||
}
|
||||
|
||||
/// Import with every option at once, and place it; whatever comes back must render and export.
|
||||
fn survive_dxf(what: &str, bytes: &[u8], fit: bool) -> Result<(), TestCaseError> {
|
||||
let o = vectorcraft_cad::ImportOptions { fit, center: !fit, merge_layers: fit, ..Default::default() };
|
||||
survive(what, || vectorcraft_cad::import(bytes, &o).ok().map(|r| r.document))?;
|
||||
let _ = vectorcraft_cad::info(bytes);
|
||||
let _ = vectorcraft_cad::is_dxf(bytes);
|
||||
survive(what, || {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).ok()?;
|
||||
let p = json!({"name": "x.dxf", "dataBase64": vectorcraft_format::base64_encode(bytes), "dxf": {"fit": fit}, "thumbnail": 8});
|
||||
let _ = s.execute("file.place.info", &p);
|
||||
let _ = s.execute("file.place", &p);
|
||||
let _ = s.execute("document.dxfInfo", &p);
|
||||
Some((*s.doc().ok()?.doc).clone())
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn dxf_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..400), section in any::<bool>()) {
|
||||
let mut b = if section { b" 0\nSECTION\n 2\nENTITIES\n".to_vec() } else { vec![] };
|
||||
b.extend(bytes);
|
||||
survive_dxf("dxf garbage", &b, section)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_hostile_entities_never_panic(
|
||||
block in prop::collection::vec(arb_dxf_entity(), 0..4),
|
||||
body in prop::collection::vec(arb_dxf_entity(), 0..8),
|
||||
fit in any::<bool>(),
|
||||
) {
|
||||
let bytes = hostile_dxf(&block, &body);
|
||||
survive_dxf("hostile dxf", &bytes, fit)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_mutated_export_never_panics(
|
||||
r12 in any::<bool>(),
|
||||
cut in 0usize..60_000,
|
||||
edits in prop::collection::vec((0usize..60_000, prop::sample::select(vec![b'0', b'1', b'9', b'-', b'.', b'e', b'\n', b' ', b'A', b'^', b'\\', 0xC3])), 0..12),
|
||||
) {
|
||||
static SAMPLES: std::sync::OnceLock<[Vec<u8>; 2]> = std::sync::OnceLock::new();
|
||||
let samples = SAMPLES.get_or_init(|| [rich_dxf("2018"), rich_dxf("R12")]);
|
||||
let mut bytes = samples[usize::from(r12)].clone();
|
||||
for &(at, b) in &edits {
|
||||
let n = bytes.len();
|
||||
bytes[at % n] = b;
|
||||
}
|
||||
bytes.truncate(cut.max(8));
|
||||
survive_dxf("mutated dxf", &bytes, cut % 2 == 0)?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,295 @@
|
|||
//! DXF Import Options: opening or placing a DXF drawing asks which layout to import (model space
|
||||
//! or a paper layout, previewed), its scale (fitted to the artboard, or 1 unit of the art = N
|
||||
//! drawing units: by default the drawing at 1:1 in its own unit), whether lineweights scale with
|
||||
//! it, whether it is centred, and whether its layers merge into one.
|
||||
//!
|
||||
//! Fields: `mode` (`open` | `place`), `name`, `path?`, `layouts` (model space first), `units` and
|
||||
//! `version` (what the drawing declares), and the `dxf` options of `document.open`: `layout`,
|
||||
//! `fit`, `unit` (a unit name), `scale`, `scaleLineweights`, `center`, `mergeLayers`. `__place`
|
||||
//! keeps the other `file.place` params. The file's bytes stay in
|
||||
//! [`crate::state::UiState::dialog_file`]. OK opens or places the drawing and remembers the fit,
|
||||
//! lineweight, centring and layer choices for the next one; errors keep the dialog open.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
|
||||
use super::DialogSpec;
|
||||
use super::document_setup::{LABEL, check, choice};
|
||||
use super::import_pdf::{DialogFile, other_place_params, place_source, preview_texture, show_preview, with_file};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, io, widgets};
|
||||
|
||||
pub const KIND: &str = "dxfImport";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |d| if d.str("mode") == "place" { "Place DXF" } else { "DXF Import Options" }.into(),
|
||||
body,
|
||||
confirm,
|
||||
min_width: 560.0,
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// The longest side of the layout preview (pt).
|
||||
const PREVIEW: f32 = 170.0;
|
||||
/// The choices the next drawing starts from (the scale belongs to each drawing's units).
|
||||
const REMEMBERED: [&str; 4] = ["fit", "scaleLineweights", "center", "mergeLayers"];
|
||||
|
||||
/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when they
|
||||
/// are a DXF drawing; false when they aren't (or can't be read: the loader then says why).
|
||||
pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, place: Option<&Value>) -> bool {
|
||||
if fileio::detect(name, bytes).is_none_or(|f| f.id != "dxf") {
|
||||
return false;
|
||||
}
|
||||
let Ok(info) = vectorcraft_cad::info(bytes) else { return false };
|
||||
app.ui.dialog_file = Some(DialogFile::new(bytes.to_vec()));
|
||||
let mut fields = json!({
|
||||
"mode": if place.is_some() { "place" } else { "open" },
|
||||
"name": name,
|
||||
"layouts": info.layouts,
|
||||
"layout": info.layouts.first().map_or("Model", String::as_str),
|
||||
"units": info.units,
|
||||
"version": info.version,
|
||||
"fit": false,
|
||||
"unit": info.unit.label(),
|
||||
"scale": info.scale,
|
||||
"scaleLineweights": false,
|
||||
"center": true,
|
||||
"mergeLayers": false,
|
||||
});
|
||||
if let Some(last) = app.ui.dxf_import.as_object() {
|
||||
for k in REMEMBERED {
|
||||
if let Some(v) = last.get(k).filter(|v| v.is_boolean()) {
|
||||
fields[k] = v.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = path {
|
||||
fields["path"] = json!(p);
|
||||
}
|
||||
if let Some(p) = place {
|
||||
fields["__place"] = other_place_params(p, &["dxf"]);
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
true
|
||||
}
|
||||
|
||||
/// `file.place` with params `p` asks first (true) when they name a `.dxf` drawing and no `dxf`
|
||||
/// options.
|
||||
pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool {
|
||||
let named = p.get("path").or_else(|| p.get("name")).and_then(Value::as_str).map(fileio::extension);
|
||||
if p.get("dxf").is_some() || named.as_deref() != Some("dxf") {
|
||||
return false;
|
||||
}
|
||||
let Some((name, bytes, path)) = place_source(app, p, |ext| ext == "dxf") else { return false };
|
||||
offer(app, &name, &bytes, path, Some(p))
|
||||
}
|
||||
|
||||
/// The `dxf` options of `document.open` and `file.place` the fields set.
|
||||
fn options(d: &Dialog) -> Value {
|
||||
let fit = d.bool("fit");
|
||||
let mut o = json!({
|
||||
"layout": d.str("layout"),
|
||||
"fit": fit,
|
||||
"scaleLineweights": d.bool("scaleLineweights"),
|
||||
"center": d.bool("center"),
|
||||
"mergeLayers": d.bool("mergeLayers"),
|
||||
});
|
||||
if !fit {
|
||||
o["unit"] = json!(d.str("unit"));
|
||||
o["scale"] = json!(d.f64("scale", 1.0));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// OK: open or place the drawing with the options.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let file = app.ui.dialog_file.clone().ok_or("the drawing is no longer loaded")?;
|
||||
let dxf = options(d);
|
||||
app.ui.dxf_import = Value::Object(REMEMBERED.iter().map(|k| (k.to_string(), dxf[*k].clone())).collect());
|
||||
let r = if d.str("mode") == "place" {
|
||||
let mut p = with_file(d.fields.get("__place"), d, &file);
|
||||
p["dxf"] = dxf;
|
||||
crate::place::run(app, &p).map(|_| ())
|
||||
} else {
|
||||
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
io::open_document(app, &d.str("name"), &file.bytes, path, &json!({ "dxf": dxf }))
|
||||
};
|
||||
match r {
|
||||
Ok(()) => {
|
||||
// Placing may have opened another dialog; only this one closes.
|
||||
if app.ui.dialog.as_ref().is_some_and(|x| x.kind == KIND) {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Err(e) => {
|
||||
app.ui.dialog = Some(d.clone());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
show_preview(ui, preview(app, ui.ctx(), d).as_ref(), PREVIEW);
|
||||
ui.add_space(6.0);
|
||||
widgets::dim_label(ui, &format!("DXF {} · {}", d.str("version"), d.str("units")));
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.vertical(|ui| {
|
||||
let layouts: Vec<String> =
|
||||
d.fields.get("layouts").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
|
||||
let rows: Vec<(&str, &str)> = layouts.iter().map(|l| (l.as_str(), l.as_str())).collect();
|
||||
choice(ui, d, "layout", "Layout:", &rows);
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Scaling");
|
||||
check(ui, d, "fit", "Scale to Fit Artboard");
|
||||
ui.add_enabled_ui(!d.bool("fit"), |ui| super::dxf_options::scale_row(ui, d));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL + 8.0);
|
||||
check(ui, d, "scaleLineweights", "Scale Lineweights");
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Options");
|
||||
check(ui, d, "center", "Center Artwork");
|
||||
check(ui, d, "mergeLayers", "Merge Layers");
|
||||
});
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// The chosen layout fitted to the preview, rendered once per drawing and layout.
|
||||
fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog) -> Option<egui::TextureHandle> {
|
||||
let file = app.ui.dialog_file.clone()?;
|
||||
let layout = d.str("layout");
|
||||
let key = egui::Id::new(("dxf-preview", file.token, layout.as_str()));
|
||||
let o = vectorcraft_cad::ImportOptions { layout: Some(layout.clone()), fit: true, ..Default::default() };
|
||||
// A layout that can't be read shows an empty preview (and isn't read again).
|
||||
preview_texture(app, ctx, key, "dxf-preview", PREVIEW, || vectorcraft_cad::import(&file.bytes, &o).ok().map(|r| r.document))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::doc::NodeKind;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A millimetre drawing: a line in model space and a circle on a paper layout.
|
||||
fn drawing() -> Vec<u8> {
|
||||
let pairs: &[(i32, &str)] = &[
|
||||
(0, "SECTION"),
|
||||
(2, "HEADER"),
|
||||
(9, "$INSUNITS"),
|
||||
(70, "4"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "ENTITIES"),
|
||||
(0, "LINE"),
|
||||
(8, "Walls"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "100"),
|
||||
(21, "50"),
|
||||
(0, "CIRCLE"),
|
||||
(67, "1"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(40, "5"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
];
|
||||
pairs.iter().map(|(c, v)| format!("{c:>3}\n{v}\n")).collect::<String>().into_bytes()
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) -> String {
|
||||
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, k: &str, v: Value) {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_drawing_asks_for_the_import_options() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
io::open_bytes(&mut app, "plan.dxf", &drawing(), None).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("mode"), d.str("layout"), d.str("unit")), (KIND, "open".into(), "Model".into(), "Millimeters".into()));
|
||||
assert_eq!(d.fields["layouts"], json!(["Model", "Layout1"]));
|
||||
let text = frame(&mut app);
|
||||
for label in [
|
||||
"DXF Import Options",
|
||||
"Layout:",
|
||||
"Scaling",
|
||||
"Scale to Fit Artboard",
|
||||
"Scale:",
|
||||
"Scale Lineweights",
|
||||
"Center Artwork",
|
||||
"Merge Layers",
|
||||
"Millimeters",
|
||||
"OK",
|
||||
] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
// Fit greys out the ratio; a paper layout previews and opens.
|
||||
set(&mut app, "fit", json!(true));
|
||||
set(&mut app, "layout", json!("Layout1"));
|
||||
set(&mut app, "mergeLayers", json!(true));
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let st = app.session.doc().unwrap();
|
||||
let mut circles = 0;
|
||||
st.doc.walk(|n| circles += usize::from(matches!(n.kind, NodeKind::Path { .. })));
|
||||
assert_eq!(circles, 1, "the paper layout's circle");
|
||||
assert_eq!(st.doc.layers.len(), 1);
|
||||
// The next drawing starts from these choices, its own scale.
|
||||
io::open_bytes(&mut app, "plan.dxf", &drawing(), None).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert!(d.bool("fit") && d.bool("mergeLayers"));
|
||||
assert_eq!(d.str("layout"), "Model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_options_keep_the_dialog_open() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
io::open_bytes(&mut app, "plan.dxf", &drawing(), None).unwrap();
|
||||
set(&mut app, "layout", json!("Sheet 9"));
|
||||
assert!(super::super::confirm(&mut app).is_err());
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
|
||||
set(&mut app, "layout", json!("Model"));
|
||||
set(&mut app, "unit", json!("Points"));
|
||||
set(&mut app, "scale", json!(1));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let w = app.session.doc().unwrap().doc.artboards[0].rect.width();
|
||||
assert_eq!(w, 100.0, "1 pt = 1 unit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placing_a_drawing_asks_then_places_one_group() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||||
let p = json!({"name": "plan.dxf", "dataBase64": vectorcraft_format::base64_encode(&drawing()), "template": false});
|
||||
crate::place::run(&mut app, &p).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("mode")), (KIND, "place".into()));
|
||||
assert_eq!(d.fields["__place"], json!({"template": false}));
|
||||
assert!(frame(&mut app).contains("Place DXF"));
|
||||
set(&mut app, "fit", json!(true));
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let st = app.session.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
let id = st.selection.objects[0];
|
||||
let b = st.doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
assert!((b.width() - 600.0).abs() < 1.0, "fitted to the artboard: {b:?}");
|
||||
// Params with dxf options place without asking.
|
||||
let mut p = p.clone();
|
||||
p["dxf"] = json!({});
|
||||
crate::place::run(&mut app, &p).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -108,8 +108,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
/// Scale: 1 [unit] = [scale] Units.
|
||||
fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
/// Scale: 1 [unit] = [scale] Units (the `unit` and `scale` fields; DXF import reads them too).
|
||||
pub(super) fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let units: Vec<&str> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| u.label()).collect();
|
||||
let unit = Unit::named(&d.str("unit")).unwrap_or(Unit::Millimeters);
|
||||
|
|
|
|||
|
|
@ -28,12 +28,88 @@ pub(super) const SPEC: DialogSpec =
|
|||
const PREVIEW: f32 = 200.0;
|
||||
const LABEL_WIDTH: f32 = 70.0;
|
||||
|
||||
/// The file the dialog reads.
|
||||
/// The file an import dialog reads (Import PDF, DXF Import Options).
|
||||
#[derive(Debug)]
|
||||
pub struct DialogFile {
|
||||
pub bytes: Vec<u8>,
|
||||
/// Unique per file the dialog opened (keys its preview cache).
|
||||
token: u64,
|
||||
pub(super) token: u64,
|
||||
}
|
||||
|
||||
impl DialogFile {
|
||||
pub fn new(bytes: Vec<u8>) -> Arc<Self> {
|
||||
static TOKEN: AtomicU64 = AtomicU64::new(0);
|
||||
Arc::new(Self { bytes, token: TOKEN.fetch_add(1, Ordering::Relaxed) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The file `file.place` params `p` name when it is one `wanted` takes by its extension (a path,
|
||||
/// read through the app's services) or given as `dataBase64` → (name, bytes, path).
|
||||
pub(super) fn place_source(app: &VectorcraftApp, p: &Value, wanted: impl Fn(&str) -> bool) -> Option<(String, Vec<u8>, Option<String>)> {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
match (s("path"), s("dataBase64")) {
|
||||
(Some(path), _) if wanted(&fileio::extension(path)) => {
|
||||
let bytes = app.services.read.as_ref().and_then(|r| r(path).ok())?;
|
||||
Some((path.to_string(), bytes, Some(path.to_string())))
|
||||
}
|
||||
(None, Some(b64)) => Some((s("name").unwrap_or("Untitled").to_string(), vectorcraft_format::base64_decode(b64)?, None)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `file.place` params besides the file and `taken` (kept by an import dialog for its OK).
|
||||
pub(super) fn other_place_params(p: &Value, taken: &[&str]) -> Value {
|
||||
let rest =
|
||||
p.as_object().into_iter().flatten().filter(|(k, _)| !matches!(k.as_str(), "path" | "name" | "dataBase64") && !taken.contains(&k.as_str()));
|
||||
Value::Object(rest.map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
}
|
||||
|
||||
/// `p` naming the dialog's file again: its path, or its name and bytes.
|
||||
pub(super) fn with_file(p: Option<&Value>, d: &Dialog, file: &DialogFile) -> Value {
|
||||
let mut p = p.filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({}));
|
||||
match d.fields.get("path").and_then(Value::as_str) {
|
||||
Some(path) => p["path"] = json!(path),
|
||||
None => {
|
||||
p["name"] = json!(d.str("name"));
|
||||
p["dataBase64"] = json!(vectorcraft_format::base64_encode(&file.bytes));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A preview of `doc`'s first artboard, at most `size` points on its longer side, rendered once
|
||||
/// per `key` (`None`: there is nothing to show).
|
||||
pub(super) fn preview_texture(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &egui::Context,
|
||||
key: egui::Id,
|
||||
name: &str,
|
||||
size: f32,
|
||||
doc: impl FnOnce() -> Option<vectorcraft_engine::doc::Document>,
|
||||
) -> Option<egui::TextureHandle> {
|
||||
if let Some(t) = ctx.data(|m| m.get_temp::<Option<egui::TextureHandle>>(key)) {
|
||||
return t;
|
||||
}
|
||||
let tex = doc().and_then(|doc| {
|
||||
let r = doc.artboards.first()?.rect;
|
||||
let px = size as f64 * ctx.pixels_per_point() as f64;
|
||||
Some(widgets::region_texture(ctx, &mut app.canvas.renderer, name, &doc, r, px))
|
||||
});
|
||||
ctx.data_mut(|m| m.insert_temp(key, tex.clone()));
|
||||
tex
|
||||
}
|
||||
|
||||
/// `tex` fitted in a `size` square on the pasteboard, framed.
|
||||
pub(super) fn show_preview(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, size: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
|
||||
ui.painter().rect_filled(r, 2.0, t.pasteboard);
|
||||
if let Some(tex) = tex {
|
||||
let sz = tex.size_vec2();
|
||||
let ir = egui::Rect::from_center_size(r.center(), sz * (size / sz.x.max(sz.y)));
|
||||
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
ui.painter().rect_stroke(ir, 0.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the
|
||||
|
|
@ -48,8 +124,7 @@ pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<St
|
|||
Err(PdfError::NeedsPassword) => (0, true),
|
||||
_ => return false,
|
||||
};
|
||||
static TOKEN: AtomicU64 = AtomicU64::new(0);
|
||||
app.ui.dialog_file = Some(Arc::new(DialogFile { bytes: bytes.to_vec(), token: TOKEN.fetch_add(1, Ordering::Relaxed) }));
|
||||
app.ui.dialog_file = Some(DialogFile::new(bytes.to_vec()));
|
||||
let mut fields = json!({
|
||||
"mode": if place.is_some() { "place" } else { "open" },
|
||||
"name": name,
|
||||
|
|
@ -64,13 +139,8 @@ pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<St
|
|||
if let Some(p) = path {
|
||||
fields["path"] = json!(p);
|
||||
}
|
||||
if let Some(Value::Object(p)) = place {
|
||||
let rest: serde_json::Map<String, Value> = p
|
||||
.iter()
|
||||
.filter(|(k, _)| !matches!(k.as_str(), "path" | "name" | "dataBase64" | "page" | "crop" | "password"))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
fields["__place"] = Value::Object(rest);
|
||||
if let Some(p) = place {
|
||||
fields["__place"] = other_place_params(p, &["page", "crop", "password"]);
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
true
|
||||
|
|
@ -82,18 +152,7 @@ pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool {
|
|||
if p.get("page").is_some() {
|
||||
return false;
|
||||
}
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let (name, bytes, path) = match (s("path"), s("dataBase64")) {
|
||||
(Some(path), _) if matches!(fileio::extension(path).as_str(), "pdf" | "ai" | "ait") => {
|
||||
let Some(bytes) = app.services.read.as_ref().and_then(|r| r(path).ok()) else { return false };
|
||||
(path.to_string(), bytes, Some(path.to_string()))
|
||||
}
|
||||
(None, Some(b64)) => match vectorcraft_format::base64_decode(b64) {
|
||||
Some(bytes) => (s("name").unwrap_or("Untitled").to_string(), bytes, None),
|
||||
None => return false,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
let Some((name, bytes, path)) = place_source(app, p, |ext| matches!(ext, "pdf" | "ai" | "ait")) else { return false };
|
||||
offer(app, &name, &bytes, path, Some(p))
|
||||
}
|
||||
|
||||
|
|
@ -131,14 +190,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||
let name = d.str("name");
|
||||
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
let r = if d.str("mode") == "place" {
|
||||
let mut p = d.fields.get("__place").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
match &path {
|
||||
Some(path) => p["path"] = json!(path),
|
||||
None => {
|
||||
p["name"] = json!(name);
|
||||
p["dataBase64"] = json!(vectorcraft_format::base64_encode(&file.bytes));
|
||||
}
|
||||
}
|
||||
let mut p = with_file(d.fields.get("__place"), &d, &file);
|
||||
p["page"] = json!(page(&d));
|
||||
p["crop"] = json!(crop(&d).id());
|
||||
if let Some(pw) = password(&d) {
|
||||
|
|
@ -180,14 +232,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
let mut current = page(d);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(PREVIEW, PREVIEW), egui::Sense::hover());
|
||||
ui.painter().rect_filled(r, 2.0, t.pasteboard);
|
||||
if let Some(tex) = preview(app, ui.ctx(), d, current) {
|
||||
let sz = tex.size_vec2();
|
||||
let ir = egui::Rect::from_center_size(r.center(), sz * (PREVIEW / sz.x.max(sz.y)));
|
||||
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
ui.painter().rect_stroke(ir, 0.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Outside);
|
||||
}
|
||||
show_preview(ui, preview(app, ui.ctx(), d, current).as_ref(), PREVIEW);
|
||||
ui.add_space(6.0);
|
||||
// Page navigation: first, previous, the page number, next, last.
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -261,16 +306,9 @@ fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog, page: usiz
|
|||
let file = app.ui.dialog_file.clone()?;
|
||||
let crop = crop(d);
|
||||
let key = egui::Id::new(("pdf-preview", file.token, page, crop.id()));
|
||||
if let Some(t) = ctx.data(|m| m.get_temp::<Option<egui::TextureHandle>>(key)) {
|
||||
return t;
|
||||
}
|
||||
let opts = fileio::LoadOptions { crop, password: password(d), ..Default::default() };
|
||||
// A page that can't be read shows an empty preview (and isn't read again).
|
||||
let tex = fileio::page_document(&file.bytes, page - 1, &opts).ok().and_then(|(doc, _)| {
|
||||
let r = doc.artboards.first()?.rect;
|
||||
let px = PREVIEW as f64 * ctx.pixels_per_point() as f64;
|
||||
Some(widgets::region_texture(ctx, &mut app.canvas.renderer, &format!("pdf-preview-{page}"), &doc, r, px))
|
||||
});
|
||||
ctx.data_mut(|m| m.insert_temp(key, tex.clone()));
|
||||
tex
|
||||
preview_texture(app, ctx, key, &format!("pdf-preview-{page}"), PREVIEW, || {
|
||||
fileio::page_document(&file.bytes, page - 1, &opts).ok().map(|(doc, _)| doc)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ mod color_picker;
|
|||
mod command;
|
||||
pub mod confirm;
|
||||
mod document_setup;
|
||||
pub mod dxf_import;
|
||||
pub mod dxf_options;
|
||||
mod effect;
|
||||
pub mod eps_options;
|
||||
|
|
@ -225,6 +226,7 @@ registry! {
|
|||
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
|
||||
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
|
||||
Recovery: [crate::recovery::KIND] => recovery::SPEC,
|
||||
DxfImport: [dxf_import::KIND] => dxf_import::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -38,8 +38,11 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
|
|||
let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library };
|
||||
return load(app, p).map(|_| ());
|
||||
}
|
||||
// A PDF with several pages or a password asks first (the Import PDF dialog).
|
||||
if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) {
|
||||
// A PDF with several pages or a password asks first (the Import PDF dialog), and so does a
|
||||
// DXF drawing (DXF Import Options).
|
||||
if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None)
|
||||
|| crate::dialogs::dxf_import::offer(app, name, bytes, path.clone(), None)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
open_document(app, name, bytes, path, &Value::Null)
|
||||
|
|
|
|||
|
|
@ -89,6 +89,10 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
|||
if crate::dialogs::import_pdf::offer_place(app, p) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// A DXF drawing asks for its import options.
|
||||
if crate::dialogs::dxf_import::offer_place(app, p) {
|
||||
return Ok(json!({ "dialog": crate::dialogs::dxf_import::KIND }));
|
||||
}
|
||||
let mut p = p.clone();
|
||||
if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none())
|
||||
&& let Some(v) = app.view()
|
||||
|
|
|
|||
|
|
@ -292,6 +292,10 @@ pub struct UiState {
|
|||
/// The EPS Options chosen last (`document.exportEps` options; null: never used).
|
||||
#[serde(default)]
|
||||
pub eps_options: Value,
|
||||
/// The DXF Import Options chosen last (fit, scaleLineweights, center, mergeLayers; null:
|
||||
/// never used).
|
||||
#[serde(default)]
|
||||
pub dxf_import: Value,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
|
|
@ -352,6 +356,7 @@ impl Default for UiState {
|
|||
dialog_file: None,
|
||||
dxf_options: Value::Null,
|
||||
eps_options: Value::Null,
|
||||
dxf_import: Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,15 @@ Export Selected Art Only with nothing selected, keeps the dialog open), remember
|
|||
writes the file (asking for a path when there is none). Export As also lists DWG, greyed out: setting its `format` to
|
||||
`dwg` shows, and on confirm answers, the hint to export DXF instead.
|
||||
|
||||
DXF Import Options: opening a DXF drawing through the app (`app.open`, `file.open`, a drop) or placing one
|
||||
(`file.place` with a `.dxf` file and no `dxf` options, which answers `{"dialog": "dxfImport"}`) opens the `dxfImport`
|
||||
dialog (`mode`: `open` or `place`; `layouts`, `units` and `version` describe the drawing). Its fields are the
|
||||
`document.open` `dxf` options: `layout` (one of `layouts`), `fit`, `unit` (a unit name such as `Millimeters`) and
|
||||
`scale` (1 unit = `scale` drawing units; ignored with `fit`), `scaleLineweights`, `center`, `mergeLayers`.
|
||||
`ui.dialog.confirm` opens or places the drawing (placing keeps the other `file.place` params in `__place`) and
|
||||
remembers `fit`, `scaleLineweights`, `center` and `mergeLayers` for the next drawing; an error (an unknown layout,
|
||||
art too large at that scale) keeps the dialog open.
|
||||
|
||||
Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?}` for agents) opens the
|
||||
`placementOptions` dialog for the selected images (fields `ids`, `preserve`: `transforms`/`bounds`/`fileDimensions`/
|
||||
`fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as
|
||||
|
|
|
|||
24
docs/mcp.md
24
docs/mcp.md
|
|
@ -1112,6 +1112,30 @@ opened.
|
|||
{"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}}
|
||||
```
|
||||
|
||||
## DXF import
|
||||
|
||||
`document.open` reads ASCII DXF drawings of any version (by content or the `.dxf` extension), and `file.place`
|
||||
places one as a group. Both take `dxf: {layout, unit, scale, fit, fitTo, scaleLineweights, center, mergeLayers}`
|
||||
(all optional). `document.dxfInfo` reads a drawing without opening it: its `version`, drawing `units`, `layouts`
|
||||
(`Model` first, then the paper layouts), `layers`, and the default ratio (`unit`, `scale`: the drawing at 1:1 in its
|
||||
own unit; unitless metric drawings read millimetres, imperial ones inches). A ratio is 1 `unit` of the art = `scale`
|
||||
drawing units; `fit: true` instead scales the art to fit `fitTo` (a letter page when opening; when placing, the
|
||||
artboard under `at`). `center: false` puts the drawing's origin (fitted art: its bottom-left corner) on the
|
||||
artboard's bottom-left corner; `mergeLayers` puts all art on one layer. Each DXF layer holding art becomes a layer
|
||||
(off and frozen layers hidden, locked ones locked, non-plotting ones non-printing). Lines, polylines (arc segments from
|
||||
bulges), circles, arcs, ellipses, splines (NURBS, within a millionth of their size), solids and hatches become paths (solid
|
||||
hatches filled, pattern hatches as their boundaries), text and multiline text point type, named blocks symbols
|
||||
(one per look when their art takes colour "by block"), anonymous blocks such as dimensions groups. Indexed colours
|
||||
(7 is black on paper), true colour, lineweights (`scaleLineweights` scales them with the art), linetypes and
|
||||
transparency resolve through layers and blocks. What is left out (viewports, images, meshes, unknown entities) is
|
||||
listed in `warnings`. Binary DXF and DWG files answer an error saying to save them as ASCII DXF.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.dxfInfo","params":{"path":"/tmp/plan.dxf"}}}
|
||||
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/plan.dxf","dxf":{"layout":"Layout1","fit":true,"mergeLayers":true}}}}
|
||||
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/detail.dxf","dxf":{"unit":"mm","scale":10,"center":false}}}}
|
||||
```
|
||||
|
||||
## Links panel
|
||||
|
||||
`links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top
|
||||
|
|
|
|||
Loading…
Reference in New Issue