Compare commits

...

10 Commits

Author SHA1 Message Date
lillith a211e61c7d WIP conditional Drawings owned DXF producer adapters
DO NOT INTEGRATE until the admission policy in #5319 is resolved: https://github.com/tangency-labs/solidcraft/issues/5319#issuecomment-6482559607 . The owned writer and legacy producers have different limits and numeric admission domains. This branch preserves sheet and view stage-B alternatives separately from the connect-only follow-up. No silent fallback or raised limits is authorized. Source review only; no compilation, tests or full CI for this source.
2026-10-29 10:53:39 +01:00
lillith 8eec0bf630 WIP Drawings DXF command acceptance and connected hidden lines
Follow-up to #5319/#5320 and PR #8515. Includes sheet tests V5, view command tests and connect-only stage A. Source-reviewed but NOT compiled or tested. The full CI pass on parent d8e7a869 applies only to that parent, not this commit. Original legacy writer calls and geometry assertions remain. Typed metadata assertions intentionally migrate three legacy substring checks; this is not a byte-equivalence claim.
2026-10-29 10:53:35 +01:00
lillith d8e7a8690a fix(dxf): keep counter and copy tests compatible with strict Clippy
Use fresh local mutable counter values instead of mutable references to
constants. Enumerate the ten original copied shapes after asserting their
independent fixture count. Preserve all production code, numeric expected
values, refusals and the other tests. No lint suppression or ignored tests.

The prior 6b39ee36 attempt stopped at test-target Clippy, before native
test builds/runs and wasm. Both test-only hunks were independently reviewed;
normal formatting passed without changing bytes. New results are pending.
2026-10-29 03:17:21 +01:00
lillith 6b39ee3686 fix(dxf): resolve strict Clippy findings without weakening checks
Remove an unused private Interval helper and collapse the copied-vertex
guard to an equivalent let-chain with identical evaluation order and error.
Preserve all 28 geometry and 10 bounds tests, limits and assertions.

The prior a7f3c78b development attempt stopped at Clippy; IO/engine tests
and wasm were not reached. Both corrective hunks were source-reviewed,
and normal formatting passed. Tests for this new source remain unrun.
2026-10-29 02:59:05 +01:00
lillith a7f3c78b07 feat(dxf): add bounded bulged paths and layered text import (WIP #5319 #5320)
Add the reviewed minimal X1 bulged-path producer, conservative bounds,
ordered layered bulge reader, ELLIPSE/TEXT imports and two original
cross-feature cases. Preserve legacy entry points; retain the two explicit
source-supported fixture migrations, not a blanket assertion relaxation.

Include the three original owner corrections from #8534 to unblock wasm,
without adopting later main or transferring upstream validation.

Normal cargo fmt --all passed. 223 declarations in the nine IO files
(333 including 110 unchanged UI/web declarations) are not test results.
This WIP has not been compiled or run; full CI and caller migration remain.
Earlier-source native passes do not qualify this candidate.

Co-authored-by: Ray <raykholo@users.noreply.github.com>
2026-10-29 02:39:19 +01:00
lillith 471e17f50e feat(dxf): preserve layer-zero imports and nine TEXT alignments (WIP #5319 #5320)
Add explicit alignment production and bounds callbacks, immutable layer-zero
initialization/import, and a reviewed fix retaining its metadata through copies.
Preserve the existing POINT and real writer-reader roundtrip foundation.

156 test declarations across four modules, including 26 additions against the
published parent; this source has NOT been formatted, compiled or run. Separate
source reviews do not qualify runtime. The older-source X2 gate remains separate.
2026-10-28 23:29:45 +01:00
lillith 1891344e07 feat(dxf): preserve POINT and original round-trip acceptance (WIP #5320)
Source-only reviewed four-file increment. 18 added test declarations, not executed. Published at the user's request; current X2 validation continues on unchanged older source.
2026-10-28 22:54:24 +01:00
lillith 1e176d8503 feat(dxf): preserve partial layered reader WIP (#5320)
Stacked on the owned X2 model. Includes 52 declared tests, not executed. Legacy projection and the remaining importer contract are pending; not ready for merge.
2026-10-28 21:57:39 +01:00
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
18 changed files with 7472 additions and 157 deletions
+6 -2
View File
@@ -40,6 +40,8 @@ pub enum Intent {
Open,
/// Add its bodies to the open design (STEP, 3MF, STL).
Insert,
/// Import it as a theme: it was dropped on Preferences' Import tile (#8226).
Theme,
}
/// A stored design, as the file panel lists it.
@@ -615,13 +617,15 @@ fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
}
/// Read files dropped on the page.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
/// `theme`: the pointer is over Preferences' Import tile, so the files are themes.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
let intent = if theme { Intent::Theme } else { Intent::Insert };
for f in std::mem::take(&mut raw.dropped_files) {
let files = files.clone();
spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(b) => files.arrive(name, b, Intent::Insert),
Ok(b) => files.arrive(name, b, intent),
Err(e) => files.say(format!("{name}: {e}"), true),
}
});
+7 -2
View File
@@ -271,6 +271,10 @@ mod web {
!companion
});
for (name, bytes, intent) in incoming {
if intent == Intent::Theme {
solidcraft_ui_egui::prefs::import_theme_bytes(&mut self.app, bytes);
continue;
}
let design = solidcraft_engine::io::is_design_path(&name);
let path = if design && intent == Intent::Open {
// A design from the computer is kept in the browser (a copy), so Save works.
@@ -287,7 +291,7 @@ mod web {
};
match intent {
Intent::Open => self.app.open_path(&path),
Intent::Insert => self.app.drop_path(&path),
Intent::Insert | Intent::Theme => self.app.drop_path(&path),
}
self.files.panel.set(false);
}
@@ -632,7 +636,8 @@ mod web {
ctx.request_repaint_after(delay);
}
if self.launch.persists() {
files::take_drops(&self.files, raw);
let theme = solidcraft_ui_egui::prefs::theme_drop_target(&self.app, ctx);
files::take_drops(&self.files, raw, theme);
} else {
raw.dropped_files.clear();
}
+21 -11
View File
@@ -784,7 +784,7 @@ fn bend_note(b: &solidcraft_doc::sheet::BendInfo, height: f64, display: sheet::A
/// per pocket depth and PERFORATION. Inner contours are written before the outline, so a program
/// that follows the file's order cuts inside-out. Free-form curves as `o.curves` says.
pub fn flat_dxf(sh: &SheetBody, o: &DxfOptions) -> Result<String> {
use solidcraft_io::{DxfEntity, DxfShape};
use solidcraft_io::{DxfEntity, DxfFile, DxfShape};
let regions = sh.flat_regions()?;
let pockets = sh.machining()?;
let letters = sh.letters();
@@ -884,18 +884,28 @@ pub fn flat_dxf(sh: &SheetBody, o: &DxfOptions) -> Result<String> {
for (c, r) in perforation {
entities.push(DxfEntity { layer: "PERFORATION".into(), shape: DxfShape::Circle { c, r } });
}
// One layer: every entity on layer 0, which every drawing has.
if o.one_layer {
for e in &mut entities {
e.layer = "0".into();
let mut file = DxfFile::new()?;
let mut layers = std::collections::BTreeMap::new();
if !o.one_layer {
// Preserve the four baseline layers and the existing sorted declaration
// order: it determines the serialized tables and subsequent handles.
let mut names: std::collections::BTreeSet<String> = ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"].map(String::from).into();
names.extend(entities.iter().map(|e| e.layer.clone()));
for name in names {
let style = dxf_layer(&name);
let layer = file.declare_layer(&style.name, style.color, style.linetype, None)?;
layers.insert(name, layer);
}
return Ok(solidcraft_io::write_dxf_drawing(&[], &entities)?);
}
// The four layers of every flat pattern, and those the rest is on.
let mut names: std::collections::BTreeSet<String> = ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"].map(String::from).into();
names.extend(entities.iter().map(|e| e.layer.clone()));
let layers: Vec<_> = names.iter().map(|name| dxf_layer(name)).collect();
Ok(solidcraft_io::write_dxf_drawing(&layers, &entities)?)
for entity in entities {
let layer = if o.one_layer {
file.default_layer()
} else {
layers.get(&entity.layer).copied().ok_or_else(|| EngineError::Other("flat-pattern entity layer was not declared".into()))?
};
file.push(layer, entity.shape, None)?;
}
Ok(solidcraft_io::write_dxf_file(&file)?)
}
/// What a `sheet.export_dxf` call asks for; a value it cannot use is refused.
+167 -60
View File
@@ -6,6 +6,7 @@ use std::f64::consts::{PI, TAU};
use serde_json::{Value, json};
use solidcraft_geom::{Seg2, Vec2};
use solidcraft_io::{DxfLinetype, DxfShape};
use crate::Session;
@@ -68,6 +69,63 @@ fn export(s: &mut Session, params: Value) -> String {
run(s, "sheet.export_dxf", params)["dxf"].as_str().unwrap_or_default().to_string()
}
/// Entity observations from the real layered reader, with file-owned handles resolved.
/// This is not a DXF parser: unsupported input fails at read_dxf_file without fallback.
#[derive(Debug, PartialEq)]
struct LayeredEntity {
layer: String,
shape: DxfShape,
}
fn layered_entities(dxf: &str) -> Vec<LayeredEntity> {
let raw = entities(dxf);
let file = solidcraft_io::read_dxf_file(dxf).unwrap();
let observed: Vec<_> = file.entities()
.iter()
.map(|entity| LayeredEntity {
layer: file.layer(entity.layer()).unwrap().name().to_string(),
shape: entity.geometry().clone(),
})
.collect();
// The production reader skips paper space. These exporter tests promise
// every ENTITIES record, including its original kind, layer and order.
assert_eq!(observed.len(), raw.len(), "layered observations must cover every raw entity");
for (raw, observed) in raw.iter().zip(&observed) {
assert_eq!(raw.0.as_str(), kind(observed), "raw entity kind/order must be preserved");
assert_eq!(layer_of(raw), observed.layer, "raw entity layer/order must be preserved");
}
observed
}
fn kind(entity: &LayeredEntity) -> &'static str {
match &entity.shape {
DxfShape::Point(_) => "POINT",
DxfShape::Line(..) => "LINE",
DxfShape::Polyline { .. } | DxfShape::BulgedPolyline { .. } => "LWPOLYLINE",
DxfShape::Circle { .. } => "CIRCLE",
DxfShape::Arc { .. } => "ARC",
DxfShape::Ellipse { .. } => "ELLIPSE",
DxfShape::Spline { .. } => "SPLINE",
DxfShape::Text { .. } | DxfShape::AlignedText { .. } => "TEXT",
}
}
fn layered_count(entities: &[LayeredEntity], name: &str) -> usize {
entities.iter().filter(|entity| kind(entity) == name).count()
}
/// The same first location checked by the glyph tests: LINE start, curve centre,
/// or first spline control. Other shape kinds are not glyph output in these cases.
fn glyph_point(entity: &LayeredEntity) -> Vec2 {
match &entity.shape {
DxfShape::Line(start, _) => *start,
DxfShape::Circle { c, .. } | DxfShape::Arc { c, .. } | DxfShape::Ellipse { c, .. } => *c,
DxfShape::Spline { ctrl, .. } => *ctrl.first().unwrap(),
shape => panic!("unexpected glyph shape: {shape:?}"),
}
}
/// Serialization-only observations retained where DxfShape has no raw wire field.
/// The entities of a DXF text: type and group codes.
type Entity = (String, Vec<(i32, String)>);
@@ -94,10 +152,6 @@ fn values(e: &Entity, code: i32) -> Vec<f64> {
e.1.iter().filter(|(c, _)| *c == code).filter_map(|(_, v)| v.parse().ok()).collect()
}
fn count(es: &[Entity], kind: &str) -> usize {
es.iter().filter(|e| e.0 == kind).count()
}
fn point(e: &Entity, code: i32) -> Vec2 {
Vec2::new(values(e, code).first().copied().unwrap_or(f64::NAN), values(e, code + 10).first().copied().unwrap_or(f64::NAN))
}
@@ -118,20 +172,42 @@ fn reimported_areas(dxf: &str) -> Vec<f64> {
areas
}
#[test]
fn layered_observations_reject_an_extra_paper_space_entity() {
let model = concat!(
"0\nSECTION\n2\nENTITIES\n",
"0\nLINE\n8\n0\n10\n0\n20\n0\n11\n1\n21\n0\n",
"0\nENDSEC\n0\nEOF\n",
);
assert_eq!(layered_entities(model).len(), 1);
let paper = "0\nLINE\n67\n1\n8\n0\n10\n2\n20\n0\n11\n3\n21\n0\n";
let with_paper = model.replacen("0\nENDSEC\n", &format!("{paper}0\nENDSEC\n"), 1);
assert_eq!(entities(&with_paper).len(), 2);
assert_eq!(solidcraft_io::read_dxf_file(&with_paper).unwrap().entities().len(), 1);
let rejected = std::panic::catch_unwind(|| layered_entities(&with_paper))
.expect_err("the extra paper-space entity must fail the export observation");
let message = rejected.downcast_ref::<String>().map(String::as_str)
.or_else(|| rejected.downcast_ref::<&str>().copied()).unwrap_or_default();
assert!(message.contains("layered observations must cover every raw entity"), "{message}");
}
#[test]
fn an_elliptic_cut_out_is_one_ellipse() {
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
let es = layered_entities(&dxf);
// The four lines of the outline and one ELLIPSE: no other entity.
assert_eq!((count(&es, "LINE"), count(&es, "ELLIPSE"), es.len()), (4, 1, 5), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
assert_eq!((layered_count(&es, "LINE"), layered_count(&es, "ELLIPSE"), es.len()), (4, 1, 5), "{:?}", es.iter().map(kind).collect::<Vec<_>>());
// By hand: centre (40, 30), the major semi-axis 15 along x (to (55, 30) or (25, 30)), the
// minor one 8, so the ratio is 8/15; the whole turn runs from parameter 0 to 2π.
let e = es.iter().find(|e| e.0 == "ELLIPSE").unwrap();
let (c, major) = (point(e, 10), point(e, 11));
let e = es.iter().find(|e| kind(e) == "ELLIPSE").unwrap();
let DxfShape::Ellipse { c, major, ratio, t0, t1 } = &e.shape else { panic!("expected ELLIPSE: {e:?}") };
assert!(c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.x.abs() - 15.0).abs() < 1e-9 && major.y.abs() < 1e-9, "{c:?} {major:?}");
assert!((values(e, 40)[0] - 8.0 / 15.0).abs() < 1e-9 && values(e, 41)[0] == 0.0 && (values(e, 42)[0] - TAU).abs() < 1e-9);
assert_eq!(values(e, 230), [1.0]);
assert!((*ratio - 8.0 / 15.0).abs() < 1e-9 && *t0 == 0.0 && (*t1 - TAU).abs() < 1e-9);
// The model does not preserve whether the default normal was explicit.
let wire = entities(&dxf);
let wire_ellipse = wire.iter().find(|e| e.0 == "ELLIPSE").unwrap();
assert_eq!(values(wire_ellipse, 230), [1.0]);
// Our reader gets the same ellipse back, and the same plate: 6000 mm² less π·15·8.
let read = solidcraft_io::read_dxf(&dxf).unwrap();
assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Ellipse { c, major, minor }
@@ -145,19 +221,25 @@ fn a_spline_cut_out_is_the_splines_it_was() {
let points = json!([[20, 15], [40, 45], [65, 40], [85, 20]]);
let mut s = plate_with(&[("sketch.spline.fit_point", json!({"points": points})), ("sketch.line", json!({"points": [[85, 20], [20, 15]]}))]);
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
let es = layered_entities(&dxf);
// The cut-out's segments of the flat pattern, one entity each: its cubic pieces are SPLINEs
// of degree 3 over the same four control points, its line a LINE.
let hole: Vec<Seg2> = loops(&s).get(1).map(|l| l.segs.clone()).unwrap_or_default();
let cubics: Vec<[Vec2; 4]> =
hole.iter().filter_map(|g| if let Seg2::Cubic { p0, p1, p2, p3 } = g { Some([*p0, *p1, *p2, *p3]) } else { None }).collect();
assert!(!cubics.is_empty() && cubics.len() + 1 == hole.len(), "{hole:?}");
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (5, cubics.len(), 5 + cubics.len()));
for (spline, want) in es.iter().filter(|e| e.0 == "SPLINE").zip(&cubics) {
assert_eq!((layered_count(&es, "LINE"), layered_count(&es, "SPLINE"), es.len()), (5, cubics.len(), 5 + cubics.len()));
for (spline, want) in es.iter().filter(|e| kind(e) == "SPLINE").zip(&cubics) {
let DxfShape::Spline { degree, knots, ctrl, weights } = &spline.shape else { panic!("expected SPLINE: {spline:?}") };
assert_eq!((*degree, knots.len(), ctrl.len()), (3, 8, 4));
assert!(weights.is_none());
assert_eq!(knots.as_slice(), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
assert!(want.iter().enumerate().all(|(i, p)| (ctrl[i].x - p.x).abs() < 1e-9 && (ctrl[i].y - p.y).abs() < 1e-9), "{ctrl:?} {want:?}");
}
// Planar flag 8 and the literal wire counts are not retained by DxfShape.
let wire = entities(&dxf);
for spline in wire.iter().filter(|e| e.0 == "SPLINE") {
assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![3.0], vec![8.0], vec![4.0]));
assert_eq!(values(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
let (xs, ys) = (values(spline, 10), values(spline, 20));
assert!(want.iter().enumerate().all(|(i, p)| (xs[i] - p.x).abs() < 1e-9 && (ys[i] - p.y).abs() < 1e-9), "{xs:?} {ys:?} {want:?}");
}
// The same plate comes back.
let areas = reimported_areas(&dxf);
@@ -184,11 +266,16 @@ fn a_g4_blend_cut_out_is_one_spline_of_degree_nine_and_closes() {
// One SPLINE of degree 9 with all ten control points: its ends are the blend's own.
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (7, 1, 8));
let spline = es.iter().find(|e| e.0 == "SPLINE").unwrap();
assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![9.0], vec![20.0], vec![10.0]));
let ctrl: Vec<Vec2> = values(spline, 10).into_iter().zip(values(spline, 20)).map(|(x, y)| Vec2::new(x, y)).collect();
let es = layered_entities(&dxf);
assert_eq!((layered_count(&es, "LINE"), layered_count(&es, "SPLINE"), es.len()), (7, 1, 8));
let spline = es.iter().find(|e| kind(e) == "SPLINE").unwrap();
let DxfShape::Spline { degree, knots, ctrl, weights } = &spline.shape else { panic!("expected SPLINE: {spline:?}") };
assert_eq!((*degree, knots.len(), ctrl.len()), (9, 20, 10));
assert!(weights.is_none());
// Preserve the original exact wire flags/counts in addition to typed geometry.
let wire = entities(&dxf);
let wire_spline = wire.iter().find(|e| e.0 == "SPLINE").unwrap();
assert_eq!((values(wire_spline, 70), values(wire_spline, 71), values(wire_spline, 72), values(wire_spline, 73)), (vec![8.0], vec![9.0], vec![20.0], vec![10.0]));
assert!(ctrl.len() == 10 && ctrl.iter().zip(&blend).all(|(a, b)| a.dist(*b) < 1e-9), "{ctrl:?} {blend:?}");
let ends = [ctrl[0], ctrl[9]];
assert!(ends.contains(&Vec2::new(40.0, 20.0)) && ends.contains(&Vec2::new(40.0, 40.0)), "{ends:?}");
@@ -203,7 +290,7 @@ fn a_g4_blend_cut_out_is_one_spline_of_degree_nine_and_closes() {
fn text_is_splines_and_lines_not_chords() {
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
let es = layered_entities(&dxf);
// One entity for each segment of the flat pattern: a straight one a LINE, a glyph's
// quadratic and cubic curves SPLINEs of degree 2 and 3.
let segs: Vec<Seg2> = loops(&s).iter().flat_map(|l| l.segs.clone()).collect();
@@ -214,9 +301,9 @@ fn text_is_splines_and_lines_not_chords() {
n(&|g| matches!(g, Seg2::Cubic { .. })),
);
assert_eq!(lines + quadratics + cubics, segs.len(), "only lines and Béziers");
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (lines, quadratics + cubics, segs.len()));
let degree = |d: f64| es.iter().filter(|e| e.0 == "SPLINE" && values(e, 71) == [d]).count();
assert_eq!((degree(2.0), degree(3.0)), (quadratics, cubics));
assert_eq!((layered_count(&es, "LINE"), layered_count(&es, "SPLINE"), es.len()), (lines, quadratics + cubics, segs.len()));
let degree = |d: u8| es.iter().filter(|e| matches!(&e.shape, DxfShape::Spline { degree, .. } if *degree == d)).count();
assert_eq!((degree(2), degree(3)), (quadratics, cubics));
// The plate with the letters cut out comes back from the file with its area (the largest region; the islands inside the
// letters come in no fixed order).
let areas = reimported_areas(&dxf);
@@ -228,13 +315,14 @@ fn text_is_splines_and_lines_not_chords() {
fn arcs_option_writes_only_lines_and_tangent_arcs_within_a_thousandth() {
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
let dxf = export(&mut s, json!({"curves": "arcs"}));
let es = entities(&dxf);
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
let es = layered_entities(&dxf);
assert!(es.iter().all(|e| kind(e) == "LINE" || kind(e) == "ARC"), "{:?}", es.iter().map(kind).collect::<Vec<_>>());
let arcs: Vec<(Vec2, f64, [Vec2; 2], Vec<Vec2>)> = es
.iter()
.filter(|e| e.0 == "ARC")
.filter(|e| kind(e) == "ARC")
.map(|e| {
let (c, r, (a0, a1)) = (point(e, 10), values(e, 40)[0], (values(e, 50)[0].to_radians(), values(e, 51)[0].to_radians()));
let DxfShape::Arc { c, r, a0, a1 } = &e.shape else { panic!("expected ARC: {e:?}") };
let (c, r, (a0, a1)) = (*c, *r, (a0.to_radians(), a1.to_radians()));
let sweep = (a1 - a0).rem_euclid(TAU);
let samples = (0..=20).map(|k| c + Vec2::from_angle(a0 + sweep * f64::from(k) / 20.0) * r).collect::<Vec<Vec2>>();
(c, r, [c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r], samples)
@@ -270,10 +358,10 @@ fn arcs_option_writes_only_lines_and_tangent_arcs_within_a_thousandth() {
#[test]
fn arcs_option_has_no_splines_for_text_and_the_curves_value_is_checked() {
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
let es = entities(&export(&mut s, json!({"curves": "ARCS"})));
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC") && count(&es, "ARC") > 0);
let exact = entities(&export(&mut s, json!({"curves": "exact"})));
assert!(count(&exact, "SPLINE") > 0 && count(&exact, "ARC") == 0);
let es = layered_entities(&export(&mut s, json!({"curves": "ARCS"})));
assert!(es.iter().all(|e| kind(e) == "LINE" || kind(e) == "ARC") && layered_count(&es, "ARC") > 0);
let exact = layered_entities(&export(&mut s, json!({"curves": "exact"})));
assert!(layered_count(&exact, "SPLINE") > 0 && layered_count(&exact, "ARC") == 0);
assert!(s.execute("sheet.export_dxf", &json!({"curves": "chords"})).is_err());
}
@@ -282,11 +370,11 @@ fn bend_lines_and_layers_are_still_there() {
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
let out = run(&mut s, "sheet.export_dxf", json!({}));
let es = entities(out["dxf"].as_str().unwrap_or_default());
let on = |layer: &str| es.iter().filter(|e| e.1.contains(&(8, layer.to_string()))).count();
let es = layered_entities(out["dxf"].as_str().unwrap_or_default());
let on = |layer: &str| es.iter().filter(|e| e.layer == layer).count();
assert_eq!(on("BEND_UP") + on("BEND_DOWN"), out["bends"].as_u64().unwrap_or(0) as usize);
// The ellipse is a cut-out, on CUT; the outline is the plate's.
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("CUT") == 1);
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && layered_count(&es, "ELLIPSE") == 1 && on("CUT") == 1);
assert_eq!(on("OUTLINE") + on("CUT"), es.len() - on("BEND_UP") - on("BEND_DOWN"));
}
@@ -307,6 +395,26 @@ fn on_layer(es: &[Entity], layer: &str) -> Vec<(String, Vec<(i32, String)>)> {
/// The LAYER table: name → (colour, linetype).
fn layer_table(dxf: &str) -> std::collections::BTreeMap<String, (i64, String)> {
let file = solidcraft_io::read_dxf_file(dxf).unwrap();
let observed = file.layers()
.iter()
.map(|layer| {
let linetype = match layer.linetype() {
DxfLinetype::Continuous => "Continuous",
DxfLinetype::Dashed => "DASHED",
DxfLinetype::Hidden => "HIDDEN",
};
(layer.name().to_string(), (i64::from(layer.color()), linetype.to_string()))
})
.collect();
// The model initializes layer 0 even if its declaration was omitted.
// Retain the original wire expectation until that presence is observable.
assert_eq!(observed, serialized_layer_table(dxf));
observed
}
/// The LAYER table: name → (colour, linetype).
fn serialized_layer_table(dxf: &str) -> std::collections::BTreeMap<String, (i64, String)> {
let lines: Vec<&str> = dxf.lines().collect();
let pairs: Vec<(i32, &str)> = lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim()))).collect();
let start = pairs.iter().position(|p| *p == (2, "LAYER")).unwrap_or(0);
@@ -340,30 +448,30 @@ fn the_flat_pattern_is_on_layers_by_role_and_inner_contours_come_first() {
let mut s = tray_with(&marks());
let out = run(&mut s, "sheet.export_dxf", json!({}));
let dxf = out["dxf"].as_str().unwrap_or_default();
let es = entities(dxf);
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
let es = layered_entities(dxf);
let on = |layer: &str| es.iter().filter(|e| e.layer == layer).collect::<Vec<_>>();
// OUTLINE: the tray's outer contour alone, one entity for each of its segments (lines and arcs).
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
let plate =
regions.iter().max_by(|a, b| a.area().total_cmp(&b.area())).map(|r| r.outer.clone()).unwrap_or(solidcraft_geom::Loop2 { segs: Vec::new() });
assert_eq!(on("OUTLINE").len(), solidcraft_io::loop_shapes(&plate).len());
assert!(on("OUTLINE").iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", on("OUTLINE").iter().map(|e| &e.0).collect::<Vec<_>>());
assert!(on("OUTLINE").iter().all(|e| kind(e) == "LINE" || kind(e) == "ARC"), "{:?}", on("OUTLINE").iter().map(|e| kind(e)).collect::<Vec<_>>());
// CUT: what is cut out inside it that is no text: the Ø5 hole as one CIRCLE, the slot's four lines.
let cut = on("CUT");
assert_eq!((count_of(&cut, "CIRCLE"), count_of(&cut, "LINE"), cut.len()), (1, 4, 5), "{:?}", cut.iter().map(|e| &e.0).collect::<Vec<_>>());
assert_eq!((layered_count_of(&cut, "CIRCLE"), layered_count_of(&cut, "LINE"), cut.len()), (1, 4, 5), "{:?}", cut.iter().map(|e| kind(e)).collect::<Vec<_>>());
// TEXT: the letters of "AB-12" and the counters of A and B, all within the label's box (5 letters
// of at most 10 mm from (20, 28)).
let letters = on("TEXT");
assert!(letters.len() > 20, "{} letter entities", letters.len());
for e in &letters {
let p = point(e, 10);
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
let p = glyph_point(e);
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", kind(e));
}
// The bend lines by direction: three walls up, one down.
assert_eq!((on("BEND_UP").len(), on("BEND_DOWN").len(), out["bends"].as_u64()), (3, 1, Some(4)));
// Inner contours are written before the outline, so a program that follows the file cuts inside-out.
let last_inner = es.iter().rposition(|e| matches!(layer_of(e).as_str(), "CUT" | "TEXT"));
let first_outline = es.iter().position(|e| layer_of(e) == "OUTLINE");
let last_inner = es.iter().rposition(|e| matches!(e.layer.as_str(), "CUT" | "TEXT"));
let first_outline = es.iter().position(|e| e.layer == "OUTLINE");
let (Some(last_inner), Some(first_outline)) = (last_inner, first_outline) else { panic!("{last_inner:?} {first_outline:?}") };
assert!(last_inner < first_outline, "an inner contour at {last_inner}, the outline from {first_outline}");
// The layers, with the colours and linetypes the guide gives; nothing else is declared.
@@ -378,9 +486,9 @@ fn the_flat_pattern_is_on_layers_by_role_and_inner_contours_come_first() {
assert_eq!(layer_table(dxf), want.map(|(n, c, l)| (n.to_string(), (c, l.to_string()))).into_iter().collect());
}
/// How many entities of `kind` there are among `list`.
fn count_of(list: &[&Entity], kind: &str) -> usize {
list.iter().filter(|e| e.0 == kind).count()
/// How many entities of `name` there are among `list`.
fn layered_count_of(list: &[&LayeredEntity], name: &str) -> usize {
list.iter().filter(|entity| kind(entity) == name).count()
}
#[test]
@@ -419,21 +527,21 @@ fn engraved_text_is_etched_and_other_pockets_keep_their_depth_layer() {
// Part way through the sheet: pockets, 1 mm deep from the bottom.
run(&mut s, "solid.extrude", json!({"sketch": "Marks", "distance": 1, "operation": "cut"}));
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
let es = layered_entities(&dxf);
// The rectangle is a pocket of the depth its layer names; the letters are a marking, on ETCH.
let pocket = "POCKET_BOTTOM_Z0.000000_TO_1.000000_MM";
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
assert_eq!((count_of(&on(pocket), "LINE"), on(pocket).len()), (4, 4));
let on = |layer: &str| es.iter().filter(|e| e.layer == layer).collect::<Vec<_>>();
assert_eq!((layered_count_of(&on(pocket), "LINE"), on(pocket).len()), (4, 4));
let letters = on("ETCH");
assert!(letters.len() > 20, "{} letter entities", letters.len());
for e in &letters {
let p = point(e, 10);
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
let p = glyph_point(e);
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", kind(e));
}
let layers = layer_table(&dxf);
assert!(layers.contains_key(pocket) && layers.contains_key("ETCH") && !layers.contains_key("TEXT"), "{layers:?}");
// `text` is about letters cut through: it changes nothing here.
assert_eq!(on_layer(&entities(&export(&mut s, json!({"text": "etch"}))), "ETCH"), on_layer(&es, "ETCH"));
assert_eq!(on_layer(&entities(&export(&mut s, json!({"text": "etch"}))), "ETCH"), on_layer(&entities(&dxf), "ETCH"));
}
#[test]
@@ -536,7 +644,7 @@ fn each_sheet_body_can_have_a_file_of_its_own() {
let single = export(&mut s, json!({"body": f["body"]}));
assert_eq!(f["dxf"].as_str(), Some(single.as_str()), "{}", f["body"]);
}
let bend_lines = |f: &Value| entities(f["dxf"].as_str().unwrap_or_default()).iter().filter(|e| layer_of(e) == "BEND_UP").count();
let bend_lines = |f: &Value| layered_entities(f["dxf"].as_str().unwrap_or_default()).iter().filter(|e| e.layer == "BEND_UP").count();
assert_eq!((bend_lines(&files[0]), bend_lines(&files[1])), (0, 1));
// With a path, each body is written to a file named after it, beside the path, and no text comes back.
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-dxf-per-body-{}", std::process::id()));
@@ -600,16 +708,15 @@ fn the_file_of_a_body_is_named_after_it_and_no_two_share_a_name() {
#[test]
fn bend_lines_can_be_left_out_and_everything_put_on_layer_0() {
let mut s = tray_with(&marks());
let layer = |e: &Entity| e.1.iter().find(|(c, _)| *c == 8).map(|(_, v)| v.clone()).unwrap_or_default();
let default = entities(&export(&mut s, json!({})));
let bends = |es: &[Entity]| es.iter().filter(|e| matches!(layer(e).as_str(), "BEND_UP" | "BEND_DOWN")).count();
let default = layered_entities(&export(&mut s, json!({})));
let bends = |es: &[LayeredEntity]| es.iter().filter(|e| matches!(e.layer.as_str(), "BEND_UP" | "BEND_DOWN")).count();
assert_eq!(bends(&default), 4, "the tray's four bends");
let without = entities(&export(&mut s, json!({"bend_lines": false})));
let without = layered_entities(&export(&mut s, json!({"bend_lines": false})));
assert_eq!(bends(&without), 0);
assert_eq!(without.len(), default.len() - 4, "only the bend lines go");
let single = entities(&export(&mut s, json!({"layers": "single"})));
let single = layered_entities(&export(&mut s, json!({"layers": "single"})));
assert_eq!(single.len(), default.len());
assert!(single.iter().all(|e| layer(e) == "0"), "{:?}", single.iter().map(layer).collect::<Vec<_>>());
assert!(single.iter().all(|e| e.layer == "0"), "{:?}", single.iter().map(|e| &e.layer).collect::<Vec<_>>());
for bad in [json!({"bend_lines": "no"}), json!({"layers": "one"})] {
assert!(s.execute("sheet.export_dxf", &bad).is_err(), "{bad}");
}
+38 -22
View File
@@ -12,7 +12,7 @@ pub(super) mod sketch;
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_io::{DxfEntity, DxfLayer, DxfLinetype, DxfShape};
use solidcraft_io::{DxfFile, DxfLayerId, DxfLinetype, DxfShape};
use solidcraft_render::Camera;
use super::CommandSpec;
@@ -65,30 +65,40 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
writing.check().map_err(|e| bad(CMD, e))?;
let mut visible = 0;
let mut hidden = 0;
let mut entities = Vec::new();
let mut visible_lines = Vec::new();
let mut hidden_lines = Vec::new();
for line in drawing.lines {
if line.hidden && !dashed {
continue;
}
let layer = if line.hidden {
if line.hidden {
if !dashed {
continue;
}
hidden += 1;
"HIDDEN"
hidden_lines.push((line.a, line.b));
} else {
visible += 1;
"VISIBLE"
};
entities.push(DxfEntity { layer: layer.into(), shape: DxfShape::Line(line.a, line.b) });
visible_lines.push((line.a, line.b));
}
}
let mut entities = projection::connect(entities);
let sketch = sketch_entities(drawing.sketch, dashed, &mut entities);
let mut layers = vec![DxfLayer::new("VISIBLE", 7)];
if dashed {
layers.push(DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) });
// Join hidden geometry before constructing any DXF entities. Preserve the
// existing output order: visible lines, connected hidden runs, then sketches.
let hidden_paths = projection::connect(hidden_lines);
let mut file = DxfFile::new()?;
let visible_layer = file.declare_layer("VISIBLE", 7, DxfLinetype::Continuous, None)?;
let hidden_layer = if dashed { Some(file.declare_layer("HIDDEN", 8, DxfLinetype::Hidden, None)?) } else { None };
let sketch_layer = if drawing.sketch.iter().any(|path| !path.hidden) {
Some(file.declare_layer("SKETCH", 5, DxfLinetype::Continuous, None)?)
} else {
None
};
for (a, b) in visible_lines {
file.push(visible_layer, DxfShape::Line(a, b), None)?;
}
if sketch > 0 {
layers.push(DxfLayer::new("SKETCH", 5));
for path in hidden_paths {
let layer = hidden_layer.ok_or_else(|| bad(CMD, "missing hidden DXF layer"))?;
file.push(layer, DxfShape::Polyline { points: path.points }, None)?;
}
let text = solidcraft_io::write_dxf_drawing(&layers, &entities)?;
let sketch = sketch_entities(drawing.sketch, dashed, &mut file, hidden_layer, sketch_layer)?;
let text = solidcraft_io::write_dxf_file(&file)?;
writing.check().map_err(|e| bad(CMD, e))?;
let mut result = json!({"visible":visible,"hidden":hidden,"sketch":sketch,"units":"mm","tolerance":tolerance,"projection":if cam.fov > 0.0 {"perspective"} else {"orthographic"}});
result["stats"] = json!({"bodies":meshes.placements.len(),"shared_meshes":meshes.meshes.len(),"triangles":meshes.triangles,
@@ -106,18 +116,24 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
/// Sketch runs as entities: visible ones on SKETCH, hidden ones dashed on HIDDEN or omitted.
/// Returns the number of SKETCH entities.
fn sketch_entities(paths: Vec<projection::Path>, dashed: bool, entities: &mut Vec<DxfEntity>) -> usize {
fn sketch_entities(
paths: Vec<projection::Path>,
dashed: bool,
file: &mut DxfFile<DxfShape>,
hidden_layer: Option<DxfLayerId>,
sketch_layer: Option<DxfLayerId>,
) -> Result<usize> {
let mut count = 0;
for path in paths.into_iter().filter(|p| dashed || !p.hidden) {
let layer = if path.hidden { "HIDDEN" } else { "SKETCH" };
let layer = (if path.hidden { hidden_layer } else { sketch_layer }).ok_or_else(|| bad(CMD, "missing sketch DXF layer"))?;
count += usize::from(!path.hidden);
let shape = match <[Vec2; 2]>::try_from(path.points) {
Ok([a, b]) => DxfShape::Line(a, b),
Err(points) => DxfShape::Polyline { points },
};
entities.push(DxfEntity { layer: layer.into(), shape });
file.push(layer, shape, None)?;
}
count
Ok(count)
}
#[cfg(test)]
+28 -10
View File
@@ -3,7 +3,6 @@
#[cfg(test)]
use solidcraft_geom::Mesh;
use solidcraft_geom::{Aabb3, Vec2, Vec3};
use solidcraft_io::{DxfEntity, DxfShape};
use solidcraft_render::Camera;
use std::collections::BTreeMap;
@@ -33,15 +32,10 @@ pub(super) fn drawing(meshes: &super::meshes::Scene, sketch: &[super::sketch::Pa
/// Keep the hidden curves connected: restarting a dash on every short chord would make a
/// small circular hole look solid. Degree-two joins continue; junctions end the polyline.
pub(super) fn connect(entities: Vec<DxfEntity>) -> Vec<DxfEntity> {
// The caller supplies hidden line endpoints only. Layer names and DXF entity
// construction belong to the caller; this helper returns geometric runs.
pub(super) fn connect(lines: Vec<(Vec2, Vec2)>) -> Vec<Path> {
let mut result = Vec::new();
let mut lines = Vec::new();
for entity in entities {
match entity {
DxfEntity { layer, shape: DxfShape::Line(a, b) } if layer == "HIDDEN" => lines.push((a, b)),
e => result.push(e),
}
}
let extent = lines.iter().flat_map(|(a, b)| [a, b]).fold(1.0_f64, |s, p| s.max(p.x.abs()).max(p.y.abs()));
let eps = extent * 1e-8;
let key = |p: Vec2| [(p.x / eps).round() as i64, (p.y / eps).round() as i64];
@@ -71,7 +65,7 @@ pub(super) fn connect(entities: Vec<DxfEntity>) -> Vec<DxfEntity> {
}
points.reverse();
}
result.push(DxfEntity { layer: "HIDDEN".into(), shape: DxfShape::Polyline { points } });
result.push(Path { points, hidden: true });
}
result
}
@@ -371,4 +365,28 @@ mod tests {
let (lo, hi) = occluded(Vec3::new(0., 0.5, 0.), Vec3::new(1., 0.5, 2.), t, 0.).unwrap();
assert!(lo.abs() < 1e-9 && (hi - 0.5).abs() < 1e-9);
}
#[test]
fn hidden_line_runs_keep_endpoint_order_and_disconnected_runs() {
let (a, b, c) = (Vec2::new(0., 0.), Vec2::new(1., 0.), Vec2::new(2., 0.));
let (d, e) = (Vec2::new(10., 0.), Vec2::new(11., 0.));
let paths = connect(vec![(a, b), (c, b), (d, e)]);
assert_eq!(paths.len(), 2);
assert_eq!(paths[0].points, [a, b, c]);
assert_eq!(paths[1].points, [d, e]);
assert!(paths.iter().all(|path| path.hidden));
}
#[test]
fn hidden_line_junctions_stop_runs_and_cycles_keep_the_repeated_endpoint() {
let (a, b, c, d) = (Vec2::new(0., 0.), Vec2::new(1., 0.), Vec2::new(1., 1.), Vec2::new(0., 1.));
let junction = connect(vec![(d, a), (a, b), (a, c)]);
assert_eq!(junction.len(), 3);
assert_eq!(junction[0].points, [d, a]);
assert_eq!(junction[1].points, [a, b]);
assert_eq!(junction[2].points, [a, c]);
let cycle = connect(vec![(a, b), (b, c), (c, d), (d, a)]);
assert_eq!(cycle.len(), 1);
assert_eq!(cycle[0].points, [a, b, c, d, a]);
assert!(junction.iter().chain(&cycle).all(|path| path.hidden));
}
}
+28 -4
View File
@@ -1,5 +1,6 @@
use super::*;
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_io::{DxfFile, DxfLinetype, DxfShape};
use solidcraft_render::StandardView;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
@@ -18,7 +19,17 @@ fn camera(view: StandardView) -> Camera {
c
}
fn export(s: &mut Session, c: Camera, hidden: &str) -> Value {
run(s, CMD, json!({"camera":c,"hidden":hidden}))
let result = run(s, CMD, json!({"camera":c,"hidden":hidden}));
let file = read_view_dxf(&result);
assert!(!file.entities().is_empty());
// Current-view curves are LINEs or straight LWPOLYLINEs, not analytic
// circles or bulged paths. Keep the separate legacy segment assertions.
assert!(file.entities().iter().all(|entity| matches!(entity.geometry(), DxfShape::Line(..) | DxfShape::Polyline { .. })));
result
}
fn read_view_dxf(value: &Value) -> DxfFile<DxfShape> {
solidcraft_io::read_dxf_file(value["dxf"].as_str().unwrap()).unwrap()
}
#[test]
@@ -75,8 +86,15 @@ fn view_dxf_blind_back_hole_is_omitted_or_dashed_and_front_hole_is_visible() {
assert_eq!(top["hidden"], 0);
assert!(dashed["hidden"].as_u64().unwrap() > 12, "{dashed}");
assert_eq!(top["visible"], dashed["visible"]);
assert!(!top["dxf"].as_str().unwrap().contains("HIDDEN"));
assert!(dashed["dxf"].as_str().unwrap().contains("LWPOLYLINE"));
let top_file = read_view_dxf(&top);
assert!(!top_file.layers().iter().any(|layer| layer.name() == "HIDDEN"));
let dashed_file = read_view_dxf(&dashed);
assert!(dashed_file.entities().iter().any(|entity| matches!(entity.geometry(), DxfShape::Polyline { .. })));
assert!(dashed_file.entities().iter().enumerate().any(|(index, entity)| {
dashed_file.layer(entity.layer()).unwrap().name() == "HIDDEN"
&& dashed_file.effective_linetype(index).unwrap() == DxfLinetype::Hidden
}));
// Wire pin: PLINEGEN is not retained as a separate DxfFile property.
assert!(dashed["dxf"].as_str().unwrap().contains("128\n"));
let bottom = export(&mut s, camera(StandardView::Bottom), "omit");
assert!(bottom["visible"].as_u64().unwrap() > top["visible"].as_u64().unwrap());
@@ -187,6 +205,10 @@ fn view_dxf_exports_a_sketch_only_design_on_the_sketch_layer() {
let v = export(&mut s, camera(StandardView::Top), "omit");
// Four rectangle sides and one circle, each its own entity.
assert_eq!(v["sketch"], 5, "{v}");
let file = read_view_dxf(&v);
assert!(file.entities().iter().enumerate().all(|(index, entity)| {
file.layer(entity.layer()).unwrap().name() == "SKETCH" && file.effective_linetype(index).unwrap() == DxfLinetype::Continuous
}));
let geometry = solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap();
let (mut sides, mut circle) = (Vec::new(), 0);
for g in geometry {
@@ -224,7 +246,9 @@ fn view_dxf_hides_finished_sketches_behind_bodies_but_draws_the_active_sketch_on
};
let active = export(&mut s, camera(StandardView::Top), "omit");
assert_eq!(active["sketch"], 1, "{active}");
assert!((length(&active) - 80.).abs() < 1e-3 && active["dxf"].as_str().unwrap().contains("SKETCH"));
let active_file = read_view_dxf(&active);
assert!(active_file.entities().iter().any(|entity| active_file.layer(entity.layer()).unwrap().name() == "SKETCH"));
assert!((length(&active) - 80.).abs() < 1e-3);
run(&mut s, "sketch.finish", json!({}));
let finished = export(&mut s, camera(StandardView::Top), "omit");
assert_eq!(finished["sketch"], 2, "{finished}");
+2597 -4
View File
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
//! Nine-alignment TEXT adapter over the real parsed Ent. No parser or glyph layout.
//! Registration and the production call remain with the parent dxf module owner.
use solidcraft_geom::Vec2;
use super::text_value::decode_text_value;
use super::{Ent, MAX_WORK, layered_array_budget, layered_number, layered_required, layered_text};
use crate::dxf_out::{DxfTextHorizontalAlignment, DxfTextVerticalAlignment};
use crate::{DxfShape, IoError, MAX_DRAWING_BYTES, Result};
const MAX_TEXT_ENCODED_BYTES: usize = 250 * 7;
const MAX_TEXT_DECODED_BYTES: usize = 250 * 3;
fn invalid(reason: &str) -> IoError {
IoError::Invalid(format!("layered DXF TEXT: {reason}"))
}
fn number(entity: &Ent, code: i32) -> Result<Option<f64>> {
let value = layered_number(entity, code)?;
if value.is_some_and(|value| !value.is_finite()) {
return Err(invalid("nonfinite numeric field"));
}
Ok(value)
}
fn required(entity: &Ent, code: i32) -> Result<f64> {
let value = layered_required(entity, code)?;
if !value.is_finite() {
return Err(invalid("nonfinite required field"));
}
Ok(value)
}
/// Read nine-alignment model-space TEXT; return an error for MTEXT and any
/// unrepresented alignment/style/OCS. Input is raw Ent.s, decoded exactly once.
/// Both counters belong to the whole file; callers must not reset them per TEXT.
/// Decoded text is restricted to the current writer's faithful plain BMP subset.
pub(super) fn read_text_entity(
entity: &Ent,
k: f64,
max_encoded_bytes: usize,
max_decoded_bytes: usize,
bytes: &mut usize,
work: &mut usize,
) -> Result<DxfShape> {
if entity.kind != "TEXT" {
return Err(invalid("not supported yet: MTEXT attachment/wrapping or a non-TEXT entity"));
}
if !k.is_finite() || k <= 0.0 {
return Err(invalid("unit scale must be finite and positive"));
}
if max_encoded_bytes > MAX_DRAWING_BYTES || max_decoded_bytes > MAX_DRAWING_BYTES {
return Err(invalid("decoder byte caps exceed the file ceiling"));
}
let remaining_bytes = MAX_DRAWING_BYTES.checked_sub(*bytes).ok_or_else(|| invalid("invalid shared byte counter"))?;
let remaining_work = MAX_WORK.checked_sub(*work).ok_or_else(|| invalid("invalid shared work counter"))?;
// Known envelope fields retain the parent's layer/weight/space semantics.
// Unknown geometry, explicit style7, MTEXT chunks3,
// entity color/visibility/linetype and extension data cannot silently vanish.
for code in entity.f.iter().map(|(code, _)| *code).chain(entity.s.iter().map(|(code, _)| *code)) {
if !matches!(code, 1 | 5 | 8 | 10 | 11 | 20 | 21 | 30 | 31 | 39 | 40 | 41 | 50 | 51 | 67 | 71 | 72 | 73 | 100 | 210 | 220 | 230 | 330 | 370) {
return Err(invalid("not supported yet: unrepresented field or style"));
}
}
for code in [5, 8, 330] {
let _ = layered_text(entity, code)?;
}
// Repeated subclass markers100 are ordinary TEXT framing, not scalar fields.
let _ = super::layered_entity_weight(entity)?;
if number(entity, 67)?.unwrap_or(0.0) != 0.0 {
return Err(invalid("expected model-space TEXT after caller space filtering"));
}
for (code, neutral) in [(30, 0.0), (31, 0.0), (39, 0.0), (41, 1.0), (51, 0.0), (71, 0.0), (210, 0.0), (220, 0.0), (230, 1.0)] {
if number(entity, code)?.is_some_and(|value| value != neutral) {
return Err(invalid("not supported yet: nondefault alignment, width, oblique angle, generation flags or OCS"));
}
}
// Parent lexical preflight must validate72/73 before section converts them
// through f64. These finite local enum checks also protect direct Ent callers.
let horizontal = match number(entity, 72)?.unwrap_or(0.0) {
0.0 => DxfTextHorizontalAlignment::Left,
1.0 => DxfTextHorizontalAlignment::Centre,
2.0 => DxfTextHorizontalAlignment::Right,
_ => return Err(invalid("not supported yet: horizontal TEXT alignment")),
};
let vertical = match number(entity, 73)?.unwrap_or(0.0) {
0.0 => DxfTextVerticalAlignment::Baseline,
2.0 => DxfTextVerticalAlignment::Middle,
3.0 => DxfTextVerticalAlignment::Top,
_ => return Err(invalid("not supported yet: vertical TEXT alignment")),
};
let insertion = Vec2::new(required(entity, 10)?, required(entity, 20)?);
let source = if horizontal == DxfTextHorizontalAlignment::Left && vertical == DxfTextVerticalAlignment::Baseline {
// Optional second point is irrelevant for left/baseline, but malformed
// or duplicate fields still refuse. No silent fallback for other modes.
let _ = number(entity, 11)?;
let _ = number(entity, 21)?;
insertion
} else {
Vec2::new(required(entity, 11)?, required(entity, 21)?)
};
let at = source * k;
if !at.is_finite() || (source.x != 0.0 && at.x == 0.0) || (source.y != 0.0 && at.y == 0.0) {
return Err(invalid("nonfinite or collapsed scaled insertion point"));
}
let raw_height = required(entity, 40)?;
let height = raw_height * k;
if raw_height <= 0.0 || !height.is_finite() || height <= 0.0 {
return Err(invalid("height must remain finite and positive after scaling"));
}
let angle_deg = number(entity, 50)?.unwrap_or(0.0);
if !angle_deg.to_radians().is_finite() {
return Err(invalid("invalid rotation angle"));
}
let value = layered_text(entity, 1)?.ok_or_else(|| invalid("missing raw group1 value"))?;
// Check input before allocation/scanning. Decoded output cannot allocate past
// remaining shared payload/work even when its UTF8 representation expands.
let encoded_limit = max_encoded_bytes.min(MAX_TEXT_ENCODED_BYTES);
if value.len() > encoded_limit || value.len() > remaining_work {
return Err(invalid("encoded byte or shared work budget exceeded"));
}
*work = work.checked_add(value.len()).ok_or_else(|| invalid("work counter overflow"))?;
// Current owned writer accepts at most250 BMP scalars: at most750 UTF8 bytes.
let decoded_limit = max_decoded_bytes.min(remaining_bytes).min(remaining_work - value.len()).min(MAX_TEXT_DECODED_BYTES);
let text = decode_text_value(value, encoded_limit, decoded_limit)?;
if text.chars().count() > 250 || text.chars().any(|ch| u32::from(ch) > 0xffff || ch.is_control() || matches!(ch, '%' | '\\')) {
return Err(invalid("not supported yet: decoded text exceeds the current writer's plain BMP subset"));
}
layered_array_budget::<u8>(text.len(), bytes, work)?;
if horizontal == DxfTextHorizontalAlignment::Centre && vertical == DxfTextVerticalAlignment::Baseline {
Ok(DxfShape::Text { at, height, angle_deg, text })
} else {
Ok(DxfShape::AlignedText { at, height, angle_deg, text, horizontal, vertical })
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIELDS: &str = "10\n99\n20\n88\n11\n1\n21\n2\n40\n3\n72\n1\n1\nA\n";
fn parsed(fields: &str) -> Ent {
let input = format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n{fields}0\nENDSEC\n0\nEOF\n");
super::super::section(&super::super::pairs(&input), 1).unwrap().remove(0)
}
fn basic(value: &str) -> Ent {
parsed(&FIELDS.replacen("1\nA\n", &format!("1\n{value}\n"), 1))
}
fn read(entity: &Ent) -> Result<DxfShape> {
read_text_entity(entity, 1.0, 4096, 4096, &mut 0, &mut 0)
}
#[test]
fn raw_text_uses_real_decoder_and_second_alignment_point_without_trimming() {
assert_eq!(
read(&basic(" %%d %%p %%c \\U+4E2D ")).unwrap(),
DxfShape::Text { at: Vec2::new(1.0, 2.0), height: 3.0, angle_deg: 0.0, text: " ° ± \u{2205} 中 ".into() }
);
for value in ["", " ", "ASCII braces {}"] {
assert!(matches!(read(&basic(value)).unwrap(), DxfShape::Text { text, .. } if text == value));
}
}
#[test]
fn scale_alignment_point_and_height_once_keep_rotation_in_degrees() {
let fields = FIELDS.replacen("40\n3\n", "40\n0.5\n50\n-90\n", 1);
assert_eq!(
read_text_entity(&parsed(&fields), 25.4, 1, 1, &mut 0, &mut 0).unwrap(),
DxfShape::Text { at: Vec2::new(25.4, 50.8), height: 12.7, angle_deg: -90.0, text: "A".into() }
);
}
#[test]
fn neutral_fields_and_repeated_real_subclasses_are_admitted() {
let entity = parsed(&format!(
"{FIELDS}100\nAcDbEntity\n100\nAcDbText\n100\nAcDbText\n30\n0\n31\n0\n39\n0\n41\n1\n50\n0\n51\n0\n71\n0\n73\n0\n210\n0\n220\n0\n230\n1\n"
));
assert!(read(&entity).is_ok());
}
#[test]
fn missing_malformed_and_duplicate_fields_refuse() {
for removed in ["10\n99\n", "20\n88\n", "11\n1\n", "21\n2\n", "40\n3\n", "1\nA\n"] {
assert!(read(&parsed(&FIELDS.replacen(removed, "", 1))).is_err(), "{removed}");
}
for extra in ["10\n99\n", "11\n1\n", "21\n2\n", "40\n3\n", "72\n1\n", "1\nA\n", "50\n0\n50\n0\n", "73\n0\n73\n0\n", "230\n1\n230\n1\n"] {
assert!(read(&parsed(&format!("{FIELDS}{extra}"))).is_err(), "{extra}");
}
for (from, to) in [("11\n1", "11\nNaN"), ("21\n2", "21\nbad"), ("40\n3", "40\ninf"), ("72\n1", "72\nwrong")] {
assert!(read(&parsed(&FIELDS.replacen(from, to, 1))).is_err());
}
let mut entity = basic("A");
entity.f.push((1, 1.0));
assert!(read(&entity).is_err());
}
#[test]
fn unsupported_alignment_style_ocs_and_mtext_remain_explicit_errors() {
for mode in ["-1", "3", "4", "5", "0.5"] {
assert!(read(&parsed(&FIELDS.replacen("72\n1\n", &format!("72\n{mode}\n"), 1))).is_err());
}
for extra in [
"7\nSTANDARD\n",
"7\nCustom\n",
"41\n2\n",
"51\n15\n",
"71\n2\n",
"73\n1\n",
"30\n1\n",
"31\n1\n",
"39\n1\n",
"210\n1\n",
"220\n1\n",
"230\n-1\n",
"3\nextra\n",
"6\nDASHED\n",
"62\n1\n",
"60\n1\n",
"67\n1\n",
"999\ncomment\n",
] {
assert!(read(&parsed(&format!("{FIELDS}{extra}"))).is_err(), "{extra}");
}
let mut entity = basic("A");
entity.kind = "MTEXT".into();
assert!(read(&entity).unwrap_err().to_string().contains("MTEXT attachment/wrapping"));
}
#[test]
fn nonrepresentable_literals_are_refused_after_exactly_one_decode() {
for value in ["%", "%%%", "\\U+0025\\U+0025d", "\\U+005CU+0041", "\\P", "a\tb", "中𠀋", "\\U+D800", "\\U+0000", "%%q"] {
assert!(read(&basic(value)).is_err(), "{value}");
}
assert!(read(&basic(&"中".repeat(250))).is_ok());
assert!(read(&basic(&"A".repeat(251))).is_err());
assert!(read(&basic(&"中".repeat(251))).is_err());
}
#[test]
fn invalid_geometry_scale_and_underflow_refuse() {
for k in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MAX] {
assert!(read_text_entity(&basic("A"), k, 1, 1, &mut 0, &mut 0).is_err());
}
for (code, value) in [(10, f64::NAN), (11, f64::INFINITY), (21, f64::NAN), (40, 0.0), (40, -1.0), (40, f64::NAN), (50, f64::NAN)] {
let mut entity = basic("A");
entity.f.retain(|(field, _)| *field != code);
entity.f.push((code, value));
assert!(read(&entity).is_err());
}
for fields in [FIELDS.replacen("11\n1\n", "11\n1e-300\n", 1), FIELDS.replacen("40\n3\n", "40\n1e-300\n", 1)] {
assert!(read_text_entity(&parsed(&fields), 1e-100, 1, 1, &mut 0, &mut 0).is_err());
}
}
#[test]
fn inclusive_decoder_caps_measure_utf8_bytes() {
let entity = basic("\\U+FFFF");
assert!(read_text_entity(&entity, 1.0, 7, 3, &mut 0, &mut 0).is_ok());
assert!(read_text_entity(&entity, 1.0, 6, 3, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&entity, 1.0, 7, 2, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&basic("中"), 1.0, 2, 3, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&basic(""), 1.0, 0, 0, &mut 0, &mut 0).is_ok());
assert!(read_text_entity(&basic("A"), 1.0, MAX_DRAWING_BYTES + 1, 1, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&basic("A"), 1.0, 1, MAX_DRAWING_BYTES + 1, &mut 0, &mut 0).is_err());
}
#[test]
fn shared_counters_remain_cumulative_and_limit_before_decode_allocation() {
let entity = basic("A");
let mut bytes = MAX_DRAWING_BYTES - 2;
let mut work = MAX_WORK - 4;
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut bytes, &mut work).is_ok());
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut bytes, &mut work).is_ok());
assert_eq!((bytes, work), (MAX_DRAWING_BYTES, MAX_WORK));
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut bytes, &mut work).is_err());
let mut full_bytes = MAX_DRAWING_BYTES;
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut full_bytes, &mut 0).is_err());
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut 0, &mut (MAX_WORK - 1)).is_err());
let mut invalid_bytes = usize::MAX;
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut invalid_bytes, &mut 0).is_err());
let mut invalid_work = usize::MAX;
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut 0, &mut invalid_work).is_err());
}
#[test]
fn parent_lexical_preflight_must_precede_float_field_conversion() {
for extra in ["71\n1e-400\n", "73\n0.0\n"] {
let input = format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n{FIELDS}{extra}0\nENDSEC\n0\nEOF\n");
assert!(super::super::layered_array_integer_fields(&super::super::pairs(&input), 1).is_err());
}
let fields = FIELDS.replacen("72\n1\n", "72\n1.0000000000000000001\n", 1);
let input = format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n{fields}0\nENDSEC\n0\nEOF\n");
assert!(super::super::layered_array_integer_fields(&super::super::pairs(&input), 1).is_err());
}
#[test]
fn nine_literal_mode_pairs_select_the_actual_anchor_and_preserve_legacy_text() {
use DxfTextHorizontalAlignment::{Centre, Left, Right};
use DxfTextVerticalAlignment::{Baseline, Middle, Top};
for (h, v, horizontal, vertical) in [
(0, 0, Left, Baseline),
(1, 0, Centre, Baseline),
(2, 0, Right, Baseline),
(0, 2, Left, Middle),
(1, 2, Centre, Middle),
(2, 2, Right, Middle),
(0, 3, Left, Top),
(1, 3, Centre, Top),
(2, 3, Right, Top),
] {
let fields = FIELDS.replacen("72\n1\n", &format!("72\n{h}\n73\n{v}\n50\n-90\n"), 1);
let shape = read_text_entity(&parsed(&fields), 2.0, 1, 1, &mut 0, &mut 0).unwrap();
let at = if h == 0 && v == 0 { Vec2::new(198.0, 176.0) } else { Vec2::new(2.0, 4.0) };
let expected = if h == 1 && v == 0 {
DxfShape::Text { at, height: 6.0, angle_deg: -90.0, text: "A".into() }
} else {
DxfShape::AlignedText { at, height: 6.0, angle_deg: -90.0, text: "A".into(), horizontal, vertical }
};
assert_eq!(shape, expected, "72={h},73={v}");
}
}
#[test]
fn default_left_baseline_uses_first_point_without_requiring_second_point() {
let fields = FIELDS.replacen("72\n1\n", "", 1).replacen("11\n1\n", "", 1).replacen("21\n2\n", "", 1);
assert_eq!(
read(&parsed(&fields)).unwrap(),
DxfShape::AlignedText {
at: Vec2::new(99.0, 88.0),
height: 3.0,
angle_deg: 0.0,
text: "A".into(),
horizontal: DxfTextHorizontalAlignment::Left,
vertical: DxfTextVerticalAlignment::Baseline,
}
);
// Though irrelevant for placement, a present malformed/duplicate point
// cannot be used to hide corrupted input in the default mode.
for extra in ["11\nbad\n", "21\nNaN\n", "11\n1\n11\n1\n"] {
assert!(read(&parsed(&format!("{fields}{extra}"))).is_err());
}
}
#[test]
fn every_other_mode_requires_second_anchor_and_never_falls_back_to_first() {
for (h, v) in [(1, 0), (2, 0), (0, 2), (1, 2), (2, 2), (0, 3), (1, 3), (2, 3)] {
let fields = FIELDS.replacen("72\n1\n", &format!("72\n{h}\n73\n{v}\n"), 1);
for removed in ["11\n1\n", "21\n2\n"] {
assert!(read(&parsed(&fields.replacen(removed, "", 1))).is_err());
}
}
for invalid in ["73\n-1\n", "73\n1\n", "73\n4\n", "73\n0.5\n", "73\n2\n73\n2\n"] {
assert!(read(&parsed(&format!("{FIELDS}{invalid}"))).is_err());
}
}
#[test]
fn two_hundred_fifty_cif_bmp_chars_need_1750_encoded_and_750_decoded_bytes() {
let encoded = "\\U+4E2D".repeat(250);
let expected = "中".repeat(250);
assert_eq!(encoded.len(), 1750);
assert_eq!(expected.len(), 750);
let entity = basic(&encoded);
let (mut bytes, mut work) = (0, 0);
assert_eq!(
read_text_entity(&entity, 1.0, 1750, 750, &mut bytes, &mut work).unwrap(),
DxfShape::Text { at: Vec2::new(1.0, 2.0), height: 3.0, angle_deg: 0.0, text: expected }
);
assert_eq!((bytes, work), (750, 2500));
let (mut bytes, mut work) = (0, 0);
assert!(read_text_entity(&entity, 1.0, 1749, 750, &mut bytes, &mut work).is_err());
assert_eq!((bytes, work), (0, 0), "encoded admission precedes decoding");
assert!(read_text_entity(&entity, 1.0, 1750, 749, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&entity, 1.0, 1750, 750, &mut (MAX_DRAWING_BYTES - 749), &mut 0).is_err());
assert!(read_text_entity(&entity, 1.0, 1750, 750, &mut 0, &mut (MAX_WORK - 2499)).is_err());
assert!(read(&basic(&"\\U+4E2D".repeat(251))).is_err());
assert!(read(&basic(&"A".repeat(251))).is_err());
}
}
+101
View File
@@ -0,0 +1,101 @@
//! TEXT-only value decoding; no MTEXT helper or registration substitute.
//! Caller supplies raw group1 and encoded/decoded byte caps. Output is not rescanned.
use crate::{IoError, Result};
fn invalid(reason: impl std::fmt::Display) -> IoError {
IoError::Invalid(format!("DXF text value: {reason}"))
}
fn push(out: &mut String, ch: char, max_bytes: usize) -> Result<()> {
let needed = out.len().checked_add(ch.len_utf8()).ok_or_else(|| invalid("decoded size overflow"))?;
if needed > max_bytes {
return Err(invalid("decoded byte budget exceeded"));
}
out.try_reserve(ch.len_utf8()).map_err(|error| invalid(format!("cannot reserve decoded text: {error}")))?;
out.push(ch);
Ok(())
}
fn cif(chars: &mut impl Iterator<Item = char>) -> Result<char> {
if chars.next() != Some('+') {
return Err(invalid("malformed CIF Unicode prefix"));
}
let mut code = 0_u32;
for _ in 0..4 {
let digit = chars.next().and_then(|ch| ch.to_digit(16)).ok_or_else(|| invalid("CIF Unicode escape requires four hexadecimal digits"))?;
code = code * 16 + digit;
}
let ch = char::from_u32(code).ok_or_else(|| invalid("CIF Unicode escape is not a scalar value; surrogate pairs are unsupported"))?;
if ch.is_control() {
return Err(invalid("not supported yet: a control character encoded as CIF"));
}
Ok(ch)
}
fn percent(chars: &mut impl Iterator<Item = char>) -> Result<char> {
match chars.next() {
Some('%') => Ok('%'),
Some('d') => Ok('°'),
Some('p') => Ok('±'),
Some('c') => Ok('\u{2205}'),
_ => Err(invalid("not supported yet: percent formatting code")),
}
}
pub(super) fn decode_text_value(value: &str, max_encoded_bytes: usize, max_decoded_bytes: usize) -> Result<String> {
if value.len() > max_encoded_bytes {
return Err(invalid("encoded byte budget exceeded"));
}
let mut out = String::new();
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
let decoded = match ch {
c if c.is_control() => return Err(invalid("not supported yet: raw text control character")),
'\\' if chars.peek() == Some(&'U') => {
chars.next();
cif(&mut chars)?
}
'%' if chars.peek() == Some(&'%') => {
chars.next();
percent(&mut chars)?
}
c => c,
};
push(&mut out, decoded, max_decoded_bytes)?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::decode_text_value;
#[test]
fn literals_percent_cif_and_decode_once_retain_text_semantics() {
assert_eq!(decode_text_value("%%% %%d %%p %%c \\U+FFFF", 4096, 4096).unwrap(), "% ° ± \u{2205} \u{ffff}");
assert_eq!(decode_text_value(" \\U+0025\\U+0025d \\U+005CU+0041 \\P ", 4096, 4096).unwrap(), " %%d \\U+0041 \\P ");
assert_eq!(decode_text_value("trailing\\", 4096, 4096).unwrap(), "trailing\\");
}
#[test]
fn malformed_styles_surrogates_and_controls_still_refuse() {
for value in [
"\\U+", "\\U+XYZ0", "\\U+123", "\\U0041", "\\U+D800", "\\U+DC00", "%%", "%%q", "%%u", "%%123", "a\rb", "a\nb", "a\tb", "\\U+0000",
"\\U+000A", "\\U+0085",
] {
assert!(decode_text_value(value, 4096, 4096).is_err(), "{value}");
}
}
#[test]
fn encoded_and_decoded_budgets_are_separate_utf8_byte_bounds() {
assert_eq!(decode_text_value("\\U+FFFF", 7, 3).unwrap(), "\u{ffff}");
assert!(decode_text_value("\\U+FFFF", 6, 3).is_err());
assert!(decode_text_value("\\U+FFFF", 7, 2).is_err());
assert_eq!(decode_text_value("", 0, 0).unwrap(), "");
// The general TEXT value decoder still preserves newer Unicode; the
// current writer-compatible entity adapter separately refuses non-BMP.
assert_eq!(decode_text_value("中𠀋", 7, 7).unwrap(), "中𠀋");
}
}
+1490 -33
View File
File diff suppressed because it is too large Load Diff
+902
View File
@@ -0,0 +1,902 @@
//! 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 is initialized by new() or with_layer_zero()
//! and stays immutable after construction. Unicode names remain unsupported.
use std::sync::atomic::{AtomicUsize, Ordering};
use super::DxfLinetype;
use crate::{IoError, Result};
mod bulges;
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)
}
/// Construct a new file with the imported style of its first layer, `0`.
/// This is initialization, not a setter: the style is immutable after construction.
/// Colors use the same indexed range as [`Self::declare_layer`]; a missing
/// physical weight remains absent rather than becoming an explicit zero.
/// Every successful construction mints fresh file-scoped ownership handles.
pub fn with_layer_zero(color: i16, linetype: DxfLinetype, weight: Option<StandardWeight>) -> Result<Self> {
Self::with_layer_zero_limits(color, linetype, weight, MAX_LAYERS, MAX_ENTITIES)
}
fn with_limits(max_layers: usize, max_entities: usize) -> Result<Self> {
Self::with_layer_zero_limits(7, DxfLinetype::Continuous, None, max_layers, max_entities)
}
fn with_layer_zero_limits(
color: i16,
linetype: DxfLinetype,
weight: Option<StandardWeight>,
max_layers: usize,
max_entities: usize,
) -> Result<Self> {
if !(1..=255).contains(&color) {
return Err(invalid("DXF layer color must be an index from 1 to 255"));
}
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, linetype, weight });
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());
}
// Round trips exercise the real writer and layered reader, not group-code test parsers.
use crate::DxfShape;
use solidcraft_geom::Vec2;
fn read_written_file(file: &DxfFile<DxfShape>) -> DxfFile<DxfShape> {
crate::read_dxf_file(&crate::write_dxf_file(file).unwrap()).unwrap()
}
#[test]
fn writer_reader_keep_mixed_layer_declarations_entity_order_and_file_scoped_handles() {
let mut source = DxfFile::new().unwrap();
let cut = source.declare_layer("CUT", 1, DxfLinetype::Continuous, Some(StandardWeight::from_code(50).unwrap())).unwrap();
let hidden = source.declare_layer("HIDDEN", 8, DxfLinetype::Hidden, Some(StandardWeight::from_code(0).unwrap())).unwrap();
source.declare_layer("UNUSED", 3, DxfLinetype::Dashed, None).unwrap();
source.push(cut, DxfShape::Line(Vec2::new(-2.0, 1.5), Vec2::new(3.0, 1.5)), None).unwrap();
source.push(source.default_layer(), DxfShape::Circle { c: Vec2::new(4.0, -1.0), r: 0.5 }, Some(EntityWeight::ByBlock)).unwrap();
source.push(hidden, DxfShape::Arc { c: Vec2::new(-3.0, 2.0), r: 2.0, a0: 350.0, a1: 10.0 }, Some(EntityWeight::ByLayer)).unwrap();
source.push(cut, DxfShape::Line(Vec2::new(7.0, 0.0), Vec2::new(7.0, 4.0)), Some(EntityWeight::Default)).unwrap();
let recovered = read_written_file(&source);
let layers: Vec<_> = recovered
.layers()
.iter()
.map(|layer| (layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)))
.collect();
assert_eq!(
layers,
[
("0", 7, DxfLinetype::Continuous, None),
("CUT", 1, DxfLinetype::Continuous, Some(50)),
("HIDDEN", 8, DxfLinetype::Hidden, Some(0)),
("UNUSED", 3, DxfLinetype::Dashed, None),
]
);
let names: Vec<_> = recovered.entities().iter().map(|entity| recovered.layer(entity.layer()).unwrap().name()).collect();
assert_eq!(names, ["CUT", "0", "HIDDEN", "CUT"]);
assert_eq!(recovered.entities().len(), 4);
assert_eq!(recovered.entities()[0].geometry(), &DxfShape::Line(Vec2::new(-2.0, 1.5), Vec2::new(3.0, 1.5)));
assert_eq!(recovered.entities()[1].geometry(), &DxfShape::Circle { c: Vec2::new(4.0, -1.0), r: 0.5 });
assert_eq!(recovered.entities()[2].geometry(), &DxfShape::Arc { c: Vec2::new(-3.0, 2.0), r: 2.0, a0: 350.0, a1: 10.0 });
assert_eq!(recovered.entities()[3].geometry(), &DxfShape::Line(Vec2::new(7.0, 0.0), Vec2::new(7.0, 4.0)));
assert_eq!(
recovered.entities().iter().map(Entity::weight).collect::<Vec<_>>(),
[None, Some(EntityWeight::ByBlock), Some(EntityWeight::ByLayer), Some(EntityWeight::Default)]
);
// Imported handles belong to the new file; compare resolved metadata, not owner IDs.
assert!(recovered.layer(cut).is_err());
assert!(recovered.layer(source.default_layer()).is_err());
assert!(source.layer(recovered.default_layer()).is_err());
}
#[test]
fn writer_reader_keep_omission_symbols_and_all_physical_weights_in_each_linetype() {
let mut weights = vec![None, Some(EntityWeight::ByLayer), Some(EntityWeight::ByBlock), Some(EntityWeight::Default)];
// Independent explicit public code list: not the model's internal lookup table.
for code in [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211] {
weights.push(Some(EntityWeight::Explicit(StandardWeight::from_code(code).unwrap())));
}
let styles = [
("MEASURED", 1, DxfLinetype::Continuous, Some(50)),
("OMITTED", 3, DxfLinetype::Dashed, None),
("ZERO_WEIGHT", 8, DxfLinetype::Hidden, Some(0)),
];
let mut source = DxfFile::new().unwrap();
for (name, color, linetype, weight) in styles {
let layer = source.declare_layer(name, color, linetype, weight.map(|code| StandardWeight::from_code(code).unwrap())).unwrap();
for weight in &weights {
source.push(layer, DxfShape::Line(Vec2::new(0.5, 0.75), Vec2::new(1.25, -0.5)), *weight).unwrap();
}
}
let recovered = read_written_file(&source);
assert_eq!(recovered.layers().len(), 4);
assert_eq!(recovered.entities().len(), 84);
for (style_index, (name, color, linetype, layer_weight)) in styles.iter().enumerate() {
for (weight_index, expected_weight) in weights.iter().enumerate() {
let index = style_index * 28 + weight_index;
let entity = &recovered.entities()[index];
let layer = recovered.layer(entity.layer()).unwrap();
assert_eq!(
(layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)),
(*name, *color, *linetype, *layer_weight)
);
assert_eq!(entity.weight(), *expected_weight, "style={name} weight ordinal={weight_index}");
assert_eq!(entity.geometry(), &DxfShape::Line(Vec2::new(0.5, 0.75), Vec2::new(1.25, -0.5)));
}
}
}
#[test]
fn writer_reader_keep_exact_supported_curves_on_all_three_layer_styles() {
for (name, linetype) in [("SOLID", DxfLinetype::Continuous), ("DASH", DxfLinetype::Dashed), ("HIDE", DxfLinetype::Hidden)] {
let mut source = DxfFile::new().unwrap();
let layer = source.declare_layer(name, 5, linetype, None).unwrap();
for shape in [
DxfShape::Line(Vec2::new(-1.25, 2.5), Vec2::new(3.75, -4.0)),
DxfShape::Circle { c: Vec2::new(8.0, -2.0), r: 1.25 },
DxfShape::Arc { c: Vec2::new(2.0, 3.0), r: 0.75, a0: 45.0, a1: 135.0 },
// Open straight chain, with an intentional repeated vertex retained.
DxfShape::Polyline { points: vec![Vec2::new(-2.0, 0.0), Vec2::new(0.0, 1.5), Vec2::new(0.0, 1.5), Vec2::new(4.0, -0.5)] },
] {
source.push(layer, shape, Some(EntityWeight::ByLayer)).unwrap();
}
let recovered = read_written_file(&source);
assert_eq!(recovered.entities().len(), 4);
assert_eq!(recovered.entities()[0].geometry(), &DxfShape::Line(Vec2::new(-1.25, 2.5), Vec2::new(3.75, -4.0)));
assert_eq!(recovered.entities()[1].geometry(), &DxfShape::Circle { c: Vec2::new(8.0, -2.0), r: 1.25 });
assert_eq!(recovered.entities()[2].geometry(), &DxfShape::Arc { c: Vec2::new(2.0, 3.0), r: 0.75, a0: 45.0, a1: 135.0 });
assert_eq!(
recovered.entities()[3].geometry(),
&DxfShape::Polyline { points: vec![Vec2::new(-2.0, 0.0), Vec2::new(0.0, 1.5), Vec2::new(0.0, 1.5), Vec2::new(4.0, -0.5)] }
);
for entity in recovered.entities() {
let layer = recovered.layer(entity.layer()).unwrap();
assert_eq!((layer.name(), layer.linetype()), (name, linetype));
assert_eq!(entity.weight(), Some(EntityWeight::ByLayer));
}
}
}
#[test]
fn writer_reader_keep_degree_nine_nonunit_knots_controls_and_optional_rational_weights() {
for linetype in [DxfLinetype::Continuous, DxfLinetype::Dashed, DxfLinetype::Hidden] {
let mut source = DxfFile::new().unwrap();
let layer = source.declare_layer("CURVE", 2, linetype, Some(StandardWeight::from_code(35).unwrap())).unwrap();
for weights in [None, Some(vec![1.0; 10]), Some(vec![0.5, 1.0, 2.0, 4.0, 0.25, 0.5, 1.0, 2.0, 4.0, 1.0])] {
source
.push(
layer,
DxfShape::Spline {
degree: 9,
knots: vec![-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
ctrl: vec![
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 2.0),
Vec2::new(2.0, 4.0),
Vec2::new(3.0, 8.0),
Vec2::new(4.0, 4.0),
Vec2::new(5.0, 2.0),
Vec2::new(6.0, 1.0),
Vec2::new(7.0, 0.0),
Vec2::new(8.0, -1.0),
Vec2::new(9.0, -2.0),
],
weights,
},
Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap())),
)
.unwrap();
}
let recovered = read_written_file(&source);
assert_eq!(recovered.entities().len(), 3);
for (index, entity) in recovered.entities().iter().enumerate() {
let DxfShape::Spline { degree, knots, ctrl, weights } = entity.geometry() else { panic!("expected degree-nine spline") };
assert_eq!(*degree, 9);
assert_eq!(
ctrl,
&[
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 2.0),
Vec2::new(2.0, 4.0),
Vec2::new(3.0, 8.0),
Vec2::new(4.0, 4.0),
Vec2::new(5.0, 2.0),
Vec2::new(6.0, 1.0),
Vec2::new(7.0, 0.0),
Vec2::new(8.0, -1.0),
Vec2::new(9.0, -2.0)
]
);
assert_eq!(knots, &[-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0]);
match index {
0 => assert_eq!(weights, &None),
1 => assert_eq!(weights.as_deref(), Some([1.0; 10].as_slice())),
2 => assert_eq!(weights.as_deref(), Some([0.5, 1.0, 2.0, 4.0, 0.25, 0.5, 1.0, 2.0, 4.0, 1.0].as_slice())),
_ => panic!("unexpected extra spline"),
}
let layer = recovered.layer(entity.layer()).unwrap();
assert_eq!((layer.name(), layer.linetype(), layer.weight().map(StandardWeight::code)), ("CURVE", linetype, Some(35)));
assert_eq!(entity.weight(), Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap())));
}
}
}
#[test]
fn writer_reader_keep_degree_nine_interior_knots_without_normalizing_the_domain() {
let mut source = DxfFile::new().unwrap();
source
.push(
source.default_layer(),
DxfShape::Spline {
degree: 9,
knots: vec![
-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -0.5, 2.5, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0,
],
ctrl: vec![
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 1.0),
Vec2::new(2.0, 4.0),
Vec2::new(3.0, 1.0),
Vec2::new(4.0, 0.0),
Vec2::new(5.0, -1.0),
Vec2::new(6.0, -2.0),
Vec2::new(7.0, -1.0),
Vec2::new(8.0, 0.0),
Vec2::new(9.0, 1.0),
Vec2::new(10.0, 2.0),
Vec2::new(11.0, 0.0),
],
weights: Some(vec![1.0, 0.5, 2.0, 1.0, 0.25, 4.0, 1.0, 2.0, 0.5, 1.0, 4.0, 1.0]),
},
Some(EntityWeight::Default),
)
.unwrap();
let recovered = read_written_file(&source);
assert_eq!(recovered.entities().len(), 1);
let entity = &recovered.entities()[0];
let DxfShape::Spline { degree, knots, ctrl, weights } = entity.geometry() else { panic!("expected degree-nine spline") };
assert_eq!(*degree, 9);
assert_eq!(knots, &[-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -0.5, 2.5, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0]);
assert_eq!(
ctrl,
&[
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 1.0),
Vec2::new(2.0, 4.0),
Vec2::new(3.0, 1.0),
Vec2::new(4.0, 0.0),
Vec2::new(5.0, -1.0),
Vec2::new(6.0, -2.0),
Vec2::new(7.0, -1.0),
Vec2::new(8.0, 0.0),
Vec2::new(9.0, 1.0),
Vec2::new(10.0, 2.0),
Vec2::new(11.0, 0.0)
]
);
assert_eq!(weights.as_deref(), Some([1.0, 0.5, 2.0, 1.0, 0.25, 4.0, 1.0, 2.0, 0.5, 1.0, 4.0, 1.0].as_slice()));
assert_eq!(recovered.layer(entity.layer()).unwrap().name(), "0");
assert_eq!(entity.weight(), Some(EntityWeight::Default));
}
// DEPENDENT CASE ONLY: apply after W1's real DxfShape::Point(Vec2) and writer
// support plus Kernel's real POINT read_dxf_file branch are integrated.
// No fake variant/API, ignore attribute or active-source compile claim.
#[test]
fn writer_reader_keep_point_geometry_layer_and_stored_weight() {
let mut source = DxfFile::new().unwrap();
let points = source.declare_layer("DATUM", 4, DxfLinetype::Hidden, Some(StandardWeight::from_code(50).unwrap())).unwrap();
source.push(points, DxfShape::Point(Vec2::new(-0.5, 1.25)), Some(EntityWeight::ByBlock)).unwrap();
source.push(source.default_layer(), DxfShape::Point(Vec2::new(8.0, -2.0)), None).unwrap();
let recovered = read_written_file(&source);
assert_eq!(recovered.entities().len(), 2);
assert_eq!(recovered.entities()[0].geometry(), &DxfShape::Point(Vec2::new(-0.5, 1.25)));
assert_eq!(recovered.entities()[1].geometry(), &DxfShape::Point(Vec2::new(8.0, -2.0)));
let layer = recovered.layer(recovered.entities()[0].layer()).unwrap();
assert_eq!(
(layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)),
("DATUM", 4, DxfLinetype::Hidden, Some(50))
);
assert_eq!(recovered.entities()[0].weight(), Some(EntityWeight::ByBlock));
assert_eq!(recovered.layer(recovered.entities()[1].layer()).unwrap().name(), "0");
assert_eq!(recovered.entities()[1].weight(), None);
assert!(recovered.layer(points).is_err());
}
#[test]
fn layer_zero_default_constructor_keeps_new_metadata_and_writer_bytes() {
let mut original = DxfFile::new().unwrap();
let mut imported = DxfFile::with_layer_zero(7, DxfLinetype::Continuous, None).unwrap();
for file in [&original, &imported] {
assert_eq!(file.layers().len(), 1);
assert!(file.entities().is_empty());
let zero = file.layer(file.default_layer()).unwrap();
assert_eq!((zero.name(), zero.color(), zero.linetype(), zero.weight()), ("0", 7, DxfLinetype::Continuous, None));
}
assert_eq!(crate::write_dxf_file(&original).unwrap(), crate::write_dxf_file(&imported).unwrap());
original.push(original.default_layer(), DxfShape::Line(Vec2::new(-1.0, 0.5), Vec2::new(2.0, 0.5)), None).unwrap();
imported.push(imported.default_layer(), DxfShape::Line(Vec2::new(-1.0, 0.5), Vec2::new(2.0, 0.5)), None).unwrap();
assert_eq!(original.effective_weight(0).unwrap(), EntityWeight::Default);
assert_eq!(imported.effective_weight(0).unwrap(), EntityWeight::Default);
assert_eq!(crate::write_dxf_file(&original).unwrap(), crate::write_dxf_file(&imported).unwrap());
assert!(original.layer(imported.default_layer()).is_err());
assert!(imported.layer(original.default_layer()).is_err());
}
#[test]
fn imported_layer_zero_is_first_and_immutable_through_every_zero_alias() {
let zero_weight = Some(StandardWeight::from_code(0).unwrap());
let mut file = DxfFile::with_layer_zero(3, DxfLinetype::Hidden, zero_weight).unwrap();
let zero = file.default_layer();
assert!(file.entities().is_empty());
let cut = file.declare_layer("CUT", 1, DxfLinetype::Dashed, None).unwrap();
file.push(zero, (), None).unwrap();
for alias in ["0", " 0 ", "\t0\r\n", "", " \t\r\n\x0b\x0c "] {
assert_eq!(file.declare_layer(alias, 3, DxfLinetype::Hidden, zero_weight).unwrap(), zero);
assert!(file.declare_layer(alias, 7, DxfLinetype::Hidden, zero_weight).is_err());
assert!(file.declare_layer(alias, 3, DxfLinetype::Continuous, zero_weight).is_err());
assert!(file.declare_layer(alias, 3, DxfLinetype::Hidden, None).is_err());
}
assert_eq!(file.layers().iter().map(Layer::name).collect::<Vec<_>>(), ["0", "CUT"]);
let layer = file.layer(zero).unwrap();
assert_eq!((layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)), (3, DxfLinetype::Hidden, Some(0)));
assert_eq!(file.layer(cut).unwrap().name(), "CUT");
assert_eq!(file.entities().len(), 1);
assert_eq!(file.entities()[0].layer(), zero);
}
#[test]
fn imported_zero_inheritance_keeps_omission_symbols_and_physical_zero_distinct() {
for code in [None, Some(0), Some(50)] {
let layer_weight = code.map(|value| StandardWeight::from_code(value).unwrap());
let mut file = DxfFile::with_layer_zero(5, DxfLinetype::Dashed, layer_weight).unwrap();
for weight in [
None,
Some(EntityWeight::ByLayer),
Some(EntityWeight::ByBlock),
Some(EntityWeight::Default),
Some(EntityWeight::Explicit(StandardWeight::from_code(13).unwrap())),
] {
file.push(file.default_layer(), (), weight).unwrap();
}
let inherited = match code {
None => EntityWeight::Default,
Some(0) => EntityWeight::Explicit(StandardWeight::from_code(0).unwrap()),
Some(50) => EntityWeight::Explicit(StandardWeight::from_code(50).unwrap()),
_ => panic!("unexpected fixture weight"),
};
assert_eq!(file.layer(file.default_layer()).unwrap().weight().map(StandardWeight::code), code);
assert_eq!(file.entities()[0].weight(), None);
assert_eq!(file.entities()[1].weight(), Some(EntityWeight::ByLayer));
assert_eq!(file.effective_weight(0).unwrap(), inherited);
assert_eq!(file.effective_weight(1).unwrap(), inherited);
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(13).unwrap()));
assert_eq!(file.effective_linetype(0).unwrap(), DxfLinetype::Dashed);
}
}
#[test]
fn imported_layer_zero_uses_validated_colors_and_standard_weight_types() {
for color in [i16::MIN, -1, 0, 256, i16::MAX] {
let error = DxfFile::<()>::with_layer_zero(color, DxfLinetype::Continuous, None).unwrap_err();
assert!(error.to_string().contains("color must be an index from 1 to 255"));
}
for color in [1, 255] {
let file = DxfFile::<()>::with_layer_zero(color, DxfLinetype::Hidden, Some(StandardWeight::from_code(211).unwrap())).unwrap();
let zero = file.layer(file.default_layer()).unwrap();
assert_eq!((zero.color(), zero.linetype(), zero.weight().map(StandardWeight::code)), (color, DxfLinetype::Hidden, Some(211)));
}
assert!(StandardWeight::from_code(6).is_err());
}
#[test]
fn imported_zero_handles_are_fresh_and_foreign_insertion_is_atomic() {
let first = DxfFile::<()>::with_layer_zero(1, DxfLinetype::Dashed, None).unwrap();
let mut second = DxfFile::with_layer_zero(1, DxfLinetype::Dashed, None).unwrap();
let mut ordinary = DxfFile::new().unwrap();
assert!(second.push(first.default_layer(), (), None).is_err());
assert!(ordinary.push(second.default_layer(), (), None).is_err());
assert!(second.push(ordinary.default_layer(), (), None).is_err());
assert!(second.entities().is_empty());
assert!(ordinary.entities().is_empty());
second.push(second.default_layer(), (), None).unwrap();
assert_eq!(second.entities().len(), 1);
assert_eq!(second.layers().len(), 1);
assert_eq!(second.layer(second.entities()[0].layer()).unwrap().name(), "0");
}
#[test]
fn imported_zero_constructor_keeps_fallible_resource_limits() {
assert!(DxfFile::<()>::with_layer_zero_limits(1, DxfLinetype::Dashed, None, 0, 1).is_err());
let mut empty = DxfFile::with_layer_zero_limits(1, DxfLinetype::Dashed, None, 1, 0).unwrap();
assert!(empty.push(empty.default_layer(), (), None).is_err());
assert!(empty.entities().is_empty());
let mut file = DxfFile::with_layer_zero_limits(2, DxfLinetype::Hidden, None, 2, 1).unwrap();
file.declare_layer("CUT", 1, DxfLinetype::Continuous, None).unwrap();
assert!(file.declare_layer("EXCESS", 1, 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(), file.entities().len()), (2, 1));
let bounded = DxfFile::<()>::with_layer_zero_limits(7, DxfLinetype::Continuous, None, usize::MAX, usize::MAX).unwrap();
assert_eq!((bounded.max_layers, bounded.max_entities), (1024, 1_000_000));
}
}
+346
View File
@@ -0,0 +1,346 @@
//! Bounded admission and conservative bounds for the real X1 BulgedPolyline.
//! Bounds contain each whole supporting circle, not only the directed arc.
//! No trig/hypot, sampling, output array, topology repair or physical stroke width.
use solidcraft_geom::Vec2;
use super::super::{DxfVertex, path::MAX_PATH_ITEMS};
use super::DxfGeometryBounds;
use crate::{IoError, Result};
const MAX_WORK: usize = 64 << 20;
const EDGE_WORK: usize = 512;
fn invalid(message: &str) -> IoError {
IoError::Invalid(format!("DXF bulged polyline bounds: {message}"))
}
// Intervals enclose REAL arithmetic on the supplied binary64 endpoints/bulge.
// Nonzero subnormal inputs/intermediates and infinite outward endpoints refuse.
// This deliberately conservative admission is not a tolerance or ULP-accuracy
// promise for any transcendental function or the old reconstructed Arc sampler.
#[derive(Clone, Copy)]
struct Interval {
lo: f64,
hi: f64,
}
impl Interval {
fn checked(lo: f64, hi: f64) -> Result<Self> {
if lo > hi || !lo.is_finite() || !hi.is_finite() || (lo != 0.0 && !lo.is_normal()) || (hi != 0.0 && !hi.is_normal()) {
return Err(invalid("nonfinite or subnormal support interval is not representable"));
}
Ok(Self { lo, hi })
}
fn exact(value: f64) -> Result<Self> {
Self::checked(value, value)
}
fn rounded(value: f64) -> Result<Self> {
if value == 0.0 || !value.is_normal() {
return Err(invalid("support arithmetic overflow or underflow"));
}
Self::checked(value.next_down(), value.next_up())
}
fn neg(self) -> Self {
Self { lo: -self.hi, hi: -self.lo }
}
fn sum(a: f64, b: f64) -> Result<Self> {
if a == 0.0 {
return Self::exact(b);
}
if b == 0.0 {
return Self::exact(a);
}
if a == -b {
return Self::exact(0.0);
}
Self::rounded(a + b)
}
fn product(a: f64, b: f64) -> Result<Self> {
if a == 0.0 || b == 0.0 {
return Self::exact(0.0);
}
if a.abs() == 1.0 {
return Self::exact(if a < 0.0 { -b } else { b });
}
if b.abs() == 1.0 {
return Self::exact(if b < 0.0 { -a } else { a });
}
Self::rounded(a * b)
}
fn quotient(a: f64, b: f64) -> Result<Self> {
if b == 0.0 {
return Err(invalid("support divisor contains zero"));
}
if a == 0.0 {
return Self::exact(0.0);
}
if b.abs() == 1.0 {
return Self::exact(if b < 0.0 { -a } else { a });
}
Self::rounded(a / b)
}
fn hull(self, other: Self) -> Result<Self> {
Self::checked(self.lo.min(other.lo), self.hi.max(other.hi))
}
fn add(self, rhs: Self) -> Result<Self> {
let lo = Self::sum(self.lo, rhs.lo)?.lo;
let hi = Self::sum(self.hi, rhs.hi)?.hi;
Self::checked(lo, hi)
}
fn sub(self, rhs: Self) -> Result<Self> {
self.add(rhs.neg())
}
fn mul(self, rhs: Self) -> Result<Self> {
Self::product(self.lo, rhs.lo)?
.hull(Self::product(self.lo, rhs.hi)?)?
.hull(Self::product(self.hi, rhs.lo)?)?
.hull(Self::product(self.hi, rhs.hi)?)
}
fn div(self, rhs: Self) -> Result<Self> {
if rhs.lo <= 0.0 && rhs.hi >= 0.0 {
return Err(invalid("support divisor contains zero"));
}
Self::quotient(self.lo, rhs.lo)?
.hull(Self::quotient(self.lo, rhs.hi)?)?
.hull(Self::quotient(self.hi, rhs.lo)?)?
.hull(Self::quotient(self.hi, rhs.hi)?)
}
fn abs(self) -> Result<Self> {
let hi = self.lo.abs().max(self.hi.abs());
let lo = if self.lo <= 0.0 && self.hi >= 0.0 { 0.0 } else { self.lo.abs().min(self.hi.abs()) };
Self::checked(lo, hi)
}
}
fn supporting_arc(from: DxfVertex, to: Vec2) -> Result<DxfGeometryBounds> {
let (x0, y0) = (Interval::exact(from.at.x)?, Interval::exact(from.at.y)?);
let (x1, y1) = (Interval::exact(to.x)?, Interval::exact(to.y)?);
let b = Interval::exact(from.bulge)?;
let b2 = b.mul(b)?;
let one = Interval::exact(1.0)?;
let two = Interval::exact(2.0)?;
let four = Interval::exact(4.0)?;
let (dx, dy) = (x1.sub(x0)?, y1.sub(y0)?);
// Centre = midpoint + perp(chord/(4b)) * (1-b²).
// Dividing the chord components first avoids an unbounded standalone1/b.
let divisor = four.mul(b)?;
let (qx, qy) = (dx.div(divisor)?, dy.div(divisor)?);
let factor = one.sub(b2)?;
let cx = x0.div(two)?.add(x1.div(two)?)?.sub(qy.mul(factor)?)?;
let cy = y0.div(two)?.add(y1.div(two)?)?.add(qx.mul(factor)?)?;
// r = ||chord/(4b)||2 * (1+b²) <= its L1 analogue.
// All terms are enclosed before selecting the upper radius, so no assumed
// libm rounding bound or single-final-ULP shortcut is hidden here.
let radius = qx.abs()?.add(qy.abs()?)?.mul(one.add(b2)?)?.hi;
if radius <= 0.0 {
return Err(invalid("support radius has collapsed"));
}
let radius = Interval::exact(radius)?;
let x = cx.sub(radius)?.hull(cx.add(radius)?)?;
let y = cy.sub(radius)?.hull(cy.add(radius)?)?;
DxfGeometryBounds::new(Vec2::new(x.lo, y.lo), Vec2::new(x.hi, y.hi))
}
fn edge(from: DxfVertex, to: Vec2) -> Result<DxfGeometryBounds> {
if from.at == to && from.bulge != 0.0 {
return Err(invalid("zero chord cannot carry a nonzero bulge"));
}
let endpoints = DxfGeometryBounds::new(Vec2::new(from.at.x.min(to.x), from.at.y.min(to.y)), Vec2::new(from.at.x.max(to.x), from.at.y.max(to.y)))?;
if from.bulge == 0.0 {
return Ok(endpoints);
}
let support = supporting_arc(from, to)?;
// Include stored endpoints explicitly even for an extremely wide support
// interval; neither a support reconstruction nor a hull may discard them.
Ok(union(endpoints, support))
}
fn union(a: DxfGeometryBounds, b: DxfGeometryBounds) -> DxfGeometryBounds {
DxfGeometryBounds { min: Vec2::new(a.min.x.min(b.min.x), a.min.y.min(b.min.y)), max: Vec2::new(a.max.x.max(b.max.x), a.max.y.max(b.max.y)) }
}
/// New internal proposal: validate/bound the real X1 vertices, without copying.
/// max_vertices/max_work may tighten hard limits only. Work is len +512*edges,
/// checked BEFORE scanning values. Caller must charge that same deterministic
/// cost cumulatively across the file; passing a fresh per-entity cap is not a
/// whole-file budget. No hidden caller or model/variant adoption is assumed.
pub(super) fn bounds(vertices: &[DxfVertex], closed: bool, max_vertices: usize, max_work: usize) -> Result<DxfGeometryBounds> {
let count = vertices.len();
if !(2..=max_vertices.min(MAX_PATH_ITEMS)).contains(&count) {
return Err(invalid("vertex count exceeds admission or needs at least two vertices"));
}
let edges = if closed { count } else { count - 1 };
let work = edges.checked_mul(EDGE_WORK).and_then(|cost| cost.checked_add(count)).ok_or_else(|| invalid("work count overflow"))?;
if work > max_work.min(MAX_WORK) {
return Err(invalid("work budget exceeded before vertex scan"));
}
if vertices.iter().any(|v| !v.at.is_finite() || !v.bulge.is_finite() || v.bulge.abs() > 1.0) {
return Err(invalid("nonfinite vertex/bulge or more than a half turn"));
}
if !closed && vertices.last().is_some_and(|v| v.bulge != 0.0) {
return Err(invalid("open terminal bulge must be zero"));
}
// Zero-bulge coincident points are valid authored LINE geometry, but must
// not silently admit the duplicated terminal vertex of a closed polyline.
if closed && vertices.first().zip(vertices.last()).is_some_and(|(first, last)| first.at == last.at) {
return Err(invalid("closed polyline must not duplicate its first vertex at the seam"));
}
let mut pairs = vertices.iter().zip(vertices.iter().skip(1));
let (first, second) = pairs.next().ok_or_else(|| invalid("missing first segment"))?;
let mut total = edge(*first, second.at)?;
for (from, to) in pairs {
total = union(total, edge(*from, to.at)?);
}
if closed {
let (last, first) = vertices.last().zip(vertices.first()).ok_or_else(|| invalid("missing closed seam"))?;
total = union(total, edge(*last, first.at)?);
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
fn v(x: f64, y: f64, bulge: f64) -> DxfVertex {
DxfVertex { at: Vec2::new(x, y), bulge }
}
fn read(vertices: &[DxfVertex], closed: bool) -> Result<DxfGeometryBounds> {
bounds(vertices, closed, MAX_PATH_ITEMS, MAX_WORK)
}
fn contains(b: DxfGeometryBounds, x: f64, y: f64) -> bool {
b.min.x <= x && x <= b.max.x && b.min.y <= y && y <= b.max.y
}
#[test]
fn lines_use_exact_endpoint_hull_without_arc_arithmetic() {
let b = read(&[v(-2.0, 3.0, 0.0), v(4.0, -1.0, 0.0), v(0.0, 8.0, 0.0)], false).unwrap();
assert_eq!(b, DxfGeometryBounds { min: Vec2::new(-2.0, -1.0), max: Vec2::new(4.0, 8.0) });
assert!(read(&[v(-f64::MAX, 0.0, 0.0), v(f64::MAX, 0.0, 0.0)], false).is_ok());
}
#[test]
fn signed_semicircles_include_all_four_exact_support_cardinals() {
for bulge in [-1.0, 1.0] {
let b = read(&[v(-1.0, 0.0, bulge), v(1.0, 0.0, 0.0)], false).unwrap();
for (x, y) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)] {
assert!(contains(b, x, y));
}
}
}
#[test]
fn asymmetric_signed_bulges_use_analytic_centre_and_radius_not_writer_output() {
// Chord(0,0)->(4,0), b=1/2: centre(2,3/2), radius5/2.
for (bulge, cy) in [(0.5, 1.5), (-0.5, -1.5)] {
let b = read(&[v(0.0, 0.0, bulge), v(4.0, 0.0, 0.0)], false).unwrap();
for (x, y) in [(-0.5, cy), (4.5, cy), (2.0, cy - 2.5), (2.0, cy + 2.5)] {
assert!(contains(b, x, y));
}
}
// Oblique chord(0,0)->(4,3), b=1/2: centre(7/8,3), r=25/8.
let b = read(&[v(0.0, 0.0, 0.5), v(4.0, 3.0, 0.0)], false).unwrap();
for (x, y) in [(-2.25, 3.0), (4.0, 3.0), (0.875, -0.125), (0.875, 6.125)] {
assert!(contains(b, x, y));
}
}
#[test]
fn closed_last_bulge_is_used_and_duplicate_seam_is_refused() {
let b = read(&[v(-1.0, 0.0, 0.0), v(1.0, 0.0, 1.0)], true).unwrap();
assert!(contains(b, 0.0, 1.0) && contains(b, 0.0, -1.0));
assert!(read(&[v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0)], true).is_err());
assert!(read(&[v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.5)], false).is_err());
}
#[test]
fn nonfinite_or_out_of_domain_values_refuse_without_mutating_inputs() {
for input in [
[v(f64::NAN, 0.0, 0.0), v(1.0, 0.0, 0.0)],
[v(0.0, 0.0, f64::INFINITY), v(1.0, 0.0, 0.0)],
[v(0.0, 0.0, 1.0000000000000002), v(1.0, 0.0, 0.0)],
[v(0.0, 0.0, -1.0000000000000002), v(1.0, 0.0, 0.0)],
[v(0.0, 0.0, 0.0), v(1.0, f64::NEG_INFINITY, 0.0)],
] {
let before = input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits()));
assert!(read(&input, false).is_err());
assert_eq!(input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits())), before);
}
}
#[test]
fn tiny_representable_support_survives_without_squaring_chord() {
let b = read(&[v(0.0, 0.0, 1.0), v(1e-200, 0.0, 0.0)], false).unwrap();
assert!(contains(b, 0.0, 0.0) && contains(b, 1e-200, 0.0));
assert!(b.min.y < 0.0 && b.max.y > 0.0 && b.min.is_finite() && b.max.is_finite());
assert!(read(&[v(0.0, 0.0, 1.0), v(f64::from_bits(1), 0.0, 0.0)], false).is_err());
assert!(read(&[v(0.0, 0.0, 1e-300), v(1.0, 0.0, 0.0)], false).is_err());
}
#[test]
fn overflow_or_outward_infinite_support_is_explicitly_refused() {
assert!(read(&[v(-f64::MAX, 0.0, 1.0), v(f64::MAX, 0.0, 0.0)], false).is_err());
assert!(read(&[v(f64::MAX, 0.0, 0.5), v(f64::MAX, 1e308, 0.0)], false).is_err());
}
#[test]
fn vertex_and_work_admission_precede_payload_scan_and_are_inclusive() {
let input = [v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0)];
assert!(bounds(&input, false, 2, 514).is_ok());
assert!(bounds(&input, false, 1, 514).is_err());
assert!(bounds(&input, false, 2, 513).is_err());
assert!(bounds(&input, true, 2, 1026).is_ok());
assert!(bounds(&input, true, 2, 1025).is_err());
let hostile = [v(f64::NAN, 0.0, 0.0), v(1.0, 0.0, 0.0)];
assert!(bounds(&hostile, false, 2, 513).unwrap_err().to_string().contains("work budget"));
assert!(bounds(&hostile, false, 1, usize::MAX).unwrap_err().to_string().contains("vertex count"));
assert!(bounds(&[], false, usize::MAX, usize::MAX).is_err());
assert!(bounds(&input[..1], false, usize::MAX, usize::MAX).is_err());
}
#[test]
fn authored_zero_length_line_keeps_exact_point_bounds_and_input_bits() {
// Exact migrated former Err fixture, now SUPPORTED to match X1's
// original polylines() zero-length LINE -> open two-vertex entity.
let original = [v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0)];
assert_eq!(read(&original, false).unwrap(), DxfGeometryBounds { min: Vec2::ZERO, max: Vec2::ZERO });
for point in [Vec2::new(3.0, -4.0), Vec2::new(f64::from_bits(1), 0.0), Vec2::new(f64::MAX, -f64::MAX)] {
let input = [DxfVertex { at: point, bulge: 0.0 }, DxfVertex { at: point, bulge: 0.0 }];
let before = input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits()));
assert_eq!(read(&input, false).unwrap(), DxfGeometryBounds { min: point, max: point });
assert_eq!(input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits())), before);
}
let mixed = [v(2.0, 3.0, 0.0), v(2.0, 3.0, 0.0), v(5.0, 7.0, 0.0)];
assert_eq!(read(&mixed, false).unwrap(), DxfGeometryBounds { min: Vec2::new(2.0, 3.0), max: Vec2::new(5.0, 7.0) });
}
#[test]
fn zero_chord_arc_and_explicit_closed_duplicate_seam_still_refuse() {
for bulge in [-1.0, -0.5, 0.5, 1.0] {
assert!(read(&[v(2.0, 3.0, bulge), v(2.0, 3.0, 0.0)], false).is_err());
}
for input in [
vec![v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0)],
vec![v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0)],
vec![v(0.0, 0.0, 1.0), v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0)],
] {
assert!(read(&input, true).unwrap_err().to_string().contains("closed polyline must not duplicate"));
}
let nonfinite = [v(f64::INFINITY, 0.0, 0.0), v(f64::INFINITY, 0.0, 0.0)];
assert!(read(&nonfinite, false).is_err());
assert!(bounds(&[v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0)], false, 2, 513).is_err());
}
}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
//! Minimal X1 bulged-polyline support; path conversion/fitting is not ported.
use solidcraft_geom::{Seg2, Vec2};
/// A polyline vertex. `bulge` describes the edge leaving `at`: `tan(sweep / 4)`.
/// The terminal vertex of an open polyline has zero bulge.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DxfVertex {
pub at: Vec2,
pub bulge: f64,
}
pub(super) const MAX_PATH_ITEMS: usize = 200_000;
fn circle_bounds_finite(c: Vec2, r: f64) -> bool {
c.is_finite() && r.is_finite() && r > 0.0 && (c - Vec2::new(r, r)).is_finite() && (c + Vec2::new(r, r)).is_finite()
}
/// Reconstruct a non-straight polyline edge with a signed sweep for bounds and validation.
/// Avoid squaring the chord length or first forming 1/bulge: those can overflow even when
/// the resulting radius is finite. A zero chord with a nonzero bulge is not an arc.
pub(super) fn bulge_arc(from: DxfVertex, to: Vec2) -> Option<Seg2> {
let b = from.bulge;
if !(from.at.is_finite() && to.is_finite() && b.is_finite()) || b == 0.0 || b.abs() > 1.0 {
return None;
}
let chord = to - from.at;
let length = chord.len();
if !chord.is_finite() || !length.is_finite() || length == 0.0 {
return None;
}
let scaled = (length * 0.25) / b;
let radius = scaled.abs() * (1.0 + b * b);
let center = from.at * 0.5 + to * 0.5 + (chord / length).perp() * (scaled * (1.0 - b * b));
if !circle_bounds_finite(center, radius) {
return None;
}
let radial = from.at - center;
if !radial.is_finite() || radial == Vec2::ZERO {
return None;
}
let start = radial.angle();
let sweep = 4.0 * b.atan();
(start.is_finite() && sweep.is_finite() && sweep != 0.0).then_some(Seg2::Arc { center, radius, start, sweep })
}
+6 -2
View File
@@ -32,8 +32,12 @@ mod threemf;
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::{read_dxf, read_dxf_file, write_dxf};
pub use dxf_out::{
DxfEntity, DxfEntityWeight, DxfFile, DxfFileEntity, DxfFileLayer, DxfGeometryBounds, DxfLayer, DxfLayerId, DxfLinetype, DxfShape,
DxfStandardWeight, DxfTextHorizontalAlignment, DxfTextVerticalAlignment, DxfVertex, loop_shapes, seg_shape, write_dxf_drawing, write_dxf_file,
write_dxf_file_with_text_bounds,
};
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};
+30 -7
View File
@@ -2728,20 +2728,43 @@ pub fn take_theme_paste(app: &mut SolveApp, ctx: &egui::Context) {
/// Files dropped on the theme Import tile are themes to import, not designs to open (#8226).
pub fn take_theme_drops(app: &mut SolveApp, ctx: &egui::Context) {
let Some(rect) = app.prefs_window.import_tile.filter(|_| app.prefs_window.open) else { return };
if !ctx.input(|i| !i.raw.dropped_files.is_empty() && i.pointer.latest_pos().is_some_and(|p| rect.contains(p))) {
if !theme_drop_target(app, ctx) || !ctx.input(|i| !i.raw.dropped_files.is_empty()) {
return;
}
let files = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in files.iter().take(8) {
let text = f
.bytes()
.and_then(|b| if b.len() > crate::theme_files::MAX_BYTES { Err("the file is too large for a theme".into()) } else { Ok(b) })
.and_then(|b| String::from_utf8(b).map_err(|_| "not a theme file".to_string()));
import_theme_text(app, text);
// The browser reads dropped files only asynchronously: the web shell takes them first and
// hands a theme's bytes to `import_theme_bytes` (#8226), so none reach this on wasm.
#[cfg(not(target_arch = "wasm32"))]
match f.bytes() {
Ok(bytes) => import_theme_bytes(app, bytes),
Err(e) => import_theme_text(app, Err(e.to_string())),
}
#[cfg(target_arch = "wasm32")]
let _ = f;
}
}
/// Whether files dropped now land on the Import tile: the Preferences window is open and the
/// pointer was last seen over the tile.
pub fn theme_drop_target(app: &SolveApp, ctx: &egui::Context) -> bool {
app.prefs_window
.import_tile
.filter(|_| app.prefs_window.open)
.is_some_and(|rect| ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p)))
}
/// A file dropped on the Import tile, as bytes: imported when it is a theme, refused with the reason
/// otherwise.
pub fn import_theme_bytes(app: &mut SolveApp, bytes: Vec<u8>) {
let text = if bytes.len() > crate::theme_files::MAX_BYTES {
Err("the file is too large for a theme".to_string())
} else {
String::from_utf8(bytes).map_err(|_| "not a theme file".to_string())
};
import_theme_text(app, text);
}
pub fn import_theme(app: &mut SolveApp, v: &serde_json::Value) -> Result<String, String> {
let theme = match v {
serde_json::Value::String(text) => crate::theme_files::parse(text)?,
+5
View File
@@ -0,0 +1,5 @@
# WIP conditional Drawings owned DXF producer adapters
DO NOT INTEGRATE until the admission policy in #5319 is resolved: https://github.com/tangency-labs/solidcraft/issues/5319#issuecomment-6482559607 . The owned writer and legacy producers have different limits and numeric admission domains. This branch preserves sheet and view stage-B alternatives separately from the connect-only follow-up. No silent fallback or raised limits is authorized. Source review only; no compilation, tests or full CI for this source.
This branch preserves unfinished work at Judit's request. It does not replace the existing contributor claims or PRs, and is not ready for merge. Original parent history and authorship are retained. No tests were run to create this preservation branch.