mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-27 12:27:04 -06:00
- Layer Properties Manager: filter tree (All / All Used Layers) with search, sortable table
(Status, Name, On, Freeze, Lock, Plot, Color, Linetype, Lineweight, Transparency,
New VP Freeze, Description; VP Freeze / VP Color inside an MSPACE viewport), inline
rename and description, ACI + true-colour picker, linetype picker with Load from the
library, lineweight picker, layer states save/restore/delete.
- Engine: qselect rewritten ({type, property, operator =,!=,>,<,*, value, mode new|append|
exclude, applyTo}) + qselect.info; layerstate.list/delete/rename; layer list reports
used layers and states; Layer.vp_freeze_new (DXF flag 2, applied by MVIEW) and
layer.set newVpFreeze; per-viewport layer colours (Viewport.layer_colors, vplayer
colors, honoured by the renderer); vplayer defaults to the active viewport; QPMODE
setting/sysvar/toggle; properties.set length/angle for lines.
- Canvas: constraint bars with code-drawn glyphs and coincident markers, dynamic
dimensional constraints (aligned/horizontal/vertical/radius/diameter/angular) with
name=value labels and lock icon, honouring CONSTRAINTBAR/DCDISPLAY; cached per
drawing revision; double-click a label to edit its expression (parameters).
- Quick Properties palette near the selection (status-bar toggle), Quick Select dialog,
Parameters Manager (edit, add user parameter, delete).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
381 lines
15 KiB
Rust
381 lines
15 KiB
Rust
use crate::raster::{RasterOptions, View, render};
|
|
use crate::*;
|
|
use cadcraft_doc::{Common, Drawing, EntityKind, Hatch, HatchLoop, Space};
|
|
use cadcraft_geom::{PolyVertex, Vec3};
|
|
|
|
fn sample() -> Drawing {
|
|
let mut d = Drawing::new_imperial();
|
|
d.add(&Space::Model, Common::default(), EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) }))
|
|
.unwrap();
|
|
d.add(
|
|
&Space::Model,
|
|
Common { color: Color::Index(1), ..Default::default() },
|
|
EntityKind::Circle(cadcraft_doc::Circle { center: Vec3::new(5.0, 5.0, 0.0), radius: 3.0 }),
|
|
)
|
|
.unwrap();
|
|
d
|
|
}
|
|
|
|
#[test]
|
|
fn builds_lines_and_circles() {
|
|
let d = sample();
|
|
let l = build(&d, &Space::Model, &Options { tolerance: 0.01, ..Default::default() });
|
|
assert_eq!(l.prims.len(), 2);
|
|
assert!(l.prims[1].len > 20);
|
|
assert_eq!(l.prims[1].color, Rgb(255, 0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn linetype_dashes_line() {
|
|
let mut d = sample();
|
|
d.linetypes.extend(cadcraft_doc::library::standard_linetypes());
|
|
d.layer_mut("0").unwrap().linetype = "DASHED".into();
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
assert!(l.prims.len() > 10);
|
|
}
|
|
|
|
#[test]
|
|
fn frozen_layer_hidden() {
|
|
let mut d = sample();
|
|
d.layer_mut("0").unwrap().frozen = true;
|
|
assert!(build(&d, &Space::Model, &Options::default()).prims.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn text_produces_strokes() {
|
|
let mut d = Drawing::new_imperial();
|
|
d.add(
|
|
&Space::Model,
|
|
Common::default(),
|
|
EntityKind::Text(cadcraft_doc::Text {
|
|
insert: Vec3::ZERO,
|
|
align_pt: None,
|
|
height: 1.0,
|
|
value: "CAD".into(),
|
|
rotation: 0.0,
|
|
width_factor: 1.0,
|
|
oblique: 0.0,
|
|
style: "Standard".into(),
|
|
halign: Default::default(),
|
|
valign: Default::default(),
|
|
}),
|
|
)
|
|
.unwrap();
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
assert!(l.prims.len() >= 3);
|
|
}
|
|
|
|
#[test]
|
|
fn hatch_pattern_and_solid() {
|
|
let mut d = Drawing::new_imperial();
|
|
let sq = vec![
|
|
PolyVertex::new(Vec2::new(0.0, 0.0)),
|
|
PolyVertex::new(Vec2::new(4.0, 0.0)),
|
|
PolyVertex::new(Vec2::new(4.0, 4.0)),
|
|
PolyVertex::new(Vec2::new(0.0, 4.0)),
|
|
];
|
|
let h = Hatch {
|
|
pattern: "ANSI31".into(),
|
|
solid: false,
|
|
loops: vec![HatchLoop { vertices: sq.clone(), outer: true }],
|
|
scale: 1.0,
|
|
angle: 0.0,
|
|
associative: false,
|
|
style: 0,
|
|
elevation: 0.0,
|
|
gradient: None,
|
|
origin: Vec2::ZERO,
|
|
background: None,
|
|
};
|
|
d.add(&Space::Model, Common::default(), EntityKind::Hatch(h.clone())).unwrap();
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
// 45° lines at 0.125 spacing across a 4x4 square: about 4*sqrt(2)/0.125 ≈ 45 lines.
|
|
assert!(l.prims.len() > 35 && l.prims.len() < 60, "{}", l.prims.len());
|
|
let mut d2 = Drawing::new_imperial();
|
|
d2.add(&Space::Model, Common::default(), EntityKind::Hatch(Hatch { solid: true, pattern: "SOLID".into(), ..h })).unwrap();
|
|
let l2 = build(&d2, &Space::Model, &Options::default());
|
|
assert_eq!(l2.prims.len(), 1);
|
|
assert_eq!(l2.prims[0].kind, Kind::Tris);
|
|
}
|
|
|
|
#[test]
|
|
fn raster_draws_pixels() {
|
|
let d = sample();
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
let v = View::fit(&l.bounds, 200, 200, 0.1);
|
|
let pm = render(&l, &v, &RasterOptions::default()).unwrap();
|
|
let bg = pm.pixels().iter().filter(|p| p.red() == 33).count();
|
|
assert!(bg < 200 * 200);
|
|
// A red pixel exists from the circle.
|
|
assert!(pm.pixels().iter().any(|p| p.red() > 200 && p.green() < 60));
|
|
}
|
|
|
|
#[test]
|
|
fn raster_rejects_bad_size() {
|
|
let l = DisplayList::default();
|
|
assert!(render(&l, &View { center: Vec2::ZERO, scale: 1.0, width: 0, height: 10 }, &RasterOptions::default()).is_none());
|
|
}
|
|
|
|
fn layout_with_viewport(vp: cadcraft_doc::Viewport) -> Drawing {
|
|
let mut d = sample(); // line (0,0)-(10,0) and red circle at (5,5) r3
|
|
let paper = Space::Paper("Layout1".into());
|
|
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
|
|
d
|
|
}
|
|
|
|
fn vp(center: Vec2, w: f64, h: f64, view_center: Vec2, view_height: f64, id: u32) -> cadcraft_doc::Viewport {
|
|
cadcraft_doc::Viewport {
|
|
center: center.to3(0.0),
|
|
width: w,
|
|
height: h,
|
|
view_center,
|
|
view_height,
|
|
id,
|
|
locked: false,
|
|
frozen_layers: Vec::new(),
|
|
layer_colors: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn layout_viewport_shows_model_clipped() {
|
|
// Viewport 4x4 paper units centred at (5,4), showing model window centred at (5,5), 8 high:
|
|
// scale 0.5, so model (5,5) → paper (5,4); the visible model window is (1,1)..(9,9).
|
|
let d = layout_with_viewport(vp(Vec2::new(5.0, 4.0), 4.0, 4.0, Vec2::new(5.0, 5.0), 8.0, 2));
|
|
let l = build(&d, &Space::Paper("Layout1".into()), &Options { tolerance: 0.01, ..Default::default() });
|
|
let sheet = l.sheet.unwrap();
|
|
assert!(sheet.size.x > sheet.size.y, "landscape sheet");
|
|
let rect = Bounds2::new(Vec2::new(3.0, 2.0), Vec2::new(7.0, 6.0)).expand(1e-9);
|
|
let content: Vec<&DPrim> = l.prims.iter().filter(|p| p.handle == VIEWPORT_CONTENT).collect();
|
|
assert!(!content.is_empty(), "model geometry is drawn in the viewport");
|
|
for p in &content {
|
|
for q in l.points(p) {
|
|
assert!(rect.contains(*q), "{q:?} outside the viewport");
|
|
}
|
|
}
|
|
// The circle (red) is visible, scaled by 0.5 about the view centre: radius 1.5 around (5,4).
|
|
let red: Vec<Vec2> = content.iter().filter(|p| p.color == Rgb(255, 0, 0)).flat_map(|p| l.points(p).to_vec()).collect();
|
|
assert!(!red.is_empty());
|
|
assert!(red.iter().all(|q| (q.dist(Vec2::new(5.0, 4.0)) - 1.5).abs() < 0.02));
|
|
// The line y=0 maps to paper y=1.5, below the viewport: clipped away entirely.
|
|
assert!(content.iter().filter(|p| p.color != Rgb(255, 0, 0)).all(|p| l.points(p).iter().all(|q| q.y > 1.9)));
|
|
// The border is drawn with the viewport's own handle.
|
|
assert!(l.prims.iter().any(|p| p.handle != VIEWPORT_CONTENT));
|
|
}
|
|
|
|
#[test]
|
|
fn layout_viewport_frozen_layer_and_id1() {
|
|
let mut v = vp(Vec2::new(5.0, 4.0), 20.0, 20.0, Vec2::new(5.0, 5.0), 20.0, 2);
|
|
v.frozen_layers = vec!["0".into()];
|
|
let d = layout_with_viewport(v);
|
|
let l = build(&d, &Space::Paper("Layout1".into()), &Options::default());
|
|
assert!(l.prims.iter().all(|p| p.handle != VIEWPORT_CONTENT), "layer 0 is frozen in this viewport");
|
|
// Viewport id 1 (the paper-space view) draws nothing.
|
|
let d = layout_with_viewport(vp(Vec2::new(5.0, 4.0), 20.0, 20.0, Vec2::new(5.0, 5.0), 20.0, 1));
|
|
assert!(build(&d, &Space::Paper("Layout1".into()), &Options::default()).prims.is_empty());
|
|
// Model space never draws viewport contents; plotting skips non-plot layers.
|
|
let mut d = sample();
|
|
d.layer_mut("0").unwrap().plot = false;
|
|
assert!(build_plot(&d, &Space::Model, &Options::default()).prims.is_empty());
|
|
assert!(!build(&d, &Space::Model, &Options::default()).prims.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_viewports_do_not_panic() {
|
|
for v in [
|
|
vp(Vec2::new(f64::NAN, 0.0), 1.0, 1.0, Vec2::ZERO, 1.0, 2),
|
|
vp(Vec2::ZERO, -1.0, 1.0, Vec2::ZERO, 1.0, 2),
|
|
vp(Vec2::ZERO, 1.0, 1.0, Vec2::ZERO, 0.0, 2),
|
|
vp(Vec2::ZERO, 1e308, 1e308, Vec2::new(1e308, -1e308), 1e-308, 2),
|
|
vp(Vec2::ZERO, 1.0, 1.0, Vec2::new(f64::INFINITY, 0.0), 1.0, 2),
|
|
] {
|
|
let d = layout_with_viewport(v);
|
|
let _ = build(&d, &Space::Paper("Layout1".into()), &Options::default());
|
|
}
|
|
}
|
|
|
|
fn text_doc(style_font: &str, value: &str) -> Drawing {
|
|
let mut d = Drawing::new_imperial();
|
|
d.text_styles.push(cadcraft_doc::TextStyle { name: "TT".into(), font: style_font.into(), ..Default::default() });
|
|
d.add(
|
|
&Space::Model,
|
|
Common::default(),
|
|
EntityKind::Text(cadcraft_doc::Text {
|
|
insert: Vec3::ZERO,
|
|
align_pt: None,
|
|
height: 1.0,
|
|
value: value.into(),
|
|
rotation: 0.0,
|
|
width_factor: 1.0,
|
|
oblique: 0.0,
|
|
style: "TT".into(),
|
|
halign: Default::default(),
|
|
valign: Default::default(),
|
|
}),
|
|
)
|
|
.unwrap();
|
|
d
|
|
}
|
|
|
|
/// The first common system TrueType font, if any is installed (tests skip otherwise).
|
|
fn system_font() -> Option<&'static str> {
|
|
["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].into_iter().find(|n| cadcraft_fonts::ttf::find(n).is_some())
|
|
}
|
|
|
|
#[test]
|
|
fn truetype_text_is_filled_or_outlined() {
|
|
let Some(font) = system_font() else { return };
|
|
let mut d = text_doc(font, "OH");
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
assert!(l.prims.iter().any(|p| p.kind == Kind::Tris), "TEXTFILL=1 fills glyphs");
|
|
// The O's counter is a hole: filled area is well below the glyph box area.
|
|
let area: f64 = l
|
|
.prims
|
|
.iter()
|
|
.filter(|p| p.kind == Kind::Tris)
|
|
.flat_map(|p| l.points(p).chunks(3).map(|t| ((t[1] - t[0]).cross(t[2] - t[0]) / 2.0).abs()).collect::<Vec<_>>())
|
|
.sum();
|
|
assert!(area > 0.1 && area < l.bounds.width() * l.bounds.height() * 0.8, "area {area}");
|
|
assert!((l.bounds.max.y - 1.0).abs() < 0.1, "cap height = text height");
|
|
d.header.set_i64("TEXTFILL", 0);
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
assert!(l.prims.iter().all(|p| p.kind == Kind::Polyline), "TEXTFILL=0 outlines");
|
|
assert!(l.prims.len() >= 3, "O has two contours, H one");
|
|
}
|
|
|
|
#[test]
|
|
fn missing_font_falls_back_to_stroke() {
|
|
let d = text_doc("NoSuchFontAnywhere.ttf", "OH");
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
assert!(!l.prims.is_empty() && l.prims.iter().all(|p| p.kind == Kind::Polyline));
|
|
}
|
|
|
|
#[test]
|
|
fn backwards_and_upside_down_mirror_text() {
|
|
let mut d = text_doc("CADCraft Stroke", "ABC");
|
|
let normal = build(&d, &Space::Model, &Options::default()).bounds;
|
|
assert!(normal.min.x >= -1e-9 && normal.min.y >= -1e-9);
|
|
d.text_styles.iter_mut().for_each(|s| s.backwards = true);
|
|
let back = build(&d, &Space::Model, &Options::default()).bounds;
|
|
assert!(back.max.x <= 1e-9 && (back.width() - normal.width()).abs() < 1e-9, "mirrored to the left");
|
|
d.text_styles.iter_mut().for_each(|s| {
|
|
s.backwards = false;
|
|
s.upside_down = true;
|
|
});
|
|
let up = build(&d, &Space::Model, &Options::default()).bounds;
|
|
assert!(up.max.y <= 1e-9 && up.min.y < -0.9, "mirrored below the baseline");
|
|
}
|
|
|
|
#[test]
|
|
fn truetype_mtext_and_dimension_text() {
|
|
let Some(font) = system_font() else { return };
|
|
let mut d = Drawing::new_imperial();
|
|
d.text_styles.iter_mut().for_each(|s| s.font = font.into());
|
|
d.add(
|
|
&Space::Model,
|
|
Common::default(),
|
|
EntityKind::MText(cadcraft_doc::MText {
|
|
insert: Vec3::ZERO,
|
|
height: 1.0,
|
|
width: 0.0,
|
|
attach: 1,
|
|
rotation: 0.0,
|
|
style: "Standard".into(),
|
|
contents: "A{\\C1;B}\\P\\S1/2;".into(),
|
|
line_spacing: 1.0,
|
|
}),
|
|
)
|
|
.unwrap();
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
assert!(l.prims.iter().any(|p| p.kind == Kind::Tris && p.color == Rgb(255, 0, 0)), "red B");
|
|
assert!(l.prims.iter().any(|p| p.kind == Kind::Tris && p.color != Rgb(255, 0, 0)));
|
|
let mut d = Drawing::new_imperial();
|
|
d.text_styles.iter_mut().for_each(|s| s.font = font.into());
|
|
d.add(&Space::Model, Common::default(), EntityKind::Dimension(lin_dim())).unwrap();
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
assert!(l.prims.iter().filter(|p| p.kind == Kind::Tris).count() > 2, "arrows plus filled glyphs");
|
|
}
|
|
|
|
fn lin_dim() -> cadcraft_doc::Dimension {
|
|
cadcraft_doc::Dimension {
|
|
kind: cadcraft_doc::DimKind::Linear { rotation: 0.0 },
|
|
defpt: Vec3::new(0.0, 2.0, 0.0),
|
|
text_mid: Vec3::ZERO,
|
|
p13: Vec3::ZERO,
|
|
p14: Vec3::new(10.0, 0.0, 0.0),
|
|
p15: Vec3::ZERO,
|
|
p16: Vec3::ZERO,
|
|
text: String::new(),
|
|
style: "Standard".into(),
|
|
measurement: 0.0,
|
|
text_rotation: 0.0,
|
|
user_text_pos: false,
|
|
block: None,
|
|
overrides: Default::default(),
|
|
assoc: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dimension_colours_by_role() {
|
|
let mut d = Drawing::new_imperial();
|
|
if let Some(st) = d.dim_styles.first_mut() {
|
|
st.dim_line_color = Color::Index(1);
|
|
st.ext_line_color = Color::Index(3);
|
|
st.text_color = Color::Index(5);
|
|
}
|
|
d.add(&Space::Model, Common::default(), EntityKind::Dimension(lin_dim())).unwrap();
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
let has = |c: Rgb, k: Kind| l.prims.iter().any(|p| p.color == c && p.kind == k);
|
|
assert!(has(Rgb(255, 0, 0), Kind::Polyline) && has(Rgb(255, 0, 0), Kind::Tris), "dimension line and arrows red");
|
|
assert!(has(Rgb(0, 255, 0), Kind::Polyline), "extension lines green");
|
|
assert!(has(Rgb(0, 0, 255), Kind::Polyline), "text blue");
|
|
// ByBlock (the default) takes the dimension's own colour.
|
|
let mut d = Drawing::new_imperial();
|
|
d.add(&Space::Model, Common { color: Color::Index(6), ..Default::default() }, EntityKind::Dimension(lin_dim())).unwrap();
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
assert!(l.prims.iter().all(|p| p.color == Rgb(255, 0, 255)));
|
|
}
|
|
|
|
#[test]
|
|
fn table_title_is_centred_and_merged() {
|
|
let mut d = Drawing::new_imperial();
|
|
let cell = |t: &str| cadcraft_doc::TableCell { text: t.into(), merged: None };
|
|
let mut title = vec![cell("TITLE"), cell(""), cell("")];
|
|
title[0].merged = Some((1, 3));
|
|
let t = cadcraft_doc::Table {
|
|
insert: Vec3::new(0.0, 0.0, 0.0),
|
|
col_widths: vec![2.0, 2.0, 2.0],
|
|
row_heights: vec![1.0, 1.0],
|
|
cells: vec![title, vec![cell("a"), cell("b"), cell("c")]],
|
|
style: "Standard".into(),
|
|
text_height: 0.2,
|
|
title: true,
|
|
header: false,
|
|
};
|
|
d.add(&Space::Model, Common::default(), EntityKind::Table(t)).unwrap();
|
|
let l = build(&d, &Space::Model, &Options::default());
|
|
// No vertical cell borders cross the merged title row (y in 0..-1) except the outer ones.
|
|
let inner_vertical_in_title = l.prims.iter().filter(|p| p.kind == Kind::Polyline).any(|p| {
|
|
l.points(p).windows(2).any(|w| {
|
|
(w[0].x - w[1].x).abs() < 1e-12
|
|
&& (w[0].y - w[1].y).abs() > 0.5
|
|
&& w[0].x > 0.1
|
|
&& w[0].x < 5.9
|
|
&& w[0].y.max(w[1].y) > -0.99
|
|
&& w[0].y.min(w[1].y) < -0.01
|
|
})
|
|
});
|
|
assert!(!inner_vertical_in_title);
|
|
// Title text centred on the table width.
|
|
let text: Vec<Vec2> = l
|
|
.prims
|
|
.iter()
|
|
.filter(|p| p.kind == Kind::Polyline)
|
|
.flat_map(|p| l.points(p).to_vec())
|
|
.filter(|q| q.y > -0.95 && q.y < -0.05 && q.x > 0.05 && q.x < 5.95)
|
|
.collect();
|
|
let b = Bounds2::from_points(text);
|
|
assert!((b.center().x - 3.0).abs() < 0.05, "{b:?}");
|
|
}
|