Compare commits

..
121 changed files with 365 additions and 7058 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())
});
+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.
-5
View File
@@ -16,7 +16,6 @@ mod header;
pub mod library;
mod plotstyle;
mod plotter;
mod raster;
mod refedit;
mod store;
mod tables;
@@ -34,7 +33,6 @@ 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::*;
@@ -138,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 {
@@ -200,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
+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 -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 -4
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()))
})
-22
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))
}
+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)
+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(())
}
-440
View File
@@ -1,440 +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,
};
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`].
+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(())
}
}
+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));
-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",
+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.
-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);
}
+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();
@@ -27,7 +27,6 @@ fn drawing() -> (Drawing, Space) {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
}),
)
.unwrap();
+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]
+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]
-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);
}
-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
@@ -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).
-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::*;
+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 -19
View File
@@ -243,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)) {
+1 -8
View File
@@ -684,15 +684,8 @@ fn write_viewport_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper:
// Status 68: a positive value is "on" (the stacking order; 1 is the active viewport), 0 off.
w.i(68, if v.on { i64::from(v.id.clamp(1, 32767)) } else { 0 });
w.i(69, i64::from(v.id));
// Within one turn, so a hostile twist (from JSON or MCP) writes a sane angle.
let twist = cadcraft_geom::norm_angle(v.view_twist());
// The view centre in display coordinates (turned by the twist).
w.p2(12, dxf_ext::model_center_to_dcs(v.view_center, twist));
w.p2(12, v.view_center);
w.f(45, v.view_height);
// View twist (degrees), when the viewport has one.
if twist != 0.0 {
w.f(51, twist.to_degrees());
}
// Status flags: 16384 = display locked, 32768 = always set, 65536 = clipped by the
// 340 entity, 131072 = off.
let clip = v.clip.filter(|h| *h != e.handle && d.entity(*h).is_some());
+7 -20
View File
@@ -72,24 +72,13 @@ pub(super) fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &C
/// The VPORT table: the `*Active` model space tiles (one viewport on the extents when there is no
/// tiled configuration) and the saved configurations, each tile a record, the current one first.
pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) {
// An untiled view's twist: the lone `*Active` tile the editor keeps for it.
let twist = d
.vports
.iter()
.find(|c| c.is_active() && c.tiles.len() == 1)
.and_then(|c| c.tiles.first())
.map_or(0.0, |t| cadcraft_geom::norm_angle(t.twist));
// The extents fitted as the turned view sees them.
let (center, height) = if ext.is_empty() {
(Vec2::new(6.0, 4.5), 9.0)
} else if twist == 0.0 {
(ext.center(), (ext.height() * 1.1).max(1e-6))
} else {
let r = Bounds2::from_points(ext.corners().map(|p| p.rotate(twist)));
let height = r.height() * 1.1;
(r.center().rotate(-twist), if height.is_finite() { height.max(1e-6) } else { 9.0 })
let single = VportTile {
min: Vec2::ZERO,
max: Vec2::new(1.0, 1.0),
center: if ext.is_empty() { Vec2::new(6.0, 4.5) } else { ext.center() },
height: if ext.is_empty() { 9.0 } else { (ext.height() * 1.1).max(1e-6) },
aspect: 1.6,
};
let single = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center, height, aspect: 1.6, twist };
let tiled = d.vports.iter().find(|c| c.is_active() && c.tiles.len() > 1);
let mut records: Vec<(&str, &VportTile)> = match tiled {
Some(c) => c.tiles.iter().map(|t| (VportConfig::ACTIVE, t)).collect(),
@@ -104,8 +93,7 @@ pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) {
w.i(70, 0);
w.p2(10, finite(t.min, Vec2::ZERO));
w.p2(11, finite(t.max, Vec2::new(1.0, 1.0)));
let twist = cadcraft_geom::norm_angle(t.twist);
w.p2(12, dxf_ext::model_center_to_dcs(finite(t.center, Vec2::ZERO), twist));
w.p2(12, finite(t.center, Vec2::ZERO));
// Snap grid origin and rotation (SNAPBASE, SNAPANG in degrees): R13+ files keep them here.
w.p2(13, d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO));
w.p2(14, d.header.snap_unit());
@@ -116,7 +104,6 @@ pub(super) fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) {
w.f(41, if t.aspect.is_finite() && t.aspect > 0.0 { t.aspect } else { 1.0 });
w.f(42, 50.0);
w.f(50, d.header.f64("SNAPANG", 0.0));
w.f(51, twist.to_degrees());
}
w.s(0, "ENDTAB");
}
+2 -3
View File
@@ -136,15 +136,14 @@ fn write_tables(w: &mut W, plan: TableWritePlan<'_>) -> HashMap<String, String>
w.s(2, &v.name);
w.i(70, i64::from(v.layout.is_some()));
w.f(40, v.height);
let twist = cadcraft_geom::norm_angle(v.twist);
w.p2(10, dxf_ext::model_center_to_dcs(v.center, twist));
w.p2(10, v.center);
w.f(41, v.width);
w.p(11, Vec3::Z);
w.p(12, Vec3::ZERO);
w.f(42, 50.0);
w.f(43, 0.0);
w.f(44, 0.0);
w.f(50, twist.to_degrees());
w.f(50, if v.twist.is_finite() { v.twist.to_degrees() } else { 0.0 });
w.i(71, 0);
w.i(281, 0);
w.i(72, 0);
-2
View File
@@ -14,8 +14,6 @@ mod dxf_write;
mod dxf_xref;
pub mod pdf;
pub mod plotter;
/// Raster image files (PNG, JPEG, BMP, TIFF) decoded to pixels.
pub mod raster;
/// Open → save → reopen comparison and the real-file corpus harness (gaps FF1, FF5).
pub mod roundtrip;
pub mod svg;
-2
View File
@@ -873,8 +873,6 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts:
}
}
Kind::Point => point(s, &mut batch, &p.map, raw, m),
// Raster images are not written to PDF yet: their frame is.
Kind::Image { .. } => {}
}
}
flush(s, &mut batch);
-79
View File
@@ -1,79 +0,0 @@
//! Raster image files decoded to pixels (IMAGEATTACH): PNG, JPEG, BMP and TIFF, the format taken
//! from the content. Files are hostile input: the size is read from the header and checked
//! before any pixel is decoded.
use std::io::Cursor;
use cadcraft_doc::Raster;
use crate::{IoError, Result};
/// Largest image file read (bytes).
pub const MAX_IMAGE_BYTES: usize = 256 << 20;
/// Most pixels decoded per image (four bytes each in memory).
pub const MAX_IMAGE_PIXELS: u64 = 128_000_000;
fn bad(e: impl std::fmt::Display) -> IoError {
IoError::Format(format!("image: {e}"))
}
/// A reader over an image file, once it is small enough and of a format we read.
fn reader(bytes: &[u8]) -> Result<image::ImageReader<Cursor<&[u8]>>> {
if bytes.len() > MAX_IMAGE_BYTES {
return Err(bad(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20)));
}
// The format comes from the file's first bytes, not from its name.
let file = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(bad)?;
if file.format().is_none() {
return Err(bad("not a PNG, JPEG, BMP or TIFF file"));
}
Ok(file)
}
/// The width and height in pixels of the image in `bytes`, read from its header.
pub fn probe(bytes: &[u8]) -> Result<(u32, u32)> {
let (width, height) = reader(bytes)?.into_dimensions().map_err(bad)?;
if width == 0 || height == 0 {
return Err(bad("the image is empty"));
}
if u64::from(width).saturating_mul(u64::from(height)) > MAX_IMAGE_PIXELS {
return Err(bad(format!("{width} x {height} pixels is more than {} megapixels", MAX_IMAGE_PIXELS / 1_000_000)));
}
Ok((width, height))
}
/// Decode the image in `bytes`.
pub fn decode(bytes: &[u8]) -> Result<Raster> {
// The size is checked first, from the header alone.
let (width, height) = probe(bytes)?;
let mut file = reader(bytes)?;
file.limits(decode_limits(width, height));
let pixels = file.decode().map_err(bad)?.into_rgba8();
Raster::new(pixels.width(), pixels.height(), pixels.into_raw()).ok_or_else(|| bad("the image is empty"))
}
/// What the decoder may allocate for an image of this size: the pixels themselves (up to 16
/// bytes each before they become 8-bit RGBA) and its own working buffers.
fn decode_limits(width: u32, height: u32) -> image::Limits {
let pixels = u64::from(width).saturating_mul(u64::from(height));
let mut limits = image::Limits::default();
limits.max_image_width = Some(width);
limits.max_image_height = Some(height);
limits.max_alloc = Some(pixels.saturating_mul(16).saturating_add(64 << 20));
limits
}
/// `raster` as a PNG file.
pub fn encode_png(raster: &Raster) -> Result<Vec<u8>> {
let mut out = Cursor::new(Vec::new());
image::write_buffer_with_format(
&mut out,
raster.rgba(),
raster.width(),
raster.height(),
image::ExtendedColorType::Rgba8,
image::ImageFormat::Png,
)
.map_err(bad)?;
Ok(out.into_inner())
}
-2
View File
@@ -96,8 +96,6 @@ pub fn export_list(list: &cadcraft_render::DisplayList, window: Option<Bounds2>)
}
s.push_str(r#""/>"#);
}
// Raster images are not written to SVG yet: their frame is.
Kind::Image { .. } => {}
Kind::Point => {
if let Some(q) = pts.first() {
let _ = write!(s, r#"<circle cx="{:.dp$}" cy="{:.dp$}" r="{:.dp$}" fill="{c}"{op}/>"#, tx(q.x), ty(q.y), stroke);
-4
View File
@@ -540,7 +540,6 @@ fn pdf_layout_one_to_one_with_viewport() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
}),
)
.unwrap();
@@ -647,7 +646,6 @@ fn viewport_frozen_layers_roundtrip() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
})
};
d.add(&paper, Common::default(), vp(2, vec!["Walls".into(), "A B".into()])).unwrap();
@@ -690,7 +688,6 @@ fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
});
d.add(&paper, Common::default(), vp).unwrap();
let text = dxf_2018(&d);
@@ -833,7 +830,6 @@ fn viewport_layer_colors_roundtrip() {
layer_colors: colors,
clip: None,
on: true,
twist: 0.0,
})
};
let colors = vec![("Walls".to_string(), Color::Index(5)), ("A B".to_string(), Color::True(cadcraft_color::Rgb(255, 128, 0)))];
-1
View File
@@ -117,7 +117,6 @@ fn an_empty_followup_viewport_does_not_mark_an_exact_budget_truncated() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
let paper = Space::Paper("Layout1".into());
d.add(&paper, Common::default(), EntityKind::Viewport(viewport(Vec3::new(50.0, 50.0, 0.0), Vec2::ZERO, vec!["FROZEN".into()]))).unwrap();
@@ -27,7 +27,6 @@ fn viewport(id: u32) -> EntityKind {
layer_colors: vec![],
clip: None,
on: true,
twist: 0.0,
})
}
@@ -183,67 +182,3 @@ fn the_added_paper_viewport_follows_the_plot_scale() {
assert!((sheet_width(49.6, true) - 420.0).abs() < 1e-9, "scaled to fit: the scale means nothing");
assert!((sheet_width(f64::NAN, false) - 420.0).abs() < 1e-9, "an unusable scale is 1");
}
/// A layout viewport's view twist (group 51, #1316) is read and written, so DXF and DWG keep it;
/// a hostile angle reads as no twist and never panics.
#[test]
fn viewport_twist_survives_dxf_and_dwg() {
let mut d = Drawing::new_metric();
let name = d.layouts[0].name.clone();
let space = Space::Paper(name.clone());
let twist = 30f64.to_radians();
let EntityKind::Viewport(mut vp) = viewport(2) else { unreachable!() };
vp.twist = twist;
let h = d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap();
let twist_of = |d: &Drawing| {
let l = d.layouts.iter().find(|l| l.name == name).unwrap();
l.entities
.iter()
.find_map(|e| {
if let EntityKind::Viewport(v) = &e.kind
&& v.id == 2
{
Some(v.twist)
} else {
None
}
})
.unwrap()
};
let dxf = cadcraft_io::write(&d, "x.dxf").unwrap();
let text = String::from_utf8_lossy(&dxf).to_string();
let rec = records(&text, "VIEWPORT").into_iter().find(|r| group(r, "5") == Some(h.hex().as_str())).unwrap();
assert_eq!(group(&rec, "51").and_then(|v| v.parse::<f64>().ok()).map(f64::round), Some(30.0), "{rec:?}");
assert!((twist_of(&cadcraft_io::read(&dxf, "x.dxf").unwrap()) - twist).abs() < 1e-9);
assert!((twist_of(&through_dwg(&d)) - twist).abs() < 1e-9);
// An untwisted viewport writes no group 51; a hostile twist (from JSON or MCP) writes a finite
// angle within one turn, or none.
for (value, want) in [(0.0, None), (f64::NAN, None), (f64::INFINITY, None), (1e300, Some(()))] {
let mut d = Drawing::new_metric();
let EntityKind::Viewport(mut vp) = viewport(2) else { unreachable!() };
vp.twist = value;
let h = d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap();
let text = String::from_utf8_lossy(&cadcraft_io::write(&d, "x.dxf").unwrap()).to_string();
let rec = records(&text, "VIEWPORT").into_iter().find(|r| group(r, "5") == Some(h.hex().as_str())).unwrap();
let written = group(&rec, "51").map(|v| v.parse::<f64>().unwrap());
assert_eq!(written.map(|_| ()), want, "{value}: {rec:?}");
assert!(written.is_none_or(|a| a.is_finite() && (0.0..360.0).contains(&a)), "{value}: {written:?}");
}
// Hostile angles: not a number reads as 0, a huge one is brought into one turn.
for (value, want) in
[("nan", Some(0.0)), ("1e400", Some(0.0)), ("-1e-20", Some(0.0)), ("-330", Some(30f64.to_radians())), ("7200000000000000000030", None)]
{
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
let at = lines.windows(2).position(|w| w[0].trim() == "51" && Some(w[1].trim()) == group(&rec, "51")).unwrap();
lines[at + 1] = value.to_string();
let bad = lines.join("\n");
let back = twist_of(&cadcraft_io::read(bad.as_bytes(), "x.dxf").unwrap());
assert!(back.is_finite() && (0.0..std::f64::consts::TAU).contains(&back), "{value}: {back}");
if let Some(w) = want {
assert!((back - w).abs() < 1e-9, "{value}: {back}");
}
}
}
-1
View File
@@ -18,7 +18,6 @@ fn viewport(id: u32, frozen_layers: Vec<String>, layer_colors: Vec<(String, Colo
layer_colors,
clip: None,
on: true,
twist: 0.0,
})
}
-1
View File
@@ -149,7 +149,6 @@ fn viewport_contents_keep_their_layers() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
d.add(&paper, Common { layer: "Viewports".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap();
add_line(&mut d, &paper, "Title", (10.0, 10.0), (50.0, 10.0));
+2 -2
View File
@@ -145,8 +145,8 @@ pub(super) fn vports() -> Case {
drawing.vports.push(VportConfig {
name: "Split".into(),
tiles: vec![
VportTile { min: Vec2::ZERO, max: Vec2::new(0.5, 1.0), center: Vec2::new(2.0, 3.0), height: 20.0, aspect: 0.75, twist: 0.0 },
VportTile { min: Vec2::new(0.5, 0.0), max: Vec2::new(1.0, 1.0), center: Vec2::new(7.0, 8.0), height: 30.0, aspect: 0.75, twist: 0.0 },
VportTile { min: Vec2::ZERO, max: Vec2::new(0.5, 1.0), center: Vec2::new(2.0, 3.0), height: 20.0, aspect: 0.75 },
VportTile { min: Vec2::new(0.5, 0.0), max: Vec2::new(1.0, 1.0), center: Vec2::new(7.0, 8.0), height: 30.0, aspect: 0.75 },
],
});
Case { name: "drawing/tiled-viewports".into(), drawing }
+5 -4
View File
@@ -29,9 +29,10 @@ fn model_and_paper_views_round_trip() {
let r = round_trip(&d);
assert_eq!(r.views.len(), 3);
for (a, b) in d.views.iter().zip(&r.views) {
assert_eq!((&a.name, a.height, a.width, &a.layer_state, &a.layout), (&b.name, b.height, b.width, &b.layer_state, &b.layout));
// A twisted view's centre is stored turned by the twist, and turned back on reading.
assert!((a.center - b.center).len() < 1e-9, "{}: {:?} vs {:?}", a.name, a.center, b.center);
assert_eq!(
(&a.name, a.center, a.height, a.width, &a.layer_state, &a.layout),
(&b.name, b.center, b.height, b.width, &b.layer_state, &b.layout)
);
assert!((a.twist - b.twist).abs() < 1e-12, "{}: {} vs {}", a.name, a.twist, b.twist);
}
}
@@ -74,7 +75,7 @@ fn foreign_paper_view_and_hostile_twist() {
}
fn tile(min: (f64, f64), max: (f64, f64), center: (f64, f64), height: f64) -> VportTile {
VportTile { min: Vec2::new(min.0, min.1), max: Vec2::new(max.0, max.1), center: Vec2::new(center.0, center.1), height, aspect: 0.75, twist: 0.0 }
VportTile { min: Vec2::new(min.0, min.1), max: Vec2::new(max.0, max.1), center: Vec2::new(center.0, center.1), height, aspect: 0.75 }
}
#[test]
-1
View File
@@ -335,7 +335,6 @@ fn text_seen_through_a_viewport_and_on_layers() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
text(&mut d, &paper, "Real", (20.0, 20.0), "B", (0.0, 1.0, 0.0));
-172
View File
@@ -1,172 +0,0 @@
//! Raster image files decoded to pixels (IMAGEATTACH): round trip through PNG, and hostile files
//! (truncated, random, absurd sizes) refused without a panic.
use cadcraft_doc::Raster;
use cadcraft_io::raster::{MAX_IMAGE_PIXELS, decode, encode_png, probe};
/// A 3 x 2 image with a different colour per pixel, one of them half transparent.
fn sample() -> Raster {
let px: [[u8; 4]; 6] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 0, 255], [0, 255, 255, 128], [10, 20, 30, 255]];
Raster::new(3, 2, px.concat()).unwrap()
}
#[test]
fn png_round_trips_pixel_for_pixel() {
let r = sample();
let png = encode_png(&r).unwrap();
assert_eq!(probe(&png).unwrap(), (3, 2));
let back = decode(&png).unwrap();
assert_eq!(back, r);
assert!(!back.opaque());
assert_eq!(back.pixel(1, 1), Some([0, 255, 255, 128]));
assert_eq!(back.pixel(3, 0), None);
}
#[test]
fn hostile_image_does_not_panic() {
let png = encode_png(&sample()).unwrap();
// Empty, random, not an image, and every truncation of a real file.
for bytes in [&b""[..], &[0u8; 64], b"0\nSECTION\n2\nHEADER\n", &[0xff; 4096]] {
assert!(decode(bytes).is_err());
assert!(probe(bytes).is_err());
}
for n in 0..png.len() {
let _ = probe(&png[..n]);
let _ = decode(&png[..n]);
}
assert!(decode(&png[..png.len() / 2]).is_err());
// Every byte flipped in turn: an error or an image, never a panic.
for i in 0..png.len() {
let mut b = png.clone();
b[i] ^= 0xff;
let _ = decode(&b);
}
}
#[test]
fn an_absurd_declared_size_is_refused_before_decoding() {
// A PNG declaring 60000 x 60000 pixels (3.6 gigapixels) with no pixel data.
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
let mut ihdr = b"IHDR".to_vec();
ihdr.extend(60000u32.to_be_bytes());
ihdr.extend(60000u32.to_be_bytes());
ihdr.extend([8, 6, 0, 0, 0]);
for chunk in [&ihdr[..], b"IDAT", b"IEND"] {
png.extend(u32::try_from(chunk.len() - 4).unwrap().to_be_bytes());
png.extend(chunk);
png.extend(crc32(chunk).to_be_bytes());
}
assert!(u64::from(60000u32) * 60000 > MAX_IMAGE_PIXELS);
let e = decode(&png).unwrap_err().to_string();
assert!(e.contains("megapixels"), "{e}");
}
fn crc32(data: &[u8]) -> u32 {
let mut c = !0u32;
for b in data {
c ^= u32::from(*b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
#[test]
fn raster_new_checks_the_buffer() {
assert!(Raster::new(0, 4, Vec::new()).is_none());
assert!(Raster::new(2, 2, vec![0; 15]).is_none());
assert!(Raster::new(u32::MAX, u32::MAX, vec![0; 16]).is_none());
assert!(Raster::new(2, 2, vec![255; 16]).unwrap().opaque());
}
/// `r` written in `format` by the image library.
fn encoded(r: &Raster, format: image::ImageFormat, color: image::ExtendedColorType, bytes: &[u8]) -> Vec<u8> {
let mut out = std::io::Cursor::new(Vec::new());
image::write_buffer_with_format(&mut out, bytes, r.width(), r.height(), color, format).unwrap();
out.into_inner()
}
#[test]
fn bmp_tiff_and_jpeg_files_are_read() {
// Opaque colours in 8 x 8 blocks (JPEG works in blocks of that size).
let (w, h) = (16u32, 8u32);
let rgb: Vec<u8> = (0..h).flat_map(|_| (0..w).flat_map(|x| if x < 8 { [200u8, 30, 30] } else { [30, 30, 200] })).collect();
let rgba: Vec<u8> = rgb.chunks(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect();
let r = Raster::new(w, h, rgba).unwrap();
for format in [image::ImageFormat::Bmp, image::ImageFormat::Tiff] {
let file = encoded(&r, format, image::ExtendedColorType::Rgb8, &rgb);
assert_eq!(probe(&file).unwrap(), (w, h), "{format:?}");
let back = decode(&file).unwrap();
assert_eq!(back, r, "{format:?} keeps every pixel");
assert!(back.opaque());
}
// JPEG loses a little: the colours are close, the size exact.
let file = encoded(&r, image::ImageFormat::Jpeg, image::ExtendedColorType::Rgb8, &rgb);
let back = decode(&file).unwrap();
assert_eq!((back.width(), back.height()), (w, h));
for (x, want) in [(2, [200u8, 30, 30]), (13, [30, 30, 200])] {
let got = back.pixel(x, 4).unwrap();
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 12) && got[3] == 255, "{got:?} at {x}");
}
// Grey and grey-with-alpha files become RGBA.
let grey = Raster::new(2, 1, vec![0, 0, 0, 255, 255, 255, 255, 255]).unwrap();
assert_eq!(decode(&encoded(&grey, image::ImageFormat::Png, image::ExtendedColorType::L8, &[0, 255])).unwrap(), grey);
let grey_alpha = Raster::new(2, 1, vec![9, 9, 9, 0, 255, 255, 255, 128]).unwrap();
assert_eq!(decode(&encoded(&grey_alpha, image::ImageFormat::Png, image::ExtendedColorType::La8, &[9, 0, 255, 128])).unwrap(), grey_alpha);
}
#[test]
fn damaged_files_of_every_format_do_not_panic() {
let (w, h) = (16u32, 8u32);
let rgb = vec![120u8; (w * h * 3) as usize];
let r = Raster::new(w, h, vec![255; (w * h * 4) as usize]).unwrap();
for format in [image::ImageFormat::Bmp, image::ImageFormat::Tiff, image::ImageFormat::Jpeg] {
let file = encoded(&r, format, image::ExtendedColorType::Rgb8, &rgb);
for n in 0..file.len() {
let _ = probe(&file[..n]);
let _ = decode(&file[..n]);
}
for i in 0..file.len() {
let mut b = file.clone();
b[i] ^= 0xff;
let _ = decode(&b);
b[i] = 0;
let _ = decode(&b);
}
}
}
#[test]
fn the_pixels_never_reach_the_drawing_file() {
use cadcraft_doc::{Common, Drawing, EntityKind, Image, Space};
use cadcraft_geom::{Vec2, Vec3};
let mut d = Drawing::new_metric();
let i = Image {
insert: Vec3::new(3.0, 4.0, 0.0),
u: Vec3::new(0.5, 0.0, 0.0),
v: Vec3::new(0.0, 0.5, 0.0),
size: Vec2::new(3.0, 2.0),
path: "maps/site.png".into(),
name: "site".into(),
..Image::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Image(i.clone())).unwrap();
d.rasters.insert("maps/site.png", std::sync::Arc::new(sample()));
let text = cadcraft_io::write_dxf(&d);
assert!(text.contains("maps/site.png"));
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
assert!(back.rasters.is_empty(), "a drawing just read has no pixels");
let got: Vec<&Image> = back.model.iter().filter_map(|e| if let EntityKind::Image(i) = &e.kind { Some(i) } else { None }).collect();
// An unclipped image is written with the boundary of the whole image, switched off.
let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(2.5, 1.5)], ..i };
assert_eq!(got, [&i]);
// Loaded pixels don't make two drawings differ.
let mut bare = d.clone();
bare.rasters.remove("maps/site.png");
assert!(bare.rasters.is_empty() && d.rasters.len() == 1);
assert_eq!(bare, d);
}
-222
View File
@@ -1,222 +0,0 @@
//! Raster image files CADCraft did not write itself (#956): files made by another program, and
//! files laid out by hand from the formats' public descriptions, so reading is not only checked
//! against the library that also does the writing.
//!
//! The four embedded files hold our own synthetic picture (16 x 8 pixels: the left half
//! (200, 30, 30), the right half (30, 30, 200)), converted from a hand-built PNG with the macOS
//! command `sips` (`-s format jpeg|tiff|bmp|png`, colour profile removed). They contain nothing
//! but those pixels and the encoder's headers.
use cadcraft_doc::Raster;
use cadcraft_io::raster::{decode, probe};
/// JPEG, baseline, quality 90, with JFIF and Exif headers.
const SIPS_JPEG: &str = "ffd8ffe000104a46494600010100004800480000ffe1004c4578696600004d4d002a00000008000187690004000000010000001a000000000003a001 \
000300000001ffff0000a00200040000000100000010a0030004000000010000000800000000ffed003850686f746f73686f7020332e30003842494d \
04040000000000003842494d0425000000000010d41d8cd98f00b204e9800998ecf8427effc00011080008001003012200021101031101ffc4001f00 \
00010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d0102030004110512213141 \
0613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758 \
595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8 \
c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f01000301010101010101010100000000000001020304050607 \
08090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272 \
d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a8283848586 \
8788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3 \
f4f5f6f7f8f9faffdb004300020202020202030202030503030305060505050506080606060606080a0808080808080a0a0a0a0a0a0a0a0c0c0c0c0c \
0c0e0e0e0e0e0f0f0f0f0f0f0f0f0f0fffdb00430102030304040407040407100b090b10101010101010101010101010101010101010101010101010 \
10101010101010101010101010101010101010101010101010ffdd00040001ffda000c03010002110311003f00f90ebe7fafa02be7fafdc7e8f3ff00 \
331ffb87ff00b79fd1de3aff00cc07fdbfff00b69fffd9";
/// TIFF, big-endian, LZW compressed.
const SIPS_TIFF_LZW: &str = "4d4d002a000000680002a00200040000000100000010a0030004000000010000000800000000803203c1e004160d0784424ac00554261d0f814121f1 \
38342e1b148a44631148b46e271a8f43a3b2184c82490791c9e0d2695002532d964aa5f2a98c9e672780800000100100000300000001001000000101 \
0003000000010008000001020003000000030000012e010300030000000100050000010600030000000100020000010a000300000001000100000111 \
00040000000100000026011200030000000100010000011500030000000100030000011600030000000100080000011700040000000100000041011c \
00030000000100010000012800030000000100020000013d000300000001000200000153000300000003000001348769000400000001000000080000 \
0000000800080008000100010001";
/// TIFF, big-endian, not compressed.
const SIPS_TIFF_PLAIN: &str = "4d4d002a000001a60002a00200040000000100000010a0030004000000010000000800000000c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \
1e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e \
1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e \
1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec8 \
1e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \
1e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e \
1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e \
1ec8000f0100000300000001001000000101000300000001000800000102000300000003000002600103000300000001000100000106000300000001 \
00020000010a000300000001000100000111000400000001000000260112000300000001000100000115000300000001000300000116000300000001 \
00080000011700040000000100000180011c000300000001000100000128000300000001000200000153000300000003000002668769000400000001 \
0000000800000000000800080008000100010001";
/// BMP, 24 bits, rows stored top-down (negative height).
const SIPS_BMP: &str = "424db601000000000000360000002800000010000000f8ffffff010018000000000080010000000000000000000000000000000000001e1ec81e1ec8 \
1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \
1e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1e \
c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e \
c81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec8 \
1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \
1e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1e \
c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e";
/// PNG as that program writes it (its own chunks and compression).
const SIPS_PNG: &str = "89504e470d0a1a0a0000000d49484452000000100000000808020000007f14e8c00000000467414d410000b18f0bfc6105000000206348524d00007a \
26000080840000fa00000080e8000075300000ea6000003a98000017709cba513c00000038655849664d4d002a000000080001876900040000000100 \
00001a000000000002a00200040000000100000010a0030004000000010000000800000000470d8f8b000001c869545874584d4c3a636f6d2e61646f \
62652e786d7000000000003c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f2220783a786d70746b3d22584d5020 \
436f726520362e302e30223e0a2020203c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30 \
322f32322d7264662d73796e7461782d6e7323223e0a2020202020203c7264663a4465736372697074696f6e207264663a61626f75743d22220a2020 \
20202020202020202020786d6c6e733a657869663d22687474703a2f2f6e732e61646f62652e636f6d2f657869662f312e302f223e0a202020202020 \
2020203c657869663a436f6c6f7253706163653e313c2f657869663a436f6c6f7253706163653e0a2020202020202020203c657869663a506978656c \
5844696d656e73696f6e3e31363c2f657869663a506978656c5844696d656e73696f6e3e0a2020202020202020203c657869663a506978656c594469 \
6d656e73696f6e3e383c2f657869663a506978656c5944696d656e73696f6e3e0a2020202020203c2f7264663a4465736372697074696f6e3e0a2020 \
203c2f7264663a5244463e0a3c2f783a786d706d6574613e0ae6ba92b400000021494441541819633c2127c7800d8431acc226ccc08455148fe0a806 \
3c8103970200086302148ee3f7130000000049454e44ae426082";
fn bytes(hex: &str) -> Vec<u8> {
let digits: Vec<u8> = hex.bytes().filter(u8::is_ascii_hexdigit).collect();
digits.chunks(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()).collect()
}
const LEFT: [u8; 3] = [200, 30, 30];
const RIGHT: [u8; 3] = [30, 30, 200];
/// The picture is the two-colour 16 x 8 one, each colour within `tolerance`.
#[track_caller]
fn is_the_picture(r: &Raster, tolerance: u8, what: &str) {
assert_eq!((r.width(), r.height()), (16, 8), "{what}");
assert!(r.opaque(), "{what}");
for y in 0..8 {
for x in 0..16 {
// A lossy file blends the columns around the colour change (JPEG stores colour at
// half resolution).
if tolerance > 3 && (6..=9).contains(&x) {
continue;
}
let want = if x < 8 { LEFT } else { RIGHT };
let got = r.pixel(x, y).unwrap();
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= tolerance), "{what}: {got:?} at {x},{y}, wanted {want:?}");
}
}
}
#[test]
fn files_written_by_another_program_are_read() {
for (hex, tolerance, what) in [
(SIPS_PNG, 3, "PNG"),
(SIPS_BMP, 3, "top-down BMP"),
(SIPS_TIFF_LZW, 3, "LZW TIFF"),
(SIPS_TIFF_PLAIN, 3, "plain TIFF"),
(SIPS_JPEG, 14, "JPEG"),
] {
let file = bytes(hex);
assert_eq!(probe(&file).unwrap(), (16, 8), "{what}");
is_the_picture(&decode(&file).unwrap(), tolerance, what);
// Cut short or damaged, they are refused or read: never a panic.
for n in 0..file.len() {
let _ = decode(&file[..n]);
}
for i in 0..file.len() {
let mut b = file.clone();
b[i] ^= 0x55;
let _ = decode(&b);
}
}
}
/// An uncompressed RGB TIFF of one strip, laid out by hand: header, pixels, then the directory.
fn tiff_by_hand(big_endian: bool, w: u32, h: u32, rgb: &[u8]) -> Vec<u8> {
let u16b = |v: u16| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() };
let u32b = |v: u32| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() };
let mut f: Vec<u8> = if big_endian { b"MM".to_vec() } else { b"II".to_vec() };
f.extend(u16b(42));
let pixels_at = 8u32;
let bits_at = pixels_at + rgb.len() as u32;
let dir_at = bits_at + 6;
f.extend(u32b(dir_at));
f.extend(rgb);
for _ in 0..3 {
f.extend(u16b(8));
}
// (tag, type: 3 short / 4 long, count, value)
let entries: [(u16, u16, u32, u32); 10] = [
(256, 4, 1, w),
(257, 4, 1, h),
(258, 3, 3, bits_at),
(259, 3, 1, 1),
(262, 3, 1, 2),
(273, 4, 1, pixels_at),
(277, 3, 1, 3),
(278, 4, 1, h),
(279, 4, 1, rgb.len() as u32),
(284, 3, 1, 1),
];
f.extend(u16b(entries.len() as u16));
for (tag, kind, count, value) in entries {
f.extend(u16b(tag));
f.extend(u16b(kind));
f.extend(u32b(count));
// A single short sits in the first two bytes of the value field.
if kind == 3 && count == 1 {
f.extend(u16b(value as u16));
f.extend([0, 0]);
} else {
f.extend(u32b(value));
}
}
f.extend(u32b(0));
f
}
/// A 24-bit BMP laid out by hand: rows bottom-up, blue-green-red, each padded to four bytes.
fn bmp_by_hand(w: u32, h: u32, rgb: &[u8]) -> Vec<u8> {
let row = (w as usize * 3).div_ceil(4) * 4;
let size = 54 + row * h as usize;
let mut f = b"BM".to_vec();
f.extend((size as u32).to_le_bytes());
f.extend([0; 4]);
f.extend(54u32.to_le_bytes());
f.extend(40u32.to_le_bytes());
f.extend((w as i32).to_le_bytes());
f.extend((h as i32).to_le_bytes());
f.extend(1u16.to_le_bytes());
f.extend(24u16.to_le_bytes());
f.extend([0; 24]);
for y in (0..h as usize).rev() {
let line = &rgb[y * w as usize * 3..(y + 1) * w as usize * 3];
for p in line.chunks(3) {
f.extend([p[2], p[1], p[0]]);
}
f.extend(std::iter::repeat_n(0, row - w as usize * 3));
}
f
}
#[test]
fn files_laid_out_by_hand_are_read_pixel_for_pixel() {
// 3 x 2: an odd width, so BMP rows are padded; every pixel different.
let rgb: Vec<u8> = (0..18u8).map(|i| i * 13 + 5).collect();
let rgba: Vec<u8> = rgb.chunks(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect();
let want = Raster::new(3, 2, rgba).unwrap();
for (file, what) in [
(tiff_by_hand(false, 3, 2, &rgb), "little-endian TIFF"),
(tiff_by_hand(true, 3, 2, &rgb), "big-endian TIFF"),
(bmp_by_hand(3, 2, &rgb), "bottom-up BMP"),
] {
assert_eq!(probe(&file).unwrap(), (3, 2), "{what}");
assert_eq!(decode(&file).unwrap(), want, "{what}");
}
}
#[test]
fn hand_made_files_that_lie_about_their_size_are_refused() {
let rgb = [9u8; 18];
// More pixels declared than the file holds.
for file in [tiff_by_hand(false, 300, 200, &rgb), tiff_by_hand(true, 3, 2000, &rgb), bmp_by_hand(3, 2, &rgb)[..60].to_vec()] {
assert!(decode(&file).is_err());
}
// Sizes past the limit, before any pixel is read.
for file in [tiff_by_hand(false, 60_000, 60_000, &rgb), tiff_by_hand(true, u32::MAX, u32::MAX, &rgb)] {
assert!(decode(&file).is_err());
}
let mut bmp = bmp_by_hand(3, 2, &rgb);
bmp[18..22].copy_from_slice(&60_000i32.to_le_bytes());
bmp[22..26].copy_from_slice(&60_000i32.to_le_bytes());
assert!(decode(&bmp).is_err());
// A zero-sized image.
assert!(decode(&tiff_by_hand(false, 0, 0, &[])).is_err());
}
-168
View File
@@ -1,168 +0,0 @@
//! A twisted view's centre is stored in display coordinates, turned by the twist: VIEWPORT and
//! VPORT group 12 and VIEW group 10. The reader turns it back to the drawing point at the middle
//! of the view and the writer turns it again, so a twisted view far from the origin (a site plan
//! on a national grid) shows its own part of the drawing.
use cadcraft_doc::*;
use cadcraft_geom::{Vec2, Vec3};
/// The far site: a national-grid point.
const SITE: Vec2 = Vec2 { x: 2_600_000.0, y: 1_200_000.0 };
fn twist() -> f64 {
(-30.0_f64).to_radians()
}
/// The display-coordinate centre another program stores for a view on `SITE` with `twist()`.
fn dcs() -> Vec2 {
SITE.rotate(twist())
}
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).len() < 1e-6
}
/// Every `(code, value)` pair of a DXF text.
fn pairs(dxf: &str) -> Vec<(i32, String)> {
let lines: Vec<&str> = dxf.lines().collect();
lines.chunks(2).filter_map(|p| Some((p.first()?.trim().parse().ok()?, p.get(1)?.trim().to_string()))).collect()
}
/// Groups `x`/`x + 10` of each record of type `kind` that has group `has` (its twist).
fn centres(dxf: &str, kind: &str, x: i32, has: i32) -> Vec<Vec2> {
let mut out = Vec::new();
let mut rec: Option<(Option<f64>, Option<f64>, bool)> = None;
for (c, v) in pairs(dxf).into_iter().chain(std::iter::once((0, String::new()))) {
if c == 0 {
if let Some((Some(px), Some(py), true)) = rec.take() {
out.push(Vec2::new(px, py));
}
rec = (v == kind).then_some((None, None, false));
} else if let Some(r) = rec.as_mut() {
if c == x {
r.0 = v.parse().ok();
} else if c == x + 10 {
r.1 = v.parse().ok();
} else if c == has && v.parse::<f64>().is_ok_and(|a| a != 0.0) {
r.2 = true;
}
}
}
out
}
fn viewport(view_center: Vec2) -> EntityKind {
EntityKind::Viewport(Viewport {
center: Vec3::new(230.0, 80.0, 0.0),
width: 420.0,
height: 120.0,
view_center,
view_height: 60.0,
id: 2,
twist: twist().rem_euclid(std::f64::consts::TAU),
..Default::default()
})
}
fn sheet_viewport(d: &Drawing) -> Viewport {
d.layouts
.iter()
.flat_map(|l| l.entities.iter())
.find_map(|e| match &e.kind {
EntityKind::Viewport(v) if v.id == 2 => Some(v.clone()),
_ => None,
})
.expect("the sheet viewport")
}
#[test]
fn a_twisted_layout_viewport_far_from_the_origin_looks_at_its_site() {
let mut d = Drawing::new_metric();
let sheet = Space::Paper(d.layouts[0].name.clone());
d.add(&sheet, Common::default(), viewport(SITE)).unwrap();
let dxf = cadcraft_io::write_dxf(&d);
// Written as other programs write it: the centre in display coordinates.
let written = centres(&dxf, "VIEWPORT", 12, 51);
assert_eq!(written.len(), 1, "{written:?}");
assert!(written.first().is_some_and(|c| close(*c, dcs())), "{written:?} vs {:?}", dcs());
// Read back, the site is at the middle of the viewport.
let v = sheet_viewport(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap());
assert!(close(v.view_center, SITE), "{:?}", v.view_center);
assert!(close(v.model_to_paper(SITE), Vec2::new(230.0, 80.0)), "{:?}", v.model_to_paper(SITE));
}
#[test]
fn twisted_model_views_and_named_views_store_display_centres() {
let mut d = Drawing::new_metric();
let tile = |center: Vec2, twist: f64| VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center, height: 50.0, aspect: 1.5, twist };
// A saved configuration of two tiles, one twisted.
d.vports = vec![VportConfig { name: "Site".into(), tiles: vec![tile(SITE, twist()), tile(Vec2::new(5.0, 5.0), 0.0)] }];
d.views.push(NamedView { name: "Site".into(), center: SITE, height: 50.0, width: 75.0, twist: twist(), ..Default::default() });
let dxf = cadcraft_io::write_dxf(&d);
let vports = centres(&dxf, "VPORT", 12, 51);
assert_eq!(vports.len(), 1, "{vports:?}");
assert!(vports.iter().all(|c| close(*c, dcs())), "{vports:?} vs {:?}", dcs());
let views = centres(&dxf, "VIEW", 10, 50);
assert_eq!(views.len(), 1, "{views:?}");
assert!(views.iter().all(|c| close(*c, dcs())), "{views:?} vs {:?}", dcs());
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let tiles: Vec<Vec2> = back.vports.iter().filter(|c| c.name == "Site").flat_map(|c| c.tiles.iter().map(|t| t.center)).collect();
assert_eq!(tiles.len(), 2, "{:?}", back.vports);
assert!(close(tiles[0], SITE) && close(tiles[1], Vec2::new(5.0, 5.0)), "{tiles:?}");
assert!(back.views.iter().all(|v| close(v.center, SITE)), "{:?}", back.views);
}
/// `dxf` with every record of type `kind` that has a nonzero group `has` (its twist) moved to
/// view target `target` (groups `t`/`t + 10`): its centre (groups `x`/`x + 10`) is set to the
/// display-coordinate centre of `SITE` seen from that target, as another program stores it.
fn with_target(dxf: &str, kind: &str, x: i32, t: i32, has: i32, target: Vec2) -> String {
let c = (SITE - target).rotate(twist());
let mut records: Vec<Vec<(i32, String)>> = Vec::new();
for (code, v) in pairs(dxf) {
match records.last_mut() {
Some(r) if code != 0 => r.push((code, v)),
_ => records.push(vec![(code, v)]),
}
}
let mut out = String::new();
for mut r in records {
let twisted = r.first().is_some_and(|(_, v)| v == kind) && r.iter().any(|(code, v)| *code == has && v.parse::<f64>().is_ok_and(|a| a != 0.0));
if twisted {
r.retain(|(code, _)| ![x, x + 10, t, t + 10, t + 20].contains(code));
r.extend([
(x, c.x.to_string()),
(x + 10, c.y.to_string()),
(t, target.x.to_string()),
(t + 10, target.y.to_string()),
(t + 20, "0".into()),
]);
}
for (code, v) in r {
out.push_str(&format!("{code}\n{v}\n"));
}
}
out
}
#[test]
fn display_centres_are_measured_from_the_view_target() {
let target = Vec2::new(2_599_000.0, 1_199_500.0);
// A layout viewport.
let mut d = Drawing::new_metric();
let sheet = Space::Paper(d.layouts[0].name.clone());
d.add(&sheet, Common::default(), viewport(SITE)).unwrap();
let dxf = with_target(&cadcraft_io::write_dxf(&d), "VIEWPORT", 12, 17, 51, target);
let v = sheet_viewport(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap());
assert!(close(v.view_center, SITE), "{:?}", v.view_center);
// A model view tile and a named view.
let mut d = Drawing::new_metric();
let tile = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: SITE, height: 50.0, aspect: 1.5, twist: twist() };
d.vports = vec![VportConfig { name: "Site".into(), tiles: vec![tile, VportTile { center: Vec2::new(5.0, 5.0), twist: 0.0, ..tile }] }];
d.views.push(NamedView { name: "Site".into(), center: SITE, height: 50.0, width: 75.0, twist: twist(), ..Default::default() });
let dxf = with_target(&cadcraft_io::write_dxf(&d), "VPORT", 12, 17, 51, target);
let dxf = with_target(&dxf, "VIEW", 10, 12, 50, target);
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let tiles: Vec<Vec2> = back.vports.iter().filter(|c| c.name == "Site").flat_map(|c| c.tiles.iter().map(|t| t.center)).collect();
assert!(tiles.first().is_some_and(|c| close(*c, SITE)), "{tiles:?}");
assert!(back.views.iter().all(|v| close(v.center, SITE)), "{:?}", back.views);
}
+9 -17
View File
@@ -56,15 +56,13 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc:
let m = Mat3::translate(off).then_before(ins.transform(blk.base.xy()));
let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, m) };
for be in blk.entities.iter() {
// Every visit is charged, drawn or not, so a block of definitions can't make
// the copies loop free.
if !b.expand() {
break 'copies;
}
// Constant/visible attribute definitions inside blocks are not drawn; attribs are.
if matches!(be.kind, EntityKind::AttDef(_)) {
continue;
}
if !b.expand() {
break 'copies;
}
entity(b, &sub, be);
}
}
@@ -75,17 +73,6 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc:
let attmode = ctx.d.header.i64("ATTMODE", 1);
// The reference's transparency; each attribute draws with its own.
let reference = b.transparency;
// The block's first definition per tag (without case), built once for all the attributes.
let mut defs: std::collections::HashMap<String, &Entity> = std::collections::HashMap::new();
if !ins.attribs.is_empty()
&& let Some(blk) = ctx.d.block(&ins.block)
{
for be in blk.entities.iter() {
if let EntityKind::AttDef(d) = &be.kind {
defs.entry(d.tag.to_ascii_lowercase()).or_insert(be.as_ref());
}
}
}
for a in &ins.attribs {
if attmode == 0 || (a.invisible && attmode != 2) {
continue;
@@ -96,7 +83,7 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc:
// Properties of the attribute's definition (as AutoCAD copies them to the attribute
// on insertion), except those set on the attribute itself: ByBlock = the reference's,
// layer 0 = the reference's layer.
let def = defs.get(&a.tag.to_ascii_lowercase()).copied();
let def = ctx.d.block(&ins.block).and_then(|blk| blk.entities.iter().find(|be| attdef_tag(be, &a.tag)));
let (ink, lw, shown, t) = match (def, a.props.is_empty()) {
(None, true) => (rgb, resolve(ctx, e, b.plotting).1, true, reference),
(def, _) => {
@@ -130,3 +117,8 @@ fn by_block() -> cadcraft_doc::Common {
..cadcraft_doc::Common::default()
}
}
/// The attribute definition with tag `tag` (any case).
fn attdef_tag(e: &Entity, tag: &str) -> bool {
matches!(&e.kind, EntityKind::AttDef(d) if d.tag.eq_ignore_ascii_case(tag))
}
+2 -2
View File
@@ -1,13 +1,13 @@
use super::display::Kind;
use super::draw::stroke_with;
use super::props::default_lineweight;
use super::text_entity::{layout_mtext_entity, place_text_entity, style_font, text_style};
use super::text_entity::{layout_mtext_entity, place_text_entity, text_style};
use super::{Builder, Color, Ctx, DimGeometry, DimText, Drawing, Entity, EntityKind, LineRole, Lineweight, Vec2, dim, ink};
/// The font settings of a dimension style's text style.
pub fn dim_text(d: &Drawing, st: &cadcraft_doc::DimStyle) -> DimText {
let ts = text_style(d, &st.text_style);
DimText { font: style_font(&ts), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique }
DimText { font: cadcraft_fonts::TextFont::resolve(&ts.font), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique }
}
/// Geometry of a dimension in a drawing: its style, overrides and text font.
-52
View File
@@ -22,51 +22,6 @@ pub enum Kind {
Point,
/// An infinite line or ray: `verts[start]` = base, `verts[start+1]` = unit direction.
Infinite { ray: bool },
/// A raster image, [`DisplayList::images`]`[index]`; `verts[start..start+4]` are its corners
/// (top left, top right, bottom right, bottom left of the pixel grid).
Image { index: u32 },
}
/// A raster image placed in the world.
#[derive(Clone, Debug, PartialEq)]
pub struct DImage {
pub raster: std::sync::Arc<cadcraft_doc::Raster>,
/// Where the top-left corner of the top-left pixel is.
pub origin: Vec2,
/// One pixel to the right and one pixel down, in world units.
pub right: Vec2,
pub down: Vec2,
/// Closed outlines the image is cut to (all of them: its own clip boundary, then the
/// viewports it is seen through). Empty: the whole image.
pub clips: Vec<Vec<Vec2>>,
/// Fade towards the background, percent (0..=100).
pub fade: u8,
}
impl DImage {
/// The world position of pixel-grid point (`x`, `y`), `y` down from the top.
pub fn at(&self, x: f64, y: f64) -> Vec2 {
self.origin + self.right * x + self.down * y
}
/// The pixel-grid point at world position `q` (the inverse of [`DImage::at`]); `None` for an
/// image squashed to a line or a point.
pub fn pixel_of(&self, q: Vec2) -> Option<Vec2> {
let det = self.right.x * self.down.y - self.right.y * self.down.x;
if !(det.is_finite() && det.abs() > 0.0) {
return None;
}
let d = q - self.origin;
Some(Vec2::new((d.x * self.down.y - d.y * self.down.x) / det, (self.right.x * d.y - self.right.y * d.x) / det))
}
/// How opaque the image is drawn, 0..=1: the object's opacity `alpha` (0..=255) less the fade.
pub fn opacity(&self, alpha: u8) -> f32 {
f32::from(alpha) / 255.0 * (1.0 - f32::from(self.fade.min(100)) / 100.0)
}
/// The corners: top left, top right, bottom right, bottom left.
pub fn corners(&self) -> [Vec2; 4] {
let (w, h) = (f64::from(self.raster.width()), f64::from(self.raster.height()));
[self.at(0.0, 0.0), self.at(w, 0.0), self.at(w, h), self.at(0.0, h)]
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -91,8 +46,6 @@ pub struct DisplayList {
pub prims: Vec<DPrim>,
pub verts: Vec<Vec2>,
pub tris: Vec<Vec2>,
/// The raster images drawn by [`Kind::Image`] primitives.
pub images: Vec<DImage>,
/// Plots ([`build_plot`]): for each primitive, the index in [`Drawing::layers`] of the layer
/// it is drawn on (block contents on layer 0: the block reference's; [`NO_LAYER`] when the
/// layer isn't in the table), for PDF layers. Empty for other lists.
@@ -182,19 +135,14 @@ impl DisplayList {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn append(dst: &mut DisplayList, src: DisplayList) {
let (verts, tris, prims) = (dst.verts.len() as u32, dst.tris.len() as u32, dst.prims.len() as u32);
let images = dst.images.len() as u32;
// Pens are parallel to the primitives, with missing entries meaning "use object".
if !src.pens.is_empty() {
dst.pens.resize(dst.prims.len(), Pen::default());
}
dst.prims.extend(src.prims.into_iter().map(|mut p| {
p.start += if matches!(p.kind, Kind::Tris | Kind::Mask) { tris } else { verts };
if let Kind::Image { index } = &mut p.kind {
*index = index.saturating_add(images);
}
p
}));
dst.images.extend(src.images);
dst.verts.extend(src.verts);
dst.tris.extend(src.tris);
dst.prim_layers.extend(src.prim_layers);
+9 -19
View File
@@ -89,7 +89,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
b.polyline(ctx, rgb, lw, &dash);
}
}
// Text and shapes embedded in the linetype (shapes when their SHX file is found).
// Text embedded in the linetype (shapes need SHX files and are not drawn).
if b.opts.text {
for sh in linetype_text::texts(ctx.d, pts, lt, ltscale, min) {
b.shaped(ctx, rgb, lw, &sh);
@@ -193,7 +193,14 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
}
}
EntityKind::Wipeout(w) => draw_wipeout(b, ctx, w, rgb, lw),
EntityKind::Image(i) => draw_image(b, ctx, i, rgb, lw),
EntityKind::Image(i) => {
let o = i.insert.xy();
let u = i.u.xy() * i.size.x;
let v = i.v.xy() * i.size.y;
b.polyline(ctx, rgb, lw, &[o, o + u, o + u + v, o + v, o]);
b.polyline(ctx, rgb, lw, &[o, o + u + v]);
b.polyline(ctx, rgb, lw, &[o + u, o + v]);
}
EntityKind::Table(t) => table(b, ctx, t, rgb, lw),
EntityKind::Tolerance(t) => tolerance::draw(b, ctx, e, t, lw, tol),
EntityKind::Leader(l) => {
@@ -282,23 +289,6 @@ fn draw_mleader(b: &mut Builder, ctx: &Ctx, e: &Entity, m: &cadcraft_doc::MLeade
}
}
fn draw_image(b: &mut Builder, ctx: &Ctx, i: &cadcraft_doc::Image, rgb: Ink, lw: f32) {
let o = i.insert.xy();
let u = i.u.xy() * i.size.x;
let v = i.v.xy() * i.size.y;
// Display flag 1: show the image. Without pixels (file not found, not loaded yet, hidden)
// the rectangle is crossed out.
let raster = if i.display & 1 != 0 { ctx.d.rasters.get(&i.path) } else { None };
if let Some(r) = raster {
b.image(ctx, rgb, i, r);
}
b.polyline(ctx, rgb, lw, &[o, o + u, o + u + v, o + v, o]);
if raster.is_none() {
b.polyline(ctx, rgb, lw, &[o, o + u + v]);
b.polyline(ctx, rgb, lw, &[o + u, o + v]);
}
}
fn draw_wipeout(b: &mut Builder, ctx: &Ctx, w: &cadcraft_doc::Wipeout, rgb: Ink, lw: f32) {
// Hide what was drawn before it (draw order), then the frame per WIPEOUTFRAME:
// 0 = none, 1 = shown and plotted, 2 = shown but not plotted.
+40 -6
View File
@@ -367,6 +367,7 @@ pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool {
/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two
/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only
/// the part of the hatch inside it is generated; `None` = all of it.
#[cfg(test)]
pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
pattern_lines_until(d, h, loops, px, view, None)
}
@@ -380,6 +381,21 @@ pub(crate) fn pattern_lines_until(
px: f64,
view: Option<&Bounds2>,
cancel: Option<&AtomicBool>,
) -> Vec<Vec<Vec2>> {
pattern_lines_within(d, h, loops, px, view, cancel, MAX_RUNS, MAX_HATCH_WORK)
}
/// [`pattern_lines_until`] with the most runs and the dash-work allowance given.
#[allow(clippy::too_many_arguments)]
fn pattern_lines_within(
d: &Drawing,
h: &Hatch,
loops: &[Vec<Vec2>],
px: f64,
view: Option<&Bounds2>,
cancel: Option<&AtomicBool>,
max_runs: usize,
work_limit: u64,
) -> Vec<Vec<Vec2>> {
let lines = lines_of(d, h);
let scale = scale_of(h);
@@ -388,7 +404,7 @@ pub(crate) fn pattern_lines_until(
// Shared work budget across all families and spans, counting dash-processing steps even when
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
// their cost (CWE-400).
let mut work: u64 = MAX_HATCH_WORK;
let mut work: u64 = work_limit;
for fam in &lines {
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
// are not swept at all.
@@ -403,8 +419,8 @@ pub(crate) fn pattern_lines_until(
continue;
}
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
Family { max_runs, origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
if out.len() >= max_runs || work == 0 || is_set(cancel) {
break;
}
}
@@ -415,6 +431,8 @@ pub(crate) fn pattern_lines_until(
const SWATCH_REPEATS: f64 = 6.0;
/// Most runs a swatch holds.
const MAX_SWATCH_RUNS: usize = 4000;
/// The dash-work allowance of a swatch (a hatch's is [`MAX_HATCH_WORK`]).
const MAX_SWATCH_WORK: u64 = 2_000_000;
/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the
/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the
@@ -441,14 +459,19 @@ pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec<Vec<Vec2>> {
pattern_lines: lines.to_vec(),
};
let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)];
let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None);
// The generator stops at the swatch's own limits, so a compact pattern is never made in full
// (about a million runs) to keep a few thousand.
let mut runs = pattern_lines_within(&Drawing::default(), &h, &[square], 0.0, None, None, MAX_SWATCH_RUNS, MAX_SWATCH_WORK);
runs.truncate(MAX_SWATCH_RUNS);
runs.shrink_to_fit();
runs
}
/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin`
/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last.
struct Family<'a> {
/// Most runs the hatch may hold in all ([`MAX_RUNS`], less for a swatch).
max_runs: usize,
origin: Vec2,
ang: f64,
delta: Vec2,
@@ -487,6 +510,12 @@ impl Family<'_> {
/// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits.
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
// Transforming the boundary is charged before it is done, at the scanline rate (one unit per 8 vertices).
let verts: u64 = loops.iter().map(|l| l.len() as u64).sum();
if *work == 0 {
return;
}
*work = work.saturating_sub(verts / 8 + 1);
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
let bb = Bounds2::from_points(local.iter().flatten().copied());
if bb.is_empty() {
@@ -505,10 +534,15 @@ impl Family<'_> {
// family draws anything at all).
let draws = self.dashes.iter().any(|d| *d >= 0.0);
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
// Each scanline visits every boundary edge and sorts the crossings: that costs the shared
// allowance one unit per 8 edges (a 5,000-edge boundary swept by 10,000 lines uses about
// 6 million of its 50 million), whether or not any dash is made.
let edges: u64 = local.iter().map(|l| l.len() as u64).sum();
for k in k0..=k1 {
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
if out.len() >= self.max_runs || *work == 0 || is_set(cancel) {
return;
}
*work = work.saturating_sub(edges / 8 + 1);
let y = k as f64 * self.spacing;
let xs = crossings(&local, y);
let offset = shift_per_line * k as f64;
@@ -539,7 +573,7 @@ impl Family<'_> {
for d in self.dashes {
guard += 1;
*work = work.saturating_sub(1);
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
if guard > 100_000 || out.len() >= self.max_runs || *work == 0 {
break 'outer;
}
let len = d.abs();
+3 -59
View File
@@ -186,45 +186,6 @@ impl Builder<'_> {
len: pts.len() as u32,
});
}
/// The pixels of image `i` over its rectangle, cut to its clip boundary.
fn image(&mut self, ctx: &Ctx, color: Ink, i: &cadcraft_doc::Image, raster: &std::sync::Arc<cadcraft_doc::Raster>) {
// Where the entity puts its picture: the top-left corner, and one of its pixels to the
// right and down.
let top_left = ctx.xf.apply(i.insert.xy() + i.v.xy() * i.size.y);
let right = ctx.xf.apply_vec(i.u.xy());
let down = ctx.xf.apply_vec(i.v.xy() * -1.0);
let clip = image_clip(i, |q| top_left + right * q.x + down * q.y);
// The file found can have another pixel count than the entity records: its pixels are
// stretched over the same rectangle.
let img = DImage {
raster: raster.clone(),
origin: top_left,
right: right * (i.size.x / f64::from(raster.width())),
down: down * (i.size.y / f64::from(raster.height())),
clips: clip.into_iter().collect(),
fade: i.fade.min(100),
};
let corners = img.corners();
if !corners.iter().all(|q| q.x.is_finite() && q.y.is_finite()) {
return;
}
let (Ok(index), Ok(start)) = (u32::try_from(self.list.images.len()), u32::try_from(self.list.verts.len())) else { return };
for q in corners {
self.list.bounds.add(q);
self.list.verts.push(q);
}
self.list.images.push(img);
self.list.prims.push(DPrim {
handle: ctx.top,
color: color.rgb,
aci7: color.aci7,
lw: 0.0,
transparency: self.transparency,
kind: Kind::Image { index },
start,
len: 4,
});
}
fn tris(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2]) {
self.tris_at(ctx, color, tris, ctx.xf);
}
@@ -341,23 +302,6 @@ impl Builder<'_> {
}
}
/// The clip boundary of image `i` as a closed outline, or `None` when the whole image shows.
/// `place` gives the position of a point of the entity's pixel grid (x right, y down from the
/// top-left corner of the image).
fn image_clip(i: &cadcraft_doc::Image, place: impl Fn(Vec2) -> Vec2) -> Option<Vec<Vec2>> {
if !i.clipping {
return None;
}
// Boundaries count from pixel centres: the top-left corner of the image is (-0.5, -0.5).
let at = |q: Vec2| place(Vec2::new(q.x + 0.5, q.y + 0.5));
match i.clip.as_slice() {
// Two opposite corners of a rectangle.
[a, b] => Some(vec![at(*a), at(Vec2::new(b.x, a.y)), at(*b), at(Vec2::new(a.x, b.y))]),
polygon if polygon.len() >= 3 => Some(polygon.iter().map(|q| at(*q)).collect()),
_ => None,
}
}
mod table;
pub use table::{MAX_LAYOUT_CELLS, TableFill, TableGeom, TableLine, TableText, table_covered, table_geometry, table_style_of};
@@ -371,12 +315,12 @@ mod text_entity;
mod viewports;
pub use dim_draw::{dim_text, dimension_in};
pub use display::{DImage, DPrim, DisplayList, Kind, MAX_TRANSPARENCY, NO_LAYER, PlotText, VIEWPORT_CONTENT, alpha};
pub use display::{DPrim, DisplayList, Kind, MAX_TRANSPARENCY, NO_LAYER, PlotText, VIEWPORT_CONTENT, alpha};
pub use pipeline::{Options, build, build_entities, build_plot};
use props::{default_lineweight, resolve_common};
use text_entity::text_style;
pub use text_entity::{layout_mtext_entity, place_text_entity, style_font, text_align};
pub use viewports::{clip_list, viewport_clip, viewport_content};
pub use text_entity::{layout_mtext_entity, place_text_entity, text_align};
pub use viewports::{viewport_clip, viewport_content};
#[cfg(test)]
mod tests;
+45 -141
View File
@@ -1,10 +1,9 @@
//! Text and shapes embedded in complex linetypes (a gas line's `---- GAS ---- GAS ----`, a
//! fence's crosses).
//! Text embedded in complex linetypes (a gas line's `---- GAS ---- GAS ----`).
//!
//! A dash element may carry a text (DXF `74` bit 2, `9` string) or a shape (`74` bit 4, `75`
//! shape number). As in a `.lin` definition, where the bracketed text or shape follows the dash
//! or gap that carries it, it is placed where that element ends, at every repeat of the pattern
//! (laid out from the start of the run like [`crate::linetype::apply`]):
//! A dash element may carry a text (DXF `74` bit 2, `9` string). As in a `.lin` definition,
//! where the bracketed text follows the dash or gap that carries it, the text is placed where
//! that element ends, at every repeat of the pattern (laid out from the start of the run like
//! [`crate::linetype::apply`]):
//! - height: the element's scale (`46`), times the text style's fixed height when it has one,
//! times the linetype scale;
//! - origin: the anchor shifted by the X/Y offsets (`44`/`45`, times the linetype scale) along
@@ -12,39 +11,38 @@
//! - rotation (`50`): relative to the line direction, or absolute (`74` bit 1);
//! - font, width factor, oblique angle and mirroring from the text style (`340`).
//!
//! A shape comes from the SHX shape file its style (`340`) names, as the font table finds it
//! ([`cadcraft_fonts::shx::find`]): an unnamed shape-file style is read as its file name, a
//! named style by its font. It is drawn at the element's scale times the linetype scale per
//! text height of the file (its font record's `above`, else one vector unit), rotated and
//! offset like text. A shape whose file isn't found draws nothing and leaves its gap empty.
//! Shape elements (`74` bit 4) need SHX shape files, which CADCraft does not read: they draw
//! nothing and leave their gap empty.
use cadcraft_doc::{Drawing, Linetype};
use cadcraft_fonts::{Shaped, TextParams};
use cadcraft_fonts::{Shaped, TextFont, TextParams};
use cadcraft_geom::Vec2;
/// Upper bound on the texts and shapes drawn along one run (hostile scales; `apply` caps the
/// dashes).
/// Upper bound on the texts drawn along one run (hostile scales; `apply` caps the dashes).
const MAX_TEXTS: usize = 10_000;
/// Most points all the placed texts and shapes of one polyline hold together (a large shape
/// repeated many times).
const MAX_POINTS: usize = 2_000_000;
/// One text or shape element of the pattern, shaped once at the origin.
/// Upper bound on the points of all the texts of one run: each placement keeps its own copy of the
/// label's outline, so a long label times [`MAX_TEXTS`] would otherwise be too much to hold.
const MAX_TEXT_POINTS: usize = 4_000_000;
/// One text element of the pattern, shaped once at the origin.
struct Element {
/// Distance from the start of a pattern repeat to the anchor (where the element ends).
at: f64,
local: Shaped,
/// Points in `local`, each placement's cost against [`MAX_TEXT_POINTS`].
points: usize,
rotation: f64,
absolute: bool,
offset: Vec2,
}
/// The texts and shapes of `lt` along the polyline `pts`, in the coordinates of `pts`. Empty
/// when the linetype has none or its scaled pattern is drawn continuous (shorter than
/// `min_pattern`, or repeating too often).
/// The texts of `lt` along the polyline `pts`, in the coordinates of `pts`. Empty when the
/// linetype has no text or its scaled pattern is drawn continuous (shorter than `min_pattern`,
/// or repeating too often).
pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern: f64) -> Vec<Shaped> {
let mut out = Vec::new();
if pts.len() < 2 || !lt.pattern.iter().any(|e| e.text.as_deref().is_some_and(|t| !t.trim().is_empty()) || e.shape.is_some()) {
if pts.len() < 2 || !lt.pattern.iter().any(|e| e.text.as_deref().is_some_and(|t| !t.trim().is_empty())) {
return out;
}
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
@@ -58,15 +56,16 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
return out;
}
let mut walk = Walk { pts, i: 0, start: 0.0 };
let mut points_left = MAX_TEXT_POINTS;
let mut base = 0.0;
let mut points = 0usize;
'repeats: while base <= len {
for el in &elements {
let s = base + el.at;
points = points.saturating_add(point_count(&el.local));
if s > len + 1e-9 || out.len() >= MAX_TEXTS || points > MAX_POINTS {
if s > len + 1e-9 || out.len() >= MAX_TEXTS {
break 'repeats;
}
let Some(left) = points_left.checked_sub(el.points) else { break 'repeats };
points_left = left;
let Some((p, dir)) = walk.at(s) else { break 'repeats };
let origin = p + dir * el.offset.x + dir.perp() * el.offset.y;
let rot = if el.absolute { el.rotation } else { dir.angle() + el.rotation };
@@ -79,45 +78,12 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
out
}
fn point_count(s: &Shaped) -> usize {
s.strokes.iter().map(Vec::len).chain(s.glyphs.iter().flatten().map(Vec::len)).fold(0, usize::saturating_add)
}
/// The shape element `el` at the origin (scaled by the element's and the linetype's scale), if
/// its shape file is found and has the shape.
fn shape(d: &Drawing, el: &cadcraft_doc::DashElement, scale: f64) -> Option<Shaped> {
let number = el.shape.filter(|n| *n != 0)?;
let style = el.style.as_deref().map(str::trim).filter(|s| !s.is_empty())?;
let file = d.text_style(style).map_or_else(|| style.to_string(), |ts| ts.font.clone());
let font = cadcraft_fonts::shx::find(&file)?;
let glyph = font.glyph(number, false)?;
let k = if el.scale.is_finite() && el.scale > 0.0 { el.scale } else { 1.0 } * scale / font.units_per_height();
if !(k.is_finite() && k > 0.0) {
return None;
}
let strokes = glyph.strokes.iter().map(|s| s.iter().map(|p| *p * k).collect()).collect();
Some(Shaped { strokes, width: glyph.advance.x * k, ..Shaped::default() })
}
/// The text and shape elements of `lt`, scaled by `scale`, in pattern order.
/// The text elements of `lt`, scaled by `scale`, in pattern order.
fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
let mut out = Vec::new();
let mut at = 0.0;
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
for el in &lt.pattern {
at += el.length.abs() * scale;
if el.text.is_none()
&& let Some(local) = shape(d, el, scale)
{
out.push(Element {
at,
local,
rotation: finite(el.rotation),
absolute: el.absolute,
offset: Vec2::new(finite(el.offset.x), finite(el.offset.y)) * scale,
});
continue;
}
let Some(text) = el.text.as_deref().filter(|t| !t.trim().is_empty()) else { continue };
let ts = d.text_style(el.style.as_deref().unwrap_or("Standard")).cloned().unwrap_or_default();
let k = if el.scale.is_finite() && el.scale > 0.0 { el.scale } else { 1.0 };
@@ -126,7 +92,7 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
if !(height.is_finite() && height > 0.0) {
continue;
}
let font = crate::style_font(&ts);
let font = TextFont::resolve(&ts.font);
let params = TextParams {
width_factor: if ts.width_factor.is_finite() && ts.width_factor > 0.0 { ts.width_factor } else { 1.0 },
oblique: if ts.oblique.is_finite() { ts.oblique } else { 0.0 },
@@ -135,8 +101,17 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
..TextParams::new(Vec2::ZERO, height)
};
let (local, _) = cadcraft_fonts::place(&font, text, &params);
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
// Points plus one per stroke and glyph slot, and one for the label itself, so a label of empty
// glyphs still has a cost proportional to its size.
let points = local.strokes.iter().map(Vec::len).sum::<usize>()
+ local.glyphs.iter().flatten().map(Vec::len).sum::<usize>()
+ local.strokes.len()
+ local.glyphs.len()
+ 1;
out.push(Element {
at,
points,
local,
rotation: finite(el.rotation),
absolute: el.absolute,
@@ -217,89 +192,18 @@ mod tests {
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty());
}
/// A shape file written for this test: shape 130 a 2 x 2 box with its pen ending at x = 2.
fn register_shapes() {
let src = "*130,7,BOX\n1,020,024,028,02C,2,020,0\n";
cadcraft_fonts::shx::register("cadcraft-render-lt.shx", &cadcraft_fonts::shx::compile(src).unwrap()).unwrap();
}
fn fence(style: &str) -> Linetype {
Linetype {
name: "FENCE".into(),
description: String::new(),
pattern: vec![
DashElement::dash(0.5),
DashElement { shape: Some(130), style: Some(style.into()), scale: 0.1, ..DashElement::dash(-0.2) },
DashElement::dash(-0.25),
],
}
}
/// A long label repeated along a long run stops at the point allowance, well before MAX_TEXTS.
#[test]
fn shape_elements_draw_their_shape_at_each_repeat() {
register_shapes();
fn placed_labels_stop_at_the_point_allowance() {
let d = Drawing::new_imperial();
// An unnamed shape-file style reads as its file name (DXF); scale 0.1 per vector unit.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &fence("cadcraft-render-lt.shx"), 1.0, 0.0);
assert_eq!(t.len(), 2);
let x = starts(&t);
assert!((x[0] - 0.7).abs() < 1e-9 && (x[1] - 1.65).abs() < 1e-9, "{x:?}");
let ys = t[0].strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert!((ys.0).abs() < 1e-9 && (ys.1 - 0.2).abs() < 1e-9, "{ys:?}");
// Along a vertical line the shape turns with it; absolute rotation keeps it level.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(0.0, 2.0)], &fence("cadcraft-render-lt.shx"), 2.0, 0.0);
assert_eq!(t.len(), 1);
let xs = t[0].strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)));
assert!((xs.0 + 0.4).abs() < 1e-9 && xs.1.abs() < 1e-9, "{xs:?}");
// A named style whose font is the shape file.
let mut d = Drawing::new_imperial();
d.text_styles.push(cadcraft_doc::TextStyle { name: "SHAPES".into(), font: "cadcraft-render-lt.shx".into(), ..Default::default() });
assert_eq!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &fence("SHAPES"), 1.0, 0.0).len(), 2);
// A shape file that isn't found, or a shape it lacks, draws nothing.
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &fence("cadcraft-no-such-file.shx"), 1.0, 0.0).is_empty());
let mut lt = fence("cadcraft-render-lt.shx");
lt.pattern[1].shape = Some(131);
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &lt, 1.0, 0.0).is_empty());
}
#[test]
fn a_large_shape_repeated_many_times_is_bounded() {
// A shape of 1,000 one-unit vectors there and back: about 1,000 points each time.
let body: Vec<&str> = (0..500).flat_map(|_| ["010", "018"]).collect();
let src = format!("*131,1002,ZIGZAG\n1,{},0\n", body.join(","));
cadcraft_fonts::shx::register("cadcraft-render-big.shx", &cadcraft_fonts::shx::compile(&src).unwrap()).unwrap();
let d = Drawing::new_imperial();
let mut lt = fence("cadcraft-render-big.shx");
lt.pattern[1].shape = Some(131);
// 3,000 repeats would hold about 3 million points.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(2850.0, 0.0)], &lt, 1.0, 0.0);
let points: usize = t.iter().map(point_count).sum();
assert!(!t.is_empty() && t.len() < 3000 && points <= MAX_POINTS, "{} shapes, {points} points", t.len());
}
#[test]
fn text_in_an_shx_style_uses_the_font_metrics() {
// A text font written for this test: A is 4 wide with a gap of 2, cap height 6.
let src = "*0,4,render test\n6,2,0,0\n*65,7,UCA\n1,064,040,06C,2,020,0\n";
cadcraft_fonts::shx::register("cadcraft-render-text.shx", &cadcraft_fonts::shx::compile(src).unwrap()).unwrap();
let mut d = Drawing::new_imperial();
d.text_styles.push(cadcraft_doc::TextStyle { name: "SHX".into(), font: "cadcraft-render-text.shx".into(), ..Default::default() });
let t = cadcraft_doc::Text {
insert: cadcraft_geom::Vec3::new(0.0, 0.0, 0.0),
align_pt: None,
height: 3.0,
value: "AA".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "SHX".into(),
halign: Default::default(),
valign: Default::default(),
};
let (sh, bb) = crate::place_text_entity(&d, &t, "AA");
assert!((sh.width - 6.0).abs() < 1e-9 && (bb.width() - 6.0).abs() < 1e-9);
let maxy = sh.strokes.iter().flatten().map(|p| p.y).fold(f64::MIN, f64::max);
assert!((maxy - 3.0).abs() < 1e-9);
let mut lt = gas();
lt.pattern[1].text = Some("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM".into());
// 0.95 per repeat over 9,000: about 9,500 repeats, under the 50,000 the run is drawn for.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(9_000.0, 0.0)], &lt, 1.0, 0.0);
let points: usize =
t.iter().map(|s| s.strokes.iter().map(Vec::len).sum::<usize>() + s.glyphs.iter().flatten().map(Vec::len).sum::<usize>()).sum();
assert!(!t.is_empty() && t.len() < MAX_TEXTS, "{}", t.len());
assert!(points <= MAX_TEXT_POINTS, "{points}");
}
#[test]
+4 -28
View File
@@ -106,12 +106,10 @@ fn chain(mut segs: Vec<(Vec2, Vec2)>) -> Vec<Vec<Vec2>> {
}
}
segs.sort_unstable_by(|(a, b), (c, d)| cmp(*a, *c).then_with(|| cmp(*b, *d)));
// Segments at each vertex with a cursor past the used ones, so many segments meeting at one
// vertex are each examined once rather than from the start on every visit.
let mut at: HashMap<Key, (Vec<usize>, usize)> = HashMap::new();
let mut at: HashMap<Key, Vec<usize>> = HashMap::new();
for (i, (a, b)) in segs.iter().enumerate() {
at.entry(key(*a)).or_default().0.push(i);
at.entry(key(*b)).or_default().0.push(i);
at.entry(key(*a)).or_default().push(i);
at.entry(key(*b)).or_default().push(i);
}
let mut used = vec![false; segs.len()];
let mut out = Vec::new();
@@ -127,15 +125,7 @@ fn chain(mut segs: Vec<(Vec2, Vec2)>) -> Vec<Vec<Vec2>> {
let mut end = b;
// Each step uses one segment, so the walk ends within `segs.len()` steps.
loop {
let next = at.get_mut(&key(end)).and_then(|(ids, cursor)| {
while let Some(&i) = ids.get(*cursor) {
if !used.get(i).copied().unwrap_or(true) {
return Some(i);
}
*cursor += 1;
}
None
});
let next = at.get(&key(end)).and_then(|ids| ids.iter().copied().find(|&i| !used.get(i).copied().unwrap_or(true)));
let Some(i) = next else { break };
let Some(&(p, q)) = segs.get(i) else { break };
if let Some(u) = used.get_mut(i) {
@@ -157,20 +147,6 @@ mod tests {
lines.iter().flat_map(|l| l.windows(2).map(|w| w[0].dist(w[1]))).sum()
}
/// Thousands of lobes meeting at one vertex chain in one pass over that vertex's segments.
#[test]
fn many_lobes_at_one_vertex_chain_quickly() {
let mut tris = Vec::new();
for i in 0..20_000 {
let x = f64::from(i + 1);
tris.extend_from_slice(&[Vec2::ZERO, Vec2::new(x, 1.0), Vec2::new(x + 0.5, 2.0)]);
}
// No wall-clock bound (flaky on loaded runners): a quadratic scan would make this test crawl
// rather than fail, so check the output is complete instead.
let lines = fill_outlines(&tris);
assert_eq!(lines.iter().map(|l| l.len() - 1).sum::<usize>(), 60_000, "every triangle edge is emitted once");
}
#[test]
fn a_square_of_two_triangles_is_one_loop() {
let (a, b, c, d) = (Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0), Vec2::new(2.0, 1.0), Vec2::new(0.0, 1.0));
+1 -97
View File
@@ -4,7 +4,7 @@ use cadcraft_color::Rgb;
use cadcraft_geom::{Bounds2, Vec2};
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
use crate::{DImage, DisplayList, Kind};
use crate::{DisplayList, Kind};
/// A 2D view: world point at the image centre and pixels per drawing unit.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -147,13 +147,6 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
}
}
}
Kind::Image { index } => {
if let Some(img) = list.images.get(index as usize)
&& Bounds2::from_points(pts.iter().copied()).intersects(&vis)
{
image(&mut pm, img, view, p.alpha(), o.antialias);
}
}
Kind::Infinite { ray } => {
let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) else { continue };
let far = (vis.width() + vis.height()) * 2.0 + base.dist(vis.center());
@@ -260,95 +253,6 @@ pub fn blank_outside_pixels(pm: &mut Pixmap, top_left: (f64, f64), bottom_right:
}
}
/// Draw `img` with the object opacity `alpha`, cut to its clip outlines.
fn image(pm: &mut Pixmap, img: &DImage, view: &View, alpha: u8, antialias: bool) {
let r = &img.raster;
let Some(to_screen) = grid_to_screen(img, view) else { return };
let Ok(mask) = clip_mask(pm, img, view, antialias) else { return };
// tiny-skia wants premultiplied pixels; opaque ones already are.
let owned;
let pixels = if r.opaque() {
tiny_skia::PixmapRef::from_bytes(r.rgba(), r.width(), r.height())
} else {
owned = premultiplied(r);
owned.as_ref().map(Pixmap::as_ref)
};
let Some(pixels) = pixels else { return };
let paint = Paint {
shader: tiny_skia::Pattern::new(
pixels,
tiny_skia::SpreadMode::Pad,
tiny_skia::FilterQuality::Bilinear,
img.opacity(alpha),
Transform::identity(),
),
anti_alias: antialias,
..Paint::default()
};
// The picture's own rectangle in its pixel grid, carried to the screen by `to_screen`.
// Filled as a path: tiny-skia's anti-aliased `fill_rect` trips a debug assertion on narrow
// rects (see the points above), which a one-pixel image drawn at its own size would be.
if let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, r.width() as f32, r.height() as f32) {
pm.fill_path(&PathBuilder::from_rect(rect), &paint, FillRule::Winding, to_screen, mask.as_ref());
}
}
/// The transform from `img`'s pixel grid (x right, y down) to screen pixels; `None` when the
/// image has no area on screen or lies out of number range.
fn grid_to_screen(img: &DImage, view: &View) -> Option<Transform> {
let s = view.scale;
let (x, y) = view.to_screen(img.origin);
// Screen y runs down, world y up.
let ts = Transform::from_row((img.right.x * s) as f32, (-img.right.y * s) as f32, (img.down.x * s) as f32, (-img.down.y * s) as f32, x, y);
(ts.is_finite() && ts.invert().is_some()).then_some(ts)
}
/// The image has a clip outline without area: nothing of it shows.
struct Hidden;
/// The part of the picture `img`'s clip outlines leave visible: `None` for all of it.
fn clip_mask(pm: &Pixmap, img: &DImage, view: &View, antialias: bool) -> Result<Option<tiny_skia::Mask>, Hidden> {
let mut mask: Option<tiny_skia::Mask> = None;
for outline in &img.clips {
let path = outline_path(outline, view).ok_or(Hidden)?;
match &mut mask {
// The first outline starts the mask; each further one narrows it.
None => {
let mut m = tiny_skia::Mask::new(pm.width(), pm.height()).ok_or(Hidden)?;
m.fill_path(&path, FillRule::EvenOdd, antialias, Transform::identity());
mask = Some(m);
}
Some(m) => m.intersect_path(&path, FillRule::EvenOdd, antialias, Transform::identity()),
}
}
Ok(mask)
}
/// A closed world-space outline as a screen path.
fn outline_path(outline: &[Vec2], view: &View) -> Option<tiny_skia::Path> {
let mut pb = PathBuilder::new();
for (i, q) in outline.iter().enumerate() {
let (x, y) = view.to_screen(*q);
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
pb.close();
pb.finish()
}
/// The pixels of `r` with each colour multiplied by its alpha.
fn premultiplied(r: &cadcraft_doc::Raster) -> Option<Pixmap> {
let mut data = Vec::with_capacity(r.rgba().len());
for [red, green, blue, a] in r.rgba().as_chunks::<4>().0 {
let m = |c: &u8| ((u16::from(*c) * u16::from(*a) + 127) / 255) as u8;
data.extend_from_slice(&[m(red), m(green), m(blue), *a]);
}
Pixmap::from_vec(data, tiny_skia::IntSize::from_wh(r.width(), r.height())?)
}
/// Render and encode as PNG.
pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Vec<u8>> {
render(list, view, o)?.encode_png().ok()
+1 -1
View File
@@ -375,7 +375,7 @@ pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, t: &Table, rgb: Ink, lw: f32) {
}
for tx in &g.texts {
let ts = crate::text_style(ctx.d, &tx.style);
let font = crate::style_font(&ts);
let font = cadcraft_fonts::TextFont::resolve(&ts.font);
// Cell content is MTEXT (`\P`, `{\Q15;…}`, `\F…;`): laid out as such, at the cell's
// alignment point (its numbering is MTEXT's attachment).
let mp = cadcraft_fonts::MTextParams {
-1
View File
@@ -137,7 +137,6 @@ fn vp(center: Vec2, w: f64, h: f64, view_center: Vec2, view_height: f64, id: u32
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
}
}
+2 -8
View File
@@ -5,16 +5,10 @@ pub(crate) fn text_style(d: &Drawing, name: &str) -> cadcraft_doc::TextStyle {
d.text_style(name).cloned().unwrap_or_default()
}
/// The font a text style draws with: its font file (TrueType, or an SHX font found by name),
/// its big font and its vertical flag.
pub fn style_font(ts: &cadcraft_doc::TextStyle) -> cadcraft_fonts::TextFont {
cadcraft_fonts::TextFont::resolve_style(&ts.font, &ts.big_font, ts.vertical)
}
/// Shape a TEXT / ATTRIB / ATTDEF with its style's font and generation flags.
pub fn place_text_entity(d: &Drawing, t: &cadcraft_doc::Text, value: &str) -> (cadcraft_fonts::Shaped, Bounds2) {
let ts = text_style(d, &t.style);
let font = style_font(&ts);
let font = cadcraft_fonts::TextFont::resolve(&ts.font);
let (h, v) = align(t.halign, t.valign);
let p = cadcraft_fonts::TextParams {
insert: t.insert.xy(),
@@ -42,7 +36,7 @@ pub fn layout_mtext_entity(d: &Drawing, t: &cadcraft_doc::MText) -> cadcraft_fon
rotation: t.rotation,
line_spacing: t.line_spacing,
line_spacing_exact: t.line_spacing_exact,
font: style_font(&ts),
font: cadcraft_fonts::TextFont::resolve(&ts.font),
width_factor: ts.width_factor,
oblique: ts.oblique,
columns: t.columns.as_ref().map(|c| cadcraft_fonts::MTextColumnSpec {
+6 -43
View File
@@ -1,6 +1,6 @@
#[cfg(not(target_arch = "wasm32"))]
use super::display::append;
use super::display::{DImage, DPrim, DisplayList, Kind, PlotText, VIEWPORT_CONTENT};
use super::display::{DPrim, DisplayList, Kind, PlotText, VIEWPORT_CONTENT};
use super::draw::entity;
#[cfg(not(target_arch = "wasm32"))]
use super::pipeline::PARALLEL_CHUNK;
@@ -88,12 +88,11 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
if !ok(s) {
return;
}
// Model space scaled onto the sheet and turned by the viewport's view twist.
let xf = vp.model_to_paper_matrix();
// The model box the area covers (turned, with a twist), with slack: entity bounds are
// approximate (text, dimensions).
let mbox = Bounds2::from_points(area.corners().map(|p| vp.paper_to_model(p)));
let win = mbox.expand(mbox.width().max(mbox.height()) * 0.05);
let xf = Mat3::translate(center).then_before(Mat3::scale(s, s)).then_before(Mat3::translate(-vp.view_center));
let mhalf = Vec2::new(area.width(), area.height()) / (2.0 * s);
let mcenter = vp.view_center + (area.center() - center) / s;
// Model window with slack: entity bounds are approximate (text, dimensions).
let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
// PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in
// paper space, whatever the viewport scale.
let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 };
@@ -188,14 +187,6 @@ pub fn viewport_clip(d: &Drawing, handle: Handle, vp: &cadcraft_doc::Viewport) -
clip::PolyClip::new(&clip::boundary_polygon(&be.kind)?)
}
/// `list` clipped to `clip` (a clipped viewport's outline in the list's own units), for a list
/// drawn inside a clipped viewport apart from its contents.
pub fn clip_list(list: &DisplayList, clip: &clip::PolyClip) -> DisplayList {
let mut out = DisplayList::default();
append_clipped(&mut out, list, &Clip::Poly(clip));
out
}
/// What viewport contents are clipped to.
enum Clip<'a> {
/// The viewport rectangle (the fast path).
@@ -238,11 +229,6 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) {
push_raw(dst, p, Kind::Point, &[*q]);
}
}
(Kind::Image { index }, _) => {
if let Some(img) = src.images.get(index as usize) {
push_clipped_image(dst, p, img, pts, clip);
}
}
(Kind::Infinite { ray }, Clip::Rect(rect)) => {
if let (Some(base), Some(dir)) = (pts.first(), pts.get(1))
&& let Some((a, c)) = clip::clip_infinite(*base, *dir, ray, rect)
@@ -261,29 +247,6 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) {
}
}
/// Append image `img` (primitive `p`, corners `corners`) to `dst`, to be cut to `clip` when it
/// is drawn: the picture is not cut here, the outline is added to those it carries.
fn push_clipped_image(dst: &mut DisplayList, p: &DPrim, img: &DImage, corners: &[Vec2], clip: &Clip) {
let (outline, area) = match clip {
Clip::Rect(rect) => (vec![rect.min, Vec2::new(rect.max.x, rect.min.y), rect.max, Vec2::new(rect.min.x, rect.max.y)], *rect),
Clip::Poly(c) => (c.polygon().to_vec(), c.bounds()),
};
let own = Bounds2::from_points(corners.iter().copied());
if !own.intersects(&area) {
return;
}
let Ok(index) = u32::try_from(dst.images.len()) else { return };
let mut img = img.clone();
img.clips.push(outline);
dst.images.push(img);
// The list's bounds take only the part inside the clip, as for the other primitives.
let before = dst.bounds;
push_raw(dst, p, Kind::Image { index }, corners);
dst.bounds = before;
dst.bounds.add(own.min.max(area.min));
dst.bounds.add(own.max.min(area.max));
}
fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) {
let min = match kind {
Kind::Polyline => 2,
+1 -3
View File
@@ -95,9 +95,7 @@ pub fn dashed(p: &LwPolyline, tol: f64, lt: &Linetype, scale: f64, min_pattern:
continue;
}
let (Some(first), Some(last)) = (at(run, a), at(run, b)) else { continue };
// The samples strictly between a and b: `run` is ordered by `s`, so a slice, not a scan.
let (lo, hi) = (run.partition_point(|x| x.s <= a), run.partition_point(|x| x.s < b));
let inner = run.get(lo..hi.max(lo)).unwrap_or_default().iter().copied();
let inner = run.iter().filter(|x| x.s > a && x.s < b).copied();
let pts: Vec<Sample> = std::iter::once(first).chain(inner).chain(std::iter::once(last)).collect();
for w in pts.windows(2) {
if let [x, y] = w {
+11
View File
@@ -211,3 +211,14 @@ fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() {
assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1);
}
}
/// A pattern with a huge number of tiny dashes makes a swatch of a few thousand runs, stopping
/// there; and many scanlines over a many-edged boundary are charged to the hatch's work allowance.
#[test]
fn compact_patterns_stop_at_the_swatch_limits() {
let lines = vec![PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![1e-6, -1e-6] }];
let started = std::time::Instant::now();
let runs = cadcraft_render::pattern_swatch(&lines, 1.0);
assert!(!runs.is_empty() && runs.len() <= 4000, "{}", runs.len());
assert!(started.elapsed() < std::time::Duration::from_secs(20));
}
@@ -167,7 +167,6 @@ fn viewport_contents_get_their_pens() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
let space = Space::Paper("Layout1".into());
d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap();
-1
View File
@@ -25,7 +25,6 @@ fn drawing(psltscale: i64) -> Drawing {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap();
d

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