Compare commits

..
174 changed files with 377 additions and 9001 deletions
Generated
-3
View File
@@ -729,7 +729,6 @@ name = "cadcraft-fonts"
version = "0.17.0"
dependencies = [
"cadcraft-geom",
"encoding_rs",
"harfrust 0.14.0",
"icu_segmenter",
"skrifa 0.48.0",
@@ -756,7 +755,6 @@ dependencies = [
"cadcraft-geom",
"cadcraft-render",
"craft-pdf",
"image",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
@@ -823,7 +821,6 @@ name = "cadcraft-web"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-io",
"cadcraft-ui-egui",
"eframe",
+1 -1
View File
@@ -54,7 +54,7 @@ unicode-bidi = "0.3.18"
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
quick-xml = "0.42"
-1
View File
@@ -45,7 +45,6 @@ fn install_io() {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
-1
View File
@@ -18,7 +18,6 @@ path = "src/main.rs"
cadcraft-engine = { workspace = true }
cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
cadcraft-fonts = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
-1
View File
@@ -119,7 +119,6 @@ mod web {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
+8 -54
View File
@@ -2,11 +2,6 @@
//! a path from, so the picker is a hidden `<input type="file">`; the chosen file (or one dropped on
//! the page) is read in the background and handed to the app on its next frame, which opens it
//! with OPEN `{data, name}` ([`CadApp::open_bytes`]).
//!
//! SHX fonts (text fonts, big fonts, shape files) chosen or dropped with a drawing are the web
//! build's font folder: they are registered with the font table
//! ([`cadcraft_fonts::shx::register`]) under their file name, so the styles and linetypes that
//! name them draw with them, and the drawings are regenerated.
use std::cell::Cell;
use std::rc::Rc;
@@ -17,14 +12,8 @@ use web_sys::js_sys;
/// The hidden file input (one at a time; a cancelled pick leaves it until the next one).
const INPUT_ID: &str = "cadcraft_open_input";
/// What the picker offers: the formats OPEN reads, and the SHX fonts drawings name.
const ACCEPT: &str = ".dxf,.dwg,.shx";
/// Most files taken from one pick.
const MAX_PICKED: u32 = 64;
/// Most bytes being read at once, all files together: a large pick must not exhaust the page's
/// memory (each file is copied once more into the app).
const MAX_READING_BYTES: u64 = 512 << 20;
/// What the picker offers: the formats OPEN reads.
const ACCEPT: &str = ".dxf,.dwg";
/// A file read in the browser (name, contents), or why it couldn't be read.
type Upload = Result<(String, Vec<u8>), String>;
@@ -33,14 +22,12 @@ type Upload = Result<(String, Vec<u8>), String>;
#[derive(Clone)]
pub struct Inbox {
files: Rc<Cell<Vec<Upload>>>,
/// Bytes of the files being read now.
reading: Rc<Cell<u64>>,
ctx: egui::Context,
}
impl Inbox {
pub fn new(ctx: &egui::Context) -> Self {
Inbox { files: Rc::default(), reading: Rc::default(), ctx: ctx.clone() }
Inbox { files: Rc::default(), ctx: ctx.clone() }
}
/// Show the browser's file picker and return at once. A chosen file arrives through
@@ -64,23 +51,13 @@ impl Inbox {
.map_err(|_| "could not create a file input".to_string())?;
input.set_type("file");
input.set_accept(ACCEPT);
// A drawing and the fonts it uses can be chosen together.
input.set_multiple(true);
input.set_id(INPUT_ID);
input.set_attribute("style", "display:none").map_err(js_error)?;
let (inbox, chosen) = (self.clone(), input.clone());
// Called once, when files were chosen (browsers don't fire `change` on cancel).
// Called once, when a file was chosen (browsers don't fire `change` on cancel).
let on_change = wasm_bindgen::closure::Closure::once_into_js(move || {
if let Some(files) = chosen.files() {
for i in 0..files.length().min(MAX_PICKED) {
if let Some(file) = files.get(i) {
inbox.read(file);
}
}
let skipped: Vec<String> = (MAX_PICKED..files.length()).filter_map(|i| files.get(i)).map(|f| f.name()).collect();
if !skipped.is_empty() {
inbox.push(Err(format!("Open: at most {MAX_PICKED} files at a time; not opened: {}", skipped.join(", "))));
}
if let Some(file) = chosen.files().and_then(|f| f.get(0)) {
inbox.read(file);
}
chosen.remove();
});
@@ -110,18 +87,12 @@ impl Inbox {
if let Some(e) = CadApp::open_size_error(&name, len) {
return self.push(Err(e));
}
let reading = self.reading.get();
if reading.saturating_add(len) > MAX_READING_BYTES {
return self.push(Err(format!("Open: {name} was not read: the files being read together are too large; open it on its own")));
}
self.reading.set(reading.saturating_add(len));
let inbox = self.clone();
wasm_bindgen_futures::spawn_local(async move {
let r = match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => Ok((name, js_sys::Uint8Array::new(&buf).to_vec())),
Err(e) => Err(format!("Open: could not read {name}: {}", js_error(e))),
};
inbox.reading.set(inbox.reading.get().saturating_sub(len));
inbox.push(r);
});
}
@@ -147,21 +118,9 @@ impl Inbox {
self.ctx.request_repaint();
}
/// Open what arrived since the last frame (or report why it couldn't be read). Fonts are
/// registered first, so drawings opened with them draw with them.
/// Open what arrived since the last frame (or report why it couldn't be read).
pub fn drain(&self, app: &mut CadApp) {
let (fonts, files): (Vec<Upload>, Vec<Upload>) = self.files.take().into_iter().partition(|u| u.as_ref().is_ok_and(|(n, _)| is_shx(n)));
let mut registered = false;
for (name, bytes) in fonts.into_iter().flatten() {
match cadcraft_fonts::shx::register(&name, &bytes) {
Ok(()) => registered = true,
Err(e) => app.open_failed(format!("{name}: {e}")),
}
}
if registered {
let _ = app.run("regenall", serde_json::json!({}));
}
for upload in files {
for upload in self.files.take() {
match upload {
Ok((name, bytes)) => app.open_bytes(&name, &bytes),
Err(e) => app.open_failed(e),
@@ -170,11 +129,6 @@ impl Inbox {
}
}
/// Whether `name` is an SHX font or shape file.
fn is_shx(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".shx")
}
fn js_error(e: wasm_bindgen::JsValue) -> String {
e.as_string().unwrap_or_else(|| format!("{e:?}"))
}
-1
View File
@@ -146,7 +146,6 @@ pub fn install_io() {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
-1
View File
@@ -800,7 +800,6 @@ mod budget_tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}
}
-2
View File
@@ -302,7 +302,6 @@ fn block_reference_moves_rigidly() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
);
assert_eq!(obj_dof(&d, ins), Some(3));
@@ -344,7 +343,6 @@ fn hostile_new_geometry_never_panics() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
);
let l = line(&mut d, (0.0, 0.0), (0.0, 0.0));
-1
View File
@@ -228,7 +228,6 @@ impl ArrayParams {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
out.push(Entity { handle: d.new_handle(), common: common.clone(), kind: EntityKind::Insert(ins) });
}
-19
View File
@@ -161,25 +161,6 @@ pub struct Insert {
pub col_spacing: f64,
#[serde(default)]
pub row_spacing: f64,
/// XCLIP boundary of this reference (other references to the block are not clipped).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clip: Option<crate::ClipBoundary>,
}
impl Default for Insert {
fn default() -> Self {
Insert {
block: String::new(),
insert: Vec3::ZERO,
scale: unit_scale(),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}
}
}
fn unit_scale() -> Vec3 {
+2 -39
View File
@@ -1,5 +1,3 @@
use cadcraft_geom::Mat3;
use super::common::Handle;
use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes};
@@ -27,39 +25,6 @@ pub struct Viewport {
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
#[serde(default = "yes")]
pub on: bool,
/// The view twist (DVIEW TWist inside the viewport; DXF group 51): radians counterclockwise,
/// the model view seen through the viewport is turned on the sheet by it. 0 is untwisted.
#[serde(default, skip_serializing_if = "is_zero_twist")]
pub twist: f64,
}
fn is_zero_twist(v: &f64) -> bool {
*v == 0.0
}
impl Viewport {
/// The view twist, usable: 0 when it isn't finite.
pub fn view_twist(&self) -> f64 {
if self.twist.is_finite() { self.twist } else { 0.0 }
}
/// Paper units per model unit.
pub fn scale(&self) -> f64 {
self.height / self.view_height
}
/// Where model point `m` is on the sheet: `center + scale · R(twist)(m − view_center)`.
pub fn model_to_paper(&self, m: Vec2) -> Vec2 {
self.center.xy() + (m - self.view_center).rotate(self.view_twist()) * self.scale()
}
/// The model point at sheet point `p` (the inverse of [`Viewport::model_to_paper`]).
pub fn paper_to_model(&self, p: Vec2) -> Vec2 {
self.view_center + ((p - self.center.xy()) / self.scale()).rotate(-self.view_twist())
}
/// [`Viewport::model_to_paper`] as a matrix.
pub fn model_to_paper_matrix(&self) -> Mat3 {
let s = self.scale();
Mat3::translate(self.center.xy())
.then_before(Mat3::scale(s, s))
.then_before(Mat3::rotate(self.view_twist()))
.then_before(Mat3::translate(-self.view_center))
}
}
impl Default for Viewport {
@@ -76,7 +41,6 @@ impl Default for Viewport {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
}
}
}
@@ -95,9 +59,8 @@ pub struct Image {
/// The image definition's name (its key in the drawing's image dictionary).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
/// Clip boundary in pixel coordinates (y down, counted from pixel centres: the centre of the
/// top-left pixel is the origin, so the whole image is -0.5 to size - 0.5): two opposite
/// corners of a rectangle, or a polygon's vertices. Empty: the whole image.
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clip: Vec<Vec2>,
/// The clip boundary is applied.
+4 -48
View File
@@ -179,23 +179,11 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
}
};
b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
// XCLIP: the reference app takes the boundary's box, also where it reaches past the
// block or misses it (an inverted clip can show anything, so it keeps the block's).
if let Some(c) = ins.clip.as_ref().filter(|c| c.active() && !c.inverted)
&& !b.is_empty()
{
b = Bounds2::from_points(c.polygon().into_iter().map(|p| m.apply(p)));
}
if ins.cols > 1 || ins.rows > 1 {
// The grid's four corner copies (as many as are drawn): turned, the far corner
// alone misses the side ones.
let (cols, rows) =
(f64::from(ins.cols.clamp(1, 10_000).saturating_sub(1)), f64::from(ins.rows.clamp(1, 10_000).saturating_sub(1)));
let one = b;
for (c, r) in [(cols, 0.0), (0.0, rows), (cols, rows)] {
let off = Vec2::new(ins.col_spacing * c, ins.row_spacing * r).rotate(ins.rotation);
b = b.union(&Bounds2::from_points(one.corners().map(|p| p + off)));
}
let off =
Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
.rotate(ins.rotation);
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
}
}
for a in &ins.attribs {
@@ -498,7 +486,6 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
};
@@ -527,35 +514,4 @@ mod tests {
assert!(extents.width() > drawn.width());
}
}
/// A turned array of references reaches its side copies, not only the first and the last;
/// a hostile count measures only the copies that are drawn.
#[test]
fn a_rotated_array_counts_every_corner_copy() {
let mut d = Drawing::new_metric();
let mut b = crate::Block::new("B");
b.entities.push(Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) })));
d.blocks.insert("B".into(), std::sync::Arc::new(b));
let array = |cols: u32, rows: u32, rotation: f64| {
let ins = crate::Insert {
block: "B".into(),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation,
cols,
rows,
col_spacing: 10.0,
row_spacing: 10.0,
..crate::Insert::default()
};
Entity::new(crate::Handle(2), EntityKind::Insert(ins))
};
// Turned 45°: the copies sit at (0, 0), (7.07, 7.07), (-7.07, 7.07) and (0, 14.14).
let b = entity_bounds(&d, &array(2, 2, std::f64::consts::FRAC_PI_4), 0);
let side = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
assert!((b.min.x + side).abs() < 1e-9 && b.max.x >= side, "{b:?}");
assert!((b.max.y - 2.0 * side - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-9, "{b:?}");
// The renderer draws at most 10,000 columns.
let b = entity_bounds(&d, &array(u32::MAX, 1, 0.0), 0);
assert!((b.max.x - (10.0 * 9_999.0 + 1.0)).abs() < 1e-6, "{b:?}");
}
}
-7
View File
@@ -16,11 +16,9 @@ mod header;
pub mod library;
mod plotstyle;
mod plotter;
mod raster;
mod refedit;
mod store;
mod tables;
mod xclip;
use std::collections::{BTreeMap, BTreeSet};
@@ -35,11 +33,9 @@ pub use gdt::*;
pub use header::{HVal, Header};
pub use plotstyle::*;
pub use plotter::*;
pub use raster::{Raster, Rasters};
pub use refedit::{RefEdit, WorkItem};
pub use store::EntityStore;
pub use tables::*;
pub use xclip::{ClipBoundary, MAX_CLIP_VERTICES};
/// File data CADCraft doesn't model, kept for saving (#528).
pub mod dynblock;
@@ -140,8 +136,6 @@ pub struct Drawing {
/// for the Linux fonts of text styles, and each style keeps its own in CADCraft xdata, so the
/// drawing reopens with it. Never set on an open drawing.
pub portable_fonts: bool,
/// The pixels of the raster images that are loaded (IMAGE). Never written to a file.
pub rasters: Rasters,
}
impl Default for Drawing {
@@ -202,7 +196,6 @@ impl Drawing {
refedit: None,
skipped_records: BTreeMap::new(),
portable_fonts: false,
rasters: Rasters::default(),
}
}
-93
View File
@@ -1,93 +0,0 @@
//! Decoded raster image pixels (IMAGE entities). The drawing keeps them for the session only:
//! they are loaded from the image files after a drawing opens and are never written to a file.
use std::collections::BTreeMap;
use std::sync::Arc;
/// The pixels of a raster image: 8-bit RGBA, not premultiplied, rows from the top.
#[derive(Clone, PartialEq)]
pub struct Raster {
width: u32,
height: u32,
rgba: Vec<u8>,
opaque: bool,
}
impl Raster {
/// `None` unless both sides are at least one pixel and `rgba` holds exactly four bytes per pixel.
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Option<Raster> {
let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?;
if pixels == 0 || rgba.len() != pixels.checked_mul(4)? {
return None;
}
let opaque = rgba.as_chunks::<4>().0.iter().all(|[_, _, _, alpha]| *alpha == u8::MAX);
Some(Raster { width, height, rgba, opaque })
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
/// Four bytes per pixel (red, green, blue, alpha), `width * height` pixels.
pub fn rgba(&self) -> &[u8] {
&self.rgba
}
/// No pixel is transparent.
pub fn opaque(&self) -> bool {
self.opaque
}
/// The pixel in column `x` of row `y` (from the top): red, green, blue, alpha.
pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
if x >= self.width || y >= self.height {
return None;
}
let (x, y, width) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?, usize::try_from(self.width).ok()?);
let pixels = self.rgba.as_chunks::<4>().0;
pixels.get(y.checked_mul(width)?.checked_add(x)?).copied()
}
}
impl std::fmt::Debug for Raster {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Raster({}x{})", self.width, self.height)
}
}
/// The loaded pixels of a drawing's raster images, by image file path ([`crate::Image::path`]).
/// Session data: two drawings are equal whatever images they have loaded, and copying a drawing
/// (undo) shares the pixels.
#[derive(Clone, Default)]
pub struct Rasters(Arc<BTreeMap<String, Arc<Raster>>>);
impl Rasters {
pub fn get(&self, path: &str) -> Option<&Arc<Raster>> {
self.0.get(path)
}
pub fn insert(&mut self, path: &str, raster: Arc<Raster>) {
Arc::make_mut(&mut self.0).insert(path.to_string(), raster);
}
pub fn remove(&mut self, path: &str) {
if self.0.contains_key(path) {
Arc::make_mut(&mut self.0).remove(path);
}
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl PartialEq for Rasters {
fn eq(&self, _: &Rasters) -> bool {
true
}
}
impl std::fmt::Debug for Rasters {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Rasters({})", self.0.len())
}
}
+1 -4
View File
@@ -863,7 +863,7 @@ pub struct NamedView {
/// The layer state restored with the view (its layer snapshot).
#[serde(default)]
pub layer_state: Option<String>,
/// View twist (radians, counterclockwise); VIEW Restore applies it to the model space view.
/// View twist (radians, counterclockwise); kept for files, not applied to the 2D display.
#[serde(default)]
pub twist: f64,
/// The layout a paper space view belongs to (`None`: a model space view; an empty name: a
@@ -884,9 +884,6 @@ pub struct VportTile {
/// Width / height of the tile on screen when it was saved (0: unknown).
#[serde(default)]
pub aspect: f64,
/// The tile's view twist (radians, counterclockwise; DXF VPORT group 51 in degrees).
#[serde(default)]
pub twist: f64,
}
/// A model space viewport configuration (VPORTS): `*Active` is the one shown, other names are
-5
View File
@@ -189,7 +189,6 @@ fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
};
d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty")));
@@ -243,7 +242,6 @@ fn visible_attributes_of_a_missing_block_still_count() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
};
@@ -287,7 +285,6 @@ fn insert_bounds_with_cyclic_block_terminate() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
));
b.entities.push(Entity::new(Handle(2), line((0.0, 0.0), (1.0, 1.0))));
@@ -306,7 +303,6 @@ fn insert_bounds_with_cyclic_block_terminate() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
.unwrap();
@@ -424,7 +420,6 @@ fn plain_insert(block: &str) -> EntityKind {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
}
-85
View File
@@ -1,85 +0,0 @@
//! XCLIP: the clipping boundary of one block or xref reference.
//!
//! The boundary is kept in the referenced block's coordinates, so it moves, rotates and scales
//! with its reference; the block definition and other references to it are untouched. In a file
//! it is the reference's `ACAD_FILTER` / `SPATIAL` extension-dictionary entry (a SPATIAL_FILTER
//! object, DXF Reference), read and written by the io crate.
use cadcraft_geom::Vec2;
use serde::{Deserialize, Serialize};
/// Most boundary vertices a clip keeps (hostile files and parameters are capped to this).
pub const MAX_CLIP_VERTICES: usize = 4096;
/// A reference's clipping boundary.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipBoundary {
/// Vertices in the block's coordinates: two opposite corners for a rectangle, three or more
/// for a closed polygon (not repeating the first vertex).
pub points: Vec<Vec2>,
/// Whether clipping is on (XCLIP ON/OFF); an off boundary is kept but nothing is clipped.
#[serde(default = "on")]
pub enabled: bool,
/// Inverted clip: what is inside the boundary is hidden, what is outside shows.
#[serde(default)]
pub inverted: bool,
}
fn on() -> bool {
true
}
impl ClipBoundary {
/// A boundary from `points` (block coordinates), on and not inverted.
pub fn new(points: Vec<Vec2>) -> Self {
ClipBoundary { points, enabled: true, inverted: false }
}
/// The closed outline in block coordinates: a rectangle's four corners or the polygon's
/// vertices. Empty when the boundary has fewer than two vertices, more than
/// [`MAX_CLIP_VERTICES`] or a non-finite one.
pub fn polygon(&self) -> Vec<Vec2> {
if self.points.len() > MAX_CLIP_VERTICES || !self.points.iter().all(|p| p.is_finite()) {
return Vec::new();
}
match self.points.as_slice() {
[a, b] => vec![
Vec2::new(a.x.min(b.x), a.y.min(b.y)),
Vec2::new(a.x.max(b.x), a.y.min(b.y)),
Vec2::new(a.x.max(b.x), a.y.max(b.y)),
Vec2::new(a.x.min(b.x), a.y.max(b.y)),
],
[_, _, _, ..] => self.points.clone(),
_ => Vec::new(),
}
}
/// Whether the boundary is a rectangle given by two corners.
pub fn is_rectangle(&self) -> bool {
self.points.len() == 2
}
/// Whether drawing the reference is clipped by it now (on, with a usable outline).
pub fn active(&self) -> bool {
self.enabled && self.polygon().len() >= 3
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rectangle_and_polygon_outlines() {
let r = ClipBoundary::new(vec![Vec2::new(2.0, 3.0), Vec2::new(0.0, 0.0)]);
assert_eq!(r.polygon(), vec![Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0), Vec2::new(2.0, 3.0), Vec2::new(0.0, 3.0)]);
assert!(r.active() && r.is_rectangle());
let bad = ClipBoundary::new(vec![Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 1.0)]);
assert!(bad.polygon().is_empty() && !bad.active());
let one = ClipBoundary::new(vec![Vec2::ZERO]);
assert!(!one.active());
let off = ClipBoundary { enabled: false, ..r };
assert!(!off.active());
}
}
+43
View File
@@ -71,6 +71,8 @@ pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: im
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
let write = (|| {
if let Some(permissions) = permissions {
#[cfg(unix)]
let permissions = keep_group(&file, path, permissions);
file.set_permissions(permissions)?;
}
fill(&mut file)?;
@@ -90,6 +92,20 @@ pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: im
Ok(())
}
/// `permissions` for the new file when `path` already exists: the group bits mean "this group" only
/// if the file is still in the old file's group, so the group is restored first. When it can't be
/// (the user is not a member of it), the group bits are dropped rather than handed to another group.
#[cfg(unix)]
fn keep_group(file: &File, path: &Path, permissions: Permissions) -> Permissions {
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
let Ok(old) = std::fs::symlink_metadata(path) else { return permissions };
// A file already in the old group needs no change (and a restrictive syscall policy may refuse a no-op fchown).
if !old.is_file() || file.metadata().is_ok_and(|m| m.gid() == old.gid()) || fchown(file, None, Some(old.gid())).is_ok() {
return permissions;
}
Permissions::from_mode(permissions.mode() & !0o070)
}
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
/// refuse to sync a directory, and the file itself is already on disk.
@@ -231,6 +247,33 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// The replacement keeps the old file's group (when the user is in another group, as `id -G`
/// says) together with its mode, so the group bits never name a different group.
#[cfg(unix)]
#[test]
fn keeps_the_group_and_mode_of_the_file_it_replaces() {
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
let dir = std::env::temp_dir().join(format!("cadcraft-aw-grp-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("out.bin");
std::fs::write(&dest, b"old").unwrap();
std::fs::set_permissions(&dest, Permissions::from_mode(0o640)).unwrap();
let own = std::fs::metadata(&dest).unwrap().gid();
let other = std::process::Command::new("id")
.arg("-G")
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
if let Some(gid) = other {
chown(&dest, None, Some(gid)).unwrap();
}
let want = std::fs::metadata(&dest).unwrap();
write_atomic(&dest, b"new").unwrap();
let got = std::fs::metadata(&dest).unwrap();
assert_eq!((got.gid(), got.mode() & 0o777), (want.gid(), 0o640));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn does_not_follow_a_planted_temp_symlink() {
+6 -58
View File
@@ -405,11 +405,10 @@ mod native {
pub fn prune_locks(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let names: Vec<String> = entries.flatten().take(10_000).map(|e| e.file_name().to_string_lossy().to_string()).collect();
// The instances that own autosave files, parsed once rather than once per lock file.
let with_files: std::collections::HashSet<&str> = names.iter().filter_map(|f| parse_file_name(f)).map(|(_, i, _)| i).collect();
for n in &names {
let Some(instance) = parse_lock_name(n) else { continue };
if !with_files.contains(instance) && !owner_alive(dir, instance) {
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
if !has_files && !owner_alive(dir, instance) {
let _ = std::fs::remove_file(dir.join(n));
}
}
@@ -436,22 +435,7 @@ mod native {
}
// Create a fresh temporary file exclusively and rename it into place, so a planted
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
// The file holds unsaved work: owner-only from its creation, whatever the umask.
crate::atomic_write::write_atomic_with(Path::new(path), private_mode(), |f| f.write_all(&bytes)).map_err(|e| e.to_string())
}
/// Owner read/write only (`None` where file modes don't exist).
fn private_mode() -> Option<std::fs::Permissions> {
#[cfg(unix)]
return Some(std::os::unix::fs::PermissionsExt::from_mode(0o600));
#[cfg(not(unix))]
None
}
/// Write this app's lock file at `path` without following a link planted there: the file is
/// replaced (exclusive temporary file, then rename), never written through (CWE-59).
fn write_lock(path: &Path, id: u32) -> std::io::Result<()> {
crate::atomic_write::write_atomic_with(path, private_mode(), |f| f.write_all(id.to_string().as_bytes()))
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
}
impl Autosaver {
@@ -602,7 +586,7 @@ mod native {
for (held, file) in &self.locks {
let refreshed = match file {
Some(file) => file.set_modified(SystemTime::now()),
None => write_lock(held, id),
None => std::fs::write(held, id.to_string()),
};
if held == path {
current = Some(refreshed);
@@ -611,22 +595,10 @@ mod native {
if let Some(refreshed) = current {
return refreshed;
}
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// A symlink planted at the lock path must not be opened (and truncated) through,
// nor a FIFO wait for a reader.
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK).mode(0o600);
}
let mut file = options.open(path)?;
if !file.metadata()?.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the lock file is not a plain file"));
}
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
if file.try_lock().is_err() {
self.locks.push((path.to_path_buf(), None));
return write_lock(path, id);
return std::fs::write(path, id.to_string());
}
file.set_len(0)?;
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
@@ -655,30 +627,6 @@ mod native {
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// A symlink planted at the lock path is neither opened through nor written through.
#[test]
fn lock_writes_do_not_follow_a_planted_link() {
let dir = std::env::temp_dir().join(format!("cadcraft-lock-link-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let victim = dir.join("victim.txt");
std::fs::write(&victim, b"precious").unwrap();
let link = dir.join("1-a.lock");
std::os::unix::fs::symlink(&victim, &link).unwrap();
let mut a = Autosaver::new("1-a");
assert!(a.refresh_lock(&link).is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
write_lock(&link, 42).unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&link).unwrap(), b"42");
std::fs::remove_dir_all(dir).unwrap();
}
}
}
#[cfg(test)]
-2
View File
@@ -18,7 +18,6 @@ mod gripcmds;
mod griphot;
pub(crate) mod groups;
pub(crate) mod hatch;
mod image;
mod inquiry;
mod layer;
mod layout;
@@ -201,7 +200,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(layer::specs());
v.extend(layout::specs());
v.extend(exchange::specs());
v.extend(image::specs());
v.extend(props::specs());
v.extend(inquiry::specs());
v.extend(settings::specs());
-1
View File
@@ -159,7 +159,6 @@ pub(crate) fn make_assoc(s: &mut Session, objs: &[Handle], base: Vec2, mut p: Ar
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}))
}
-3
View File
@@ -25,7 +25,6 @@ mod dyngrip;
mod extract;
pub(crate) mod refedit;
mod wblock;
mod xclip;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -93,7 +92,6 @@ pub fn specs() -> Vec<CommandSpec> {
.chain(attribs::attdisp_menu_specs())
.chain(xref::specs())
.chain(refedit::specs())
.chain(xclip::specs())
.chain(bedit::specs())
.collect()
}
@@ -230,7 +228,6 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
if let Some(b) = base {
let landed = ins.transform(blk.base.xy()).apply(b);
-484
View File
@@ -1,484 +0,0 @@
//! XCLIP and XCLIPFRAME: clip block and xref references to a boundary.
//!
//! A boundary belongs to one reference ([`cadcraft_doc::Insert::clip`]), in the block's own
//! coordinates, so it moves, rotates and scales with the reference and other references of the
//! block stay whole. It is a rectangle, a polygon or the outline of a closed polyline, can hide
//! what is inside instead of what is outside (Invert clip), and can be turned off, deleted or
//! turned into a polyline. Clip depth (front and back clipping planes) has no meaning in a 2D
//! drawing and is ignored. XCLIPFRAME says whether the boundary is drawn and plotted.
use cadcraft_doc::{ClipBoundary, EntityKind, Handle, MAX_CLIP_VERTICES};
use cadcraft_geom::{PolyVertex, Vec2};
use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase};
use crate::cmd::{CommandSpec, bad, bool_or, has_doc, point_value, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("xclip", "Clip Xref", run)
.menu(&["Modify", "Clip", "Xref"])
.alias(&["xc"])
.params("{handles? | handle? (block or xref references, default the selection), option?: new|on|off|delete|polyline|clipdepth|query (default new with a boundary, else query), rectangle?: [[x,y],[x,y]] | points?: [[x,y],…] (3 to 4096, drawing coordinates) | polyline?: handle of a closed polyline, invert?: bool (hide what is inside)} → {handles, option} (query: {clips: [{handle, boundary, enabled, inverted}]}; polyline: {polylines})")
.enabled(has_doc)
.interactive(|_| Ok(Box::new(XclipM::default()))),
CommandSpec::new("xclipframe", "Xref Clip Frame", run_frame)
.params("{value?: 0 (frames hidden) | 1 (shown and plotted) | 2 (shown, not plotted; the default)} → {XCLIPFRAME}")
.enabled(has_doc)
.interactive(|_| Ok(Box::new(FrameM))),
]
}
/// The block references among `hs` (other objects are ignored, as XCLIP's selection does).
fn references(s: &Session, hs: &[Handle]) -> Vec<Handle> {
let Ok(d) = s.doc() else { return Vec::new() };
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).collect()
}
/// A rectangle's two corners as a closed outline.
fn rectangle(a: Vec2, b: Vec2) -> Vec<Vec2> {
vec![
Vec2::new(a.x.min(b.x), a.y.min(b.y)),
Vec2::new(a.x.max(b.x), a.y.min(b.y)),
Vec2::new(a.x.max(b.x), a.y.max(b.y)),
Vec2::new(a.x.min(b.x), a.y.max(b.y)),
]
}
/// The boundary a JSON call gives, in drawing coordinates, and whether it is a rectangle.
fn world_boundary(s: &Session, p: &Value) -> Result<(Vec<Vec2>, bool)> {
if let Some(r) = p.get("rectangle") {
let pts: Vec<Vec2> = r.as_array().map(|a| a.iter().filter_map(point_value).collect()).unwrap_or_default();
let [a, b] = pts.as_slice() else { return Err(bad("xclip", "`rectangle` needs two finite corners [[x,y],[x,y]]")) };
if (a.x - b.x).abs() <= 1e-12 || (a.y - b.y).abs() <= 1e-12 {
return Err(bad("xclip", "the clipping rectangle must have a width and a height"));
}
return Ok((vec![*a, *b], true));
}
if let Some(v) = p.get("polyline") {
let h = v
.as_str()
.and_then(Handle::parse_hex)
.or_else(|| v.as_u64().map(Handle))
.ok_or_else(|| bad("xclip", "`polyline` must be an object handle"))?;
let d = s.doc()?;
let e = d.entity(h).ok_or_else(|| bad("xclip", format!("no object {}", h.hex())))?;
let closed = matches!(&e.kind, EntityKind::LwPolyline(pl) if pl.closed) || matches!(&e.kind, EntityKind::Polyline3d(pl) if pl.closed);
if !closed {
return Err(bad("xclip", "select a closed polyline"));
}
let pts = cadcraft_render::clip::boundary_polygon(&e.kind).ok_or_else(|| bad("xclip", "the polyline does not enclose an area"))?;
return Ok((pts, false));
}
let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("xclip", "a boundary is required: `rectangle`, `points` or `polyline`"))?;
if !(3..=MAX_CLIP_VERTICES).contains(&pts.len()) {
return Err(bad("xclip", format!("a polygonal boundary needs 3 to {MAX_CLIP_VERTICES} points")));
}
let pts: Vec<Vec2> =
pts.iter().map(|v| point_value(v).ok_or_else(|| bad("xclip", "every boundary point must be a finite [x, y]"))).collect::<Result<_>>()?;
Ok((pts, false))
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let hs = references(s, &targets(s, p)?);
if hs.is_empty() {
return Err(bad("xclip", "select block or xref references"));
}
let has_boundary = ["rectangle", "points", "polyline"].iter().any(|k| p.get(*k).is_some());
let option = str_param(p, "option").map(str::to_ascii_lowercase).unwrap_or_else(|| if has_boundary { "new".into() } else { "query".into() });
match option.as_str() {
"new" | "n" => {
let (world, rect) = world_boundary(s, p)?;
let outline = if rect { world.first().zip(world.get(1)).map(|(a, b)| rectangle(*a, *b)).unwrap_or_default() } else { world.clone() };
if cadcraft_render::clip::PolyClip::new(&outline).is_none() {
return Err(bad("xclip", "the clipping boundary encloses no area"));
}
let invert = bool_or(p, "invert", false);
let mut clips = Vec::with_capacity(hs.len());
{
let d = s.doc()?;
for h in &hs {
let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue };
let base = d.block(&ins.block).map(|b| b.base.xy()).ok_or_else(|| bad("xclip", format!("block \"{}\" is missing", ins.block)))?;
let inv = ins.transform(base).inverse().ok_or_else(|| bad("xclip", "a reference with a zero scale can't be clipped"))?;
let points: Vec<Vec2> = world.iter().map(|q| inv.apply(*q)).collect();
// A rotated or skewed reference sees the rectangle as a polygon.
let r = ins.rotation.rem_euclid(std::f64::consts::FRAC_PI_2);
let square = r < 1e-9 || std::f64::consts::FRAC_PI_2 - r < 1e-9;
let points = if rect && !square { outline.iter().map(|q| inv.apply(*q)).collect() } else { points };
clips.push((*h, ClipBoundary { points, enabled: true, inverted: invert }));
}
}
for (h, c) in clips {
set_clip(s, h, Some(c))?;
}
}
"on" | "off" => {
let on = option == "on";
// Every reference is checked before any changes: an error leaves the drawing as it was.
let d = s.doc()?;
if !hs.iter().all(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Insert(i)) if i.clip.is_some())) {
return Err(bad("xclip", "no clipping boundary on the selected reference"));
}
// Only the flag changes, once per reference (a selection may name one many times).
let mut seen = std::collections::HashSet::new();
for h in hs.iter().filter(|h| seen.insert(**h)) {
s.doc_mut()?
.modify_entity(*h, |e| {
if let EntityKind::Insert(i) = &mut e.kind
&& let Some(c) = &mut i.clip
{
c.enabled = on;
}
})
.map_err(|e| bad("xclip", e.to_string()))?;
}
}
"delete" | "d" => {
for h in &hs {
set_clip(s, *h, None)?;
}
}
"polyline" | "p" | "generate" => {
let mut made = Vec::new();
for h in &hs {
let Some(world) = world_outline(s, *h) else { continue };
let vertices = world.into_iter().map(|p| PolyVertex { p, bulge: 0.0, start_width: 0.0, end_width: 0.0 }).collect();
let pl = cadcraft_doc::LwPolyline { vertices, closed: true, const_width: 0.0, elevation: 0.0, plinegen: false };
made.push(s.add_entity(EntityKind::LwPolyline(pl))?.hex());
}
if made.is_empty() {
return Err(bad("xclip", "no clipping boundary on the selected references"));
}
return Ok(json!({ "handles": hexes(&hs), "option": "polyline", "polylines": made }));
}
// Front and back clipping planes have no meaning in 2D: nothing changes.
"clipdepth" | "c" => return Ok(json!({ "handles": hexes(&hs), "option": "clipdepth", "ignored": true })),
"query" | "?" => {
let clips: Vec<Value> = hs
.iter()
.map(|h| {
let c = clip_of(s, *h);
json!({
"handle": h.hex(),
"boundary": world_outline(s, *h).map(|w| w.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>()),
"enabled": c.as_ref().is_some_and(|c| c.enabled),
"inverted": c.as_ref().is_some_and(|c| c.inverted),
})
})
.collect();
return Ok(json!({ "clips": clips }));
}
o => return Err(bad("xclip", format!("unknown option \"{o}\" (new, on, off, delete, polyline, clipdepth or query)"))),
}
Ok(json!({ "handles": hexes(&hs), "option": option }))
}
fn hexes(hs: &[Handle]) -> Vec<String> {
hs.iter().map(|h| h.hex()).collect()
}
fn clip_of(s: &Session, h: Handle) -> Option<ClipBoundary> {
match &s.doc().ok()?.entity(h)?.kind {
EntityKind::Insert(i) => i.clip.clone(),
_ => None,
}
}
/// Reference `h`'s boundary outline in drawing coordinates.
fn world_outline(s: &Session, h: Handle) -> Option<Vec<Vec2>> {
let d = s.doc().ok()?;
let EntityKind::Insert(i) = &d.entity(h)?.kind else { return None };
let c = i.clip.as_ref()?;
let m = i.transform(d.block(&i.block)?.base.xy());
let poly = c.polygon();
(poly.len() >= 3).then(|| poly.into_iter().map(|p| m.apply(p)).collect())
}
fn set_clip(s: &mut Session, h: Handle, c: Option<ClipBoundary>) -> Result<()> {
s.doc_mut()?
.modify_entity(h, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
i.clip = c;
}
})
.map_err(|e| bad("xclip", e.to_string()))
}
// ---------------- XCLIPFRAME ----------------
fn run_frame(s: &mut Session, p: &Value) -> Result<Value> {
let v = match p.get("value").or_else(|| p.get("XCLIPFRAME")) {
None | Some(Value::Null) => return Ok(json!({ "XCLIPFRAME": s.doc()?.header.i64("XCLIPFRAME", 2) })),
Some(v) => v.as_i64().or_else(|| v.as_str().and_then(|t| t.trim().parse().ok())),
};
let v = v.filter(|v| (0..=2).contains(v)).ok_or_else(|| bad("xclipframe", "`value` must be 0, 1 or 2"))?;
s.doc_mut()?.header.set_i64("XCLIPFRAME", v);
Ok(json!({ "XCLIPFRAME": v }))
}
struct FrameM;
impl Interactive for FrameM {
fn name(&self) -> &'static str {
"XCLIPFRAME"
}
fn prompt(&self, s: &Session) -> Prompt {
let cur = s.doc().map(|d| d.header.i64("XCLIPFRAME", 2)).unwrap_or(2);
Prompt::new("Enter new value for XCLIPFRAME", Accept::TEXT).kw(&["0", "1", "2"]).default(cur.to_string())
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let t = match i {
Input::Cancel => return Ok(Step::Cancel),
Input::Enter => return Ok(Step::Done),
Input::Text(t) | Input::Keyword(t) => t,
_ => return Ok(Step::Continue),
};
match run_frame(s, &json!({ "value": t.trim() })) {
Ok(_) => Ok(Step::Done),
Err(_) => {
s.echo("Requires an integer between 0 and 2.");
Ok(Step::Continue)
}
}
}
}
// ---------------- interactive XCLIP ----------------
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum Stage {
#[default]
Select,
Option,
DeleteOld,
Boundary,
Corner1,
Corner2,
Polygon,
Polyline,
}
#[derive(Default)]
struct XclipM {
stage: Stage,
sel: SelectPhase,
refs: Vec<Handle>,
invert: bool,
pts: Vec<Vec2>,
}
impl XclipM {
fn selected(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
self.refs = references(s, hs);
if self.refs.is_empty() {
s.echo("No block or xref references selected.");
return Ok(Step::Done);
}
self.stage = Stage::Option;
Ok(Step::Continue)
}
fn apply(&mut self, s: &mut Session, mut p: Value) -> Result<Step> {
p["handles"] = json!(hexes(&self.refs));
let r = run(s, &p);
s.set_selection(Vec::new());
match r {
Ok(_) => Ok(Step::Done),
Err(e) => {
s.echo(e.to_string());
Ok(Step::Done)
}
}
}
/// Delete: as the reference app, with nothing to delete it says so and asks again.
fn delete(&mut self, s: &mut Session) -> Result<Step> {
if !self.any_clipped(s) {
s.echo("No clip boundary found.");
return Ok(Step::Continue);
}
self.apply(s, json!({ "option": "delete" }))
}
fn any_clipped(&self, s: &Session) -> bool {
self.refs.iter().any(|h| clip_of(s, *h).is_some())
}
}
fn kw(i: &Input) -> Option<String> {
match i {
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()),
_ => None,
}
}
impl Interactive for XclipM {
fn name(&self) -> &'static str {
"XCLIP"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.selected(s, &hs);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.stage {
Stage::Select => self.sel.prompt(),
Stage::Option => Prompt::new("Enter clipping option", Accept::TEXT)
.kw(&["ON", "OFF", "Clipdepth", "Delete", "generate Polyline", "New boundary"])
.default("New"),
Stage::DeleteOld => Prompt::new("Delete old boundary(s)?", Accept::TEXT).kw(&["Yes", "No"]).default("Yes"),
Stage::Boundary => Prompt::new("Specify clipping boundary or select invert option", Accept::POINT)
.kw(&["Select polyline", "Polygonal", "Rectangular", "Invert clip"])
.default("Rectangular"),
Stage::Corner1 => Prompt::new("Specify first corner", Accept::POINT),
Stage::Corner2 => {
let p = Prompt::new("Specify opposite corner", Accept::POINT);
match self.pts.first() {
Some(a) => p.corner(*a),
None => p,
}
}
Stage::Polygon => {
let msg = if self.pts.is_empty() { "Specify first point" } else { "Specify next point" };
let p = Prompt::new(msg, Accept::POINT).base_opt(self.pts.last().copied());
if self.pts.is_empty() { p } else { p.kw(&["Undo"]) }
}
Stage::Polyline => Prompt::new("Select polyline", Accept::SELECT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if matches!(i, Input::Cancel) {
return Ok(Step::Cancel);
}
match self.stage {
Stage::Select => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.selected(s, &hs),
},
Stage::Option => {
let k = if matches!(i, Input::Enter) { Some("new".to_string()) } else { kw(&i) };
match k.as_deref() {
Some("on") => self.apply(s, json!({ "option": "on" })),
Some("off") => self.apply(s, json!({ "option": "off" })),
Some(k) if k.starts_with('c') => {
s.echo("Clip depth does not apply to a 2D drawing: front and back clipping planes are ignored.");
Ok(Step::Done)
}
Some(k) if k.starts_with('d') => self.delete(s),
Some(k) if k.starts_with('p') || k.starts_with('g') => self.apply(s, json!({ "option": "polyline" })),
Some(k) if k.starts_with('n') => {
self.stage = if self.any_clipped(s) { Stage::DeleteOld } else { Stage::Boundary };
Ok(Step::Continue)
}
_ => {
s.echo("Invalid option keyword.");
Ok(Step::Continue)
}
}
}
Stage::DeleteOld => match kw(&i).as_deref() {
None if matches!(i, Input::Enter) => {
self.stage = Stage::Boundary;
Ok(Step::Continue)
}
Some(k) if k.starts_with('y') => {
self.stage = Stage::Boundary;
Ok(Step::Continue)
}
Some(k) if k.starts_with('n') => Ok(Step::Done),
_ => Ok(Step::Continue),
},
Stage::Boundary => match (&i, kw(&i).as_deref()) {
(Input::Enter, _) => {
self.stage = Stage::Corner1;
Ok(Step::Continue)
}
(Input::Point(p), _) => {
// A point starts the default rectangle.
self.pts = vec![*p];
self.stage = Stage::Corner2;
Ok(Step::Continue)
}
(_, Some(k)) if k.starts_with('i') => {
self.invert = !self.invert;
s.echo(if self.invert {
"Inside mode - Objects inside boundary will be hidden."
} else {
"Outside mode - Objects outside boundary will be hidden."
});
Ok(Step::Continue)
}
(_, Some(k)) if k.starts_with('s') => {
self.stage = Stage::Polyline;
Ok(Step::Continue)
}
(_, Some(k)) if k.starts_with('p') => {
self.pts.clear();
self.stage = Stage::Polygon;
Ok(Step::Continue)
}
(_, Some(k)) if k.starts_with('r') => {
self.stage = Stage::Corner1;
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
},
Stage::Corner1 => match i {
Input::Point(p) => {
self.pts = vec![p];
self.stage = Stage::Corner2;
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
},
Stage::Corner2 => match (i, self.pts.first().copied()) {
(Input::Point(b), Some(a)) => self.apply(s, json!({ "option": "new", "rectangle": [[a.x, a.y], [b.x, b.y]], "invert": self.invert })),
_ => Ok(Step::Continue),
},
Stage::Polygon => match i {
Input::Point(p) if self.pts.len() < MAX_CLIP_VERTICES => {
self.pts.push(p);
Ok(Step::Continue)
}
Input::Point(_) => {
s.echo(format!("A boundary has at most {MAX_CLIP_VERTICES} points."));
Ok(Step::Continue)
}
Input::Keyword(_) | Input::Text(_) => {
self.pts.pop();
Ok(Step::Continue)
}
Input::Enter if self.pts.len() >= 3 => {
let pts: Vec<[f64; 2]> = self.pts.iter().map(|p| [p.x, p.y]).collect();
self.apply(s, json!({ "option": "new", "points": pts, "invert": self.invert }))
}
Input::Enter => {
s.echo("A polygonal boundary needs at least three points.");
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
},
Stage::Polyline => match i {
Input::Pick(hs) => match hs.first() {
Some(h) => {
let h = *h;
self.apply(s, json!({ "option": "new", "polyline": h.hex(), "invert": self.invert }))
}
None => Ok(Step::Continue),
},
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
},
}
}
}
+1 -10
View File
@@ -62,20 +62,11 @@ pub(crate) fn after_open(s: &mut Session, i: usize) {
st.saved = st.doc.clone();
let ext = st.doc.extents(&cadcraft_doc::Space::Model);
if !ext.is_empty() {
// Fit in the frame of the saved twist, which open_drawing has restored.
let twist = st.view().twist;
let (center, w, h) = crate::View::twisted_box(ext, twist);
st.set_view_quiet(crate::View { center, height: (h.max(w * px.1 / px.0.max(1.0)) * 1.1).max(1e-6), twist });
st.set_view_quiet(crate::View { center: ext.center(), height: (ext.height().max(ext.width() * px.1 / px.0.max(1.0)) * 1.1).max(1e-6) });
}
echo_all(s, &out);
}
/// The file a reference saved as `path` in a drawing at `host` is read from, if there is one
/// (xrefs and raster images look in the same places).
pub(crate) fn find_file(path: &str, host: Option<&str>) -> Option<std::path::PathBuf> {
load::find(path, host.map(std::path::Path::new), false).ok()
}
/// The open drawing's path (xref paths are relative to its folder).
fn host(s: &Session) -> Option<String> {
s.state().ok().and_then(|st| st.path.clone())
+4 -5
View File
@@ -55,11 +55,11 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["printpdf", "pdfb"])
.params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match")
.noundo(),
CommandSpec::new("attach", "Attach", |s, p| match str_param(p, "path").map(extension) {
Some(e) if super::image::is_image_ext(&e) => super::image::run_imageattach(s, p),
_ => Err(EngineError::Other("ATTACH is not available yet for this file: only raster images (PNG, JPEG, BMP, TIFF; see IMAGEATTACH) can be attached, PDF/DWF/DGN underlays can't be shown yet".into())),
CommandSpec::new("attach", "Attach", |_, _| {
Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into()))
})
.params("{path: image file, …} as IMAGEATTACH; other file types are not available yet"),
.params("not available yet")
.noundo(),
CommandSpec::new("share", "Share", |_, _| {
Err(EngineError::Other("SHARE is not available yet: there is no online service to share through; EXPORT or PUBLISH the drawing and send the file".into()))
})
@@ -642,7 +642,6 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
}
+32 -30
View File
@@ -70,19 +70,6 @@ pub fn entity_codec() -> Option<&'static EntityCodec> {
/// COMPARE (drawing comparison).
pub mod compare;
/// Decode a raster image file (PNG, JPEG, BMP, TIFF) to pixels: installed by the app with the
/// image reader, a lower layer.
pub type ImageHook = fn(&[u8]) -> std::result::Result<cadcraft_doc::Raster, String>;
static IMAGE: OnceLock<ImageHook> = OnceLock::new();
pub fn set_image_decoder(h: ImageHook) {
let _ = IMAGE.set(h);
}
pub fn image_decoder() -> Option<ImageHook> {
IMAGE.get().copied()
}
/// Hand finished file bytes to the user where there is no file system (the browser: a download).
/// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started.
pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>;
@@ -324,7 +311,6 @@ pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
let ReadFile { drawing, binary_dxf, name, path } = f;
let i = add_read(s, drawing, binary_dxf, &name, path);
super::blocks::xref::after_open(s, i);
super::image::after_open(s, i);
Ok(opened(s, i, true))
}
@@ -334,12 +320,6 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
// asks where to save instead of exporting over the original.
let path = path.filter(|p| is_drawing_file(p));
// The SHX fonts its text styles name are looked for beside the drawing too.
if let Some(dir) = path.as_deref().and_then(|p| std::path::Path::new(p).parent()) {
// A bare file name is in the current folder.
let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir };
cadcraft_fonts::shx::add_drawing_dir(&std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()));
}
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
}
@@ -383,7 +363,6 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
super::blocks::xref::after_open(s, i);
super::image::after_open(s, i);
return Ok(opened(s, i, true));
}
#[cfg(target_arch = "wasm32")]
@@ -394,7 +373,6 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Drawing.dxf");
let i = open_bytes(s, &bytes, name, None)?;
super::blocks::xref::after_open(s, i);
super::image::after_open(s, i);
Ok(opened(s, i, false))
}
@@ -623,6 +601,21 @@ pub fn save_keeps_current(_path: &std::path::Path) -> bool {
false
}
/// The mode of a new backup: no more open than the drawing (`src_mode`, group `src_gid`) nor than the
/// `.bak` it replaces (`old`: mode and group). The backup keeps the old `.bak`'s group, so when that
/// differs from the drawing's group the group bits are dropped: that group could not read the drawing.
#[cfg(unix)]
fn backup_mode(src_mode: u32, src_gid: u32, old: Option<(u32, u32)>) -> u32 {
let mut mode = src_mode & 0o777;
if let Some((old_mode, old_gid)) = old {
mode &= old_mode;
if old_gid != src_gid {
mode &= !0o070;
}
}
mode
}
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
/// to keep when there is no file yet. A file that doesn't look like a drawing
@@ -647,14 +640,10 @@ fn backup(path: &std::path::Path) -> std::io::Result<()> {
// written through.
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt;
let mut mode = src.metadata()?.permissions().mode() & 0o777;
if let Ok(m) = std::fs::symlink_metadata(&bak)
&& m.is_file()
{
mode &= m.permissions().mode();
}
Some(std::fs::Permissions::from_mode(mode))
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let sm = src.metadata()?;
let old = std::fs::symlink_metadata(&bak).ok().filter(|m| m.is_file());
Some(std::fs::Permissions::from_mode(backup_mode(sm.mode(), sm.gid(), old.map(|m| (m.mode(), m.gid())))))
};
#[cfg(not(unix))]
let permissions = None;
@@ -1015,6 +1004,19 @@ mod tests {
use crate::Session;
use crate::temp_dir::TempDir;
/// A backup is never more open than the drawing or the old `.bak`, and loses the group bits when
/// it would keep the old `.bak`'s group while the drawing is in another.
#[cfg(unix)]
#[test]
fn backup_mode_drops_group_bits_for_a_different_group() {
use super::backup_mode;
assert_eq!(backup_mode(0o640, 10, None), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 10))), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 20))), 0o600);
assert_eq!(backup_mode(0o644, 10, Some((0o644, 20))), 0o604);
assert_eq!(backup_mode(0o600, 10, Some((0o664, 10))), 0o600);
}
fn active_title(s: &Session) -> String {
s.state().unwrap().title.clone()
}
+1 -2
View File
@@ -59,8 +59,7 @@ pub(super) fn run_new(s: &mut Session, p: &Value) -> Result<Option<Value>> {
&& hi.y > lo.y
{
let center = cadcraft_geom::Vec2::new((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0);
let twist = st.view().twist;
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6), twist });
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6) });
}
Ok(Some(json!({ "index": i, "title": title, "template": path })))
}
-353
View File
@@ -1,353 +0,0 @@
//! Raster images (IMAGEATTACH, IMAGE). An IMAGE entity names its file; the pixels are decoded
//! when the drawing opens or the image is attached and kept with the open drawing
//! ([`Drawing::rasters`]), never in the file. An image whose file can't be found or read stays in
//! the drawing as a crossed-out frame.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use cadcraft_doc::{Drawing, EntityKind, Image, Raster};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::file::base64_decode;
use super::{CommandSpec, bad, f64_or, point_param, str_param};
use crate::{Result, Session};
/// Most image files loaded per drawing.
const MAX_IMAGE_FILES: usize = 1000;
/// Largest image file read (bytes).
#[cfg(not(target_arch = "wasm32"))]
const MAX_IMAGE_BYTES: u64 = 256 << 20;
/// Most decoded pixel bytes kept per drawing; images past it stay unloaded.
const MAX_LOADED_BYTES: u64 = 2 << 30;
/// Most image files decoded at the same time when a drawing opens.
const MAX_LOADERS: usize = 4;
/// Longest image path taken from a caller.
const MAX_PATH: usize = 4096;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("imageattach", "Raster Image Reference...", run_imageattach).alias(&["iat"]).params(
"{path: PNG/JPEG/BMP/TIFF file (saved as given; a relative path is relative to this drawing's folder), data?: base64 file contents \
(instead of reading `path`), at?: [x,y] lower-left corner (default 0,0), scale?: number (default 1: the image is one drawing unit \
wide), width?: width in drawing units (instead of `scale`), rotation? (degrees), name? (default: the file name)} → {name, handle, \
path, pixels: [w, h], size: [w, h]}",
),
CommandSpec::new("image", "Raster Images", run_image)
.alias(&["im"])
.params("{} → {images: [{name, path, foundAt, status: Loaded|Not Found|Not loaded, references, pixels?: [w, h]}]}")
.noundo(),
]
}
/// Whether `ext` (lower case, no dot) is an image file type IMAGEATTACH reads.
pub(crate) fn is_image_ext(ext: &str) -> bool {
matches!(ext, "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff")
}
/// Every IMAGE entity of `d`: in model space, on the layouts and in the blocks.
fn images(d: &Drawing) -> impl Iterator<Item = &Image> {
let model = std::iter::once(&d.model);
let layouts = d.layouts.iter().map(|l| &l.entities);
let blocks = d.blocks.values().map(|b| &b.entities);
model.chain(layouts).chain(blocks).flat_map(|store| store.iter()).filter_map(|e| match &e.kind {
EntityKind::Image(i) => Some(i),
_ => None,
})
}
/// The image files `d` names, each once, in the order they are first used.
fn paths(d: &Drawing) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut paths = Vec::new();
for i in images(d) {
if !i.path.trim().is_empty() && seen.insert(i.path.as_str()) {
paths.push(i.path.clone());
}
}
paths
}
/// The contents of the image file saved as `path` in a drawing at `host`.
fn read_file(path: &str, host: Option<&str>) -> std::result::Result<Vec<u8>, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let file = super::blocks::xref::find_file(path, host).ok_or("file not found")?;
let len = std::fs::metadata(&file).map_err(|e| e.to_string())?.len();
if len > MAX_IMAGE_BYTES {
return Err(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20));
}
// Read no more than the limit: the file can grow or change after the check above.
let limit = usize::try_from(MAX_IMAGE_BYTES).unwrap_or(usize::MAX);
crate::capped_read::read_capped(&file, limit).map_err(|e| {
if e.kind() == std::io::ErrorKind::InvalidData { format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20) } else { e.to_string() }
})
}
#[cfg(target_arch = "wasm32")]
{
let _ = (path, host);
Err("files are not available on the web".into())
}
}
/// Decode an image file. A decoder that panics on a damaged file fails this image only: the
/// drawing it belongs to must still open.
fn decode(bytes: &[u8]) -> std::result::Result<Raster, String> {
let hook = super::file::image_decoder().ok_or("image files can't be read in this build")?;
match std::panic::catch_unwind(|| hook(bytes)) {
Ok(r) => r,
Err(_) => Err("the file is damaged and can't be read".into()),
}
}
fn bytes_of(r: &Raster) -> u64 {
u64::try_from(r.rgba().len()).unwrap_or(u64::MAX)
}
/// Load the images of drawing `i`, just opened. Those that can't be loaded are reported and
/// stay crossed-out frames; opening never fails over an image.
pub(crate) fn after_open(s: &mut Session, i: usize) {
let Some(st) = s.docs.get_mut(i) else { return };
let paths = paths(&st.doc);
if paths.is_empty() {
return;
}
let mut rasters = st.doc.rasters.clone();
let notes = load_missing(&paths, st.path.as_deref(), &mut rasters);
if !rasters.is_empty() {
// Loading pixels is not a change to the drawing. A drawing counts as unchanged while
// it is the very one that was saved, so the saved one follows.
let unchanged = Arc::ptr_eq(&st.doc, &st.saved);
Arc::make_mut(&mut st.doc).rasters = rasters;
if unchanged {
st.saved = st.doc.clone();
}
}
for n in notes {
s.echo(n);
}
}
/// Read and decode into `rasters` the files of `paths` that are not in it yet, for a drawing at
/// `host`. Returns one message per file that could not be loaded.
fn load_missing(paths: &[String], host: Option<&str>, rasters: &mut cadcraft_doc::Rasters) -> Vec<String> {
let mut notes = Vec::new();
let mut missing: Vec<&str> = paths.iter().map(String::as_str).filter(|p| rasters.get(p).is_none()).collect();
if missing.len() > MAX_IMAGE_FILES {
missing.truncate(MAX_IMAGE_FILES);
notes.push(format!("Images: only the first {MAX_IMAGE_FILES} image files are loaded"));
}
let decoded = decode_files(&missing, host);
// Kept in the order the drawing uses them, up to the memory limit.
let mut kept_bytes = 0u64;
for (path, result) in missing.iter().zip(decoded) {
match result {
Ok(r) => {
kept_bytes = kept_bytes.saturating_add(bytes_of(&r));
if kept_bytes > MAX_LOADED_BYTES {
notes.push(over_limit(path));
break;
}
rasters.insert(path, Arc::new(r));
}
Err(e) => notes.push(format!("Image {path}: {e}")),
}
}
notes
}
fn over_limit(path: &str) -> String {
format!("Image {path}: not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20)
}
/// Read and decode the image files `paths` of a drawing at `host`, a few at a time (decoding is
/// what opening a drawing with large images waits for). The results are in the order of `paths`.
fn decode_files(paths: &[&str], host: Option<&str>) -> Vec<std::result::Result<Raster, String>> {
// Decoded bytes so far, shared by the loaders: past the limit no further file is started.
let decoded_bytes = AtomicU64::new(0);
let load = |path: &str| -> std::result::Result<Raster, String> {
if decoded_bytes.load(Ordering::Relaxed) > MAX_LOADED_BYTES {
return Err(format!("not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20));
}
let r = read_file(path, host).and_then(|bytes| decode(&bytes))?;
decoded_bytes.fetch_add(bytes_of(&r), Ordering::Relaxed);
Ok(r)
};
let mut results: Vec<Option<std::result::Result<Raster, String>>> = paths.iter().map(|_| None).collect();
let loaders = loader_count(paths.len());
if loaders > 1 {
// Each loader takes the next file nobody has started.
let next = AtomicUsize::new(0);
let work = || {
let mut done = Vec::new();
loop {
let index = next.fetch_add(1, Ordering::Relaxed);
let Some(path) = paths.get(index) else { break };
done.push((index, load(path)));
}
done
};
let finished: Vec<_> = std::thread::scope(|scope| {
let started: Vec<_> = (0..loaders).filter_map(|_| std::thread::Builder::new().spawn_scoped(scope, work).ok()).collect();
started.into_iter().filter_map(|loader| loader.join().ok()).flatten().collect()
});
for (index, result) in finished {
if let Some(slot) = results.get_mut(index) {
*slot = Some(result);
}
}
}
// One loader, or files a loader that could not start or finish left behind.
results.into_iter().zip(paths).map(|(result, path)| result.unwrap_or_else(|| load(path))).collect()
}
/// How many files are decoded at once: one per processor, at most [`MAX_LOADERS`] (each large
/// image needs several hundred megabytes while it is decoded) and no more than there are files.
fn loader_count(files: usize) -> usize {
#[cfg(not(target_arch = "wasm32"))]
let processors = std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(1);
#[cfg(target_arch = "wasm32")]
let processors = 1;
processors.min(MAX_LOADERS).min(files)
}
/// The file name without folders and extension (either separator).
fn stem(path: &str) -> String {
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
match file.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
_ => file.to_string(),
}
}
/// The definition name for a new image of `path`: the name that file already has in `d`, else
/// `wanted`, numbered (`wanted_2`, `wanted_3`, …) when another file has taken it.
fn def_name(d: &Drawing, path: &str, wanted: &str) -> String {
if let Some(i) = images(d).find(|i| i.path == path && !i.name.is_empty()) {
return i.name.clone();
}
// The names other files have, compared without regard to case.
let taken: BTreeSet<String> = images(d).filter(|i| i.path != path).map(|i| i.name.to_ascii_lowercase()).collect();
let free = |name: &str| !taken.contains(&name.to_ascii_lowercase());
if free(wanted) {
return wanted.to_string();
}
// One of these is free: there are more candidates than names taken.
let candidates = 2..taken.len().saturating_add(3);
candidates.map(|n| format!("{wanted}_{n}")).find(|name| free(name)).unwrap_or_else(|| wanted.to_string())
}
const ID: &str = "imageattach";
pub(crate) fn run_imageattach(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(ID, "`path` is required"))?.to_string();
if path.len() > MAX_PATH {
return Err(bad(ID, "`path` is too long"));
}
let place = placement(p)?;
let raster = pixels(s, p, &path)?;
let (w, h) = (f64::from(raster.width()), f64::from(raster.height()));
// One pixel in drawing units, along the image's bottom edge (`u`) and its left edge (`v`).
let pixel = place.width / w;
let u = Vec2::from_angle(place.rotation) * pixel;
let v = u.perp();
if !(pixel.is_normal() && (pixel * h).is_normal() && u.x.is_finite() && u.y.is_finite()) {
return Err(bad(ID, "the image would be too small or too large to draw"));
}
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| stem(&path));
let name = def_name(s.doc()?, &path, &wanted);
let image = Image {
insert: place.at.to3(0.0),
u: u.to3(0.0),
v: v.to3(0.0),
size: Vec2::new(w, h),
path: path.clone(),
name: name.clone(),
..Image::default()
};
s.doc_mut()?.rasters.insert(&path, raster);
let handle = s.add_entity(EntityKind::Image(image))?;
Ok(json!({ "name": name, "handle": handle.hex(), "path": path, "pixels": [w, h], "size": [place.width, pixel * h] }))
}
/// Where and how large IMAGEATTACH puts the image.
struct Placement {
/// The lower-left corner.
at: Vec2,
/// Radians, counter-clockwise.
rotation: f64,
/// The image's width in drawing units.
width: f64,
}
/// `at`, `rotation` and the width (`width`, else `scale`: an image is one unit wide at scale 1).
fn placement(p: &Value) -> Result<Placement> {
let at = point_param(p, "at").unwrap_or(Vec2::ZERO);
if !(at.x.is_finite() && at.y.is_finite()) {
return Err(bad(ID, "`at` must be a point"));
}
let width = match p.get("width") {
Some(v) => v.as_f64().filter(|w| w.is_finite()).ok_or_else(|| bad(ID, "`width` must be a number"))?,
None => f64_or(p, "scale", 1.0),
};
if !(width.is_finite() && width > 0.0) {
return Err(bad(ID, "`scale` and `width` must be greater than zero"));
}
Ok(Placement { at, rotation: f64_or(p, "rotation", 0.0).to_radians(), width })
}
/// The pixels of the image at `path`: decoded from `data` when the caller hands the file over,
/// else the ones already loaded for that path, else read from the file.
fn pixels(s: &Session, p: &Value, path: &str) -> Result<Arc<Raster>> {
let bytes = match p.get("data") {
Some(data) => {
let text = data.as_str().ok_or_else(|| bad(ID, "`data` must be base64 text"))?;
base64_decode(text).ok_or_else(|| bad(ID, "`data` is not valid base64"))?
}
None => {
if let Some(loaded) = s.doc()?.rasters.get(path) {
return Ok(loaded.clone());
}
let host = s.state()?.path.clone();
read_file(path, host.as_deref()).map_err(|e| bad(ID, format!("{path}: {e}")))?
}
};
Ok(Arc::new(decode(&bytes).map_err(|e| bad(ID, format!("{path}: {e}")))?))
}
fn run_image(s: &mut Session, _p: &Value) -> Result<Value> {
let host = s.state()?.path.clone();
let d = s.doc()?;
// Per file: its definition name and how many images show it.
let mut uses: BTreeMap<&str, (&str, usize)> = BTreeMap::new();
for i in images(d) {
uses.entry(i.path.as_str()).or_insert((i.name.as_str(), 0)).1 += 1;
}
let rows: Vec<Value> = paths(d)
.iter()
.map(|path| {
let (name, references) = uses.get(path.as_str()).copied().unwrap_or_default();
let mut row = describe(d, path, host.as_deref());
row["name"] = json!(name);
row["references"] = json!(references);
row
})
.collect();
Ok(json!({ "images": rows }))
}
/// The state of image file `path` of drawing `d` (saved at `host`): where it is and whether its
/// pixels are loaded.
fn describe(d: &Drawing, path: &str, host: Option<&str>) -> Value {
let loaded = d.rasters.get(path);
let found_at = super::blocks::xref::find_file(path, host).map(|f| f.to_string_lossy().into_owned());
let status = match (loaded, &found_at) {
(Some(_), _) => "Loaded",
(None, None) => "Not Found",
// The file is there but could not be read as an image.
(None, Some(_)) => "Not loaded",
};
json!({ "path": path, "foundAt": found_at, "status": status, "pixels": loaded.map(|r| json!([r.width(), r.height()])) })
}
-1
View File
@@ -524,7 +524,6 @@ fn viewport_json(h: Handle, v: &Viewport) -> Value {
"height": v.height,
"viewCenter": [v.view_center.x, v.view_center.y],
"viewHeight": v.view_height,
"twist": v.view_twist().to_degrees(),
"scale": if v.view_height > 0.0 { v.height / v.view_height } else { 0.0 },
"locked": v.locked,
"on": v.on,
+1 -1
View File
@@ -283,7 +283,7 @@ fn mspace_edits_model_through_viewport() {
s.cmdline("circle 10,10 3").unwrap();
assert_eq!(s.doc().unwrap().model.len(), before + 1);
// Zooming changes the viewport's model view, not the sheet.
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0, twist: v.twist });
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 });
let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1;
assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9);
assert_eq!(s.state().unwrap().paper_view(), pv);
+1 -1
View File
@@ -329,7 +329,7 @@ pub(super) fn run_vpclip(s: &mut Session, p: &Value) -> Result<Value> {
let k = cur.view_height / cur.height.max(1e-12);
let mut v = cur;
let c = b.center();
let view_center = v.paper_to_model(c);
let view_center = v.view_center + (c - v.center.xy()) * k;
let view_height = b.height() * k;
if view_center.is_finite() && view_height.is_finite() && view_height > 0.0 {
v.view_center = view_center;
+1 -1
View File
@@ -109,7 +109,7 @@ fn parse_color(t: &str) -> Result<Color> {
fn viewport_at(s: &Session, p: Vec2) -> Option<Handle> {
let st = s.state().ok()?;
let at = match st.active_viewport() {
Some((_, vp)) => vp.model_to_paper(p),
Some((_, vp)) => vp.center.xy() + (p - vp.view_center) * (vp.height / vp.view_height),
None => p,
};
super::viewports::viewport_at(s, at)
-1
View File
@@ -54,7 +54,6 @@ pub(super) fn apply(s: &Session, k: EntityKind, c: &Content) -> Result<EntityKin
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
if *center {
// Move the block so the centre of its extents lands on the anchor.
+2 -1
View File
@@ -182,7 +182,8 @@ fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>)
};
let m = match viewport_of(s, &layout, vp) {
Some(v) => {
let to_paper = v.model_to_paper_matrix();
let k = v.view_height / v.height;
let to_paper = Mat3::translate(v.center.xy()).then_before(Mat3::scale(1.0 / k, 1.0 / k)).then_before(Mat3::translate(-v.view_center));
if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper }
}
None => Mat3::IDENTITY,
-1
View File
@@ -411,7 +411,6 @@ fn chspace_json_and_command_line() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Default::default(), EntityKind::Viewport(vp)).unwrap();
let r = s.execute("chspace", &json!({"handles": [l]})).unwrap();
+18 -50
View File
@@ -1,5 +1,5 @@
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), the view
//! twist (DVIEW TWist), layout switching.
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), layout
//! switching.
use cadcraft_doc::{Space, entity_bounds};
use cadcraft_geom::{Bounds2, Vec2};
@@ -10,7 +10,6 @@ use super::{CommandSpec, bad, bool_or, f64_or, f64_req, ok, point_param, point_r
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
mod dview;
mod named_views;
mod vports;
@@ -96,13 +95,10 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["r"])
.noundo()
.transparent(),
dview::spec(),
CommandSpec::new("view.set", "Set View", run_view_set)
.params("{center: [x,y], height, twist?: degrees (default: keep the current twist; in a layout viewport it is the viewport's twist, an undoable edit; not in Paper space)}")
.noundo(),
CommandSpec::new("view.set", "Set View", run_view_set).params("{center: [x,y], height}").noundo(),
CommandSpec::new("view.get", "Get View", |s, _| {
let v = s.state()?.view();
Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "twist": v.twist.to_degrees(), "viewportPx": [s.viewport_px.0, s.viewport_px.1]}))
Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "viewportPx": [s.viewport_px.0, s.viewport_px.1]}))
})
.noundo(),
CommandSpec::new("layout.set", "Switch Layout", run_layout_set).params("{name: \"Model\" | layout name}").noundo(),
@@ -124,11 +120,8 @@ fn fit(s: &mut Session, b: Bounds2) -> Result<()> {
let (w, h) = s.viewport_px;
let st = s.state_mut()?;
let aspect = st.zoom_aspect(w / h.max(1.0));
// A twisted view fits the box the bounds make on screen.
let twist = st.zoom_frame().twist;
let (center, bw, bh) = View::twisted_box(b, twist);
let height = bh.max(bw / aspect.max(1e-6)).max(1e-9);
st.set_zoom_frame(View { center, height, twist });
let height = b.height().max(b.width() / aspect.max(1e-6)).max(1e-9);
st.set_zoom_frame(View { center: b.center(), height });
Ok(())
}
@@ -157,18 +150,18 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
let f = if mode == "in" { 2.0 } else { 0.5 };
let st = s.state_mut()?;
let v = st.zoom_frame();
st.set_zoom_frame(View { height: v.height / f, ..v });
st.set_zoom_frame(View { center: v.center, height: v.height / f });
}
"center" | "c" | "dynamic" | "d" => {
let c = point_req("zoom", p, "center")?;
let cur = s.state()?.zoom_frame();
let h = if p.get("factor").is_some() { factor_height(s, p, cur.height)? } else { f64_or(p, "height", cur.height) };
s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9), ..cur });
let cur = s.state()?.zoom_frame().height;
let h = if p.get("factor").is_some() { factor_height(s, p, cur)? } else { f64_or(p, "height", cur) };
s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9) });
}
"scale" | "s" => {
let v = s.state()?.zoom_frame();
let height = factor_height(s, p, v.height)?;
s.state_mut()?.set_zoom_frame(View { height, ..v });
s.state_mut()?.set_zoom_frame(View { center: v.center, height });
}
"realtime" => {
let v = s.state()?.zoom_frame();
@@ -176,7 +169,7 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
if f <= 0.0 {
return Err(bad("zoom", "factor must be positive"));
}
s.state_mut()?.set_zoom_frame(View { height: (v.height / f).clamp(1e-9, 1e12), ..v });
s.state_mut()?.set_zoom_frame(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
}
"object" | "o" => {
let hs = targets(s, p)?;
@@ -249,8 +242,7 @@ fn pan_frac(s: &mut Session, f: Vec2) -> Result<Value> {
let st = s.state_mut()?;
let v = st.view();
let ww = v.height * w / h.max(1.0);
// Screen directions: in a twisted view the drawing is turned by the twist.
st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height).rotate(-v.twist), ..v });
st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height), height: v.height });
ok()
}
@@ -266,7 +258,7 @@ fn run_pan(s: &mut Session, p: &Value) -> Result<Value> {
let b = point_req("pan", p, "to")?;
v.center - (b - a)
};
st.set_view(View { center: c, ..v });
st.set_view(View { center: c, height: v.height });
ok()
}
@@ -276,33 +268,9 @@ fn run_view_set(s: &mut Session, p: &Value) -> Result<Value> {
if h <= 0.0 {
return Err(bad("view.set", "height must be positive"));
}
let twist = match p.get("twist") {
Some(t) => t.as_f64().filter(|t| t.is_finite()).ok_or_else(|| bad("view.set", "twist must be a number of degrees"))?.to_radians(),
None => s.state()?.view().twist,
};
// As DVIEW TWist: the Model tab's view or the active layout viewport turns; a layout sheet
// (paper space) is never twisted.
let target = match dview::target(s) {
Ok(t) => Some(t),
Err(_) if twist == 0.0 => None,
Err(e) => return Err(e),
};
let before = (s.state()?.doc.clone(), s.state()?.selection.clone());
let twist = match target {
Some(t @ dview::Target::Viewport(_)) => {
dview::set_twist(s, t, twist)?;
s.state()?.view().twist
}
Some(dview::Target::Model) => cadcraft_geom::norm_angle(twist),
None => 0.0,
};
if !s.state_mut()?.set_view(View { center: c, height: h, twist }) {
s.state_mut()?.doc = before.0;
if !s.state_mut()?.set_view(View { center: c, height: h }) {
return Err(bad("view.set", "the view is too large to show. Nothing was changed"));
}
if let Some(dview::Target::Viewport(h)) = target {
dview::record_viewport_edit(s, before, h)?;
}
ok()
}
@@ -326,7 +294,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
let page = st.doc.layout(n).map(|l| l.page.clone()).unwrap_or_default();
let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };
let unit = if st.doc.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 1.0 / 25.4 };
st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15, twist: 0.0 });
st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15 });
}
s.touch();
ok()
@@ -473,7 +441,7 @@ impl Interactive for ZoomM {
if st.view_history.last() == Some(&b.screen) {
st.view_history.pop();
}
st.set_zoom_frame(View { center: b.center, height: b.height, twist: b.frame.twist });
st.set_zoom_frame(View { center: b.center, height: b.height });
return Ok(Step::Done);
}
(ZoomStage::Dynamic(mut b), Input::Point(p)) => {
@@ -597,7 +565,7 @@ impl Session {
let f = 2f64.powf((-dy / half).clamp(-16.0, 16.0));
let st = self.state_mut()?;
let v = st.view();
st.set_view_quiet(View { height: (v.height / f).clamp(1e-9, 1e12), ..v });
st.set_view_quiet(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
self.touch();
Ok(())
}
-441
View File
@@ -1,441 +0,0 @@
//! DVIEW: the dynamic view, on CADCraft's 2D plan views.
//!
//! It starts with "Select objects or <use DVIEWBLOCK>:": the objects
//! picked are the preview set, and Enter with nothing picked previews the DVIEWBLOCK block (the
//! drawing's own block of that name, else a built-in sample house). While DVIEW runs only the
//! preview set is drawn ([`Interactive::display_only`]); the whole drawing is regenerated when it
//! ends. Then it repeats "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/
//! Off/Undo]:" until Enter. TWist, PAn, Zoom, Off and Undo work; the options that need a 3D
//! camera say they are not available yet and stay at the option prompt.
//!
//! DVIEW works on the Model tab's view, or on the active (double-clicked) layout viewport; on a
//! layout with no active viewport it is not available. A Model tab twist is a view change like
//! ZOOM (view history, not undo). A layout viewport's twist belongs to the viewport entity, so a
//! DVIEW that changed it is one undo step.
use std::sync::Arc;
use cadcraft_doc::{Entity, EntityKind, Handle, Insert, Line, Space};
use cadcraft_geom::{Vec2, Vec3};
use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::{CommandSpec, bad, f64_req};
use crate::{Accept, DisplayOnly, EngineError, Input, Interactive, Prompt, Result, Session, Snapshot, Step, View};
/// What DVIEW (and a view twist asked for anywhere) says on a layout with no active viewport.
pub(super) const PAPER_SPACE: &str = "This command is not available in Paper space.";
/// The option prompt's keywords, in prompt order.
const OPTIONS: &[&str] = &["CAmera", "TArget", "Distance", "POints", "PAn", "Zoom", "TWist", "CLip", "Hide", "Off", "Undo"];
/// The block DVIEW previews when nothing is selected.
const DVIEWBLOCK: &str = "DVIEWBLOCK";
pub(super) fn spec() -> CommandSpec {
CommandSpec::new("dview", "DView", run_dview)
.alias(&["dv"])
.params("{twist: degrees counterclockwise} (DVIEW TWist: the Model tab's view twist, or the active layout viewport's, an undoable edit; not in Paper space)")
.noundo()
.interactive(|s| {
// Refused as it starts on a layout with no active viewport.
target(s)?;
Ok(Box::new(DviewM::default()))
})
}
/// The view DVIEW changes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Target {
/// The Model tab's view.
Model,
/// The active (MSPACE) layout viewport: its own view twist.
Viewport(Handle),
}
/// The view DVIEW changes: the active layout viewport, else the Model tab's view. A layout with
/// no active viewport has none.
pub(super) fn target(s: &Session) -> Result<Target> {
let st = s.state()?;
if let Some((h, _)) = st.active_viewport() {
return Ok(Target::Viewport(h));
}
if st.space == Space::Model {
return Ok(Target::Model);
}
Err(EngineError::Other(PAPER_SPACE.into()))
}
/// A twist kept as files hold it: [0, 2π).
fn normal(twist: f64) -> f64 {
cadcraft_geom::norm_angle(twist)
}
/// The active viewport's twist, if it is `h`.
fn viewport_twist(s: &Session, h: Handle) -> Option<f64> {
s.state().ok()?.active_viewport().filter(|(vh, _)| *vh == h).map(|(_, vp)| vp.view_twist())
}
/// Turn `target`'s view to `twist` radians. The Model tab's twist is a view change (view
/// history, like ZOOM); a viewport's is a change to the viewport entity, which the caller
/// records as an undo step ([`record_viewport_edit`]).
pub(super) fn set_twist(s: &mut Session, target: Target, twist: f64) -> Result<()> {
if !twist.is_finite() {
return Err(bad("dview", "the twist must be a finite angle"));
}
let twist = normal(twist);
match target {
Target::Model => {
let st = s.state_mut()?;
let v = st.view();
st.set_view(View { twist, ..v });
}
Target::Viewport(h) => {
if viewport_twist(s, h) == Some(twist) {
return Ok(());
}
// A locked viewport keeps its view, the twist with it.
if s.state()?.active_viewport().is_some_and(|(vh, vp)| vh == h && vp.locked) {
return Err(EngineError::Other("The viewport is locked.".into()));
}
let space = s.state()?.space.clone();
let d = s.doc_mut()?;
if let Some(store) = d.space_mut(&space) {
store.modify(h, |e| {
if let EntityKind::Viewport(vp) = &mut e.kind {
vp.twist = twist;
}
});
}
}
}
Ok(())
}
/// Record a layout viewport's twist change as one undo step, `before` being the drawing (and
/// selection) before it. Nothing when the twist did not change, and nothing inside a LISP routine
/// (its own single step holds it).
pub(super) fn record_viewport_edit(s: &mut Session, before: (Arc<cadcraft_doc::Drawing>, Vec<Handle>), h: Handle) -> Result<()> {
let old = before.0.entity(h).and_then(|e| match &e.kind {
EntityKind::Viewport(vp) => Some(vp.view_twist()),
_ => None,
});
if old == viewport_twist(s, h) || s.lisp_groups_undo() {
return Ok(());
}
let st = s.state_mut()?;
if !Arc::ptr_eq(&before.0, &st.doc) {
st.push_undo(Snapshot { label: "DView".into(), doc: before.0, selection: before.1 });
st.revision += 1;
}
Ok(())
}
/// The drawing and selection now, for [`record_viewport_edit`].
fn snapshot(s: &Session) -> Result<(Arc<cadcraft_doc::Drawing>, Vec<Handle>)> {
let st = s.state()?;
Ok((st.doc.clone(), st.selection.clone()))
}
fn run_dview(s: &mut Session, p: &Value) -> Result<Value> {
let t = target(s)?;
let deg = f64_req("dview", p, "twist")?;
let before = snapshot(s)?;
set_twist(s, t, deg.to_radians())?;
if let Target::Viewport(h) = t {
record_viewport_edit(s, before, h)?;
}
let v = s.state()?.view();
Ok(json!({ "twist": v.twist.to_degrees() }))
}
/// The built-in DVIEWBLOCK: a house front in the unit square, origin at the lower-left corner
/// (the size and origin a DVIEWBLOCK of one's own is expected to have). CADCraft's own drawing.
fn sample_house() -> Vec<(Vec2, Vec2)> {
let p = Vec2::new;
let mut lines = Vec::new();
let mut poly = |pts: &[Vec2], closed: bool| {
for w in pts.windows(2) {
lines.push((w[0], w[1]));
}
if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) {
lines.push((*a, *b));
}
};
// Walls.
poly(&[p(0.1, 0.0), p(0.9, 0.0), p(0.9, 0.6), p(0.1, 0.6)], true);
// Roof with eaves, and a chimney on its right slope.
poly(&[p(0.0, 0.55), p(0.5, 1.0), p(1.0, 0.55)], false);
poly(&[p(0.7, 0.82), p(0.7, 0.95), p(0.8, 0.95), p(0.8, 0.73)], false);
// Door.
poly(&[p(0.42, 0.0), p(0.42, 0.35), p(0.58, 0.35), p(0.58, 0.0)], false);
// Two windows with their bars.
for x in [0.17, 0.67] {
poly(&[p(x, 0.25), p(x + 0.16, 0.25), p(x + 0.16, 0.45), p(x, 0.45)], true);
poly(&[p(x + 0.08, 0.25), p(x + 0.08, 0.45)], false);
poly(&[p(x, 0.35), p(x + 0.16, 0.35)], false);
}
lines
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Stage {
#[default]
Select,
Option,
Twist,
PanBase,
PanSecond(Vec2),
Zoom,
}
/// DVIEW, interactive.
#[derive(Default)]
struct DviewM {
stage: Stage,
sel: SelectPhase,
/// The preview set; empty with `sample` set: the DVIEWBLOCK.
preview: Vec<Handle>,
/// Where the DVIEWBLOCK preview stands: its lower-left corner and size (drawing units), fixed
/// as the options start so that PAn and Zoom move the view, not the house.
sample: Option<(Vec2, f64)>,
/// The views before each DVIEW action, for Undo.
undo: Vec<View>,
/// What DVIEW works on, and the drawing as it started (a viewport's twist change is one
/// undo step).
target: Option<Target>,
before: Option<(Arc<cadcraft_doc::Drawing>, Vec<Handle>)>,
}
impl DviewM {
fn target(&self, s: &Session) -> Result<Target> {
match self.target {
Some(t) => Ok(t),
None => target(s),
}
}
/// Start the options with this preview set (empty: the DVIEWBLOCK).
fn options(&mut self, s: &mut Session, picked: Vec<Handle>) -> Result<Step> {
// Objects picked at the prompt don't stay selected.
s.set_selection(Vec::new());
if picked.is_empty() {
let v = s.state()?.view();
let size = v.height * 0.5;
self.sample = Some((v.center - Vec2::new(size, size) * 0.5, size));
}
self.preview = picked;
self.stage = Stage::Option;
Ok(Step::Continue)
}
/// Show view `v` (centre, height and twist) on the target.
fn show(&self, s: &mut Session, v: View) -> Result<()> {
let t = self.target(s)?;
let before = s.state()?.doc.clone();
let twist = normal(v.twist);
if let Target::Viewport(_) = t {
// The twist is the viewport's; the view inside it then moves as asked.
set_twist(s, t, twist)?;
}
if !s.state_mut()?.set_view(View { twist, ..v }) {
s.state_mut()?.doc = before;
return Err(EngineError::Other("The view is too large to show.".into()));
}
Ok(())
}
/// Apply a view as one DVIEW action (Undo takes it back).
fn change(&mut self, s: &mut Session, f: impl FnOnce(View) -> View) -> Result<()> {
let v = s.state()?.view();
self.show(s, f(v))?;
self.undo.push(v);
Ok(())
}
/// The command ends: a viewport twist change becomes one undo step, and the whole drawing is
/// regenerated.
fn end(&mut self, s: &mut Session) -> Result<()> {
if let (Some(Target::Viewport(h)), Some(before)) = (self.target, self.before.take()) {
record_viewport_edit(s, before, h)?;
}
s.touch();
Ok(())
}
}
impl Interactive for DviewM {
fn name(&self) -> &'static str {
"DVIEW"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.target = Some(target(s)?);
self.before = Some(snapshot(s)?);
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match self.stage {
Stage::Select if self.sel.removing => self.sel.prompt(),
Stage::Select => Prompt::new("Select objects or", Accept::SELECT).default("use DVIEWBLOCK"),
Stage::Option => Prompt::new("Enter option", Accept::TEXT).kw(OPTIONS),
Stage::Twist => {
let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
let cur = s.state().map(|st| st.view().twist).unwrap_or(0.0);
// Offered as it would be typed: clockwise is positive when ANGDIR is 1.
let cur = if s.angle_settings().clockwise { -cur } else { cur };
let center = s.state().map(|st| st.view().center).ok();
Prompt::new("Specify view twist angle", Accept::POINT_OR_NUMBER).default(crate::units::format_angle(cur, au, ap)).base_opt(center)
}
Stage::PanBase => Prompt::new("Specify displacement base point", Accept::POINT),
Stage::PanSecond(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
Stage::Zoom => Prompt::new("Specify zoom scale factor", Accept::NUMBER).default("1"),
}
}
fn cancelled(&mut self, s: &mut Session) {
if let Err(e) = self.end(s) {
s.echo(e.to_string());
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.stage {
Stage::Select => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => self.options(s, Vec::new()),
SelOutcome::Done(hs) => self.options(s, hs),
},
Stage::Option => {
let word = match &i {
Input::Enter => {
self.end(s)?;
return Ok(Step::Done);
}
Input::Keyword(t) | Input::Text(t) => self.prompt(s).match_keyword(t),
_ => None,
};
match word.as_deref() {
Some("TWist") => self.stage = Stage::Twist,
Some("PAn") => self.stage = Stage::PanBase,
Some("Zoom") => self.stage = Stage::Zoom,
// Perspective is never on in a 2D view: there is nothing to turn off.
Some("Off") => {}
Some("Undo") => {
if let Some(v) = self.undo.pop() {
self.show(s, v)?;
}
}
// CAmera, TArget, Distance, POints, CLip and Hide need a 3D view.
Some(other) => s.echo(format!("DVIEW {other} is not available yet.")),
None => return Err(EngineError::Other("Invalid option keyword.".into())),
}
Ok(Step::Continue)
}
Stage::Twist => {
let twist = match &i {
Input::Enter => None,
Input::Point(p) => {
let c = s.state()?.view().center;
let d = *p - c;
if d.len() < 1e-12 {
return Err(EngineError::Other("Requires a valid angle.".into()));
}
Some(d.y.atan2(d.x))
}
Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None,
Input::Text(t) | Input::Keyword(t) => {
Some(s.angle_settings().rotation(t).ok_or_else(|| EngineError::Other("Requires a valid angle.".into()))?)
}
_ => return Ok(Step::Continue),
};
if let Some(a) = twist {
self.change(s, |v| View { twist: normal(a), ..v })?;
}
self.stage = Stage::Option;
Ok(Step::Continue)
}
Stage::PanBase => match i {
Input::Point(p) => {
self.stage = Stage::PanSecond(p);
Ok(Step::Continue)
}
Input::Enter => {
self.stage = Stage::Option;
Ok(Step::Continue)
}
_ => Err(EngineError::Other("Invalid point.".into())),
},
Stage::PanSecond(b) => match i {
Input::Point(p) => {
self.change(s, |v| View { center: v.center - (p - b), ..v })?;
self.stage = Stage::Option;
Ok(Step::Continue)
}
Input::Enter => {
self.stage = Stage::Option;
Ok(Step::Continue)
}
_ => Err(EngineError::Other("Invalid point.".into())),
},
Stage::Zoom => {
let f = match &i {
Input::Enter => None,
Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None,
Input::Text(t) | Input::Keyword(t) => match number(t) {
Some(f) if f.is_finite() && f > 0.0 => Some(f),
_ => return Err(EngineError::Other("Requires a positive number.".into())),
},
_ => return Ok(Step::Continue),
};
if let Some(f) = f.filter(|f| *f != 1.0) {
self.change(s, |v| View { height: v.height / f, ..v })?;
}
self.stage = Stage::Option;
Ok(Step::Continue)
}
}
}
fn display_only(&self, s: &Session) -> Option<DisplayOnly> {
if self.stage == Stage::Select {
return None;
}
let mut extra = Vec::new();
if let Some((corner, size)) = self.sample {
let common = s.current_common();
let d = s.doc().ok()?;
if d.block(DVIEWBLOCK).is_some() {
// The drawing's own DVIEWBLOCK, defined in a 1 by 1 unit area.
let ins = Insert {
block: DVIEWBLOCK.into(),
insert: corner.to3(0.0),
scale: Vec3::new(size, size, size),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
extra.push(Entity { handle: Handle(0), common, kind: EntityKind::Insert(ins) });
} else {
extra.extend(sample_house().into_iter().map(|(a, b)| Entity {
handle: Handle(0),
common: common.clone(),
kind: EntityKind::Line(Line { a: (corner + a * size).to3(0.0), b: (corner + b * size).to3(0.0) }),
}));
}
}
Some(DisplayOnly { handles: self.preview.clone(), extra })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sample_house_fits_the_unit_square() {
let lines = sample_house();
assert!(lines.len() > 10);
for (a, b) in lines {
for q in [a, b] {
assert!((0.0..=1.0).contains(&q.x) && (0.0..=1.0).contains(&q.y), "{q:?}");
}
}
}
}
+5 -18
View File
@@ -110,25 +110,15 @@ fn save(s: &mut Session, name: &str, window: Option<Bounds2>, snapshot: bool) ->
(Space::Paper(l), false) => Some(l.clone()),
_ => None,
};
// The view this saves: the viewport's model view inside an MSPACE viewport, else the screen.
let frame = if in_viewport { st.zoom_frame() } else { st.view() };
// The view twist goes with the view (paper space has none).
let twist = if layout.is_some() { 0.0 } else { frame.twist };
let (center, height, width) = match window {
Some(b) => {
if !(b.width() > 1e-12 && b.height() > 1e-12 && b.min.is_finite() && b.max.is_finite()) {
return Err(EngineError::Other("The window has no area.".into()));
}
// In a twisted view the window's box on screen is larger than its world box.
let (center, width, height) = crate::View::twisted_box(b, twist);
// Turning corners near the number limit can overflow: save no view that can't be restored.
if !(center.is_finite() && width.is_finite() && height.is_finite() && width > 1e-12 && height > 1e-12) {
return Err(EngineError::Other("The window is too large for a twisted view.".into()));
}
(center, height, width)
(b.center(), b.height(), b.width())
}
None => {
let f = frame;
let f = if in_viewport { st.zoom_frame() } else { st.view() };
let aspect = if in_viewport { st.zoom_aspect(w / h.max(1.0)) } else { w / h.max(1.0) };
(f.center, f.height, f.height * aspect)
}
@@ -149,7 +139,7 @@ fn save(s: &mut Session, name: &str, window: Option<Bounds2>, snapshot: bool) ->
}
d.views.retain(|v| !v.name.eq_ignore_ascii_case(old));
}
d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist, layout });
d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist: 0.0, layout });
let msg = if replaced.is_some() { format!("View \"{name}\" replaced.") } else { format!("View \"{name}\" saved.") };
Ok(json!({ "name": name, "center": [center.x, center.y], "height": height, "width": width, "message": msg }))
}
@@ -178,7 +168,7 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
let st = s.state_mut()?;
st.mspace = None;
let height = v.height.max(v.width / (w / h.max(1.0)).max(1e-9));
st.set_view(View { center: v.center, height, twist: 0.0 });
st.set_view(View { center: v.center, height });
}
None => {
// A model space view: into the active MSPACE viewport, else on the Model tab.
@@ -187,11 +177,8 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
}
let (w, h) = s.viewport_px;
let st = s.state_mut()?;
// In an unlocked MSPACE viewport the saved twist turns the viewport (an edit of the
// viewport, recorded as the command's undo step).
let aspect = st.zoom_aspect(w / h.max(1.0)).max(1e-9);
let twist = cadcraft_geom::norm_angle(v.twist);
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect), twist });
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect) });
}
}
let mut msg = format!("View \"{}\" restored.", v.name);
+5 -5
View File
@@ -104,7 +104,7 @@ fn rect(t: &VportTile) -> Rect {
}
fn view_of(t: &VportTile) -> View {
View { center: t.center, height: t.height, twist: if t.twist.is_finite() { t.twist } else { 0.0 } }
View { center: t.center, height: t.height }
}
fn active_config(d: &Drawing) -> Option<&VportConfig> {
@@ -117,7 +117,7 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec<VportTile>, usize) {
let now = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v).unwrap_or_default();
let Some(cfg) = active_config(&st.doc) else {
let aspect = px.0 / px.1.max(1.0);
return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect, twist: now.twist }], 0);
return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect }], 0);
};
let mut tiles = cfg.tiles.clone();
let synced =
@@ -125,11 +125,11 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec<VportTile>, usize) {
let active = if synced { st.tiles.active.min(tiles.len() - 1) } else { 0 };
if synced {
for (t, v) in tiles.iter_mut().zip(&st.tiles.views) {
(t.center, t.height, t.twist) = (v.center, v.height, v.twist);
(t.center, t.height) = (v.center, v.height);
}
}
if let Some(t) = tiles.get_mut(active) {
(t.center, t.height, t.twist) = (now.center, now.height, now.twist);
(t.center, t.height) = (now.center, now.height);
}
// Screen aspects from the current tile's size in pixels.
if let Some(cur) = tiles.get(active).copied() {
@@ -171,7 +171,7 @@ fn summary(s: &Session) -> Result<Value> {
let rows: Vec<Value> = tiles
.iter()
.enumerate()
.map(|(i, t)| json!({ "index": i + 1, "min": [t.min.x, t.min.y], "max": [t.max.x, t.max.y], "center": [t.center.x, t.center.y], "height": t.height, "twist": t.twist.to_degrees(), "current": i == active }))
.map(|(i, t)| json!({ "index": i + 1, "min": [t.min.x, t.min.y], "max": [t.max.x, t.max.y], "center": [t.center.x, t.center.y], "height": t.height, "current": i == active }))
.collect();
Ok(json!({ "tiles": rows }))
}
+9 -46
View File
@@ -1,5 +1,5 @@
use super::view::{ModelTiles, View, usable_view};
use super::{Arc, Drawing, Handle, Space, Vec2, blockedit, undoctl};
use super::{Arc, Drawing, Handle, Space, blockedit, undoctl};
#[derive(Clone, Debug)]
pub struct Snapshot {
@@ -80,7 +80,6 @@ impl DocState {
pub(crate) fn swap_in(&mut self, snap: Snapshot) -> Snapshot {
let cur = Snapshot { label: snap.label, doc: std::mem::replace(&mut self.doc, snap.doc), selection: std::mem::take(&mut self.selection) };
self.follow_layout(&cur.doc);
self.reconcile_untiled_twist();
self.revision += 1;
cur
}
@@ -136,8 +135,7 @@ impl DocState {
match self.active_viewport() {
Some((_, vp)) => {
let k = vp.view_height / vp.height;
// The viewport's twist turns the model view inside it.
View { center: vp.paper_to_model(pv.center), height: pv.height * k, twist: vp.view_twist() }
View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
}
None => pv,
}
@@ -152,18 +150,16 @@ impl DocState {
if vp.locked {
// A locked viewport keeps its scale: pan/zoom the sheet instead.
let k0 = vp.view_height / vp.height;
let pc = vp.model_to_paper(v.center);
let paper = View { center: pc, height: v.height / k0, twist: pv.twist };
let pc = vp.center.xy() + (v.center - vp.view_center) / k0;
let paper = View { center: pc, height: v.height / k0 };
if !usable_view(&paper) {
return false;
}
self.put_paper_view(paper);
} else {
// `v`'s twist is the one the viewport shows (a non-finite one keeps its own).
let twist = if v.twist.is_finite() { v.twist } else { vp.view_twist() };
let center = v.center - ((pv.center - vp.center.xy()) * k).rotate(-twist);
let center = v.center - (pv.center - vp.center.xy()) * k;
let height = vp.height * k;
if !usable_view(&View { center, height, twist }) {
if !usable_view(&View { center, height }) {
return false;
}
let doc = Arc::make_mut(&mut self.doc);
@@ -172,7 +168,6 @@ impl DocState {
if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind {
x.view_center = center;
x.view_height = height;
x.twist = twist;
}
});
}
@@ -187,36 +182,6 @@ impl DocState {
Some((_, slot)) => *slot = v,
None => self.views.push((self.space.clone(), v)),
}
if self.space == Space::Model {
self.keep_untiled_twist(v);
}
}
/// An untiled Model tab keeps its view twist in the drawing as a lone `*Active` VPORT tile
/// (written as that record's group 51 and read back), so it survives save and reopen. Only a
/// change of twist touches the drawing; an untwisted view keeps no record.
/// After undo or redo has put back another drawing: its untiled-twist record follows the
/// Model tab's live view again (views are not part of the drawing's undo).
pub(crate) fn reconcile_untiled_twist(&mut self) {
if let Some(v) = self.views.iter().find(|(s, _)| *s == Space::Model).map(|(_, v)| *v) {
self.keep_untiled_twist(v);
}
}
fn keep_untiled_twist(&mut self, v: View) {
use cadcraft_doc::{VportConfig, VportTile};
let twist = cadcraft_geom::norm_angle(v.twist);
let active = self.doc.vports.iter().find(|c| c.is_active());
if active.is_some_and(|c| c.tiles.len() > 1) {
return;
}
if active.and_then(|c| c.tiles.first()).map_or(0.0, |t| t.twist) == twist {
return;
}
let d = Arc::make_mut(&mut self.doc);
d.vports.retain(|c| !c.is_active());
if twist != 0.0 {
let tile = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: v.center, height: v.height, aspect: 1.0, twist };
d.vports.insert(0, VportConfig { name: VportConfig::ACTIVE.into(), tiles: vec![tile] });
}
}
/// Set the view, recording view history. Returns false, changing nothing, when the view (or,
/// in MSPACE, the viewport view it maps to) isn't finite with a positive height.
@@ -245,7 +210,7 @@ impl DocState {
/// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`].
pub fn zoom_frame(&self) -> View {
match self.zoom_viewport() {
Some(v) => View { center: v.view_center, height: v.view_height, twist: v.view_twist() },
Some(v) => View { center: v.view_center, height: v.view_height },
None => self.view(),
}
}
@@ -260,9 +225,7 @@ impl DocState {
Some(vp) => {
let pv = self.paper_view();
let k = f.height / vp.height;
// The frame's twist turns the viewport (a non-finite one keeps the viewport's).
let twist = if f.twist.is_finite() { f.twist } else { vp.view_twist() };
View { center: f.center + ((pv.center - vp.center.xy()) * k).rotate(-twist), height: pv.height * k, twist }
View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
}
None => f,
};
@@ -270,7 +233,7 @@ impl DocState {
}
/// Set without recording view history (realtime pan/zoom frames).
pub fn set_view_quiet(&mut self, v: View) {
if !usable_view(&v) {
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
return;
}
self.store_view(v);
-17
View File
@@ -66,14 +66,6 @@ impl Interactive for Guarded {
fn begin(&mut self, s: &mut Session) -> Result<Step> {
catch_unwind(AssertUnwindSafe(|| self.inner.begin(s))).unwrap_or_else(|p| Err(self.internal(&*p)))
}
fn cancelled(&mut self, s: &mut Session) {
if self.broken.get().is_none()
&& let Err(p) = catch_unwind(AssertUnwindSafe(|| self.inner.cancelled(s)))
{
let e = self.internal(&*p);
s.echo(e.to_string());
}
}
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
if self.broken.get().is_some() {
return Vec::new();
@@ -110,15 +102,6 @@ impl Interactive for Guarded {
None
})
}
fn display_only(&self, s: &Session) -> Option<crate::prompt::DisplayOnly> {
if self.broken.get().is_some() {
return None;
}
catch_unwind(AssertUnwindSafe(|| self.inner.display_only(s))).unwrap_or_else(|_| {
self.broken.set(Some("panic in preview"));
None
})
}
}
#[cfg(test)]
+1 -1
View File
@@ -49,7 +49,7 @@ pub use cadcraft_geom as geom;
pub use cadcraft_render as render;
pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use prompt::{Accept, DisplayOnly, Drag, Input, Interactive, Picker, Prompt, Step};
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
mod doc_state;
mod error;
+1 -21
View File
@@ -31,7 +31,7 @@ impl Accept {
/// [Truecolor/COlorbook] <BYLAYER>:". A prompt asking for something else besides its keywords has
/// it: "Enter block name or [?]:", "Specify insertion base point or [Basepoint/…]:". Read from
/// AutoCAD's command line over measured runs, where no prompt was written both ways.
const NO_OR: [&str; 28] = [
const NO_OR: [&str; 27] = [
"Bring to front",
"Calculate rectangle dimensions based on",
"Choose mode for selected object conversion",
@@ -47,7 +47,6 @@ const NO_OR: [&str; 28] = [
"Enter leader format option",
"Enter line spacing type",
"Enter object ordering option",
"Enter option",
"Enter polyline linetype generation option",
"Enter property to change",
"Enter the type of array",
@@ -267,9 +266,6 @@ pub trait Interactive: Send {
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
Ok(Step::Continue)
}
/// Called when the command is cancelled from outside (Esc, or another command starting),
/// before it finishes: what it records on every exit path.
fn cancelled(&mut self, _s: &mut Session) {}
/// Rubber-band geometry for the cursor position.
fn preview(&self, _s: &Session, _cursor: Vec2) -> Vec<EntityKind> {
Vec::new()
@@ -291,22 +287,6 @@ pub trait Interactive: Send {
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
None
}
/// What the drawing area shows in place of the drawing while the command runs (DVIEW draws
/// only its preview set); `None` shows the drawing.
fn display_only(&self, _s: &Session) -> Option<DisplayOnly> {
None
}
}
/// See [`Interactive::display_only`]: the only things drawn in the current view while a command
/// runs. The whole drawing is drawn again when it ends.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DisplayOnly {
/// Objects of the drawing, drawn as they are.
pub handles: Vec<Handle>,
/// Geometry that is not in the drawing (DVIEW's sample house, or an insert of the drawing's
/// DVIEWBLOCK block), with its own properties.
pub extra: Vec<cadcraft_doc::Entity>,
}
/// See [`Interactive::drag`].
-1
View File
@@ -527,7 +527,6 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap();
(d, h)
+6 -8
View File
@@ -6,7 +6,7 @@ impl Session {
self.untitled_counter += 1;
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None);
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0, twist: 0.0 } } else { View::default() });
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() });
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
@@ -18,13 +18,11 @@ impl Session {
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
let mut st = DocState::new(d, title, path);
let ext = st.doc.extents(&Space::Model);
// The current tile (the first), or the lone untiled view, keeps its saved twist.
let twist = cadcraft_geom::norm_angle(st.doc.vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map_or(0.0, |t| t.twist));
if ext.is_empty() {
st.set_view_quiet(View { twist, ..st.view() });
} else {
let (center, w, h) = View::twisted_box(ext, twist);
st.set_view_quiet(View { center, height: (h.max(w * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6), twist });
if !ext.is_empty() {
st.set_view_quiet(View {
center: ext.center(),
height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6),
});
}
self.end_running();
self.docs.push(st);
+3 -16
View File
@@ -1,5 +1,3 @@
use crate::DisplayOnly;
use super::{
Arc, Bounds2, Drag, Drawing, EngineError, Entity, Handle, Input, PendingWindow, Prompt, Result, Running, Session, Snapshot, Step, Value, Vec2,
assoc, cmd, find_command, guard, pointmod, select, snap, sysvars,
@@ -201,7 +199,7 @@ impl Session {
Input::Point(p) => {
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
if let Some(pw) = self.pending_window.take() {
let crossing = self.window_is_crossing(pw.corner, p);
let crossing = p.x < pw.corner.x;
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
}
@@ -281,10 +279,7 @@ impl Session {
let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None };
let Some(mut run) = self.running.take() else { return Ok(()) };
let step = match input {
Some(Input::Cancel) => {
run.machine.cancelled(self);
Ok(Step::Cancel)
}
Some(Input::Cancel) => Ok(Step::Cancel),
Some(i) => {
if let Input::Point(p) = &i {
self.last_point = *p;
@@ -443,8 +438,7 @@ impl Session {
}
if self.running.is_some() {
if self.suspended.is_some() {
if let Some(mut nested) = self.running.take() {
nested.machine.cancelled(self);
if let Some(nested) = self.running.take() {
self.finish(nested, true);
}
self.running = self.suspended.take();
@@ -529,13 +523,6 @@ impl Session {
out
}
/// What the drawing area shows in place of the drawing while a command runs (DVIEW's preview
/// set, see [`Interactive::display_only`]), also while a transparent command interrupts it.
/// `None`: the drawing.
pub fn display_only(&self) -> Option<DisplayOnly> {
[&self.running, &self.suspended].into_iter().flatten().find_map(|r| r.machine.display_only(self))
}
/// The active command's preview as a transform of existing objects (see
/// [`Interactive::drag`]), with its extra rubber-band geometry as entities.
pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec<Entity>)> {
+1 -8
View File
@@ -16,13 +16,6 @@ impl Session {
true
}
/// Whether a selection window from `corner` to `p` is a crossing window: it was dragged
/// leftwards on screen (in a twisted view, not along the drawing's X axis).
pub fn window_is_crossing(&self, corner: Vec2, p: Vec2) -> bool {
let twist = self.state().map_or(0.0, |st| st.view().twist);
(p - corner).rotate(twist).x < 0.0
}
/// A click with no command running: pick/toggle objects or start a selection window.
///
/// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick
@@ -31,7 +24,7 @@ impl Session {
let space = self.space();
let pickadd = self.settings.pickadd;
if let Some(pw) = self.pending_window.take() {
let crossing = self.window_is_crossing(pw.corner, p);
let crossing = p.x < pw.corner.x;
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
let hs = cmd::groups::expand(self, hs);
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
+3 -5
View File
@@ -23,10 +23,8 @@ impl Session {
let ext = if ext.is_empty() { self.zoom_limits()? } else { ext };
let st = self.state_mut()?;
let aspect = st.zoom_aspect(w / h.max(1.0));
let twist = st.zoom_frame().twist;
let (center, bw, bh) = View::twisted_box(ext, twist);
let height = bh.max(bw / aspect.max(1e-6)).max(1e-6) * 1.05;
st.set_zoom_frame(View { center, height, twist });
let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05;
st.set_zoom_frame(View { center: ext.center(), height });
Ok(())
}
@@ -40,7 +38,7 @@ impl Session {
let h = (v.height / factor).clamp(1e-9, 1e12);
let k = h / v.height;
let c = about + (v.center - about) * k;
st.set_view_quiet(View { center: c, height: h, twist: v.twist });
st.set_view_quiet(View { center: c, height: h });
Ok(())
}
}
-1
View File
@@ -714,7 +714,6 @@ impl Sink<'_> {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, frozen, &mut |k, m| self.object(k, Some(m)));
}
+4 -7
View File
@@ -40,8 +40,7 @@ impl<'a> Projection<'a> {
}
let half = Vec2::new(viewport.width / 2.0, viewport.height / 2.0);
let paper_clip = Bounds2::new(center - half, center + half);
// The model box the viewport shows (turned, with a view twist).
let model_clip = Bounds2::from_points(paper_clip.corners().map(|p| viewport.paper_to_model(p)));
let model_clip = Bounds2::new(viewport.view_center - half / scale, viewport.view_center + half / scale);
if ![paper_clip.min, paper_clip.max, model_clip.min, model_clip.max].into_iter().all(Vec2::is_finite) {
return None;
}
@@ -49,11 +48,11 @@ impl<'a> Projection<'a> {
}
pub fn to_model(&self, point: Vec2) -> Vec2 {
self.viewport.paper_to_model(point)
self.viewport.view_center + (point - self.viewport.center.xy()) / self.scale
}
fn to_paper(&self, point: Vec2) -> Vec2 {
self.viewport.model_to_paper(point)
self.viewport.center.xy() + (point - self.viewport.view_center) * self.scale
}
pub fn hit(&self, mut hit: SnapHit) -> Option<SnapHit> {
@@ -77,9 +76,7 @@ impl<'a> Projection<'a> {
if !(center.is_finite() && radius.is_finite() && radius > 0.0) {
return None;
}
// The arc turns on the sheet with the viewport's twist.
let twist = self.viewport.view_twist();
SnapCurve::Arc { center, radius, start: start + twist, end: end + twist }
SnapCurve::Arc { center, radius, start, end }
}
SnapCurve::Line { a, b } => {
let (a, b) = (self.to_paper(a), self.to_paper(b));
-2
View File
@@ -364,7 +364,6 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
8 if i % 40 == 8 => EntityKind::XLine(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(r.r(-1.0, 1.0), 1.0) }),
8 if i % 40 == 18 => EntityKind::Ray(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(1.0, r.r(-1.0, 1.0)) }),
@@ -547,7 +546,6 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap();
let small = Bounds2::new(Vec2::new(-2.0, -2.0), Vec2::new(2.0, 2.0));
-3
View File
@@ -184,8 +184,6 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
json!([v.center.x, v.center.y, 0.0])
}
"VIEWSIZE" => json!(s.state().ok()?.view().height),
// The view twist (DVIEW TWist), in degrees counterclockwise.
"VIEWTWIST" => json!(s.state().ok()?.view().twist.to_degrees()),
"EXTMIN" | "EXTMAX" => {
let d = s.doc().ok()?;
let e = d.extents(&s.space());
@@ -262,7 +260,6 @@ const READ_ONLY: &[&str] = &[
"DISTANCE",
"VIEWCTR",
"VIEWSIZE",
"VIEWTWIST",
"EXTMIN",
"EXTMAX",
"UNDOCTL",
-2
View File
@@ -74,8 +74,6 @@ const HEADER: &[(&str, Kind, Def)] = &[
("TRIMEXTENDMODE", Kind::Choice(&[0, 1]), Def::Int(1)),
("TRIMMODE", Kind::Choice(&[0, 1]), Def::Int(1)),
("WIPEOUTFRAME", Kind::Choice(&[0, 1, 2]), Def::Int(1)),
// XCLIP boundaries: 0 hidden, 1 shown and plotted, 2 shown but not plotted.
("XCLIPFRAME", Kind::Choice(&[0, 1, 2]), Def::Int(2)),
];
/// The standard lineweights in hundredths of a millimetre.
+1 -1
View File
@@ -185,7 +185,7 @@ fn move_with_pickfirst_and_window_selection() {
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
s.execute("line", &json!({"points": [[5, 5], [6, 5]]})).unwrap();
s.viewport_px = (1000.0, 1000.0);
s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0, twist: 0.0 });
s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0 });
// Window selection from left to right picks only the first line.
s.cmdline("move").unwrap();
s.input(Input::Point(Vec2::new(-1.0, -1.0))).unwrap();
+3 -21
View File
@@ -1,38 +1,20 @@
use cadcraft_geom::Bounds2;
use super::{Deserialize, Serialize, Vec2};
/// A 2D view of a space: world centre and visible height in drawing units, and the view twist
/// (VIEWTWIST, DVIEW TWist): how far the view is turned on screen, in radians counterclockwise
/// (the drawing appears turned by `twist`; 0 is the plain plan view).
/// A 2D view of a space: world centre and visible height in drawing units.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct View {
pub center: Vec2,
pub height: f64,
#[serde(default)]
pub twist: f64,
}
impl Default for View {
fn default() -> Self {
View { center: Vec2::new(6.0, 4.5), height: 10.0, twist: 0.0 }
}
}
impl View {
/// Box `b` (world) as a view turned by `twist` shows it: the world centre of the box it
/// makes on screen, and that box's width and height (`b`'s own when there is no twist).
pub fn twisted_box(b: Bounds2, twist: f64) -> (Vec2, f64, f64) {
if twist == 0.0 || !twist.is_finite() {
return (b.center(), b.width(), b.height());
}
let r = Bounds2::from_points(b.corners().map(|p| p.rotate(twist)));
(r.center().rotate(-twist), r.width(), r.height())
View { center: Vec2::new(6.0, 4.5), height: 10.0 }
}
}
pub(crate) fn usable_view(v: &View) -> bool {
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 && v.twist.is_finite()
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12
}
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
-1
View File
@@ -24,7 +24,6 @@ fn insert(block: &str) -> EntityKind {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
}
-1
View File
@@ -487,7 +487,6 @@ fn a_block_editor_that_cannot_be_composed_is_not_autosaved_as_the_old_host() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
});
d.model.push(Entity::new(h, kind));
assert!(s.state().unwrap().recovery_drawing().is_err());
-1
View File
@@ -28,7 +28,6 @@ fn insert(d: &mut Drawing, block: &str, at: (f64, f64)) -> Entity {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
});
Entity { handle: d.new_handle(), common: Common::default(), kind }
}
-2
View File
@@ -124,7 +124,6 @@ fn hooks() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).map_err(|e| e.to_string())?;
return Ok(d);
@@ -176,7 +175,6 @@ fn hostile_inputs_are_errors() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
b.entities.push(Entity::new(cadcraft_engine::doc::Handle(0x900), EntityKind::Insert(ins.clone())));
let d = s.doc_mut().unwrap();
-381
View File
@@ -1,381 +0,0 @@
//! DVIEW starts with "Select objects or <use DVIEWBLOCK>:", then the option prompt;
//! TWist, PAn, Zoom, Off and Undo on a 2D view; the 3D options not available yet. It works on the
//! Model tab and inside an active layout viewport (whose twist is an undoable edit of the
//! viewport), and not on a layout sheet.
use cadcraft_doc::{EntityKind, Handle, Viewport};
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, Session, View};
use serde_json::json;
const SELECT: &str = "Select objects or <use DVIEWBLOCK>:";
const OPTION: &str = "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/Off/Undo]:";
const PAPER: &str = "This command is not available in Paper space.";
fn session() -> Session {
let mut s = Session::new();
s.viewport_px = (1200.0, 800.0);
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
s.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap();
s
}
fn prompt(s: &Session) -> String {
s.current_prompt().map(|p| p.display()).unwrap_or_default()
}
fn view(s: &Session) -> View {
s.state().unwrap().view()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn twist_deg(s: &Session) -> f64 {
view(s).twist.to_degrees()
}
fn logged(s: &Session, m: &str) -> bool {
s.log.iter().any(|l| l == m)
}
#[test]
fn every_prompt_step_on_the_model_tab() {
let mut s = session();
let undo = s.state().unwrap().undo.len();
s.cmdline("dview").unwrap();
assert_eq!(prompt(&s), SELECT);
// The whole drawing shows while objects are picked.
assert!(s.display_only().is_none());
// Enter with nothing selected: the DVIEWBLOCK sample (a house of lines) is the preview set.
s.cmdline("").unwrap();
assert_eq!(prompt(&s), OPTION);
let only = s.display_only().unwrap();
assert!(only.handles.is_empty());
assert!(only.extra.len() > 10 && only.extra.iter().all(|e| matches!(e.kind, EntityKind::Line(_))));
twist_pan_and_zoom(&mut s);
// The sample house stays where it was put: the view moves over it.
assert_eq!(s.display_only().unwrap(), only);
off_and_unavailable_options(&mut s);
undo_steps(&mut s);
// An unknown option is refused and asked again.
assert!(s.cmdline("bogus").is_ok());
assert_eq!(prompt(&s), OPTION);
// Enter ends DVIEW; the whole drawing shows again, and a Model tab twist is no undo step.
s.cmdline("tw").unwrap();
s.cmdline("45").unwrap();
s.cmdline("").unwrap();
assert!(s.current_prompt().is_none());
assert!(s.display_only().is_none());
assert!(near(twist_deg(&s), 45.0));
assert_eq!(s.state().unwrap().undo.len(), undo);
}
/// DVIEW's TWist, PAn and Zoom steps from the option prompt of a fresh Model tab session.
fn twist_pan_and_zoom(s: &mut Session) {
// TWist: the current twist is the default; a typed angle turns the view.
s.cmdline("tw").unwrap();
assert_eq!(prompt(s), "Specify view twist angle <0>:");
s.cmdline("30").unwrap();
assert!(near(twist_deg(s), 30.0));
assert_eq!(prompt(s), OPTION);
s.cmdline("TWist").unwrap();
assert_eq!(prompt(s), "Specify view twist angle <30>:");
// Enter keeps it.
s.cmdline("").unwrap();
assert!(near(twist_deg(s), 30.0));
// PAn: base point, second point.
s.cmdline("pa").unwrap();
assert_eq!(prompt(s), "Specify displacement base point:");
s.cmdline("0,0").unwrap();
assert_eq!(prompt(s), "Specify second point:");
s.cmdline("4,0").unwrap();
assert_eq!(view(s).center, Vec2::new(6.0, 10.0));
assert_eq!(prompt(s), OPTION);
// Zoom: a scale factor of the current view.
s.cmdline("z").unwrap();
assert_eq!(prompt(s), "Specify zoom scale factor <1>:");
s.cmdline("2").unwrap();
assert!(near(view(s).height, 10.0));
}
/// DVIEW's Off option and the options that need a 3D camera.
fn off_and_unavailable_options(s: &mut Session) {
// Off: perspective is never on in a 2D view, so nothing changes.
let before = view(s);
s.cmdline("o").unwrap();
assert_eq!(prompt(s), OPTION);
assert_eq!(view(s), before);
// The options that need a 3D camera are not available yet and stay at the option prompt.
for (typed, name) in [("ca", "CAmera"), ("ta", "TArget"), ("d", "Distance"), ("po", "POints"), ("cl", "CLip"), ("h", "Hide")] {
s.cmdline(typed).unwrap();
assert!(logged(s, &format!("DVIEW {name} is not available yet.")), "{name}: {:?}", s.log);
assert_eq!(prompt(s), OPTION);
}
assert!(!s.log.iter().any(|l| l.contains("Only the view twist")));
}
/// DVIEW's Undo after [`twist_pan_and_zoom`].
fn undo_steps(s: &mut Session) {
// Undo takes back one DVIEW action at a time: zoom, pan, twist.
s.cmdline("u").unwrap();
assert!(near(view(s).height, 20.0));
s.cmdline("u").unwrap();
assert_eq!(view(s).center, Vec2::new(10.0, 10.0));
assert!(near(twist_deg(s), 30.0));
s.cmdline("u").unwrap();
assert_eq!(view(s).twist, 0.0);
// Nothing left to undo: still at the option prompt.
s.cmdline("u").unwrap();
assert_eq!(prompt(s), OPTION);
}
#[test]
fn selected_objects_are_the_preview_set() {
let mut s = session();
let line = s.doc().unwrap().model.iter().next().map(|e| e.handle).unwrap();
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
s.cmdline("dview").unwrap();
s.input(Input::Pick(vec![line])).unwrap();
assert_eq!(prompt(&s), SELECT);
s.cmdline("").unwrap();
assert_eq!(prompt(&s), OPTION);
let only = s.display_only().unwrap();
assert_eq!(only.handles, vec![line]);
assert!(only.extra.is_empty(), "no sample house with a selection");
// Picked at the prompt, the objects don't stay selected.
assert!(s.selection().is_empty());
// Escape ends it too, and the drawing shows again.
s.input(Input::Cancel).unwrap();
assert!(s.current_prompt().is_none());
assert!(s.display_only().is_none());
}
#[test]
fn a_dviewblock_of_the_drawings_own_is_previewed() {
let mut s = session();
s.execute("circle", &json!({ "center": [0.5, 0.5], "radius": 0.5 })).unwrap();
let c = s.doc().unwrap().model.iter().last().map(|e| e.handle).unwrap();
s.execute("block", &json!({ "name": "DVIEWBLOCK", "base": [0, 0], "handles": [c.hex()], "keep": "delete" })).unwrap();
s.cmdline("dview").unwrap();
s.cmdline("").unwrap();
let only = s.display_only().unwrap();
assert_eq!(only.extra.len(), 1);
match &only.extra[0].kind {
EntityKind::Insert(i) => {
assert_eq!(i.block, "DVIEWBLOCK");
// Scaled from its 1 by 1 unit area to half the view's height.
assert!(near(i.scale.x, 10.0) && near(i.scale.y, 10.0));
}
k => panic!("{k:?}"),
}
s.cmdline("").unwrap();
}
/// A layout with one viewport, `(session, viewport handle)`.
fn layout_session() -> (Session, Handle) {
let mut s = session();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let r = s.execute("mview", &json!({ "p1": [20, 20], "p2": [220, 120] })).unwrap();
let h = Handle::parse_hex(r["viewports"][0].as_str().unwrap()).unwrap();
(s, h)
}
fn vp(s: &Session, h: Handle) -> Viewport {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Viewport(v) => v.clone(),
_ => panic!("not a viewport"),
}
}
#[test]
fn on_a_layout_sheet_it_is_not_available() {
let (mut s, _) = layout_session();
assert_eq!(s.execute("dview", &json!({ "twist": 10 })).unwrap_err().to_string(), PAPER);
assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER);
assert!(s.current_prompt().is_none());
// view.set can't turn the sheet either; an untwisted view is fine.
assert_eq!(s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap_err().to_string(), PAPER);
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
}
#[test]
fn inside_an_active_viewport_it_twists_that_viewport_as_one_undo_step() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
let undo = s.state().unwrap().undo.len();
let pv = s.state().unwrap().paper_view();
s.cmdline("dview").unwrap();
assert_eq!(prompt(&s), SELECT);
s.cmdline("").unwrap();
s.cmdline("tw").unwrap();
s.cmdline("30").unwrap();
// The viewport entity carries the twist, and the model view inside it is turned by it.
assert!(near(vp(&s, h).twist.to_degrees(), 30.0));
assert!(near(twist_deg(&s), 30.0));
// The sheet itself is not turned.
assert_eq!(s.state().unwrap().paper_view(), pv);
s.cmdline("tw").unwrap();
s.cmdline("50").unwrap();
s.cmdline("").unwrap();
assert!(s.current_prompt().is_none());
assert!(near(vp(&s, h).twist.to_degrees(), 50.0));
// One undo step for the whole DVIEW, which turns the viewport back.
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
s.execute("undo", &json!({})).unwrap();
assert_eq!(vp(&s, h).twist, 0.0);
// The JSON form and view.set twist the viewport the same way, each one undo step.
s.execute("dview", &json!({ "twist": -90 })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 270.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
let v = view(&s);
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 15 })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 15.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 2);
// Setting a view without a twist keeps the viewport's.
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 15.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 2);
// Back on the sheet, DVIEW is not available again.
s.execute("pspace", &json!({})).unwrap();
assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER);
}
#[test]
fn a_locked_viewport_keeps_its_twist() {
let (mut s, h) = layout_session();
s.doc_mut()
.unwrap()
.modify_entity(h, |e| {
if let EntityKind::Viewport(v) = &mut e.kind {
v.locked = true;
}
})
.unwrap();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
let undo = s.state().unwrap().undo.len();
assert!(s.execute("dview", &json!({ "twist": 30 })).is_err());
let v = view(&s);
assert!(s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 30 })).is_err());
assert_eq!(vp(&s, h).twist, 0.0);
assert_eq!(s.state().unwrap().undo.len(), undo);
// A view with the twist it already has is fine.
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap();
}
#[test]
fn escape_inside_an_active_viewport_keeps_the_twist_as_one_undo_step() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
let undo = s.state().unwrap().undo.len();
for l in ["dview", "", "tw", "30"] {
s.cmdline(l).unwrap();
}
s.input(Input::Cancel).unwrap();
assert!(s.current_prompt().is_none());
assert!(near(vp(&s, h).twist.to_degrees(), 30.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
s.execute("undo", &json!({})).unwrap();
assert_eq!(vp(&s, h).twist, 0.0);
}
#[test]
fn the_view_inside_a_twisted_viewport_maps_the_screen_like_the_sheet() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
let st = s.state().unwrap();
let (vpe, pv, v) = (vp(&s, h), st.paper_view(), st.view());
// The model view's centre is the model point at the sheet's view centre.
assert!((vpe.model_to_paper(v.center) - pv.center).len() < 1e-9);
assert!(near(v.twist, vpe.twist));
// Panning the view inside the viewport moves its model view, keeping the twist.
let moved = View { center: v.center + Vec2::new(5.0, -2.0), ..v };
s.state_mut().unwrap().set_view(moved);
let after = view(&s);
assert!((after.center - moved.center).len() < 1e-9, "{after:?}");
assert!(near(after.twist, vpe.twist));
assert_eq!(s.state().unwrap().paper_view(), pv);
// ZOOM Extents fits the turned box of the drawing in the viewport.
s.execute("zoom", &json!({ "mode": "extents" })).unwrap();
assert!(near(view(&s).twist, vpe.twist));
}
#[test]
fn a_viewport_twist_survives_a_dxf_round_trip() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let twists: Vec<f64> = back
.layouts
.iter()
.flat_map(|l| l.entities.iter())
.filter_map(|e| match &e.kind {
EntityKind::Viewport(v) if v.id != 1 => Some(v.twist.to_degrees()),
_ => None,
})
.collect();
assert_eq!(twists.len(), 1);
assert!((twists[0] - 30.0).abs() < 1e-9, "{twists:?}");
}
#[test]
fn a_view_saved_in_a_twisted_viewport_restores_its_twist_as_one_undo_step() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
s.execute("view", &json!({ "option": "save", "name": "Turned" })).unwrap();
let saved = s.doc().unwrap().views.iter().find(|v| v.name == "Turned").map(|v| v.twist.to_degrees()).unwrap();
assert!(near(saved, 30.0), "saved twist {saved}");
let center = vp(&s, h).view_center;
s.execute("dview", &json!({ "twist": 0 })).unwrap();
s.execute("view.set", &json!({ "center": [80, 40], "height": 3 })).unwrap();
let undo = s.state().unwrap().undo.len();
s.execute("view", &json!({ "option": "restore", "name": "Turned" })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 30.0), "viewport twist {}", vp(&s, h).twist.to_degrees());
assert!(near(twist_deg(&s), 30.0));
assert!((vp(&s, h).view_center - center).len() < 1e-6, "{:?} vs {center:?}", vp(&s, h).view_center);
// The viewport's twist changed: one undo step, which turns it back.
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
s.execute("undo", &json!({})).unwrap();
assert_eq!(vp(&s, h).twist, 0.0);
}
#[test]
fn undoing_a_drawing_change_keeps_the_untiled_twist_saved() {
let mut s = session();
s.execute("line", &json!({ "points": [[0, 10], [5, 10]] })).unwrap();
// A Model tab twist is a view change, not an undo step.
s.execute("dview", &json!({ "twist": 30 })).unwrap();
s.execute("undo", &json!({})).unwrap();
assert!(near(twist_deg(&s), 30.0));
let saved = |s: &Session| s.doc().unwrap().vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map(|t| t.twist.to_degrees());
assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s));
s.execute("redo", &json!({})).unwrap();
assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s));
}
#[test]
fn a_leftward_drag_on_a_turned_screen_is_a_crossing_window() {
let mut s = session();
let r = s.execute("line", &json!({ "points": [[200, 0], [260, 0]] })).unwrap();
let line = Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap();
s.execute("dview", &json!({ "twist": 180 })).unwrap();
// Turned half way round, a drag towards +X in the drawing runs leftwards on screen.
assert!(s.window_is_crossing(Vec2::new(210.0, -5.0), Vec2::new(230.0, 5.0)));
assert!(!s.window_is_crossing(Vec2::new(230.0, -5.0), Vec2::new(210.0, 5.0)));
// A crossing window takes the line it only partly covers.
s.idle_click(Vec2::new(210.0, -5.0), false).unwrap();
s.idle_click(Vec2::new(230.0, 5.0), false).unwrap();
assert_eq!(s.selection(), vec![line]);
}
-376
View File
@@ -1,376 +0,0 @@
//! Raster images (#956): IMAGEATTACH from a file and from handed-over bytes, the pixels loaded
//! again when the drawing is reopened, a missing file left as a frame, and bad input refused.
use std::path::{Path, PathBuf};
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io};
use cadcraft_engine::doc::{Drawing, EntityKind, Image, Raster};
use serde_json::{Value, json};
fn hooks() {
set_io(IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: None,
});
set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
}
/// A fresh folder for one test.
fn folder(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("cadcraft-image-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A PNG file of 4 x 2 pixels.
fn png() -> Vec<u8> {
let px: Vec<u8> = (0..8u8).flat_map(|i| [i * 30, 255 - i * 30, 7, 255]).collect();
cadcraft_io::raster::encode_png(&Raster::new(4, 2, px).unwrap()).unwrap()
}
fn images(s: &Session) -> Vec<Image> {
let d = s.doc().unwrap();
d.model.iter().filter_map(|e| if let EntityKind::Image(i) = &e.kind { Some(i.clone()) } else { None }).collect()
}
fn open(s: &mut Session, path: &Path) {
s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap();
}
fn listed(s: &mut Session) -> Value {
s.execute("image", &json!({})).unwrap()["images"].clone()
}
#[test]
fn attach_from_bytes_places_the_image() {
hooks();
let mut s = Session::new();
let r = s.execute("imageattach", &json!({ "path": "maps/site.png", "data": base64_encode(&png()), "at": [10, 20], "width": 100 })).unwrap();
assert_eq!((r["name"].as_str(), r["pixels"].clone(), r["size"].clone()), (Some("site"), json!([4.0, 2.0]), json!([100.0, 50.0])));
let i = images(&s).pop().unwrap();
assert_eq!((i.insert.x, i.insert.y, i.u.x, i.u.y, i.v.x, i.v.y), (10.0, 20.0, 25.0, 0.0, 0.0, 25.0));
assert_eq!((i.size.x, i.size.y, i.path.as_str(), i.name.as_str()), (4.0, 2.0, "maps/site.png", "site"));
assert_eq!(s.doc().unwrap().rasters.get("maps/site.png").map(|r| (r.width(), r.height())), Some((4, 2)));
let l = listed(&mut s);
assert_eq!((l[0]["status"].as_str(), l[0]["references"].as_u64(), l[0]["pixels"].clone()), (Some("Loaded"), Some(1), json!([4, 2])));
// A second reference to the same file shares the definition and the pixels; another file
// with the same file name gets its own name.
s.execute("imageattach", &json!({ "path": "maps/site.png", "at": [200, 0], "rotation": 90, "scale": 8 })).unwrap();
let r = s.execute("attach", &json!({ "path": "other/site.png", "data": base64_encode(&png()) })).unwrap();
assert_eq!(r["name"], "site_2");
let all = images(&s);
assert_eq!(all.len(), 3);
assert_eq!(all[1].name, "site");
assert!((all[1].u.x).abs() < 1e-12 && (all[1].u.y - 2.0).abs() < 1e-12 && (all[1].v.x + 2.0).abs() < 1e-12, "{:?}", all[1]);
assert_eq!(listed(&mut s)[0]["references"], 2);
// Undo takes the reference away; the drawing draws with its pixels.
s.execute("undo", &json!({})).unwrap();
assert_eq!(images(&s).len(), 2);
let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default());
assert_eq!(list.images.len(), 2);
}
#[test]
fn a_file_is_found_next_to_the_drawing_and_loaded_again_on_open() {
hooks();
let dir = folder("file");
std::fs::write(dir.join("map.png"), png()).unwrap();
let host = dir.join("host.dxf");
std::fs::write(&host, cadcraft_io::write(&Drawing::new_metric(), "host.dxf").unwrap()).unwrap();
let mut s = Session::new();
open(&mut s, &host);
let r = s.execute("imageattach", &json!({ "path": "map.png" })).unwrap();
assert_eq!(r["size"], json!([1.0, 0.5]), "one drawing unit wide by default");
s.execute("qsave", &json!({})).unwrap();
// Reopened: the pixels come back from the file.
let mut s = Session::new();
open(&mut s, &host);
assert_eq!(images(&s).len(), 1);
assert!(s.doc().unwrap().rasters.get("map.png").is_some());
assert!(!s.state().unwrap().is_dirty(), "loading images is not a change");
let l = listed(&mut s);
assert_eq!(l[0]["status"], "Loaded");
assert!(l[0]["foundAt"].as_str().is_some_and(|f| f.ends_with("map.png")));
// The file gone: the drawing still opens, says so, and keeps the reference.
std::fs::remove_file(dir.join("map.png")).unwrap();
let mut s = Session::new();
open(&mut s, &host);
assert_eq!(images(&s).len(), 1);
assert!(s.doc().unwrap().rasters.get("map.png").is_none());
assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("file not found")), "{:?}", s.log);
assert_eq!(listed(&mut s)[0]["status"], "Not Found");
// A file that is not an image is reported the same way.
std::fs::write(dir.join("map.png"), b"not an image").unwrap();
let mut s = Session::new();
open(&mut s, &host);
assert!(s.doc().unwrap().rasters.get("map.png").is_none());
assert!(s.log.iter().any(|l| l.contains("map.png")), "{:?}", s.log);
assert_eq!(listed(&mut s)[0]["status"], "Not loaded");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bad_input_is_refused_and_changes_nothing() {
hooks();
let mut s = Session::new();
let data = base64_encode(&png());
for p in [
json!({}),
json!({ "path": " " }),
json!({ "path": "nowhere/missing.png" }),
json!({ "path": "a.png", "data": "***" }),
json!({ "path": "a.png", "data": 5 }),
json!({ "path": "a.png", "data": base64_encode(b"plain text") }),
json!({ "path": "a.png", "data": data, "scale": 0 }),
json!({ "path": "a.png", "data": data, "width": -3 }),
json!({ "path": "a.png", "data": data, "width": "wide" }),
json!({ "path": "a.png", "data": data, "width": 1e-320 }),
json!({ "path": "x".repeat(5000), "data": data }),
] {
assert!(s.execute("imageattach", &p).is_err(), "{p}");
}
assert!(images(&s).is_empty());
assert!(s.execute("attach", &json!({ "path": "plan.pdf" })).unwrap_err().to_string().contains("not available yet"));
assert_eq!(listed(&mut s), json!([]));
}
#[test]
fn an_image_among_many_objects_keeps_its_pixels() {
// More objects than one build task takes: the display list is built in parts and joined,
// and the image must still point at its own pixels.
hooks();
let mut s = Session::new();
for i in 0..700 {
s.execute("line", &json!({ "points": [[i, 0], [i, 1]] })).unwrap();
}
s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [0, 5], "width": 4 })).unwrap();
for i in 0..700 {
s.execute("line", &json!({ "points": [[i, 2], [i, 3]] })).unwrap();
}
s.execute("imageattach", &json!({ "path": "b.png", "data": base64_encode(&png()), "at": [10, 5], "width": 8 })).unwrap();
let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default());
let widths: Vec<f64> = list
.prims
.iter()
.filter_map(|p| match p.kind {
cadcraft_engine::render::Kind::Image { index } => list.images.get(index as usize).map(|i| i.right.x * 4.0),
_ => None,
})
.collect();
assert_eq!(widths, [4.0, 8.0]);
}
fn drawn_images(s: &Session) -> usize {
cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default()).images.len()
}
#[test]
fn editing_commands_work_on_images_and_undo_keeps_the_pixels() {
hooks();
let mut s = Session::new();
let r = s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [10, 0], "width": 4 })).unwrap();
let h = r["handle"].as_str().unwrap().to_string();
// Move, then a quarter turn about the origin: the corner and both axes follow.
s.execute("move", &json!({ "handles": [h], "delta": [0, 5] })).unwrap();
s.execute("rotate", &json!({ "handles": [h], "base": [0, 0], "angle": 90 })).unwrap();
let i = images(&s).pop().unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(close(i.insert.x, -5.0) && close(i.insert.y, 10.0), "{i:?}");
assert!(close(i.u.x, 0.0) && close(i.u.y, 1.0) && close(i.v.x, -1.0) && close(i.v.y, 0.0), "{i:?}");
assert_eq!(drawn_images(&s), 1);
// A copy is a second reference to the same file.
s.execute("copy", &json!({ "handles": [h], "delta": [20, 0] })).unwrap();
assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2));
assert_eq!(listed(&mut s)[0]["references"], 2);
assert_eq!(listed(&mut s).as_array().map(Vec::len), Some(1));
// Scaled about its corner: twice the size, still the same pixels.
s.execute("scale", &json!({ "handles": [h], "base": [-5, 10], "factor": 2 })).unwrap();
let i = images(&s).into_iter().find(|i| close(i.insert.x, -5.0)).unwrap();
assert!(close(i.u.y, 2.0) && close(i.size.x, 4.0), "{i:?}");
// Erased, then brought back by undo, with its picture; redo erases again.
s.execute("erase", &json!({ "handles": [h] })).unwrap();
assert_eq!((images(&s).len(), drawn_images(&s)), (1, 1));
s.execute("undo", &json!({})).unwrap();
assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2));
s.execute("redo", &json!({})).unwrap();
assert_eq!(images(&s).len(), 1);
// Everything undone: no image, and nothing left to draw.
for _ in 0..10 {
let _ = s.execute("undo", &json!({}));
}
assert!(images(&s).is_empty());
assert_eq!(drawn_images(&s), 0);
assert_eq!(listed(&mut s), json!([]));
}
#[test]
fn the_alias_and_the_command_line_reach_the_command() {
hooks();
assert_eq!(cadcraft_engine::cmd::resolve_alias("IAT"), "imageattach");
assert_eq!(cadcraft_engine::cmd::resolve_alias("im"), "image");
let spec = |id: &str| cadcraft_engine::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
assert!(!spec("imageattach").params.is_empty() && !spec("image").params.is_empty());
// Typed without a file it has nothing to attach: an error, not a crash or a stuck prompt.
let mut s = Session::new();
let _ = s.cmdline("imageattach");
let _ = s.cmdline("");
s.cancel();
assert!(s.running.is_none() && images(&s).is_empty());
}
#[test]
fn images_in_blocks_and_layouts_are_loaded_on_open() {
use cadcraft_engine::doc::geom::{Vec2, Vec3};
use cadcraft_engine::doc::{Block, Common, Entity, Space};
hooks();
let dir = folder("spaces");
std::fs::create_dir_all(dir.join("maps")).unwrap();
std::fs::write(dir.join("maps").join("a.png"), png()).unwrap();
std::fs::write(dir.join("b.png"), png()).unwrap();
let image = |path: &str, name: &str| {
EntityKind::Image(Image {
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(4.0, 2.0),
path: path.into(),
name: name.into(),
..Image::default()
})
};
let mut d = Drawing::new_metric();
// The same file in model space, in a block and on a layout; another only on the layout,
// saved with a folder that no longer exists (found by its name next to the drawing); and
// one that is nowhere.
d.add(&Space::Model, Common::default(), image("maps/a.png", "a")).unwrap();
let mut blk = Block::new("MAP");
let bh = d.new_handle();
blk.entities.push(Entity::new(bh, image("maps/a.png", "a")));
d.blocks.insert("MAP".into(), std::sync::Arc::new(blk));
let layout = Space::Paper("Layout1".into());
d.add(&layout, Common::default(), image("maps/a.png", "a")).unwrap();
d.add(&layout, Common::default(), image("C:\\old\\place\\b.png", "b")).unwrap();
d.add(&layout, Common::default(), image("gone.png", "gone")).unwrap();
let host = dir.join("host.dxf");
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
let mut s = Session::new();
open(&mut s, &host);
let doc = s.doc().unwrap();
assert_eq!(doc.rasters.len(), 2, "each file once");
assert!(doc.rasters.get("maps/a.png").is_some() && doc.rasters.get("C:\\old\\place\\b.png").is_some());
assert!(!s.state().unwrap().is_dirty());
assert_eq!(s.log.iter().filter(|l| l.starts_with("Image ")).count(), 1, "{:?}", s.log);
let l = listed(&mut s);
let row = |name: &str| l.as_array().unwrap().iter().find(|r| r["name"] == name).cloned().unwrap();
assert_eq!((row("a")["status"].clone(), row("a")["references"].clone()), (json!("Loaded"), json!(3)));
assert_eq!(row("b")["status"], "Loaded");
assert_eq!(row("gone")["status"], "Not Found");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_drawing_opened_from_bytes_gets_its_pixels_handed_over() {
// The web app's way: no file system, the drawing arrives as bytes and so do the images.
use cadcraft_engine::doc::geom::{Vec2, Vec3};
use cadcraft_engine::doc::{Common, Space};
hooks();
let mut d = Drawing::new_metric();
let i = Image {
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(4.0, 2.0),
path: "no-such-folder/web.png".into(),
name: "web".into(),
..Image::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
let dxf = cadcraft_io::write(&d, "plan.dxf").unwrap();
let mut s = Session::new();
s.execute("open", &json!({ "data": base64_encode(&dxf), "name": "plan.dxf" })).unwrap();
assert_eq!(images(&s).len(), 1, "opened with its image reference");
assert!(s.doc().unwrap().rasters.is_empty());
assert!(s.log.iter().any(|l| l.contains("web.png")), "{:?}", s.log);
assert_eq!(listed(&mut s)[0]["status"], "Not Found");
assert_eq!(drawn_images(&s), 0);
// The same file handed over as bytes: the reference that was there draws too.
s.execute("imageattach", &json!({ "path": "no-such-folder/web.png", "data": base64_encode(&png()), "at": [10, 0] })).unwrap();
assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2));
let l = listed(&mut s);
assert_eq!((l[0]["status"].clone(), l[0]["references"].clone(), l[0]["name"].clone()), (json!("Loaded"), json!(2), json!("web")));
// Saved and read back: the references are kept, the pixels are not in the file.
let bytes = cadcraft_io::write(s.doc().unwrap(), "plan.dxf").unwrap();
let back = cadcraft_io::read(&bytes, "plan.dxf").unwrap();
assert!(back.rasters.is_empty());
assert_eq!(back.model.iter().filter(|e| matches!(e.kind, EntityKind::Image(_))).count(), 2);
}
#[test]
fn many_files_are_loaded_together_and_reported_in_order() {
// More files than loaders, with missing and unreadable ones among them: each good file is
// loaded, each bad one reported once, in the order the drawing uses them.
use cadcraft_engine::doc::geom::{Vec2, Vec3};
use cadcraft_engine::doc::{Common, Space};
hooks();
let dir = folder("many");
let mut d = Drawing::new_metric();
let mut expected_notes = Vec::new();
for n in 0..60u32 {
let name = format!("tile{n:02}.png");
match n % 10 {
3 => expected_notes.push(format!("Image {name}: file not found")),
7 => {
std::fs::write(dir.join(&name), b"not an image").unwrap();
expected_notes.push(format!("Image {name}: "));
}
_ => {
// A different width per file, so a mix-up would show.
let px = vec![200u8; (n as usize + 1) * 4];
std::fs::write(dir.join(&name), cadcraft_io::raster::encode_png(&Raster::new(n + 1, 1, px).unwrap()).unwrap()).unwrap();
}
}
let i = Image {
insert: Vec3::new(f64::from(n) * 3.0, 0.0, 0.0),
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(f64::from(n + 1), 1.0),
path: name.clone(),
name: format!("tile{n:02}"),
..Image::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
}
let host = dir.join("host.dxf");
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
for _ in 0..3 {
let mut s = Session::new();
open(&mut s, &host);
let doc = s.doc().unwrap();
assert_eq!(doc.rasters.len(), 48);
for n in 0..60u32 {
let loaded = doc.rasters.get(&format!("tile{n:02}.png")).map(|r| r.width());
assert_eq!(loaded, if matches!(n % 10, 3 | 7) { None } else { Some(n + 1) }, "tile {n}");
}
let notes: Vec<&String> = s.log.iter().filter(|l| l.starts_with("Image ")).collect();
assert_eq!(notes.len(), expected_notes.len(), "{notes:?}");
assert!(notes.iter().zip(&expected_notes).all(|(got, want)| got.starts_with(want.as_str())), "{notes:?}");
assert!(!s.state().unwrap().is_dirty());
}
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1,55 +0,0 @@
//! A raster image decoder that panics on a damaged file fails that image only (#956): the
//! drawing it belongs to still opens, and IMAGEATTACH reports an error.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io};
use cadcraft_engine::doc::geom::{Vec2, Vec3};
use cadcraft_engine::doc::{Common, Drawing, EntityKind, Image, Line, Space};
use serde_json::json;
fn hooks() {
set_io(IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: None,
});
set_image_decoder(|_| panic!("decoder bug"));
}
#[test]
fn a_panicking_decoder_fails_the_image_not_the_drawing() {
hooks();
let dir = std::env::temp_dir().join(format!("cadcraft-image-panic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("map.png"), b"anything").unwrap();
let mut d = Drawing::new_metric();
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 5.0, 0.0) })).unwrap();
let i = Image {
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(4.0, 2.0),
path: "map.png".into(),
name: "map".into(),
..Image::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
let host = dir.join("host.dxf");
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
let mut s = Session::new();
s.execute("open", &json!({ "path": host.to_string_lossy() })).unwrap();
let doc = s.doc().unwrap();
assert_eq!(doc.model.len(), 2, "the drawing opened with everything in it");
assert!(doc.rasters.is_empty());
assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("damaged")), "{:?}", s.log);
let before = s.doc().unwrap().model.len();
let e = s.execute("imageattach", &json!({ "path": "other.png", "data": base64_encode(b"bytes") })).unwrap_err().to_string();
assert!(e.contains("damaged"), "{e}");
assert_eq!(s.doc().unwrap().model.len(), before);
// The session still works.
s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), before + 1);
let _ = std::fs::remove_dir_all(&dir);
}
-1
View File
@@ -40,7 +40,6 @@ fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
d.model.push(Entity::new(Handle(0x61), EntityKind::Insert(ins)));
Ok(d)
-1
View File
@@ -40,7 +40,6 @@ fn drawing() -> (Session, Handle) {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap();
(s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap())
-1
View File
@@ -21,7 +21,6 @@ fn insert(block: &str) -> Insert {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}
}
-1
View File
@@ -20,7 +20,6 @@ fn insert(block: &str, x: f64, cols: u32, rows: u32) -> EntityKind {
rows,
col_spacing: 5.0,
row_spacing: 5.0,
clip: None,
})
}
+3 -3
View File
@@ -135,9 +135,9 @@ fn mview_restore_puts_a_saved_configuration_on_a_layout() {
fn mview_restore_ignores_unusable_tiles_from_files() {
let (mut s, _) = two_tiles();
let bad = [
VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0, twist: 0.0 },
VportTile { min: v(0.5, 0.5), max: v(0.5, 0.9), center: v(0.0, 0.0), height: 1.0, aspect: 1.0, twist: 0.0 },
VportTile { min: v(-3.0, -3.0), max: v(9.0, 9.0), center: v(f64::INFINITY, 0.0), height: -1.0, aspect: f64::NAN, twist: 0.0 },
VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 },
VportTile { min: v(0.5, 0.5), max: v(0.5, 0.9), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 },
VportTile { min: v(-3.0, -3.0), max: v(9.0, 9.0), center: v(f64::INFINITY, 0.0), height: -1.0, aspect: f64::NAN },
];
if let Some(c) = s.doc_mut().unwrap().vports.iter_mut().find(|c| c.name == "Two") {
c.tiles = bad.to_vec();
-1
View File
@@ -165,7 +165,6 @@ mod viewport_clip;
mod vplayer_prompts;
mod vports;
mod wipeout_grips;
mod xclip;
mod xdata_follows_entity;
mod zoom_keywords;
mod zoom_viewport;
@@ -27,7 +27,6 @@ fn drawing() -> (Drawing, Space) {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
}),
)
.unwrap();
@@ -311,7 +310,6 @@ fn viewport_frozen_layers_apply_inside_nested_block_references() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
};
let mut outer = Block::new("Outer");
+5 -19
View File
@@ -46,7 +46,7 @@ fn typed_save_restore_list_delete() {
// Saving is an undo step; a view change is not.
set_view(&mut s, 0.0, 0.0, 500.0);
typed(&mut s, &["view", "r", "front"]);
assert_eq!(view(&s), View { center: Vec2::new(10.0, 20.0), height: 30.0, twist: 0.0 });
assert_eq!(view(&s), View { center: Vec2::new(10.0, 20.0), height: 30.0 });
// Zoom Previous goes back to the view before the restore.
s.execute("zoom.previous", &json!({})).unwrap();
assert_eq!(view(&s).height, 500.0);
@@ -84,20 +84,6 @@ fn window_option_and_json_forms() {
assert!(s.execute("view", &json!({ "option": "orthographic" })).is_err());
}
#[test]
fn a_window_saved_in_a_twisted_view_restores_whole() {
let mut s = session();
s.execute("view.set", &json!({ "center": [0, 0], "height": 10, "twist": 45 })).unwrap();
s.execute("view", &json!({ "option": "window", "name": "Turned", "p1": [0, 0], "p2": [10, 10] })).unwrap();
let v = s.doc().unwrap().views.first().cloned().unwrap();
// A 10 x 10 world square seen at 45° is 10√2 across on screen.
assert!(close(v.width, 200f64.sqrt()) && close(v.height, 200f64.sqrt()), "{} x {}", v.width, v.height);
// Corners near the number limit overflow when turned: an error, and the saved view stays.
let huge = json!({ "option": "window", "name": "Turned", "p1": [1.4e308, 1.4e308], "p2": [1.5e308, 1.5e308] });
assert!(s.execute("view", &huge).is_err());
assert!(close(s.doc().unwrap().views.first().unwrap().width, 200f64.sqrt()));
}
#[test]
fn layer_snapshot_restores_layers_and_the_setting_turns_it_off() {
let mut s = session();
@@ -126,7 +112,7 @@ fn paper_space_view_switches_to_its_layout() {
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
s.execute("view", &json!({ "option": "restore", "name": "Sheet" })).unwrap();
assert_eq!(s.layout_space(), Space::Paper(layout));
assert_eq!(view(&s), View { center: Vec2::new(5.0, 4.0), height: 6.0, twist: 0.0 });
assert_eq!(view(&s), View { center: Vec2::new(5.0, 4.0), height: 6.0 });
}
#[test]
@@ -152,7 +138,7 @@ fn zoom_realtime_drags_and_enter_ends() {
assert_eq!(view(&s).height, 100.0);
// The JSON form zooms by a factor about the centre.
s.execute("zoom.realtime", &json!({ "factor": 4 })).unwrap();
assert_eq!(view(&s), View { center: Vec2::ZERO, height: 25.0, twist: 0.0 });
assert_eq!(view(&s), View { center: Vec2::ZERO, height: 25.0 });
}
#[test]
@@ -172,10 +158,10 @@ fn zoom_dynamic_places_and_sizes_the_view_box() {
assert!(close(v.height, 40.0), "{v:?}");
// Zoom Previous skips the overview.
s.execute("zoom.previous", &json!({})).unwrap();
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.0 });
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0 });
// JSON: like Center.
s.execute("zoom.dynamic", &json!({ "center": [1, 2], "height": 3 })).unwrap();
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0, twist: 0.0 });
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0 });
}
#[test]
@@ -41,7 +41,6 @@ fn session() -> Session {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
s
-2
View File
@@ -20,7 +20,6 @@ fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: f64, rot_deg: f64
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
});
s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex()
}
@@ -247,7 +246,6 @@ fn a_block_that_references_itself_in_a_file_does_not_hang() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
});
let d = s.doc_mut().unwrap();
let hh = d.new_handle();
-1
View File
@@ -31,7 +31,6 @@ fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: (f64, f64), rotat
rows: grid.1,
col_spacing: grid.2,
row_spacing: grid.3,
clip: None,
});
s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex()
}
-1
View File
@@ -41,7 +41,6 @@ fn insert_block(s: &mut Session, name: &str, at: Vec2) -> String {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
.unwrap()
+5 -5
View File
@@ -46,15 +46,15 @@ fn split_activate_and_each_tile_keeps_its_view() {
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]);
// Every new tile starts with the view that was split.
assert!(tiles(&s).iter().all(|t| t.view == View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.0 }));
assert!(tiles(&s).iter().all(|t| t.view == View { center: Vec2::new(10.0, 10.0), height: 20.0 }));
let first = current(&s);
let other = 1 - first;
s.activate_tile(other).unwrap();
assert_eq!(current(&s), other);
s.execute("view.set", &json!({ "center": [50, 25], "height": 5 })).unwrap();
s.execute("vports.activate", &json!({ "index": first + 1 })).unwrap();
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.0 });
assert_eq!(tiles(&s)[other].view, View { center: Vec2::new(50.0, 25.0), height: 5.0, twist: 0.0 });
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0 });
assert_eq!(tiles(&s)[other].view, View { center: Vec2::new(50.0, 25.0), height: 5.0 });
// Switching tiles changes no drawing data; arranging them is an undo step.
assert_eq!(s.state().unwrap().undo.len(), 2, "line + VPORTS");
s.undo().unwrap();
@@ -82,7 +82,7 @@ fn typed_three_right_then_join_and_single() {
s.click_tile(bottom).unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]);
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0, twist: 0.0 });
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0 });
// Tiles that don't make a rectangle can't be joined.
s.execute("-vports", &json!({ "option": "3", "arrangement": "above" })).unwrap();
let t = tiles(&s);
@@ -93,7 +93,7 @@ fn typed_three_right_then_join_and_single() {
s.execute("view.set", &json!({ "center": [7, 7], "height": 7 })).unwrap();
typed(&mut s, &["-vports", "si"]);
assert!(tiles(&s).is_empty());
assert_eq!(view(&s), View { center: Vec2::new(7.0, 7.0), height: 7.0, twist: 0.0 });
assert_eq!(view(&s), View { center: Vec2::new(7.0, 7.0), height: 7.0 });
}
#[test]
-273
View File
@@ -1,273 +0,0 @@
//! XCLIP and XCLIPFRAME: clipping block and xref references (gap F2).
use cadcraft_engine::doc::{ClipBoundary, Common, EntityKind, Handle, Insert, Space};
use cadcraft_engine::geom::{Vec2, Vec3};
use cadcraft_engine::render::{self, Options};
use cadcraft_engine::{Input, Session};
use serde_json::{Value, json};
fn h(v: &Value) -> String {
v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_string()
}
fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: f64, rot_deg: f64) -> String {
let kind = EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::new(at.0, at.1, 0.0),
scale: Vec3::new(scale, scale, scale),
rotation: rot_deg.to_radians(),
..Insert::default()
});
s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex()
}
/// Block B: a horizontal line (0,0)-(100,0) and a vertical one (50,-50)-(50,50); two references.
fn setup() -> (Session, String, String) {
let mut s = Session::new();
let a = h(&s.execute("line", &json!({ "points": [[0, 0], [100, 0]] })).unwrap());
let b = h(&s.execute("line", &json!({ "points": [[50, -50], [50, 50]] })).unwrap());
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [a, b], "keep": "delete" })).unwrap();
let r1 = insert(&mut s, "B", (0.0, 0.0), 1.0, 0.0);
let r2 = insert(&mut s, "B", (1000.0, 0.0), 2.0, 90.0);
(s, r1, r2)
}
fn clip(s: &Session, hex: &str) -> Option<ClipBoundary> {
match &s.doc().unwrap().entity(Handle::parse_hex(hex).unwrap()).unwrap().kind {
EntityKind::Insert(i) => i.clip.clone(),
_ => None,
}
}
/// The x extent of what the drawing shows around reference 1 (x < 500).
fn shown_x(s: &Session) -> (f64, f64) {
let list = render::build(s.doc().unwrap(), &Space::Model, &Options::default());
let xs: Vec<f64> = list.verts.iter().filter(|v| v.x < 500.0).map(|v| v.x).collect();
(xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max))
}
#[test]
fn rectangular_clip_cuts_only_that_reference() {
let (mut s, r1, r2) = setup();
assert_eq!(shown_x(&s), (0.0, 100.0));
s.execute("setvar", &json!({ "name": "XCLIPFRAME", "value": 0 })).unwrap();
let out = s.execute("xclip", &json!({ "handle": r1, "rectangle": [[20, -10], [70, 10]] })).unwrap();
assert_eq!(out["option"], "new");
let c = clip(&s, &r1).unwrap();
assert!(c.enabled && !c.inverted && c.points.len() == 2);
let (lo, hi) = shown_x(&s);
assert!((lo - 20.0).abs() < 1e-9 && (hi - 70.0).abs() < 1e-9, "{lo} {hi}");
assert!(clip(&s, &r2).is_none(), "the other reference stays whole");
// The boundary follows its reference.
s.execute("move", &json!({ "handles": [r1], "delta": [5, 0] })).unwrap();
let (lo, hi) = shown_x(&s);
assert!((lo - 25.0).abs() < 1e-9 && (hi - 75.0).abs() < 1e-9, "{lo} {hi}");
// Its extents are the clipped part (ZOOM Extents).
let d = s.doc().unwrap();
let b = cadcraft_engine::doc::entity_bounds(d, d.entity(Handle::parse_hex(&r1).unwrap()).unwrap(), 0);
assert!((b.min.x - 25.0).abs() < 1e-9 && (b.max.x - 75.0).abs() < 1e-9 && b.max.y <= 10.0 + 1e-9, "{b:?}");
// Query reports it in drawing coordinates.
let q = s.execute("xclip", &json!({ "handle": r1, "option": "query" })).unwrap();
assert_eq!(q["clips"][0]["boundary"][0], json!([25.0, -10.0]));
// OFF shows everything, ON clips again, Delete removes it; undo brings it back.
s.execute("xclip", &json!({ "handle": r1, "option": "off" })).unwrap();
assert_eq!(shown_x(&s), (5.0, 105.0));
s.execute("xclip", &json!({ "handle": r1, "option": "on" })).unwrap();
assert!((shown_x(&s).0 - 25.0).abs() < 1e-9);
s.execute("xclip", &json!({ "handle": r1, "option": "delete" })).unwrap();
assert!(clip(&s, &r1).is_none());
s.execute("undo", &json!({})).unwrap();
assert!(clip(&s, &r1).is_some());
}
#[test]
fn off_on_a_selection_with_an_unclipped_reference_changes_nothing() {
let (mut s, r1, r2) = setup();
s.execute("xclip", &json!({ "handle": r1, "rectangle": [[20, -10], [70, 10]] })).unwrap();
// r2 has no boundary: the command fails, and r1 keeps its clip on.
assert!(s.execute("xclip", &json!({ "handles": [r1, r2], "option": "off" })).is_err());
assert!(clip(&s, &r1).unwrap().enabled);
assert!(clip(&s, &r2).is_none());
// A reference named many times is turned off once, its boundary kept.
s.execute("xclip", &json!({ "handles": vec![r1.clone(); 1000], "option": "off" })).unwrap();
let c = clip(&s, &r1).unwrap();
assert!(!c.enabled && c.points.len() == 2);
}
#[test]
fn a_clipped_reference_plots_its_contents_on_their_own_layers() {
let mut s = Session::new();
s.doc_mut().unwrap().layers.push(cadcraft_engine::doc::Layer { name: "Walls".into(), ..Default::default() });
let a = h(&s.execute("line", &json!({ "points": [[0, 0], [100, 0]] })).unwrap());
let b = h(&s.execute("line", &json!({ "points": [[50, -50], [50, 50]] })).unwrap());
s.doc_mut().unwrap().modify_entity(Handle::parse_hex(&a).unwrap(), |e| e.common.layer = "Walls".into()).unwrap();
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [a, b], "keep": "delete" })).unwrap();
let r = insert(&mut s, "B", (0.0, 0.0), 1.0, 0.0);
s.execute("setvar", &json!({ "name": "XCLIPFRAME", "value": 0 })).unwrap();
s.execute("xclip", &json!({ "handle": r, "rectangle": [[20, -10], [70, 10]] })).unwrap();
let d = s.doc().unwrap();
let index = |name: &str| d.layers.iter().position(|l| l.name == name).map(|i| i as u32);
let list = render::build_plot(d, &Space::Model, &Options::default());
assert_eq!(list.prim_layers.len(), list.prims.len());
// The horizontal line is on Walls, the vertical one (layer 0) takes the reference's layer.
assert!(list.prim_layers.contains(&index("Walls").unwrap()), "{:?}", list.prim_layers);
assert!(list.prim_layers.contains(&index("0").unwrap()), "{:?}", list.prim_layers);
}
#[test]
fn extents_are_the_boundary_box_as_in_the_reference_app() {
let (mut s, r1, _) = setup();
let bounds = |s: &Session| {
let d = s.doc().unwrap();
cadcraft_engine::doc::entity_bounds(d, d.entity(Handle::parse_hex(&r1).unwrap()).unwrap(), 0)
};
// A boundary reaching past the block (0..100 x -50..50): its own box, not the overlap.
s.execute("xclip", &json!({ "handle": r1, "rectangle": [[50, -20], [150, 30]] })).unwrap();
let b = bounds(&s);
assert!((b.min - Vec2::new(50.0, -20.0)).len() < 1e-9 && (b.max - Vec2::new(150.0, 30.0)).len() < 1e-9, "{b:?}");
// One that misses the block entirely: still its box.
s.execute("xclip", &json!({ "handle": r1, "rectangle": [[200, 200], [300, 300]] })).unwrap();
let b = bounds(&s);
assert!((b.min - Vec2::new(200.0, 200.0)).len() < 1e-9 && (b.max - Vec2::new(300.0, 300.0)).len() < 1e-9, "{b:?}");
}
#[test]
fn rotated_and_scaled_reference_gets_a_polygon_in_block_coordinates() {
let (mut s, _, r2) = setup();
// r2: scale 2, rotated 90 degrees at (1000, 0): the block's x axis points up.
s.execute("xclip", &json!({ "handle": r2, "rectangle": [[990, 10], [1010, 50]] })).unwrap();
let c = clip(&s, &r2).unwrap();
// A 90 degree rotation keeps the rectangle a rectangle: (5..25, -5..5) in the block.
let poly = c.polygon();
let xs: Vec<f64> = poly.iter().map(|p| p.x).collect();
assert!(xs.iter().any(|x| (x - 5.0).abs() < 1e-9) && xs.iter().any(|x| (x - 25.0).abs() < 1e-9), "{poly:?}");
// 30 degrees: kept as the four corners.
let r3 = insert(&mut s, "B", (2000.0, 0.0), 1.0, 30.0);
s.execute("xclip", &json!({ "handle": r3, "rectangle": [[1990, -10], [2010, 10]] })).unwrap();
assert_eq!(clip(&s, &r3).unwrap().points.len(), 4);
}
#[test]
fn inverted_polygonal_and_polyline_boundaries() {
let (mut s, r1, _) = setup();
s.execute("setvar", &json!({ "name": "XCLIPFRAME", "value": 0 })).unwrap();
s.execute("xclip", &json!({ "handle": r1, "points": [[40, -10], [60, -10], [60, 10], [40, 10]], "invert": true })).unwrap();
assert!(clip(&s, &r1).unwrap().inverted);
let list = render::build(s.doc().unwrap(), &Space::Model, &Options::default());
// The middle of the horizontal line is hidden, both ends show.
assert!(!list.verts.iter().any(|v| v.x > 40.5 && v.x < 59.5 && v.y.abs() < 1e-9));
assert!(list.verts.iter().any(|v| v.x == 0.0) && list.verts.iter().any(|v| v.x == 100.0));
// A closed polyline as the boundary.
let pl = h(&s.execute("pline", &json!({ "vertices": [[10, -5], [30, -5], [30, 5], [10, 5]], "closed": true })).unwrap());
s.execute("xclip", &json!({ "handle": r1, "polyline": pl })).unwrap();
let c = clip(&s, &r1).unwrap();
assert!(!c.inverted && c.points.len() == 4);
// An open polyline is refused, a bad boundary changes nothing.
let open = h(&s.execute("pline", &json!({ "vertices": [[0, 0], [5, 5], [9, 0]] })).unwrap());
assert!(s.execute("xclip", &json!({ "handle": r1, "polyline": open })).is_err());
assert!(s.execute("xclip", &json!({ "handle": r1, "points": [[0, 0], [1, 1]] })).is_err());
assert!(s.execute("xclip", &json!({ "handle": r1, "rectangle": [[0, 0], [0, 10]] })).is_err());
assert_eq!(clip(&s, &r1), Some(c));
// Generate polyline makes the boundary an object.
let n = s.doc().unwrap().model.len();
let out = s.execute("xclip", &json!({ "handle": r1, "option": "polyline" })).unwrap();
assert_eq!(out["polylines"].as_array().unwrap().len(), 1);
assert_eq!(s.doc().unwrap().model.len(), n + 1);
// Clip depth has no effect in 2D.
assert_eq!(s.execute("xclip", &json!({ "handle": r1, "option": "clipdepth" })).unwrap()["ignored"], true);
}
#[test]
fn hostile_params_never_panic() {
let (mut s, r1, _) = setup();
for p in [
json!({ "handle": r1, "points": "x" }),
json!({ "handle": r1, "points": [[1e308, 0], [-1e308, 0], [0, 1e308]] }),
json!({ "handle": r1, "rectangle": [[f64::MAX, 0]] }),
json!({ "handle": r1, "rectangle": 5 }),
json!({ "handle": r1, "polyline": "ZZZZ" }),
json!({ "handle": r1, "option": "bogus" }),
json!({ "handle": "nothex" }),
json!({ "handles": [] }),
json!({ "handle": r1, "points": vec![[0.0, 0.0]; 5000] }),
] {
let _ = s.execute("xclip", &p);
}
assert!(s.execute("xclipframe", &json!({ "value": 7 })).is_err());
assert!(s.execute("xclip", &json!({ "handle": r1, "option": "on" })).is_err(), "no boundary to turn on");
}
#[test]
fn typed_xclip_follows_the_prompts() {
let (mut s, r1, r2) = setup();
let pick = |hex: &str| Input::Pick(vec![Handle::parse_hex(hex).unwrap()]);
s.start("xclip").unwrap();
s.input(pick(&r1)).unwrap();
s.input(Input::Enter).unwrap();
assert!(s.prompt_text().contains("Enter clipping option"), "{}", s.prompt_text());
s.input(Input::Enter).unwrap(); // New
assert!(s.prompt_text().contains("Invert clip"), "{}", s.prompt_text());
s.input(Input::Keyword("Invert clip".into())).unwrap();
assert!(s.log.iter().any(|l| l.contains("Inside mode")));
s.input(Input::Keyword("Rectangular".into())).unwrap();
s.input(Input::Point(Vec2::new(10.0, -10.0))).unwrap();
s.input(Input::Point(Vec2::new(30.0, 10.0))).unwrap();
let c = clip(&s, &r1).unwrap();
assert!(c.inverted && c.points.len() == 2);
// Again on the clipped reference: "Delete old boundary(s)?", then a polygon.
s.cmdline("xclip").unwrap();
s.input(pick(&r1)).unwrap();
s.input(Input::Enter).unwrap();
s.cmdline("n").unwrap();
assert!(s.prompt_text().contains("Delete old boundary"), "{}", s.prompt_text());
s.cmdline("y").unwrap();
s.cmdline("p").unwrap();
for p in ["0,-5", "20,-5", "20,5"] {
s.cmdline(p).unwrap();
}
s.input(Input::Enter).unwrap();
let c = clip(&s, &r1).unwrap();
assert!(!c.inverted && c.points.len() == 3);
// OFF, then Delete on two references at once.
s.start("xclip").unwrap();
s.input(pick(&r1)).unwrap();
s.input(Input::Enter).unwrap();
s.cmdline("off").unwrap();
assert!(!clip(&s, &r1).unwrap().enabled);
s.execute("xclip", &json!({ "handle": r2, "rectangle": [[0, 0], [1, 1]] })).unwrap();
s.start("xclip").unwrap();
s.input(Input::Pick(vec![Handle::parse_hex(&r1).unwrap(), Handle::parse_hex(&r2).unwrap()])).unwrap();
s.input(Input::Enter).unwrap();
s.cmdline("d").unwrap();
assert!(clip(&s, &r1).is_none() && clip(&s, &r2).is_none());
// Delete with no boundary left says so and asks again.
s.start("xclip").unwrap();
s.input(pick(&r1)).unwrap();
s.input(Input::Enter).unwrap();
s.cmdline("d").unwrap();
assert!(s.log.iter().any(|l| l.contains("No clip boundary found.")), "{:?}", s.log);
assert!(s.prompt_text().contains("Enter clipping option"), "{}", s.prompt_text());
s.input(Input::Cancel).unwrap();
// XCLIPFRAME typed.
s.cmdline("xclipframe").unwrap();
s.cmdline("1").unwrap();
assert_eq!(s.execute("getvar", &json!({ "name": "XCLIPFRAME" })).unwrap()["value"], 1);
}
#[test]
fn the_frame_shows_and_plots_as_xclipframe_says() {
let (mut s, r1, _) = setup();
s.execute("xclip", &json!({ "handle": r1, "rectangle": [[20, -10], [70, 10]] })).unwrap();
let frame_at = |s: &Session, plot: bool| {
let d = s.doc().unwrap();
let list =
if plot { render::build_plot(d, &Space::Model, &Options::default()) } else { render::build(d, &Space::Model, &Options::default()) };
list.verts.iter().any(|v| (v.y - 10.0).abs() < 1e-9 && (v.x - 20.0).abs() < 1e-9)
};
// Default 2: shown, not plotted.
assert!(frame_at(&s, false) && !frame_at(&s, true));
s.execute("xclipframe", &json!({ "value": 1 })).unwrap();
assert!(frame_at(&s, false) && frame_at(&s, true));
s.execute("xclipframe", &json!({ "value": 0 })).unwrap();
assert!(!frame_at(&s, false) && !frame_at(&s, true));
}
-34
View File
@@ -1,34 +0,0 @@
//! FF8: the SHX fonts a drawing's text styles name are found beside the drawing it opens. The
//! font is compiled here from shape source written for the test.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::{Drawing, TextStyle};
use serde_json::json;
#[test]
fn opening_a_drawing_finds_the_shx_fonts_beside_it() {
set_io(IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: None,
});
let dir = std::env::temp_dir().join(format!("cadcraft-shx-beside-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let font = cadcraft_fonts::shx::compile("*0,4,beside\n6,2,0,0\n*65,7,UCA\n1,064,040,06C,2,020,0\n").unwrap();
std::fs::write(dir.join("cadcraft-beside-engine.shx"), font).unwrap();
let mut d = Drawing::new_imperial();
d.text_styles.push(TextStyle { name: "Beside".into(), font: "cadcraft-beside-engine.shx".into(), ..TextStyle::default() });
let path = dir.join("host.dxf");
std::fs::write(&path, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
assert!(!cadcraft_fonts::TextFont::resolve("cadcraft-beside-engine.shx").is_shx());
let mut s = Session::new();
s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap();
let style = s.doc().unwrap().text_style("Beside").cloned().unwrap();
let font = cadcraft_fonts::TextFont::resolve_style(&style.font, &style.big_font, style.vertical);
assert!(font.is_shx());
assert!((cadcraft_fonts::text_width(&font, "AA", 3.0, 1.0) - 6.0).abs() < 1e-9);
let _ = std::fs::remove_dir_all(&dir);
}
-1
View File
@@ -50,7 +50,6 @@ fn insert(handle: u64, block: &str, layer: &str) -> Entity {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
);
e.common = Common { layer: layer.into(), ..Common::default() };
-193
View File
@@ -1,193 +0,0 @@
//! The view twist (VIEWTWIST): DVIEW TWist turns the Model tab's view, ZOOM Previous turns it
//! back, each VPORTS tile keeps its own (DXF VPORT group 51), and VIEW Save/Restore keep it.
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Session, View, sysvars};
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.viewport_px = (1200.0, 800.0);
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
s.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap();
s
}
fn typed(s: &mut Session, lines: &[&str]) {
for l in lines {
s.cmdline(l).unwrap();
}
}
fn view(s: &Session) -> View {
s.state().unwrap().view()
}
fn twist_deg(s: &Session) -> f64 {
view(s).twist.to_degrees()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn dview_twist_sets_viewtwist_and_zoom_previous_turns_it_back() {
let mut s = session();
assert_eq!(view(&s).twist, 0.0);
typed(&mut s, &["dview", "", "tw", "30", ""]);
assert!(near(twist_deg(&s), 30.0), "twist {}", twist_deg(&s));
let vt = sysvars::get(&s, "VIEWTWIST").and_then(|v| v.as_f64()).unwrap();
assert!(near(vt, 30.0), "VIEWTWIST {vt}");
// Only the twist changed; the view is not an undo step.
assert_eq!((view(&s).center, view(&s).height), (Vec2::new(10.0, 10.0), 20.0));
assert_eq!(s.state().unwrap().undo.len(), 1, "just the line");
// Zooming keeps the twist; ZOOM Previous steps back through it.
s.execute("zoom", &json!({ "mode": "in" })).unwrap();
assert!(near(twist_deg(&s), 30.0));
s.execute("zoom", &json!({ "mode": "previous" })).unwrap();
s.execute("zoom", &json!({ "mode": "previous" })).unwrap();
assert_eq!(view(&s).twist, 0.0);
assert!(sysvars::is_read_only("VIEWTWIST"));
// JSON form; negative angles wrap into 0..360.
s.execute("dview", &json!({ "twist": -90 })).unwrap();
assert!(near(twist_deg(&s), 270.0));
// The 3D options are not available yet; the command carries on.
typed(&mut s, &["dview", "", "ca", ""]);
assert!(s.current_prompt().is_none());
// Not in a layout.
let layout = s.doc().unwrap().layouts.first().map(|l| l.name.clone()).unwrap();
s.execute("layout.set", &json!({ "name": layout })).unwrap();
assert!(s.execute("dview", &json!({ "twist": 10 })).is_err());
}
const PAPER: &str = "This command is not available in Paper space.";
#[test]
fn dview_in_paper_space_says_it_is_not_available() {
let mut s = session();
let layout = s.doc().unwrap().layouts.first().map(|l| l.name.clone()).unwrap();
s.execute("layout.set", &json!({ "name": layout })).unwrap();
let e = s.execute("dview", &json!({ "twist": 10 })).unwrap_err();
assert_eq!(e.to_string(), PAPER);
// Typed, it is refused as it starts: no prompt is left open.
let e = s.cmdline("dview").unwrap_err();
assert_eq!(e.to_string(), PAPER);
assert!(s.current_prompt().is_none());
// view.set can't turn a layout's view either (an untwisted one is fine).
let e = s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap_err();
assert_eq!(e.to_string(), PAPER);
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
assert_eq!(view(&s).twist, 0.0);
}
#[test]
fn a_single_untiled_view_keeps_its_twist_through_a_dxf_round_trip() {
let mut s = session();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
assert!(s.model_tiles().len() <= 1, "untiled");
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let written = lines.windows(2).any(|w| w[0] == "51" && w[1].parse::<f64>().is_ok_and(|a| near(a, 30.0)));
assert!(written, "the lone *ACTIVE VPORT carries group 51");
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let mut o = Session::new();
o.viewport_px = (1200.0, 800.0);
o.open_drawing(back.clone(), "single", None);
assert!(near(twist_deg(&o), 30.0), "twist {}", twist_deg(&o));
assert!(o.model_tiles().len() <= 1, "still untiled");
// And again: saved from the reopened session, it is still turned.
let again = cadcraft_io::read_dxf(cadcraft_io::write_dxf(o.doc().unwrap()).as_bytes()).unwrap();
let mut o2 = Session::new();
o2.viewport_px = (1200.0, 800.0);
o2.open_drawing(again, "again", None);
assert!(near(twist_deg(&o2), 30.0));
// Turned back (ZOOM Previous here), the drawing keeps no twist.
s.execute("zoom", &json!({ "mode": "previous" })).unwrap();
assert_eq!(view(&s).twist, 0.0);
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
let mut o3 = Session::new();
o3.viewport_px = (1200.0, 800.0);
o3.open_drawing(back, "plain", None);
assert_eq!(view(&o3).twist, 0.0);
}
#[test]
fn zoom_extents_fits_in_the_twisted_frame() {
let mut s = session();
s.execute("dview", &json!({ "twist": 90 })).unwrap();
s.execute("zoom", &json!({ "mode": "extents" })).unwrap();
let v = view(&s);
// Turned a quarter, the 100 x 50 line's box stands 100 high on screen.
assert!(near(v.twist.to_degrees(), 90.0));
assert!((v.center - Vec2::new(50.0, 25.0)).len() < 1e-6, "{:?}", v.center);
assert!(near(v.height, 105.0), "height {}", v.height);
}
#[test]
fn each_tile_keeps_its_twist_through_a_dxf_round_trip() {
let mut s = session();
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
let first = s.model_tiles().iter().position(|t| t.active).unwrap();
s.activate_tile(1 - first).unwrap();
assert_eq!(view(&s).twist, 0.0, "the other tile has its own twist");
s.execute("dview", &json!({ "twist": 60 })).unwrap();
let mut tw: Vec<f64> = s.model_tiles().iter().map(|t| t.view.twist.to_degrees()).collect();
tw.sort_by(f64::total_cmp);
assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}");
// Switching back shows the first tile's twist again.
s.activate_tile(first).unwrap();
assert!(near(twist_deg(&s), 30.0));
// A saved configuration keeps both twists through DXF (VPORT group 51).
s.execute("-vports", &json!({ "option": "save", "name": "Pair" })).unwrap();
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let written = lines.windows(2).any(|w| w[0] == "51" && w[1].parse::<f64>().is_ok_and(|a| near(a, 30.0)));
assert!(written, "group 51 written");
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let pair = back.vports.iter().find(|c| c.name == "Pair").unwrap();
let mut tw: Vec<f64> = pair.tiles.iter().map(|t| t.twist.to_degrees()).collect();
tw.sort_by(f64::total_cmp);
assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}");
// Opened, restoring the configuration shows each tile turned as saved.
let mut o = Session::new();
o.viewport_px = (1200.0, 800.0);
o.open_drawing(back, "pair", None);
o.execute("-vports", &json!({ "option": "restore", "name": "Pair" })).unwrap();
let mut tw: Vec<f64> = o.model_tiles().iter().map(|t| t.view.twist.to_degrees()).collect();
tw.sort_by(f64::total_cmp);
assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}");
}
#[test]
fn opening_a_tiled_drawing_shows_the_current_tile_twisted() {
let mut s = session();
s.execute("dview", &json!({ "twist": 45 })).unwrap();
s.execute("-vports", &json!({ "option": "2" })).unwrap();
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
let mut o = Session::new();
o.viewport_px = (1200.0, 800.0);
o.open_drawing(back, "tiled", None);
assert!(near(twist_deg(&o), 45.0), "twist {}", twist_deg(&o));
}
#[test]
fn view_save_and_restore_keep_the_twist() {
let mut s = session();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
s.execute("view", &json!({ "option": "save", "name": "Turned" })).unwrap();
let saved = s.doc().unwrap().views.iter().find(|v| v.name == "Turned").map(|v| v.twist.to_degrees()).unwrap();
assert!(near(saved, 30.0));
s.execute("dview", &json!({ "twist": 0 })).unwrap();
s.execute("view.set", &json!({ "center": [80, 40], "height": 3 })).unwrap();
assert_eq!(view(&s).twist, 0.0);
s.execute("view", &json!({ "option": "restore", "name": "Turned" })).unwrap();
assert!(near(twist_deg(&s), 30.0));
assert!((view(&s).center - Vec2::new(10.0, 10.0)).len() < 1e-9);
// view.set keeps the twist unless given one.
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
assert!(near(twist_deg(&s), 30.0));
s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap();
assert!(near(twist_deg(&s), 15.0));
}
-1
View File
@@ -52,7 +52,6 @@ fn insert(block: &str, x: f64) -> EntityKind {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
}
-1
View File
@@ -14,4 +14,3 @@ skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
icu_segmenter = { workspace = true }
encoding_rs = { workspace = true }
+13 -72
View File
@@ -1,8 +1,7 @@
//! CADCraft text layout.
//!
//! Turns TEXT and MTEXT into stroke polylines with the built-in single-stroke font
//! ("CADCraft Stroke"), the user's SHX shape fonts and big fonts ([`shx`]) or glyph outlines
//! of installed TrueType fonts ([`ttf`]), with a
//! ("CADCraft Stroke") or glyph outlines of installed TrueType fonts ([`ttf`]), with a
//! per-character fallback to installed fonts for characters the chosen font lacks. Handles the
//! `%%` control codes, alignment modes, width factor, obliquing, MTEXT inline formatting
//! (paragraphs, stacking, height changes), word wrap and attachment points.
@@ -19,7 +18,6 @@ pub mod bidi;
mod complex;
mod linebreak;
mod mtext;
pub mod shx;
mod stroke;
pub mod subset;
pub mod ttf;
@@ -44,8 +42,8 @@ pub struct Run {
}
/// Replace `%%` control codes: `%%d` degree, `%%p` plus/minus, `%%c` diameter, `%%%` percent,
/// `%%nnn` character code, and `\U+XXXX` Unicode escapes. Underline/overline toggles (`%%u`,
/// `%%o`) are returned as flags per character.
/// `%%nnn` character code. Underline/overline toggles (`%%u`, `%%o`) are returned as flags
/// per character.
pub fn decode_controls(s: &str) -> Vec<(char, bool, bool)> {
let mut out = Vec::with_capacity(s.len());
let mut under = false;
@@ -54,14 +52,6 @@ pub fn decode_controls(s: &str) -> Vec<(char, bool, bool)> {
let mut i = 0;
while i < chars.len() {
let c = chars.get(i).copied().unwrap_or(' ');
// `\U+XXXX`: a Unicode character by its code.
if c == '\\'
&& let Some(u) = unicode_escape(&chars, i)
{
out.push((u, under, over));
i += 7;
continue;
}
if c == '%' && chars.get(i + 1) == Some(&'%') {
let code = chars.get(i + 2).copied().unwrap_or(' ');
match code.to_ascii_lowercase() {
@@ -93,18 +83,6 @@ pub fn decode_controls(s: &str) -> Vec<(char, bool, bool)> {
out
}
/// The character of a `\U+XXXX` escape (four hex digits) starting at `chars[i]`.
pub(crate) fn unicode_escape(chars: &[char], i: usize) -> Option<char> {
if chars.get(i) != Some(&'\\') || !matches!(chars.get(i + 1), Some('U' | 'u')) || chars.get(i + 2) != Some(&'+') {
return None;
}
let hex: String = chars.get(i + 3..i + 7)?.iter().collect();
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
char::from_u32(u32::from_str_radix(&hex, 16).ok()?)
}
/// Advance width of one character at height 1 and width factor 1.
pub fn char_advance(c: char) -> f64 {
match stroke::glyph(c) {
@@ -301,65 +279,35 @@ pub fn space_width(font: &TextFont, height: f64, width_factor: f64) -> f64 {
{
return w;
}
if let TextFont::Shx(t) = font {
return shx::width(t, " ", height, width_factor);
}
stroke_advance(' ', height, width_factor) + stroke::GAP / stroke::CAP * height * width_factor
}
/// The font a piece of text is set in: the built-in stroke font, an installed TrueType /
/// OpenType face (outlines) or the user's SHX fonts (a main font and a big font). Whichever,
/// characters the font lacks come from a fallback font.
/// The font a piece of text is set in: the built-in stroke font or an installed TrueType /
/// OpenType face (outlines). Either way, characters the font lacks come from a fallback font.
#[derive(Clone, Debug, Default)]
pub enum TextFont {
#[default]
Stroke,
Outline(ttf::Face),
Shx(std::sync::Arc<shx::ShxText>),
}
impl TextFont {
/// Resolve a text style font name (no big font, horizontal): see [`TextFont::resolve_style`].
/// Resolve a text style font name: an installed TTF/OTF/TTC when found; else, for a font we
/// can't find (an SHX file, a missing TTF), the FONTALT font ([`ttf::font_alt`]) when set;
/// else the stroke font.
pub fn resolve(name: &str) -> TextFont {
TextFont::resolve_style(name, "", false)
}
/// Resolve a text style's fonts: an installed TTF/OTF/TTC when `font` names one; else the
/// SHX font and big font when found ([`shx::find`]), a missing one replaced by the FONTALT
/// font ([`ttf::font_alt`], which may name both: `simplex.shx,bigfont.shx`); else FONTALT
/// as a TrueType font; else the stroke font (which also draws the characters a missing big
/// font would have). `vertical` is the style's vertical flag, honoured by fonts made for it.
pub fn resolve_style(font: &str, big_font: &str, vertical: bool) -> TextFont {
if let Some(f) = ttf::find(font) {
if let Some(f) = ttf::find(name) {
return TextFont::Outline(f);
}
let n = font.trim();
let builtin = n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT);
let alt = ttf::font_alt();
let (alt_main, alt_big) = alt.split_once(',').map_or((alt.as_str(), ""), |(a, b)| (a.trim(), b.trim()));
let main = if builtin { None } else { shx::find(n).or_else(|| shx::find(alt_main)) };
let (big, big_name) = match big_font.trim() {
"" => (None, ""),
b => match shx::find(b) {
Some(f) => (Some(f), b),
None => (shx::find(alt_big), alt_big),
},
};
if main.is_some() || big.is_some() {
return TextFont::Shx(std::sync::Arc::new(shx::ShxText::new(main, big, big_name, vertical)));
}
if builtin {
let n = name.trim();
if n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT) {
return TextFont::Stroke;
}
ttf::find(alt_main).map(TextFont::Outline).unwrap_or(TextFont::Stroke)
ttf::find(&ttf::font_alt()).map(TextFont::Outline).unwrap_or(TextFont::Stroke)
}
pub fn is_outline(&self) -> bool {
matches!(self, TextFont::Outline(_))
}
/// Whether the text is drawn with SHX fonts.
pub fn is_shx(&self) -> bool {
matches!(self, TextFont::Shx(_))
}
}
/// Shaped text in local or world coordinates: open strokes (stroke-font glyphs, underlines,
@@ -488,9 +436,6 @@ pub fn shape_line_dir(font: &TextFont, s: &str, height: f64, width_factor: f64,
{
return sh;
}
if let TextFont::Shx(t) = font {
return shx::shape(t, s, height, width_factor, oblique);
}
shape_stroke_dir(s, height, width_factor, oblique, base_rtl)
}
@@ -505,9 +450,6 @@ pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f
{
return w;
}
if let TextFont::Shx(t) = font {
return shx::width(t, s, height, width_factor);
}
line_width(s, height, width_factor)
}
@@ -587,8 +529,7 @@ pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 {
fn bottom_depth(font: &TextFont, height: f64) -> f64 {
match font {
TextFont::Outline(face) => ttf::descent_ratio(face).map_or(height / 3.0, |r| r * height),
// AutoCAD places it a third of the height down with SHX fonts.
TextFont::Stroke | TextFont::Shx(_) => height / 3.0,
TextFont::Stroke => height / 3.0,
}
}
-16
View File
@@ -55,13 +55,6 @@ pub fn plain_mtext(s: &str) -> String {
i = j + 1;
}
'L' | 'l' | 'O' | 'o' | 'K' | 'k' => i += 2,
// `\U+XXXX`: a character by its Unicode code, as the layout reads it.
'U' if crate::unicode_escape(&chars, i).is_some() => {
if let Some(u) = crate::unicode_escape(&chars, i) {
out.push(u);
}
i += 7;
}
// Codes with an argument terminated by ';' (\f, \F, \H, \W, \Q, \T, \A, \C, \c, \p).
_ => {
let mut j = i + 2;
@@ -294,13 +287,6 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
'O' => derive(&mut fmts, &mut cur, &|f| f.over = true),
'o' => derive(&mut fmts, &mut cur, &|f| f.over = false),
'K' | 'k' => {}
// `\U+XXXX`: a character by its Unicode code.
'U' if i >= 2 && crate::unicode_escape(&chars, i - 2).is_some() => {
if let Some(u) = crate::unicode_escape(&chars, i - 2) {
push_char(&mut word, u, cur);
}
i += 5;
}
'S' => {
let mut j = i;
let (mut top, mut bottom, mut sep) = (String::new(), String::new(), None);
@@ -799,8 +785,6 @@ mod tests {
assert_eq!(plain_mtext("a\\~b\\\\c"), "a b\\c");
assert_eq!(plain_mtext("\\H2.5x;Big"), "Big");
assert_eq!(plain_mtext("unterminated \\H2"), "unterminated ");
assert_eq!(plain_mtext("\\U+0041B \\U+00E8"), "AB è");
assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument");
}
#[test]
-313
View File
@@ -1,313 +0,0 @@
//! Shape source (`.shp`) to a compiled SHX file, as COMPILE does.
//!
//! A source is a list of definitions: a header line `*number,defbytes,NAME` followed by its
//! specification bytes (on as many lines as needed, ending with 0). Numbers are decimal, or
//! hexadecimal after a leading `0` (`012`, `08cfb`); parentheses only group; `;` starts a
//! comment. A big font starts with `*BIGFONT nchars,nranges,first,last,…` and a Unicode font
//! with `*UNIFONT,6,name` followed by `above,below,modes,encoding,type,0`. Shape 0 is a text
//! font's record (`above,below,modes,0`).
//!
//! Operands are encoded by what they are: a subshape number takes two bytes in a Unicode font
//! (and after `7,0` in a big font), displacements and bulges are two's complement, and an arc's
//! direction byte (`-043`) keeps its sign in the high bit. `defbytes` is not checked: the
//! program's own length is written.
use super::ShxError;
use super::parse::{BIGFONT, SHAPES_10, UNIFONT};
/// Largest source compiled.
const MAX_SOURCE: usize = 8 << 20;
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Shapes,
Big,
Uni,
}
struct Shape {
number: u16,
name: String,
program: Vec<u8>,
}
/// A number token: value (sign included).
fn number(tok: &str, line: usize) -> Result<i64, ShxError> {
let t: String = tok.chars().filter(|c| !matches!(c, '(' | ')') && !c.is_whitespace()).collect();
let (neg, digits) = match t.strip_prefix('-') {
Some(d) => (true, d),
None => (false, t.strip_prefix('+').unwrap_or(&t)),
};
let err = || ShxError::Source(line, format!("not a number: {tok:?}"));
let v = if digits.len() > 1 && digits.starts_with('0') {
i64::from_str_radix(digits.get(1..).unwrap_or_default(), 16).map_err(|_| err())?
} else {
digits.parse::<i64>().map_err(|_| err())?
};
if v > 0xFFFF {
return Err(err());
}
Ok(if neg { -v } else { v })
}
/// The comma-separated numbers of a line (comment removed, blank fields skipped).
fn numbers(text: &str, line: usize) -> Result<Vec<i64>, ShxError> {
text.split(',').map(str::trim).filter(|t| !t.is_empty() && *t != "(" && *t != ")").map(|t| number(t, line)).collect()
}
/// The spec-byte encoder: walks the tokens as the reader walks the bytes.
struct Enc<'a> {
toks: &'a [i64],
i: usize,
out: Vec<u8>,
kind: Kind,
line: usize,
}
impl Enc<'_> {
fn next(&mut self) -> Result<i64, ShxError> {
let v = self.toks.get(self.i).copied().ok_or_else(|| ShxError::Source(self.line, "the shape ends inside a command".into()))?;
self.i += 1;
Ok(v)
}
fn byte(&mut self) -> Result<(), ShxError> {
let v = self.next()?;
let b = u8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a byte")))?;
self.out.push(b);
Ok(())
}
/// Two's complement −128..127.
fn signed(&mut self) -> Result<i64, ShxError> {
let v = self.next()?;
let b = i8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not −128..127")))?;
self.out.extend(b.to_le_bytes());
Ok(v)
}
/// Sign in the high bit (an arc's direction byte).
fn sign_magnitude(&mut self) -> Result<(), ShxError> {
let v = self.next()?;
let m = u8::try_from(v.unsigned_abs())
.ok()
.filter(|m| *m < 0x80)
.ok_or_else(|| ShxError::Source(self.line, format!("{v} is not an octant byte")))?;
self.out.push(if v < 0 { m | 0x80 } else { m });
Ok(())
}
fn word(&mut self) -> Result<(), ShxError> {
let v = self.next()?;
let w = u16::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a shape number")))?;
self.out.extend(w.to_be_bytes());
Ok(())
}
fn program(mut self) -> Result<Vec<u8>, ShxError> {
while self.i < self.toks.len() {
let code = self.next()?;
let c = u8::try_from(code).map_err(|_| ShxError::Source(self.line, format!("{code} is not a code")))?;
self.out.push(c);
match c {
0 => break,
3 | 4 => self.byte()?,
7 => match self.kind {
Kind::Uni => self.word()?,
Kind::Big => {
let first = self.toks.get(self.i).copied().unwrap_or(0);
if first == 0 {
self.byte()?;
self.word()?;
for _ in 0..4 {
self.byte()?;
}
} else {
self.byte()?;
}
}
Kind::Shapes => self.byte()?,
},
8 => {
self.signed()?;
self.signed()?;
}
9 => loop {
let (x, y) = (self.signed()?, self.signed()?);
if x == 0 && y == 0 {
break;
}
},
10 => {
self.byte()?;
self.sign_magnitude()?;
}
11 => {
for _ in 0..4 {
self.byte()?;
}
self.sign_magnitude()?;
}
12 => {
for _ in 0..3 {
self.signed()?;
}
}
13 => loop {
let (x, y) = (self.signed()?, self.signed()?);
if x == 0 && y == 0 {
break;
}
self.signed()?;
},
_ => {}
}
}
if self.out.last() != Some(&0) {
self.out.push(0);
}
Ok(self.out)
}
}
/// Compile shape source into an SHX file (a shape file or text font, a big font, or a Unicode
/// font, as its first definition says).
pub fn compile(source: &str) -> Result<Vec<u8>, ShxError> {
if source.len() > MAX_SOURCE {
return Err(ShxError::TooLarge);
}
let mut kind = Kind::Shapes;
let mut ranges: Vec<(u16, u16)> = Vec::new();
let mut uni_info: Option<(String, Vec<u8>)> = None;
// Definitions: header (number, name, line) and the numbers that follow it.
let mut defs: Vec<(i64, String, usize, Vec<i64>)> = Vec::new();
let mut uni_header_pending = false;
for (no, raw) in source.lines().enumerate() {
let line = no + 1;
let text = raw.split(';').next().unwrap_or_default().trim();
if text.is_empty() {
continue;
}
if let Some(head) = text.strip_prefix('*') {
let upper = head.trim_start().to_ascii_uppercase();
if let Some(rest) = upper.strip_prefix("BIGFONT") {
kind = Kind::Big;
let v = numbers(rest, line)?;
let n = usize::try_from(v.get(1).copied().unwrap_or(0)).unwrap_or(0);
for k in 0..n {
let lo = v.get(2 + 2 * k).copied().unwrap_or(0);
let hi = v.get(3 + 2 * k).copied().unwrap_or(lo);
let to = |x: i64| u16::try_from(x).map_err(|_| ShxError::Source(line, "bad escape range".into()));
ranges.push((to(lo)?, to(hi)?));
}
continue;
}
if upper.starts_with("UNIFONT") {
kind = Kind::Uni;
let name = head.splitn(3, ',').nth(2).unwrap_or_default().trim().to_string();
uni_info = Some((name, Vec::new()));
uni_header_pending = true;
continue;
}
uni_header_pending = false;
let mut parts = head.splitn(3, ',');
let num = number(parts.next().unwrap_or_default().trim(), line)?;
let _defbytes = parts.next();
let name = parts.next().unwrap_or_default().trim().to_string();
defs.push((num, name, line, Vec::new()));
continue;
}
let v = numbers(text, line)?;
if uni_header_pending {
if let Some((_, info)) = uni_info.as_mut() {
for x in v {
info.push(u8::try_from(x).map_err(|_| ShxError::Source(line, "bad font record".into()))?);
}
}
continue;
}
match defs.last_mut() {
Some(d) => d.3.extend(v),
None => return Err(ShxError::Source(line, "specification bytes before any *number,defbytes,NAME line".into())),
}
}
let mut shapes: Vec<Shape> = Vec::new();
for (num, name, line, toks) in defs {
let number = u16::try_from(num).map_err(|_| ShxError::Source(line, format!("shape number {num} out of range")))?;
let program = if number == 0 {
// The font record: plain bytes.
toks.iter().map(|x| u8::try_from(*x).map_err(|_| ShxError::Source(line, "bad font record".into()))).collect::<Result<Vec<u8>, _>>()?
} else {
Enc { toks: &toks, i: 0, out: Vec::new(), kind, line }.program()?
};
if shapes.iter().any(|s| s.number == number) {
return Err(ShxError::Source(line, format!("shape {number} is defined twice")));
}
shapes.push(Shape { number, name, program });
}
if shapes.is_empty() {
return Err(ShxError::Empty);
}
shapes.sort_by_key(|s| s.number);
let record = |s: &Shape| -> Result<Vec<u8>, ShxError> {
let mut r: Vec<u8> = s.name.bytes().filter(|b| *b != 0).collect();
r.push(0);
r.extend(&s.program);
if r.len() > usize::from(u16::MAX) { Err(ShxError::TooLarge) } else { Ok(r) }
};
let count = u16::try_from(shapes.len()).map_err(|_| ShxError::TooLarge)?;
let len16 = |r: &[u8]| u16::try_from(r.len()).unwrap_or(u16::MAX);
let mut out = Vec::new();
match kind {
Kind::Shapes => {
out.extend(SHAPES_10);
out.extend(shapes.first().map_or(0, |s| s.number).to_le_bytes());
out.extend(shapes.last().map_or(0, |s| s.number).to_le_bytes());
out.extend(count.to_le_bytes());
let recs: Vec<Vec<u8>> = shapes.iter().map(record).collect::<Result<_, _>>()?;
for (s, r) in shapes.iter().zip(&recs) {
out.extend(s.number.to_le_bytes());
out.extend(len16(r).to_le_bytes());
}
for r in &recs {
out.extend(r);
}
out.extend(b"EOF");
}
Kind::Big => {
out.extend(BIGFONT);
let nranges = u16::try_from(ranges.len()).map_err(|_| ShxError::TooLarge)?;
out.extend(count.to_le_bytes());
out.extend(count.to_le_bytes());
out.extend(nranges.to_le_bytes());
for (lo, hi) in &ranges {
out.extend(lo.to_le_bytes());
out.extend(hi.to_le_bytes());
}
let recs: Vec<Vec<u8>> = shapes.iter().map(record).collect::<Result<_, _>>()?;
let mut off = out.len() + 8 * recs.len();
for (s, r) in shapes.iter().zip(&recs) {
out.extend(s.number.to_le_bytes());
out.extend(len16(r).to_le_bytes());
out.extend(u32::try_from(off).map_err(|_| ShxError::TooLarge)?.to_le_bytes());
off += r.len();
}
for r in &recs {
out.extend(r);
}
}
Kind::Uni => {
out.extend(UNIFONT);
let (name, info) = uni_info.unwrap_or_default();
out.extend((u32::from(count) + 1).to_le_bytes());
let mut rec: Vec<u8> = name.bytes().filter(|b| *b != 0).collect();
rec.push(0);
rec.extend(&info);
out.extend(len16(&rec).to_le_bytes());
out.extend(&rec);
for s in &shapes {
let r = record(s)?;
out.extend(s.number.to_le_bytes());
out.extend(len16(&r).to_le_bytes());
out.extend(r);
}
}
}
Ok(out)
}
-248
View File
@@ -1,248 +0,0 @@
//! SHX shape fonts: text fonts, big fonts (East Asian double-byte) and Unicode fonts that a
//! drawing's text styles name, read at run time from files the user has. None is bundled.
//!
//! - [`ShxFont::parse`] reads the compiled containers (`shapes 1.0/1.1`, `bigfont 1.0`,
//! `unifont 1.0`) and [`ShxFont::glyph`] runs a shape's pen program (the vector length and
//! direction codes and special codes 0–14 of the public shape description language) into
//! polylines in font units. Every count, offset and loop in a file is bounded: a hostile or
//! truncated file never panics, it reads as far as it is valid or not at all.
//! - [`compile`] turns shape source (`.shp`) into a compiled file, as COMPILE does. The tests
//! build every font they use with it: no font file is in this repository.
//! - Fonts are found by name through the font table (`table`): registered bytes
//! ([`register`], the web build's files opened alongside a drawing), an app-set
//! [`FontProvider`], the folders of opened drawings ([`add_drawing_dir`]) and the support
//! search path ([`set_search_path`], `CADCRAFT_SHXPATH`), after the font map
//! ([`set_font_map`]). CADCraft never looks inside another CAD program's installation.
//! - [`ShxText`] is a text style's pair of fonts (main + big font) and orientation, which the
//! text layout draws ([`crate::TextFont::Shx`]): advances are the pen's end positions, so
//! widths and extents follow the font's own metrics.
//!
//! Binary container layouts (little-endian): a signature line ending in CR LF 0x1A, then
//! - shapes: first and last shape number, count, an index of (number, record length), the
//! records (a NUL-terminated name, then the program) and `EOF`;
//! - big font: index size, count, escape-range count, the ranges (first, last lead byte),
//! an index of (number, record length, file offset) and the records;
//! - unifont: shape count (32 bits), the length of the font record, the font record (name,
//! above, below, modes, encoding, embedding, 0) and then each shape as (number, length,
//! record).
mod compile;
mod parse;
mod program;
mod table;
#[cfg(test)]
mod tests;
mod text;
use std::collections::HashMap;
use std::sync::{Arc, PoisonError, RwLock};
use cadcraft_geom::Vec2;
pub use compile::compile;
pub use table::{
FontProvider, add_drawing_dir, clear_registered, drawing_dirs, find, font_map, generation, register, search_path, set_font_map, set_provider,
set_search_path,
};
pub use text::ShxText;
pub(crate) use text::{shape, width};
/// Why a font file can't be read or a shape source can't be compiled.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ShxError {
/// Not an SHX file (unknown signature).
NotShx,
/// The file ends inside its header or index.
Truncated,
/// The file is larger, or has more of something, than CADCraft reads.
TooLarge,
/// The file is well-formed but holds no shape.
Empty,
/// Shape source error: line and message.
Source(usize, String),
}
impl std::fmt::Display for ShxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ShxError::NotShx => f.write_str("not an SHX shape or font file"),
ShxError::Truncated => f.write_str("the SHX file is truncated"),
ShxError::TooLarge => f.write_str("the SHX file is too large"),
ShxError::Empty => f.write_str("the SHX file has no shapes"),
ShxError::Source(line, msg) => write!(f, "line {line}: {msg}"),
}
}
}
impl std::error::Error for ShxError {}
/// The kind of SHX file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShxKind {
/// A shape file or a text font with single-byte character codes (`shapes 1.0/1.1`).
Shapes,
/// A big font: double-byte character codes after an escape (lead) byte.
BigFont,
/// A Unicode font: shape numbers are Unicode code points.
Unifont,
}
/// A shape's program run into polylines, in font units (pen starts at the origin).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ShxGlyph {
/// Pen-down runs (two points or more).
pub strokes: Vec<Vec<Vec2>>,
/// Where the pen ends: the advance to the next character.
pub advance: Vec2,
}
/// One shape record: where its name and program are in [`ShxFont::data`].
#[derive(Clone, Copy, Debug)]
pub(crate) struct Record {
name: (usize, usize),
program: (usize, usize),
}
/// Glyphs cached per font, keyed by (shape, vertical).
type GlyphCache = HashMap<(u16, bool), Option<Arc<ShxGlyph>>>;
/// Most glyphs cached per font (a big font has thousands; text uses a few hundred).
const MAX_CACHED_GLYPHS: usize = 16_384;
/// A parsed SHX file.
pub struct ShxFont {
kind: ShxKind,
name: String,
/// Shape 0 (the font record) is present: the file is a text font, not only shapes.
header: bool,
above: f64,
below: f64,
/// Modes 2: horizontal and vertical (code 14 is honoured).
dual: bool,
/// Unifont encoding: 0 Unicode, 1 packed multibyte, 2 shape file.
encoding: u8,
/// Extended big font: the font record gives a character width (subshape cell).
char_width: f64,
/// Big font escape ranges (lead bytes).
ranges: Vec<(u16, u16)>,
data: Vec<u8>,
records: HashMap<u16, Record>,
cache: RwLock<GlyphCache>,
}
impl std::fmt::Debug for ShxFont {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ShxFont")
.field("kind", &self.kind)
.field("name", &self.name)
.field("above", &self.above)
.field("below", &self.below)
.field("dual", &self.dual)
.field("shapes", &self.records.len())
.finish()
}
}
impl ShxFont {
/// Parse a compiled SHX file.
pub fn parse(bytes: &[u8]) -> Result<ShxFont, ShxError> {
parse::parse(bytes)
}
pub fn kind(&self) -> ShxKind {
self.kind
}
/// The font name in its font record (shape 0), or empty.
pub fn name(&self) -> &str {
&self.name
}
/// Whether the file has a font record (shape 0): a text font rather than a shape file.
pub fn is_font(&self) -> bool {
self.header
}
/// Vector units uppercase letters rise above the baseline (big font: the character height).
/// Text height `h` draws the font at `h / above` per unit. 0 for a shape file.
pub fn above(&self) -> f64 {
self.above
}
/// Vector units lowercase descenders drop below the baseline.
pub fn below(&self) -> f64 {
self.below
}
/// The font supports vertical text (modes 2).
pub fn is_dual(&self) -> bool {
self.dual
}
/// The units per text height: [`Self::above`], or 1 for a file without a font record.
pub fn units_per_height(&self) -> f64 {
if self.above > 0.0 { self.above } else { 1.0 }
}
/// Big font escape ranges: `(first, last)` lead bytes.
pub fn escape_ranges(&self) -> &[(u16, u16)] {
&self.ranges
}
/// Whether `b` starts a double-byte character in this big font.
pub fn is_lead_byte(&self, b: u8) -> bool {
self.ranges.iter().any(|(lo, hi)| (*lo..=*hi).contains(&u16::from(b)))
}
/// Unifont encoding (0 Unicode, 1 packed multibyte, 2 shape file); 0 for other kinds.
pub fn encoding(&self) -> u8 {
self.encoding
}
pub fn shape_count(&self) -> usize {
self.records.len()
}
pub fn has_shape(&self, number: u16) -> bool {
number != 0 && self.records.contains_key(&number)
}
/// The shape numbers in the file, sorted (not shape 0).
pub fn shape_numbers(&self) -> Vec<u16> {
let mut v: Vec<u16> = self.records.keys().copied().filter(|n| *n != 0).collect();
v.sort_unstable();
v
}
/// The name of shape `number` as the file stores it.
pub fn shape_name(&self, number: u16) -> Option<String> {
let r = self.records.get(&number)?;
self.data.get(r.name.0..r.name.1).map(|b| b.iter().map(|c| char::from(*c)).collect())
}
/// The number of the shape called `name` (case-insensitive), as a linetype's
/// `[SHAPE,file.shx]` element names it.
pub fn shape_number(&self, name: &str) -> Option<u16> {
let want = name.trim();
if want.is_empty() {
return None;
}
let mut found: Vec<u16> = self
.records
.iter()
.filter(|(n, r)| **n != 0 && self.data.get(r.name.0..r.name.1).is_some_and(|b| b.eq_ignore_ascii_case(want.as_bytes())))
.map(|(n, _)| *n)
.collect();
found.sort_unstable();
found.first().copied()
}
/// The program bytes of shape `number`.
pub(crate) fn program(&self, number: u16) -> Option<&[u8]> {
let r = self.records.get(&number)?;
self.data.get(r.program.0..r.program.1)
}
/// Shape `number` drawn horizontally or vertically, in font units; `None` when the font
/// has no such shape or its program is broken. Cached.
pub fn glyph(&self, number: u16, vertical: bool) -> Option<Arc<ShxGlyph>> {
if !self.has_shape(number) {
return None;
}
let vertical = vertical && self.dual;
let key = (number, vertical);
if let Some(g) = self.cache.read().unwrap_or_else(PoisonError::into_inner).get(&key) {
return g.clone();
}
let g = program::outline(self, number, vertical).ok().map(Arc::new);
let mut c = self.cache.write().unwrap_or_else(PoisonError::into_inner);
if c.len() >= MAX_CACHED_GLYPHS {
c.clear();
}
c.insert(key, g.clone());
g
}
}
-224
View File
@@ -1,224 +0,0 @@
//! Reading the compiled SHX containers (layouts in the module documentation). Every count and
//! offset comes from the file, so each is checked against its length; records that run past
//! the end are dropped and the rest kept.
use std::collections::HashMap;
use std::sync::RwLock;
use super::{Record, ShxError, ShxFont, ShxKind};
pub(super) const SHAPES_10: &[u8] = b"AutoCAD-86 shapes 1.0\r\n\x1a";
pub(super) const SHAPES_11: &[u8] = b"AutoCAD-86 shapes 1.1\r\n\x1a";
pub(super) const BIGFONT: &[u8] = b"AutoCAD-86 bigfont 1.0\r\n\x1a";
pub(super) const UNIFONT: &[u8] = b"AutoCAD-86 unifont 1.0\r\n\x1a";
/// Largest file read (the largest big fonts are a few megabytes).
pub(super) const MAX_FILE_BYTES: usize = 32 << 20;
/// Most escape ranges in a big font (lead bytes: at most 256).
const MAX_RANGES: usize = 256;
/// A checked little-endian reader.
struct Rd<'a> {
b: &'a [u8],
pos: usize,
}
impl<'a> Rd<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], ShxError> {
let end = self.pos.checked_add(n).ok_or(ShxError::Truncated)?;
let s = self.b.get(self.pos..end).ok_or(ShxError::Truncated)?;
self.pos = end;
Ok(s)
}
fn u16(&mut self) -> Result<u16, ShxError> {
match self.take(2)? {
[a, b] => Ok(u16::from_le_bytes([*a, *b])),
_ => Err(ShxError::Truncated),
}
}
fn u32(&mut self) -> Result<u32, ShxError> {
match self.take(4)? {
[a, b, c, d] => Ok(u32::from_le_bytes([*a, *b, *c, *d])),
_ => Err(ShxError::Truncated),
}
}
}
/// A font being read.
struct Font {
kind: ShxKind,
name: String,
header: bool,
above: f64,
below: f64,
dual: bool,
encoding: u8,
char_width: f64,
ranges: Vec<(u16, u16)>,
records: HashMap<u16, Record>,
}
impl Font {
fn new(kind: ShxKind) -> Font {
Font {
kind,
name: String::new(),
header: false,
above: 0.0,
below: 0.0,
dual: false,
encoding: 0,
char_width: 0.0,
ranges: Vec::new(),
records: HashMap::new(),
}
}
/// Add the record of shape `number` at `start..end` of `data`: a NUL-terminated name, then
/// the program. Shape 0 is the font record. A record without a name terminator is dropped;
/// the first record of a number wins.
fn record(&mut self, data: &[u8], number: u16, start: usize, end: usize) {
let Some(bytes) = data.get(start..end) else { return };
let Some(nul) = bytes.iter().position(|b| *b == 0) else { return };
let name = (start, start + nul);
let program = (start + nul + 1, end);
if number == 0 {
if !self.header {
self.font_record(data.get(program.0..program.1).unwrap_or_default(), data.get(name.0..name.1).unwrap_or_default());
}
return;
}
self.records.entry(number).or_insert(Record { name, program });
}
/// The font record: above, below, modes (and for a unifont encoding and embedding); an
/// extended big font's is character height, 0, modes, character width.
fn font_record(&mut self, info: &[u8], name: &[u8]) {
self.header = true;
self.name = name.iter().take(255).map(|c| char::from(*c)).collect();
let byte = |i: usize| info.get(i).copied().unwrap_or(0);
self.above = f64::from(byte(0));
self.below = f64::from(byte(1));
self.dual = byte(2) == 2;
if self.kind == ShxKind::Unifont {
self.encoding = byte(3);
}
if self.kind == ShxKind::BigFont && info.len() >= 5 && byte(1) == 0 {
// Extended: height, 0, modes, width, 0.
self.below = 0.0;
self.char_width = f64::from(byte(3));
}
}
fn finish(self, data: Vec<u8>) -> Result<ShxFont, ShxError> {
if self.records.is_empty() {
return Err(ShxError::Empty);
}
Ok(ShxFont {
kind: self.kind,
name: self.name,
header: self.header,
above: self.above,
below: self.below,
dual: self.dual,
encoding: self.encoding,
char_width: if self.char_width > 0.0 { self.char_width } else { self.above },
ranges: self.ranges,
data,
records: self.records,
cache: RwLock::default(),
})
}
}
pub(super) fn parse(bytes: &[u8]) -> Result<ShxFont, ShxError> {
if bytes.len() > MAX_FILE_BYTES {
return Err(ShxError::TooLarge);
}
if bytes.starts_with(SHAPES_10) || bytes.starts_with(SHAPES_11) {
shapes(bytes)
} else if bytes.starts_with(BIGFONT) {
bigfont(bytes)
} else if bytes.starts_with(UNIFONT) {
unifont(bytes)
} else {
Err(ShxError::NotShx)
}
}
fn shapes(bytes: &[u8]) -> Result<ShxFont, ShxError> {
let mut r = Rd { b: bytes, pos: SHAPES_10.len() };
let _first = r.u16()?;
let _last = r.u16()?;
let count = usize::from(r.u16()?);
// The whole index must be there before anything is read from it.
let index = r.take(count.checked_mul(4).ok_or(ShxError::TooLarge)?)?;
let mut f = Font::new(ShxKind::Shapes);
let mut pos = r.pos;
for [a, b, c, d] in index.as_chunks::<4>().0 {
let (number, len) = (u16::from_le_bytes([*a, *b]), usize::from(u16::from_le_bytes([*c, *d])));
let Some(end) = pos.checked_add(len).filter(|e| *e <= bytes.len()) else { break };
f.record(bytes, number, pos, end);
pos = end;
}
f.finish(bytes.to_vec())
}
fn bigfont(bytes: &[u8]) -> Result<ShxFont, ShxError> {
let mut r = Rd { b: bytes, pos: BIGFONT.len() };
let index_size = usize::from(r.u16()?);
let count = usize::from(r.u16()?);
let nranges = usize::from(r.u16()?);
if nranges > MAX_RANGES {
return Err(ShxError::TooLarge);
}
let mut f = Font::new(ShxKind::BigFont);
for _ in 0..nranges {
let (lo, hi) = (r.u16()?, r.u16()?);
f.ranges.push((lo.min(hi), lo.max(hi)));
}
// The index has `count` entries of 8 bytes; a file whose count doesn't fit but whose index
// size does is read by that.
let fits = |n: usize| n.checked_mul(8).and_then(|l| r.pos.checked_add(l)).is_some_and(|e| e <= bytes.len());
let n = if fits(count) {
count
} else if fits(index_size) {
index_size
} else {
return Err(ShxError::Truncated);
};
let index = r.take(n.checked_mul(8).ok_or(ShxError::TooLarge)?)?;
for [a, b, c, d, o0, o1, o2, o3] in index.as_chunks::<8>().0 {
let number = u16::from_le_bytes([*a, *b]);
let len = usize::from(u16::from_le_bytes([*c, *d]));
let Ok(off) = usize::try_from(u32::from_le_bytes([*o0, *o1, *o2, *o3])) else { continue };
if len == 0 {
continue;
}
let Some(end) = off.checked_add(len).filter(|e| *e <= bytes.len()) else { continue };
f.record(bytes, number, off, end);
}
f.finish(bytes.to_vec())
}
fn unifont(bytes: &[u8]) -> Result<ShxFont, ShxError> {
let mut r = Rd { b: bytes, pos: UNIFONT.len() };
let count = r.u32()?;
let info_len = usize::from(r.u16()?);
let info = r.take(info_len)?;
let mut f = Font::new(ShxKind::Unifont);
let nul = info.iter().position(|b| *b == 0).unwrap_or(info.len());
f.font_record(info.get(nul.saturating_add(1)..).unwrap_or_default(), info.get(..nul).unwrap_or_default());
// `count` includes the font record; never trusted beyond the data that is there.
let mut left = count.saturating_sub(1);
while left > 0 {
left -= 1;
let Ok(number) = r.u16() else { break };
let Ok(len) = r.u16().map(usize::from) else { break };
let start = r.pos;
let Some(end) = start.checked_add(len).filter(|e| *e <= bytes.len()) else { break };
r.pos = end;
f.record(bytes, number, start, end);
}
f.finish(bytes.to_vec())
}
-344
View File
@@ -1,344 +0,0 @@
//! The shape pen program: vector length/direction bytes and special codes 0–14, run into
//! polylines. Bounded everywhere: subshape depth, location stack, steps, points, coordinates.
//!
//! - A byte with a non-zero high nibble is a vector: length (high nibble) in one of 16
//! directions (low nibble, counter-clockwise from east in 22.5° steps; the off-axis ones
//! are half-unit or unit steps on the minor axis).
//! - 0 end, 1 pen down, 2 pen up, 3/4 divide/multiply vector lengths by the next byte,
//! 5/6 push/pop the location, 7 subshape (one byte; two bytes high first in a Unicode font;
//! `0`, a two-byte number, base point, width and height in an extended big font),
//! 8 one X-Y displacement, 9 displacements up to (0,0), 10 octant arc (radius,
//! ±0SC: sign = clockwise, S start octant, C octants, 0 = 8), 11 fractional arc (start
//! offset, end offset, high and low radius, ±0SC; offsets in 1/256 of 45° from the octant
//! boundaries), 12 bulge arc (dx, dy, bulge: ±127 = semicircle), 13 bulge arcs up to (0,0),
//! 14 the next command is for vertical text only (dual-orientation fonts).
//! - Displacements and bulges are two's complement bytes; the arc direction byte keeps its
//! sign in the high bit.
use std::f64::consts::{FRAC_PI_4, TAU};
use cadcraft_geom::Vec2;
use super::{ShxFont, ShxGlyph, ShxKind};
/// Subshape nesting (a cycle ends here).
const MAX_DEPTH: usize = 16;
/// Location stack (the format promises four).
const MAX_STACK: usize = 64;
/// Bytes run per glyph, subshapes included.
const MAX_STEPS: usize = 200_000;
/// Points per glyph.
const MAX_POINTS: usize = 50_000;
/// Coordinates in font units.
const MAX_COORD: f64 = 1.0e7;
/// Arc segments per full turn.
const ARC_STEPS: f64 = 64.0;
/// Why a program stops early: the glyph is then not drawn.
#[derive(Debug)]
pub(super) struct Broken;
type Res = Result<(), Broken>;
struct Pen<'a> {
font: &'a ShxFont,
vertical: bool,
pos: Vec2,
down: bool,
/// Vector length factor (codes 3 and 4).
scale: f64,
/// Per-axis factor of an extended big font subshape.
axes: Vec2,
stack: Vec<Vec2>,
strokes: Vec<Vec<Vec2>>,
active: Vec<Vec2>,
steps: usize,
points: usize,
}
pub(super) fn outline(font: &ShxFont, number: u16, vertical: bool) -> Result<ShxGlyph, Broken> {
let mut pen = Pen {
font,
vertical,
pos: Vec2::ZERO,
down: true,
scale: 1.0,
axes: Vec2::new(1.0, 1.0),
stack: Vec::new(),
strokes: Vec::new(),
active: Vec::new(),
steps: 0,
points: 0,
};
pen.run(number, 0)?;
pen.flush();
Ok(ShxGlyph { strokes: pen.strokes, advance: pen.pos })
}
/// The 16 vector directions (unit length on the major axis).
fn direction(d: u8) -> Vec2 {
let (x, y) = match d & 15 {
0 => (1.0, 0.0),
1 => (1.0, 0.5),
2 => (1.0, 1.0),
3 => (0.5, 1.0),
4 => (0.0, 1.0),
5 => (-0.5, 1.0),
6 => (-1.0, 1.0),
7 => (-1.0, 0.5),
8 => (-1.0, 0.0),
9 => (-1.0, -0.5),
10 => (-1.0, -1.0),
11 => (-0.5, -1.0),
12 => (0.0, -1.0),
13 => (0.5, -1.0),
14 => (1.0, -1.0),
_ => (1.0, -0.5),
};
Vec2::new(x, y)
}
/// A two's complement operand byte.
fn signed(b: u8) -> f64 {
f64::from(i8::from_le_bytes([b]))
}
/// A program being read: its bytes and position.
struct Cur<'p> {
b: &'p [u8],
pos: usize,
}
impl Cur<'_> {
fn byte(&mut self) -> Result<u8, Broken> {
let v = self.b.get(self.pos).copied().ok_or(Broken)?;
self.pos += 1;
Ok(v)
}
}
impl Pen<'_> {
fn step(&mut self) -> Res {
self.steps += 1;
if self.steps > MAX_STEPS { Err(Broken) } else { Ok(()) }
}
fn flush(&mut self) {
if self.active.len() >= 2 {
self.strokes.push(std::mem::take(&mut self.active));
} else {
self.active.clear();
}
}
/// Move the pen by `d` font units (already scaled), drawing when the pen is down.
fn to(&mut self, target: Vec2) -> Res {
if !target.is_finite() || target.x.abs().max(target.y.abs()) > MAX_COORD {
return Err(Broken);
}
if self.down {
if self.active.is_empty() {
self.active.push(self.pos);
self.points += 1;
}
self.active.push(target);
self.points += 1;
if self.points > MAX_POINTS {
return Err(Broken);
}
} else {
self.flush();
}
self.pos = target;
Ok(())
}
/// A displacement in vector units (times the length factor and the subshape axes).
fn by(&mut self, d: Vec2) -> Res {
let k = d * self.scale;
self.to(self.pos + Vec2::new(k.x * self.axes.x, k.y * self.axes.y))
}
/// An arc of `radius` vector units from the pen, starting at angle `start` on its circle
/// and turning `sweep` radians (positive counter-clockwise).
fn arc(&mut self, radius: f64, start: f64, sweep: f64) -> Res {
let r = radius * self.scale;
if !(r.is_finite() && r > 0.0 && sweep.is_finite()) {
return Ok(());
}
let n = if self.down { (sweep.abs() / TAU * ARC_STEPS).ceil().clamp(1.0, ARC_STEPS) as usize } else { 1 };
let from = self.pos;
let s0 = Vec2::new(start.cos(), start.sin());
for i in 1..=n {
let a = start + sweep * i as f64 / n as f64;
let d = (Vec2::new(a.cos(), a.sin()) - s0) * r;
self.to(from + Vec2::new(d.x * self.axes.x, d.y * self.axes.y))?;
}
Ok(())
}
/// An arc to the displacement `d` with `bulge` (−127..127, ±127 a semicircle).
fn bulge(&mut self, d: Vec2, bulge: f64) -> Res {
let b = bulge.clamp(-127.0, 127.0) / 127.0;
if b == 0.0 || !self.down || d.len2() == 0.0 {
return self.by(d);
}
// Centre from the chord midpoint along its left normal; sweep 4·atan(b).
let centre = d * 0.5 + Vec2::new(-d.y, d.x) * ((1.0 - b * b) / (4.0 * b));
let rel = -centre;
self.arc(rel.len(), rel.y.atan2(rel.x), 4.0 * b.atan())
}
/// The octant byte `±0SC`: (clockwise, start octant, octants with 0 = 8).
fn octants(spec: u8) -> (f64, f64, f64) {
let sign = if spec & 0x80 != 0 { -1.0 } else { 1.0 };
let s = f64::from((spec >> 4) & 7);
let c = match spec & 7 {
0 => 8.0,
c => f64::from(c),
};
(sign, s, c)
}
/// The vector unit size of an extended big font's subshape cell.
fn cell(&self) -> Vec2 {
let h = if self.font.above > 0.0 { self.font.above } else { 1.0 };
let w = if self.font.char_width > 0.0 { self.font.char_width } else { h };
Vec2::new(w, h)
}
fn run(&mut self, number: u16, depth: usize) -> Res {
if depth >= MAX_DEPTH {
return Err(Broken);
}
let program = self.font.program(number).ok_or(Broken)?;
let mut r = Cur { b: program, pos: 0 };
let mut skip = false;
while r.pos < r.b.len() {
self.step()?;
let code = r.byte()?;
// Code 14: in a dual-orientation font the next command is for vertical text only.
let exec = !skip;
skip = false;
match code {
0 => return Ok(()),
1 if exec => self.down = true,
2 if exec => {
self.down = false;
self.flush();
}
3 | 4 => {
let k = f64::from(r.byte()?);
if exec && k > 0.0 {
let s = if code == 3 { self.scale / k } else { self.scale * k };
if !(1e-9..=MAX_COORD).contains(&s) {
return Err(Broken);
}
self.scale = s;
}
}
5 if exec => {
if self.stack.len() >= MAX_STACK {
return Err(Broken);
}
self.stack.push(self.pos);
}
6 if exec => {
self.flush();
if let Some(p) = self.stack.pop() {
self.pos = p;
}
}
7 => self.subshape(&mut r, exec, depth)?,
8 | 9 | 12 | 13 => loop {
self.step()?;
let d = Vec2::new(signed(r.byte()?), signed(r.byte()?));
if matches!(code, 9 | 13) && d == Vec2::ZERO {
break;
}
let bulge = if matches!(code, 12 | 13) { signed(r.byte()?) } else { 0.0 };
if exec {
self.bulge(d, bulge)?;
}
if matches!(code, 8 | 12) {
break;
}
},
10 => {
let radius = f64::from(r.byte()?);
let (sign, s, c) = Self::octants(r.byte()?);
if exec {
self.arc(radius, s * FRAC_PI_4, sign * c * FRAC_PI_4)?;
}
}
11 => {
let start = f64::from(r.byte()?) / 256.0;
let end = match r.byte()? {
0 => 1.0,
e => f64::from(e) / 256.0,
};
let radius = f64::from(u16::from_be_bytes([r.byte()?, r.byte()?]));
let (sign, s, c) = Self::octants(r.byte()?);
if exec {
// From `start` past the first octant boundary to `end` past the last
// octant's.
self.arc(radius, (s + sign * start) * FRAC_PI_4, sign * (c - 1.0 + end - start) * FRAC_PI_4)?;
}
}
14 => skip = self.font.dual && !self.vertical && exec,
1..=15 => {}
v => {
if exec {
self.by(direction(v) * f64::from(v >> 4))?;
}
}
}
}
Ok(())
}
/// Code 7 and its operands.
fn subshape(&mut self, r: &mut Cur, exec: bool, depth: usize) -> Res {
match self.font.kind {
ShxKind::Unifont => {
let n = u16::from_be_bytes([r.byte()?, r.byte()?]);
if exec {
self.run(n, depth + 1)?;
}
}
ShxKind::BigFont => {
let first = r.byte()?;
if first != 0 {
if exec {
self.run(u16::from(first), depth + 1)?;
}
return Ok(());
}
// Extended: the primitive drawn in the box (x, y, width, height) of the cell.
let n = u16::from_be_bytes([r.byte()?, r.byte()?]);
let (x, y, w, h) = (f64::from(r.byte()?), f64::from(r.byte()?), f64::from(r.byte()?), f64::from(r.byte()?));
if !exec {
return Ok(());
}
let (pos, axes, down) = (self.pos, self.axes, self.down);
let cell = self.cell();
self.flush();
self.pos = pos + Vec2::new(x * axes.x, y * axes.y) * self.scale;
self.axes = Vec2::new(axes.x * w / cell.x, axes.y * h / cell.y);
let res = self.run(n, depth + 1);
self.flush();
self.axes = axes;
self.pos = pos;
self.down = down;
res?;
}
ShxKind::Shapes => {
let n = u16::from(r.byte()?);
if exec {
self.run(n, depth + 1)?;
}
}
}
Ok(())
}
}
-267
View File
@@ -1,267 +0,0 @@
//! Finding SHX files by the name a text style or linetype gives (`romans.shx`, `GBCBIG`,
//! `C:\fonts\simplex.shx`): only the file name counts, so a path stored in a drawing never
//! reaches another folder or computer.
//!
//! Order: the font map ([`set_font_map`]) renames the file; then fonts registered by the app
//! ([`register`]: the web build's files opened alongside a drawing, tests); then the app's
//! [`FontProvider`]; then, natively, the folders of opened drawings ([`add_drawing_dir`], most
//! recent first) and the support search path ([`set_search_path`], from `CADCRAFT_SHXPATH` at
//! start). Results, found or not, are cached until one of these changes ([`generation`]
//! counts the changes, so a viewer knows to regenerate).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
use super::parse::MAX_FILE_BYTES;
use super::{ShxError, ShxFont};
/// Where an app finds font files CADCraft can't reach itself (a web page's file store, an
/// app's own font folder). Called with the normalised file name (`romans.shx`); returns the
/// file's bytes. Called without the font table's lock held.
pub trait FontProvider: Send + Sync {
fn load(&self, file_name: &str) -> Option<Vec<u8>>;
}
/// Most fonts kept loaded, and their total bytes.
const MAX_FONTS: usize = 128;
const MAX_LOADED_BYTES: usize = 256 << 20;
/// Most drawing folders and search path entries remembered.
const MAX_DIRS: usize = 32;
/// Most font map entries.
const MAX_MAP: usize = 1024;
#[derive(Default)]
struct Table {
registered: HashMap<String, Arc<ShxFont>>,
resolved: HashMap<String, Option<Arc<ShxFont>>>,
loaded_bytes: usize,
provider: Option<Arc<dyn FontProvider>>,
drawing_dirs: Vec<PathBuf>,
search_path: Vec<PathBuf>,
map: Vec<(String, String)>,
generation: u64,
}
impl Table {
/// Forget cached results: something that changes them changed.
fn changed(&mut self) {
self.resolved.clear();
self.loaded_bytes = self.registered.values().map(|f| f.data.len()).sum();
self.generation = self.generation.wrapping_add(1);
}
}
fn table() -> MutexGuard<'static, Table> {
static T: OnceLock<Mutex<Table>> = OnceLock::new();
T.get_or_init(|| {
#[cfg(not(target_arch = "wasm32"))]
let search_path = std::env::var_os("CADCRAFT_SHXPATH")
.map(|p| std::env::split_paths(&p).filter(|d| !d.as_os_str().is_empty()).take(MAX_DIRS).collect())
.unwrap_or_default();
#[cfg(target_arch = "wasm32")]
let search_path = Vec::new();
Mutex::new(Table { search_path, ..Table::default() })
})
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
/// The lookup key of a font name: its lower-case file name with `.shx` (`romans.shx`), or
/// `None` for a name that isn't an SHX file (a TrueType font, empty, the built-in font).
pub(crate) fn key(name: &str) -> Option<String> {
let base = name.trim().rsplit(['/', '\\']).next()?.trim();
if base.is_empty() || base.len() > 255 || base.contains(':') || base.chars().any(char::is_control) {
return None;
}
let lower = base.to_ascii_lowercase();
if lower.ends_with(".shx") {
return (lower.len() > 4).then_some(lower);
}
let ext = Path::new(&lower).extension().map(|e| e.to_string_lossy().to_string());
match ext.as_deref() {
// `txt` (no extension) is a classic style font name; other extensions are other kinds.
None => (lower != crate::BUILTIN_FONT.to_ascii_lowercase() && !lower.contains(' ')).then(|| format!("{lower}.shx")),
Some(_) => None,
}
}
/// Register font bytes under a file name (the web build's files opened alongside a drawing;
/// tests). Replaces a font registered under the same name.
pub fn register(name: &str, bytes: &[u8]) -> Result<(), ShxError> {
let k = key(name).ok_or(ShxError::NotShx)?;
let font = Arc::new(ShxFont::parse(bytes)?);
let mut t = table();
if !t.registered.contains_key(&k) && t.registered.len() >= MAX_FONTS {
return Err(ShxError::TooLarge);
}
t.registered.insert(k, font);
t.changed();
Ok(())
}
/// Forget every registered font (a web session closing its drawings).
pub fn clear_registered() {
let mut t = table();
t.registered.clear();
t.changed();
}
/// Set (or clear) the app's font provider.
pub fn set_provider(provider: Option<Arc<dyn FontProvider>>) {
let mut t = table();
t.provider = provider;
t.changed();
}
/// Search the folder of a drawing being opened for the fonts its styles name (most recent
/// first).
pub fn add_drawing_dir(dir: &Path) {
if dir.as_os_str().is_empty() {
return;
}
let mut t = table();
if t.drawing_dirs.first().is_some_and(|d| d == dir) {
return;
}
t.drawing_dirs.retain(|d| d != dir);
t.drawing_dirs.insert(0, dir.to_path_buf());
t.drawing_dirs.truncate(MAX_DIRS);
t.changed();
}
/// The drawing folders searched, most recent first.
pub fn drawing_dirs() -> Vec<PathBuf> {
table().drawing_dirs.clone()
}
/// Set the support search path for SHX files: folders separated by `;` (or the platform's
/// path separator). Empty clears it.
pub fn set_search_path(list: &str) {
let dirs: Vec<PathBuf> = list
.split(';')
.flat_map(|p| if cfg!(any(windows, target_arch = "wasm32")) { vec![PathBuf::from(p)] } else { std::env::split_paths(p).collect() })
.filter(|d| !d.as_os_str().is_empty() && !d.to_string_lossy().trim().is_empty())
.take(MAX_DIRS)
.collect();
let mut t = table();
t.search_path = dirs;
t.changed();
}
/// The support search path, `;`-separated.
pub fn search_path() -> String {
table().search_path.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(";")
}
/// Set the font map: one `font;replacement` per line (or separated by `|`), as a font mapping
/// file lists them. Lines that aren't a pair are ignored; empty clears it.
pub fn set_font_map(text: &str) {
let map: Vec<(String, String)> = text
.split(['\n', '|'])
.filter_map(|l| {
let (a, b) = l.split_once(';')?;
Some((key(a)?, b.trim().to_string()))
})
.filter(|(_, b)| !b.is_empty())
.take(MAX_MAP)
.collect();
let mut t = table();
t.map = map;
t.changed();
}
/// The font map, one `font;replacement` per line.
pub fn font_map() -> String {
table().map.iter().map(|(a, b)| format!("{a};{b}")).collect::<Vec<_>>().join("\n")
}
/// Counts changes to what fonts are found (registration, provider, folders, map).
pub fn generation() -> u64 {
table().generation
}
/// The SHX font file a style or linetype names, if CADCraft can find and read it.
pub fn find(name: &str) -> Option<Arc<ShxFont>> {
let mut k = key(name)?;
let (provider, dirs, generation) = {
let t = table();
// The font map renames once (a map can't loop).
if let Some(to) = t.map.iter().find(|(from, _)| *from == k).and_then(|(_, to)| key(to)) {
k = to;
}
if let Some(f) = t.registered.get(&k) {
return Some(f.clone());
}
if let Some(r) = t.resolved.get(&k) {
return r.clone();
}
let dirs: Vec<PathBuf> = t.drawing_dirs.iter().chain(t.search_path.iter()).cloned().collect();
(t.provider.clone(), dirs, t.generation)
};
let font = provider
.and_then(|p| p.load(&k))
.or_else(|| read_from(&dirs, &k))
.filter(|b| b.len() <= MAX_FILE_BYTES)
.and_then(|b| ShxFont::parse(&b).ok())
.map(Arc::new);
let mut t = table();
// Something changed while the file was read: keep the result out of the new cache.
if t.generation != generation {
return font;
}
if t.resolved.len() >= MAX_FONTS * 4 {
t.resolved.clear();
t.loaded_bytes = t.registered.values().map(|f| f.data.len()).sum();
}
let size = font.as_ref().map_or(0, |f| f.data.len());
if t.loaded_bytes.saturating_add(size) > MAX_LOADED_BYTES {
// Over the memory bound: used, not kept.
return font;
}
t.loaded_bytes += size;
t.resolved.insert(k, font.clone());
font
}
/// Most directory entries looked at for a font file whose name differs only in case.
#[cfg(not(target_arch = "wasm32"))]
const MAX_DIR_ENTRIES: usize = 10_000;
/// The file `name` (a lower-case key) in the first of `dirs` that has it, whatever the case of
/// its name: lower-case, upper-case, else any other case found in the directory (`Simplex.shx`
/// on a case-sensitive file system).
#[cfg(not(target_arch = "wasm32"))]
fn read_from(dirs: &[PathBuf], name: &str) -> Option<Vec<u8>> {
use std::io::Read as _;
let read = |file: &Path| -> Option<Vec<u8>> {
let meta = std::fs::metadata(file).ok()?;
if !meta.is_file() || meta.len() > MAX_FILE_BYTES as u64 {
return None;
}
let mut bytes = Vec::new();
let limit = u64::try_from(MAX_FILE_BYTES).unwrap_or(u64::MAX).saturating_add(1);
std::fs::File::open(file).and_then(|f| f.take(limit).read_to_end(&mut bytes)).ok()?;
Some(bytes)
};
for d in dirs {
if let Some(b) = [d.join(name), d.join(name.to_ascii_uppercase())].iter().find_map(|f| read(f)) {
return Some(b);
}
let Ok(entries) = std::fs::read_dir(d) else { continue };
let found = entries
.take(MAX_DIR_ENTRIES)
.filter_map(Result::ok)
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(name)))
.and_then(|e| read(&e.path()));
if found.is_some() {
return found;
}
}
None
}
#[cfg(target_arch = "wasm32")]
fn read_from(_dirs: &[PathBuf], _name: &str) -> Option<Vec<u8>> {
None
}
-450
View File
@@ -1,450 +0,0 @@
//! SHX tests. Every font here is compiled from shape source written for these tests: no font
//! file is read from disk or committed.
use std::f64::consts::PI;
use cadcraft_geom::Vec2;
use super::*;
use crate::{TextFont, TextParams};
/// A small dual-orientation text font: 6 units above, 2 below.
pub(crate) const TEXT_FONT: &str = "\
;; CADCraft test font (written for the tests)
*0,4,cadcraft test
6,2,2,0
*32,3,SPACE
2,060,0
; A: up 6, right 4, down 6, then a gap of 2
*65,7,UCA
1,064,040,06C,2,020,0
; B: a displacement out and back, then 5 on
*66,10,UCB
2,010,1,8,(3,4),8,(-3,-4),2,050,0
; C: vertical-only moves (code 14)
*67,12,UCC
2,14,8,(0,-8),1,064,2,14,8,(0,-6),040,0
; the degree sign: a full octant circle
*256,12,DEGREE
2,8,(1,4),1,10,(1,0),2,8,(2,-4),0
*257,6,PLUSMINUS
1,040,2,020,0
*258,6,DIAMETER
1,044,2,020,0
";
/// A Unicode font with the same letters, a subshape and the diameter sign at U+2205.
const UNI_FONT: &str = "\
*UNIFONT,6,cadcraft uni
6,2,0,0,0,0
*00041,7,uca
1,064,040,06C,2,020,0
*000C5,9,ring a
7,00041,2,8,(-4,7),1,040,2,8,(2,-7),0
*02205,6,diameter
1,044,2,020,0
";
/// A big font with Shift-JIS lead bytes: one square character (U+3042, 0x82A0).
const BIG_FONT: &str = "\
*BIGFONT 2,1,081,09F
*0,4,cadcraft big
8,0,0,0
*082A0,7,square
1,080,084,088,08C,2,080,0
";
/// An extended big font: two primitives of a 16 x 16 cell drawn into one character.
const EXT_BIG_FONT: &str = "\
*BIGFONT 2,1,081,09F
*0,5,cadcraft ext
16,0,0,16,0
*08F01,10,box
4,2,1,080,084,088,08C,3,2,0
*082A2,20,two boxes
7,0,08F01,0,0,8,16,7,0,08F01,8,0,8,16,2,4,2,080,3,2,0
";
fn font(src: &str) -> ShxFont {
ShxFont::parse(&compile(src).unwrap()).unwrap()
}
/// A shape file (no font record) with one shape from `spec`.
fn one(spec: &str) -> ShxFont {
font(&format!("*1,1,T\n{spec}\n"))
}
fn adv(spec: &str) -> Vec2 {
one(spec).glyph(1, false).unwrap().advance
}
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).len() < 1e-6
}
fn dir(deg: f64) -> Vec2 {
let r = deg.to_radians();
Vec2::new(r.cos(), r.sin())
}
#[test]
fn every_vector_direction() {
let want = [
(1.0, 0.0),
(1.0, 0.5),
(1.0, 1.0),
(0.5, 1.0),
(0.0, 1.0),
(-0.5, 1.0),
(-1.0, 1.0),
(-1.0, 0.5),
(-1.0, 0.0),
(-1.0, -0.5),
(-1.0, -1.0),
(-0.5, -1.0),
(0.0, -1.0),
(0.5, -1.0),
(1.0, -1.0),
(1.0, -0.5),
];
for (d, (x, y)) in want.iter().enumerate() {
let a = adv(&format!("02{d:X},0"));
assert!(close(a, Vec2::new(2.0 * x, 2.0 * y)), "direction {d}: {a:?}");
}
// Length 15, the longest vector.
assert!(close(adv("0F0,0"), Vec2::new(15.0, 0.0)));
}
#[test]
fn pen_up_down_scale_push_pop() {
let f = one("2,010,1,020,2,010,1,010,0");
let g = f.glyph(1, false).unwrap();
assert_eq!(g.strokes, vec![vec![Vec2::new(1.0, 0.0), Vec2::new(3.0, 0.0)], vec![Vec2::new(4.0, 0.0), Vec2::new(5.0, 0.0)]]);
// 4 multiplies, 3 divides: 3 + 1.
assert!(close(adv("4,3,010,3,3,010,0"), Vec2::new(4.0, 0.0)));
// Push, draw, pop: the next run starts where the push was.
let g = one("5,040,6,014,0").glyph(1, false).unwrap();
assert_eq!(g.strokes.len(), 2);
assert!(close(g.strokes[1][0], Vec2::ZERO) && close(g.advance, Vec2::new(0.0, 1.0)));
// An unmatched pop is ignored.
assert!(close(adv("6,010,0"), Vec2::new(1.0, 0.0)));
}
#[test]
fn displacements() {
assert!(close(adv("8,(-3,5),0"), Vec2::new(-3.0, 5.0)));
assert!(close(adv("9,(1,1),(2,-1),(-127,0),(0,0),0"), Vec2::new(-124.0, 0.0)));
}
#[test]
fn octant_and_fractional_arcs() {
// A full circle comes back to where it started.
let g = one("10,(2,0),0").glyph(1, false).unwrap();
assert!(close(g.advance, Vec2::ZERO));
let maxx = g.strokes.iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max);
assert!((maxx - 0.0).abs() < 1e-9 && g.strokes.iter().flatten().any(|p| (p.x + 4.0).abs() < 1e-6), "circle left of the pen");
// Clockwise from octant 4 (180°), three octants: ends at 45°.
assert!(close(adv("10,(1,-043),0"), dir(45.0) - dir(180.0)));
// Counter-clockwise quarter from 0°.
assert!(close(adv("10,(3,002),0"), (dir(90.0) - dir(0.0)) * 3.0));
// The documented fractional example: 55° to 95°, radius 3 (start octant 1, two octants).
let a0 = 45.0 + 56.0 / 256.0 * 45.0;
let a1 = 90.0 + 28.0 / 256.0 * 45.0;
assert!(close(adv("11,(56,28,0,3,012),0"), (dir(a1) - dir(a0)) * 3.0));
// High radius byte: 256 + 4.
assert!(close(adv("11,(0,0,1,4,001),0"), (dir(45.0) - dir(0.0)) * 260.0));
}
#[test]
fn bulge_arcs() {
// A counter-clockwise semicircle up the chord bulges to the right... of the chord's left
// normal: from (0,0) to (0,4) about (0,2), through (2,2).
let g = one("12,(0,4,127),0").glyph(1, false).unwrap();
assert!(close(g.advance, Vec2::new(0.0, 4.0)));
assert!(g.strokes.iter().flatten().any(|p| close(*p, Vec2::new(2.0, 2.0))), "{:?}", g.strokes);
// Bulge 0 is a straight line.
assert_eq!(one("12,(3,0,0),0").glyph(1, false).unwrap().strokes, vec![vec![Vec2::ZERO, Vec2::new(3.0, 0.0)]]);
// Polyarc: two arcs, then the (0,0) end without a bulge.
let g = one("13,(4,0,127),(0,4,-127),(0,0),010,0").glyph(1, false).unwrap();
assert!(close(g.advance, Vec2::new(5.0, 4.0)));
assert!(g.strokes.iter().flatten().any(|p| (p.y + 2.0).abs() < 1e-6), "first arc dips below");
}
#[test]
fn vertical_only_commands_and_text_font_record() {
let f = font(TEXT_FONT);
assert_eq!((f.kind(), f.name(), f.above(), f.below(), f.is_dual(), f.is_font()), (ShxKind::Shapes, "cadcraft test", 6.0, 2.0, true, true));
let h = f.glyph(67, false).unwrap();
assert_eq!(h.strokes, vec![vec![Vec2::ZERO, Vec2::new(0.0, 6.0)]]);
let v = f.glyph(67, true).unwrap();
assert_eq!(v.strokes, vec![vec![Vec2::new(0.0, -8.0), Vec2::new(0.0, -2.0)]]);
assert!(close(v.advance, Vec2::new(4.0, -8.0)));
// A font that isn't dual runs code 14's command in horizontal text too.
let g = one("2,14,8,(0,-8),0").glyph(1, false).unwrap();
assert!(close(g.advance, Vec2::new(0.0, -8.0)));
assert_eq!(f.shape_number("ucb"), Some(66));
assert_eq!(f.shape_name(65).as_deref(), Some("UCA"));
assert_eq!(f.shape_numbers().first(), Some(&32));
}
#[test]
fn subshapes_shapes_unicode_and_extended_big_font() {
let s = font("*1,4,BAR\n1,030,0\n*2,6,TWO\n7,1,7,1,0\n");
assert!(close(s.glyph(2, false).unwrap().advance, Vec2::new(6.0, 0.0)));
let u = font(UNI_FONT);
assert_eq!((u.kind(), u.name(), u.above(), u.encoding()), (ShxKind::Unifont, "cadcraft uni", 6.0, 0));
let ring = u.glyph(0xC5, false).unwrap();
assert_eq!(ring.strokes.len(), 2, "the A and the ring's bar");
assert!(close(ring.advance, Vec2::new(8.0, 0.0)));
let e = font(EXT_BIG_FONT);
assert_eq!((e.kind(), e.above()), (ShxKind::BigFont, 16.0));
let g = e.glyph(0x82A2, false).unwrap();
assert_eq!(g.strokes.len(), 2);
let xs: Vec<(f64, f64)> = g.strokes.iter().map(|s| s.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)))).collect();
assert_eq!(xs, vec![(0.0, 8.0), (8.0, 16.0)], "each primitive squeezed into half the cell");
let ys = g.strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert_eq!(ys, (0.0, 16.0));
assert!(close(g.advance, Vec2::new(16.0, 0.0)));
}
#[test]
fn big_font_header_and_escape_ranges() {
let b = font(BIG_FONT);
assert_eq!(b.kind(), ShxKind::BigFont);
assert_eq!(b.escape_ranges(), &[(0x81, 0x9F)]);
assert!(b.is_lead_byte(0x82) && !b.is_lead_byte(0x41) && !b.is_lead_byte(0xA0));
assert_eq!(b.above(), 8.0);
assert!(b.has_shape(0x82A0));
}
#[test]
fn cyclic_and_deep_subshapes_are_bounded() {
let f = font("*1,3,SELF\n7,1,0\n*2,3,A\n7,3,0\n*3,3,B\n7,2,0\n*4,3,MISSING\n7,9,0\n");
for n in 1..=4 {
assert!(f.glyph(n, false).is_none(), "shape {n}");
}
// A chain deeper than the bound fails; a shallow one draws.
let mut src = String::new();
for n in 1..=40u16 {
if n == 40 {
src.push_str(&format!("*{n},2,L\n010,0\n"));
} else {
src.push_str(&format!("*{n},3,L\n7,{},0\n", n + 1));
}
}
let f = font(&src);
assert!(f.glyph(1, false).is_none() && f.glyph(30, false).is_some());
// Doubling with subshapes: 2^n steps are cut off by the step bound.
let mut src = String::from("*1,2,L\n010,0\n");
for n in 2..=10u16 {
src.push_str(&format!("*{n},9,D\n7,{0},7,{0},7,{0},7,{0},0\n", n - 1));
}
let f = font(&src);
assert!(f.glyph(10, false).is_none() && f.glyph(3, false).is_some());
// Pushes without pops, and runaway scales.
let pushes = format!("{}0", "5,".repeat(100));
assert!(one(&pushes).glyph(1, false).is_none());
let scales = format!("{}010,0", "4,255,".repeat(10));
assert!(one(&scales).glyph(1, false).is_none());
// A zero radius or a zero factor draws nothing and doesn't fail.
assert!(one("10,(0,001),4,0,010,0").glyph(1, false).is_some());
}
/// Parse every prefix and many mutations of `bytes`; draw every shape of whatever parses.
fn hammer(bytes: &[u8]) {
let draw = |b: &[u8]| {
if let Ok(f) = ShxFont::parse(b) {
for n in f.shape_numbers() {
let _ = f.glyph(n, false);
let _ = f.glyph(n, true);
}
let _ = f.shape_number("X");
}
};
for n in 0..=bytes.len() {
draw(&bytes[..n]);
}
let mut seed: u64 = 0x5EED_1234;
for _ in 0..2000 {
let mut b = bytes.to_vec();
for _ in 0..3 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let i = (seed >> 33) as usize % b.len().max(1);
if let Some(x) = b.get_mut(i) {
*x = (seed >> 24) as u8;
}
}
draw(&b);
}
}
#[test]
fn hostile_files_never_panic() {
for src in [TEXT_FONT, UNI_FONT, BIG_FONT, EXT_BIG_FONT] {
hammer(&compile(src).unwrap());
}
// Counts far beyond the data.
let mut shapes = parse::SHAPES_10.to_vec();
shapes.extend([1, 0, 0xFF, 0xFF, 0xFF, 0xFF]);
assert_eq!(ShxFont::parse(&shapes).err(), Some(ShxError::Truncated));
let mut big = parse::BIGFONT.to_vec();
big.extend([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::TooLarge));
let mut big = parse::BIGFONT.to_vec();
big.extend([0xFF, 0xFF, 0xFF, 0xFF, 0, 0]);
assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::Truncated));
// A big font index pointing anywhere.
let mut big = parse::BIGFONT.to_vec();
big.extend([1, 0, 1, 0, 0, 0, 0x41, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F]);
assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::Empty));
let mut uni = parse::UNIFONT.to_vec();
uni.extend([0xFF, 0xFF, 0xFF, 0xFF, 2, 0, b'x', 0]);
assert_eq!(ShxFont::parse(&uni).err(), Some(ShxError::Empty));
assert_eq!(ShxFont::parse(b"not a font").err(), Some(ShxError::NotShx));
assert_eq!(ShxFont::parse(&[]).err(), Some(ShxError::NotShx));
// Hostile shape source.
for src in ["*1,2,X\n7\n", "*1,2,X\n9,(1,1)\n", "*99999,1,X\n0\n", "*1,1,X\n300,0\n", "010,0\n", "*1,1,X\n1,0zz,0\n", "*1,1,X\n0\n*1,1,Y\n0\n"] {
assert!(compile(src).is_err(), "{src:?}");
}
assert!(compile("").is_err());
}
/// Register a compiled test font under `name` (each test its own names: the table is shared).
fn reg(name: &str, src: &str) {
register(name, &compile(src).unwrap()).unwrap();
}
#[test]
fn text_metrics_follow_the_font() {
reg("cadcraft-test-metrics.shx", TEXT_FONT);
let f = TextFont::resolve_style("CADCRAFT-TEST-METRICS", "", false);
assert!(f.is_shx() && !f.is_outline());
let h = 3.0;
// Advances are the pen's end positions: A 6, B 6, at h / above = 0.5.
assert!((crate::text_width(&f, "AB", h, 1.0) - 6.0).abs() < 1e-9);
assert!((crate::text_width(&f, "AB", h, 0.5) - 3.0).abs() < 1e-9);
let sh = crate::shape_line(&f, "AB", h, 1.0, 0.0);
assert!((sh.width - 6.0).abs() < 1e-9);
let ys = sh.strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert_eq!(ys, (0.0, 3.0), "cap height = text height");
// %%d %%p %%c come from shapes 256-258; \U+0041 is A.
assert!((crate::text_width(&f, "%%d", h, 1.0) - 1.5).abs() < 1e-9);
assert!((crate::text_width(&f, "%%p%%c", h, 1.0) - 4.0).abs() < 1e-9);
assert!((crate::text_width(&f, "\\U+0041", h, 1.0) - 3.0).abs() < 1e-9);
assert_eq!(crate::shape_line(&f, "\\U+0041", h, 1.0, 0.0), crate::shape_line(&f, "A", h, 1.0, 0.0));
// A character the font lacks comes from the stroke font, with its advance.
let z = crate::text_width(&f, "Z", h, 1.0);
assert!((z - crate::stroke_advance('Z', h, 1.0)).abs() < 1e-9);
assert!(!crate::shape_line(&f, "Z", h, 1.0, 0.0).strokes.is_empty());
// Widths and shapes agree, with obliquing, underline and mixed fallbacks.
for s in ["AB AZ", "%%uAB%%u", "A\u{3042}B"] {
let sh = crate::shape_line(&f, s, h, 0.8, 0.2);
assert!((sh.width - crate::text_width(&f, s, h, 0.8)).abs() < 1e-9, "{s}");
}
// Placement: centred text sits on the alignment point by the font's own width.
let p = TextParams { h: crate::Align::Center, align_pt: Some(Vec2::new(10.0, 0.0)), ..TextParams::new(Vec2::ZERO, h) };
let (_, bb) = crate::place(&f, "AB", &p);
assert!((bb.center().x - 10.0).abs() < 1e-9 && (bb.width() - 6.0).abs() < 1e-9);
// MTEXT with the style's SHX font, and its \U+ escape.
let mp = crate::MTextParams { font: f.clone(), ..crate::MTextParams::new(Vec2::ZERO, h) };
let m = crate::layout_mtext_with("\\U+0041B", &mp);
assert_eq!(m.lines, vec!["AB".to_string()]);
assert_eq!(crate::space_width(&f, h, 1.0), 3.0);
}
#[test]
fn unifont_text_and_specials() {
reg("cadcraft-test-uni.shx", UNI_FONT);
let f = TextFont::resolve("cadcraft-test-uni.shx");
assert!((crate::text_width(&f, "A\u{C5}", 6.0, 1.0) - 14.0).abs() < 1e-9);
// %%c is the diameter sign, at U+2205 in this font.
assert!((crate::text_width(&f, "%%c", 6.0, 1.0) - 2.0).abs() < 1e-9);
}
#[test]
fn big_font_draws_double_byte_characters() {
reg("cadcraft-test-main.shx", TEXT_FONT);
reg("cadcraft-test-big.shx", BIG_FONT);
let f = TextFont::resolve_style("cadcraft-test-main.shx", "cadcraft-test-big.shx", false);
let TextFont::Shx(t) = &f else { panic!("SHX") };
assert_eq!(t.big_font_encoding(), "Shift_JIS");
let h = 2.5;
// あ (Shift-JIS 82A0) fills a square cell of the text height.
let w = crate::text_width(&f, "\u{3042}", h, 1.0);
assert!((w - h).abs() < 1e-9, "{w}");
let sh = crate::shape_line(&f, "A\u{3042}", h, 1.0, 0.0);
let big: Vec<Vec2> = sh.strokes.iter().flatten().copied().filter(|p| p.x > 2.5 - 1e-9).collect();
let ys = big.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert_eq!(ys, (0.0, h));
// Only the big font found: Latin letters from the stroke font, the big font's own.
let f = TextFont::resolve_style("cadcraft-missing-main-font.shx", "cadcraft-test-big.shx", false);
assert!(f.is_shx());
assert!((crate::text_width(&f, "\u{3042}", h, 1.0) - h).abs() < 1e-9);
assert!((crate::text_width(&f, "A", h, 1.0) - crate::stroke_advance('A', h, 1.0)).abs() < 1e-9);
// Neither found: the stroke font as before.
assert!(!TextFont::resolve_style("cadcraft-missing-a.shx", "cadcraft-missing-b.shx", false).is_shx());
}
#[test]
fn big_font_code_pages() {
for (name, ranges, want) in [
("gbcbig.shx", vec![(0xA1, 0xFE)], "GBK"),
("chineset.shx", vec![(0xA1, 0xFE)], "Big5"),
("whgtxt.shx", vec![(0xA1, 0xFE)], "EUC-KR"),
("extfont2.shx", vec![(0x81, 0x9F)], "Shift_JIS"),
("custom.shx", vec![(0x81, 0x9F), (0xE0, 0xFC)], "Shift_JIS"),
("custom.shx", vec![(0xA1, 0xFE)], "GBK"),
] {
let src = format!(
"*BIGFONT 1,{},{}\n*0,4,x\n8,0,0,0\n*0FFFF,2,x\n010,0\n",
ranges.len(),
ranges.iter().map(|(a, b)| format!("0{a:X},0{b:X}")).collect::<Vec<_>>().join(",")
);
let b = std::sync::Arc::new(font(&src));
assert_eq!(ShxText::new(None, Some(b), name, false).big_font_encoding(), want, "{name}");
}
}
#[test]
fn vertical_text_runs_down() {
reg("cadcraft-test-vertical.shx", TEXT_FONT);
let v = TextFont::resolve_style("cadcraft-test-vertical.shx", "", true);
let sh = crate::shape_line(&v, "CC", 1.0, 1.0, 0.0);
let miny = sh.strokes.iter().flatten().map(|p| p.y).fold(f64::MAX, f64::min);
// Two characters, each 8 units (8/6 of the height) down.
assert!((miny + 16.0 / 6.0).abs() < 1e-9, "{miny}");
assert!(sh.strokes.iter().flatten().all(|p| p.y <= 1e-9), "below the insertion point");
// A font without modes 2 stays horizontal.
reg("cadcraft-test-horizontal.shx", "*0,4,h\n6,2,0,0\n*67,3,C\n1,064,0\n");
let h = TextFont::resolve_style("cadcraft-test-horizontal.shx", "", true);
assert!(crate::shape_line(&h, "CC", 1.0, 1.0, 0.0).strokes.iter().flatten().all(|p| p.y >= -1e-9));
}
#[test]
fn names_and_lookup() {
assert_eq!(table::key("ROMANS.SHX").as_deref(), Some("romans.shx"));
assert_eq!(table::key("txt").as_deref(), Some("txt.shx"));
assert_eq!(table::key(r"C:\Fonts\Simplex.shx").as_deref(), Some("simplex.shx"));
assert_eq!(table::key("../x/gbcbig.shx").as_deref(), Some("gbcbig.shx"));
for n in ["", "arial.ttf", "CADCraft Stroke", "Times New Roman", ".shx", "a\u{0}.shx"] {
assert_eq!(table::key(n), None, "{n:?}");
}
assert!(register("not-a-font.ttf", &compile(TEXT_FONT).unwrap()).is_err());
assert!(register("broken.shx", b"AutoCAD-86 shapes 1.0\r\n\x1a").is_err());
// A path stored in a drawing only names the file.
reg("cadcraft-test-path.shx", TEXT_FONT);
assert!(find(r"\\server\share\CADCRAFT-TEST-PATH.SHX").is_some());
assert!(find("cadcraft-never-registered.shx").is_none());
}
#[test]
fn arcs_follow_the_circle() {
// Every point of an octant arc is on its circle.
let g = one("10,(5,034),0").glyph(1, false).unwrap();
let c = -dir(135.0) * 5.0;
for p in g.strokes.iter().flatten() {
assert!(((*p - c).len() - 5.0).abs() < 1e-9);
}
assert!(close(g.advance, (dir(135.0 + 180.0) - dir(135.0)) * 5.0));
let _ = PI;
}
-202
View File
@@ -1,202 +0,0 @@
//! Text set in SHX fonts: a text style's main font and big font, horizontal or vertical.
//!
//! Each character is drawn by the font that has it: a double-byte character of the big font's
//! code page (its lead byte in an escape range) from the big font, others from the main font
//! (character code, or Unicode in a Unicode font; `%%d` `%%p` `%%c` from shapes 256–258 or their
//! older or Unicode places). A font draws at text height / its `above` value, and its pen's end
//! position is the advance: there is no added letter gap. Characters neither font has come from
//! the built-in stroke font (and its fallback fonts), so nothing goes missing.
//!
//! Vertical text (a style flag, for fonts with modes 2) runs each shape's vertical-only
//! commands (code 14) and follows the pen downward.
use std::sync::Arc;
use cadcraft_geom::Vec2;
use encoding_rs::Encoding;
use super::{ShxFont, ShxKind};
use crate::{Shaped, bidi};
/// A text style's SHX fonts: the main font, the big font and the orientation. Either font may
/// be missing (not found): its characters then come from the stroke font.
#[derive(Clone, Debug)]
pub struct ShxText {
pub main: Option<Arc<ShxFont>>,
pub big: Option<Arc<ShxFont>>,
/// The style is vertical and the main font supports it.
pub vertical: bool,
/// The big font's code page (from its name and escape ranges).
big_encoding: &'static Encoding,
}
impl ShxText {
/// The fonts of a style: `big_name` is the big font's file name (it picks the code page).
pub fn new(main: Option<Arc<ShxFont>>, big: Option<Arc<ShxFont>>, big_name: &str, vertical: bool) -> ShxText {
let big_encoding = big.as_deref().map_or(encoding_rs::GBK, |b| big_font_encoding(big_name, b.escape_ranges()));
let vertical = vertical && main.as_ref().is_some_and(|m| m.is_dual());
ShxText { main, big, vertical, big_encoding }
}
/// The code page of the big font's double-byte codes.
pub fn big_font_encoding(&self) -> &'static str {
self.big_encoding.name()
}
}
/// The code page a big font's double-byte codes are in: known Japanese, Chinese and Korean
/// big font names, else its escape ranges (Shift-JIS lead bytes from 0x81; the others start at
/// 0xA1 or above, read as GBK).
fn big_font_encoding(name: &str, ranges: &[(u16, u16)]) -> &'static Encoding {
let stem = name.trim().rsplit(['/', '\\']).next().unwrap_or_default().to_ascii_lowercase();
let stem = stem.trim_end_matches(".shx").trim_start_matches('@');
if stem.starts_with("whg") || stem.starts_with("wht") || stem.contains("kor") || stem.contains("ksc") {
return encoding_rs::EUC_KR;
}
if stem.starts_with("chineset") || stem.contains("big5") || stem.starts_with("cht") {
return encoding_rs::BIG5;
}
if stem.starts_with("gb") || stem.starts_with("hz") || stem.contains("chs") {
return encoding_rs::GBK;
}
if stem.starts_with("bigfont") || stem.starts_with("extfont") || stem.contains("jis") || stem.contains("jpn") {
return encoding_rs::SHIFT_JIS;
}
if ranges.iter().any(|(lo, _)| (0x81..0xA1).contains(lo)) { encoding_rs::SHIFT_JIS } else { encoding_rs::GBK }
}
/// The double-byte code of `c` in `enc`, if it has one.
fn double_byte(c: char, enc: &'static Encoding) -> Option<u16> {
let mut buf = [0u8; 4];
let (bytes, _, errors) = enc.encode(c.encode_utf8(&mut buf));
match (errors, bytes.as_ref()) {
(false, [a, b]) => Some(u16::from_be_bytes([*a, *b])),
_ => None,
}
}
/// The shape numbers that may draw `c` in `font`, best first.
fn candidates(c: char, font: &ShxFont) -> [Option<u16>; 4] {
let u = u32::from(c);
let unicode = font.kind() == ShxKind::Unifont && font.encoding() == 0;
let code = |x: u32| u16::try_from(x).ok();
// The special characters of `%%d` `%%p` `%%c`: shapes 256–258, the Latin-1 or Unicode code,
// and the oldest fonts' 127–129.
let special = match c {
'°' => Some((256, 0xB0, 127)),
'±' => Some((257, 0xB1, 128)),
'⌀' | '∅' => Some((258, u, 129)),
_ => None,
};
if let Some((s, latin, old)) = special {
return if unicode {
[code(u), code(if c == '⌀' { 0x2205 } else { latin }), Some(s), Some(old)]
} else {
[Some(s), code(latin), Some(old), None]
};
}
if unicode || u < 0x80 {
return [code(u), None, None, None];
}
// A single-byte font: the Windows Latin-1 code of the character (not the old special
// codes 127–129).
let mut buf = [0u8; 4];
let (bytes, _, errors) = encoding_rs::WINDOWS_1252.encode(c.encode_utf8(&mut buf));
match (errors, bytes.as_ref()) {
(false, [b]) if !(127..=129).contains(b) => [Some(u16::from(*b)), None, None, None],
_ => [None, None, None, None],
}
}
/// The font and shape that draw `c`.
fn pick(t: &ShxText, c: char) -> Option<(&ShxFont, u16)> {
let from_main = || {
let m = t.main.as_deref()?;
candidates(c, m).into_iter().flatten().find(|n| m.has_shape(*n)).map(|n| (m, n))
};
let from_big = || {
let b = t.big.as_deref()?;
let code = double_byte(c, t.big_encoding)?;
let [lead, _] = code.to_be_bytes();
(b.is_lead_byte(lead) && b.has_shape(code)).then_some((b, code))
};
if u32::from(c) < 0x100 || matches!(c, '⌀' | '∅') { from_main().or_else(from_big) } else { from_big().or_else(from_main) }
}
fn finite_or(v: f64, d: f64) -> f64 {
if v.is_finite() { v } else { d }
}
/// Shape one line (baseline at y = 0, x from 0).
pub(crate) fn shape(t: &ShxText, s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped {
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
let shear = finite_or(oblique, 0.0).tan().clamp(-10.0, 10.0);
let chars = crate::decode_controls(s);
let logical: Vec<char> = chars.iter().map(|v| v.0).collect();
let mut out = Shaped::default();
let mut spans: Vec<(f64, f64, bool, bool)> = Vec::new();
let mut pen = Vec2::ZERO;
let mut max_x: f64 = 0.0;
let runs = if t.vertical { vec![(0..logical.len(), false)] } else { bidi::visual_runs(&logical, None) };
for (run, rtl) in runs {
let order: Box<dyn Iterator<Item = usize>> = if rtl { Box::new(run.rev()) } else { Box::new(run) };
for i in order {
let Some(&(c, under, over)) = chars.get(i) else { continue };
if bidi::is_format_control(c) {
continue;
}
let c = if rtl { bidi::mirror(c) } else { c };
let x0 = pen.x;
match pick(t, c).and_then(|(f, n)| Some((f, f.glyph(n, t.vertical)?))) {
Some((f, g)) => {
let k = h / f.units_per_height();
let place = |p: Vec2| Vec2::new(pen.x + p.x * k * wf + p.y * k * shear, pen.y + p.y * k);
for st in &g.strokes {
let pts: Vec<Vec2> = st.iter().map(|p| place(*p)).collect();
max_x = pts.iter().fold(max_x, |m, p| m.max(p.x));
out.strokes.push(pts);
}
let adv = Vec2::new(g.advance.x * k * wf, g.advance.y * k);
pen = if t.vertical { pen + adv } else { Vec2::new(pen.x + adv.x, pen.y) };
}
None => {
// The stroke font draws it, at the pen.
let mut fb = crate::shape_stroke(&c.to_string(), h, wf, oblique);
let at = pen;
fb.map(|p| p + at);
max_x = max_x.max(at.x + fb.width);
out.extend(fb);
if t.vertical {
pen.y -= h * 1.5;
} else {
pen.x += crate::stroke_advance(c, h, wf);
}
}
}
if !t.vertical {
spans.push((x0, pen.x, under, over));
}
}
}
out.strokes.extend(crate::decorations(&spans, h));
out.width = if t.vertical { max_x.max(0.0) } else { pen.x.max(0.0) };
out
}
/// The width of one line: the sum of the advances.
pub(crate) fn width(t: &ShxText, s: &str, height: f64, width_factor: f64) -> f64 {
if t.vertical || bidi::has_rtl(s) {
return shape(t, s, height, width_factor, 0.0).width;
}
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
crate::decode_controls(s)
.into_iter()
.filter(|(c, _, _)| !bidi::is_format_control(*c))
.map(|(c, _, _)| match pick(t, c).and_then(|(f, n)| Some((f, f.glyph(n, false)?))) {
Some((f, g)) => g.advance.x * h / f.units_per_height() * wf,
None => crate::stroke_advance(c, h, wf),
})
.sum::<f64>()
.max(0.0)
}
-76
View File
@@ -1,76 +0,0 @@
//! How SHX fonts are found: drawing folders, the search path, the font map, an app's font
//! provider and FONTALT. Process-wide settings, so one test in its own binary. The fonts are
//! compiled here from shape source written for the test; nothing is read from a CAD install.
use std::sync::Arc;
use cadcraft_fonts::shx::{self, FontProvider};
use cadcraft_fonts::{TextFont, ttf};
const FONT: &str = "*0,4,lookup test\n6,2,0,0\n*65,4,UCA\n1,064,0\n";
struct Provider;
impl FontProvider for Provider {
fn load(&self, file_name: &str) -> Option<Vec<u8>> {
(file_name == "cadcraft-provided.shx").then(|| shx::compile(FONT).ok()).flatten()
}
}
#[test]
fn fonts_are_found_where_the_drawing_and_the_app_keep_them() {
let bytes = shx::compile(FONT).unwrap();
let dir = std::env::temp_dir().join(format!("cadcraft-shx-lookup-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("cadcraft-beside.shx"), &bytes).unwrap();
// Beside the drawing: found once the drawing's folder is known; a later change of folders
// starts a new generation (viewers regenerate).
assert!(shx::find("cadcraft-beside.shx").is_none());
let g = shx::generation();
shx::add_drawing_dir(&dir);
assert!(shx::generation() != g);
assert_eq!(shx::drawing_dirs().first(), Some(&dir));
assert!(shx::find("CADCRAFT-BESIDE").is_some_and(|f| f.has_shape(65)));
assert!(TextFont::resolve("cadcraft-beside.shx").is_shx());
// A file whose name has another case than the drawing's (found on case-sensitive systems
// too, by looking through the folder).
std::fs::write(dir.join("CadCraft-Mixed.SHX"), &bytes).unwrap();
assert!(shx::find("cadcraft-mixed.shx").is_some());
// The search path, and the font map renaming a font to one that is there.
let other = dir.join("lib");
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("cadcraft-onpath.shx"), &bytes).unwrap();
assert!(shx::find("cadcraft-onpath.shx").is_none());
shx::set_search_path(&other.to_string_lossy());
assert!(shx::find("cadcraft-onpath.shx").is_some());
shx::set_font_map("cadcraft-old.shx;cadcraft-onpath.shx\nnonsense");
assert_eq!(shx::font_map(), "cadcraft-old.shx;cadcraft-onpath.shx");
assert!(shx::find("cadcraft-old").is_some());
shx::set_font_map("");
// An app's provider (the web build's file store).
assert!(shx::find("cadcraft-provided.shx").is_none());
shx::set_provider(Some(Arc::new(Provider)));
assert!(shx::find("cadcraft-provided.shx").is_some());
shx::set_provider(None);
assert!(shx::find("cadcraft-provided.shx").is_none());
// FONTALT replaces a missing SHX font (and may name a big font after a comma).
assert!(!TextFont::resolve("cadcraft-missing.shx").is_shx());
ttf::set_font_alt("cadcraft-beside.shx");
assert!(TextFont::resolve("cadcraft-missing.shx").is_shx());
assert!(!TextFont::resolve("").is_shx(), "the built-in font is not replaced");
ttf::set_font_alt("");
// Registered bytes (files opened alongside a drawing on the web) and clearing them.
shx::register("cadcraft-registered.shx", &bytes).unwrap();
assert!(shx::find("cadcraft-registered.shx").is_some());
shx::clear_registered();
assert!(shx::find("cadcraft-registered.shx").is_none());
shx::set_search_path("");
assert_eq!(shx::search_path(), "");
let _ = std::fs::remove_dir_all(&dir);
}
-1
View File
@@ -22,7 +22,6 @@ serde = { workspace = true }
miniz_oxide = "0.9"
craft-pdf = { workspace = true, features = ["write"] }
quick-xml = { workspace = true }
image = { workspace = true }
[dev-dependencies]
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
-551
View File
@@ -1,551 +0,0 @@
//! XCLIP in a file (DXF Reference, SPATIAL_FILTER): a clipped block reference has an extension
//! dictionary with an `ACAD_FILTER` dictionary whose `SPATIAL` entry is a SPATIAL_FILTER object.
//! The filter holds the boundary vertices in the reference's coordinates (group 70 the count,
//! 10/20 the vertices), whether clipping is on (71), the front and back clip (72/73, 40/41) and
//! two 3×4 matrices (group 40, row by row): the inverse of the reference's transform, which takes
//! the boundary into the block's coordinates, and the boundary's own transform (identity here).
//!
//! An inverted clip ("Invert clip") is marked as the reference app writes it in a file: the
//! filter has an extension dictionary whose `ACAD_XREC_ROUNDTRIP` entry is an XRECORD holding
//! `102 ACAD_INVERTEDCLIP_ROUNDTRIP` then the boundary (10/20/30), and
//! `102 ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE` then the filter's own vertices. While the filter
//! still holds those vertices the clip is inverted, with the first list as its boundary. The
//! reference app shows the flag as group 290 on the object but never writes 290 in a DXF file,
//! and discards a file whose filter has one; 290 is read when another program wrote it, never
//! written. The record goes through DWG as it is.
//!
//! Reading takes the planar filters the model can hold out of the kept foreign data and puts them
//! on the references ([`read`]); others (front/back clipping, a skewed boundary transform) stay
//! foreign and are written back untouched. Writing adds the dictionaries and the filter of every
//! clipped reference to a copy of the drawing's foreign data ([`prepare`]), which the raw-data
//! writer (#528) then places on the reference.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::foreign::{Foreign, ForeignObject};
use cadcraft_doc::{ClipBoundary, Drawing, EntityKind, Handle, MAX_CLIP_VERTICES, RawTag};
use cadcraft_geom::{Mat3, Vec2};
/// The filter's extension-dictionary entry of the reference app's inverted-clip record.
const ROUNDTRIP_KEY: &str = "ACAD_XREC_ROUNDTRIP";
const ROUNDTRIP: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP";
const COMPARE: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE";
/// Handles above this are not handed out (the DXF handle space is 64-bit, keep a margin).
const MAX_HANDLE: u64 = 1 << 48;
fn raw(code: i32, value: impl ToString) -> RawTag {
RawTag { code, value: value.to_string() }
}
/// The handle of dictionary entry `name` (group 3 followed by 350/360).
fn entry(tags: &[RawTag], name: &str) -> Option<Handle> {
tags.windows(2)
.find(|w| matches!(w, [k, v] if k.code == 3 && k.value.eq_ignore_ascii_case(name) && matches!(v.code, 350 | 360)))
.and_then(|w| w.get(1))
.and_then(|v| Handle::parse_hex(&v.value))
}
/// Remove dictionary entry `name`; returns the handle it named.
fn remove_entry(tags: &mut Vec<RawTag>, name: &str) -> Option<Handle> {
let mut old = None;
let mut i = 0;
while let (Some(k), Some(v)) = (tags.get(i), tags.get(i + 1)) {
if k.code == 3 && k.value.eq_ignore_ascii_case(name) && matches!(v.code, 350 | 360) {
old = old.or_else(|| Handle::parse_hex(&v.value));
tags.drain(i..i + 2);
} else {
i += 1;
}
}
old
}
fn number(tags: &[RawTag], code: i32) -> Option<f64> {
tags.iter().find(|t| t.code == code)?.value.trim().parse::<f64>().ok().filter(|n| n.is_finite())
}
/// Where each handle's object is in `Foreign::objects` (the first, as a scan would find it), so
/// a file with many references and objects isn't searched once per reference.
type Index = HashMap<Handle, usize>;
fn index(f: &Foreign) -> Index {
let mut idx = Index::with_capacity(f.objects.len());
for (i, o) in f.objects.iter().enumerate() {
idx.entry(o.handle).or_insert(i);
}
idx
}
fn object<'a>(f: &'a Foreign, idx: &Index, h: Handle) -> Option<&'a ForeignObject> {
f.objects.get(*idx.get(&h)?)
}
/// The extension dictionary, ACAD_FILTER dictionary and SPATIAL_FILTER of entity `h`.
fn chain<'a>(f: &'a Foreign, idx: &Index, h: Handle) -> Option<(Handle, Handle, &'a ForeignObject)> {
let xd = f.entities.get(&h)?.xdict?;
let fd = entry(&object(f, idx, xd)?.tags, "ACAD_FILTER")?;
let sf = entry(&object(f, idx, fd)?.tags, "SPATIAL")?;
Some((xd, fd, object(f, idx, sf).filter(|o| o.kind.eq_ignore_ascii_case("SPATIAL_FILTER"))?))
}
/// A 3×4 matrix (12 values row by row) as a 2D transform, when it is one (no Z mixing).
fn matrix_2d(m: &[f64]) -> Option<Mat3> {
let [a, c, z1, e, b, d, z2, f, z3, z4, one, z5] = <[f64; 12]>::try_from(m).ok()?;
let flat = [z1, z2, z3, z4, z5].iter().all(|v| v.abs() <= 1e-9) && (one - 1.0).abs() <= 1e-9;
(flat && [a, b, c, d, e, f].iter().all(|v| v.is_finite())).then_some(Mat3 { a, b, c, d, e, f })
}
fn matrix_values(m: &Mat3) -> [f64; 12] {
[m.a, m.c, 0.0, m.e, m.b, m.d, 0.0, m.f, 0.0, 0.0, 1.0, 0.0]
}
/// The `count` vertices (10/20) a SPATIAL_FILTER holds, as written.
fn vertices(tags: &[RawTag], count: usize) -> Option<Vec<Vec2>> {
// The count is the file's: bound it before allocating.
if count > MAX_CLIP_VERTICES {
return None;
}
let mut points = Vec::with_capacity(count);
for w in tags.windows(2) {
let [x, y] = w else { continue };
if x.code != 10 {
continue;
}
if y.code != 20 || points.len() >= count {
return None;
}
points.push(Vec2::new(x.value.trim().parse().ok()?, y.value.trim().parse().ok()?));
}
(points.len() == count).then_some(points)
}
/// The handle of an object's own extension dictionary (`102 {ACAD_XDICTIONARY`, 360).
fn own_xdict(tags: &[RawTag]) -> Option<Handle> {
tags.windows(2)
.find(|w| matches!(w, [k, v] if k.code == 102 && k.value.trim().eq_ignore_ascii_case("{ACAD_XDICTIONARY") && v.code == 360))
.and_then(|w| w.get(1))
.and_then(|v| Handle::parse_hex(&v.value))
}
/// The boundary of an inverted clip as the reference app marks it on `filter` (see above), when
/// the filter still holds the vertices the mark was made for.
fn roundtrip(f: &Foreign, idx: &Index, filter: &ForeignObject) -> Option<Vec<Vec2>> {
let rec = object(f, idx, entry(&object(f, idx, own_xdict(&filter.tags)?)?.tags, ROUNDTRIP_KEY)?)?;
if !rec.kind.eq_ignore_ascii_case("XRECORD") {
return None;
}
let (mut boundary, mut compare) = (Vec::new(), Vec::new());
// 0 before either list, 1 in the boundary, 2 in the filter's vertices.
let mut list = 0;
for w in rec.tags.windows(2) {
let [x, y] = w else { continue };
match x.code {
102 => {
list = match x.value.trim() {
v if v.eq_ignore_ascii_case(ROUNDTRIP) => 1,
v if v.eq_ignore_ascii_case(COMPARE) => 2,
_ => 0,
}
}
10 if list != 0 && y.code == 20 => {
let into = if list == 1 { &mut boundary } else { &mut compare };
if into.len() >= MAX_CLIP_VERTICES {
return None;
}
into.push(Vec2::new(x.value.trim().parse().ok()?, y.value.trim().parse().ok()?));
}
_ => {}
}
}
let own = vertices(&filter.tags, usize::try_from(number(&filter.tags, 70)? as i64).ok()?)?;
let near = |a: f64, b: f64| (a - b).abs() <= 1e-9 * (1.0 + a.abs().max(b.abs()));
let same = own.len() == compare.len() && own.iter().zip(&compare).all(|(p, q)| near(p.x, q.x) && near(p.y, q.y));
(same && boundary.len() >= 2).then_some(boundary)
}
/// The boundary a planar SPATIAL_FILTER holds, in the block's coordinates; with `boundary`, the
/// clip is inverted and that is its outline (in the filter's coordinates). `None` for anything
/// the model can't hold as it is (it then stays foreign).
fn parse_filter(o: &ForeignObject, boundary: Option<&[Vec2]>) -> Option<ClipBoundary> {
let count = usize::try_from(number(&o.tags, 70)? as i64).ok()?;
if !(2..=MAX_CLIP_VERTICES).contains(&count) {
return None;
}
let normal_up = number(&o.tags, 210).unwrap_or(0.0).abs() <= 1e-9
&& number(&o.tags, 220).unwrap_or(0.0).abs() <= 1e-9
&& (number(&o.tags, 230).unwrap_or(1.0) - 1.0).abs() <= 1e-9;
let no_depth = number(&o.tags, 72).unwrap_or(0.0) == 0.0 && number(&o.tags, 73).unwrap_or(0.0) == 0.0;
if !normal_up || !no_depth {
return None;
}
let m: Vec<f64> = o.tags.iter().filter(|t| t.code == 40).map(|t| t.value.trim().parse::<f64>().unwrap_or(f64::NAN)).collect();
let (inverse, own) = match m.len() {
24 => (matrix_2d(m.get(..12)?)?, matrix_2d(m.get(12..)?)?),
// Without matrices the vertices are taken as the block's own.
0 => (Mat3::IDENTITY, Mat3::IDENTITY),
_ => return None,
};
if (own.a - 1.0).abs() > 1e-9
|| own.b.abs() > 1e-9
|| own.c.abs() > 1e-9
|| (own.d - 1.0).abs() > 1e-9
|| own.e.abs() > 1e-9
|| own.f.abs() > 1e-9
{
return None;
}
let own = vertices(&o.tags, count)?;
let outline = boundary.unwrap_or(&own);
if !(2..=MAX_CLIP_VERTICES).contains(&outline.len()) {
return None;
}
let points: Vec<Vec2> = outline.iter().map(|p| inverse.apply(*p)).collect();
if !points.iter().all(|p| p.is_finite()) {
return None;
}
let inverted = boundary.is_some() || number(&o.tags, 290).is_some_and(|v| v != 0.0);
let clip = ClipBoundary { points, enabled: number(&o.tags, 71).unwrap_or(1.0) != 0.0, inverted };
cadcraft_render::clip::PolyClip::new(&clip.polygon()).is_some().then_some(clip)
}
/// Put the planar filters of the file read into `d` on their references, taking them out of
/// the kept foreign data (the model writes them from now on).
pub(crate) fn read(d: &mut Drawing) {
if d.foreign.objects.is_empty() {
return;
}
let idx = index(&d.foreign);
let found: Vec<(Handle, ClipBoundary)> = d
.foreign
.entities
.keys()
.filter_map(|h| {
let (_, _, o) = chain(&d.foreign, &idx, *h)?;
let boundary = roundtrip(&d.foreign, &idx, o);
Some((*h, parse_filter(o, boundary.as_deref())?))
})
.collect();
if found.is_empty() {
return;
}
let mut taken = Vec::new();
for (h, clip) in found {
let set = |e: &mut cadcraft_doc::Entity| {
if let EntityKind::Insert(i) = &mut e.kind {
i.clip = Some(clip.clone());
}
};
let is_insert = |e: &cadcraft_doc::Entity| matches!(e.kind, EntityKind::Insert(_));
let done = if d.entity(h).is_some_and(|e| is_insert(e)) {
d.modify_entity(h, set).is_ok()
} else {
let mut done = false;
for b in d.blocks.values_mut() {
if b.entities.get(h).is_some_and(|e| is_insert(e)) {
done = Arc::make_mut(b).entities.modify(h, set);
break;
}
}
done
};
if done {
taken.push(h);
}
}
let f = Arc::make_mut(&mut d.foreign);
let mut gone = HashSet::new();
for h in taken {
forget(f, &idx, h, &mut gone);
}
f.objects.retain(|o| !gone.contains(&o.handle));
}
/// Take the clip of entity `h` out of the foreign data: the ACAD_FILTER entry and what it holds,
/// and the extension dictionary when nothing else is left in it. The
/// objects to remove are added to `gone`; the caller removes them all at once.
fn forget(f: &mut Foreign, idx: &Index, h: Handle, all_gone: &mut HashSet<Handle>) {
let Some(xd) = f.entities.get(&h).and_then(|x| x.xdict) else { return };
let mut gone: Vec<Handle> = Vec::new();
let mut empty = false;
if let Some(o) = idx.get(&xd).copied().and_then(|i| f.objects.get_mut(i)) {
gone.extend(remove_entry(&mut o.tags, "ACAD_FILTER"));
empty = !o.tags.iter().any(|t| t.code == 3);
}
// The filter dictionary's entries go with it, and the filter's own extension dictionary.
let mut i = 0;
while let Some(g) = gone.get(i).copied() {
if let Some(o) = object(f, idx, g) {
if o.kind.eq_ignore_ascii_case("DICTIONARY") {
gone.extend(o.tags.windows(2).filter_map(|w| match w {
[k, v] if k.code == 3 && matches!(v.code, 350 | 360) => Handle::parse_hex(&v.value),
_ => None,
}));
}
gone.extend(own_xdict(&o.tags));
}
i += 1;
if i > MAX_CLIP_VERTICES {
break;
}
}
if empty {
gone.push(xd);
if let Some(x) = f.entities.get_mut(&h) {
x.xdict = None;
}
if f.entities.get(&h).is_some_and(|x| x.is_empty()) {
f.entities.remove(&h);
}
}
all_gone.extend(gone);
}
fn dictionary(h: Handle, owner: Handle) -> ForeignObject {
ForeignObject {
handle: h,
kind: "DICTIONARY".into(),
tags: vec![
raw(5, h.hex()),
raw(102, "{ACAD_REACTORS"),
raw(330, owner.hex()),
raw(102, "}"),
raw(330, owner.hex()),
raw(100, "AcDbDictionary"),
raw(280, 1),
raw(281, 1),
],
}
}
/// The SPATIAL_FILTER of `clip`; `xdict` is its extension dictionary (an inverted clip's).
/// No group 290: the reference app discards a file whose filter has one.
fn spatial_filter(h: Handle, owner: Handle, clip: &ClipBoundary, to_world: &Mat3, inverse: &Mat3, xdict: Option<Handle>) -> ForeignObject {
let mut tags = vec![raw(5, h.hex()), raw(102, "{ACAD_REACTORS"), raw(330, owner.hex()), raw(102, "}")];
if let Some(x) = xdict {
tags.extend([raw(102, "{ACAD_XDICTIONARY"), raw(360, x.hex()), raw(102, "}")]);
}
tags.extend([raw(330, owner.hex()), raw(100, "AcDbFilter"), raw(100, "AcDbSpatialFilter"), raw(70, clip.points.len())]);
for p in &clip.points {
let q = to_world.apply(*p);
tags.push(raw(10, q.x));
tags.push(raw(20, q.y));
}
tags.extend([raw(210, 0.0), raw(220, 0.0), raw(230, 1.0), raw(11, 0.0), raw(21, 0.0), raw(31, 0.0)]);
tags.extend([raw(71, i32::from(clip.enabled)), raw(72, 0), raw(73, 0)]);
for v in matrix_values(inverse).into_iter().chain(matrix_values(&Mat3::IDENTITY)) {
tags.push(raw(40, v));
}
ForeignObject { handle: h, kind: "SPATIAL_FILTER".into(), tags }
}
/// The reference app's inverted-clip record for a filter whose vertices are `points` (drawing
/// coordinates): the boundary and the filter's vertices are the same list.
fn roundtrip_record(h: Handle, owner: Handle, points: &[Vec2]) -> ForeignObject {
let mut tags = vec![raw(5, h.hex()), raw(102, "{ACAD_REACTORS"), raw(330, owner.hex()), raw(102, "}")];
tags.extend([raw(330, owner.hex()), raw(100, "AcDbXrecord"), raw(280, 1)]);
for name in [ROUNDTRIP, COMPARE] {
tags.push(raw(102, name));
for p in points {
tags.extend([raw(10, p.x), raw(20, p.y), raw(30, 0.0)]);
}
}
ForeignObject { handle: h, kind: "XRECORD".into(), tags }
}
/// A copy of `d` whose foreign data holds the SPATIAL_FILTER of every clipped reference (any
/// filter kept from the file for it is replaced). `None` when no reference is clipped.
pub(crate) fn prepare(d: &Drawing) -> Option<Drawing> {
let mut clips: Vec<(Handle, ClipBoundary, Mat3)> = Vec::new();
let mut take = |e: &cadcraft_doc::Entity| {
if let EntityKind::Insert(i) = &e.kind
&& let Some(c) = &i.clip
&& c.polygon().len() >= 3
&& let Some(base) = d.block(&i.block).map(|b| b.base.xy())
{
clips.push((e.handle, c.clone(), i.transform(base)));
}
};
d.model.iter().for_each(|e| take(e));
d.layouts.iter().flat_map(|l| l.entities.iter()).for_each(|e| take(e));
d.blocks.values().filter(|b| b.xref_path.is_none()).flat_map(|b| b.entities.iter()).for_each(|e| take(e));
if clips.is_empty() {
return None;
}
let mut out = d.clone();
let f = Arc::make_mut(&mut out.foreign);
let mut next = d.handseed.max(f.objects.iter().map(|o| o.handle.0).max().unwrap_or(0)).saturating_add(1);
fn fresh(next: &mut u64) -> Handle {
let h = Handle(*next);
*next = next.saturating_add(1);
h
}
let mut idx = index(f);
let mut gone = HashSet::new();
for (insert, clip, to_world) in clips {
let Some(inverse) = to_world.inverse() else { continue };
if next.saturating_add(6) >= MAX_HANDLE {
break;
}
// A filter kept from the file for this reference is replaced.
forget(f, &idx, insert, &mut gone);
let xd = match f.entities.get(&insert).and_then(|x| x.xdict).filter(|h| idx.contains_key(h) && !gone.contains(h)) {
Some(h) => h,
None => {
let h = fresh(&mut next);
idx.insert(h, f.objects.len());
f.objects.push(dictionary(h, insert));
f.entities.entry(insert).or_default().xdict = Some(h);
h
}
};
let (fd, sf) = (fresh(&mut next), fresh(&mut next));
// An inverted clip's filter has an extension dictionary and the record in it.
let marks = clip.inverted.then(|| (fresh(&mut next), fresh(&mut next)));
if let Some(o) = idx.get(&xd).copied().and_then(|i| f.objects.get_mut(i)) {
o.tags.extend([raw(3, "ACAD_FILTER"), raw(360, fd.hex())]);
}
let mut filters = dictionary(fd, xd);
filters.tags.extend([raw(3, "SPATIAL"), raw(360, sf.hex())]);
let mut add = |o: ForeignObject| {
idx.insert(o.handle, f.objects.len());
f.objects.push(o);
};
add(filters);
add(spatial_filter(sf, fd, &clip, &to_world, &inverse, marks.map(|(x, _)| x)));
if let Some((x, r)) = marks {
let mut own = dictionary(x, sf);
own.tags.extend([raw(3, ROUNDTRIP_KEY), raw(360, r.hex())]);
add(own);
let world: Vec<Vec2> = clip.points.iter().map(|p| to_world.apply(*p)).collect();
add(roundtrip_record(r, x, &world));
}
}
f.objects.retain(|o| !gone.contains(&o.handle));
// The writer hands out handles of its own from the seed on: start it past these.
out.handseed = out.handseed.max(next);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matrices_round_trip_row_by_row() {
let m = Mat3::translate(Vec2::new(10.0, 20.0)).then_before(Mat3::rotate(0.5)).then_before(Mat3::scale(2.0, 2.0));
let back = matrix_2d(&matrix_values(&m)).unwrap();
assert!((back.a - m.a).abs() < 1e-12 && (back.c - m.c).abs() < 1e-12 && (back.f - m.f).abs() < 1e-12);
// Translation is the fourth value of each row.
let t = matrix_values(&Mat3::translate(Vec2::new(-5.0, 7.0)));
assert_eq!((t[3], t[7]), (-5.0, 7.0));
assert!(matrix_2d(&[1.0; 11]).is_none());
}
#[test]
fn hostile_filters_stay_foreign() {
let o = |tags: Vec<RawTag>| ForeignObject { handle: Handle(1), kind: "SPATIAL_FILTER".into(), tags };
assert!(parse_filter(&o(vec![raw(70, 1_000_000_000)]), None).is_none());
assert!(parse_filter(&o(vec![raw(70, 3), raw(10, 0), raw(20, 0)]), None).is_none());
assert!(parse_filter(&o(vec![raw(70, 2), raw(10, "nan"), raw(20, 0), raw(10, 1), raw(20, 1)]), None).is_none());
assert!(parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1), raw(72, 1)]), None).is_none());
let ok = parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1)]), None).unwrap();
assert_eq!((ok.points.len(), ok.inverted), (2, false));
}
#[test]
fn the_writer_starts_its_handles_past_the_clip_objects() {
let mut d = Drawing::new_metric();
d.blocks.insert("B".into(), Arc::new(cadcraft_doc::Block::new("B")));
for i in 0..3 {
let clip = ClipBoundary::new(vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)]);
let ins = cadcraft_doc::Insert { block: "B".into(), clip: Some(ClipBoundary { inverted: i == 0, ..clip }), ..Default::default() };
d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), EntityKind::Insert(ins)).unwrap();
}
let out = prepare(&d).unwrap();
let highest = out.foreign.objects.iter().map(|o| o.handle.0).max().unwrap();
assert!(out.handseed > highest, "seed {} at or below {highest}", out.handseed);
}
#[test]
fn group_290_is_read_but_never_written() {
let tags = |invert: &str| vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1), raw(290, invert)];
let o = |invert| ForeignObject { handle: Handle(1), kind: "SPATIAL_FILTER".into(), tags: tags(invert) };
assert!(parse_filter(&o("1"), None).unwrap().inverted);
assert!(!parse_filter(&o("0"), None).unwrap().inverted);
assert!(!parse_filter(&o("junk"), None).unwrap().inverted);
// The reference app discards a DXF whose filter has 290.
let clip = ClipBoundary { inverted: true, ..ClipBoundary::new(vec![Vec2::ZERO, Vec2::new(1.0, 1.0)]) };
let written = spatial_filter(Handle(2), Handle(1), &clip, &Mat3::IDENTITY, &Mat3::IDENTITY, Some(Handle(3)));
assert!(!written.tags.iter().any(|t| t.code == 290));
assert_eq!(own_xdict(&written.tags), Some(Handle(3)));
}
/// The filter, its extension dictionary and the reference app's record, as one foreign set.
fn marked(filter_points: &[(f64, f64)], boundary: &[(f64, f64)], compare: &[(f64, f64)]) -> (Foreign, ForeignObject) {
let mut tags = vec![raw(5, "10"), raw(102, "{ACAD_XDICTIONARY"), raw(360, "11"), raw(102, "}"), raw(70, filter_points.len())];
for (x, y) in filter_points {
tags.extend([raw(10, x), raw(20, y)]);
}
let filter = ForeignObject { handle: Handle(0x10), kind: "SPATIAL_FILTER".into(), tags };
let mut f = Foreign::default();
let mut dict = dictionary(Handle(0x11), Handle(0x10));
dict.tags.extend([raw(3, ROUNDTRIP_KEY), raw(360, "12")]);
f.objects.push(dict);
let mut rec = vec![raw(100, "AcDbXrecord"), raw(280, 1)];
for (name, list) in [(ROUNDTRIP, boundary), (COMPARE, compare)] {
rec.push(raw(102, name));
for (x, y) in list {
rec.extend([raw(10, x), raw(20, y), raw(30, 0)]);
}
}
f.objects.push(ForeignObject { handle: Handle(0x12), kind: "XRECORD".into(), tags: rec });
f.objects.push(filter.clone());
(f, filter)
}
#[test]
fn the_reference_apps_record_marks_an_inverted_clip() {
// As the reference app writes an inverted rectangle: a keyhole filter, the rectangle kept.
let keyhole = [
(0.0, 0.0),
(100.0, 0.0),
(100.0, 50.0),
(0.0, 50.0),
(0.0, 10.0),
(20.0, 10.0),
(20.0, 40.0),
(80.0, 40.0),
(80.0, 10.0),
(20.0, 10.0),
(0.0, 10.0),
];
let (f, filter) = marked(&keyhole, &[(20.0, 10.0), (80.0, 40.0)], &keyhole);
let boundary = roundtrip(&f, &index(&f), &filter).unwrap();
let clip = parse_filter(&filter, Some(&boundary)).unwrap();
assert!(clip.inverted);
assert_eq!(clip.points, vec![Vec2::new(20.0, 10.0), Vec2::new(80.0, 40.0)]);
// The simpler form: the filter keeps the boundary itself.
let rect = [(20.0, 10.0), (80.0, 40.0)];
let (f, filter) = marked(&rect, &rect, &rect);
assert!(roundtrip(&f, &index(&f), &filter).is_some());
// A filter changed since the record was made (the clip edited elsewhere) isn't inverted.
let (f, filter) = marked(&[(0.0, 0.0), (5.0, 5.0)], &rect, &rect);
assert!(roundtrip(&f, &index(&f), &filter).is_none());
}
#[test]
fn a_hostile_vertex_count_beside_the_record_does_not_allocate() {
let rect = [(20.0, 10.0), (80.0, 40.0)];
for count in ["9223372036854775807", "1000000000", "1e300"] {
let (mut f, mut filter) = marked(&rect, &rect, &rect);
for t in filter.tags.iter_mut().filter(|t| t.code == 70) {
t.value = count.into();
}
f.objects.retain(|o| o.handle != filter.handle);
f.objects.push(filter.clone());
assert!(roundtrip(&f, &index(&f), &filter).is_none(), "{count}");
assert!(parse_filter(&filter, None).is_none(), "{count}");
}
}
}
-17
View File
@@ -881,23 +881,6 @@ pub(crate) fn parse_constraints(text: &str) -> Option<(Vec<Constraint>, Parametr
Some((p.constraints, p.parametric))
}
/// A view centre as a file stores it (VIEWPORT and VPORT group 12, VIEW group 10), as the
/// drawing point at the middle of the view. The stored centre is in display coordinates: their
/// origin is the view's `target` (VIEWPORT and VPORT group 17, VIEW group 12) and their axes are
/// turned by the view's `twist` (radians). Turning back keeps a twisted view on its part of the
/// drawing however far that is from the origin; a non-finite result keeps the stored centre.
pub(crate) fn dcs_center_to_model(c: cadcraft_geom::Vec2, twist: f64, target: cadcraft_geom::Vec2) -> cadcraft_geom::Vec2 {
let m = c.rotate(-twist) + if target.is_finite() { target } else { cadcraft_geom::Vec2::ZERO };
if m.is_finite() { m } else { c }
}
/// The display-coordinate view centre a file stores for the drawing point `m` at the middle of
/// a view with `twist`, its target at the origin (the inverse of [`dcs_center_to_model`]).
pub(crate) fn model_center_to_dcs(m: cadcraft_geom::Vec2, twist: f64) -> cadcraft_geom::Vec2 {
let c = m.rotate(twist);
if c.is_finite() { c } else { m }
}
#[cfg(test)]
mod tests {
use super::*;
-1
View File
@@ -325,7 +325,6 @@ pub(crate) fn read(tags: &[Tag]) -> MLeader {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}
});
MLeader {
-1
View File
@@ -139,7 +139,6 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
d.layers.insert(0, Layer::default());
}
crate::dxf_raw::read(&secs, &mut d);
crate::dxf_clip::read(&mut d);
d.skipped_records = skipped;
Ok(d)
}
+4 -2
View File
@@ -23,8 +23,10 @@ impl T<'_> {
/// origin is the view target (`target`), so the point in the middle of a plan view is their
/// sum: a file whose view centre is 0,0 keeps the location in the target (#2492). A sum that
/// overflows leaves the stored centre.
pub(super) fn view_center(&self, center: i32, target: i32, twist: f64) -> Vec2 {
crate::dxf_ext::dcs_center_to_model(self.p(center).xy(), twist, self.p(target).xy())
pub(super) fn view_center(&self, center: i32, target: i32) -> Vec2 {
let c = self.p(center).xy();
let sum = c + self.p(target).xy();
if sum.is_finite() { sum } else { c }
}
pub(super) fn all_f(&self, code: i32) -> Vec<f64> {
self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect()
+14 -20
View File
@@ -167,7 +167,6 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef
rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32,
col_spacing,
row_spacing: t.fd(45, 0.0),
clip: None,
})
}
"DIMENSION" => {
@@ -244,25 +243,20 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef
k
}
"ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)),
"VIEWPORT" => EntityKind::Viewport({
// View twist (group 51, degrees).
let twist = t.f(51).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians()));
Viewport {
center: t.p(10),
width: t.fd(40, 1.0),
height: t.fd(41, 1.0),
// 12/22 is in display coordinates, whose origin is the view target 17/27/37.
view_center: t.view_center(12, 17, twist),
view_height: t.fd(45, 1.0),
id: t.i(69).unwrap_or(0) as u32,
locked: t.i(90).unwrap_or(0) & 16384 != 0,
frozen_layers: crate::dxf_ext::read_frozen(tags),
layer_colors: crate::dxf_ext::read_layer_colors(tags),
// Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off.
clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0),
on: t.i(90).unwrap_or(0) & 131072 == 0,
twist,
}
"VIEWPORT" => EntityKind::Viewport(Viewport {
center: t.p(10),
width: t.fd(40, 1.0),
height: t.fd(41, 1.0),
// 12/22 is in display coordinates, whose origin is the view target 17/27/37.
view_center: t.view_center(12, 17),
view_height: t.fd(45, 1.0),
id: t.i(69).unwrap_or(0) as u32,
locked: t.i(90).unwrap_or(0) & 16384 != 0,
frozen_layers: crate::dxf_ext::read_frozen(tags),
layer_colors: crate::dxf_ext::read_layer_colors(tags),
// Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off.
clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0),
on: t.i(90).unwrap_or(0) & 131072 == 0,
}),
"WIPEOUT" => {
let o = t.p(10);
+6 -9
View File
@@ -224,9 +224,8 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS {
continue;
}
let twist = t.f(50).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians()));
// The view centre 10/20 is in display coordinates, whose origin is the target 12/22/32.
let center = t.view_center(10, 12, twist);
let center = t.view_center(10, 12);
if !(center.x.is_finite() && center.y.is_finite()) {
continue;
}
@@ -236,6 +235,7 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
};
// 70 bit 1: a paper space view; its layout is CADCraft xdata.
let layout = (t.i(70).unwrap_or(0) & 1 != 0).then(|| field("LAYOUT").unwrap_or_default());
let twist = t.f(50).filter(|a| a.is_finite()).map_or(0.0, |a| a.rem_euclid(360.0).to_radians());
let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state: field("LAYERSTATE"), twist, layout };
match d.views.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = v,
@@ -286,9 +286,8 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
_ => {}
}
}
// One *Active viewport is no tiling: the view is the editor's (it zooms to the extents),
// kept only for its view twist (see `cadcraft_engine::DocState`).
d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active()) || c.tiles.first().is_some_and(|t| t.twist != 0.0));
// One *Active viewport is no tiling: the view is the editor's (it zooms to the extents).
d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active()));
}
fn index_table_handles(recs: &[(String, Vec<Tag>)], rx: &mut Rx) {
@@ -325,10 +324,8 @@ const MAX_VPORT_TILES: usize = 64;
/// are fractions of the drawing area; unusable tiles are skipped.
fn vport_record(d: &mut Drawing, t: &T) {
let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default();
// View twist (group 51, degrees).
let twist = t.f(51).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians()));
// The view centre 12/22 is in display coordinates, whose origin is the view target 17/27/37.
let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17, twist));
let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17));
let height = t.fd(40, 0.0);
let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v);
let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit)
@@ -342,7 +339,7 @@ fn vport_record(d: &mut Drawing, t: &T) {
}
let aspect = t.f(41).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(0.0);
let clamp = |p: Vec2| Vec2::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0));
let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect, twist };
let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect };
let name = if name.eq_ignore_ascii_case(VportConfig::ACTIVE) { VportConfig::ACTIVE.to_string() } else { name };
let room = d.vports.len() < MAX_OBJECTS;
match d.vports.iter_mut().find(|c| c.name.eq_ignore_ascii_case(&name)) {

Some files were not shown because too many files have changed in this diff Show More