mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-04 01:21:42 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24cd02feb3 | ||
|
|
27f3a18cc9 |
Generated
-3
@@ -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
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
});
|
||||
|
||||
@@ -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:?}"))
|
||||
}
|
||||
|
||||
@@ -146,7 +146,6 @@ pub fn install_io() {
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
|
||||
@@ -1,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.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()))
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
|
||||
@@ -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()])) })
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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>)> {
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,38 +1,20 @@
|
||||
use cadcraft_geom::Bounds2;
|
||||
|
||||
use super::{Deserialize, Serialize, Vec2};
|
||||
|
||||
/// A 2D view of a space: world centre and visible height in drawing units, and the view twist
|
||||
/// (VIEWTWIST, DVIEW TWist): how far the view is turned on screen, in radians counterclockwise
|
||||
/// (the drawing appears turned by `twist`; 0 is the plain plan view).
|
||||
/// A 2D view of a space: world centre and visible height in drawing units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct View {
|
||||
pub center: Vec2,
|
||||
pub height: f64,
|
||||
#[serde(default)]
|
||||
pub twist: f64,
|
||||
}
|
||||
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
View { center: Vec2::new(6.0, 4.5), height: 10.0, twist: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// Box `b` (world) as a view turned by `twist` shows it: the world centre of the box it
|
||||
/// makes on screen, and that box's width and height (`b`'s own when there is no twist).
|
||||
pub fn twisted_box(b: Bounds2, twist: f64) -> (Vec2, f64, f64) {
|
||||
if twist == 0.0 || !twist.is_finite() {
|
||||
return (b.center(), b.width(), b.height());
|
||||
}
|
||||
let r = Bounds2::from_points(b.corners().map(|p| p.rotate(twist)));
|
||||
(r.center().rotate(-twist), r.width(), r.height())
|
||||
View { center: Vec2::new(6.0, 4.5), height: 10.0 }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usable_view(v: &View) -> bool {
|
||||
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 && v.twist.is_finite()
|
||||
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12
|
||||
}
|
||||
|
||||
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
|
||||
|
||||
@@ -1,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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
//! FF8: the SHX fonts a drawing's text styles name are found beside the drawing it opens. The
|
||||
//! font is compiled here from shape source written for the test.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::{Drawing, TextStyle};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn opening_a_drawing_finds_the_shx_fonts_beside_it() {
|
||||
set_io(IoHooks {
|
||||
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: None,
|
||||
});
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-shx-beside-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let font = cadcraft_fonts::shx::compile("*0,4,beside\n6,2,0,0\n*65,7,UCA\n1,064,040,06C,2,020,0\n").unwrap();
|
||||
std::fs::write(dir.join("cadcraft-beside-engine.shx"), font).unwrap();
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.text_styles.push(TextStyle { name: "Beside".into(), font: "cadcraft-beside-engine.shx".into(), ..TextStyle::default() });
|
||||
let path = dir.join("host.dxf");
|
||||
std::fs::write(&path, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
|
||||
|
||||
assert!(!cadcraft_fonts::TextFont::resolve("cadcraft-beside-engine.shx").is_shx());
|
||||
let mut s = Session::new();
|
||||
s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap();
|
||||
let style = s.doc().unwrap().text_style("Beside").cloned().unwrap();
|
||||
let font = cadcraft_fonts::TextFont::resolve_style(&style.font, &style.big_font, style.vertical);
|
||||
assert!(font.is_shx());
|
||||
assert!((cadcraft_fonts::text_width(&font, "AA", 3.0, 1.0) - 6.0).abs() < 1e-9);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,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));
|
||||
}
|
||||
@@ -14,4 +14,3 @@ skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
icu_segmenter = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
|
||||
+13
-72
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)))];
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <.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, ¶ms);
|
||||
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)], <, 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)], <, 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)], <, 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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user