mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 11:35:55 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fac0043543 | |||
| 119c40c814 |
+675
-29
@@ -17,6 +17,12 @@ use solidcraft_geom::{Loop2, MAX_BEZIER_DEGREE, Seg2, Vec2};
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
mod file_model;
|
||||
pub use file_model::{
|
||||
DxfFile, DxfGeometryBounds, Entity as DxfFileEntity, EntityWeight as DxfEntityWeight, Layer as DxfFileLayer, LayerId as DxfLayerId,
|
||||
StandardWeight as DxfStandardWeight,
|
||||
};
|
||||
|
||||
/// A layer's linetype, defined in the file's LTYPE table so a viewer needs nothing else.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DxfLinetype {
|
||||
@@ -244,22 +250,96 @@ impl DxfShape {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fallible bounded sink for the owned-file path. Formatting remains exactly that of Out.
|
||||
struct BoundedDxfString<'a> {
|
||||
text: &'a mut String,
|
||||
limit: usize,
|
||||
failure: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl std::fmt::Write for BoundedDxfString<'_> {
|
||||
fn write_str(&mut self, value: &str) -> std::fmt::Result {
|
||||
if self.failure.is_some() {
|
||||
return Err(std::fmt::Error);
|
||||
}
|
||||
if self.text.len().checked_add(value.len()).is_none_or(|n| n > self.limit) {
|
||||
self.failure = Some("DXF file output exceeds the drawing-byte limit");
|
||||
return Err(std::fmt::Error);
|
||||
}
|
||||
if self.text.try_reserve(value.len()).is_err() {
|
||||
self.failure = Some("DXF file output allocation failed");
|
||||
return Err(std::fmt::Error);
|
||||
}
|
||||
self.text.push_str(value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The existing twelve-decimal DXF spelling, shared by output and owned admission.
|
||||
fn number_value(value: f64) -> std::result::Result<String, &'static str> {
|
||||
let mut text = String::new();
|
||||
// A finite f64 in fixed notation needs at most 323 bytes, including sign.
|
||||
let mut sink = BoundedDxfString { text: &mut text, limit: 384, failure: None };
|
||||
if write!(sink, "{value:.12}").is_err() {
|
||||
return Err(sink.failure.unwrap_or("DXF number formatting failed"));
|
||||
}
|
||||
let end = text.trim_end_matches('0').len();
|
||||
text.truncate(end);
|
||||
if text.ends_with('.') {
|
||||
text.try_reserve(1).map_err(|_| "DXF number allocation failed")?;
|
||||
text.push('0');
|
||||
}
|
||||
if text == "-0.0" {
|
||||
text.clear();
|
||||
text.push_str("0.0");
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Group codes and handles, in file order.
|
||||
struct Out {
|
||||
s: String,
|
||||
next: u64,
|
||||
limit: Option<usize>,
|
||||
failure: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Out {
|
||||
fn new(next: u64, limit: Option<usize>) -> Self {
|
||||
Self { s: String::new(), next, limit, failure: None }
|
||||
}
|
||||
fn check(&self) -> Result<()> {
|
||||
match self.failure {
|
||||
Some(message) => Err(IoError::Invalid(message.into())),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
fn code(&mut self, c: i32, v: impl std::fmt::Display) {
|
||||
let _ = write!(self.s, "{c:>3}\n{v}\n");
|
||||
if self.failure.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(limit) = self.limit {
|
||||
let mut sink = BoundedDxfString { text: &mut self.s, limit, failure: None };
|
||||
if write!(sink, "{c:>3}\n{v}\n").is_err() {
|
||||
self.failure = Some(sink.failure.unwrap_or("DXF file formatting failed"));
|
||||
}
|
||||
} else {
|
||||
let _ = write!(self.s, "{c:>3}\n{v}\n");
|
||||
}
|
||||
}
|
||||
/// A real number: up to twelve decimals, at least one.
|
||||
fn num(&mut self, c: i32, v: f64) {
|
||||
let text = format!("{:.12}", if v.is_finite() { v } else { 0.0 });
|
||||
let text = text.trim_end_matches('0');
|
||||
let text = if text.ends_with('.') { format!("{text}0") } else { text.to_string() };
|
||||
self.code(c, if text == "-0.0" { "0.0" } else { text.as_str() });
|
||||
if self.failure.is_some() {
|
||||
return;
|
||||
}
|
||||
if self.limit.is_some() && !v.is_finite() {
|
||||
self.failure = Some("DXF file contains a non-finite output number");
|
||||
return;
|
||||
}
|
||||
match number_value(if v.is_finite() { v } else { 0.0 }) {
|
||||
Ok(text) => self.code(c, text),
|
||||
Err(error) => self.failure = Some(error),
|
||||
}
|
||||
}
|
||||
/// A point in the plane at height 0: codes `c`, `c + 10`, `c + 20`.
|
||||
fn point(&mut self, c: i32, p: Vec2) {
|
||||
@@ -269,7 +349,16 @@ impl Out {
|
||||
}
|
||||
fn handle(&mut self) -> String {
|
||||
let h = format!("{:X}", self.next);
|
||||
self.next += 1;
|
||||
if self.limit.is_some() {
|
||||
match self.next.checked_add(1) {
|
||||
Some(next) => self.next = next,
|
||||
None => {
|
||||
self.failure.get_or_insert("DXF file handle space exhausted");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.next += 1;
|
||||
}
|
||||
h
|
||||
}
|
||||
/// The start of a symbol table; returns its handle.
|
||||
@@ -342,18 +431,6 @@ fn text_value(s: &str) -> Result<String> {
|
||||
/// (layer `0` is always there). A TEXT character beyond U+FFFF returns an error naming the
|
||||
/// character, its position, the entity and its layer; no drawing is returned on failure.
|
||||
pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<String> {
|
||||
let mut pts = Vec::new();
|
||||
for e in entities {
|
||||
e.shape.sample(&mut pts);
|
||||
}
|
||||
pts.retain(|p| p.is_finite());
|
||||
let (lo, hi) = pts
|
||||
.iter()
|
||||
.fold(None, |acc: Option<(Vec2, Vec2)>, p| {
|
||||
Some(acc.map_or((*p, *p), |(lo, hi)| (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))))
|
||||
})
|
||||
.unwrap_or((Vec2::ZERO, Vec2::ZERO));
|
||||
|
||||
let mut layer_list = vec![DxfLayer::new("0", 7)];
|
||||
for l in layers {
|
||||
if !layer_list.iter().any(|x| x.name == l.name) {
|
||||
@@ -361,8 +438,279 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
let layers = layer_list.iter().map(|layer| LayerView { name: &layer.name, color: layer.color, linetype: layer.linetype, weight: None });
|
||||
let entities = entities.iter().map(|entity| Ok(EntityView { layer: &entity.layer, shape: &entity.shape, weight: None }));
|
||||
write_drawing_views(layers, entities, None, None)
|
||||
}
|
||||
|
||||
// These limits bound work in the owned-file entry point, not the legacy writer.
|
||||
// A unit is one sampled extent point or one additional SPLINE knot/weight.
|
||||
const MAX_DXF_FILE_VALUES: usize = 1_000_000;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct LayerView<'a> {
|
||||
name: &'a str,
|
||||
color: i16,
|
||||
linetype: DxfLinetype,
|
||||
weight: Option<DxfStandardWeight>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct EntityView<'a> {
|
||||
layer: &'a str,
|
||||
shape: &'a DxfShape,
|
||||
weight: Option<DxfEntityWeight>,
|
||||
}
|
||||
|
||||
/// Write the currently supported owned-file subset as AutoCAD 2000 (`AC1015`).
|
||||
/// Layers, entities and their stored optional lineweights retain declaration order and
|
||||
/// presence: omitted, ByLayer, ByBlock, Default and an explicit physical weight stay distinct.
|
||||
/// This uses the same geometry, handles, tables and numeric/text spelling as
|
||||
/// [`write_dxf_drawing`]; it does not implement X3 import or X4 alignment/transform policy.
|
||||
///
|
||||
/// Unlike that legacy entry point, this refuses invalid or non-finite geometry rather than
|
||||
/// replacing numbers with zero. It accepts positive-radius circles/arcs, nondegenerate
|
||||
/// ellipses, polylines of at least two points and positive-weight clamped splines of supported
|
||||
/// degree. TEXT is limited to 250 BMP characters and is refused rather than truncated.
|
||||
/// Geometry whose required positive values or distinct points/knot spans collapse under
|
||||
/// the existing twelve-decimal spelling is refused; legacy numeric spelling is unchanged.
|
||||
/// Extents retain the existing sampled/control-polygon approximation, not analytic bounds.
|
||||
///
|
||||
/// Work is bounded to 1,024 layers, 1,000,000 entities and 1,000,000 cumulative sampled
|
||||
/// extent points plus spline knots/weights. Output, including its header, is limited to the
|
||||
/// existing 64 MiB drawing-byte budget. Allocation/budget failure returns no partial file.
|
||||
pub fn write_dxf_file(file: &DxfFile<DxfShape>) -> Result<String> {
|
||||
write_dxf_file_limited(file, crate::MAX_DRAWING_BYTES, MAX_DXF_FILE_VALUES)
|
||||
}
|
||||
|
||||
// Private tightening seam lets the resource boundary be checked with tiny original cases.
|
||||
fn write_dxf_file_limited(file: &DxfFile<DxfShape>, bytes: usize, values: usize) -> Result<String> {
|
||||
let bytes = bytes.min(crate::MAX_DRAWING_BYTES);
|
||||
let values = values.min(MAX_DXF_FILE_VALUES);
|
||||
if file.layers().len() > 1_024 || file.entities().len() > 1_000_000 {
|
||||
return Err(IoError::Invalid("DXF file metadata count exceeds the supported limit".into()));
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut samples = Vec::new();
|
||||
let mut extents = None;
|
||||
for (index, entity) in file.entities().iter().enumerate() {
|
||||
// Resolve the file-scoped handle even though normal model insertion already checks it.
|
||||
let layer = file.layer(entity.layer())?;
|
||||
let shape = entity.geometry();
|
||||
// Charge lengths before scanning any input-derived vector or sampling a curve.
|
||||
let count = shape_work(shape)?;
|
||||
total = total
|
||||
.checked_add(count)
|
||||
.filter(|n| *n <= values)
|
||||
.ok_or_else(|| IoError::Invalid("DXF file geometry payload exceeds the supported limit".into()))?;
|
||||
shape_preflight(shape)
|
||||
.and_then(|()| shape_emission_preflight(shape))
|
||||
.map_err(|error| IoError::Invalid(format!("DXF entity {} on layer {:?}: {error}", index + 1, layer.name())))?;
|
||||
samples.clear();
|
||||
samples.try_reserve(count).map_err(|_| IoError::Invalid("DXF extent allocation failed".into()))?;
|
||||
shape.sample(&mut samples);
|
||||
for point in &samples {
|
||||
if !point.is_finite() {
|
||||
return Err(IoError::Invalid(format!("DXF entity {} has non-finite derived extents", index + 1)));
|
||||
}
|
||||
extents = Some(extend_extents(extents, *point));
|
||||
}
|
||||
}
|
||||
let layers =
|
||||
file.layers().iter().map(|layer| LayerView { name: layer.name(), color: layer.color(), linetype: layer.linetype(), weight: layer.weight() });
|
||||
let entities = file
|
||||
.entities()
|
||||
.iter()
|
||||
.map(|entity| Ok(EntityView { layer: file.layer(entity.layer())?.name(), shape: entity.geometry(), weight: entity.weight() }));
|
||||
write_drawing_views(layers, entities, Some(bytes), Some(extents.unwrap_or((Vec2::ZERO, Vec2::ZERO))))
|
||||
}
|
||||
|
||||
fn shape_preflight(shape: &DxfShape) -> Result<()> {
|
||||
let invalid = || IoError::Invalid("invalid or unsupported DXF geometry".into());
|
||||
let finite = match shape {
|
||||
DxfShape::Line(a, b) => a.is_finite() && b.is_finite(),
|
||||
DxfShape::Polyline { points } => points.len() >= 2 && points.len() <= MAX_DXF_FILE_VALUES && points.iter().all(|p| p.is_finite()),
|
||||
DxfShape::Circle { c, r } => c.is_finite() && r.is_finite() && *r > 0.0,
|
||||
DxfShape::Arc { c, r, a0, a1 } => {
|
||||
c.is_finite()
|
||||
&& r.is_finite()
|
||||
&& *r > 0.0
|
||||
&& a0.is_finite()
|
||||
&& a1.is_finite()
|
||||
&& (a1 - a0).is_finite()
|
||||
&& a0.to_radians().is_finite()
|
||||
&& a1.to_radians().is_finite()
|
||||
}
|
||||
DxfShape::Ellipse { c, major, ratio, t0, t1 } => {
|
||||
c.is_finite()
|
||||
&& major.is_finite()
|
||||
&& major.len().is_finite()
|
||||
&& major.len() > 0.0
|
||||
&& ratio.is_finite()
|
||||
&& *ratio > 0.0
|
||||
&& *ratio <= 1.0
|
||||
&& t0.is_finite()
|
||||
&& t1.is_finite()
|
||||
&& (t1 - t0).is_finite()
|
||||
}
|
||||
DxfShape::Spline { degree, knots, ctrl, weights } => {
|
||||
let degree = usize::from(*degree);
|
||||
if degree == 0
|
||||
|| degree > MAX_BEZIER_DEGREE
|
||||
|| ctrl.len() <= degree
|
||||
|| ctrl.len() > MAX_DXF_FILE_VALUES
|
||||
|| ctrl.len().checked_add(degree + 1) != Some(knots.len())
|
||||
|| knots.len() > MAX_DXF_FILE_VALUES
|
||||
|| weights.as_ref().is_some_and(|w| w.len() != ctrl.len())
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let first = knots.first().copied().ok_or_else(invalid)?;
|
||||
let last = knots.last().copied().ok_or_else(invalid)?;
|
||||
ctrl.iter().all(|p| p.is_finite())
|
||||
&& knots.iter().all(|k| k.is_finite())
|
||||
&& knots.windows(2).all(|pair| matches!(pair, [a, b] if a <= b))
|
||||
&& first < last
|
||||
&& knots.iter().take(degree + 1).all(|k| *k == first)
|
||||
&& knots.iter().rev().take(degree + 1).all(|k| *k == last)
|
||||
&& weights.as_ref().is_none_or(|w| w.iter().all(|v| v.is_finite() && *v > 0.0))
|
||||
}
|
||||
DxfShape::Text { at, height, angle_deg, text } => {
|
||||
if text.len() > 1_000 || text.chars().count() > 250 || text.chars().any(|c| u32::from(c) > 0xffff) {
|
||||
return Err(IoError::Invalid("DXF owned TEXT supports at most 250 BMP characters; no text was truncated".into()));
|
||||
}
|
||||
at.is_finite() && height.is_finite() && *height > 0.0 && angle_deg.is_finite() && angle_deg.to_radians().is_finite()
|
||||
}
|
||||
};
|
||||
if !finite {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shape_work(shape: &DxfShape) -> Result<usize> {
|
||||
match shape {
|
||||
DxfShape::Line(..) | DxfShape::Circle { .. } => Ok(2),
|
||||
DxfShape::Polyline { points } => Ok(points.len()),
|
||||
DxfShape::Arc { .. } => Ok(65),
|
||||
DxfShape::Ellipse { .. } => Ok(129),
|
||||
DxfShape::Text { .. } => Ok(4),
|
||||
DxfShape::Spline { knots, ctrl, weights, .. } => ctrl
|
||||
.len()
|
||||
.checked_add(knots.len())
|
||||
.and_then(|n| n.checked_add(weights.as_ref().map_or(0, Vec::len)))
|
||||
.ok_or_else(|| IoError::Invalid("DXF spline payload size overflow".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn emitted_number(value: f64) -> Result<f64> {
|
||||
let text = number_value(value).map_err(|message| IoError::Invalid(message.into()))?;
|
||||
text.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|value| value.is_finite())
|
||||
.ok_or_else(|| IoError::Invalid("DXF number cannot be represented by the existing spelling".into()))
|
||||
}
|
||||
|
||||
fn emitted_point(point: Vec2) -> Result<Vec2> {
|
||||
Ok(Vec2::new(emitted_number(point.x)?, emitted_number(point.y)?))
|
||||
}
|
||||
|
||||
// This is admission to the existing writer, not a new number-format policy.
|
||||
fn shape_emission_preflight(shape: &DxfShape) -> Result<()> {
|
||||
let collapse = || IoError::Invalid("DXF geometry loses a required positive value or distinct point/knot span at twelve decimals".into());
|
||||
let positive = |value| -> Result<()> { if emitted_number(value)? > 0.0 { Ok(()) } else { Err(collapse()) } };
|
||||
match shape {
|
||||
DxfShape::Line(a, b) => {
|
||||
if emitted_point(*a)? == emitted_point(*b)? {
|
||||
return Err(collapse());
|
||||
}
|
||||
}
|
||||
DxfShape::Polyline { points } => {
|
||||
let mut previous = None;
|
||||
let mut distinct = false;
|
||||
for point in points {
|
||||
let emitted = emitted_point(*point)?;
|
||||
if let Some((old, last)) = previous {
|
||||
if old != *point && last == emitted {
|
||||
return Err(collapse());
|
||||
}
|
||||
distinct |= last != emitted;
|
||||
}
|
||||
previous = Some((*point, emitted));
|
||||
}
|
||||
if !distinct {
|
||||
return Err(collapse());
|
||||
}
|
||||
}
|
||||
DxfShape::Circle { r, .. } => positive(*r)?,
|
||||
DxfShape::Arc { r, a0, a1, .. } => {
|
||||
positive(*r)?;
|
||||
let span = emitted_number(*a1)? - emitted_number(*a0)?;
|
||||
if !span.is_finite() || span.rem_euclid(360.0) == 0.0 {
|
||||
return Err(collapse());
|
||||
}
|
||||
}
|
||||
DxfShape::Ellipse { major, ratio, t0, t1, .. } => {
|
||||
positive(*ratio)?;
|
||||
if emitted_point(*major)? == Vec2::ZERO || emitted_number(*t0)? == emitted_number(*t1)? {
|
||||
return Err(collapse());
|
||||
}
|
||||
}
|
||||
DxfShape::Spline { knots, ctrl, weights, .. } => {
|
||||
let mut previous = None;
|
||||
for knot in knots {
|
||||
let emitted = emitted_number(*knot)?;
|
||||
if let Some((old, last)) = previous
|
||||
&& old < *knot
|
||||
&& last >= emitted
|
||||
{
|
||||
return Err(collapse());
|
||||
}
|
||||
previous = Some((*knot, emitted));
|
||||
}
|
||||
let mut first = None;
|
||||
let mut distinct = false;
|
||||
for point in ctrl {
|
||||
let emitted = emitted_point(*point)?;
|
||||
distinct |= first.is_some_and(|first| first != emitted);
|
||||
first.get_or_insert(emitted);
|
||||
}
|
||||
if !distinct {
|
||||
return Err(collapse());
|
||||
}
|
||||
for weight in weights.iter().flatten() {
|
||||
positive(*weight)?;
|
||||
}
|
||||
}
|
||||
DxfShape::Text { height, .. } => positive(*height)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extend_extents(extents: Option<(Vec2, Vec2)>, point: Vec2) -> (Vec2, Vec2) {
|
||||
extents.map_or((point, point), |(lo, hi)| (Vec2::new(lo.x.min(point.x), lo.y.min(point.y)), Vec2::new(hi.x.max(point.x), hi.y.max(point.y))))
|
||||
}
|
||||
|
||||
fn write_drawing_views<'a, L, E>(layers: L, entities: E, limit: Option<usize>, extents: Option<(Vec2, Vec2)>) -> Result<String>
|
||||
where
|
||||
L: Iterator<Item = LayerView<'a>> + Clone + ExactSizeIterator,
|
||||
E: Iterator<Item = Result<EntityView<'a>>> + Clone,
|
||||
{
|
||||
let (lo, hi) = match extents {
|
||||
Some(extents) => extents,
|
||||
None => {
|
||||
// Legacy path retains its existing permissive sampled extents.
|
||||
let mut pts = Vec::new();
|
||||
for e in entities.clone() {
|
||||
e?.shape.sample(&mut pts);
|
||||
}
|
||||
pts.retain(|p| p.is_finite());
|
||||
pts.iter().fold(None, |acc, point| Some(extend_extents(acc, *point))).unwrap_or((Vec2::ZERO, Vec2::ZERO))
|
||||
}
|
||||
};
|
||||
|
||||
// Everything but the header, which needs the handle seed.
|
||||
let mut o = Out { s: String::new(), next: 0x20 };
|
||||
let mut o = Out::new(0x20, limit);
|
||||
let (model, paper) = (o.handle(), o.handle());
|
||||
let root = o.handle();
|
||||
|
||||
@@ -376,7 +724,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
("Continuous", "Solid line", &[]),
|
||||
("DASHED", "Dashed __ __ __ __ __ __ __ __ __ __", &[12.7, -6.35]),
|
||||
];
|
||||
if layer_list.iter().any(|l| l.linetype == DxfLinetype::Hidden) {
|
||||
if layers.clone().any(|l| l.linetype == DxfLinetype::Hidden) {
|
||||
linetypes.push(("HIDDEN", "Hidden edges", &[2.0, -1.0]));
|
||||
}
|
||||
let t = o.table("LTYPE", linetypes.len());
|
||||
@@ -394,13 +742,16 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
}
|
||||
}
|
||||
o.code(0, "ENDTAB");
|
||||
let t = o.table("LAYER", layer_list.len());
|
||||
for l in &layer_list {
|
||||
let t = o.table("LAYER", layers.len());
|
||||
for l in layers {
|
||||
o.record("LAYER", "AcDbLayerTableRecord", &t);
|
||||
o.code(2, &l.name);
|
||||
o.code(2, l.name);
|
||||
o.code(70, 0);
|
||||
o.code(62, l.color);
|
||||
o.code(6, l.linetype.name());
|
||||
if let Some(weight) = l.weight {
|
||||
o.code(370, weight.code());
|
||||
}
|
||||
}
|
||||
o.code(0, "ENDTAB");
|
||||
// The one text style, which a TEXT entity uses when it names none.
|
||||
@@ -436,8 +787,9 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
|
||||
o.code(0, "SECTION");
|
||||
o.code(2, "ENTITIES");
|
||||
for (index, e) in entities.iter().enumerate() {
|
||||
let kind = match &e.shape {
|
||||
for (index, e) in entities.enumerate() {
|
||||
let e = e?;
|
||||
let kind = match e.shape {
|
||||
DxfShape::Line(..) => "LINE",
|
||||
DxfShape::Polyline { .. } => "LWPOLYLINE",
|
||||
DxfShape::Circle { .. } => "CIRCLE",
|
||||
@@ -451,8 +803,11 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
o.code(5, h);
|
||||
o.code(330, &model);
|
||||
o.code(100, "AcDbEntity");
|
||||
o.code(8, if e.layer.trim().is_empty() { "0" } else { e.layer.as_str() });
|
||||
match &e.shape {
|
||||
o.code(8, if e.layer.trim().is_empty() { "0" } else { e.layer });
|
||||
if let Some(weight) = e.weight {
|
||||
o.code(370, weight.group370());
|
||||
}
|
||||
match e.shape {
|
||||
DxfShape::Polyline { points } => {
|
||||
o.code(100, "AcDbPolyline");
|
||||
o.code(90, points.len());
|
||||
@@ -530,6 +885,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
o.code(73, 0);
|
||||
}
|
||||
}
|
||||
o.check()?;
|
||||
}
|
||||
o.code(0, "ENDSEC");
|
||||
|
||||
@@ -543,7 +899,8 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
o.code(0, "ENDSEC");
|
||||
o.code(0, "EOF");
|
||||
|
||||
let mut head = Out { s: String::new(), next: 0 };
|
||||
o.check()?;
|
||||
let mut head = Out::new(0, limit);
|
||||
head.code(0, "SECTION");
|
||||
head.code(2, "HEADER");
|
||||
head.code(9, "$ACADVER");
|
||||
@@ -559,7 +916,17 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
|
||||
head.code(9, "$EXTMAX");
|
||||
head.point(10, hi);
|
||||
head.code(0, "ENDSEC");
|
||||
Ok(head.s + &o.s)
|
||||
head.check()?;
|
||||
if let Some(limit) = limit {
|
||||
if head.s.len().checked_add(o.s.len()).is_none_or(|n| n > limit) {
|
||||
return Err(IoError::Invalid("DXF file output exceeds the drawing-byte limit".into()));
|
||||
}
|
||||
head.s.try_reserve_exact(o.s.len()).map_err(|_| IoError::Invalid("DXF file output allocation failed".into()))?;
|
||||
head.s.push_str(&o.s);
|
||||
Ok(head.s)
|
||||
} else {
|
||||
Ok(head.s + &o.s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -979,4 +1346,283 @@ mod tests {
|
||||
assert_eq!(&after("$EXTMIN")[..2], &[(10, "27.0".to_string()), (20, "6.0".to_string())]);
|
||||
assert_eq!(&after("$EXTMAX")[..2], &[(10, "30.0".to_string()), (20, "18.0".to_string())]);
|
||||
}
|
||||
#[test]
|
||||
fn owned_file_and_legacy_drawing_have_identical_bytes_without_weights() {
|
||||
assert_eq!(write_dxf_file(&DxfFile::new().unwrap()).unwrap(), write_dxf_drawing(&[], &[]).unwrap());
|
||||
let shapes = vec![
|
||||
DxfShape::Line(Vec2::new(-0.0, -1.25), Vec2::new(1e-15, 8.0)),
|
||||
DxfShape::Polyline { points: vec![Vec2::ZERO, Vec2::X, Vec2::new(4.0, -2.0)] },
|
||||
DxfShape::Circle { c: Vec2::new(3.0, 2.0), r: 2.0 },
|
||||
DxfShape::Arc { c: Vec2::ZERO, r: 3.0, a0: 350.0, a1: 20.0 },
|
||||
DxfShape::Ellipse { c: Vec2::new(-2.0, 3.0), major: Vec2::new(4.0, 2.0), ratio: 0.5, t0: 0.0, t1: TAU },
|
||||
bezier_spline(&[Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::X], None),
|
||||
bezier_spline(&[Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::X], Some(vec![1.0, 0.5, 1.0])),
|
||||
DxfShape::Text { at: Vec2::new(5.0, 6.0), height: 2.0, angle_deg: 90.0, text: "中 ° %%d\nA".into() },
|
||||
];
|
||||
for linetype in [DxfLinetype::Continuous, DxfLinetype::Dashed, DxfLinetype::Hidden] {
|
||||
let layers = [DxfLayer { name: "CUT".into(), color: 3, linetype }];
|
||||
let legacy: Vec<_> = shapes.iter().cloned().map(|shape| DxfEntity { layer: "CUT".into(), shape }).collect();
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let cut = file.declare_layer("CUT", 3, linetype, None).unwrap();
|
||||
for shape in &shapes {
|
||||
file.push(cut, shape.clone(), None).unwrap();
|
||||
}
|
||||
assert_eq!(write_dxf_file(&file).unwrap(), write_dxf_drawing(&layers, &legacy).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_layer_table_preserves_order_weight_zero_and_omission() {
|
||||
let mut file = DxfFile::<DxfShape>::new().unwrap();
|
||||
for (name, code, linetype) in
|
||||
[("CUT", Some(50), DxfLinetype::Dashed), ("ZERO", Some(0), DxfLinetype::Hidden), ("NOTES", None, DxfLinetype::Continuous)]
|
||||
{
|
||||
file.declare_layer(name, 3, linetype, code.map(|c| DxfStandardWeight::from_code(c).unwrap())).unwrap();
|
||||
}
|
||||
let output = write_dxf_file(&file).unwrap();
|
||||
let p = pairs(&output);
|
||||
let records = table_records(&p, "LAYER");
|
||||
assert_eq!(records.iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["0", "CUT", "ZERO", "NOTES"]);
|
||||
let weights: Vec<Vec<_>> = records.iter().map(|r| r.iter().filter(|(c, _)| *c == 370).map(|(_, v)| v.as_str()).collect()).collect();
|
||||
assert_eq!(weights, [vec![], vec!["50"], vec!["0"], vec![]]);
|
||||
assert_eq!(records.iter().map(|r| field(r, 6)).collect::<Vec<_>>(), ["Continuous", "DASHED", "HIDDEN", "Continuous"]);
|
||||
assert!(table_records(&p, "LTYPE").iter().any(|r| field(r, 2) == "HIDDEN"));
|
||||
let layer_table = p.windows(2).position(|w| w[0] == (0, "TABLE".into()) && w[1] == (2, "LAYER".into())).unwrap();
|
||||
assert!(p[layer_table..].iter().take_while(|(c, v)| (*c, v.as_str()) != (0, "ENDTAB")).any(|pair| pair == &(70, "4".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_entities_keep_every_stored_weight_distinct_from_inheritance() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let cut = file.declare_layer("CUT", 1, DxfLinetype::Continuous, Some(DxfStandardWeight::from_code(50).unwrap())).unwrap();
|
||||
for weight in [
|
||||
None,
|
||||
Some(DxfEntityWeight::ByLayer),
|
||||
Some(DxfEntityWeight::ByBlock),
|
||||
Some(DxfEntityWeight::Default),
|
||||
Some(DxfEntityWeight::Explicit(DxfStandardWeight::from_code(0).unwrap())),
|
||||
Some(DxfEntityWeight::Explicit(DxfStandardWeight::from_code(211).unwrap())),
|
||||
] {
|
||||
file.push(cut, DxfShape::Line(Vec2::ZERO, Vec2::X), weight).unwrap();
|
||||
}
|
||||
let output = write_dxf_file(&file).unwrap();
|
||||
let records = entities(&output);
|
||||
let weights: Vec<Vec<_>> = records.iter().map(|r| r.1.iter().filter(|(c, _)| *c == 370).map(|(_, v)| v.as_str()).collect()).collect();
|
||||
assert_eq!(weights, [vec![], vec!["-1"], vec!["-2"], vec!["-3"], vec!["0"], vec!["211"]]);
|
||||
assert!(records.iter().all(|r| field(&r.1, 8) == "CUT"));
|
||||
assert_eq!(file.entities()[0].weight(), None);
|
||||
assert_eq!(file.entities()[1].weight(), Some(DxfEntityWeight::ByLayer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rational_spline_weights_and_entity_lineweight_are_independent() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(
|
||||
file.default_layer(),
|
||||
bezier_spline(&[Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::X], Some(vec![1.0, 0.5, 1.0])),
|
||||
Some(DxfEntityWeight::ByBlock),
|
||||
)
|
||||
.unwrap();
|
||||
let output = write_dxf_file(&file).unwrap();
|
||||
let records = entities(&output);
|
||||
assert_eq!(records[0].0, "SPLINE");
|
||||
assert_eq!(values(&records[0], 41), [1.0, 0.5, 1.0]);
|
||||
assert_eq!(values(&records[0], 370), [-2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_geometry_refuses_nonfinite_fields_instead_of_zeroing_them() {
|
||||
for shape in [
|
||||
DxfShape::Line(Vec2::new(f64::NAN, 0.0), Vec2::X),
|
||||
DxfShape::Polyline { points: vec![Vec2::ZERO, Vec2::new(0.0, f64::INFINITY)] },
|
||||
DxfShape::Circle { c: Vec2::ZERO, r: f64::INFINITY },
|
||||
DxfShape::Arc { c: Vec2::ZERO, r: 1.0, a0: 0.0, a1: f64::NAN },
|
||||
DxfShape::Ellipse { c: Vec2::ZERO, major: Vec2::X, ratio: f64::NAN, t0: 0.0, t1: TAU },
|
||||
DxfShape::Spline { degree: 1, knots: vec![0.0, 0.0, f64::INFINITY, f64::INFINITY], ctrl: vec![Vec2::ZERO, Vec2::X], weights: None },
|
||||
bezier_spline(&[Vec2::ZERO, Vec2::X], Some(vec![1.0, f64::NAN])),
|
||||
DxfShape::Text { at: Vec2::ZERO, height: 1.0, angle_deg: f64::INFINITY, text: "A".into() },
|
||||
] {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), shape, None).unwrap();
|
||||
let error = write_dxf_file(&file).unwrap_err().to_string();
|
||||
assert!(error.contains("DXF entity 1 on layer \"0\""), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_geometry_checks_structural_requirements_and_derived_extents() {
|
||||
for shape in [
|
||||
DxfShape::Polyline { points: vec![Vec2::ZERO] },
|
||||
DxfShape::Circle { c: Vec2::ZERO, r: -1.0 },
|
||||
DxfShape::Arc { c: Vec2::ZERO, r: 0.0, a0: 0.0, a1: 10.0 },
|
||||
DxfShape::Ellipse { c: Vec2::ZERO, major: Vec2::ZERO, ratio: 0.5, t0: 0.0, t1: TAU },
|
||||
DxfShape::Ellipse { c: Vec2::ZERO, major: Vec2::X, ratio: 2.0, t0: 0.0, t1: TAU },
|
||||
DxfShape::Spline { degree: 1, knots: vec![0.0, 0.0, 1.0], ctrl: vec![Vec2::ZERO, Vec2::X], weights: None },
|
||||
DxfShape::Spline { degree: 1, knots: vec![0.0, 1.0, 0.0, 1.0], ctrl: vec![Vec2::ZERO, Vec2::X], weights: None },
|
||||
bezier_spline(&[Vec2::ZERO, Vec2::X], Some(vec![1.0])),
|
||||
bezier_spline(&[Vec2::ZERO, Vec2::X], Some(vec![1.0, 0.0])),
|
||||
DxfShape::Text { at: Vec2::ZERO, height: 0.0, angle_deg: 0.0, text: "A".into() },
|
||||
DxfShape::Circle { c: Vec2::new(f64::MAX, 0.0), r: f64::MAX },
|
||||
DxfShape::Text { at: Vec2::ZERO, height: f64::MAX, angle_deg: 0.0, text: "AAAA".into() },
|
||||
] {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), shape, None).unwrap();
|
||||
assert!(write_dxf_file(&file).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_text_accepts_250_bmp_characters_and_refuses_truncation() {
|
||||
for text in ["中".repeat(250), "A\tB\n%%d °".into()] {
|
||||
let shape = DxfShape::Text { at: Vec2::ZERO, height: 1.0, angle_deg: 0.0, text };
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), shape.clone(), None).unwrap();
|
||||
assert_eq!(write_dxf_file(&file).unwrap(), write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape }]).unwrap());
|
||||
}
|
||||
for text in ["A".repeat(251), "中".repeat(251), "😀".into(), format!("{}😀", "A".repeat(250))] {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Text { at: Vec2::ZERO, height: 1.0, angle_deg: 0.0, text }, None).unwrap();
|
||||
assert!(write_dxf_file(&file).unwrap_err().to_string().contains("250 BMP characters"));
|
||||
}
|
||||
let shape = DxfShape::Text { at: Vec2::ZERO, height: 1.0, angle_deg: 0.0, text: "A".repeat(251) };
|
||||
let legacy = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape }]).unwrap();
|
||||
assert_eq!(field(&entities(&legacy)[0].1, 1), "A".repeat(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_work_budget_charges_curve_samples_and_spline_payload() {
|
||||
for (shape, work) in [
|
||||
(DxfShape::Arc { c: Vec2::ZERO, r: 1.0, a0: 0.0, a1: 90.0 }, 65),
|
||||
(DxfShape::Ellipse { c: Vec2::ZERO, major: Vec2::X, ratio: 0.5, t0: 0.0, t1: TAU }, 129),
|
||||
(bezier_spline(&[Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::X], Some(vec![1.0, 0.5, 1.0])), 12),
|
||||
] {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), shape, None).unwrap();
|
||||
assert!(write_dxf_file_limited(&file, crate::MAX_DRAWING_BYTES, work).is_ok());
|
||||
assert!(write_dxf_file_limited(&file, crate::MAX_DRAWING_BYTES, work - 1).unwrap_err().to_string().contains("geometry payload"));
|
||||
}
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Arc { c: Vec2::ZERO, r: 1.0, a0: 0.0, a1: 90.0 }, None).unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Ellipse { c: Vec2::ZERO, major: Vec2::X, ratio: 0.5, t0: 0.0, t1: TAU }, None).unwrap();
|
||||
assert!(write_dxf_file_limited(&file, crate::MAX_DRAWING_BYTES, 194).is_ok());
|
||||
assert!(write_dxf_file_limited(&file, crate::MAX_DRAWING_BYTES, 193).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_work_budget_is_admitted_before_large_geometry_is_scanned() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Polyline { points: vec![Vec2::new(f64::NAN, 0.0); 3] }, None).unwrap();
|
||||
let error = write_dxf_file_limited(&file, crate::MAX_DRAWING_BYTES, 2).unwrap_err().to_string();
|
||||
assert!(error.contains("geometry payload"), "{error}");
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
for _ in 0..2 {
|
||||
file.push(file.default_layer(), DxfShape::Line(Vec2::ZERO, Vec2::X), None).unwrap();
|
||||
}
|
||||
assert!(write_dxf_file_limited(&file, crate::MAX_DRAWING_BYTES, 4).is_ok());
|
||||
assert!(write_dxf_file_limited(&file, crate::MAX_DRAWING_BYTES, 3).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_byte_budget_includes_header_and_body_at_the_exact_boundary() {
|
||||
let file = DxfFile::<DxfShape>::new().unwrap();
|
||||
let full = write_dxf_file(&file).unwrap();
|
||||
assert_eq!(write_dxf_file_limited(&file, full.len(), 0).unwrap(), full);
|
||||
assert!(write_dxf_file_limited(&file, full.len() - 1, 0).unwrap_err().to_string().contains("drawing-byte limit"));
|
||||
assert!(write_dxf_file_limited(&file, 0, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_out_keeps_first_error_and_never_repairs_nonfinite_values() {
|
||||
let mut out = Out::new(0x20, Some(0));
|
||||
out.code(0, "SECTION");
|
||||
let first = out.check().unwrap_err().to_string();
|
||||
out.num(40, f64::NAN);
|
||||
out.code(0, "EOF");
|
||||
assert_eq!(out.check().unwrap_err().to_string(), first);
|
||||
assert!(out.s.is_empty());
|
||||
let mut out = Out::new(0x20, Some(100));
|
||||
out.num(40, f64::NAN);
|
||||
assert!(out.check().unwrap_err().to_string().contains("non-finite output number"));
|
||||
assert!(out.s.is_empty());
|
||||
let mut out = Out::new(u64::MAX, Some(100));
|
||||
out.handle();
|
||||
assert!(out.check().unwrap_err().to_string().contains("handle space"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_and_legacy_numbers_have_identical_finite_spelling() {
|
||||
for value in [-0.0, -1e-15, 1e-15, -1.25, 2.0, 12345.6789012345, f64::MIN_POSITIVE, f64::MAX, -f64::MAX] {
|
||||
// Frozen legacy expression is a spelling compatibility check, not a geometry oracle.
|
||||
let expected = format!("{value:.12}");
|
||||
let expected = expected.trim_end_matches('0');
|
||||
let expected = if expected.ends_with('.') { format!("{expected}0") } else { expected.to_string() };
|
||||
let expected = if expected == "-0.0" { "0.0" } else { expected.as_str() };
|
||||
let mut old = Out::new(0, None);
|
||||
let mut bounded = Out::new(0, Some(1024));
|
||||
old.num(10, value);
|
||||
bounded.num(10, value);
|
||||
bounded.check().unwrap();
|
||||
assert_eq!(old.s, bounded.s);
|
||||
assert_eq!(bounded.s, format!(" 10\n{expected}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_geometry_refuses_literal_positive_values_rounded_to_zero() {
|
||||
assert_eq!(number_value(1e-20).unwrap(), "0.0");
|
||||
let mut representable = DxfFile::new().unwrap();
|
||||
representable.push(representable.default_layer(), DxfShape::Circle { c: Vec2::ZERO, r: 1e-12 }, None).unwrap();
|
||||
assert_eq!(number_value(1e-12).unwrap(), "0.000000000001");
|
||||
assert!(write_dxf_file(&representable).is_ok());
|
||||
for shape in [
|
||||
DxfShape::Circle { c: Vec2::ZERO, r: 1e-20 },
|
||||
DxfShape::Arc { c: Vec2::ZERO, r: 1e-20, a0: 0.0, a1: 90.0 },
|
||||
DxfShape::Ellipse { c: Vec2::ZERO, major: Vec2::X, ratio: 1e-20, t0: 0.0, t1: TAU },
|
||||
DxfShape::Text { at: Vec2::ZERO, height: 1e-20, angle_deg: 0.0, text: "A".into() },
|
||||
bezier_spline(&[Vec2::ZERO, Vec2::X], Some(vec![1.0, 1e-20])),
|
||||
] {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), shape.clone(), None).unwrap();
|
||||
assert!(write_dxf_file(&file).unwrap_err().to_string().contains("twelve decimals"));
|
||||
// This stricter admission is exclusive to the owned API.
|
||||
assert!(write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape }]).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_geometry_refuses_distinct_points_axes_and_knot_spans_that_collapse() {
|
||||
for shape in [
|
||||
DxfShape::Line(Vec2::ZERO, Vec2::new(1e-20, 0.0)),
|
||||
DxfShape::Polyline { points: vec![Vec2::ZERO, Vec2::new(1e-20, 0.0), Vec2::X] },
|
||||
DxfShape::Ellipse { c: Vec2::ZERO, major: Vec2::new(1e-20, 0.0), ratio: 0.5, t0: 0.0, t1: TAU },
|
||||
DxfShape::Arc { c: Vec2::ZERO, r: 1.0, a0: 0.0, a1: 1e-20 },
|
||||
DxfShape::Spline { degree: 1, ctrl: vec![Vec2::ZERO, Vec2::X], knots: vec![0.0, 0.0, 1e-20, 1e-20], weights: None },
|
||||
DxfShape::Spline {
|
||||
degree: 1,
|
||||
ctrl: vec![Vec2::ZERO, Vec2::X, Vec2::new(2.0, 0.0)],
|
||||
knots: vec![0.0, 0.0, 1e-20, 1.0, 1.0],
|
||||
weights: None,
|
||||
},
|
||||
bezier_spline(&[Vec2::ZERO, Vec2::new(1e-20, 0.0)], None),
|
||||
] {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), shape, None).unwrap();
|
||||
assert!(write_dxf_file(&file).unwrap_err().to_string().contains("twelve decimals"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_layer_deduplication_and_permissive_numbers_stay_unchanged() {
|
||||
let layers = [DxfLayer::new("0", 1), DxfLayer::new("中", 3), DxfLayer::new("中", 5), DxfLayer::new("cut", 1), DxfLayer::new("CUT", 2)];
|
||||
let output =
|
||||
write_dxf_drawing(&layers, &[DxfEntity { layer: " \t".into(), shape: DxfShape::Line(Vec2::new(f64::NAN, 0.0), Vec2::X) }]).unwrap();
|
||||
let p = pairs(&output);
|
||||
let layers = table_records(&p, "LAYER");
|
||||
assert_eq!(layers.iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["0", "中", "cut", "CUT"]);
|
||||
assert_eq!(layers.iter().map(|r| field(r, 62)).collect::<Vec<_>>(), ["7", "3", "1", "2"]);
|
||||
assert!(!p.iter().any(|(code, _)| *code == 370));
|
||||
let records = entities(&output);
|
||||
assert_eq!(field(&records[0].1, 8), "0");
|
||||
assert_eq!(values(&records[0], 10), [0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! Owned layers and entity metadata for partially built DXF files.
|
||||
//!
|
||||
//! Generic geometry is carried unchanged and is not validated or serialized by
|
||||
//! insertion. Concrete DxfShape methods provide conservative bounds and positive
|
||||
//! transforms in declared supported subsets; text bounds need a layout provider.
|
||||
//! Legacy drawing commands keep their existing APIs until their adapters are qualified.
|
||||
//!
|
||||
//! Layer declarations currently admit ASCII only. They trim ASCII whitespace,
|
||||
//! replace restricted characters, and refuse normalized/case collisions.
|
||||
//! Names admit at most 4096 raw bytes and 255 normalized characters; files hold
|
||||
//! at most 1024 layers and one million entities. These are resource limits,
|
||||
//! not a promise of complete DXF import support. Geometry owns its own budget.
|
||||
//! Standard physical lineweights use the 24 public DXF codes, with exact
|
||||
//! midpoint ties choosing the heavier weight. Absent and symbolic entity
|
||||
//! weights stay distinct. Layer zero retains its initial immutable style;
|
||||
//! importing other layer-zero styles and Unicode names remains unsupported.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use super::DxfLinetype;
|
||||
use crate::{IoError, Result};
|
||||
|
||||
mod geometry;
|
||||
pub use geometry::DxfGeometryBounds;
|
||||
|
||||
const STANDARD_WEIGHTS: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
const MAX_NAME_CHARS: usize = 255;
|
||||
// A resource bound on the RAW input, independent of the format-name length
|
||||
// after trimming. It does not change the normalized format-name bound.
|
||||
const MAX_RAW_NAME_BYTES: usize = 4096;
|
||||
const MAX_LAYERS: usize = 1024;
|
||||
const MAX_ENTITIES: usize = 1_000_000;
|
||||
static NEXT_OWNER: AtomicUsize = AtomicUsize::new(1);
|
||||
|
||||
fn invalid(message: &str) -> IoError {
|
||||
IoError::Invalid(message.into())
|
||||
}
|
||||
|
||||
/// A standard physical weight, in hundredths of a millimetre.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct StandardWeight(u16);
|
||||
|
||||
impl StandardWeight {
|
||||
pub fn from_code(code: u16) -> Result<Self> {
|
||||
if STANDARD_WEIGHTS.contains(&code) { Ok(Self(code)) } else { Err(invalid("not a standard DXF lineweight")) }
|
||||
}
|
||||
|
||||
/// Nearest rational standard weight; exact midpoint ties choose the heavier.
|
||||
/// Existing drawing writers are unchanged until their adapters use this model.
|
||||
pub fn from_mm(mm: f64) -> Result<Self> {
|
||||
if !mm.is_finite() || !(0.0..=2.11).contains(&mm) {
|
||||
return Err(invalid("DXF lineweight must be finite and between 0 and 2.11 mm"));
|
||||
}
|
||||
for pair in STANDARD_WEIGHTS.windows(2) {
|
||||
let [low, high] = pair else { return Err(invalid("invalid standard lineweight table")) };
|
||||
let midpoint_sum = f64::from(*low + *high);
|
||||
// Compare exact 200*mm to the integer midpoint_sum. When its
|
||||
// rounded product equals that integer, FMA preserves the sign of
|
||||
// the residual. At these nonzero integer midpoints the residual
|
||||
// is far above subnormal underflow. No tolerance or mm clamping.
|
||||
let scaled = mm * 200.0;
|
||||
if scaled < midpoint_sum || (scaled == midpoint_sum && mm.mul_add(200.0, -scaled) < 0.0) {
|
||||
return Ok(Self(*low));
|
||||
}
|
||||
}
|
||||
Ok(Self(211))
|
||||
}
|
||||
|
||||
pub fn code(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbols are not negative physical weights. None on Entity means the group
|
||||
/// was omitted; Some(ByLayer) is an explicitly authored -1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntityWeight {
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Default,
|
||||
Explicit(StandardWeight),
|
||||
}
|
||||
|
||||
impl EntityWeight {
|
||||
pub fn group370(self) -> i16 {
|
||||
match self {
|
||||
Self::ByLayer => -1,
|
||||
Self::ByBlock => -2,
|
||||
Self::Default => -3,
|
||||
Self::Explicit(value) => value.code() as i16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// These are in-memory ownership handles, NOT serialized DXF group-5 handles.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LayerId {
|
||||
owner: usize,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Layer {
|
||||
original: String,
|
||||
name: String,
|
||||
color: i16,
|
||||
linetype: DxfLinetype,
|
||||
/// None preserves legacy omission of group370; its semantic default is
|
||||
/// unresolved here, never silently replaced with a numeric weight.
|
||||
weight: Option<StandardWeight>,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
pub fn color(&self) -> i16 {
|
||||
self.color
|
||||
}
|
||||
pub fn linetype(&self) -> DxfLinetype {
|
||||
self.linetype
|
||||
}
|
||||
pub fn weight(&self) -> Option<StandardWeight> {
|
||||
self.weight
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Entity<G> {
|
||||
layer: LayerId,
|
||||
weight: Option<EntityWeight>,
|
||||
geometry: G,
|
||||
}
|
||||
|
||||
impl<G> Entity<G> {
|
||||
pub fn layer(&self) -> LayerId {
|
||||
self.layer
|
||||
}
|
||||
pub fn weight(&self) -> Option<EntityWeight> {
|
||||
self.weight
|
||||
}
|
||||
pub fn geometry(&self) -> &G {
|
||||
&self.geometry
|
||||
}
|
||||
}
|
||||
|
||||
/// No Clone: cloning must not accidentally duplicate ownership. A future copy
|
||||
/// operation must mint a new owner and remap its handles explicitly.
|
||||
#[derive(Debug)]
|
||||
pub struct DxfFile<G> {
|
||||
owner: usize,
|
||||
layers: Vec<Layer>,
|
||||
entities: Vec<Entity<G>>,
|
||||
max_layers: usize,
|
||||
max_entities: usize,
|
||||
}
|
||||
|
||||
fn owner_from(counter: &AtomicUsize) -> Result<usize> {
|
||||
counter.try_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)).map_err(|_| invalid("DXF file ownership identities exhausted"))
|
||||
}
|
||||
|
||||
fn owned(text: &str) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
out.try_reserve(text.len()).map_err(|_| invalid("DXF name allocation failed"))?;
|
||||
out.push_str(text);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ascii_name_space(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
|
||||
}
|
||||
|
||||
fn name_of(input: &str) -> Result<String> {
|
||||
if input.len() > MAX_RAW_NAME_BYTES {
|
||||
return Err(invalid("DXF raw layer-name input exceeds resource byte limit"));
|
||||
}
|
||||
let input = input.trim_matches(ascii_name_space);
|
||||
// Inspect at most one scalar beyond the bound before any allocation.
|
||||
// At most255 Unicode scalars imply at most1020 UTF-8 bytes. The length
|
||||
// rule alone does not establish Unicode symbol-equivalence semantics.
|
||||
if input.chars().take(MAX_NAME_CHARS + 1).count() > MAX_NAME_CHARS {
|
||||
return Err(invalid("DXF layer name exceeds 255 Unicode scalar values"));
|
||||
}
|
||||
if input.is_empty() {
|
||||
return owned("0");
|
||||
}
|
||||
let mut name = String::new();
|
||||
name.try_reserve(input.len()).map_err(|_| invalid("DXF name allocation failed"))?;
|
||||
for c in input.chars() {
|
||||
let forbidden = c.is_control() || matches!(c, '<' | '>' | '/' | '\\' | '"' | '\'' | ':' | ';' | '?' | '*' | '|' | '=');
|
||||
name.push(if forbidden { '_' } else { c });
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
impl<G> DxfFile<G> {
|
||||
pub fn new() -> Result<Self> {
|
||||
Self::with_limits(MAX_LAYERS, MAX_ENTITIES)
|
||||
}
|
||||
|
||||
fn with_limits(max_layers: usize, max_entities: usize) -> Result<Self> {
|
||||
if max_layers == 0 {
|
||||
return Err(invalid("DXF file requires layer 0"));
|
||||
}
|
||||
let owner = owner_from(&NEXT_OWNER)?;
|
||||
let mut layers = Vec::new();
|
||||
layers.try_reserve(1).map_err(|_| invalid("DXF layer allocation failed"))?;
|
||||
layers.push(Layer { original: owned("0")?, name: owned("0")?, color: 7, linetype: DxfLinetype::Continuous, weight: None });
|
||||
Ok(Self { owner, layers, entities: Vec::new(), max_layers: max_layers.min(MAX_LAYERS), max_entities: max_entities.min(MAX_ENTITIES) })
|
||||
}
|
||||
|
||||
pub fn default_layer(&self) -> LayerId {
|
||||
LayerId { owner: self.owner, index: 0 }
|
||||
}
|
||||
pub fn layers(&self) -> &[Layer] {
|
||||
&self.layers
|
||||
}
|
||||
pub fn entities(&self) -> &[Entity<G>] {
|
||||
&self.entities
|
||||
}
|
||||
|
||||
pub fn layer(&self, id: LayerId) -> Result<&Layer> {
|
||||
if id.owner != self.owner {
|
||||
return Err(invalid("layer belongs to a different DXF file"));
|
||||
}
|
||||
self.layers.get(id.index).ok_or_else(|| invalid("unknown DXF layer handle"))
|
||||
}
|
||||
|
||||
pub fn declare_layer(&mut self, original: &str, color: i16, linetype: DxfLinetype, weight: Option<StandardWeight>) -> Result<LayerId> {
|
||||
if original.len() > MAX_RAW_NAME_BYTES {
|
||||
return Err(invalid("DXF raw layer-name input exceeds resource byte limit"));
|
||||
}
|
||||
// Provisional ASCII-only container: do not claim general Unicode
|
||||
// collision safety from eq_ignore_ascii_case. The character-bound
|
||||
// helper is Unicode-aware, but a reader adapter needs an agreed
|
||||
// canonical symbol comparison before it may admit Unicode layers.
|
||||
if !original.is_ascii() {
|
||||
return Err(invalid("not supported yet: non-ASCII DXF layer names"));
|
||||
}
|
||||
let name = name_of(original)?;
|
||||
if !(1..=255).contains(&color) {
|
||||
return Err(invalid("DXF layer color must be an index from 1 to 255"));
|
||||
}
|
||||
if let Some((index, existing)) = self.layers.iter().enumerate().find(|(_, layer)| layer.name.eq_ignore_ascii_case(&name)) {
|
||||
// Blank and whitespace-surrounded0 explicitly alias built-in0.
|
||||
// Other distinct raw declarations that normalize to one symbol
|
||||
// refuse rather than adding a second or silently merging styles.
|
||||
let same_input = original == existing.original || name == "0";
|
||||
if same_input && existing.color == color && existing.linetype == linetype && existing.weight == weight {
|
||||
return Ok(LayerId { owner: self.owner, index });
|
||||
}
|
||||
return Err(invalid("DXF layer declaration collides with an existing symbol"));
|
||||
}
|
||||
if self.layers.len() >= self.max_layers {
|
||||
return Err(invalid("DXF layer count limit exceeded"));
|
||||
}
|
||||
let original = owned(original)?;
|
||||
self.layers.try_reserve(1).map_err(|_| invalid("DXF layer allocation failed"))?;
|
||||
let index = self.layers.len();
|
||||
self.layers.push(Layer { original, name, color, linetype, weight });
|
||||
Ok(LayerId { owner: self.owner, index })
|
||||
}
|
||||
|
||||
/// G is carried unchanged. A future adapter validates geometric values.
|
||||
pub fn push(&mut self, layer: LayerId, geometry: G, weight: Option<EntityWeight>) -> Result<usize> {
|
||||
self.layer(layer)?;
|
||||
if self.entities.len() >= self.max_entities {
|
||||
return Err(invalid("DXF entity count limit exceeded"));
|
||||
}
|
||||
self.entities.try_reserve(1).map_err(|_| invalid("DXF entity allocation failed"))?;
|
||||
let index = self.entities.len();
|
||||
self.entities.push(Entity { layer, weight, geometry });
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// ByBlock and Default deliberately remain symbolic, rather than inventing
|
||||
/// a parent block or the consuming application's default numeric thickness.
|
||||
pub fn effective_weight(&self, index: usize) -> Result<EntityWeight> {
|
||||
let entity = self.entities.get(index).ok_or_else(|| invalid("unknown DXF entity index"))?;
|
||||
match entity.weight {
|
||||
None | Some(EntityWeight::ByLayer) => Ok(self.layer(entity.layer)?.weight.map_or(EntityWeight::Default, EntityWeight::Explicit)),
|
||||
Some(weight) => Ok(weight),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_linetype(&self, index: usize) -> Result<DxfLinetype> {
|
||||
let entity = self.entities.get(index).ok_or_else(|| invalid("unknown DXF entity index"))?;
|
||||
Ok(self.layer(entity.layer)?.linetype)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layers_own_ordered_geometry_without_inventing_a_weight() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let cut = file.declare_layer("CUT", 1, DxfLinetype::Continuous, None).unwrap();
|
||||
let hidden = file.declare_layer("HIDDEN", 8, DxfLinetype::Hidden, None).unwrap();
|
||||
file.push(cut, ((0, 0), (10, 5)), None).unwrap();
|
||||
file.push(hidden, ((3, 4), (5, 4)), None).unwrap();
|
||||
assert_eq!(file.layers().iter().map(Layer::name).collect::<Vec<_>>(), ["0", "CUT", "HIDDEN"]);
|
||||
assert_eq!(file.entities()[0].geometry(), &((0, 0), (10, 5)));
|
||||
assert_eq!(file.entities()[0].layer(), cut);
|
||||
assert_eq!(file.entities()[0].weight(), None);
|
||||
assert_eq!(file.effective_linetype(1).unwrap(), DxfLinetype::Hidden);
|
||||
assert_eq!(file.effective_weight(0).unwrap(), EntityWeight::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_from_another_file_never_select_its_same_numbered_layer() {
|
||||
let first = DxfFile::<()>::new().unwrap();
|
||||
let mut second = DxfFile::new().unwrap();
|
||||
assert!(second.push(first.default_layer(), (), None).is_err());
|
||||
assert!(second.entities().is_empty());
|
||||
second.push(second.default_layer(), (), None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_cleaned_once_and_collisions_refuse_without_mutation() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
let id = file.declare_layer("CUT\nA", 3, DxfLinetype::Continuous, None).unwrap();
|
||||
assert_eq!(file.layer(id).unwrap().name(), "CUT_A");
|
||||
assert_eq!(file.declare_layer("CUT\nA", 3, DxfLinetype::Continuous, None).unwrap(), id);
|
||||
for name in ["CUT?A", "cut_a", "CUT_A"] {
|
||||
assert!(file.declare_layer(name, 3, DxfLinetype::Continuous, None).is_err());
|
||||
}
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
assert_eq!(file.declare_layer("", 7, DxfLinetype::Continuous, None).unwrap(), file.default_layer());
|
||||
assert!(file.declare_layer(&"x".repeat(256), 7, DxfLinetype::Continuous, None).is_err());
|
||||
assert!(file.declare_layer("bad color", 256, DxfLinetype::Continuous, None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_cannot_be_redeclared_with_different_style() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
file.declare_layer("ETCH", 4, DxfLinetype::Dashed, None).unwrap();
|
||||
assert!(file.declare_layer("ETCH", 4, DxfLinetype::Continuous, None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
assert_eq!(file.layers()[1].color(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_length_counts_scalars_with_a_bounded_utf8_allocation() {
|
||||
assert_eq!(name_of(&"漢".repeat(86)).unwrap().chars().count(), 86);
|
||||
assert_eq!(name_of(&"漢".repeat(255)).unwrap().chars().count(), 255);
|
||||
assert_eq!(name_of(&"😀".repeat(255)).unwrap().len(), 1020);
|
||||
assert!(name_of(&"漢".repeat(256)).is_err());
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
file.declare_layer(&"A".repeat(255), 7, DxfLinetype::Continuous, None).unwrap();
|
||||
assert!(file.declare_layer(&"A".repeat(256), 7, DxfLinetype::Continuous, None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_symbol_equivalence_is_refused_instead_of_assumed() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
for name in ["É", "é", "漢", "Aé", "E\u{301}"] {
|
||||
let error = file.declare_layer(name, 7, DxfLinetype::Continuous, None).unwrap_err();
|
||||
assert!(error.to_string().contains("not supported yet: non-ASCII"));
|
||||
}
|
||||
assert_eq!(file.layers().len(), 1);
|
||||
assert!(file.entities().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_a_different_layer_zero_style_remains_explicitly_pending() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
assert!(file.declare_layer("0", 3, DxfLinetype::Dashed, None).is_err());
|
||||
let zero = file.layer(file.default_layer()).unwrap();
|
||||
assert_eq!(zero.color(), 7);
|
||||
assert_eq!(zero.linetype(), DxfLinetype::Continuous);
|
||||
assert_eq!(zero.weight(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_precedes_ascii_symbol_collision_detection() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
file.declare_layer("CUT", 7, DxfLinetype::Continuous, None).unwrap();
|
||||
for (name, expected) in [(" CUT ", "CUT"), ("\tCUT\r\n", "CUT"), ("\x0bCUT\x0c", "CUT"), (" cut ", "cut")] {
|
||||
assert_eq!(name_of(name).unwrap(), expected);
|
||||
assert!(file.declare_layer(name, 7, DxfLinetype::Continuous, None).is_err());
|
||||
}
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blanks_and_trimmed_layer_zero_use_the_existing_zero_handle() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
for name in ["", " \t\r\n\x0b\x0c ", " 0 ", "\t0\r\n"] {
|
||||
assert_eq!(file.declare_layer(name, 7, DxfLinetype::Continuous, None).unwrap(), file.default_layer());
|
||||
}
|
||||
assert_eq!(file.layers().len(), 1);
|
||||
assert!(file.declare_layer(" 0 ", 3, DxfLinetype::Continuous, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_255_character_limit_is_independent_of_raw_padding() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
let name = "A".repeat(255);
|
||||
let padded = format!(" \t{name}\r\n ");
|
||||
let id = file.declare_layer(&padded, 7, DxfLinetype::Continuous, None).unwrap();
|
||||
assert_eq!(file.layer(id).unwrap().name(), name);
|
||||
assert!(name_of(&format!(" {} ", "A".repeat(256))).is_err());
|
||||
assert_eq!(name_of(&format!(" {} ", "漢".repeat(255))).unwrap().chars().count(), 255);
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_resource_limit_is_separate_from_normalized_format_length() {
|
||||
let boundary = format!("{}{}", " ".repeat(3841), "A".repeat(255));
|
||||
assert_eq!(boundary.len(), 4096);
|
||||
assert_eq!(name_of(&boundary).unwrap().len(), 255);
|
||||
assert!(name_of(&format!(" {boundary}")).unwrap_err().to_string().contains("resource byte limit"));
|
||||
assert!(name_of(&" ".repeat(4097)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_weights_keep_standard_values_and_nearest_boundaries() {
|
||||
let table = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
for code in table {
|
||||
let w = StandardWeight::from_code(code).unwrap();
|
||||
assert_eq!(w.code(), code);
|
||||
assert_eq!(StandardWeight::from_mm(f64::from(code) / 100.0).unwrap(), w);
|
||||
}
|
||||
// 0.375 is the EXACT binary midpoint between 0.35 and 0.40.
|
||||
assert_eq!(StandardWeight::from_mm(0.375_f64.next_down()).unwrap().code(), 35);
|
||||
assert_eq!(StandardWeight::from_mm(0.375).unwrap().code(), 40);
|
||||
assert_eq!(StandardWeight::from_mm(0.375_f64.next_up()).unwrap().code(), 40);
|
||||
// Stored 0.025 is slightly ABOVE the rational first midpoint.
|
||||
assert_eq!(StandardWeight::from_mm(0.025_f64.next_down()).unwrap().code(), 0);
|
||||
assert_eq!(StandardWeight::from_mm(0.025).unwrap().code(), 5);
|
||||
assert_eq!(StandardWeight::from_mm(0.01).unwrap().code(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_physical_weights_are_refused_not_clamped_or_symbols() {
|
||||
for value in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 2.11_f64.next_up()] {
|
||||
assert!(StandardWeight::from_mm(value).is_err());
|
||||
}
|
||||
assert!(StandardWeight::from_code(6).is_err());
|
||||
assert_eq!(StandardWeight::from_mm(-0.0).unwrap().code(), 0);
|
||||
assert_eq!(StandardWeight::from_mm(2.11).unwrap().code(), 211);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_symbolic_entity_weights_remain_distinct() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let fifty = StandardWeight::from_code(50).unwrap();
|
||||
let cut = file.declare_layer("CUT", 1, DxfLinetype::Continuous, Some(fifty)).unwrap();
|
||||
for weight in [
|
||||
None,
|
||||
Some(EntityWeight::ByLayer),
|
||||
Some(EntityWeight::ByBlock),
|
||||
Some(EntityWeight::Default),
|
||||
Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap())),
|
||||
] {
|
||||
file.push(cut, (), weight).unwrap();
|
||||
}
|
||||
assert_eq!(file.entities()[0].weight(), None);
|
||||
assert_eq!(file.entities()[1].weight(), Some(EntityWeight::ByLayer));
|
||||
assert_eq!(file.effective_weight(0).unwrap(), EntityWeight::Explicit(fifty));
|
||||
assert_eq!(file.effective_weight(1).unwrap(), EntityWeight::Explicit(fifty));
|
||||
assert_eq!(file.effective_weight(2).unwrap(), EntityWeight::ByBlock);
|
||||
assert_eq!(file.effective_weight(3).unwrap(), EntityWeight::Default);
|
||||
assert_eq!(file.effective_weight(4).unwrap(), EntityWeight::Explicit(StandardWeight::from_code(0).unwrap()));
|
||||
assert_eq!(EntityWeight::ByLayer.group370(), -1);
|
||||
assert_eq!(EntityWeight::ByBlock.group370(), -2);
|
||||
assert_eq!(EntityWeight::Default.group370(), -3);
|
||||
assert_eq!(EntityWeight::Explicit(fifty).group370(), 50);
|
||||
assert!(file.effective_weight(5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cumulative_limits_refuse_the_next_insert_without_a_partial_entity() {
|
||||
let mut file = DxfFile::with_limits(2, 1).unwrap();
|
||||
file.declare_layer("A", 7, DxfLinetype::Continuous, None).unwrap();
|
||||
assert!(file.declare_layer("B", 7, DxfLinetype::Continuous, None).is_err());
|
||||
file.push(file.default_layer(), (), None).unwrap();
|
||||
assert!(file.push(file.default_layer(), (), None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
assert_eq!(file.entities().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_counter_never_wraps_or_reuses_an_identity() {
|
||||
let counter = AtomicUsize::new(usize::MAX - 1);
|
||||
assert_eq!(owner_from(&counter).unwrap(), usize::MAX - 1);
|
||||
assert!(owner_from(&counter).is_err());
|
||||
assert!(owner_from(&counter).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Conservative bounds and positive uniform transforms for owned DXF geometry.
|
||||
//!
|
||||
//! ARC bounds enclose its supporting circle; ELLIPSE uses an outward L1 box;
|
||||
//! clamped continuous positive-weight SPLINE uses its control-point hull.
|
||||
//! These bounds are conservative, not tight. TEXT requires explicit world-space
|
||||
//! layout metrics rather than guessed glyph widths. Unsupported spline forms
|
||||
//! return an error. Stored styles, parameters and array order are preserved.
|
||||
//! Variable copy payload and geometry work are independently bounded before
|
||||
//! array scans/copies using MAX_DRAWING_BYTES. Neither bound promises peak RAM,
|
||||
//! final encoded file size, callback cost or exact floating-point equivalence.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use super::super::DxfShape;
|
||||
use super::DxfFile;
|
||||
use crate::{IoError, Result};
|
||||
|
||||
fn invalid(message: &str) -> IoError {
|
||||
IoError::Invalid(message.into())
|
||||
}
|
||||
|
||||
/// Axis-aligned bounds in drawing millimetres, without physical stroke width.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DxfGeometryBounds {
|
||||
pub min: Vec2,
|
||||
pub max: Vec2,
|
||||
}
|
||||
|
||||
impl DxfGeometryBounds {
|
||||
pub fn new(min: Vec2, max: Vec2) -> Result<Self> {
|
||||
if !min.is_finite() || !max.is_finite() || min.x > max.x || min.y > max.y {
|
||||
return Err(invalid("DXF bounds must be finite and ordered"));
|
||||
}
|
||||
Ok(Self { min, max })
|
||||
}
|
||||
|
||||
fn union(self, other: Self) -> Self {
|
||||
Self {
|
||||
min: Vec2::new(self.min.x.min(other.min.x), self.min.y.min(other.min.y)),
|
||||
max: Vec2::new(self.max.x.max(other.max.x), self.max.y.max(other.max.y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_point(point: Vec2) -> Result<Vec2> {
|
||||
if point.is_finite() { Ok(point) } else { Err(invalid("nonfinite DXF point")) }
|
||||
}
|
||||
|
||||
fn positive(value: f64) -> Result<f64> {
|
||||
if value.is_finite() && value > 0.0 { Ok(value) } else { Err(invalid("DXF dimension must be finite and positive")) }
|
||||
}
|
||||
|
||||
fn supporting_circle(c: Vec2, r: f64) -> Result<DxfGeometryBounds> {
|
||||
finite_point(c)?;
|
||||
positive(r)?;
|
||||
// One outward ULP encloses each correctly rounded sum/difference, including
|
||||
// subnormal results. Overflow/infinite outward bounds are explicitly refused.
|
||||
DxfGeometryBounds::new(Vec2::new((c.x - r).next_down(), (c.y - r).next_down()), Vec2::new((c.x + r).next_up(), (c.y + r).next_up()))
|
||||
}
|
||||
|
||||
fn valid_arc(a0: f64, a1: f64) -> Result<()> {
|
||||
// Preserve stored angles, including equal endpoints; resolving equal-endpoint
|
||||
// ARC semantics is the reader/writer's job, not an invented full-turn rule.
|
||||
if a0.is_finite() && a1.is_finite() { Ok(()) } else { Err(invalid("nonfinite DXF arc angle")) }
|
||||
}
|
||||
|
||||
fn valid_text(at: Vec2, height: f64, angle_deg: f64) -> Result<()> {
|
||||
finite_point(at)?;
|
||||
positive(height)?;
|
||||
if angle_deg.is_finite() { Ok(()) } else { Err(invalid("nonfinite DXF text angle")) }
|
||||
}
|
||||
|
||||
fn point_bounds(points: &[Vec2]) -> Result<DxfGeometryBounds> {
|
||||
let first = finite_point(*points.first().ok_or_else(|| invalid("DXF point array is empty"))?)?;
|
||||
let mut bounds = DxfGeometryBounds::new(first, first)?;
|
||||
for point in points.iter().skip(1) {
|
||||
let point = finite_point(*point)?;
|
||||
bounds = bounds.union(DxfGeometryBounds::new(point, point)?);
|
||||
}
|
||||
Ok(bounds)
|
||||
}
|
||||
|
||||
fn valid_polyline(points: &[Vec2]) -> Result<()> {
|
||||
if points.len() < 2 {
|
||||
return Err(invalid("DXF polyline needs at least two vertices"));
|
||||
}
|
||||
for point in points {
|
||||
finite_point(*point)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ellipse_bounds(c: Vec2, major: Vec2, ratio: f64, t0: f64, t1: f64) -> Result<DxfGeometryBounds> {
|
||||
finite_point(c)?;
|
||||
finite_point(major)?;
|
||||
if major == Vec2::ZERO || !ratio.is_finite() || ratio <= 0.0 || ratio > 1.0 || !t0.is_finite() || !t1.is_finite() {
|
||||
return Err(invalid("invalid DXF ellipse support or parameters"));
|
||||
}
|
||||
// |x-cx| <= |major.x| + ratio*|major.y|, likewise for y.
|
||||
// Each nonnegative product and sum is rounded outward independently.
|
||||
// abs is exact for finite f64. No hypot/trig accuracy assumption is used.
|
||||
let x = (major.x.abs() + (ratio * major.y.abs()).next_up()).next_up();
|
||||
let y = (major.y.abs() + (ratio * major.x.abs()).next_up()).next_up();
|
||||
DxfGeometryBounds::new(Vec2::new((c.x - x).next_down(), (c.y - y).next_down()), Vec2::new((c.x + x).next_up(), (c.y + y).next_up()))
|
||||
}
|
||||
|
||||
fn valid_spline(degree: u8, knots: &[f64], ctrl: &[Vec2], weights: Option<&[f64]>) -> Result<()> {
|
||||
let degree = usize::from(degree);
|
||||
if degree == 0 || ctrl.len() <= degree {
|
||||
return Err(invalid("DXF spline needs positive degree and at least degree+1 controls"));
|
||||
}
|
||||
let count = ctrl.len().checked_add(degree).and_then(|n| n.checked_add(1)).ok_or_else(|| invalid("DXF spline knot count overflow"))?;
|
||||
if knots.len() != count {
|
||||
return Err(invalid("invalid DXF spline knot count"));
|
||||
}
|
||||
for point in ctrl {
|
||||
finite_point(*point)?;
|
||||
}
|
||||
if knots.iter().any(|value| !value.is_finite())
|
||||
|| knots.windows(2).any(|pair| match pair {
|
||||
[a, b] => a > b,
|
||||
_ => true,
|
||||
})
|
||||
{
|
||||
return Err(invalid("DXF spline knots must be finite and nondecreasing"));
|
||||
}
|
||||
let start = *knots.get(degree).ok_or_else(|| invalid("missing DXF spline start knot"))?;
|
||||
let end = *knots.get(ctrl.len()).ok_or_else(|| invalid("missing DXF spline end knot"))?;
|
||||
if start >= end {
|
||||
return Err(invalid("DXF spline parameter domain must be nonempty"));
|
||||
}
|
||||
let prefix = knots.get(..=degree).ok_or_else(|| invalid("missing DXF spline start clamp"))?;
|
||||
let suffix = knots.get(ctrl.len()..).ok_or_else(|| invalid("missing DXF spline end clamp"))?;
|
||||
if prefix.iter().any(|value| *value != start) || suffix.iter().any(|value| *value != end) {
|
||||
return Err(invalid("not supported yet: unclamped DXF spline bounds/transform"));
|
||||
}
|
||||
let interior = knots.get(degree + 1..ctrl.len()).ok_or_else(|| invalid("invalid DXF spline interior knot range"))?;
|
||||
let mut previous = None;
|
||||
let mut repetitions = 0usize;
|
||||
for value in interior {
|
||||
if *value <= start || *value >= end {
|
||||
return Err(invalid("DXF spline endpoint knot multiplicity exceeds degree+1"));
|
||||
}
|
||||
repetitions = if previous == Some(*value) { repetitions + 1 } else { 1 };
|
||||
if repetitions > degree {
|
||||
return Err(invalid("not supported yet: discontinuous DXF spline interior knot"));
|
||||
}
|
||||
previous = Some(*value);
|
||||
}
|
||||
if let Some(weights) = weights {
|
||||
if weights.len() != ctrl.len() {
|
||||
return Err(invalid("invalid DXF spline weight count"));
|
||||
}
|
||||
for weight in weights {
|
||||
positive(*weight)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copied_array<T: Copy>(input: &[T]) -> Result<Vec<T>> {
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(input.len()).map_err(|_| invalid("DXF array allocation failed"))?;
|
||||
out.extend_from_slice(input);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn transformed_points(points: &[Vec2], scale: f64, translation: Vec2) -> Result<Vec<Vec2>> {
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(points.len()).map_err(|_| invalid("DXF point array allocation failed"))?;
|
||||
let mut previous: Option<(Vec2, Vec2)> = None;
|
||||
for point in points {
|
||||
let transformed = transformed_point(*point, scale, translation)?;
|
||||
if let Some((before, after)) = previous
|
||||
&& before != *point
|
||||
&& after == transformed
|
||||
{
|
||||
return Err(invalid("DXF transform collapses adjacent distinct points"));
|
||||
}
|
||||
out.push(transformed);
|
||||
previous = Some((*point, transformed));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn shape_bounds<F>(shape: &DxfShape, text_bounds: &mut F) -> Result<DxfGeometryBounds>
|
||||
where
|
||||
F: FnMut(&DxfShape) -> Result<DxfGeometryBounds>,
|
||||
{
|
||||
match shape {
|
||||
DxfShape::Line(a, b) => {
|
||||
finite_point(*a)?;
|
||||
finite_point(*b)?;
|
||||
DxfGeometryBounds::new(Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)))
|
||||
}
|
||||
DxfShape::Circle { c, r } => supporting_circle(*c, *r),
|
||||
DxfShape::Polyline { points } => {
|
||||
valid_polyline(points)?;
|
||||
point_bounds(points)
|
||||
}
|
||||
DxfShape::Ellipse { c, major, ratio, t0, t1 } => ellipse_bounds(*c, *major, *ratio, *t0, *t1),
|
||||
DxfShape::Spline { degree, knots, ctrl, weights } => {
|
||||
valid_spline(*degree, knots, ctrl, weights.as_deref())?;
|
||||
point_bounds(ctrl)
|
||||
}
|
||||
DxfShape::Arc { c, r, a0, a1 } => {
|
||||
valid_arc(*a0, *a1)?;
|
||||
supporting_circle(*c, *r)
|
||||
}
|
||||
DxfShape::Text { at, height, angle_deg, .. } => {
|
||||
valid_text(*at, *height, *angle_deg)?;
|
||||
let supplied = text_bounds(shape)?;
|
||||
// Public fields do not bypass validation of callback output.
|
||||
DxfGeometryBounds::new(supplied.min, supplied.max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transformed_point(point: Vec2, scale: f64, translation: Vec2) -> Result<Vec2> {
|
||||
finite_point(point)?;
|
||||
// Explicit single-rounding semantics, not vector arithmetic with an
|
||||
// overflowing intermediate multiplication that translation might cancel.
|
||||
finite_point(Vec2::new(point.x.mul_add(scale, translation.x), point.y.mul_add(scale, translation.y)))
|
||||
}
|
||||
|
||||
fn transformed_shape(shape: &DxfShape, scale: f64, translation: Vec2) -> Result<DxfShape> {
|
||||
match shape {
|
||||
DxfShape::Line(a, b) => {
|
||||
let start = transformed_point(*a, scale, translation)?;
|
||||
let end = transformed_point(*b, scale, translation)?;
|
||||
if a != b && start == end {
|
||||
return Err(invalid("DXF transform collapses a nonzero line"));
|
||||
}
|
||||
Ok(DxfShape::Line(start, end))
|
||||
}
|
||||
DxfShape::Circle { c, r } => {
|
||||
positive(*r)?;
|
||||
let c = transformed_point(*c, scale, translation)?;
|
||||
let r = positive(*r * scale)?;
|
||||
supporting_circle(c, r)?;
|
||||
Ok(DxfShape::Circle { c, r })
|
||||
}
|
||||
DxfShape::Polyline { points } => {
|
||||
valid_polyline(points)?;
|
||||
Ok(DxfShape::Polyline { points: transformed_points(points, scale, translation)? })
|
||||
}
|
||||
DxfShape::Ellipse { c, major, ratio, t0, t1 } => {
|
||||
ellipse_bounds(*c, *major, *ratio, *t0, *t1)?;
|
||||
let c = transformed_point(*c, scale, translation)?;
|
||||
let major = finite_point(Vec2::new(major.x * scale, major.y * scale))?;
|
||||
ellipse_bounds(c, major, *ratio, *t0, *t1)?;
|
||||
Ok(DxfShape::Ellipse { c, major, ratio: *ratio, t0: *t0, t1: *t1 })
|
||||
}
|
||||
DxfShape::Spline { degree, knots, ctrl, weights } => {
|
||||
valid_spline(*degree, knots, ctrl, weights.as_deref())?;
|
||||
let ctrl = transformed_points(ctrl, scale, translation)?;
|
||||
let knots = copied_array(knots)?;
|
||||
let weights = weights.as_deref().map(copied_array).transpose()?;
|
||||
Ok(DxfShape::Spline { degree: *degree, knots, ctrl, weights })
|
||||
}
|
||||
DxfShape::Arc { c, r, a0, a1 } => {
|
||||
valid_arc(*a0, *a1)?;
|
||||
positive(*r)?;
|
||||
let c = transformed_point(*c, scale, translation)?;
|
||||
let r = positive(*r * scale)?;
|
||||
supporting_circle(c, r)?;
|
||||
Ok(DxfShape::Arc { c, r, a0: *a0, a1: *a1 })
|
||||
}
|
||||
DxfShape::Text { at, height, angle_deg, text } => {
|
||||
valid_text(*at, *height, *angle_deg)?;
|
||||
let at = transformed_point(*at, scale, translation)?;
|
||||
let height = positive(*height * scale)?;
|
||||
let mut copy = String::new();
|
||||
copy.try_reserve(text.len()).map_err(|_| invalid("DXF text allocation failed"))?;
|
||||
copy.push_str(text);
|
||||
Ok(DxfShape::Text { at, height, angle_deg: *angle_deg, text: copy })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn accumulated_text_bytes(total: usize, next: usize, limit: usize) -> Result<usize> {
|
||||
let total = total.checked_add(next).ok_or_else(|| invalid("DXF cumulative TEXT byte count overflow"))?;
|
||||
if total > limit {
|
||||
return Err(invalid("DXF cumulative TEXT payload exceeds drawing byte limit"));
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GeometryBudget {
|
||||
copy_bytes: usize,
|
||||
work: usize,
|
||||
}
|
||||
|
||||
impl GeometryBudget {
|
||||
fn charge(&mut self, copy: usize, work: usize, copy_limit: usize, work_limit: usize) -> Result<()> {
|
||||
self.copy_bytes = self.copy_bytes.checked_add(copy).ok_or_else(|| invalid("DXF geometry copy byte count overflow"))?;
|
||||
self.work = self.work.checked_add(work).ok_or_else(|| invalid("DXF geometry work count overflow"))?;
|
||||
if self.copy_bytes > copy_limit {
|
||||
return Err(invalid("DXF cumulative geometry payload exceeds drawing byte limit"));
|
||||
}
|
||||
if self.work > work_limit {
|
||||
return Err(invalid("DXF cumulative geometry work exceeds drawing limit"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn array<T>(&mut self, len: usize, copy_limit: usize, work_limit: usize) -> Result<()> {
|
||||
let bytes = len.checked_mul(std::mem::size_of::<T>()).ok_or_else(|| invalid("DXF array byte count overflow"))?;
|
||||
let work = len.checked_mul(8).ok_or_else(|| invalid("DXF array work count overflow"))?;
|
||||
self.charge(bytes, work, copy_limit, work_limit)
|
||||
}
|
||||
}
|
||||
|
||||
impl DxfFile<DxfShape> {
|
||||
/// None means no entities. TEXT requires extents_with_text_bounds instead.
|
||||
/// ARC extents are conservative circle bounds, not minimum arc bounds.
|
||||
pub fn extents(&self) -> Result<Option<DxfGeometryBounds>> {
|
||||
self.extents_with_text_bounds(|_| Err(invalid("not supported yet: TEXT extents without font/layout bounds")))
|
||||
}
|
||||
|
||||
/// The callback supplies complete world-space bounds for the real TEXT
|
||||
/// layout, including rotation and centre-baseline alignment. It owns metric
|
||||
/// accuracy; this adapter only checks finiteness/order. No text is serialized.
|
||||
pub fn extents_with_text_bounds<F>(&self, mut text_bounds: F) -> Result<Option<DxfGeometryBounds>>
|
||||
where
|
||||
F: FnMut(&DxfShape) -> Result<DxfGeometryBounds>,
|
||||
{
|
||||
self.preflight_geometry(crate::MAX_DRAWING_BYTES, crate::MAX_DRAWING_BYTES)?;
|
||||
let mut bounds: Option<DxfGeometryBounds> = None;
|
||||
for entity in self.entities() {
|
||||
self.layer(entity.layer())?;
|
||||
let next = shape_bounds(entity.geometry(), &mut text_bounds)?;
|
||||
bounds = Some(match bounds {
|
||||
None => next,
|
||||
Some(previous) => previous.union(next),
|
||||
});
|
||||
}
|
||||
Ok(bounds)
|
||||
}
|
||||
|
||||
/// Returns a fresh owning file; source and all source handles remain intact
|
||||
/// even on refusal. Order, raw layer names, styles, and optional/symbolic
|
||||
/// weights are retained. Physical lineweights are NOT geometry-scaled.
|
||||
/// Scale must be finite and positive: reflections, zero and negative scales
|
||||
/// are unsupported. Only clamped continuous positive-weight splines are
|
||||
/// admitted. Variable geometry bytes and geometry work are preflighted.
|
||||
pub fn translated_scaled(&self, translation: Vec2, scale: f64) -> Result<Self> {
|
||||
self.translated_scaled_with_text_limit(translation, scale, crate::MAX_DRAWING_BYTES)
|
||||
}
|
||||
|
||||
// Private small-limit seam for boundary tests without allocating 64 MiB.
|
||||
fn translated_scaled_with_text_limit(&self, translation: Vec2, scale: f64, text_limit: usize) -> Result<Self> {
|
||||
self.translated_scaled_with_limits(translation, scale, text_limit, crate::MAX_DRAWING_BYTES)
|
||||
}
|
||||
|
||||
fn preflight_geometry(&self, copy_limit: usize, work_limit: usize) -> Result<()> {
|
||||
let mut budget = GeometryBudget::default();
|
||||
let mut text_bytes = 0usize;
|
||||
for entity in self.entities() {
|
||||
// Work units bound our geometry scans/copies, not callback work,
|
||||
// string comparison cost in layer declarations, or wall-clock time.
|
||||
budget.charge(0, 32, copy_limit, work_limit)?;
|
||||
match entity.geometry() {
|
||||
DxfShape::Text { text, .. } => {
|
||||
text_bytes = accumulated_text_bytes(text_bytes, text.len(), copy_limit)?;
|
||||
budget.charge(text.len(), text.len(), copy_limit, work_limit)?;
|
||||
}
|
||||
DxfShape::Polyline { points } => budget.array::<Vec2>(points.len(), copy_limit, work_limit)?,
|
||||
DxfShape::Spline { knots, ctrl, weights, .. } => {
|
||||
budget.array::<f64>(knots.len(), copy_limit, work_limit)?;
|
||||
budget.array::<Vec2>(ctrl.len(), copy_limit, work_limit)?;
|
||||
if let Some(weights) = weights {
|
||||
budget.array::<f64>(weights.len(), copy_limit, work_limit)?;
|
||||
}
|
||||
}
|
||||
DxfShape::Line(..) | DxfShape::Circle { .. } | DxfShape::Arc { .. } | DxfShape::Ellipse { .. } => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn translated_scaled_with_limits(&self, translation: Vec2, scale: f64, copy_limit: usize, work_limit: usize) -> Result<Self> {
|
||||
finite_point(translation)?;
|
||||
positive(scale)?;
|
||||
// Length-only preflight before validation scans, output or copying.
|
||||
self.preflight_geometry(copy_limit, work_limit)?;
|
||||
let mut output = Self::new()?;
|
||||
let mut layers = Vec::new();
|
||||
layers.try_reserve(self.layers().len()).map_err(|_| invalid("DXF layer remap allocation failed"))?;
|
||||
for layer in self.layers() {
|
||||
layers.push(output.declare_layer(&layer.original, layer.color(), layer.linetype(), layer.weight())?);
|
||||
}
|
||||
for entity in self.entities() {
|
||||
self.layer(entity.layer())?;
|
||||
let layer = layers.get(entity.layer().index).copied().ok_or_else(|| invalid("missing DXF layer remap"))?;
|
||||
output.push(layer, transformed_shape(entity.geometry(), scale, translation)?, entity.weight())?;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::DxfLinetype;
|
||||
use super::super::{EntityWeight, StandardWeight};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_bounds_and_invalid_transform_parameters() {
|
||||
let file = DxfFile::<DxfShape>::new().unwrap();
|
||||
assert_eq!(file.extents().unwrap(), None);
|
||||
for scale in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(file.translated_scaled(Vec2::new(0.0, 0.0), scale).is_err());
|
||||
}
|
||||
assert!(file.translated_scaled(Vec2::new(f64::NAN, 0.0), 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_bounds_have_analytic_endpoints() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Line(Vec2::new(-2.0, 7.0), Vec2::new(4.0, -3.0)), None).unwrap();
|
||||
assert_eq!(file.extents().unwrap(), Some(DxfGeometryBounds::new(Vec2::new(-2.0, -3.0), Vec2::new(4.0, 7.0)).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_preserves_raw_names_metadata_and_mints_new_handles() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let weight = StandardWeight::from_code(50).unwrap();
|
||||
let layer = file.declare_layer(" CUT ", 3, DxfLinetype::Hidden, Some(weight)).unwrap();
|
||||
file.push(layer, DxfShape::Line(Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)), Some(EntityWeight::ByLayer)).unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Circle { c: Vec2::new(0.0, 1.0), r: 2.0 }, None).unwrap();
|
||||
let mut copy = file.translated_scaled(Vec2::new(10.0, -5.0), 2.0).unwrap();
|
||||
assert_eq!(copy.entities()[0].geometry(), &DxfShape::Line(Vec2::new(12.0, -1.0), Vec2::new(16.0, 3.0)));
|
||||
assert_eq!(copy.entities()[1].geometry(), &DxfShape::Circle { c: Vec2::new(10.0, -3.0), r: 4.0 });
|
||||
assert_eq!(copy.entities()[0].weight(), Some(EntityWeight::ByLayer));
|
||||
assert_eq!(copy.entities()[1].weight(), None);
|
||||
assert_eq!(copy.effective_weight(0).unwrap(), EntityWeight::Explicit(weight));
|
||||
assert_eq!(copy.effective_linetype(0).unwrap(), DxfLinetype::Hidden);
|
||||
assert!(copy.layer(layer).is_err());
|
||||
assert!(copy.declare_layer(" CUT ", 3, DxfLinetype::Hidden, Some(weight)).is_ok());
|
||||
assert!(copy.declare_layer("CUT", 3, DxfLinetype::Hidden, Some(weight)).is_err());
|
||||
assert_eq!(file.entities()[0].geometry(), &DxfShape::Line(Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_arc_bounds_enclose_support_and_transform_preserves_angles() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Arc { c: Vec2::new(3.0, -1.0), r: 2.0, a0: 350.0, a1: 10.0 }, Some(EntityWeight::ByBlock)).unwrap();
|
||||
let bounds = file.extents().unwrap().unwrap();
|
||||
assert!(bounds.min.x <= 1.0 && bounds.max.x >= 5.0);
|
||||
assert!(bounds.min.y <= -3.0 && bounds.max.y >= 1.0);
|
||||
let copy = file.translated_scaled(Vec2::new(1.0, 2.0), 3.0).unwrap();
|
||||
assert_eq!(copy.entities()[0].geometry(), &DxfShape::Arc { c: Vec2::new(10.0, -1.0), r: 6.0, a0: 350.0, a1: 10.0 });
|
||||
assert_eq!(copy.entities()[0].weight(), Some(EntityWeight::ByBlock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_uses_explicit_metrics_and_retains_alignment_payload() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(
|
||||
file.default_layer(),
|
||||
DxfShape::Text { at: Vec2::new(2.0, 3.0), height: 4.0, angle_deg: 90.0, text: "A".into() },
|
||||
Some(EntityWeight::Default),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(file.extents().is_err());
|
||||
// Synthetic font contract: rotated glyph corners (1,2) and (3,6).
|
||||
let metrics = DxfGeometryBounds::new(Vec2::new(1.0, 2.0), Vec2::new(3.0, 6.0)).unwrap();
|
||||
assert_eq!(
|
||||
file.extents_with_text_bounds(|shape| {
|
||||
assert!(matches!(shape, DxfShape::Text { text, .. } if text == "A"));
|
||||
Ok(metrics)
|
||||
})
|
||||
.unwrap(),
|
||||
Some(metrics)
|
||||
);
|
||||
assert!(file.extents_with_text_bounds(|_| Ok(DxfGeometryBounds { min: Vec2::new(f64::NAN, 0.0), max: Vec2::new(1.0, 1.0) })).is_err());
|
||||
let copy = file.translated_scaled(Vec2::new(-1.0, 2.0), 2.0).unwrap();
|
||||
assert_eq!(copy.entities()[0].geometry(), &DxfShape::Text { at: Vec2::new(3.0, 8.0), height: 8.0, angle_deg: 90.0, text: "A".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_polyline_refuses_without_changing_the_source() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Polyline { points: Vec::new() }, None).unwrap();
|
||||
assert!(file.extents().is_err());
|
||||
assert!(file.translated_scaled(Vec2::new(0.0, 0.0), 1.0).is_err());
|
||||
assert_eq!(file.entities().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_geometry_overflow_underflow_and_collapse_refuse() {
|
||||
for shape in [
|
||||
DxfShape::Line(Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 0.0)),
|
||||
DxfShape::Circle { c: Vec2::new(0.0, 0.0), r: -1.0 },
|
||||
DxfShape::Arc { c: Vec2::new(0.0, 0.0), r: 1.0, a0: f64::INFINITY, a1: 1.0 },
|
||||
DxfShape::Text { at: Vec2::new(0.0, 0.0), height: 0.0, angle_deg: 0.0, text: String::new() },
|
||||
] {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), shape, None).unwrap();
|
||||
assert!(file.extents().is_err());
|
||||
assert!(file.translated_scaled(Vec2::new(0.0, 0.0), 1.0).is_err());
|
||||
}
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Circle { c: Vec2::new(0.0, 0.0), r: f64::MIN_POSITIVE }, None).unwrap();
|
||||
assert!(file.translated_scaled(Vec2::new(0.0, 0.0), f64::from_bits(1)).is_err());
|
||||
let mut line = DxfFile::new().unwrap();
|
||||
line.push(line.default_layer(), DxfShape::Line(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0)), None).unwrap();
|
||||
assert!(line.translated_scaled(Vec2::new(1e300, 0.0), 1.0).is_err());
|
||||
assert!(line.translated_scaled(Vec2::new(0.0, 0.0), f64::MAX).unwrap().extents().is_ok());
|
||||
assert!(line.translated_scaled(Vec2::new(f64::MAX, 0.0), f64::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cumulative_text_byte_limit_accepts_boundary_and_preflights_all_payloads() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
for text in ["é", "abc"] {
|
||||
file.push(file.default_layer(), DxfShape::Text { at: Vec2::new(0.0, 0.0), height: 1.0, angle_deg: 0.0, text: text.into() }, None)
|
||||
.unwrap();
|
||||
}
|
||||
let origin = Vec2::new(0.0, 0.0);
|
||||
assert!(file.translated_scaled_with_text_limit(origin, 1.0, 5).is_ok());
|
||||
assert!(file.translated_scaled_with_text_limit(origin, 1.0, 4).is_err());
|
||||
assert_eq!(file.entities().len(), 2);
|
||||
assert!(accumulated_text_bytes(usize::MAX, 1, usize::MAX).is_err());
|
||||
assert_eq!(accumulated_text_bytes(2, 3, 5).unwrap(), 5);
|
||||
// Even a preceding malformed shape cannot start output processing:
|
||||
// the text-resource refusal is observed before validating that shape.
|
||||
let mut unsupported_first = DxfFile::new().unwrap();
|
||||
unsupported_first.push(unsupported_first.default_layer(), DxfShape::Polyline { points: Vec::new() }, None).unwrap();
|
||||
unsupported_first
|
||||
.push(unsupported_first.default_layer(), DxfShape::Text { at: origin, height: 1.0, angle_deg: 0.0, text: "abc".into() }, None)
|
||||
.unwrap();
|
||||
let error = unsupported_first.translated_scaled_with_text_limit(origin, 1.0, 2).unwrap_err();
|
||||
assert!(error.to_string().contains("cumulative TEXT payload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_bounds_transform_and_repeated_vertices_keep_order() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let points = vec![Vec2::new(-2.0, 3.0), Vec2::new(-2.0, 3.0), Vec2::new(4.0, -1.0)];
|
||||
file.push(file.default_layer(), DxfShape::Polyline { points }, None).unwrap();
|
||||
assert_eq!(file.extents().unwrap(), Some(DxfGeometryBounds::new(Vec2::new(-2.0, -1.0), Vec2::new(4.0, 3.0)).unwrap()));
|
||||
let copy = file.translated_scaled(Vec2::new(1.0, -2.0), 2.0).unwrap();
|
||||
assert_eq!(
|
||||
copy.entities()[0].geometry(),
|
||||
&DxfShape::Polyline { points: vec![Vec2::new(-3.0, 4.0), Vec2::new(-3.0, 4.0), Vec2::new(9.0, -4.0)] }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_ellipse_l1_support_is_conservative_and_parameters_survive_copy() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(
|
||||
file.default_layer(),
|
||||
DxfShape::Ellipse { c: Vec2::new(10.0, -2.0), major: Vec2::new(3.0, 4.0), ratio: 0.5, t0: 5.0, t1: 1.0 },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let bounds = file.extents().unwrap().unwrap();
|
||||
// Full-support L1 half extents: (3+4/2, 4+3/2) = (5, 5.5).
|
||||
assert!(bounds.min.x <= 5.0 && bounds.max.x >= 15.0);
|
||||
assert!(bounds.min.y <= -7.5 && bounds.max.y >= 3.5);
|
||||
let copy = file.translated_scaled(Vec2::new(1.0, 1.0), 2.0).unwrap();
|
||||
assert_eq!(
|
||||
copy.entities()[0].geometry(),
|
||||
&DxfShape::Ellipse { c: Vec2::new(21.0, -3.0), major: Vec2::new(6.0, 8.0), ratio: 0.5, t0: 5.0, t1: 1.0 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rational_spline_bounds_use_hull_and_copy_retains_knots_weights_and_degree() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(
|
||||
file.default_layer(),
|
||||
DxfShape::Spline {
|
||||
degree: 2,
|
||||
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
ctrl: vec![Vec2::new(0.0, 0.0), Vec2::new(2.0, 4.0), Vec2::new(4.0, 0.0)],
|
||||
weights: Some(vec![1.0, 0.5, 1.0]),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(file.extents().unwrap(), Some(DxfGeometryBounds::new(Vec2::new(0.0, 0.0), Vec2::new(4.0, 4.0)).unwrap()));
|
||||
let copy = file.translated_scaled(Vec2::new(1.0, -2.0), 2.0).unwrap();
|
||||
assert_eq!(
|
||||
copy.entities()[0].geometry(),
|
||||
&DxfShape::Spline {
|
||||
degree: 2,
|
||||
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
ctrl: vec![Vec2::new(1.0, -2.0), Vec2::new(5.0, 6.0), Vec2::new(9.0, -2.0)],
|
||||
weights: Some(vec![1.0, 0.5, 1.0]),
|
||||
}
|
||||
);
|
||||
// A multi-span clamped nonrational curve is also in the declared subset.
|
||||
assert!(valid_spline(2, &[0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0], &[Vec2::ZERO, Vec2::X, Vec2::Y, Vec2::new(1.0, 1.0)], None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_unsupported_splines_refuse_before_claiming_hull_bounds() {
|
||||
let ctrl = [Vec2::ZERO, Vec2::X, Vec2::Y];
|
||||
let knots = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
|
||||
assert!(valid_spline(0, &knots, &ctrl, None).is_err());
|
||||
assert!(valid_spline(3, &knots, &ctrl, None).is_err());
|
||||
assert!(valid_spline(2, &knots[..5], &ctrl, None).is_err());
|
||||
assert!(valid_spline(2, &[0.0, 0.0, 0.0, 1.0, 0.5, 1.0], &ctrl, None).is_err());
|
||||
assert!(valid_spline(2, &[0.0; 6], &ctrl, None).is_err());
|
||||
assert!(valid_spline(2, &[0.0, 0.25, 0.5, 1.0, 1.0, 1.0], &ctrl, None).is_err());
|
||||
assert!(valid_spline(2, &[0.0, 0.0, 0.0, f64::NAN, 1.0, 1.0], &ctrl, None).is_err());
|
||||
for weights in [vec![1.0, 1.0], vec![1.0, 0.0, 1.0], vec![1.0, -1.0, 1.0], vec![1.0, f64::INFINITY, 1.0]] {
|
||||
assert!(valid_spline(2, &knots, &ctrl, Some(&weights)).is_err());
|
||||
}
|
||||
assert!(valid_spline(2, &knots, &[Vec2::ZERO, Vec2::new(f64::NAN, 0.0), Vec2::Y], None).is_err());
|
||||
assert!(valid_spline(2, &[0.0, 0.0, 0.0, 0.5, 0.5, 0.5, 1.0, 1.0, 1.0], &[Vec2::ZERO; 6], None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_payload_copy_and_work_caps_have_independent_inclusive_boundaries() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
for _ in 0..2 {
|
||||
file.push(file.default_layer(), DxfShape::Polyline { points: vec![Vec2::ZERO, Vec2::X] }, None).unwrap();
|
||||
}
|
||||
// Two point arrays: 2*2*16=64 bytes, 2*(32+2*8)=96 work units.
|
||||
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, 64, 96).is_ok());
|
||||
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, 63, 96).is_err());
|
||||
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, 64, 95).is_err());
|
||||
file.push(file.default_layer(), DxfShape::Text { at: Vec2::ZERO, height: 1.0, angle_deg: 0.0, text: "é".into() }, None).unwrap();
|
||||
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, 66, 130).is_ok());
|
||||
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, 65, 130).is_err());
|
||||
assert!(GeometryBudget::default().array::<Vec2>(usize::MAX, usize::MAX, usize::MAX).is_err());
|
||||
let mut budget = GeometryBudget { copy_bytes: usize::MAX, work: 0 };
|
||||
assert!(budget.charge(1, 0, usize::MAX, usize::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_invalid_array_leaves_all_source_geometry_and_weights_intact() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let first = DxfShape::Polyline { points: vec![Vec2::ZERO, Vec2::X] };
|
||||
file.push(file.default_layer(), first.clone(), Some(EntityWeight::ByBlock)).unwrap();
|
||||
file.push(file.default_layer(), DxfShape::Spline { degree: 2, knots: Vec::new(), ctrl: vec![Vec2::ZERO; 3], weights: None }, None).unwrap();
|
||||
assert!(file.translated_scaled(Vec2::new(1.0, 2.0), 2.0).is_err());
|
||||
assert!(file.extents().is_err());
|
||||
assert_eq!(file.entities().len(), 2);
|
||||
assert_eq!(file.entities()[0].geometry(), &first);
|
||||
assert_eq!(file.entities()[0].weight(), Some(EntityWeight::ByBlock));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_support_underflow_overflow_and_invalid_ratio_are_explicit() {
|
||||
assert!(ellipse_bounds(Vec2::ZERO, Vec2::ZERO, 0.5, 0.0, 1.0).is_err());
|
||||
assert!(ellipse_bounds(Vec2::ZERO, Vec2::X, 0.0, 0.0, 1.0).is_err());
|
||||
assert!(ellipse_bounds(Vec2::ZERO, Vec2::X, 1.1, 0.0, 1.0).is_err());
|
||||
assert!(ellipse_bounds(Vec2::ZERO, Vec2::X, 0.5, f64::NAN, 1.0).is_err());
|
||||
assert!(ellipse_bounds(Vec2::ZERO, Vec2::new(f64::MAX, f64::MAX), 1.0, 0.0, 1.0).is_err());
|
||||
let bounds = ellipse_bounds(Vec2::ZERO, Vec2::new(f64::from_bits(1), 0.0), f64::from_bits(1), 0.0, 1.0).unwrap();
|
||||
assert!(bounds.min.x < 0.0 && bounds.max.x > 0.0 && bounds.min.y < 0.0 && bounds.max.y > 0.0);
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
file.push(
|
||||
file.default_layer(),
|
||||
DxfShape::Ellipse { c: Vec2::ZERO, major: Vec2::new(f64::from_bits(1), 0.0), ratio: 0.5, t0: 0.0, t1: 1.0 },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(file.translated_scaled(Vec2::ZERO, 0.5).is_err());
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,10 @@ pub mod urdf;
|
||||
pub mod vfs;
|
||||
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
|
||||
pub use dxf_out::{
|
||||
DxfEntity, DxfEntityWeight, DxfFile, DxfFileEntity, DxfFileLayer, DxfGeometryBounds, DxfLayer, DxfLayerId, DxfLinetype, DxfShape,
|
||||
DxfStandardWeight, loop_shapes, seg_shape, write_dxf_drawing, write_dxf_file,
|
||||
};
|
||||
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, PREVIOUS_APP_IDS, adopt_legacy_dir, legacy_flatpak_paths};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
|
||||
Reference in New Issue
Block a user