Compare commits

...

2 Commits

Author SHA1 Message Date
lillith fac0043543 feat(dxf): preserve owned file model and shared writer WIP (#5319)
Publish the reviewed partial implementation at the user's request while the corrected normal full gate is running. Not ready or fully validated.
2026-10-28 21:57:39 +01:00
lillith 119c40c814 claim: #5319 2026-10-28 19:37:38 +01:00
4 changed files with 1841 additions and 30 deletions
+675 -29
View File
@@ -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]);
}
}
+494
View File
@@ -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());
}
}
+4 -1
View File
@@ -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};