Compare commits

...
Author SHA1 Message Date
Tanish Manku 2099288f54 Merge pull request #2751 from storytold/land/1944
SHX fonts, big fonts and unifonts; linetype shapes (#1944)
2026-11-03 18:20:27 -07:00
Tanish Manku de753897c4 Merge main into land/1944 2026-11-03 18:00:03 -07:00
Tanish Manku 39ee2e5960 SHX fonts: review fixes (search the current folder for a bare drawing name, one lock for the font cache generation, no split_paths on wasm, MTEXT \U+ upper case as the layout, a point budget for repeated linetype shapes, web picks report skipped files and cap bytes read at once) 2026-11-03 17:59:52 -07:00
Tanish Manku 5f8999f303 Merge pull request #2748 from storytold/land/1230
View twist: DVIEW, twisted views, tiles and layout viewports (#1230, #2022)
2026-11-03 17:59:40 -07:00
Tanish Manku 75049718c9 View twist: build with raster images from main (an axis rectangle is turned on a twisted screen, so it cuts an image as an outline) 2026-11-03 17:35:23 -07:00
Tanish Manku c2d62a2842 Merge main into land/1944 2026-11-03 17:11:42 -07:00
Tanish Manku 68e01dd487 Merge main into land/1230 2026-11-03 17:11:25 -07:00
Tanish Manku 6d3507f98d Merge pull request #2749 from storytold/land/2505
Raster images drawn on screen and in PNG output (#2505)
2026-11-03 17:10:59 -07:00
Tanish Manku 26e96cfb56 View twist: a locked viewport keeps its twist; the untwisted file view fits the extents as the turned view sees them
Review fixes from CodeRabbit on #2748:
- DVIEW TWist and view.set refuse to turn a locked active viewport ("The viewport is locked.")
- the untiled *Active VPORT record fits the extents rotated by the twist it writes, with the twist normalised
2026-11-03 17:10:20 -07:00
Tanish Manku 945c7f456b Merge remote-tracking branch 'origin/main' into land/1944 2026-11-03 16:52:21 -07:00
Tanish Manku f10e41409d Images: read no more than the limit, keep plot bounds to what a viewport shows, free textures with no images left
Review fixes from CodeRabbit on #2749:
- the image file is read through read_capped, so a file that grows after the size check can't exhaust memory
- an image cut by a layout viewport widens the sheet's bounds only by the part inside it, as other primitives do
- uploaded tiles of closed drawings are dropped also when the next list has no images
2026-11-03 16:52:11 -07:00
Tanish Manku bd22363d5e Merge remote-tracking branch 'origin/main' into land/2505 2026-11-03 16:40:52 -07:00
Tanish Manku a6ef259fec Merge remote-tracking branch 'origin/main' into land/1230 2026-11-03 16:40:36 -07:00
Dylan M. Taylor dad7d1c138 fix(render): charge every block entry, index attribute definitions, and walk dashes and outlines in linear time (#2724)
* fix(render): charge every block entry, index attribute definitions, and walk dashes and outlines in linear time

Array copies skipped ATTDEF entries before charging the expansion budget, each attribute searched its block for a definition, each dash scanned every centreline sample, and outline chaining searched a vertex's segment list from its start on every visit. Each is now charged, indexed or cursored.

* address review: drop wall-clock bound from outline test
2026-11-03 18:11:23 -05:00
Dylan M. Taylor b1b3754ffa fix(engine): autosave files are owner-only and lock files are never written through a link (#2717)
The heartbeat opened <instance>.lock with a create-and-truncate that followed symlinks, and fell back to std::fs::write, so another account sharing SAVEFILEPATH could plant a link and have the app truncate a file of the victim's. The lock is now opened with O_NOFOLLOW|O_NONBLOCK and must be a plain file, and unlocked writes replace the file through the atomic writer. Autosave files are created 0600 regardless of umask. Lock pruning parses the folder's autosave names once instead of once per lock.
2026-11-03 18:11:06 -05:00
Tanish Manku 0e19f9d1d1 SHX fonts: build on main (a bottom-justified SHX text sits a third of the height down; explicit render imports after #2239) 2026-11-03 15:41:48 -07:00
Tanish Manku 8232290830 View twist: a window too large to turn saves no view, and the saved one stays 2026-11-03 15:40:39 -07:00
Tanish Manku 457fc2319e View twist: review fixes
- Every engine twist (DVIEW, view.set, opening a drawing, the untiled view) goes through norm_angle, so a tiny negative twist is 0, not a full turn.
- VIEW Window in a twisted view stores the window's size along the view's axes (View::twisted_box), so a restore shows all of it.
- A deferred tangent or perpendicular snap on an arc in a twisted layout viewport turns the arc's angles with its centre.
- The active viewport's cached drawing is rebuilt when its twist changes; moving and scaling it can't turn it.
2026-11-03 15:14:10 -07:00
Tanish Manku 15afc47296 Images: Chinese labels for IMAGEATTACH and IMAGE 2026-11-03 15:12:28 -07:00
Tanish Manku d3226e629e Images: the highlight outline uses main's world-point line helper 2026-11-03 15:04:12 -07:00
Tanish Manku 238aa1789c Images: draw an IMAGE in its own function, under the complexity limit 2026-11-03 15:03:06 -07:00
Tanish Manku f1312113d2 Images: format after the merge with main 2026-11-03 15:01:55 -07:00
Tanish Manku 28421dd1d7 Merge #1944 (SHX shape fonts, big fonts and unifonts; linetype shapes) onto main 2026-11-03 14:59:22 -07:00
Tanish Manku 162cc4792b View twist: read and write every twist within one turn, so #2022's hostile-angle checks hold
A twist of -1e-20 degrees read as a full turn (2π), and a huge twist set from JSON or MCP was written as a huge angle. Readers and writers of VIEWPORT 51, VPORT 51 and VIEW 50 now go through norm_angle.
2026-11-03 14:58:37 -07:00
Tanish Manku 4c00b6732f Merge #2505 (raster images drawn on screen and in PNG output) onto main 2026-11-03 14:52:15 -07:00
Tanish Manku 58bb1e065e docs: date the view twist revision rows to the day they land 2026-11-03 14:50:59 -07:00
Tanish Manku 0955078f56 Merge #1230 (view twist: DVIEW, twisted views, tiles and layout viewports) over #2022 2026-11-03 14:50:43 -07:00
mattlafyandClaude Opus 5.5 f04be0548f fix: new Viewport, VportTile and Xf initializers from main set the view twist; DView has its Chinese label
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:56:46 -05:00
mattlafy 852688e000 Merge remote-tracking branch 'upstream/main' into wt-dview 2026-11-03 14:17:57 -05:00
ATARYS-LABandClaude Opus 5.5 6d31491952 Viewport twist: the viewport of main's block expansion test has no twist
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkJ8y75DE7yLZ7utxSRDeP
2026-11-03 18:35:48 +01:00
ATARYS-LABandClaude Opus 5.5 c8e1671eb4 Viewport twist: normalise with norm_angle on read and write; test the untwisted case on a real viewport
From CodeRabbit's review: a tiny negative angle no longer reads as a full turn, a huge finite
twist from JSON or MCP no longer writes an infinite group 51, and the "no group 51" check now
looks at a viewport that exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG
2026-11-03 18:32:40 +01:00
ATARYS-LABandClaude Opus 5.5 f7494840dc A layout viewport keeps its view twist through DXF and DWG (part of #1316)
VIEWPORT group 51 was not read, so a twisted viewport came back untwisted and a DXF or DWG
saved by CADCraft wrote it without its twist; AutoCAD then showed the sheet rotated. The twist
is now read (finite angles only, brought into one turn) and written when it isn't zero; the DWG
bridge already carries group 51 both ways. The display and plots don't apply it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG
2026-11-03 18:32:40 +01:00
mattlafy cbc9db75b1 Merge remote-tracking branch 'upstream/main' into pr/dview
# Conflicts:
#	crates/engine/src/prompt.rs
#	crates/engine/src/session/execute.rs
#	crates/io/src/dxf_read/entities.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/cmdline.rs
2026-11-02 21:53:26 -05:00
9d4b3bb091 Raster images are drawn on screen and in PNG output (#1756)
An IMAGE whose file exists was drawn as a frame with two diagonals. Now
its pixels are loaded when a drawing opens and drawn on screen and in PNG
output, with rotation, clip boundary, fade and object transparency. A file
that can't be read keeps the crossed-out frame and reports a message.

Ported from the raster commit of #1029 onto the split render and canvas
modules. New here: large images are drawn from a pyramid of levels cut
into tiles uploaded as they come into view, instead of one texture with
pixels skipped, so a large scan stays sharp zoomed in and doesn't shimmer
zoomed out.

Limits are listed in docs/gaps.md (FF10): PDF and SVG output write only
the frame, and on screen images lie under all other objects.

Co-Authored-By: Chanthawat Kiriyadee <enwuft@icloud.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:05:02 +01:00
mattlafy 2a85653976 Split the DVIEW prompt-steps test under the complexity limit 2026-10-28 15:41:01 -04:00
mattlafy 12fd74a60f Use explicit imports in DVIEW 2026-10-28 15:35:20 -04:00
mattlafy e1d0a4747e Fix imports after merging upstream/main 2026-10-28 15:34:34 -04:00
mattlafy b7c62eb93d Merge remote-tracking branch 'upstream/main' into pr/dview
# Conflicts:
#	crates/doc/src/entity.rs
#	crates/engine/src/cmd/layout.rs
#	crates/engine/src/cmd/modify2.rs
#	crates/engine/src/lib.rs
#	crates/io/src/dxf_read.rs
#	crates/io/src/dxf_write.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	docs/gaps.md
2026-10-28 15:30:03 -04:00
Francesco BVandClaude Opus 5.5 52c1e6de98 fonts: read SHX shape fonts, big fonts and unifonts; draw linetype shapes (FF8, FF9)
SHX files referenced by text styles are found by file name through a
font map, registered bytes, an app provider, the drawing's folder and a
search path (CADCRAFT_SHXPATH); never in a CAD program's install folder.
TEXT, MTEXT, attributes, dimension text and table cells draw with the
style's font, big font and vertical flag; linetype shape elements are
drawn when their file is found. The web build registers .shx files
picked or dropped with a drawing. All test fonts are compiled in code
from shape source.

Plain MTEXT text decodes \U+XXXX escapes as the layout does, and an SHX
file whose name differs in case from the one asked for is found on
case-sensitive file systems too (the folder is looked through).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 18:10:33 +01:00
mattlafy 1823eeab19 fix(view): twist follows named views in viewports, undo, screen-direction crossing, clipped DVIEW preview, DCS target
- VIEW Save inside an MSPACE viewport stores the viewport's twist; VIEW Restore into an
  unlocked viewport turns the viewport to the saved twist (one undo step).
- Undo and redo keep the untiled Model view's twist record in step with the live view.
- A selection window is a crossing window when dragged leftwards on screen, also in a
  twisted view (preview and selection alike).
- DVIEW's preview inside a clipped viewport is clipped to the viewport's outline.
- DXF view centres are read relative to the view target (VIEWPORT/VPORT group 17, VIEW
  group 12), the origin of display coordinates.
2026-10-26 16:19:28 -04:00
mattlafy 97761eb2bf io: view-centre helpers before the test module 2026-10-25 16:43:05 -04:00
mattlafy 8b97b4f47e Merge remote-tracking branch 'upstream/main' into pr/dview
# Conflicts:
#	crates/engine/src/sysvars.rs
#	crates/io/src/dxf_read.rs
#	crates/io/tests/pdf_layers.rs
#	crates/ui-egui/src/canvas.rs
2026-10-25 16:26:25 -04:00
mattlafyandClaude Opus 5.5 4c61c3c889 A twisted view's centre is read and written in display coordinates
VIEWPORT and VPORT group 12 and VIEW group 10 hold the view centre turned
by the view twist. 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 shows its own part of the drawing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:48:01 -04:00
mattlafy 94b6c485b4 DVIEW records a viewport twist as one undo step when cancelled 2026-10-20 00:00:47 -04:00
mattlafy 4c75916c75 Keep the saved view twist when a template opens or xrefs load after open 2026-10-19 22:40:23 -04:00
mattlafy ecd1bdc214 Merge remote-tracking branch 'upstream/main' into fix/dview
# Conflicts:
#	crates/engine/src/prompt.rs
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-19 22:19:12 -04:00
mattlafyandClaude Opus 5.5 212f6cc988 View twist: DVIEW, VIEWTWIST, per-tile, named-view and layout-viewport twist
A model space view carries a twist (radians CCW, View.twist, serde default
0). VIEWTWIST reports it read-only. Each VPORTS tile keeps its own twist
(VportTile.twist, DXF VPORT group 51 read and written; a single untiled
Model view's twist is kept as a lone *Active VPORT tile). VIEW Save stores
and Restore applies NamedView::twist. ZOOM fits in the twisted screen frame;
zooms, pans, the 3D mouse and view.set keep it; ZOOM Previous turns it back.

DVIEW starts with "Select objects or <use DVIEWBLOCK>:" (the picked objects,
or the DVIEWBLOCK sample, are the only things drawn while it runs) and
repeats the "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/
TWist/CLip/Hide/Off/Undo]:" prompt. TWist, PAn, Zoom, Off and Undo work on
2D views; the 3D options say they are not available yet (docs/gaps.md F1).

DVIEW works inside an active layout viewport: the doc Viewport gains a view
twist (DXF VIEWPORT group 51), drawn turned on screen and in plots, mapped
through MSPACE views, snaps and CHSPACE; a twist made there is one undo
step. A layout with no active viewport refuses it, as does view.set's twist.

Canvas: the model view and every VPORTS tile draw turned by their twist.
`Xf` gains `twist`: `to_screen` turns (p - centre) by it before scaling,
`to_world` is its exact inverse, `world_bounds` is the box of the four
turned rect corners. The GPU needs no shader change: `Xf::gpu_xform` builds
the callback xform for the drawing, drag, highlight, viewport and mask
layers. Pans map screen deltas back through the twist; clicks, hover, snaps,
grips and wheel zoom go through `to_world`/`to_screen`. Grid lines and axes
stay world-aligned; the selection window is drawn as the world box it
selects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:23:53 -04:00
119 changed files with 7056 additions and 287 deletions
Generated
+3
View File
@@ -729,6 +729,7 @@ name = "cadcraft-fonts"
version = "0.17.0"
dependencies = [
"cadcraft-geom",
"encoding_rs",
"harfrust 0.14.0",
"icu_segmenter",
"skrifa 0.48.0",
@@ -755,6 +756,7 @@ dependencies = [
"cadcraft-geom",
"cadcraft-render",
"craft-pdf",
"image",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
@@ -821,6 +823,7 @@ name = "cadcraft-web"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-io",
"cadcraft-ui-egui",
"eframe",
+1 -1
View File
@@ -54,7 +54,7 @@ unicode-bidi = "0.3.18"
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
quick-xml = "0.42"
+1
View File
@@ -45,6 +45,7 @@ fn install_io() {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
+1
View File
@@ -18,6 +18,7 @@ path = "src/main.rs"
cadcraft-engine = { workspace = true }
cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
cadcraft-fonts = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
+1
View File
@@ -119,6 +119,7 @@ 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())
});
+54 -8
View File
@@ -2,6 +2,11 @@
//! 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;
@@ -12,8 +17,14 @@ 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.
const ACCEPT: &str = ".dxf,.dwg";
/// 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;
/// A file read in the browser (name, contents), or why it couldn't be read.
type Upload = Result<(String, Vec<u8>), String>;
@@ -22,12 +33,14 @@ 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(), ctx: ctx.clone() }
Inbox { files: Rc::default(), reading: Rc::default(), ctx: ctx.clone() }
}
/// Show the browser's file picker and return at once. A chosen file arrives through
@@ -51,13 +64,23 @@ 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 a file was chosen (browsers don't fire `change` on cancel).
// Called once, when files were chosen (browsers don't fire `change` on cancel).
let on_change = wasm_bindgen::closure::Closure::once_into_js(move || {
if let Some(file) = chosen.files().and_then(|f| f.get(0)) {
inbox.read(file);
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(", "))));
}
}
chosen.remove();
});
@@ -87,12 +110,18 @@ 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);
});
}
@@ -118,9 +147,21 @@ impl Inbox {
self.ctx.request_repaint();
}
/// Open what arrived since the last frame (or report why it couldn't be read).
/// 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.
pub fn drain(&self, app: &mut CadApp) {
for upload in self.files.take() {
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 {
match upload {
Ok((name, bytes)) => app.open_bytes(&name, &bytes),
Err(e) => app.open_failed(e),
@@ -129,6 +170,11 @@ impl Inbox {
}
}
/// Whether `name` is an SHX font or shape file.
fn is_shx(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".shx")
}
fn js_error(e: wasm_bindgen::JsValue) -> String {
e.as_string().unwrap_or_else(|| format!("{e:?}"))
}
+1
View File
@@ -146,6 +146,7 @@ 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())
});
+39 -2
View File
@@ -1,3 +1,5 @@
use cadcraft_geom::Mat3;
use super::common::Handle;
use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes};
@@ -25,6 +27,39 @@ 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 {
@@ -41,6 +76,7 @@ impl Default for Viewport {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
}
}
}
@@ -59,8 +95,9 @@ 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 (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.
/// 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.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clip: Vec<Vec2>,
/// The clip boundary is applied.
+5
View File
@@ -16,6 +16,7 @@ mod header;
pub mod library;
mod plotstyle;
mod plotter;
mod raster;
mod refedit;
mod store;
mod tables;
@@ -33,6 +34,7 @@ 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::*;
@@ -136,6 +138,8 @@ 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 {
@@ -196,6 +200,7 @@ impl Drawing {
refedit: None,
skipped_records: BTreeMap::new(),
portable_fonts: false,
rasters: Rasters::default(),
}
}
+93
View File
@@ -0,0 +1,93 @@
//! 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())
}
}
+4 -1
View File
@@ -863,7 +863,7 @@ pub struct NamedView {
/// The layer state restored with the view (its layer snapshot).
#[serde(default)]
pub layer_state: Option<String>,
/// View twist (radians, counterclockwise); kept for files, not applied to the 2D display.
/// View twist (radians, counterclockwise); VIEW Restore applies it to the model space view.
#[serde(default)]
pub twist: f64,
/// The layout a paper space view belongs to (`None`: a model space view; an empty name: a
@@ -884,6 +884,9 @@ 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
+58 -6
View File
@@ -405,10 +405,11 @@ 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 };
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
if !has_files && !owner_alive(dir, instance) {
if !with_files.contains(instance) && !owner_alive(dir, instance) {
let _ = std::fs::remove_file(dir.join(n));
}
}
@@ -435,7 +436,22 @@ 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).
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
// 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()))
}
impl Autosaver {
@@ -586,7 +602,7 @@ mod native {
for (held, file) in &self.locks {
let refreshed = match file {
Some(file) => file.set_modified(SystemTime::now()),
None => std::fs::write(held, id.to_string()),
None => write_lock(held, id),
};
if held == path {
current = Some(refreshed);
@@ -595,10 +611,22 @@ mod native {
if let Some(refreshed) = current {
return refreshed;
}
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
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"));
}
if file.try_lock().is_err() {
self.locks.push((path.to_path_buf(), None));
return std::fs::write(path, id.to_string());
return write_lock(path, id);
}
file.set_len(0)?;
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
@@ -627,6 +655,30 @@ mod native {
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// A symlink planted at the lock path is neither opened through nor written through.
#[test]
fn lock_writes_do_not_follow_a_planted_link() {
let dir = std::env::temp_dir().join(format!("cadcraft-lock-link-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let victim = dir.join("victim.txt");
std::fs::write(&victim, b"precious").unwrap();
let link = dir.join("1-a.lock");
std::os::unix::fs::symlink(&victim, &link).unwrap();
let mut a = Autosaver::new("1-a");
assert!(a.refresh_lock(&link).is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
write_lock(&link, 42).unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&link).unwrap(), b"42");
std::fs::remove_dir_all(dir).unwrap();
}
}
}
#[cfg(test)]
+2
View File
@@ -18,6 +18,7 @@ mod gripcmds;
mod griphot;
pub(crate) mod groups;
pub(crate) mod hatch;
mod image;
mod inquiry;
mod layer;
mod layout;
@@ -200,6 +201,7 @@ 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());
+10 -1
View File
@@ -62,11 +62,20 @@ 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() {
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) });
// 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 });
}
echo_all(s, &out);
}
/// The file a reference saved as `path` in a drawing at `host` is read from, if there is one
/// (xrefs and raster images look in the same places).
pub(crate) fn find_file(path: &str, host: Option<&str>) -> Option<std::path::PathBuf> {
load::find(path, host.map(std::path::Path::new), false).ok()
}
/// The open drawing's path (xref paths are relative to its folder).
fn host(s: &Session) -> Option<String> {
s.state().ok().and_then(|st| st.path.clone())
+4 -4
View File
@@ -55,11 +55,11 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["printpdf", "pdfb"])
.params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match")
.noundo(),
CommandSpec::new("attach", "Attach", |_, _| {
Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into()))
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())),
})
.params("not available yet")
.noundo(),
.params("{path: image file, …} as IMAGEATTACH; other file types are not available yet"),
CommandSpec::new("share", "Share", |_, _| {
Err(EngineError::Other("SHARE is not available yet: there is no online service to share through; EXPORT or PUBLISH the drawing and send the file".into()))
})
+22
View File
@@ -70,6 +70,19 @@ 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>;
@@ -311,6 +324,7 @@ 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))
}
@@ -320,6 +334,12 @@ 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 })
}
@@ -363,6 +383,7 @@ 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")]
@@ -373,6 +394,7 @@ 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))
}
+2 -1
View File
@@ -59,7 +59,8 @@ 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);
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6) });
let twist = st.view().twist;
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6), twist });
}
Ok(Some(json!({ "index": i, "title": title, "template": path })))
}
+353
View File
@@ -0,0 +1,353 @@
//! Raster images (IMAGEATTACH, IMAGE). An IMAGE entity names its file; the pixels are decoded
//! when the drawing opens or the image is attached and kept with the open drawing
//! ([`Drawing::rasters`]), never in the file. An image whose file can't be found or read stays in
//! the drawing as a crossed-out frame.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use cadcraft_doc::{Drawing, EntityKind, Image, Raster};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::file::base64_decode;
use super::{CommandSpec, bad, f64_or, point_param, str_param};
use crate::{Result, Session};
/// Most image files loaded per drawing.
const MAX_IMAGE_FILES: usize = 1000;
/// Largest image file read (bytes).
#[cfg(not(target_arch = "wasm32"))]
const MAX_IMAGE_BYTES: u64 = 256 << 20;
/// Most decoded pixel bytes kept per drawing; images past it stay unloaded.
const MAX_LOADED_BYTES: u64 = 2 << 30;
/// Most image files decoded at the same time when a drawing opens.
const MAX_LOADERS: usize = 4;
/// Longest image path taken from a caller.
const MAX_PATH: usize = 4096;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("imageattach", "Raster Image Reference...", run_imageattach).alias(&["iat"]).params(
"{path: PNG/JPEG/BMP/TIFF file (saved as given; a relative path is relative to this drawing's folder), data?: base64 file contents \
(instead of reading `path`), at?: [x,y] lower-left corner (default 0,0), scale?: number (default 1: the image is one drawing unit \
wide), width?: width in drawing units (instead of `scale`), rotation? (degrees), name? (default: the file name)} → {name, handle, \
path, pixels: [w, h], size: [w, h]}",
),
CommandSpec::new("image", "Raster Images", run_image)
.alias(&["im"])
.params("{} → {images: [{name, path, foundAt, status: Loaded|Not Found|Not loaded, references, pixels?: [w, h]}]}")
.noundo(),
]
}
/// Whether `ext` (lower case, no dot) is an image file type IMAGEATTACH reads.
pub(crate) fn is_image_ext(ext: &str) -> bool {
matches!(ext, "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff")
}
/// Every IMAGE entity of `d`: in model space, on the layouts and in the blocks.
fn images(d: &Drawing) -> impl Iterator<Item = &Image> {
let model = std::iter::once(&d.model);
let layouts = d.layouts.iter().map(|l| &l.entities);
let blocks = d.blocks.values().map(|b| &b.entities);
model.chain(layouts).chain(blocks).flat_map(|store| store.iter()).filter_map(|e| match &e.kind {
EntityKind::Image(i) => Some(i),
_ => None,
})
}
/// The image files `d` names, each once, in the order they are first used.
fn paths(d: &Drawing) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut paths = Vec::new();
for i in images(d) {
if !i.path.trim().is_empty() && seen.insert(i.path.as_str()) {
paths.push(i.path.clone());
}
}
paths
}
/// The contents of the image file saved as `path` in a drawing at `host`.
fn read_file(path: &str, host: Option<&str>) -> std::result::Result<Vec<u8>, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let file = super::blocks::xref::find_file(path, host).ok_or("file not found")?;
let len = std::fs::metadata(&file).map_err(|e| e.to_string())?.len();
if len > MAX_IMAGE_BYTES {
return Err(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20));
}
// Read no more than the limit: the file can grow or change after the check above.
let limit = usize::try_from(MAX_IMAGE_BYTES).unwrap_or(usize::MAX);
crate::capped_read::read_capped(&file, limit).map_err(|e| {
if e.kind() == std::io::ErrorKind::InvalidData { format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20) } else { e.to_string() }
})
}
#[cfg(target_arch = "wasm32")]
{
let _ = (path, host);
Err("files are not available on the web".into())
}
}
/// Decode an image file. A decoder that panics on a damaged file fails this image only: the
/// drawing it belongs to must still open.
fn decode(bytes: &[u8]) -> std::result::Result<Raster, String> {
let hook = super::file::image_decoder().ok_or("image files can't be read in this build")?;
match std::panic::catch_unwind(|| hook(bytes)) {
Ok(r) => r,
Err(_) => Err("the file is damaged and can't be read".into()),
}
}
fn bytes_of(r: &Raster) -> u64 {
u64::try_from(r.rgba().len()).unwrap_or(u64::MAX)
}
/// Load the images of drawing `i`, just opened. Those that can't be loaded are reported and
/// stay crossed-out frames; opening never fails over an image.
pub(crate) fn after_open(s: &mut Session, i: usize) {
let Some(st) = s.docs.get_mut(i) else { return };
let paths = paths(&st.doc);
if paths.is_empty() {
return;
}
let mut rasters = st.doc.rasters.clone();
let notes = load_missing(&paths, st.path.as_deref(), &mut rasters);
if !rasters.is_empty() {
// Loading pixels is not a change to the drawing. A drawing counts as unchanged while
// it is the very one that was saved, so the saved one follows.
let unchanged = Arc::ptr_eq(&st.doc, &st.saved);
Arc::make_mut(&mut st.doc).rasters = rasters;
if unchanged {
st.saved = st.doc.clone();
}
}
for n in notes {
s.echo(n);
}
}
/// Read and decode into `rasters` the files of `paths` that are not in it yet, for a drawing at
/// `host`. Returns one message per file that could not be loaded.
fn load_missing(paths: &[String], host: Option<&str>, rasters: &mut cadcraft_doc::Rasters) -> Vec<String> {
let mut notes = Vec::new();
let mut missing: Vec<&str> = paths.iter().map(String::as_str).filter(|p| rasters.get(p).is_none()).collect();
if missing.len() > MAX_IMAGE_FILES {
missing.truncate(MAX_IMAGE_FILES);
notes.push(format!("Images: only the first {MAX_IMAGE_FILES} image files are loaded"));
}
let decoded = decode_files(&missing, host);
// Kept in the order the drawing uses them, up to the memory limit.
let mut kept_bytes = 0u64;
for (path, result) in missing.iter().zip(decoded) {
match result {
Ok(r) => {
kept_bytes = kept_bytes.saturating_add(bytes_of(&r));
if kept_bytes > MAX_LOADED_BYTES {
notes.push(over_limit(path));
break;
}
rasters.insert(path, Arc::new(r));
}
Err(e) => notes.push(format!("Image {path}: {e}")),
}
}
notes
}
fn over_limit(path: &str) -> String {
format!("Image {path}: not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20)
}
/// Read and decode the image files `paths` of a drawing at `host`, a few at a time (decoding is
/// what opening a drawing with large images waits for). The results are in the order of `paths`.
fn decode_files(paths: &[&str], host: Option<&str>) -> Vec<std::result::Result<Raster, String>> {
// Decoded bytes so far, shared by the loaders: past the limit no further file is started.
let decoded_bytes = AtomicU64::new(0);
let load = |path: &str| -> std::result::Result<Raster, String> {
if decoded_bytes.load(Ordering::Relaxed) > MAX_LOADED_BYTES {
return Err(format!("not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20));
}
let r = read_file(path, host).and_then(|bytes| decode(&bytes))?;
decoded_bytes.fetch_add(bytes_of(&r), Ordering::Relaxed);
Ok(r)
};
let mut results: Vec<Option<std::result::Result<Raster, String>>> = paths.iter().map(|_| None).collect();
let loaders = loader_count(paths.len());
if loaders > 1 {
// Each loader takes the next file nobody has started.
let next = AtomicUsize::new(0);
let work = || {
let mut done = Vec::new();
loop {
let index = next.fetch_add(1, Ordering::Relaxed);
let Some(path) = paths.get(index) else { break };
done.push((index, load(path)));
}
done
};
let finished: Vec<_> = std::thread::scope(|scope| {
let started: Vec<_> = (0..loaders).filter_map(|_| std::thread::Builder::new().spawn_scoped(scope, work).ok()).collect();
started.into_iter().filter_map(|loader| loader.join().ok()).flatten().collect()
});
for (index, result) in finished {
if let Some(slot) = results.get_mut(index) {
*slot = Some(result);
}
}
}
// One loader, or files a loader that could not start or finish left behind.
results.into_iter().zip(paths).map(|(result, path)| result.unwrap_or_else(|| load(path))).collect()
}
/// How many files are decoded at once: one per processor, at most [`MAX_LOADERS`] (each large
/// image needs several hundred megabytes while it is decoded) and no more than there are files.
fn loader_count(files: usize) -> usize {
#[cfg(not(target_arch = "wasm32"))]
let processors = std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(1);
#[cfg(target_arch = "wasm32")]
let processors = 1;
processors.min(MAX_LOADERS).min(files)
}
/// The file name without folders and extension (either separator).
fn stem(path: &str) -> String {
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
match file.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
_ => file.to_string(),
}
}
/// The definition name for a new image of `path`: the name that file already has in `d`, else
/// `wanted`, numbered (`wanted_2`, `wanted_3`, …) when another file has taken it.
fn def_name(d: &Drawing, path: &str, wanted: &str) -> String {
if let Some(i) = images(d).find(|i| i.path == path && !i.name.is_empty()) {
return i.name.clone();
}
// The names other files have, compared without regard to case.
let taken: BTreeSet<String> = images(d).filter(|i| i.path != path).map(|i| i.name.to_ascii_lowercase()).collect();
let free = |name: &str| !taken.contains(&name.to_ascii_lowercase());
if free(wanted) {
return wanted.to_string();
}
// One of these is free: there are more candidates than names taken.
let candidates = 2..taken.len().saturating_add(3);
candidates.map(|n| format!("{wanted}_{n}")).find(|name| free(name)).unwrap_or_else(|| wanted.to_string())
}
const ID: &str = "imageattach";
pub(crate) fn run_imageattach(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(ID, "`path` is required"))?.to_string();
if path.len() > MAX_PATH {
return Err(bad(ID, "`path` is too long"));
}
let place = placement(p)?;
let raster = pixels(s, p, &path)?;
let (w, h) = (f64::from(raster.width()), f64::from(raster.height()));
// One pixel in drawing units, along the image's bottom edge (`u`) and its left edge (`v`).
let pixel = place.width / w;
let u = Vec2::from_angle(place.rotation) * pixel;
let v = u.perp();
if !(pixel.is_normal() && (pixel * h).is_normal() && u.x.is_finite() && u.y.is_finite()) {
return Err(bad(ID, "the image would be too small or too large to draw"));
}
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| stem(&path));
let name = def_name(s.doc()?, &path, &wanted);
let image = Image {
insert: place.at.to3(0.0),
u: u.to3(0.0),
v: v.to3(0.0),
size: Vec2::new(w, h),
path: path.clone(),
name: name.clone(),
..Image::default()
};
s.doc_mut()?.rasters.insert(&path, raster);
let handle = s.add_entity(EntityKind::Image(image))?;
Ok(json!({ "name": name, "handle": handle.hex(), "path": path, "pixels": [w, h], "size": [place.width, pixel * h] }))
}
/// Where and how large IMAGEATTACH puts the image.
struct Placement {
/// The lower-left corner.
at: Vec2,
/// Radians, counter-clockwise.
rotation: f64,
/// The image's width in drawing units.
width: f64,
}
/// `at`, `rotation` and the width (`width`, else `scale`: an image is one unit wide at scale 1).
fn placement(p: &Value) -> Result<Placement> {
let at = point_param(p, "at").unwrap_or(Vec2::ZERO);
if !(at.x.is_finite() && at.y.is_finite()) {
return Err(bad(ID, "`at` must be a point"));
}
let width = match p.get("width") {
Some(v) => v.as_f64().filter(|w| w.is_finite()).ok_or_else(|| bad(ID, "`width` must be a number"))?,
None => f64_or(p, "scale", 1.0),
};
if !(width.is_finite() && width > 0.0) {
return Err(bad(ID, "`scale` and `width` must be greater than zero"));
}
Ok(Placement { at, rotation: f64_or(p, "rotation", 0.0).to_radians(), width })
}
/// The pixels of the image at `path`: decoded from `data` when the caller hands the file over,
/// else the ones already loaded for that path, else read from the file.
fn pixels(s: &Session, p: &Value, path: &str) -> Result<Arc<Raster>> {
let bytes = match p.get("data") {
Some(data) => {
let text = data.as_str().ok_or_else(|| bad(ID, "`data` must be base64 text"))?;
base64_decode(text).ok_or_else(|| bad(ID, "`data` is not valid base64"))?
}
None => {
if let Some(loaded) = s.doc()?.rasters.get(path) {
return Ok(loaded.clone());
}
let host = s.state()?.path.clone();
read_file(path, host.as_deref()).map_err(|e| bad(ID, format!("{path}: {e}")))?
}
};
Ok(Arc::new(decode(&bytes).map_err(|e| bad(ID, format!("{path}: {e}")))?))
}
fn run_image(s: &mut Session, _p: &Value) -> Result<Value> {
let host = s.state()?.path.clone();
let d = s.doc()?;
// Per file: its definition name and how many images show it.
let mut uses: BTreeMap<&str, (&str, usize)> = BTreeMap::new();
for i in images(d) {
uses.entry(i.path.as_str()).or_insert((i.name.as_str(), 0)).1 += 1;
}
let rows: Vec<Value> = paths(d)
.iter()
.map(|path| {
let (name, references) = uses.get(path.as_str()).copied().unwrap_or_default();
let mut row = describe(d, path, host.as_deref());
row["name"] = json!(name);
row["references"] = json!(references);
row
})
.collect();
Ok(json!({ "images": rows }))
}
/// The state of image file `path` of drawing `d` (saved at `host`): where it is and whether its
/// pixels are loaded.
fn describe(d: &Drawing, path: &str, host: Option<&str>) -> Value {
let loaded = d.rasters.get(path);
let found_at = super::blocks::xref::find_file(path, host).map(|f| f.to_string_lossy().into_owned());
let status = match (loaded, &found_at) {
(Some(_), _) => "Loaded",
(None, None) => "Not Found",
// The file is there but could not be read as an image.
(None, Some(_)) => "Not loaded",
};
json!({ "path": path, "foundAt": found_at, "status": status, "pixels": loaded.map(|r| json!([r.width(), r.height()])) })
}
+1
View File
@@ -524,6 +524,7 @@ fn viewport_json(h: Handle, v: &Viewport) -> Value {
"height": v.height,
"viewCenter": [v.view_center.x, v.view_center.y],
"viewHeight": v.view_height,
"twist": v.view_twist().to_degrees(),
"scale": if v.view_height > 0.0 { v.height / v.view_height } else { 0.0 },
"locked": v.locked,
"on": v.on,
+1 -1
View File
@@ -283,7 +283,7 @@ fn mspace_edits_model_through_viewport() {
s.cmdline("circle 10,10 3").unwrap();
assert_eq!(s.doc().unwrap().model.len(), before + 1);
// Zooming changes the viewport's model view, not the sheet.
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 });
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0, twist: v.twist });
let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1;
assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9);
assert_eq!(s.state().unwrap().paper_view(), pv);
+1 -1
View File
@@ -329,7 +329,7 @@ pub(super) fn run_vpclip(s: &mut Session, p: &Value) -> Result<Value> {
let k = cur.view_height / cur.height.max(1e-12);
let mut v = cur;
let c = b.center();
let view_center = v.view_center + (c - v.center.xy()) * k;
let view_center = v.paper_to_model(c);
let view_height = b.height() * k;
if view_center.is_finite() && view_height.is_finite() && view_height > 0.0 {
v.view_center = view_center;
+1 -1
View File
@@ -109,7 +109,7 @@ fn parse_color(t: &str) -> Result<Color> {
fn viewport_at(s: &Session, p: Vec2) -> Option<Handle> {
let st = s.state().ok()?;
let at = match st.active_viewport() {
Some((_, vp)) => vp.center.xy() + (p - vp.view_center) * (vp.height / vp.view_height),
Some((_, vp)) => vp.model_to_paper(p),
None => p,
};
super::viewports::viewport_at(s, at)
+1 -2
View File
@@ -182,8 +182,7 @@ fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>)
};
let m = match viewport_of(s, &layout, vp) {
Some(v) => {
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));
let to_paper = v.model_to_paper_matrix();
if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper }
}
None => Mat3::IDENTITY,
+1
View File
@@ -411,6 +411,7 @@ 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();
+50 -18
View File
@@ -1,5 +1,5 @@
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), layout
//! switching.
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), the view
//! twist (DVIEW TWist), layout switching.
use cadcraft_doc::{Space, entity_bounds};
use cadcraft_geom::{Bounds2, Vec2};
@@ -10,6 +10,7 @@ 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;
@@ -95,10 +96,13 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["r"])
.noundo()
.transparent(),
CommandSpec::new("view.set", "Set View", run_view_set).params("{center: [x,y], height}").noundo(),
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.get", "Get View", |s, _| {
let v = s.state()?.view();
Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "viewportPx": [s.viewport_px.0, s.viewport_px.1]}))
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]}))
})
.noundo(),
CommandSpec::new("layout.set", "Switch Layout", run_layout_set).params("{name: \"Model\" | layout name}").noundo(),
@@ -120,8 +124,11 @@ 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));
let height = b.height().max(b.width() / aspect.max(1e-6)).max(1e-9);
st.set_zoom_frame(View { center: b.center(), height });
// 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 });
Ok(())
}
@@ -150,18 +157,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 { center: v.center, height: v.height / f });
st.set_zoom_frame(View { height: v.height / f, ..v });
}
"center" | "c" | "dynamic" | "d" => {
let c = point_req("zoom", p, "center")?;
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) });
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 });
}
"scale" | "s" => {
let v = s.state()?.zoom_frame();
let height = factor_height(s, p, v.height)?;
s.state_mut()?.set_zoom_frame(View { center: v.center, height });
s.state_mut()?.set_zoom_frame(View { height, ..v });
}
"realtime" => {
let v = s.state()?.zoom_frame();
@@ -169,7 +176,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 { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
s.state_mut()?.set_zoom_frame(View { height: (v.height / f).clamp(1e-9, 1e12), ..v });
}
"object" | "o" => {
let hs = targets(s, p)?;
@@ -242,7 +249,8 @@ 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);
st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height), height: v.height });
// 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 });
ok()
}
@@ -258,7 +266,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, height: v.height });
st.set_view(View { center: c, ..v });
ok()
}
@@ -268,9 +276,33 @@ fn run_view_set(s: &mut Session, p: &Value) -> Result<Value> {
if h <= 0.0 {
return Err(bad("view.set", "height must be positive"));
}
if !s.state_mut()?.set_view(View { center: c, height: h }) {
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;
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()
}
@@ -294,7 +326,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 });
st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15, twist: 0.0 });
}
s.touch();
ok()
@@ -441,7 +473,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 });
st.set_zoom_frame(View { center: b.center, height: b.height, twist: b.frame.twist });
return Ok(Step::Done);
}
(ZoomStage::Dynamic(mut b), Input::Point(p)) => {
@@ -565,7 +597,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 { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
st.set_view_quiet(View { height: (v.height / f).clamp(1e-9, 1e12), ..v });
self.touch();
Ok(())
}
+440
View File
@@ -0,0 +1,440 @@
//! 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:?}");
}
}
}
}
+18 -5
View File
@@ -110,15 +110,25 @@ 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()));
}
(b.center(), b.height(), b.width())
// 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)
}
None => {
let f = if in_viewport { st.zoom_frame() } else { st.view() };
let f = frame;
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)
}
@@ -139,7 +149,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: 0.0, layout });
d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist, 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 }))
}
@@ -168,7 +178,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 });
st.set_view(View { center: v.center, height, twist: 0.0 });
}
None => {
// A model space view: into the active MSPACE viewport, else on the Model tab.
@@ -177,8 +187,11 @@ 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);
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect) });
let twist = cadcraft_geom::norm_angle(v.twist);
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect), twist });
}
}
let mut msg = format!("View \"{}\" restored.", v.name);
+5 -5
View File
@@ -104,7 +104,7 @@ fn rect(t: &VportTile) -> Rect {
}
fn view_of(t: &VportTile) -> View {
View { center: t.center, height: t.height }
View { center: t.center, height: t.height, twist: if t.twist.is_finite() { t.twist } else { 0.0 } }
}
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 }], 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);
};
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) = (v.center, v.height);
(t.center, t.height, t.twist) = (v.center, v.height, v.twist);
}
}
if let Some(t) = tiles.get_mut(active) {
(t.center, t.height) = (now.center, now.height);
(t.center, t.height, t.twist) = (now.center, now.height, now.twist);
}
// 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, "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, "twist": t.twist.to_degrees(), "current": i == active }))
.collect();
Ok(json!({ "tiles": rows }))
}
+46 -9
View File
@@ -1,5 +1,5 @@
use super::view::{ModelTiles, View, usable_view};
use super::{Arc, Drawing, Handle, Space, blockedit, undoctl};
use super::{Arc, Drawing, Handle, Space, Vec2, blockedit, undoctl};
#[derive(Clone, Debug)]
pub struct Snapshot {
@@ -80,6 +80,7 @@ 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
}
@@ -135,7 +136,8 @@ impl DocState {
match self.active_viewport() {
Some((_, vp)) => {
let k = vp.view_height / vp.height;
View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
// 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() }
}
None => pv,
}
@@ -150,16 +152,18 @@ 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.center.xy() + (v.center - vp.view_center) / k0;
let paper = View { center: pc, height: v.height / k0 };
let pc = vp.model_to_paper(v.center);
let paper = View { center: pc, height: v.height / k0, twist: pv.twist };
if !usable_view(&paper) {
return false;
}
self.put_paper_view(paper);
} else {
let center = v.center - (pv.center - vp.center.xy()) * k;
// `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 height = vp.height * k;
if !usable_view(&View { center, height }) {
if !usable_view(&View { center, height, twist }) {
return false;
}
let doc = Arc::make_mut(&mut self.doc);
@@ -168,6 +172,7 @@ impl DocState {
if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind {
x.view_center = center;
x.view_height = height;
x.twist = twist;
}
});
}
@@ -182,6 +187,36 @@ 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.
@@ -210,7 +245,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 },
Some(v) => View { center: v.view_center, height: v.view_height, twist: v.view_twist() },
None => self.view(),
}
}
@@ -225,7 +260,9 @@ impl DocState {
Some(vp) => {
let pv = self.paper_view();
let k = f.height / vp.height;
View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
// 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 }
}
None => f,
};
@@ -233,7 +270,7 @@ impl DocState {
}
/// Set without recording view history (realtime pan/zoom frames).
pub fn set_view_quiet(&mut self, v: View) {
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
if !usable_view(&v) {
return;
}
self.store_view(v);
+17
View File
@@ -66,6 +66,14 @@ 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();
@@ -102,6 +110,15 @@ impl Interactive for Guarded {
None
})
}
fn display_only(&self, s: &Session) -> Option<crate::prompt::DisplayOnly> {
if self.broken.get().is_some() {
return None;
}
catch_unwind(AssertUnwindSafe(|| self.inner.display_only(s))).unwrap_or_else(|_| {
self.broken.set(Some("panic in preview"));
None
})
}
}
#[cfg(test)]
+1 -1
View File
@@ -49,7 +49,7 @@ pub use cadcraft_geom as geom;
pub use cadcraft_render as render;
pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
pub use prompt::{Accept, DisplayOnly, Drag, Input, Interactive, Picker, Prompt, Step};
mod doc_state;
mod error;
+21 -1
View File
@@ -31,7 +31,7 @@ impl Accept {
/// [Truecolor/COlorbook] <BYLAYER>:". A prompt asking for something else besides its keywords has
/// it: "Enter block name or [?]:", "Specify insertion base point or [Basepoint/…]:". Read from
/// AutoCAD's command line over measured runs, where no prompt was written both ways.
const NO_OR: [&str; 27] = [
const NO_OR: [&str; 28] = [
"Bring to front",
"Calculate rectangle dimensions based on",
"Choose mode for selected object conversion",
@@ -47,6 +47,7 @@ const NO_OR: [&str; 27] = [
"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",
@@ -266,6 +267,9 @@ 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()
@@ -287,6 +291,22 @@ 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`].
+8 -6
View File
@@ -6,7 +6,7 @@ impl Session {
self.untitled_counter += 1;
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None);
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() });
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0, twist: 0.0 } } else { View::default() });
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
@@ -18,11 +18,13 @@ 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);
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),
});
// 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 });
}
self.end_running();
self.docs.push(st);
+16 -3
View File
@@ -1,3 +1,5 @@
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,
@@ -199,7 +201,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 = p.x < pw.corner.x;
let crossing = self.window_is_crossing(pw.corner, p);
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
}
@@ -279,7 +281,10 @@ 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) => Ok(Step::Cancel),
Some(Input::Cancel) => {
run.machine.cancelled(self);
Ok(Step::Cancel)
}
Some(i) => {
if let Input::Point(p) = &i {
self.last_point = *p;
@@ -438,7 +443,8 @@ impl Session {
}
if self.running.is_some() {
if self.suspended.is_some() {
if let Some(nested) = self.running.take() {
if let Some(mut nested) = self.running.take() {
nested.machine.cancelled(self);
self.finish(nested, true);
}
self.running = self.suspended.take();
@@ -523,6 +529,13 @@ 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>)> {
+8 -1
View File
@@ -16,6 +16,13 @@ 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
@@ -24,7 +31,7 @@ impl Session {
let space = self.space();
let pickadd = self.settings.pickadd;
if let Some(pw) = self.pending_window.take() {
let crossing = p.x < pw.corner.x;
let crossing = self.window_is_crossing(pw.corner, p);
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() };
+5 -3
View File
@@ -23,8 +23,10 @@ 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 height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05;
st.set_zoom_frame(View { center: ext.center(), height });
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 });
Ok(())
}
@@ -38,7 +40,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 });
st.set_view_quiet(View { center: c, height: h, twist: v.twist });
Ok(())
}
}
+7 -4
View File
@@ -40,7 +40,8 @@ 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);
let model_clip = Bounds2::new(viewport.view_center - half / scale, viewport.view_center + half / scale);
// 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)));
if ![paper_clip.min, paper_clip.max, model_clip.min, model_clip.max].into_iter().all(Vec2::is_finite) {
return None;
}
@@ -48,11 +49,11 @@ impl<'a> Projection<'a> {
}
pub fn to_model(&self, point: Vec2) -> Vec2 {
self.viewport.view_center + (point - self.viewport.center.xy()) / self.scale
self.viewport.paper_to_model(point)
}
fn to_paper(&self, point: Vec2) -> Vec2 {
self.viewport.center.xy() + (point - self.viewport.view_center) * self.scale
self.viewport.model_to_paper(point)
}
pub fn hit(&self, mut hit: SnapHit) -> Option<SnapHit> {
@@ -76,7 +77,9 @@ impl<'a> Projection<'a> {
if !(center.is_finite() && radius.is_finite() && radius > 0.0) {
return None;
}
SnapCurve::Arc { center, radius, start, end }
// 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::Line { a, b } => {
let (a, b) = (self.to_paper(a), self.to_paper(b));
+3
View File
@@ -184,6 +184,8 @@ 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());
@@ -260,6 +262,7 @@ const READ_ONLY: &[&str] = &[
"DISTANCE",
"VIEWCTR",
"VIEWSIZE",
"VIEWTWIST",
"EXTMIN",
"EXTMAX",
"UNDOCTL",
+1 -1
View File
@@ -185,7 +185,7 @@ fn move_with_pickfirst_and_window_selection() {
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
s.execute("line", &json!({"points": [[5, 5], [6, 5]]})).unwrap();
s.viewport_px = (1000.0, 1000.0);
s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0 });
s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0, twist: 0.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();
+21 -3
View File
@@ -1,20 +1,38 @@
use cadcraft_geom::Bounds2;
use super::{Deserialize, Serialize, Vec2};
/// A 2D view of a space: world centre and visible height in drawing units.
/// 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).
#[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 }
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())
}
}
pub(crate) fn usable_view(v: &View) -> bool {
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 && v.twist.is_finite()
}
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
+381
View File
@@ -0,0 +1,381 @@
//! DVIEW starts with "Select objects or <use DVIEWBLOCK>:", then the option prompt;
//! TWist, PAn, Zoom, Off and Undo on a 2D view; the 3D options not available yet. It works on the
//! Model tab and inside an active layout viewport (whose twist is an undoable edit of the
//! viewport), and not on a layout sheet.
use cadcraft_doc::{EntityKind, Handle, Viewport};
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, Session, View};
use serde_json::json;
const SELECT: &str = "Select objects or <use DVIEWBLOCK>:";
const OPTION: &str = "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/Off/Undo]:";
const PAPER: &str = "This command is not available in Paper space.";
fn session() -> Session {
let mut s = Session::new();
s.viewport_px = (1200.0, 800.0);
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
s.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap();
s
}
fn prompt(s: &Session) -> String {
s.current_prompt().map(|p| p.display()).unwrap_or_default()
}
fn view(s: &Session) -> View {
s.state().unwrap().view()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn twist_deg(s: &Session) -> f64 {
view(s).twist.to_degrees()
}
fn logged(s: &Session, m: &str) -> bool {
s.log.iter().any(|l| l == m)
}
#[test]
fn every_prompt_step_on_the_model_tab() {
let mut s = session();
let undo = s.state().unwrap().undo.len();
s.cmdline("dview").unwrap();
assert_eq!(prompt(&s), SELECT);
// The whole drawing shows while objects are picked.
assert!(s.display_only().is_none());
// Enter with nothing selected: the DVIEWBLOCK sample (a house of lines) is the preview set.
s.cmdline("").unwrap();
assert_eq!(prompt(&s), OPTION);
let only = s.display_only().unwrap();
assert!(only.handles.is_empty());
assert!(only.extra.len() > 10 && only.extra.iter().all(|e| matches!(e.kind, EntityKind::Line(_))));
twist_pan_and_zoom(&mut s);
// The sample house stays where it was put: the view moves over it.
assert_eq!(s.display_only().unwrap(), only);
off_and_unavailable_options(&mut s);
undo_steps(&mut s);
// An unknown option is refused and asked again.
assert!(s.cmdline("bogus").is_ok());
assert_eq!(prompt(&s), OPTION);
// Enter ends DVIEW; the whole drawing shows again, and a Model tab twist is no undo step.
s.cmdline("tw").unwrap();
s.cmdline("45").unwrap();
s.cmdline("").unwrap();
assert!(s.current_prompt().is_none());
assert!(s.display_only().is_none());
assert!(near(twist_deg(&s), 45.0));
assert_eq!(s.state().unwrap().undo.len(), undo);
}
/// DVIEW's TWist, PAn and Zoom steps from the option prompt of a fresh Model tab session.
fn twist_pan_and_zoom(s: &mut Session) {
// TWist: the current twist is the default; a typed angle turns the view.
s.cmdline("tw").unwrap();
assert_eq!(prompt(s), "Specify view twist angle <0>:");
s.cmdline("30").unwrap();
assert!(near(twist_deg(s), 30.0));
assert_eq!(prompt(s), OPTION);
s.cmdline("TWist").unwrap();
assert_eq!(prompt(s), "Specify view twist angle <30>:");
// Enter keeps it.
s.cmdline("").unwrap();
assert!(near(twist_deg(s), 30.0));
// PAn: base point, second point.
s.cmdline("pa").unwrap();
assert_eq!(prompt(s), "Specify displacement base point:");
s.cmdline("0,0").unwrap();
assert_eq!(prompt(s), "Specify second point:");
s.cmdline("4,0").unwrap();
assert_eq!(view(s).center, Vec2::new(6.0, 10.0));
assert_eq!(prompt(s), OPTION);
// Zoom: a scale factor of the current view.
s.cmdline("z").unwrap();
assert_eq!(prompt(s), "Specify zoom scale factor <1>:");
s.cmdline("2").unwrap();
assert!(near(view(s).height, 10.0));
}
/// DVIEW's Off option and the options that need a 3D camera.
fn off_and_unavailable_options(s: &mut Session) {
// Off: perspective is never on in a 2D view, so nothing changes.
let before = view(s);
s.cmdline("o").unwrap();
assert_eq!(prompt(s), OPTION);
assert_eq!(view(s), before);
// The options that need a 3D camera are not available yet and stay at the option prompt.
for (typed, name) in [("ca", "CAmera"), ("ta", "TArget"), ("d", "Distance"), ("po", "POints"), ("cl", "CLip"), ("h", "Hide")] {
s.cmdline(typed).unwrap();
assert!(logged(s, &format!("DVIEW {name} is not available yet.")), "{name}: {:?}", s.log);
assert_eq!(prompt(s), OPTION);
}
assert!(!s.log.iter().any(|l| l.contains("Only the view twist")));
}
/// DVIEW's Undo after [`twist_pan_and_zoom`].
fn undo_steps(s: &mut Session) {
// Undo takes back one DVIEW action at a time: zoom, pan, twist.
s.cmdline("u").unwrap();
assert!(near(view(s).height, 20.0));
s.cmdline("u").unwrap();
assert_eq!(view(s).center, Vec2::new(10.0, 10.0));
assert!(near(twist_deg(s), 30.0));
s.cmdline("u").unwrap();
assert_eq!(view(s).twist, 0.0);
// Nothing left to undo: still at the option prompt.
s.cmdline("u").unwrap();
assert_eq!(prompt(s), OPTION);
}
#[test]
fn selected_objects_are_the_preview_set() {
let mut s = session();
let line = s.doc().unwrap().model.iter().next().map(|e| e.handle).unwrap();
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
s.cmdline("dview").unwrap();
s.input(Input::Pick(vec![line])).unwrap();
assert_eq!(prompt(&s), SELECT);
s.cmdline("").unwrap();
assert_eq!(prompt(&s), OPTION);
let only = s.display_only().unwrap();
assert_eq!(only.handles, vec![line]);
assert!(only.extra.is_empty(), "no sample house with a selection");
// Picked at the prompt, the objects don't stay selected.
assert!(s.selection().is_empty());
// Escape ends it too, and the drawing shows again.
s.input(Input::Cancel).unwrap();
assert!(s.current_prompt().is_none());
assert!(s.display_only().is_none());
}
#[test]
fn a_dviewblock_of_the_drawings_own_is_previewed() {
let mut s = session();
s.execute("circle", &json!({ "center": [0.5, 0.5], "radius": 0.5 })).unwrap();
let c = s.doc().unwrap().model.iter().last().map(|e| e.handle).unwrap();
s.execute("block", &json!({ "name": "DVIEWBLOCK", "base": [0, 0], "handles": [c.hex()], "keep": "delete" })).unwrap();
s.cmdline("dview").unwrap();
s.cmdline("").unwrap();
let only = s.display_only().unwrap();
assert_eq!(only.extra.len(), 1);
match &only.extra[0].kind {
EntityKind::Insert(i) => {
assert_eq!(i.block, "DVIEWBLOCK");
// Scaled from its 1 by 1 unit area to half the view's height.
assert!(near(i.scale.x, 10.0) && near(i.scale.y, 10.0));
}
k => panic!("{k:?}"),
}
s.cmdline("").unwrap();
}
/// A layout with one viewport, `(session, viewport handle)`.
fn layout_session() -> (Session, Handle) {
let mut s = session();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let r = s.execute("mview", &json!({ "p1": [20, 20], "p2": [220, 120] })).unwrap();
let h = Handle::parse_hex(r["viewports"][0].as_str().unwrap()).unwrap();
(s, h)
}
fn vp(s: &Session, h: Handle) -> Viewport {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Viewport(v) => v.clone(),
_ => panic!("not a viewport"),
}
}
#[test]
fn on_a_layout_sheet_it_is_not_available() {
let (mut s, _) = layout_session();
assert_eq!(s.execute("dview", &json!({ "twist": 10 })).unwrap_err().to_string(), PAPER);
assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER);
assert!(s.current_prompt().is_none());
// view.set can't turn the sheet either; an untwisted view is fine.
assert_eq!(s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap_err().to_string(), PAPER);
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
}
#[test]
fn inside_an_active_viewport_it_twists_that_viewport_as_one_undo_step() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
let undo = s.state().unwrap().undo.len();
let pv = s.state().unwrap().paper_view();
s.cmdline("dview").unwrap();
assert_eq!(prompt(&s), SELECT);
s.cmdline("").unwrap();
s.cmdline("tw").unwrap();
s.cmdline("30").unwrap();
// The viewport entity carries the twist, and the model view inside it is turned by it.
assert!(near(vp(&s, h).twist.to_degrees(), 30.0));
assert!(near(twist_deg(&s), 30.0));
// The sheet itself is not turned.
assert_eq!(s.state().unwrap().paper_view(), pv);
s.cmdline("tw").unwrap();
s.cmdline("50").unwrap();
s.cmdline("").unwrap();
assert!(s.current_prompt().is_none());
assert!(near(vp(&s, h).twist.to_degrees(), 50.0));
// One undo step for the whole DVIEW, which turns the viewport back.
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
s.execute("undo", &json!({})).unwrap();
assert_eq!(vp(&s, h).twist, 0.0);
// The JSON form and view.set twist the viewport the same way, each one undo step.
s.execute("dview", &json!({ "twist": -90 })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 270.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
let v = view(&s);
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 15 })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 15.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 2);
// Setting a view without a twist keeps the viewport's.
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 15.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 2);
// Back on the sheet, DVIEW is not available again.
s.execute("pspace", &json!({})).unwrap();
assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER);
}
#[test]
fn a_locked_viewport_keeps_its_twist() {
let (mut s, h) = layout_session();
s.doc_mut()
.unwrap()
.modify_entity(h, |e| {
if let EntityKind::Viewport(v) = &mut e.kind {
v.locked = true;
}
})
.unwrap();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
let undo = s.state().unwrap().undo.len();
assert!(s.execute("dview", &json!({ "twist": 30 })).is_err());
let v = view(&s);
assert!(s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 30 })).is_err());
assert_eq!(vp(&s, h).twist, 0.0);
assert_eq!(s.state().unwrap().undo.len(), undo);
// A view with the twist it already has is fine.
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap();
}
#[test]
fn escape_inside_an_active_viewport_keeps_the_twist_as_one_undo_step() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
let undo = s.state().unwrap().undo.len();
for l in ["dview", "", "tw", "30"] {
s.cmdline(l).unwrap();
}
s.input(Input::Cancel).unwrap();
assert!(s.current_prompt().is_none());
assert!(near(vp(&s, h).twist.to_degrees(), 30.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
s.execute("undo", &json!({})).unwrap();
assert_eq!(vp(&s, h).twist, 0.0);
}
#[test]
fn the_view_inside_a_twisted_viewport_maps_the_screen_like_the_sheet() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
let st = s.state().unwrap();
let (vpe, pv, v) = (vp(&s, h), st.paper_view(), st.view());
// The model view's centre is the model point at the sheet's view centre.
assert!((vpe.model_to_paper(v.center) - pv.center).len() < 1e-9);
assert!(near(v.twist, vpe.twist));
// Panning the view inside the viewport moves its model view, keeping the twist.
let moved = View { center: v.center + Vec2::new(5.0, -2.0), ..v };
s.state_mut().unwrap().set_view(moved);
let after = view(&s);
assert!((after.center - moved.center).len() < 1e-9, "{after:?}");
assert!(near(after.twist, vpe.twist));
assert_eq!(s.state().unwrap().paper_view(), pv);
// ZOOM Extents fits the turned box of the drawing in the viewport.
s.execute("zoom", &json!({ "mode": "extents" })).unwrap();
assert!(near(view(&s).twist, vpe.twist));
}
#[test]
fn a_viewport_twist_survives_a_dxf_round_trip() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let twists: Vec<f64> = back
.layouts
.iter()
.flat_map(|l| l.entities.iter())
.filter_map(|e| match &e.kind {
EntityKind::Viewport(v) if v.id != 1 => Some(v.twist.to_degrees()),
_ => None,
})
.collect();
assert_eq!(twists.len(), 1);
assert!((twists[0] - 30.0).abs() < 1e-9, "{twists:?}");
}
#[test]
fn a_view_saved_in_a_twisted_viewport_restores_its_twist_as_one_undo_step() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
s.execute("view", &json!({ "option": "save", "name": "Turned" })).unwrap();
let saved = s.doc().unwrap().views.iter().find(|v| v.name == "Turned").map(|v| v.twist.to_degrees()).unwrap();
assert!(near(saved, 30.0), "saved twist {saved}");
let center = vp(&s, h).view_center;
s.execute("dview", &json!({ "twist": 0 })).unwrap();
s.execute("view.set", &json!({ "center": [80, 40], "height": 3 })).unwrap();
let undo = s.state().unwrap().undo.len();
s.execute("view", &json!({ "option": "restore", "name": "Turned" })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 30.0), "viewport twist {}", vp(&s, h).twist.to_degrees());
assert!(near(twist_deg(&s), 30.0));
assert!((vp(&s, h).view_center - center).len() < 1e-6, "{:?} vs {center:?}", vp(&s, h).view_center);
// The viewport's twist changed: one undo step, which turns it back.
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
s.execute("undo", &json!({})).unwrap();
assert_eq!(vp(&s, h).twist, 0.0);
}
#[test]
fn undoing_a_drawing_change_keeps_the_untiled_twist_saved() {
let mut s = session();
s.execute("line", &json!({ "points": [[0, 10], [5, 10]] })).unwrap();
// A Model tab twist is a view change, not an undo step.
s.execute("dview", &json!({ "twist": 30 })).unwrap();
s.execute("undo", &json!({})).unwrap();
assert!(near(twist_deg(&s), 30.0));
let saved = |s: &Session| s.doc().unwrap().vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map(|t| t.twist.to_degrees());
assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s));
s.execute("redo", &json!({})).unwrap();
assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s));
}
#[test]
fn a_leftward_drag_on_a_turned_screen_is_a_crossing_window() {
let mut s = session();
let r = s.execute("line", &json!({ "points": [[200, 0], [260, 0]] })).unwrap();
let line = Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap();
s.execute("dview", &json!({ "twist": 180 })).unwrap();
// Turned half way round, a drag towards +X in the drawing runs leftwards on screen.
assert!(s.window_is_crossing(Vec2::new(210.0, -5.0), Vec2::new(230.0, 5.0)));
assert!(!s.window_is_crossing(Vec2::new(230.0, -5.0), Vec2::new(210.0, 5.0)));
// A crossing window takes the line it only partly covers.
s.idle_click(Vec2::new(210.0, -5.0), false).unwrap();
s.idle_click(Vec2::new(230.0, 5.0), false).unwrap();
assert_eq!(s.selection(), vec![line]);
}
+376
View File
@@ -0,0 +1,376 @@
//! 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);
}
@@ -0,0 +1,55 @@
//! A raster image decoder that panics on a damaged file fails that image only (#956): the
//! drawing it belongs to still opens, and IMAGEATTACH reports an error.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io};
use cadcraft_engine::doc::geom::{Vec2, Vec3};
use cadcraft_engine::doc::{Common, Drawing, EntityKind, Image, Line, Space};
use serde_json::json;
fn hooks() {
set_io(IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: None,
});
set_image_decoder(|_| panic!("decoder bug"));
}
#[test]
fn a_panicking_decoder_fails_the_image_not_the_drawing() {
hooks();
let dir = std::env::temp_dir().join(format!("cadcraft-image-panic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("map.png"), b"anything").unwrap();
let mut d = Drawing::new_metric();
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 5.0, 0.0) })).unwrap();
let i = Image {
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(4.0, 2.0),
path: "map.png".into(),
name: "map".into(),
..Image::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
let host = dir.join("host.dxf");
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
let mut s = Session::new();
s.execute("open", &json!({ "path": host.to_string_lossy() })).unwrap();
let doc = s.doc().unwrap();
assert_eq!(doc.model.len(), 2, "the drawing opened with everything in it");
assert!(doc.rasters.is_empty());
assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("damaged")), "{:?}", s.log);
let before = s.doc().unwrap().model.len();
let e = s.execute("imageattach", &json!({ "path": "other.png", "data": base64_encode(b"bytes") })).unwrap_err().to_string();
assert!(e.contains("damaged"), "{e}");
assert_eq!(s.doc().unwrap().model.len(), before);
// The session still works.
s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), before + 1);
let _ = std::fs::remove_dir_all(&dir);
}
+3 -3
View File
@@ -135,9 +135,9 @@ fn mview_restore_puts_a_saved_configuration_on_a_layout() {
fn mview_restore_ignores_unusable_tiles_from_files() {
let (mut s, _) = two_tiles();
let bad = [
VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 },
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 },
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 },
];
if let Some(c) = s.doc_mut().unwrap().vports.iter_mut().find(|c| c.name == "Two") {
c.tiles = bad.to_vec();
@@ -27,6 +27,7 @@ fn drawing() -> (Drawing, Space) {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
}),
)
.unwrap();
+19 -5
View File
@@ -46,7 +46,7 @@ fn typed_save_restore_list_delete() {
// Saving is an undo step; a view change is not.
set_view(&mut s, 0.0, 0.0, 500.0);
typed(&mut s, &["view", "r", "front"]);
assert_eq!(view(&s), View { center: Vec2::new(10.0, 20.0), height: 30.0 });
assert_eq!(view(&s), View { center: Vec2::new(10.0, 20.0), height: 30.0, twist: 0.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,6 +84,20 @@ 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();
@@ -112,7 +126,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 });
assert_eq!(view(&s), View { center: Vec2::new(5.0, 4.0), height: 6.0, twist: 0.0 });
}
#[test]
@@ -138,7 +152,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 });
assert_eq!(view(&s), View { center: Vec2::ZERO, height: 25.0, twist: 0.0 });
}
#[test]
@@ -158,10 +172,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 });
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.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 });
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0, twist: 0.0 });
}
#[test]
+5 -5
View File
@@ -46,15 +46,15 @@ fn split_activate_and_each_tile_keeps_its_view() {
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]);
// Every new tile starts with the view that was split.
assert!(tiles(&s).iter().all(|t| t.view == View { center: Vec2::new(10.0, 10.0), height: 20.0 }));
assert!(tiles(&s).iter().all(|t| t.view == View { center: Vec2::new(10.0, 10.0), height: 20.0, twist: 0.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 });
assert_eq!(tiles(&s)[other].view, View { center: Vec2::new(50.0, 25.0), height: 5.0 });
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 });
// 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 });
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0, twist: 0.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 });
assert_eq!(view(&s), View { center: Vec2::new(7.0, 7.0), height: 7.0, twist: 0.0 });
}
#[test]
+34
View File
@@ -0,0 +1,34 @@
//! FF8: the SHX fonts a drawing's text styles name are found beside the drawing it opens. The
//! font is compiled here from shape source written for the test.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::{Drawing, TextStyle};
use serde_json::json;
#[test]
fn opening_a_drawing_finds_the_shx_fonts_beside_it() {
set_io(IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: None,
});
let dir = std::env::temp_dir().join(format!("cadcraft-shx-beside-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let font = cadcraft_fonts::shx::compile("*0,4,beside\n6,2,0,0\n*65,7,UCA\n1,064,040,06C,2,020,0\n").unwrap();
std::fs::write(dir.join("cadcraft-beside-engine.shx"), font).unwrap();
let mut d = Drawing::new_imperial();
d.text_styles.push(TextStyle { name: "Beside".into(), font: "cadcraft-beside-engine.shx".into(), ..TextStyle::default() });
let path = dir.join("host.dxf");
std::fs::write(&path, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
assert!(!cadcraft_fonts::TextFont::resolve("cadcraft-beside-engine.shx").is_shx());
let mut s = Session::new();
s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap();
let style = s.doc().unwrap().text_style("Beside").cloned().unwrap();
let font = cadcraft_fonts::TextFont::resolve_style(&style.font, &style.big_font, style.vertical);
assert!(font.is_shx());
assert!((cadcraft_fonts::text_width(&font, "AA", 3.0, 1.0) - 6.0).abs() < 1e-9);
let _ = std::fs::remove_dir_all(&dir);
}
+193
View File
@@ -0,0 +1,193 @@
//! The view twist (VIEWTWIST): DVIEW TWist turns the Model tab's view, ZOOM Previous turns it
//! back, each VPORTS tile keeps its own (DXF VPORT group 51), and VIEW Save/Restore keep it.
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Session, View, sysvars};
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.viewport_px = (1200.0, 800.0);
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
s.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap();
s
}
fn typed(s: &mut Session, lines: &[&str]) {
for l in lines {
s.cmdline(l).unwrap();
}
}
fn view(s: &Session) -> View {
s.state().unwrap().view()
}
fn twist_deg(s: &Session) -> f64 {
view(s).twist.to_degrees()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn dview_twist_sets_viewtwist_and_zoom_previous_turns_it_back() {
let mut s = session();
assert_eq!(view(&s).twist, 0.0);
typed(&mut s, &["dview", "", "tw", "30", ""]);
assert!(near(twist_deg(&s), 30.0), "twist {}", twist_deg(&s));
let vt = sysvars::get(&s, "VIEWTWIST").and_then(|v| v.as_f64()).unwrap();
assert!(near(vt, 30.0), "VIEWTWIST {vt}");
// Only the twist changed; the view is not an undo step.
assert_eq!((view(&s).center, view(&s).height), (Vec2::new(10.0, 10.0), 20.0));
assert_eq!(s.state().unwrap().undo.len(), 1, "just the line");
// Zooming keeps the twist; ZOOM Previous steps back through it.
s.execute("zoom", &json!({ "mode": "in" })).unwrap();
assert!(near(twist_deg(&s), 30.0));
s.execute("zoom", &json!({ "mode": "previous" })).unwrap();
s.execute("zoom", &json!({ "mode": "previous" })).unwrap();
assert_eq!(view(&s).twist, 0.0);
assert!(sysvars::is_read_only("VIEWTWIST"));
// JSON form; negative angles wrap into 0..360.
s.execute("dview", &json!({ "twist": -90 })).unwrap();
assert!(near(twist_deg(&s), 270.0));
// The 3D options are not available yet; the command carries on.
typed(&mut s, &["dview", "", "ca", ""]);
assert!(s.current_prompt().is_none());
// Not in a layout.
let layout = s.doc().unwrap().layouts.first().map(|l| l.name.clone()).unwrap();
s.execute("layout.set", &json!({ "name": layout })).unwrap();
assert!(s.execute("dview", &json!({ "twist": 10 })).is_err());
}
const PAPER: &str = "This command is not available in Paper space.";
#[test]
fn dview_in_paper_space_says_it_is_not_available() {
let mut s = session();
let layout = s.doc().unwrap().layouts.first().map(|l| l.name.clone()).unwrap();
s.execute("layout.set", &json!({ "name": layout })).unwrap();
let e = s.execute("dview", &json!({ "twist": 10 })).unwrap_err();
assert_eq!(e.to_string(), PAPER);
// Typed, it is refused as it starts: no prompt is left open.
let e = s.cmdline("dview").unwrap_err();
assert_eq!(e.to_string(), PAPER);
assert!(s.current_prompt().is_none());
// view.set can't turn a layout's view either (an untwisted one is fine).
let e = s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap_err();
assert_eq!(e.to_string(), PAPER);
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
assert_eq!(view(&s).twist, 0.0);
}
#[test]
fn a_single_untiled_view_keeps_its_twist_through_a_dxf_round_trip() {
let mut s = session();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
assert!(s.model_tiles().len() <= 1, "untiled");
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let written = lines.windows(2).any(|w| w[0] == "51" && w[1].parse::<f64>().is_ok_and(|a| near(a, 30.0)));
assert!(written, "the lone *ACTIVE VPORT carries group 51");
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let mut o = Session::new();
o.viewport_px = (1200.0, 800.0);
o.open_drawing(back.clone(), "single", None);
assert!(near(twist_deg(&o), 30.0), "twist {}", twist_deg(&o));
assert!(o.model_tiles().len() <= 1, "still untiled");
// And again: saved from the reopened session, it is still turned.
let again = cadcraft_io::read_dxf(cadcraft_io::write_dxf(o.doc().unwrap()).as_bytes()).unwrap();
let mut o2 = Session::new();
o2.viewport_px = (1200.0, 800.0);
o2.open_drawing(again, "again", None);
assert!(near(twist_deg(&o2), 30.0));
// Turned back (ZOOM Previous here), the drawing keeps no twist.
s.execute("zoom", &json!({ "mode": "previous" })).unwrap();
assert_eq!(view(&s).twist, 0.0);
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
let mut o3 = Session::new();
o3.viewport_px = (1200.0, 800.0);
o3.open_drawing(back, "plain", None);
assert_eq!(view(&o3).twist, 0.0);
}
#[test]
fn zoom_extents_fits_in_the_twisted_frame() {
let mut s = session();
s.execute("dview", &json!({ "twist": 90 })).unwrap();
s.execute("zoom", &json!({ "mode": "extents" })).unwrap();
let v = view(&s);
// Turned a quarter, the 100 x 50 line's box stands 100 high on screen.
assert!(near(v.twist.to_degrees(), 90.0));
assert!((v.center - Vec2::new(50.0, 25.0)).len() < 1e-6, "{:?}", v.center);
assert!(near(v.height, 105.0), "height {}", v.height);
}
#[test]
fn each_tile_keeps_its_twist_through_a_dxf_round_trip() {
let mut s = session();
s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
let first = s.model_tiles().iter().position(|t| t.active).unwrap();
s.activate_tile(1 - first).unwrap();
assert_eq!(view(&s).twist, 0.0, "the other tile has its own twist");
s.execute("dview", &json!({ "twist": 60 })).unwrap();
let mut tw: Vec<f64> = s.model_tiles().iter().map(|t| t.view.twist.to_degrees()).collect();
tw.sort_by(f64::total_cmp);
assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}");
// Switching back shows the first tile's twist again.
s.activate_tile(first).unwrap();
assert!(near(twist_deg(&s), 30.0));
// A saved configuration keeps both twists through DXF (VPORT group 51).
s.execute("-vports", &json!({ "option": "save", "name": "Pair" })).unwrap();
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let written = lines.windows(2).any(|w| w[0] == "51" && w[1].parse::<f64>().is_ok_and(|a| near(a, 30.0)));
assert!(written, "group 51 written");
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let pair = back.vports.iter().find(|c| c.name == "Pair").unwrap();
let mut tw: Vec<f64> = pair.tiles.iter().map(|t| t.twist.to_degrees()).collect();
tw.sort_by(f64::total_cmp);
assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}");
// Opened, restoring the configuration shows each tile turned as saved.
let mut o = Session::new();
o.viewport_px = (1200.0, 800.0);
o.open_drawing(back, "pair", None);
o.execute("-vports", &json!({ "option": "restore", "name": "Pair" })).unwrap();
let mut tw: Vec<f64> = o.model_tiles().iter().map(|t| t.view.twist.to_degrees()).collect();
tw.sort_by(f64::total_cmp);
assert!(near(tw[0], 30.0) && near(tw[1], 60.0), "{tw:?}");
}
#[test]
fn opening_a_tiled_drawing_shows_the_current_tile_twisted() {
let mut s = session();
s.execute("dview", &json!({ "twist": 45 })).unwrap();
s.execute("-vports", &json!({ "option": "2" })).unwrap();
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();
let mut o = Session::new();
o.viewport_px = (1200.0, 800.0);
o.open_drawing(back, "tiled", None);
assert!(near(twist_deg(&o), 45.0), "twist {}", twist_deg(&o));
}
#[test]
fn view_save_and_restore_keep_the_twist() {
let mut s = session();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
s.execute("view", &json!({ "option": "save", "name": "Turned" })).unwrap();
let saved = s.doc().unwrap().views.iter().find(|v| v.name == "Turned").map(|v| v.twist.to_degrees()).unwrap();
assert!(near(saved, 30.0));
s.execute("dview", &json!({ "twist": 0 })).unwrap();
s.execute("view.set", &json!({ "center": [80, 40], "height": 3 })).unwrap();
assert_eq!(view(&s).twist, 0.0);
s.execute("view", &json!({ "option": "restore", "name": "Turned" })).unwrap();
assert!(near(twist_deg(&s), 30.0));
assert!((view(&s).center - Vec2::new(10.0, 10.0)).len() < 1e-9);
// view.set keeps the twist unless given one.
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
assert!(near(twist_deg(&s), 30.0));
s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap();
assert!(near(twist_deg(&s), 15.0));
}
+1
View File
@@ -14,3 +14,4 @@ skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
icu_segmenter = { workspace = true }
encoding_rs = { workspace = true }
+72 -13
View File
@@ -1,7 +1,8 @@
//! CADCraft text layout.
//!
//! Turns TEXT and MTEXT into stroke polylines with the built-in single-stroke font
//! ("CADCraft Stroke") or glyph outlines of installed TrueType fonts ([`ttf`]), with a
//! ("CADCraft Stroke"), the user's SHX shape fonts and big fonts ([`shx`]) 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.
@@ -18,6 +19,7 @@ pub mod bidi;
mod complex;
mod linebreak;
mod mtext;
pub mod shx;
mod stroke;
pub mod subset;
pub mod ttf;
@@ -42,8 +44,8 @@ pub struct Run {
}
/// Replace `%%` control codes: `%%d` degree, `%%p` plus/minus, `%%c` diameter, `%%%` percent,
/// `%%nnn` character code. Underline/overline toggles (`%%u`, `%%o`) are returned as flags
/// per character.
/// `%%nnn` character code, and `\U+XXXX` Unicode escapes. 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;
@@ -52,6 +54,14 @@ 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() {
@@ -83,6 +93,18 @@ 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) {
@@ -279,35 +301,65 @@ 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 or an installed TrueType /
/// OpenType face (outlines). Either way, characters the font lacks come from a fallback font.
/// 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.
#[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: 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.
/// Resolve a text style font name (no big font, horizontal): see [`TextFont::resolve_style`].
pub fn resolve(name: &str) -> TextFont {
if let Some(f) = ttf::find(name) {
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) {
return TextFont::Outline(f);
}
let n = name.trim();
if n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT) {
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 {
return TextFont::Stroke;
}
ttf::find(&ttf::font_alt()).map(TextFont::Outline).unwrap_or(TextFont::Stroke)
ttf::find(alt_main).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,
@@ -436,6 +488,9 @@ 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)
}
@@ -450,6 +505,9 @@ 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)
}
@@ -529,7 +587,8 @@ 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),
TextFont::Stroke => height / 3.0,
// AutoCAD places it a third of the height down with SHX fonts.
TextFont::Stroke | TextFont::Shx(_) => height / 3.0,
}
}
+16
View File
@@ -55,6 +55,13 @@ 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;
@@ -287,6 +294,13 @@ 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);
@@ -785,6 +799,8 @@ mod tests {
assert_eq!(plain_mtext("a\\~b\\\\c"), "a b\\c");
assert_eq!(plain_mtext("\\H2.5x;Big"), "Big");
assert_eq!(plain_mtext("unterminated \\H2"), "unterminated ");
assert_eq!(plain_mtext("\\U+0041B \\U+00E8"), "AB è");
assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument");
}
#[test]
+313
View File
@@ -0,0 +1,313 @@
//! Shape source (`.shp`) to a compiled SHX file, as COMPILE does.
//!
//! A source is a list of definitions: a header line `*number,defbytes,NAME` followed by its
//! specification bytes (on as many lines as needed, ending with 0). Numbers are decimal, or
//! hexadecimal after a leading `0` (`012`, `08cfb`); parentheses only group; `;` starts a
//! comment. A big font starts with `*BIGFONT nchars,nranges,first,last,…` and a Unicode font
//! with `*UNIFONT,6,name` followed by `above,below,modes,encoding,type,0`. Shape 0 is a text
//! font's record (`above,below,modes,0`).
//!
//! Operands are encoded by what they are: a subshape number takes two bytes in a Unicode font
//! (and after `7,0` in a big font), displacements and bulges are two's complement, and an arc's
//! direction byte (`-043`) keeps its sign in the high bit. `defbytes` is not checked: the
//! program's own length is written.
use super::ShxError;
use super::parse::{BIGFONT, SHAPES_10, UNIFONT};
/// Largest source compiled.
const MAX_SOURCE: usize = 8 << 20;
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Shapes,
Big,
Uni,
}
struct Shape {
number: u16,
name: String,
program: Vec<u8>,
}
/// A number token: value (sign included).
fn number(tok: &str, line: usize) -> Result<i64, ShxError> {
let t: String = tok.chars().filter(|c| !matches!(c, '(' | ')') && !c.is_whitespace()).collect();
let (neg, digits) = match t.strip_prefix('-') {
Some(d) => (true, d),
None => (false, t.strip_prefix('+').unwrap_or(&t)),
};
let err = || ShxError::Source(line, format!("not a number: {tok:?}"));
let v = if digits.len() > 1 && digits.starts_with('0') {
i64::from_str_radix(digits.get(1..).unwrap_or_default(), 16).map_err(|_| err())?
} else {
digits.parse::<i64>().map_err(|_| err())?
};
if v > 0xFFFF {
return Err(err());
}
Ok(if neg { -v } else { v })
}
/// The comma-separated numbers of a line (comment removed, blank fields skipped).
fn numbers(text: &str, line: usize) -> Result<Vec<i64>, ShxError> {
text.split(',').map(str::trim).filter(|t| !t.is_empty() && *t != "(" && *t != ")").map(|t| number(t, line)).collect()
}
/// The spec-byte encoder: walks the tokens as the reader walks the bytes.
struct Enc<'a> {
toks: &'a [i64],
i: usize,
out: Vec<u8>,
kind: Kind,
line: usize,
}
impl Enc<'_> {
fn next(&mut self) -> Result<i64, ShxError> {
let v = self.toks.get(self.i).copied().ok_or_else(|| ShxError::Source(self.line, "the shape ends inside a command".into()))?;
self.i += 1;
Ok(v)
}
fn byte(&mut self) -> Result<(), ShxError> {
let v = self.next()?;
let b = u8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a byte")))?;
self.out.push(b);
Ok(())
}
/// Two's complement −128..127.
fn signed(&mut self) -> Result<i64, ShxError> {
let v = self.next()?;
let b = i8::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not −128..127")))?;
self.out.extend(b.to_le_bytes());
Ok(v)
}
/// Sign in the high bit (an arc's direction byte).
fn sign_magnitude(&mut self) -> Result<(), ShxError> {
let v = self.next()?;
let m = u8::try_from(v.unsigned_abs())
.ok()
.filter(|m| *m < 0x80)
.ok_or_else(|| ShxError::Source(self.line, format!("{v} is not an octant byte")))?;
self.out.push(if v < 0 { m | 0x80 } else { m });
Ok(())
}
fn word(&mut self) -> Result<(), ShxError> {
let v = self.next()?;
let w = u16::try_from(v).map_err(|_| ShxError::Source(self.line, format!("{v} is not a shape number")))?;
self.out.extend(w.to_be_bytes());
Ok(())
}
fn program(mut self) -> Result<Vec<u8>, ShxError> {
while self.i < self.toks.len() {
let code = self.next()?;
let c = u8::try_from(code).map_err(|_| ShxError::Source(self.line, format!("{code} is not a code")))?;
self.out.push(c);
match c {
0 => break,
3 | 4 => self.byte()?,
7 => match self.kind {
Kind::Uni => self.word()?,
Kind::Big => {
let first = self.toks.get(self.i).copied().unwrap_or(0);
if first == 0 {
self.byte()?;
self.word()?;
for _ in 0..4 {
self.byte()?;
}
} else {
self.byte()?;
}
}
Kind::Shapes => self.byte()?,
},
8 => {
self.signed()?;
self.signed()?;
}
9 => loop {
let (x, y) = (self.signed()?, self.signed()?);
if x == 0 && y == 0 {
break;
}
},
10 => {
self.byte()?;
self.sign_magnitude()?;
}
11 => {
for _ in 0..4 {
self.byte()?;
}
self.sign_magnitude()?;
}
12 => {
for _ in 0..3 {
self.signed()?;
}
}
13 => loop {
let (x, y) = (self.signed()?, self.signed()?);
if x == 0 && y == 0 {
break;
}
self.signed()?;
},
_ => {}
}
}
if self.out.last() != Some(&0) {
self.out.push(0);
}
Ok(self.out)
}
}
/// Compile shape source into an SHX file (a shape file or text font, a big font, or a Unicode
/// font, as its first definition says).
pub fn compile(source: &str) -> Result<Vec<u8>, ShxError> {
if source.len() > MAX_SOURCE {
return Err(ShxError::TooLarge);
}
let mut kind = Kind::Shapes;
let mut ranges: Vec<(u16, u16)> = Vec::new();
let mut uni_info: Option<(String, Vec<u8>)> = None;
// Definitions: header (number, name, line) and the numbers that follow it.
let mut defs: Vec<(i64, String, usize, Vec<i64>)> = Vec::new();
let mut uni_header_pending = false;
for (no, raw) in source.lines().enumerate() {
let line = no + 1;
let text = raw.split(';').next().unwrap_or_default().trim();
if text.is_empty() {
continue;
}
if let Some(head) = text.strip_prefix('*') {
let upper = head.trim_start().to_ascii_uppercase();
if let Some(rest) = upper.strip_prefix("BIGFONT") {
kind = Kind::Big;
let v = numbers(rest, line)?;
let n = usize::try_from(v.get(1).copied().unwrap_or(0)).unwrap_or(0);
for k in 0..n {
let lo = v.get(2 + 2 * k).copied().unwrap_or(0);
let hi = v.get(3 + 2 * k).copied().unwrap_or(lo);
let to = |x: i64| u16::try_from(x).map_err(|_| ShxError::Source(line, "bad escape range".into()));
ranges.push((to(lo)?, to(hi)?));
}
continue;
}
if upper.starts_with("UNIFONT") {
kind = Kind::Uni;
let name = head.splitn(3, ',').nth(2).unwrap_or_default().trim().to_string();
uni_info = Some((name, Vec::new()));
uni_header_pending = true;
continue;
}
uni_header_pending = false;
let mut parts = head.splitn(3, ',');
let num = number(parts.next().unwrap_or_default().trim(), line)?;
let _defbytes = parts.next();
let name = parts.next().unwrap_or_default().trim().to_string();
defs.push((num, name, line, Vec::new()));
continue;
}
let v = numbers(text, line)?;
if uni_header_pending {
if let Some((_, info)) = uni_info.as_mut() {
for x in v {
info.push(u8::try_from(x).map_err(|_| ShxError::Source(line, "bad font record".into()))?);
}
}
continue;
}
match defs.last_mut() {
Some(d) => d.3.extend(v),
None => return Err(ShxError::Source(line, "specification bytes before any *number,defbytes,NAME line".into())),
}
}
let mut shapes: Vec<Shape> = Vec::new();
for (num, name, line, toks) in defs {
let number = u16::try_from(num).map_err(|_| ShxError::Source(line, format!("shape number {num} out of range")))?;
let program = if number == 0 {
// The font record: plain bytes.
toks.iter().map(|x| u8::try_from(*x).map_err(|_| ShxError::Source(line, "bad font record".into()))).collect::<Result<Vec<u8>, _>>()?
} else {
Enc { toks: &toks, i: 0, out: Vec::new(), kind, line }.program()?
};
if shapes.iter().any(|s| s.number == number) {
return Err(ShxError::Source(line, format!("shape {number} is defined twice")));
}
shapes.push(Shape { number, name, program });
}
if shapes.is_empty() {
return Err(ShxError::Empty);
}
shapes.sort_by_key(|s| s.number);
let record = |s: &Shape| -> Result<Vec<u8>, ShxError> {
let mut r: Vec<u8> = s.name.bytes().filter(|b| *b != 0).collect();
r.push(0);
r.extend(&s.program);
if r.len() > usize::from(u16::MAX) { Err(ShxError::TooLarge) } else { Ok(r) }
};
let count = u16::try_from(shapes.len()).map_err(|_| ShxError::TooLarge)?;
let len16 = |r: &[u8]| u16::try_from(r.len()).unwrap_or(u16::MAX);
let mut out = Vec::new();
match kind {
Kind::Shapes => {
out.extend(SHAPES_10);
out.extend(shapes.first().map_or(0, |s| s.number).to_le_bytes());
out.extend(shapes.last().map_or(0, |s| s.number).to_le_bytes());
out.extend(count.to_le_bytes());
let recs: Vec<Vec<u8>> = shapes.iter().map(record).collect::<Result<_, _>>()?;
for (s, r) in shapes.iter().zip(&recs) {
out.extend(s.number.to_le_bytes());
out.extend(len16(r).to_le_bytes());
}
for r in &recs {
out.extend(r);
}
out.extend(b"EOF");
}
Kind::Big => {
out.extend(BIGFONT);
let nranges = u16::try_from(ranges.len()).map_err(|_| ShxError::TooLarge)?;
out.extend(count.to_le_bytes());
out.extend(count.to_le_bytes());
out.extend(nranges.to_le_bytes());
for (lo, hi) in &ranges {
out.extend(lo.to_le_bytes());
out.extend(hi.to_le_bytes());
}
let recs: Vec<Vec<u8>> = shapes.iter().map(record).collect::<Result<_, _>>()?;
let mut off = out.len() + 8 * recs.len();
for (s, r) in shapes.iter().zip(&recs) {
out.extend(s.number.to_le_bytes());
out.extend(len16(r).to_le_bytes());
out.extend(u32::try_from(off).map_err(|_| ShxError::TooLarge)?.to_le_bytes());
off += r.len();
}
for r in &recs {
out.extend(r);
}
}
Kind::Uni => {
out.extend(UNIFONT);
let (name, info) = uni_info.unwrap_or_default();
out.extend((u32::from(count) + 1).to_le_bytes());
let mut rec: Vec<u8> = name.bytes().filter(|b| *b != 0).collect();
rec.push(0);
rec.extend(&info);
out.extend(len16(&rec).to_le_bytes());
out.extend(&rec);
for s in &shapes {
let r = record(s)?;
out.extend(s.number.to_le_bytes());
out.extend(len16(&r).to_le_bytes());
out.extend(r);
}
}
}
Ok(out)
}
+248
View File
@@ -0,0 +1,248 @@
//! SHX shape fonts: text fonts, big fonts (East Asian double-byte) and Unicode fonts that a
//! drawing's text styles name, read at run time from files the user has. None is bundled.
//!
//! - [`ShxFont::parse`] reads the compiled containers (`shapes 1.0/1.1`, `bigfont 1.0`,
//! `unifont 1.0`) and [`ShxFont::glyph`] runs a shape's pen program (the vector length and
//! direction codes and special codes 0–14 of the public shape description language) into
//! polylines in font units. Every count, offset and loop in a file is bounded: a hostile or
//! truncated file never panics, it reads as far as it is valid or not at all.
//! - [`compile`] turns shape source (`.shp`) into a compiled file, as COMPILE does. The tests
//! build every font they use with it: no font file is in this repository.
//! - Fonts are found by name through the font table (`table`): registered bytes
//! ([`register`], the web build's files opened alongside a drawing), an app-set
//! [`FontProvider`], the folders of opened drawings ([`add_drawing_dir`]) and the support
//! search path ([`set_search_path`], `CADCRAFT_SHXPATH`), after the font map
//! ([`set_font_map`]). CADCraft never looks inside another CAD program's installation.
//! - [`ShxText`] is a text style's pair of fonts (main + big font) and orientation, which the
//! text layout draws ([`crate::TextFont::Shx`]): advances are the pen's end positions, so
//! widths and extents follow the font's own metrics.
//!
//! Binary container layouts (little-endian): a signature line ending in CR LF 0x1A, then
//! - shapes: first and last shape number, count, an index of (number, record length), the
//! records (a NUL-terminated name, then the program) and `EOF`;
//! - big font: index size, count, escape-range count, the ranges (first, last lead byte),
//! an index of (number, record length, file offset) and the records;
//! - unifont: shape count (32 bits), the length of the font record, the font record (name,
//! above, below, modes, encoding, embedding, 0) and then each shape as (number, length,
//! record).
mod compile;
mod parse;
mod program;
mod table;
#[cfg(test)]
mod tests;
mod text;
use std::collections::HashMap;
use std::sync::{Arc, PoisonError, RwLock};
use cadcraft_geom::Vec2;
pub use compile::compile;
pub use table::{
FontProvider, add_drawing_dir, clear_registered, drawing_dirs, find, font_map, generation, register, search_path, set_font_map, set_provider,
set_search_path,
};
pub use text::ShxText;
pub(crate) use text::{shape, width};
/// Why a font file can't be read or a shape source can't be compiled.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ShxError {
/// Not an SHX file (unknown signature).
NotShx,
/// The file ends inside its header or index.
Truncated,
/// The file is larger, or has more of something, than CADCraft reads.
TooLarge,
/// The file is well-formed but holds no shape.
Empty,
/// Shape source error: line and message.
Source(usize, String),
}
impl std::fmt::Display for ShxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ShxError::NotShx => f.write_str("not an SHX shape or font file"),
ShxError::Truncated => f.write_str("the SHX file is truncated"),
ShxError::TooLarge => f.write_str("the SHX file is too large"),
ShxError::Empty => f.write_str("the SHX file has no shapes"),
ShxError::Source(line, msg) => write!(f, "line {line}: {msg}"),
}
}
}
impl std::error::Error for ShxError {}
/// The kind of SHX file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShxKind {
/// A shape file or a text font with single-byte character codes (`shapes 1.0/1.1`).
Shapes,
/// A big font: double-byte character codes after an escape (lead) byte.
BigFont,
/// A Unicode font: shape numbers are Unicode code points.
Unifont,
}
/// A shape's program run into polylines, in font units (pen starts at the origin).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ShxGlyph {
/// Pen-down runs (two points or more).
pub strokes: Vec<Vec<Vec2>>,
/// Where the pen ends: the advance to the next character.
pub advance: Vec2,
}
/// One shape record: where its name and program are in [`ShxFont::data`].
#[derive(Clone, Copy, Debug)]
pub(crate) struct Record {
name: (usize, usize),
program: (usize, usize),
}
/// Glyphs cached per font, keyed by (shape, vertical).
type GlyphCache = HashMap<(u16, bool), Option<Arc<ShxGlyph>>>;
/// Most glyphs cached per font (a big font has thousands; text uses a few hundred).
const MAX_CACHED_GLYPHS: usize = 16_384;
/// A parsed SHX file.
pub struct ShxFont {
kind: ShxKind,
name: String,
/// Shape 0 (the font record) is present: the file is a text font, not only shapes.
header: bool,
above: f64,
below: f64,
/// Modes 2: horizontal and vertical (code 14 is honoured).
dual: bool,
/// Unifont encoding: 0 Unicode, 1 packed multibyte, 2 shape file.
encoding: u8,
/// Extended big font: the font record gives a character width (subshape cell).
char_width: f64,
/// Big font escape ranges (lead bytes).
ranges: Vec<(u16, u16)>,
data: Vec<u8>,
records: HashMap<u16, Record>,
cache: RwLock<GlyphCache>,
}
impl std::fmt::Debug for ShxFont {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ShxFont")
.field("kind", &self.kind)
.field("name", &self.name)
.field("above", &self.above)
.field("below", &self.below)
.field("dual", &self.dual)
.field("shapes", &self.records.len())
.finish()
}
}
impl ShxFont {
/// Parse a compiled SHX file.
pub fn parse(bytes: &[u8]) -> Result<ShxFont, ShxError> {
parse::parse(bytes)
}
pub fn kind(&self) -> ShxKind {
self.kind
}
/// The font name in its font record (shape 0), or empty.
pub fn name(&self) -> &str {
&self.name
}
/// Whether the file has a font record (shape 0): a text font rather than a shape file.
pub fn is_font(&self) -> bool {
self.header
}
/// Vector units uppercase letters rise above the baseline (big font: the character height).
/// Text height `h` draws the font at `h / above` per unit. 0 for a shape file.
pub fn above(&self) -> f64 {
self.above
}
/// Vector units lowercase descenders drop below the baseline.
pub fn below(&self) -> f64 {
self.below
}
/// The font supports vertical text (modes 2).
pub fn is_dual(&self) -> bool {
self.dual
}
/// The units per text height: [`Self::above`], or 1 for a file without a font record.
pub fn units_per_height(&self) -> f64 {
if self.above > 0.0 { self.above } else { 1.0 }
}
/// Big font escape ranges: `(first, last)` lead bytes.
pub fn escape_ranges(&self) -> &[(u16, u16)] {
&self.ranges
}
/// Whether `b` starts a double-byte character in this big font.
pub fn is_lead_byte(&self, b: u8) -> bool {
self.ranges.iter().any(|(lo, hi)| (*lo..=*hi).contains(&u16::from(b)))
}
/// Unifont encoding (0 Unicode, 1 packed multibyte, 2 shape file); 0 for other kinds.
pub fn encoding(&self) -> u8 {
self.encoding
}
pub fn shape_count(&self) -> usize {
self.records.len()
}
pub fn has_shape(&self, number: u16) -> bool {
number != 0 && self.records.contains_key(&number)
}
/// The shape numbers in the file, sorted (not shape 0).
pub fn shape_numbers(&self) -> Vec<u16> {
let mut v: Vec<u16> = self.records.keys().copied().filter(|n| *n != 0).collect();
v.sort_unstable();
v
}
/// The name of shape `number` as the file stores it.
pub fn shape_name(&self, number: u16) -> Option<String> {
let r = self.records.get(&number)?;
self.data.get(r.name.0..r.name.1).map(|b| b.iter().map(|c| char::from(*c)).collect())
}
/// The number of the shape called `name` (case-insensitive), as a linetype's
/// `[SHAPE,file.shx]` element names it.
pub fn shape_number(&self, name: &str) -> Option<u16> {
let want = name.trim();
if want.is_empty() {
return None;
}
let mut found: Vec<u16> = self
.records
.iter()
.filter(|(n, r)| **n != 0 && self.data.get(r.name.0..r.name.1).is_some_and(|b| b.eq_ignore_ascii_case(want.as_bytes())))
.map(|(n, _)| *n)
.collect();
found.sort_unstable();
found.first().copied()
}
/// The program bytes of shape `number`.
pub(crate) fn program(&self, number: u16) -> Option<&[u8]> {
let r = self.records.get(&number)?;
self.data.get(r.program.0..r.program.1)
}
/// Shape `number` drawn horizontally or vertically, in font units; `None` when the font
/// has no such shape or its program is broken. Cached.
pub fn glyph(&self, number: u16, vertical: bool) -> Option<Arc<ShxGlyph>> {
if !self.has_shape(number) {
return None;
}
let vertical = vertical && self.dual;
let key = (number, vertical);
if let Some(g) = self.cache.read().unwrap_or_else(PoisonError::into_inner).get(&key) {
return g.clone();
}
let g = program::outline(self, number, vertical).ok().map(Arc::new);
let mut c = self.cache.write().unwrap_or_else(PoisonError::into_inner);
if c.len() >= MAX_CACHED_GLYPHS {
c.clear();
}
c.insert(key, g.clone());
g
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Reading the compiled SHX containers (layouts in the module documentation). Every count and
//! offset comes from the file, so each is checked against its length; records that run past
//! the end are dropped and the rest kept.
use std::collections::HashMap;
use std::sync::RwLock;
use super::{Record, ShxError, ShxFont, ShxKind};
pub(super) const SHAPES_10: &[u8] = b"AutoCAD-86 shapes 1.0\r\n\x1a";
pub(super) const SHAPES_11: &[u8] = b"AutoCAD-86 shapes 1.1\r\n\x1a";
pub(super) const BIGFONT: &[u8] = b"AutoCAD-86 bigfont 1.0\r\n\x1a";
pub(super) const UNIFONT: &[u8] = b"AutoCAD-86 unifont 1.0\r\n\x1a";
/// Largest file read (the largest big fonts are a few megabytes).
pub(super) const MAX_FILE_BYTES: usize = 32 << 20;
/// Most escape ranges in a big font (lead bytes: at most 256).
const MAX_RANGES: usize = 256;
/// A checked little-endian reader.
struct Rd<'a> {
b: &'a [u8],
pos: usize,
}
impl<'a> Rd<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], ShxError> {
let end = self.pos.checked_add(n).ok_or(ShxError::Truncated)?;
let s = self.b.get(self.pos..end).ok_or(ShxError::Truncated)?;
self.pos = end;
Ok(s)
}
fn u16(&mut self) -> Result<u16, ShxError> {
match self.take(2)? {
[a, b] => Ok(u16::from_le_bytes([*a, *b])),
_ => Err(ShxError::Truncated),
}
}
fn u32(&mut self) -> Result<u32, ShxError> {
match self.take(4)? {
[a, b, c, d] => Ok(u32::from_le_bytes([*a, *b, *c, *d])),
_ => Err(ShxError::Truncated),
}
}
}
/// A font being read.
struct Font {
kind: ShxKind,
name: String,
header: bool,
above: f64,
below: f64,
dual: bool,
encoding: u8,
char_width: f64,
ranges: Vec<(u16, u16)>,
records: HashMap<u16, Record>,
}
impl Font {
fn new(kind: ShxKind) -> Font {
Font {
kind,
name: String::new(),
header: false,
above: 0.0,
below: 0.0,
dual: false,
encoding: 0,
char_width: 0.0,
ranges: Vec::new(),
records: HashMap::new(),
}
}
/// Add the record of shape `number` at `start..end` of `data`: a NUL-terminated name, then
/// the program. Shape 0 is the font record. A record without a name terminator is dropped;
/// the first record of a number wins.
fn record(&mut self, data: &[u8], number: u16, start: usize, end: usize) {
let Some(bytes) = data.get(start..end) else { return };
let Some(nul) = bytes.iter().position(|b| *b == 0) else { return };
let name = (start, start + nul);
let program = (start + nul + 1, end);
if number == 0 {
if !self.header {
self.font_record(data.get(program.0..program.1).unwrap_or_default(), data.get(name.0..name.1).unwrap_or_default());
}
return;
}
self.records.entry(number).or_insert(Record { name, program });
}
/// The font record: above, below, modes (and for a unifont encoding and embedding); an
/// extended big font's is character height, 0, modes, character width.
fn font_record(&mut self, info: &[u8], name: &[u8]) {
self.header = true;
self.name = name.iter().take(255).map(|c| char::from(*c)).collect();
let byte = |i: usize| info.get(i).copied().unwrap_or(0);
self.above = f64::from(byte(0));
self.below = f64::from(byte(1));
self.dual = byte(2) == 2;
if self.kind == ShxKind::Unifont {
self.encoding = byte(3);
}
if self.kind == ShxKind::BigFont && info.len() >= 5 && byte(1) == 0 {
// Extended: height, 0, modes, width, 0.
self.below = 0.0;
self.char_width = f64::from(byte(3));
}
}
fn finish(self, data: Vec<u8>) -> Result<ShxFont, ShxError> {
if self.records.is_empty() {
return Err(ShxError::Empty);
}
Ok(ShxFont {
kind: self.kind,
name: self.name,
header: self.header,
above: self.above,
below: self.below,
dual: self.dual,
encoding: self.encoding,
char_width: if self.char_width > 0.0 { self.char_width } else { self.above },
ranges: self.ranges,
data,
records: self.records,
cache: RwLock::default(),
})
}
}
pub(super) fn parse(bytes: &[u8]) -> Result<ShxFont, ShxError> {
if bytes.len() > MAX_FILE_BYTES {
return Err(ShxError::TooLarge);
}
if bytes.starts_with(SHAPES_10) || bytes.starts_with(SHAPES_11) {
shapes(bytes)
} else if bytes.starts_with(BIGFONT) {
bigfont(bytes)
} else if bytes.starts_with(UNIFONT) {
unifont(bytes)
} else {
Err(ShxError::NotShx)
}
}
fn shapes(bytes: &[u8]) -> Result<ShxFont, ShxError> {
let mut r = Rd { b: bytes, pos: SHAPES_10.len() };
let _first = r.u16()?;
let _last = r.u16()?;
let count = usize::from(r.u16()?);
// The whole index must be there before anything is read from it.
let index = r.take(count.checked_mul(4).ok_or(ShxError::TooLarge)?)?;
let mut f = Font::new(ShxKind::Shapes);
let mut pos = r.pos;
for [a, b, c, d] in index.as_chunks::<4>().0 {
let (number, len) = (u16::from_le_bytes([*a, *b]), usize::from(u16::from_le_bytes([*c, *d])));
let Some(end) = pos.checked_add(len).filter(|e| *e <= bytes.len()) else { break };
f.record(bytes, number, pos, end);
pos = end;
}
f.finish(bytes.to_vec())
}
fn bigfont(bytes: &[u8]) -> Result<ShxFont, ShxError> {
let mut r = Rd { b: bytes, pos: BIGFONT.len() };
let index_size = usize::from(r.u16()?);
let count = usize::from(r.u16()?);
let nranges = usize::from(r.u16()?);
if nranges > MAX_RANGES {
return Err(ShxError::TooLarge);
}
let mut f = Font::new(ShxKind::BigFont);
for _ in 0..nranges {
let (lo, hi) = (r.u16()?, r.u16()?);
f.ranges.push((lo.min(hi), lo.max(hi)));
}
// The index has `count` entries of 8 bytes; a file whose count doesn't fit but whose index
// size does is read by that.
let fits = |n: usize| n.checked_mul(8).and_then(|l| r.pos.checked_add(l)).is_some_and(|e| e <= bytes.len());
let n = if fits(count) {
count
} else if fits(index_size) {
index_size
} else {
return Err(ShxError::Truncated);
};
let index = r.take(n.checked_mul(8).ok_or(ShxError::TooLarge)?)?;
for [a, b, c, d, o0, o1, o2, o3] in index.as_chunks::<8>().0 {
let number = u16::from_le_bytes([*a, *b]);
let len = usize::from(u16::from_le_bytes([*c, *d]));
let Ok(off) = usize::try_from(u32::from_le_bytes([*o0, *o1, *o2, *o3])) else { continue };
if len == 0 {
continue;
}
let Some(end) = off.checked_add(len).filter(|e| *e <= bytes.len()) else { continue };
f.record(bytes, number, off, end);
}
f.finish(bytes.to_vec())
}
fn unifont(bytes: &[u8]) -> Result<ShxFont, ShxError> {
let mut r = Rd { b: bytes, pos: UNIFONT.len() };
let count = r.u32()?;
let info_len = usize::from(r.u16()?);
let info = r.take(info_len)?;
let mut f = Font::new(ShxKind::Unifont);
let nul = info.iter().position(|b| *b == 0).unwrap_or(info.len());
f.font_record(info.get(nul.saturating_add(1)..).unwrap_or_default(), info.get(..nul).unwrap_or_default());
// `count` includes the font record; never trusted beyond the data that is there.
let mut left = count.saturating_sub(1);
while left > 0 {
left -= 1;
let Ok(number) = r.u16() else { break };
let Ok(len) = r.u16().map(usize::from) else { break };
let start = r.pos;
let Some(end) = start.checked_add(len).filter(|e| *e <= bytes.len()) else { break };
r.pos = end;
f.record(bytes, number, start, end);
}
f.finish(bytes.to_vec())
}
+344
View File
@@ -0,0 +1,344 @@
//! The shape pen program: vector length/direction bytes and special codes 0–14, run into
//! polylines. Bounded everywhere: subshape depth, location stack, steps, points, coordinates.
//!
//! - A byte with a non-zero high nibble is a vector: length (high nibble) in one of 16
//! directions (low nibble, counter-clockwise from east in 22.5° steps; the off-axis ones
//! are half-unit or unit steps on the minor axis).
//! - 0 end, 1 pen down, 2 pen up, 3/4 divide/multiply vector lengths by the next byte,
//! 5/6 push/pop the location, 7 subshape (one byte; two bytes high first in a Unicode font;
//! `0`, a two-byte number, base point, width and height in an extended big font),
//! 8 one X-Y displacement, 9 displacements up to (0,0), 10 octant arc (radius,
//! ±0SC: sign = clockwise, S start octant, C octants, 0 = 8), 11 fractional arc (start
//! offset, end offset, high and low radius, ±0SC; offsets in 1/256 of 45° from the octant
//! boundaries), 12 bulge arc (dx, dy, bulge: ±127 = semicircle), 13 bulge arcs up to (0,0),
//! 14 the next command is for vertical text only (dual-orientation fonts).
//! - Displacements and bulges are two's complement bytes; the arc direction byte keeps its
//! sign in the high bit.
use std::f64::consts::{FRAC_PI_4, TAU};
use cadcraft_geom::Vec2;
use super::{ShxFont, ShxGlyph, ShxKind};
/// Subshape nesting (a cycle ends here).
const MAX_DEPTH: usize = 16;
/// Location stack (the format promises four).
const MAX_STACK: usize = 64;
/// Bytes run per glyph, subshapes included.
const MAX_STEPS: usize = 200_000;
/// Points per glyph.
const MAX_POINTS: usize = 50_000;
/// Coordinates in font units.
const MAX_COORD: f64 = 1.0e7;
/// Arc segments per full turn.
const ARC_STEPS: f64 = 64.0;
/// Why a program stops early: the glyph is then not drawn.
#[derive(Debug)]
pub(super) struct Broken;
type Res = Result<(), Broken>;
struct Pen<'a> {
font: &'a ShxFont,
vertical: bool,
pos: Vec2,
down: bool,
/// Vector length factor (codes 3 and 4).
scale: f64,
/// Per-axis factor of an extended big font subshape.
axes: Vec2,
stack: Vec<Vec2>,
strokes: Vec<Vec<Vec2>>,
active: Vec<Vec2>,
steps: usize,
points: usize,
}
pub(super) fn outline(font: &ShxFont, number: u16, vertical: bool) -> Result<ShxGlyph, Broken> {
let mut pen = Pen {
font,
vertical,
pos: Vec2::ZERO,
down: true,
scale: 1.0,
axes: Vec2::new(1.0, 1.0),
stack: Vec::new(),
strokes: Vec::new(),
active: Vec::new(),
steps: 0,
points: 0,
};
pen.run(number, 0)?;
pen.flush();
Ok(ShxGlyph { strokes: pen.strokes, advance: pen.pos })
}
/// The 16 vector directions (unit length on the major axis).
fn direction(d: u8) -> Vec2 {
let (x, y) = match d & 15 {
0 => (1.0, 0.0),
1 => (1.0, 0.5),
2 => (1.0, 1.0),
3 => (0.5, 1.0),
4 => (0.0, 1.0),
5 => (-0.5, 1.0),
6 => (-1.0, 1.0),
7 => (-1.0, 0.5),
8 => (-1.0, 0.0),
9 => (-1.0, -0.5),
10 => (-1.0, -1.0),
11 => (-0.5, -1.0),
12 => (0.0, -1.0),
13 => (0.5, -1.0),
14 => (1.0, -1.0),
_ => (1.0, -0.5),
};
Vec2::new(x, y)
}
/// A two's complement operand byte.
fn signed(b: u8) -> f64 {
f64::from(i8::from_le_bytes([b]))
}
/// A program being read: its bytes and position.
struct Cur<'p> {
b: &'p [u8],
pos: usize,
}
impl Cur<'_> {
fn byte(&mut self) -> Result<u8, Broken> {
let v = self.b.get(self.pos).copied().ok_or(Broken)?;
self.pos += 1;
Ok(v)
}
}
impl Pen<'_> {
fn step(&mut self) -> Res {
self.steps += 1;
if self.steps > MAX_STEPS { Err(Broken) } else { Ok(()) }
}
fn flush(&mut self) {
if self.active.len() >= 2 {
self.strokes.push(std::mem::take(&mut self.active));
} else {
self.active.clear();
}
}
/// Move the pen by `d` font units (already scaled), drawing when the pen is down.
fn to(&mut self, target: Vec2) -> Res {
if !target.is_finite() || target.x.abs().max(target.y.abs()) > MAX_COORD {
return Err(Broken);
}
if self.down {
if self.active.is_empty() {
self.active.push(self.pos);
self.points += 1;
}
self.active.push(target);
self.points += 1;
if self.points > MAX_POINTS {
return Err(Broken);
}
} else {
self.flush();
}
self.pos = target;
Ok(())
}
/// A displacement in vector units (times the length factor and the subshape axes).
fn by(&mut self, d: Vec2) -> Res {
let k = d * self.scale;
self.to(self.pos + Vec2::new(k.x * self.axes.x, k.y * self.axes.y))
}
/// An arc of `radius` vector units from the pen, starting at angle `start` on its circle
/// and turning `sweep` radians (positive counter-clockwise).
fn arc(&mut self, radius: f64, start: f64, sweep: f64) -> Res {
let r = radius * self.scale;
if !(r.is_finite() && r > 0.0 && sweep.is_finite()) {
return Ok(());
}
let n = if self.down { (sweep.abs() / TAU * ARC_STEPS).ceil().clamp(1.0, ARC_STEPS) as usize } else { 1 };
let from = self.pos;
let s0 = Vec2::new(start.cos(), start.sin());
for i in 1..=n {
let a = start + sweep * i as f64 / n as f64;
let d = (Vec2::new(a.cos(), a.sin()) - s0) * r;
self.to(from + Vec2::new(d.x * self.axes.x, d.y * self.axes.y))?;
}
Ok(())
}
/// An arc to the displacement `d` with `bulge` (−127..127, ±127 a semicircle).
fn bulge(&mut self, d: Vec2, bulge: f64) -> Res {
let b = bulge.clamp(-127.0, 127.0) / 127.0;
if b == 0.0 || !self.down || d.len2() == 0.0 {
return self.by(d);
}
// Centre from the chord midpoint along its left normal; sweep 4·atan(b).
let centre = d * 0.5 + Vec2::new(-d.y, d.x) * ((1.0 - b * b) / (4.0 * b));
let rel = -centre;
self.arc(rel.len(), rel.y.atan2(rel.x), 4.0 * b.atan())
}
/// The octant byte `±0SC`: (clockwise, start octant, octants with 0 = 8).
fn octants(spec: u8) -> (f64, f64, f64) {
let sign = if spec & 0x80 != 0 { -1.0 } else { 1.0 };
let s = f64::from((spec >> 4) & 7);
let c = match spec & 7 {
0 => 8.0,
c => f64::from(c),
};
(sign, s, c)
}
/// The vector unit size of an extended big font's subshape cell.
fn cell(&self) -> Vec2 {
let h = if self.font.above > 0.0 { self.font.above } else { 1.0 };
let w = if self.font.char_width > 0.0 { self.font.char_width } else { h };
Vec2::new(w, h)
}
fn run(&mut self, number: u16, depth: usize) -> Res {
if depth >= MAX_DEPTH {
return Err(Broken);
}
let program = self.font.program(number).ok_or(Broken)?;
let mut r = Cur { b: program, pos: 0 };
let mut skip = false;
while r.pos < r.b.len() {
self.step()?;
let code = r.byte()?;
// Code 14: in a dual-orientation font the next command is for vertical text only.
let exec = !skip;
skip = false;
match code {
0 => return Ok(()),
1 if exec => self.down = true,
2 if exec => {
self.down = false;
self.flush();
}
3 | 4 => {
let k = f64::from(r.byte()?);
if exec && k > 0.0 {
let s = if code == 3 { self.scale / k } else { self.scale * k };
if !(1e-9..=MAX_COORD).contains(&s) {
return Err(Broken);
}
self.scale = s;
}
}
5 if exec => {
if self.stack.len() >= MAX_STACK {
return Err(Broken);
}
self.stack.push(self.pos);
}
6 if exec => {
self.flush();
if let Some(p) = self.stack.pop() {
self.pos = p;
}
}
7 => self.subshape(&mut r, exec, depth)?,
8 | 9 | 12 | 13 => loop {
self.step()?;
let d = Vec2::new(signed(r.byte()?), signed(r.byte()?));
if matches!(code, 9 | 13) && d == Vec2::ZERO {
break;
}
let bulge = if matches!(code, 12 | 13) { signed(r.byte()?) } else { 0.0 };
if exec {
self.bulge(d, bulge)?;
}
if matches!(code, 8 | 12) {
break;
}
},
10 => {
let radius = f64::from(r.byte()?);
let (sign, s, c) = Self::octants(r.byte()?);
if exec {
self.arc(radius, s * FRAC_PI_4, sign * c * FRAC_PI_4)?;
}
}
11 => {
let start = f64::from(r.byte()?) / 256.0;
let end = match r.byte()? {
0 => 1.0,
e => f64::from(e) / 256.0,
};
let radius = f64::from(u16::from_be_bytes([r.byte()?, r.byte()?]));
let (sign, s, c) = Self::octants(r.byte()?);
if exec {
// From `start` past the first octant boundary to `end` past the last
// octant's.
self.arc(radius, (s + sign * start) * FRAC_PI_4, sign * (c - 1.0 + end - start) * FRAC_PI_4)?;
}
}
14 => skip = self.font.dual && !self.vertical && exec,
1..=15 => {}
v => {
if exec {
self.by(direction(v) * f64::from(v >> 4))?;
}
}
}
}
Ok(())
}
/// Code 7 and its operands.
fn subshape(&mut self, r: &mut Cur, exec: bool, depth: usize) -> Res {
match self.font.kind {
ShxKind::Unifont => {
let n = u16::from_be_bytes([r.byte()?, r.byte()?]);
if exec {
self.run(n, depth + 1)?;
}
}
ShxKind::BigFont => {
let first = r.byte()?;
if first != 0 {
if exec {
self.run(u16::from(first), depth + 1)?;
}
return Ok(());
}
// Extended: the primitive drawn in the box (x, y, width, height) of the cell.
let n = u16::from_be_bytes([r.byte()?, r.byte()?]);
let (x, y, w, h) = (f64::from(r.byte()?), f64::from(r.byte()?), f64::from(r.byte()?), f64::from(r.byte()?));
if !exec {
return Ok(());
}
let (pos, axes, down) = (self.pos, self.axes, self.down);
let cell = self.cell();
self.flush();
self.pos = pos + Vec2::new(x * axes.x, y * axes.y) * self.scale;
self.axes = Vec2::new(axes.x * w / cell.x, axes.y * h / cell.y);
let res = self.run(n, depth + 1);
self.flush();
self.axes = axes;
self.pos = pos;
self.down = down;
res?;
}
ShxKind::Shapes => {
let n = u16::from(r.byte()?);
if exec {
self.run(n, depth + 1)?;
}
}
}
Ok(())
}
}
+267
View File
@@ -0,0 +1,267 @@
//! Finding SHX files by the name a text style or linetype gives (`romans.shx`, `GBCBIG`,
//! `C:\fonts\simplex.shx`): only the file name counts, so a path stored in a drawing never
//! reaches another folder or computer.
//!
//! Order: the font map ([`set_font_map`]) renames the file; then fonts registered by the app
//! ([`register`]: the web build's files opened alongside a drawing, tests); then the app's
//! [`FontProvider`]; then, natively, the folders of opened drawings ([`add_drawing_dir`], most
//! recent first) and the support search path ([`set_search_path`], from `CADCRAFT_SHXPATH` at
//! start). Results, found or not, are cached until one of these changes ([`generation`]
//! counts the changes, so a viewer knows to regenerate).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
use super::parse::MAX_FILE_BYTES;
use super::{ShxError, ShxFont};
/// Where an app finds font files CADCraft can't reach itself (a web page's file store, an
/// app's own font folder). Called with the normalised file name (`romans.shx`); returns the
/// file's bytes. Called without the font table's lock held.
pub trait FontProvider: Send + Sync {
fn load(&self, file_name: &str) -> Option<Vec<u8>>;
}
/// Most fonts kept loaded, and their total bytes.
const MAX_FONTS: usize = 128;
const MAX_LOADED_BYTES: usize = 256 << 20;
/// Most drawing folders and search path entries remembered.
const MAX_DIRS: usize = 32;
/// Most font map entries.
const MAX_MAP: usize = 1024;
#[derive(Default)]
struct Table {
registered: HashMap<String, Arc<ShxFont>>,
resolved: HashMap<String, Option<Arc<ShxFont>>>,
loaded_bytes: usize,
provider: Option<Arc<dyn FontProvider>>,
drawing_dirs: Vec<PathBuf>,
search_path: Vec<PathBuf>,
map: Vec<(String, String)>,
generation: u64,
}
impl Table {
/// Forget cached results: something that changes them changed.
fn changed(&mut self) {
self.resolved.clear();
self.loaded_bytes = self.registered.values().map(|f| f.data.len()).sum();
self.generation = self.generation.wrapping_add(1);
}
}
fn table() -> MutexGuard<'static, Table> {
static T: OnceLock<Mutex<Table>> = OnceLock::new();
T.get_or_init(|| {
#[cfg(not(target_arch = "wasm32"))]
let search_path = std::env::var_os("CADCRAFT_SHXPATH")
.map(|p| std::env::split_paths(&p).filter(|d| !d.as_os_str().is_empty()).take(MAX_DIRS).collect())
.unwrap_or_default();
#[cfg(target_arch = "wasm32")]
let search_path = Vec::new();
Mutex::new(Table { search_path, ..Table::default() })
})
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
/// The lookup key of a font name: its lower-case file name with `.shx` (`romans.shx`), or
/// `None` for a name that isn't an SHX file (a TrueType font, empty, the built-in font).
pub(crate) fn key(name: &str) -> Option<String> {
let base = name.trim().rsplit(['/', '\\']).next()?.trim();
if base.is_empty() || base.len() > 255 || base.contains(':') || base.chars().any(char::is_control) {
return None;
}
let lower = base.to_ascii_lowercase();
if lower.ends_with(".shx") {
return (lower.len() > 4).then_some(lower);
}
let ext = Path::new(&lower).extension().map(|e| e.to_string_lossy().to_string());
match ext.as_deref() {
// `txt` (no extension) is a classic style font name; other extensions are other kinds.
None => (lower != crate::BUILTIN_FONT.to_ascii_lowercase() && !lower.contains(' ')).then(|| format!("{lower}.shx")),
Some(_) => None,
}
}
/// Register font bytes under a file name (the web build's files opened alongside a drawing;
/// tests). Replaces a font registered under the same name.
pub fn register(name: &str, bytes: &[u8]) -> Result<(), ShxError> {
let k = key(name).ok_or(ShxError::NotShx)?;
let font = Arc::new(ShxFont::parse(bytes)?);
let mut t = table();
if !t.registered.contains_key(&k) && t.registered.len() >= MAX_FONTS {
return Err(ShxError::TooLarge);
}
t.registered.insert(k, font);
t.changed();
Ok(())
}
/// Forget every registered font (a web session closing its drawings).
pub fn clear_registered() {
let mut t = table();
t.registered.clear();
t.changed();
}
/// Set (or clear) the app's font provider.
pub fn set_provider(provider: Option<Arc<dyn FontProvider>>) {
let mut t = table();
t.provider = provider;
t.changed();
}
/// Search the folder of a drawing being opened for the fonts its styles name (most recent
/// first).
pub fn add_drawing_dir(dir: &Path) {
if dir.as_os_str().is_empty() {
return;
}
let mut t = table();
if t.drawing_dirs.first().is_some_and(|d| d == dir) {
return;
}
t.drawing_dirs.retain(|d| d != dir);
t.drawing_dirs.insert(0, dir.to_path_buf());
t.drawing_dirs.truncate(MAX_DIRS);
t.changed();
}
/// The drawing folders searched, most recent first.
pub fn drawing_dirs() -> Vec<PathBuf> {
table().drawing_dirs.clone()
}
/// Set the support search path for SHX files: folders separated by `;` (or the platform's
/// path separator). Empty clears it.
pub fn set_search_path(list: &str) {
let dirs: Vec<PathBuf> = list
.split(';')
.flat_map(|p| if cfg!(any(windows, target_arch = "wasm32")) { vec![PathBuf::from(p)] } else { std::env::split_paths(p).collect() })
.filter(|d| !d.as_os_str().is_empty() && !d.to_string_lossy().trim().is_empty())
.take(MAX_DIRS)
.collect();
let mut t = table();
t.search_path = dirs;
t.changed();
}
/// The support search path, `;`-separated.
pub fn search_path() -> String {
table().search_path.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(";")
}
/// Set the font map: one `font;replacement` per line (or separated by `|`), as a font mapping
/// file lists them. Lines that aren't a pair are ignored; empty clears it.
pub fn set_font_map(text: &str) {
let map: Vec<(String, String)> = text
.split(['\n', '|'])
.filter_map(|l| {
let (a, b) = l.split_once(';')?;
Some((key(a)?, b.trim().to_string()))
})
.filter(|(_, b)| !b.is_empty())
.take(MAX_MAP)
.collect();
let mut t = table();
t.map = map;
t.changed();
}
/// The font map, one `font;replacement` per line.
pub fn font_map() -> String {
table().map.iter().map(|(a, b)| format!("{a};{b}")).collect::<Vec<_>>().join("\n")
}
/// Counts changes to what fonts are found (registration, provider, folders, map).
pub fn generation() -> u64 {
table().generation
}
/// The SHX font file a style or linetype names, if CADCraft can find and read it.
pub fn find(name: &str) -> Option<Arc<ShxFont>> {
let mut k = key(name)?;
let (provider, dirs, generation) = {
let t = table();
// The font map renames once (a map can't loop).
if let Some(to) = t.map.iter().find(|(from, _)| *from == k).and_then(|(_, to)| key(to)) {
k = to;
}
if let Some(f) = t.registered.get(&k) {
return Some(f.clone());
}
if let Some(r) = t.resolved.get(&k) {
return r.clone();
}
let dirs: Vec<PathBuf> = t.drawing_dirs.iter().chain(t.search_path.iter()).cloned().collect();
(t.provider.clone(), dirs, t.generation)
};
let font = provider
.and_then(|p| p.load(&k))
.or_else(|| read_from(&dirs, &k))
.filter(|b| b.len() <= MAX_FILE_BYTES)
.and_then(|b| ShxFont::parse(&b).ok())
.map(Arc::new);
let mut t = table();
// Something changed while the file was read: keep the result out of the new cache.
if t.generation != generation {
return font;
}
if t.resolved.len() >= MAX_FONTS * 4 {
t.resolved.clear();
t.loaded_bytes = t.registered.values().map(|f| f.data.len()).sum();
}
let size = font.as_ref().map_or(0, |f| f.data.len());
if t.loaded_bytes.saturating_add(size) > MAX_LOADED_BYTES {
// Over the memory bound: used, not kept.
return font;
}
t.loaded_bytes += size;
t.resolved.insert(k, font.clone());
font
}
/// Most directory entries looked at for a font file whose name differs only in case.
#[cfg(not(target_arch = "wasm32"))]
const MAX_DIR_ENTRIES: usize = 10_000;
/// The file `name` (a lower-case key) in the first of `dirs` that has it, whatever the case of
/// its name: lower-case, upper-case, else any other case found in the directory (`Simplex.shx`
/// on a case-sensitive file system).
#[cfg(not(target_arch = "wasm32"))]
fn read_from(dirs: &[PathBuf], name: &str) -> Option<Vec<u8>> {
use std::io::Read as _;
let read = |file: &Path| -> Option<Vec<u8>> {
let meta = std::fs::metadata(file).ok()?;
if !meta.is_file() || meta.len() > MAX_FILE_BYTES as u64 {
return None;
}
let mut bytes = Vec::new();
let limit = u64::try_from(MAX_FILE_BYTES).unwrap_or(u64::MAX).saturating_add(1);
std::fs::File::open(file).and_then(|f| f.take(limit).read_to_end(&mut bytes)).ok()?;
Some(bytes)
};
for d in dirs {
if let Some(b) = [d.join(name), d.join(name.to_ascii_uppercase())].iter().find_map(|f| read(f)) {
return Some(b);
}
let Ok(entries) = std::fs::read_dir(d) else { continue };
let found = entries
.take(MAX_DIR_ENTRIES)
.filter_map(Result::ok)
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(name)))
.and_then(|e| read(&e.path()));
if found.is_some() {
return found;
}
}
None
}
#[cfg(target_arch = "wasm32")]
fn read_from(_dirs: &[PathBuf], _name: &str) -> Option<Vec<u8>> {
None
}
+450
View File
@@ -0,0 +1,450 @@
//! SHX tests. Every font here is compiled from shape source written for these tests: no font
//! file is read from disk or committed.
use std::f64::consts::PI;
use cadcraft_geom::Vec2;
use super::*;
use crate::{TextFont, TextParams};
/// A small dual-orientation text font: 6 units above, 2 below.
pub(crate) const TEXT_FONT: &str = "\
;; CADCraft test font (written for the tests)
*0,4,cadcraft test
6,2,2,0
*32,3,SPACE
2,060,0
; A: up 6, right 4, down 6, then a gap of 2
*65,7,UCA
1,064,040,06C,2,020,0
; B: a displacement out and back, then 5 on
*66,10,UCB
2,010,1,8,(3,4),8,(-3,-4),2,050,0
; C: vertical-only moves (code 14)
*67,12,UCC
2,14,8,(0,-8),1,064,2,14,8,(0,-6),040,0
; the degree sign: a full octant circle
*256,12,DEGREE
2,8,(1,4),1,10,(1,0),2,8,(2,-4),0
*257,6,PLUSMINUS
1,040,2,020,0
*258,6,DIAMETER
1,044,2,020,0
";
/// A Unicode font with the same letters, a subshape and the diameter sign at U+2205.
const UNI_FONT: &str = "\
*UNIFONT,6,cadcraft uni
6,2,0,0,0,0
*00041,7,uca
1,064,040,06C,2,020,0
*000C5,9,ring a
7,00041,2,8,(-4,7),1,040,2,8,(2,-7),0
*02205,6,diameter
1,044,2,020,0
";
/// A big font with Shift-JIS lead bytes: one square character (U+3042, 0x82A0).
const BIG_FONT: &str = "\
*BIGFONT 2,1,081,09F
*0,4,cadcraft big
8,0,0,0
*082A0,7,square
1,080,084,088,08C,2,080,0
";
/// An extended big font: two primitives of a 16 x 16 cell drawn into one character.
const EXT_BIG_FONT: &str = "\
*BIGFONT 2,1,081,09F
*0,5,cadcraft ext
16,0,0,16,0
*08F01,10,box
4,2,1,080,084,088,08C,3,2,0
*082A2,20,two boxes
7,0,08F01,0,0,8,16,7,0,08F01,8,0,8,16,2,4,2,080,3,2,0
";
fn font(src: &str) -> ShxFont {
ShxFont::parse(&compile(src).unwrap()).unwrap()
}
/// A shape file (no font record) with one shape from `spec`.
fn one(spec: &str) -> ShxFont {
font(&format!("*1,1,T\n{spec}\n"))
}
fn adv(spec: &str) -> Vec2 {
one(spec).glyph(1, false).unwrap().advance
}
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).len() < 1e-6
}
fn dir(deg: f64) -> Vec2 {
let r = deg.to_radians();
Vec2::new(r.cos(), r.sin())
}
#[test]
fn every_vector_direction() {
let want = [
(1.0, 0.0),
(1.0, 0.5),
(1.0, 1.0),
(0.5, 1.0),
(0.0, 1.0),
(-0.5, 1.0),
(-1.0, 1.0),
(-1.0, 0.5),
(-1.0, 0.0),
(-1.0, -0.5),
(-1.0, -1.0),
(-0.5, -1.0),
(0.0, -1.0),
(0.5, -1.0),
(1.0, -1.0),
(1.0, -0.5),
];
for (d, (x, y)) in want.iter().enumerate() {
let a = adv(&format!("02{d:X},0"));
assert!(close(a, Vec2::new(2.0 * x, 2.0 * y)), "direction {d}: {a:?}");
}
// Length 15, the longest vector.
assert!(close(adv("0F0,0"), Vec2::new(15.0, 0.0)));
}
#[test]
fn pen_up_down_scale_push_pop() {
let f = one("2,010,1,020,2,010,1,010,0");
let g = f.glyph(1, false).unwrap();
assert_eq!(g.strokes, vec![vec![Vec2::new(1.0, 0.0), Vec2::new(3.0, 0.0)], vec![Vec2::new(4.0, 0.0), Vec2::new(5.0, 0.0)]]);
// 4 multiplies, 3 divides: 3 + 1.
assert!(close(adv("4,3,010,3,3,010,0"), Vec2::new(4.0, 0.0)));
// Push, draw, pop: the next run starts where the push was.
let g = one("5,040,6,014,0").glyph(1, false).unwrap();
assert_eq!(g.strokes.len(), 2);
assert!(close(g.strokes[1][0], Vec2::ZERO) && close(g.advance, Vec2::new(0.0, 1.0)));
// An unmatched pop is ignored.
assert!(close(adv("6,010,0"), Vec2::new(1.0, 0.0)));
}
#[test]
fn displacements() {
assert!(close(adv("8,(-3,5),0"), Vec2::new(-3.0, 5.0)));
assert!(close(adv("9,(1,1),(2,-1),(-127,0),(0,0),0"), Vec2::new(-124.0, 0.0)));
}
#[test]
fn octant_and_fractional_arcs() {
// A full circle comes back to where it started.
let g = one("10,(2,0),0").glyph(1, false).unwrap();
assert!(close(g.advance, Vec2::ZERO));
let maxx = g.strokes.iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max);
assert!((maxx - 0.0).abs() < 1e-9 && g.strokes.iter().flatten().any(|p| (p.x + 4.0).abs() < 1e-6), "circle left of the pen");
// Clockwise from octant 4 (180°), three octants: ends at 45°.
assert!(close(adv("10,(1,-043),0"), dir(45.0) - dir(180.0)));
// Counter-clockwise quarter from 0°.
assert!(close(adv("10,(3,002),0"), (dir(90.0) - dir(0.0)) * 3.0));
// The documented fractional example: 55° to 95°, radius 3 (start octant 1, two octants).
let a0 = 45.0 + 56.0 / 256.0 * 45.0;
let a1 = 90.0 + 28.0 / 256.0 * 45.0;
assert!(close(adv("11,(56,28,0,3,012),0"), (dir(a1) - dir(a0)) * 3.0));
// High radius byte: 256 + 4.
assert!(close(adv("11,(0,0,1,4,001),0"), (dir(45.0) - dir(0.0)) * 260.0));
}
#[test]
fn bulge_arcs() {
// A counter-clockwise semicircle up the chord bulges to the right... of the chord's left
// normal: from (0,0) to (0,4) about (0,2), through (2,2).
let g = one("12,(0,4,127),0").glyph(1, false).unwrap();
assert!(close(g.advance, Vec2::new(0.0, 4.0)));
assert!(g.strokes.iter().flatten().any(|p| close(*p, Vec2::new(2.0, 2.0))), "{:?}", g.strokes);
// Bulge 0 is a straight line.
assert_eq!(one("12,(3,0,0),0").glyph(1, false).unwrap().strokes, vec![vec![Vec2::ZERO, Vec2::new(3.0, 0.0)]]);
// Polyarc: two arcs, then the (0,0) end without a bulge.
let g = one("13,(4,0,127),(0,4,-127),(0,0),010,0").glyph(1, false).unwrap();
assert!(close(g.advance, Vec2::new(5.0, 4.0)));
assert!(g.strokes.iter().flatten().any(|p| (p.y + 2.0).abs() < 1e-6), "first arc dips below");
}
#[test]
fn vertical_only_commands_and_text_font_record() {
let f = font(TEXT_FONT);
assert_eq!((f.kind(), f.name(), f.above(), f.below(), f.is_dual(), f.is_font()), (ShxKind::Shapes, "cadcraft test", 6.0, 2.0, true, true));
let h = f.glyph(67, false).unwrap();
assert_eq!(h.strokes, vec![vec![Vec2::ZERO, Vec2::new(0.0, 6.0)]]);
let v = f.glyph(67, true).unwrap();
assert_eq!(v.strokes, vec![vec![Vec2::new(0.0, -8.0), Vec2::new(0.0, -2.0)]]);
assert!(close(v.advance, Vec2::new(4.0, -8.0)));
// A font that isn't dual runs code 14's command in horizontal text too.
let g = one("2,14,8,(0,-8),0").glyph(1, false).unwrap();
assert!(close(g.advance, Vec2::new(0.0, -8.0)));
assert_eq!(f.shape_number("ucb"), Some(66));
assert_eq!(f.shape_name(65).as_deref(), Some("UCA"));
assert_eq!(f.shape_numbers().first(), Some(&32));
}
#[test]
fn subshapes_shapes_unicode_and_extended_big_font() {
let s = font("*1,4,BAR\n1,030,0\n*2,6,TWO\n7,1,7,1,0\n");
assert!(close(s.glyph(2, false).unwrap().advance, Vec2::new(6.0, 0.0)));
let u = font(UNI_FONT);
assert_eq!((u.kind(), u.name(), u.above(), u.encoding()), (ShxKind::Unifont, "cadcraft uni", 6.0, 0));
let ring = u.glyph(0xC5, false).unwrap();
assert_eq!(ring.strokes.len(), 2, "the A and the ring's bar");
assert!(close(ring.advance, Vec2::new(8.0, 0.0)));
let e = font(EXT_BIG_FONT);
assert_eq!((e.kind(), e.above()), (ShxKind::BigFont, 16.0));
let g = e.glyph(0x82A2, false).unwrap();
assert_eq!(g.strokes.len(), 2);
let xs: Vec<(f64, f64)> = g.strokes.iter().map(|s| s.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)))).collect();
assert_eq!(xs, vec![(0.0, 8.0), (8.0, 16.0)], "each primitive squeezed into half the cell");
let ys = g.strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert_eq!(ys, (0.0, 16.0));
assert!(close(g.advance, Vec2::new(16.0, 0.0)));
}
#[test]
fn big_font_header_and_escape_ranges() {
let b = font(BIG_FONT);
assert_eq!(b.kind(), ShxKind::BigFont);
assert_eq!(b.escape_ranges(), &[(0x81, 0x9F)]);
assert!(b.is_lead_byte(0x82) && !b.is_lead_byte(0x41) && !b.is_lead_byte(0xA0));
assert_eq!(b.above(), 8.0);
assert!(b.has_shape(0x82A0));
}
#[test]
fn cyclic_and_deep_subshapes_are_bounded() {
let f = font("*1,3,SELF\n7,1,0\n*2,3,A\n7,3,0\n*3,3,B\n7,2,0\n*4,3,MISSING\n7,9,0\n");
for n in 1..=4 {
assert!(f.glyph(n, false).is_none(), "shape {n}");
}
// A chain deeper than the bound fails; a shallow one draws.
let mut src = String::new();
for n in 1..=40u16 {
if n == 40 {
src.push_str(&format!("*{n},2,L\n010,0\n"));
} else {
src.push_str(&format!("*{n},3,L\n7,{},0\n", n + 1));
}
}
let f = font(&src);
assert!(f.glyph(1, false).is_none() && f.glyph(30, false).is_some());
// Doubling with subshapes: 2^n steps are cut off by the step bound.
let mut src = String::from("*1,2,L\n010,0\n");
for n in 2..=10u16 {
src.push_str(&format!("*{n},9,D\n7,{0},7,{0},7,{0},7,{0},0\n", n - 1));
}
let f = font(&src);
assert!(f.glyph(10, false).is_none() && f.glyph(3, false).is_some());
// Pushes without pops, and runaway scales.
let pushes = format!("{}0", "5,".repeat(100));
assert!(one(&pushes).glyph(1, false).is_none());
let scales = format!("{}010,0", "4,255,".repeat(10));
assert!(one(&scales).glyph(1, false).is_none());
// A zero radius or a zero factor draws nothing and doesn't fail.
assert!(one("10,(0,001),4,0,010,0").glyph(1, false).is_some());
}
/// Parse every prefix and many mutations of `bytes`; draw every shape of whatever parses.
fn hammer(bytes: &[u8]) {
let draw = |b: &[u8]| {
if let Ok(f) = ShxFont::parse(b) {
for n in f.shape_numbers() {
let _ = f.glyph(n, false);
let _ = f.glyph(n, true);
}
let _ = f.shape_number("X");
}
};
for n in 0..=bytes.len() {
draw(&bytes[..n]);
}
let mut seed: u64 = 0x5EED_1234;
for _ in 0..2000 {
let mut b = bytes.to_vec();
for _ in 0..3 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let i = (seed >> 33) as usize % b.len().max(1);
if let Some(x) = b.get_mut(i) {
*x = (seed >> 24) as u8;
}
}
draw(&b);
}
}
#[test]
fn hostile_files_never_panic() {
for src in [TEXT_FONT, UNI_FONT, BIG_FONT, EXT_BIG_FONT] {
hammer(&compile(src).unwrap());
}
// Counts far beyond the data.
let mut shapes = parse::SHAPES_10.to_vec();
shapes.extend([1, 0, 0xFF, 0xFF, 0xFF, 0xFF]);
assert_eq!(ShxFont::parse(&shapes).err(), Some(ShxError::Truncated));
let mut big = parse::BIGFONT.to_vec();
big.extend([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::TooLarge));
let mut big = parse::BIGFONT.to_vec();
big.extend([0xFF, 0xFF, 0xFF, 0xFF, 0, 0]);
assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::Truncated));
// A big font index pointing anywhere.
let mut big = parse::BIGFONT.to_vec();
big.extend([1, 0, 1, 0, 0, 0, 0x41, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F]);
assert_eq!(ShxFont::parse(&big).err(), Some(ShxError::Empty));
let mut uni = parse::UNIFONT.to_vec();
uni.extend([0xFF, 0xFF, 0xFF, 0xFF, 2, 0, b'x', 0]);
assert_eq!(ShxFont::parse(&uni).err(), Some(ShxError::Empty));
assert_eq!(ShxFont::parse(b"not a font").err(), Some(ShxError::NotShx));
assert_eq!(ShxFont::parse(&[]).err(), Some(ShxError::NotShx));
// Hostile shape source.
for src in ["*1,2,X\n7\n", "*1,2,X\n9,(1,1)\n", "*99999,1,X\n0\n", "*1,1,X\n300,0\n", "010,0\n", "*1,1,X\n1,0zz,0\n", "*1,1,X\n0\n*1,1,Y\n0\n"] {
assert!(compile(src).is_err(), "{src:?}");
}
assert!(compile("").is_err());
}
/// Register a compiled test font under `name` (each test its own names: the table is shared).
fn reg(name: &str, src: &str) {
register(name, &compile(src).unwrap()).unwrap();
}
#[test]
fn text_metrics_follow_the_font() {
reg("cadcraft-test-metrics.shx", TEXT_FONT);
let f = TextFont::resolve_style("CADCRAFT-TEST-METRICS", "", false);
assert!(f.is_shx() && !f.is_outline());
let h = 3.0;
// Advances are the pen's end positions: A 6, B 6, at h / above = 0.5.
assert!((crate::text_width(&f, "AB", h, 1.0) - 6.0).abs() < 1e-9);
assert!((crate::text_width(&f, "AB", h, 0.5) - 3.0).abs() < 1e-9);
let sh = crate::shape_line(&f, "AB", h, 1.0, 0.0);
assert!((sh.width - 6.0).abs() < 1e-9);
let ys = sh.strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert_eq!(ys, (0.0, 3.0), "cap height = text height");
// %%d %%p %%c come from shapes 256-258; \U+0041 is A.
assert!((crate::text_width(&f, "%%d", h, 1.0) - 1.5).abs() < 1e-9);
assert!((crate::text_width(&f, "%%p%%c", h, 1.0) - 4.0).abs() < 1e-9);
assert!((crate::text_width(&f, "\\U+0041", h, 1.0) - 3.0).abs() < 1e-9);
assert_eq!(crate::shape_line(&f, "\\U+0041", h, 1.0, 0.0), crate::shape_line(&f, "A", h, 1.0, 0.0));
// A character the font lacks comes from the stroke font, with its advance.
let z = crate::text_width(&f, "Z", h, 1.0);
assert!((z - crate::stroke_advance('Z', h, 1.0)).abs() < 1e-9);
assert!(!crate::shape_line(&f, "Z", h, 1.0, 0.0).strokes.is_empty());
// Widths and shapes agree, with obliquing, underline and mixed fallbacks.
for s in ["AB AZ", "%%uAB%%u", "A\u{3042}B"] {
let sh = crate::shape_line(&f, s, h, 0.8, 0.2);
assert!((sh.width - crate::text_width(&f, s, h, 0.8)).abs() < 1e-9, "{s}");
}
// Placement: centred text sits on the alignment point by the font's own width.
let p = TextParams { h: crate::Align::Center, align_pt: Some(Vec2::new(10.0, 0.0)), ..TextParams::new(Vec2::ZERO, h) };
let (_, bb) = crate::place(&f, "AB", &p);
assert!((bb.center().x - 10.0).abs() < 1e-9 && (bb.width() - 6.0).abs() < 1e-9);
// MTEXT with the style's SHX font, and its \U+ escape.
let mp = crate::MTextParams { font: f.clone(), ..crate::MTextParams::new(Vec2::ZERO, h) };
let m = crate::layout_mtext_with("\\U+0041B", &mp);
assert_eq!(m.lines, vec!["AB".to_string()]);
assert_eq!(crate::space_width(&f, h, 1.0), 3.0);
}
#[test]
fn unifont_text_and_specials() {
reg("cadcraft-test-uni.shx", UNI_FONT);
let f = TextFont::resolve("cadcraft-test-uni.shx");
assert!((crate::text_width(&f, "A\u{C5}", 6.0, 1.0) - 14.0).abs() < 1e-9);
// %%c is the diameter sign, at U+2205 in this font.
assert!((crate::text_width(&f, "%%c", 6.0, 1.0) - 2.0).abs() < 1e-9);
}
#[test]
fn big_font_draws_double_byte_characters() {
reg("cadcraft-test-main.shx", TEXT_FONT);
reg("cadcraft-test-big.shx", BIG_FONT);
let f = TextFont::resolve_style("cadcraft-test-main.shx", "cadcraft-test-big.shx", false);
let TextFont::Shx(t) = &f else { panic!("SHX") };
assert_eq!(t.big_font_encoding(), "Shift_JIS");
let h = 2.5;
// あ (Shift-JIS 82A0) fills a square cell of the text height.
let w = crate::text_width(&f, "\u{3042}", h, 1.0);
assert!((w - h).abs() < 1e-9, "{w}");
let sh = crate::shape_line(&f, "A\u{3042}", h, 1.0, 0.0);
let big: Vec<Vec2> = sh.strokes.iter().flatten().copied().filter(|p| p.x > 2.5 - 1e-9).collect();
let ys = big.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert_eq!(ys, (0.0, h));
// Only the big font found: Latin letters from the stroke font, the big font's own.
let f = TextFont::resolve_style("cadcraft-missing-main-font.shx", "cadcraft-test-big.shx", false);
assert!(f.is_shx());
assert!((crate::text_width(&f, "\u{3042}", h, 1.0) - h).abs() < 1e-9);
assert!((crate::text_width(&f, "A", h, 1.0) - crate::stroke_advance('A', h, 1.0)).abs() < 1e-9);
// Neither found: the stroke font as before.
assert!(!TextFont::resolve_style("cadcraft-missing-a.shx", "cadcraft-missing-b.shx", false).is_shx());
}
#[test]
fn big_font_code_pages() {
for (name, ranges, want) in [
("gbcbig.shx", vec![(0xA1, 0xFE)], "GBK"),
("chineset.shx", vec![(0xA1, 0xFE)], "Big5"),
("whgtxt.shx", vec![(0xA1, 0xFE)], "EUC-KR"),
("extfont2.shx", vec![(0x81, 0x9F)], "Shift_JIS"),
("custom.shx", vec![(0x81, 0x9F), (0xE0, 0xFC)], "Shift_JIS"),
("custom.shx", vec![(0xA1, 0xFE)], "GBK"),
] {
let src = format!(
"*BIGFONT 1,{},{}\n*0,4,x\n8,0,0,0\n*0FFFF,2,x\n010,0\n",
ranges.len(),
ranges.iter().map(|(a, b)| format!("0{a:X},0{b:X}")).collect::<Vec<_>>().join(",")
);
let b = std::sync::Arc::new(font(&src));
assert_eq!(ShxText::new(None, Some(b), name, false).big_font_encoding(), want, "{name}");
}
}
#[test]
fn vertical_text_runs_down() {
reg("cadcraft-test-vertical.shx", TEXT_FONT);
let v = TextFont::resolve_style("cadcraft-test-vertical.shx", "", true);
let sh = crate::shape_line(&v, "CC", 1.0, 1.0, 0.0);
let miny = sh.strokes.iter().flatten().map(|p| p.y).fold(f64::MAX, f64::min);
// Two characters, each 8 units (8/6 of the height) down.
assert!((miny + 16.0 / 6.0).abs() < 1e-9, "{miny}");
assert!(sh.strokes.iter().flatten().all(|p| p.y <= 1e-9), "below the insertion point");
// A font without modes 2 stays horizontal.
reg("cadcraft-test-horizontal.shx", "*0,4,h\n6,2,0,0\n*67,3,C\n1,064,0\n");
let h = TextFont::resolve_style("cadcraft-test-horizontal.shx", "", true);
assert!(crate::shape_line(&h, "CC", 1.0, 1.0, 0.0).strokes.iter().flatten().all(|p| p.y >= -1e-9));
}
#[test]
fn names_and_lookup() {
assert_eq!(table::key("ROMANS.SHX").as_deref(), Some("romans.shx"));
assert_eq!(table::key("txt").as_deref(), Some("txt.shx"));
assert_eq!(table::key(r"C:\Fonts\Simplex.shx").as_deref(), Some("simplex.shx"));
assert_eq!(table::key("../x/gbcbig.shx").as_deref(), Some("gbcbig.shx"));
for n in ["", "arial.ttf", "CADCraft Stroke", "Times New Roman", ".shx", "a\u{0}.shx"] {
assert_eq!(table::key(n), None, "{n:?}");
}
assert!(register("not-a-font.ttf", &compile(TEXT_FONT).unwrap()).is_err());
assert!(register("broken.shx", b"AutoCAD-86 shapes 1.0\r\n\x1a").is_err());
// A path stored in a drawing only names the file.
reg("cadcraft-test-path.shx", TEXT_FONT);
assert!(find(r"\\server\share\CADCRAFT-TEST-PATH.SHX").is_some());
assert!(find("cadcraft-never-registered.shx").is_none());
}
#[test]
fn arcs_follow_the_circle() {
// Every point of an octant arc is on its circle.
let g = one("10,(5,034),0").glyph(1, false).unwrap();
let c = -dir(135.0) * 5.0;
for p in g.strokes.iter().flatten() {
assert!(((*p - c).len() - 5.0).abs() < 1e-9);
}
assert!(close(g.advance, (dir(135.0 + 180.0) - dir(135.0)) * 5.0));
let _ = PI;
}
+202
View File
@@ -0,0 +1,202 @@
//! Text set in SHX fonts: a text style's main font and big font, horizontal or vertical.
//!
//! Each character is drawn by the font that has it: a double-byte character of the big font's
//! code page (its lead byte in an escape range) from the big font, others from the main font
//! (character code, or Unicode in a Unicode font; `%%d` `%%p` `%%c` from shapes 256–258 or their
//! older or Unicode places). A font draws at text height / its `above` value, and its pen's end
//! position is the advance: there is no added letter gap. Characters neither font has come from
//! the built-in stroke font (and its fallback fonts), so nothing goes missing.
//!
//! Vertical text (a style flag, for fonts with modes 2) runs each shape's vertical-only
//! commands (code 14) and follows the pen downward.
use std::sync::Arc;
use cadcraft_geom::Vec2;
use encoding_rs::Encoding;
use super::{ShxFont, ShxKind};
use crate::{Shaped, bidi};
/// A text style's SHX fonts: the main font, the big font and the orientation. Either font may
/// be missing (not found): its characters then come from the stroke font.
#[derive(Clone, Debug)]
pub struct ShxText {
pub main: Option<Arc<ShxFont>>,
pub big: Option<Arc<ShxFont>>,
/// The style is vertical and the main font supports it.
pub vertical: bool,
/// The big font's code page (from its name and escape ranges).
big_encoding: &'static Encoding,
}
impl ShxText {
/// The fonts of a style: `big_name` is the big font's file name (it picks the code page).
pub fn new(main: Option<Arc<ShxFont>>, big: Option<Arc<ShxFont>>, big_name: &str, vertical: bool) -> ShxText {
let big_encoding = big.as_deref().map_or(encoding_rs::GBK, |b| big_font_encoding(big_name, b.escape_ranges()));
let vertical = vertical && main.as_ref().is_some_and(|m| m.is_dual());
ShxText { main, big, vertical, big_encoding }
}
/// The code page of the big font's double-byte codes.
pub fn big_font_encoding(&self) -> &'static str {
self.big_encoding.name()
}
}
/// The code page a big font's double-byte codes are in: known Japanese, Chinese and Korean
/// big font names, else its escape ranges (Shift-JIS lead bytes from 0x81; the others start at
/// 0xA1 or above, read as GBK).
fn big_font_encoding(name: &str, ranges: &[(u16, u16)]) -> &'static Encoding {
let stem = name.trim().rsplit(['/', '\\']).next().unwrap_or_default().to_ascii_lowercase();
let stem = stem.trim_end_matches(".shx").trim_start_matches('@');
if stem.starts_with("whg") || stem.starts_with("wht") || stem.contains("kor") || stem.contains("ksc") {
return encoding_rs::EUC_KR;
}
if stem.starts_with("chineset") || stem.contains("big5") || stem.starts_with("cht") {
return encoding_rs::BIG5;
}
if stem.starts_with("gb") || stem.starts_with("hz") || stem.contains("chs") {
return encoding_rs::GBK;
}
if stem.starts_with("bigfont") || stem.starts_with("extfont") || stem.contains("jis") || stem.contains("jpn") {
return encoding_rs::SHIFT_JIS;
}
if ranges.iter().any(|(lo, _)| (0x81..0xA1).contains(lo)) { encoding_rs::SHIFT_JIS } else { encoding_rs::GBK }
}
/// The double-byte code of `c` in `enc`, if it has one.
fn double_byte(c: char, enc: &'static Encoding) -> Option<u16> {
let mut buf = [0u8; 4];
let (bytes, _, errors) = enc.encode(c.encode_utf8(&mut buf));
match (errors, bytes.as_ref()) {
(false, [a, b]) => Some(u16::from_be_bytes([*a, *b])),
_ => None,
}
}
/// The shape numbers that may draw `c` in `font`, best first.
fn candidates(c: char, font: &ShxFont) -> [Option<u16>; 4] {
let u = u32::from(c);
let unicode = font.kind() == ShxKind::Unifont && font.encoding() == 0;
let code = |x: u32| u16::try_from(x).ok();
// The special characters of `%%d` `%%p` `%%c`: shapes 256–258, the Latin-1 or Unicode code,
// and the oldest fonts' 127–129.
let special = match c {
'°' => Some((256, 0xB0, 127)),
'±' => Some((257, 0xB1, 128)),
'⌀' | '∅' => Some((258, u, 129)),
_ => None,
};
if let Some((s, latin, old)) = special {
return if unicode {
[code(u), code(if c == '⌀' { 0x2205 } else { latin }), Some(s), Some(old)]
} else {
[Some(s), code(latin), Some(old), None]
};
}
if unicode || u < 0x80 {
return [code(u), None, None, None];
}
// A single-byte font: the Windows Latin-1 code of the character (not the old special
// codes 127–129).
let mut buf = [0u8; 4];
let (bytes, _, errors) = encoding_rs::WINDOWS_1252.encode(c.encode_utf8(&mut buf));
match (errors, bytes.as_ref()) {
(false, [b]) if !(127..=129).contains(b) => [Some(u16::from(*b)), None, None, None],
_ => [None, None, None, None],
}
}
/// The font and shape that draw `c`.
fn pick(t: &ShxText, c: char) -> Option<(&ShxFont, u16)> {
let from_main = || {
let m = t.main.as_deref()?;
candidates(c, m).into_iter().flatten().find(|n| m.has_shape(*n)).map(|n| (m, n))
};
let from_big = || {
let b = t.big.as_deref()?;
let code = double_byte(c, t.big_encoding)?;
let [lead, _] = code.to_be_bytes();
(b.is_lead_byte(lead) && b.has_shape(code)).then_some((b, code))
};
if u32::from(c) < 0x100 || matches!(c, '⌀' | '∅') { from_main().or_else(from_big) } else { from_big().or_else(from_main) }
}
fn finite_or(v: f64, d: f64) -> f64 {
if v.is_finite() { v } else { d }
}
/// Shape one line (baseline at y = 0, x from 0).
pub(crate) fn shape(t: &ShxText, s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped {
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
let shear = finite_or(oblique, 0.0).tan().clamp(-10.0, 10.0);
let chars = crate::decode_controls(s);
let logical: Vec<char> = chars.iter().map(|v| v.0).collect();
let mut out = Shaped::default();
let mut spans: Vec<(f64, f64, bool, bool)> = Vec::new();
let mut pen = Vec2::ZERO;
let mut max_x: f64 = 0.0;
let runs = if t.vertical { vec![(0..logical.len(), false)] } else { bidi::visual_runs(&logical, None) };
for (run, rtl) in runs {
let order: Box<dyn Iterator<Item = usize>> = if rtl { Box::new(run.rev()) } else { Box::new(run) };
for i in order {
let Some(&(c, under, over)) = chars.get(i) else { continue };
if bidi::is_format_control(c) {
continue;
}
let c = if rtl { bidi::mirror(c) } else { c };
let x0 = pen.x;
match pick(t, c).and_then(|(f, n)| Some((f, f.glyph(n, t.vertical)?))) {
Some((f, g)) => {
let k = h / f.units_per_height();
let place = |p: Vec2| Vec2::new(pen.x + p.x * k * wf + p.y * k * shear, pen.y + p.y * k);
for st in &g.strokes {
let pts: Vec<Vec2> = st.iter().map(|p| place(*p)).collect();
max_x = pts.iter().fold(max_x, |m, p| m.max(p.x));
out.strokes.push(pts);
}
let adv = Vec2::new(g.advance.x * k * wf, g.advance.y * k);
pen = if t.vertical { pen + adv } else { Vec2::new(pen.x + adv.x, pen.y) };
}
None => {
// The stroke font draws it, at the pen.
let mut fb = crate::shape_stroke(&c.to_string(), h, wf, oblique);
let at = pen;
fb.map(|p| p + at);
max_x = max_x.max(at.x + fb.width);
out.extend(fb);
if t.vertical {
pen.y -= h * 1.5;
} else {
pen.x += crate::stroke_advance(c, h, wf);
}
}
}
if !t.vertical {
spans.push((x0, pen.x, under, over));
}
}
}
out.strokes.extend(crate::decorations(&spans, h));
out.width = if t.vertical { max_x.max(0.0) } else { pen.x.max(0.0) };
out
}
/// The width of one line: the sum of the advances.
pub(crate) fn width(t: &ShxText, s: &str, height: f64, width_factor: f64) -> f64 {
if t.vertical || bidi::has_rtl(s) {
return shape(t, s, height, width_factor, 0.0).width;
}
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
crate::decode_controls(s)
.into_iter()
.filter(|(c, _, _)| !bidi::is_format_control(*c))
.map(|(c, _, _)| match pick(t, c).and_then(|(f, n)| Some((f, f.glyph(n, false)?))) {
Some((f, g)) => g.advance.x * h / f.units_per_height() * wf,
None => crate::stroke_advance(c, h, wf),
})
.sum::<f64>()
.max(0.0)
}
+76
View File
@@ -0,0 +1,76 @@
//! How SHX fonts are found: drawing folders, the search path, the font map, an app's font
//! provider and FONTALT. Process-wide settings, so one test in its own binary. The fonts are
//! compiled here from shape source written for the test; nothing is read from a CAD install.
use std::sync::Arc;
use cadcraft_fonts::shx::{self, FontProvider};
use cadcraft_fonts::{TextFont, ttf};
const FONT: &str = "*0,4,lookup test\n6,2,0,0\n*65,4,UCA\n1,064,0\n";
struct Provider;
impl FontProvider for Provider {
fn load(&self, file_name: &str) -> Option<Vec<u8>> {
(file_name == "cadcraft-provided.shx").then(|| shx::compile(FONT).ok()).flatten()
}
}
#[test]
fn fonts_are_found_where_the_drawing_and_the_app_keep_them() {
let bytes = shx::compile(FONT).unwrap();
let dir = std::env::temp_dir().join(format!("cadcraft-shx-lookup-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("cadcraft-beside.shx"), &bytes).unwrap();
// Beside the drawing: found once the drawing's folder is known; a later change of folders
// starts a new generation (viewers regenerate).
assert!(shx::find("cadcraft-beside.shx").is_none());
let g = shx::generation();
shx::add_drawing_dir(&dir);
assert!(shx::generation() != g);
assert_eq!(shx::drawing_dirs().first(), Some(&dir));
assert!(shx::find("CADCRAFT-BESIDE").is_some_and(|f| f.has_shape(65)));
assert!(TextFont::resolve("cadcraft-beside.shx").is_shx());
// A file whose name has another case than the drawing's (found on case-sensitive systems
// too, by looking through the folder).
std::fs::write(dir.join("CadCraft-Mixed.SHX"), &bytes).unwrap();
assert!(shx::find("cadcraft-mixed.shx").is_some());
// The search path, and the font map renaming a font to one that is there.
let other = dir.join("lib");
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("cadcraft-onpath.shx"), &bytes).unwrap();
assert!(shx::find("cadcraft-onpath.shx").is_none());
shx::set_search_path(&other.to_string_lossy());
assert!(shx::find("cadcraft-onpath.shx").is_some());
shx::set_font_map("cadcraft-old.shx;cadcraft-onpath.shx\nnonsense");
assert_eq!(shx::font_map(), "cadcraft-old.shx;cadcraft-onpath.shx");
assert!(shx::find("cadcraft-old").is_some());
shx::set_font_map("");
// An app's provider (the web build's file store).
assert!(shx::find("cadcraft-provided.shx").is_none());
shx::set_provider(Some(Arc::new(Provider)));
assert!(shx::find("cadcraft-provided.shx").is_some());
shx::set_provider(None);
assert!(shx::find("cadcraft-provided.shx").is_none());
// FONTALT replaces a missing SHX font (and may name a big font after a comma).
assert!(!TextFont::resolve("cadcraft-missing.shx").is_shx());
ttf::set_font_alt("cadcraft-beside.shx");
assert!(TextFont::resolve("cadcraft-missing.shx").is_shx());
assert!(!TextFont::resolve("").is_shx(), "the built-in font is not replaced");
ttf::set_font_alt("");
// Registered bytes (files opened alongside a drawing on the web) and clearing them.
shx::register("cadcraft-registered.shx", &bytes).unwrap();
assert!(shx::find("cadcraft-registered.shx").is_some());
shx::clear_registered();
assert!(shx::find("cadcraft-registered.shx").is_none());
shx::set_search_path("");
assert_eq!(shx::search_path(), "");
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -22,6 +22,7 @@ serde = { workspace = true }
miniz_oxide = "0.9"
craft-pdf = { workspace = true, features = ["write"] }
quick-xml = { workspace = true }
image = { workspace = true }
[dev-dependencies]
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
+17
View File
@@ -881,6 +881,23 @@ 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::*;
+2 -4
View File
@@ -23,10 +23,8 @@ 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) -> Vec2 {
let c = self.p(center).xy();
let sum = c + self.p(target).xy();
if sum.is_finite() { sum } else { c }
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 all_f(&self, code: i32) -> Vec<f64> {
self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect()
+19 -14
View File
@@ -243,20 +243,25 @@ 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(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,
"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,
}
}),
"WIPEOUT" => {
let o = t.p(10);
+9 -6
View File
@@ -224,8 +224,9 @@ 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);
let center = t.view_center(10, 12, twist);
if !(center.x.is_finite() && center.y.is_finite()) {
continue;
}
@@ -235,7 +236,6 @@ 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,8 +286,9 @@ 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).
d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active()));
// 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));
}
fn index_table_handles(recs: &[(String, Vec<Tag>)], rx: &mut Rx) {
@@ -324,8 +325,10 @@ 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));
let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17, twist));
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)
@@ -339,7 +342,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 };
let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect, twist };
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)) {
+8 -1
View File
@@ -684,8 +684,15 @@ 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));
w.p2(12, v.view_center);
// 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.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());
+20 -7
View File
@@ -72,13 +72,24 @@ 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) {
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,
// 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, 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(),
@@ -93,7 +104,8 @@ 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)));
w.p2(12, finite(t.center, Vec2::ZERO));
let twist = cadcraft_geom::norm_angle(t.twist);
w.p2(12, dxf_ext::model_center_to_dcs(finite(t.center, Vec2::ZERO), twist));
// 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());
@@ -104,6 +116,7 @@ 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");
}
+3 -2
View File
@@ -136,14 +136,15 @@ 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);
w.p2(10, v.center);
let twist = cadcraft_geom::norm_angle(v.twist);
w.p2(10, dxf_ext::model_center_to_dcs(v.center, twist));
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, if v.twist.is_finite() { v.twist.to_degrees() } else { 0.0 });
w.f(50, twist.to_degrees());
w.i(71, 0);
w.i(281, 0);
w.i(72, 0);
+2
View File
@@ -14,6 +14,8 @@ mod dxf_write;
mod dxf_xref;
pub mod pdf;
pub mod plotter;
/// Raster image files (PNG, JPEG, BMP, TIFF) decoded to pixels.
pub mod raster;
/// Open → save → reopen comparison and the real-file corpus harness (gaps FF1, FF5).
pub mod roundtrip;
pub mod svg;
+2
View File
@@ -873,6 +873,8 @@ fn marks(s: &mut Surface<'_>, p: &SheetPage, groups: &Groups, fonts: &mut fonts:
}
}
Kind::Point => point(s, &mut batch, &p.map, raw, m),
// Raster images are not written to PDF yet: their frame is.
Kind::Image { .. } => {}
}
}
flush(s, &mut batch);
+79
View File
@@ -0,0 +1,79 @@
//! Raster image files decoded to pixels (IMAGEATTACH): PNG, JPEG, BMP and TIFF, the format taken
//! from the content. Files are hostile input: the size is read from the header and checked
//! before any pixel is decoded.
use std::io::Cursor;
use cadcraft_doc::Raster;
use crate::{IoError, Result};
/// Largest image file read (bytes).
pub const MAX_IMAGE_BYTES: usize = 256 << 20;
/// Most pixels decoded per image (four bytes each in memory).
pub const MAX_IMAGE_PIXELS: u64 = 128_000_000;
fn bad(e: impl std::fmt::Display) -> IoError {
IoError::Format(format!("image: {e}"))
}
/// A reader over an image file, once it is small enough and of a format we read.
fn reader(bytes: &[u8]) -> Result<image::ImageReader<Cursor<&[u8]>>> {
if bytes.len() > MAX_IMAGE_BYTES {
return Err(bad(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20)));
}
// The format comes from the file's first bytes, not from its name.
let file = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(bad)?;
if file.format().is_none() {
return Err(bad("not a PNG, JPEG, BMP or TIFF file"));
}
Ok(file)
}
/// The width and height in pixels of the image in `bytes`, read from its header.
pub fn probe(bytes: &[u8]) -> Result<(u32, u32)> {
let (width, height) = reader(bytes)?.into_dimensions().map_err(bad)?;
if width == 0 || height == 0 {
return Err(bad("the image is empty"));
}
if u64::from(width).saturating_mul(u64::from(height)) > MAX_IMAGE_PIXELS {
return Err(bad(format!("{width} x {height} pixels is more than {} megapixels", MAX_IMAGE_PIXELS / 1_000_000)));
}
Ok((width, height))
}
/// Decode the image in `bytes`.
pub fn decode(bytes: &[u8]) -> Result<Raster> {
// The size is checked first, from the header alone.
let (width, height) = probe(bytes)?;
let mut file = reader(bytes)?;
file.limits(decode_limits(width, height));
let pixels = file.decode().map_err(bad)?.into_rgba8();
Raster::new(pixels.width(), pixels.height(), pixels.into_raw()).ok_or_else(|| bad("the image is empty"))
}
/// What the decoder may allocate for an image of this size: the pixels themselves (up to 16
/// bytes each before they become 8-bit RGBA) and its own working buffers.
fn decode_limits(width: u32, height: u32) -> image::Limits {
let pixels = u64::from(width).saturating_mul(u64::from(height));
let mut limits = image::Limits::default();
limits.max_image_width = Some(width);
limits.max_image_height = Some(height);
limits.max_alloc = Some(pixels.saturating_mul(16).saturating_add(64 << 20));
limits
}
/// `raster` as a PNG file.
pub fn encode_png(raster: &Raster) -> Result<Vec<u8>> {
let mut out = Cursor::new(Vec::new());
image::write_buffer_with_format(
&mut out,
raster.rgba(),
raster.width(),
raster.height(),
image::ExtendedColorType::Rgba8,
image::ImageFormat::Png,
)
.map_err(bad)?;
Ok(out.into_inner())
}
+2
View File
@@ -96,6 +96,8 @@ pub fn export_list(list: &cadcraft_render::DisplayList, window: Option<Bounds2>)
}
s.push_str(r#""/>"#);
}
// Raster images are not written to SVG yet: their frame is.
Kind::Image { .. } => {}
Kind::Point => {
if let Some(q) = pts.first() {
let _ = write!(s, r#"<circle cx="{:.dp$}" cy="{:.dp$}" r="{:.dp$}" fill="{c}"{op}/>"#, tx(q.x), ty(q.y), stroke);
+4
View File
@@ -540,6 +540,7 @@ fn pdf_layout_one_to_one_with_viewport() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
}),
)
.unwrap();
@@ -646,6 +647,7 @@ 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();
@@ -688,6 +690,7 @@ 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);
@@ -830,6 +833,7 @@ fn viewport_layer_colors_roundtrip() {
layer_colors: colors,
clip: None,
on: true,
twist: 0.0,
})
};
let colors = vec![("Walls".to_string(), Color::Index(5)), ("A B".to_string(), Color::True(cadcraft_color::Rgb(255, 128, 0)))];
+1
View File
@@ -117,6 +117,7 @@ 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,6 +27,7 @@ fn viewport(id: u32) -> EntityKind {
layer_colors: vec![],
clip: None,
on: true,
twist: 0.0,
})
}
@@ -182,3 +183,67 @@ fn the_added_paper_viewport_follows_the_plot_scale() {
assert!((sheet_width(49.6, true) - 420.0).abs() < 1e-9, "scaled to fit: the scale means nothing");
assert!((sheet_width(f64::NAN, false) - 420.0).abs() < 1e-9, "an unusable scale is 1");
}
/// A layout viewport's view twist (group 51, #1316) is read and written, so DXF and DWG keep it;
/// a hostile angle reads as no twist and never panics.
#[test]
fn viewport_twist_survives_dxf_and_dwg() {
let mut d = Drawing::new_metric();
let name = d.layouts[0].name.clone();
let space = Space::Paper(name.clone());
let twist = 30f64.to_radians();
let EntityKind::Viewport(mut vp) = viewport(2) else { unreachable!() };
vp.twist = twist;
let h = d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap();
let twist_of = |d: &Drawing| {
let l = d.layouts.iter().find(|l| l.name == name).unwrap();
l.entities
.iter()
.find_map(|e| {
if let EntityKind::Viewport(v) = &e.kind
&& v.id == 2
{
Some(v.twist)
} else {
None
}
})
.unwrap()
};
let dxf = cadcraft_io::write(&d, "x.dxf").unwrap();
let text = String::from_utf8_lossy(&dxf).to_string();
let rec = records(&text, "VIEWPORT").into_iter().find(|r| group(r, "5") == Some(h.hex().as_str())).unwrap();
assert_eq!(group(&rec, "51").and_then(|v| v.parse::<f64>().ok()).map(f64::round), Some(30.0), "{rec:?}");
assert!((twist_of(&cadcraft_io::read(&dxf, "x.dxf").unwrap()) - twist).abs() < 1e-9);
assert!((twist_of(&through_dwg(&d)) - twist).abs() < 1e-9);
// An untwisted viewport writes no group 51; a hostile twist (from JSON or MCP) writes a finite
// angle within one turn, or none.
for (value, want) in [(0.0, None), (f64::NAN, None), (f64::INFINITY, None), (1e300, Some(()))] {
let mut d = Drawing::new_metric();
let EntityKind::Viewport(mut vp) = viewport(2) else { unreachable!() };
vp.twist = value;
let h = d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap();
let text = String::from_utf8_lossy(&cadcraft_io::write(&d, "x.dxf").unwrap()).to_string();
let rec = records(&text, "VIEWPORT").into_iter().find(|r| group(r, "5") == Some(h.hex().as_str())).unwrap();
let written = group(&rec, "51").map(|v| v.parse::<f64>().unwrap());
assert_eq!(written.map(|_| ()), want, "{value}: {rec:?}");
assert!(written.is_none_or(|a| a.is_finite() && (0.0..360.0).contains(&a)), "{value}: {written:?}");
}
// Hostile angles: not a number reads as 0, a huge one is brought into one turn.
for (value, want) in
[("nan", Some(0.0)), ("1e400", Some(0.0)), ("-1e-20", Some(0.0)), ("-330", Some(30f64.to_radians())), ("7200000000000000000030", None)]
{
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
let at = lines.windows(2).position(|w| w[0].trim() == "51" && Some(w[1].trim()) == group(&rec, "51")).unwrap();
lines[at + 1] = value.to_string();
let bad = lines.join("\n");
let back = twist_of(&cadcraft_io::read(bad.as_bytes(), "x.dxf").unwrap());
assert!(back.is_finite() && (0.0..std::f64::consts::TAU).contains(&back), "{value}: {back}");
if let Some(w) = want {
assert!((back - w).abs() < 1e-9, "{value}: {back}");
}
}
}
+1
View File
@@ -18,6 +18,7 @@ fn viewport(id: u32, frozen_layers: Vec<String>, layer_colors: Vec<(String, Colo
layer_colors,
clip: None,
on: true,
twist: 0.0,
})
}
+1
View File
@@ -149,6 +149,7 @@ fn viewport_contents_keep_their_layers() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
d.add(&paper, Common { layer: "Viewports".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap();
add_line(&mut d, &paper, "Title", (10.0, 10.0), (50.0, 10.0));
+2 -2
View File
@@ -145,8 +145,8 @@ pub(super) fn vports() -> Case {
drawing.vports.push(VportConfig {
name: "Split".into(),
tiles: vec![
VportTile { min: Vec2::ZERO, max: Vec2::new(0.5, 1.0), center: Vec2::new(2.0, 3.0), height: 20.0, aspect: 0.75 },
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 },
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 },
],
});
Case { name: "drawing/tiled-viewports".into(), drawing }
+4 -5
View File
@@ -29,10 +29,9 @@ 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.center, a.height, a.width, &a.layer_state, &a.layout),
(&b.name, b.center, b.height, b.width, &b.layer_state, &b.layout)
);
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!((a.twist - b.twist).abs() < 1e-12, "{}: {} vs {}", a.name, a.twist, b.twist);
}
}
@@ -75,7 +74,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 }
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 }
}
#[test]
+1
View File
@@ -335,6 +335,7 @@ fn text_seen_through_a_viewport_and_on_layers() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
text(&mut d, &paper, "Real", (20.0, 20.0), "B", (0.0, 1.0, 0.0));
+172
View File
@@ -0,0 +1,172 @@
//! Raster image files decoded to pixels (IMAGEATTACH): round trip through PNG, and hostile files
//! (truncated, random, absurd sizes) refused without a panic.
use cadcraft_doc::Raster;
use cadcraft_io::raster::{MAX_IMAGE_PIXELS, decode, encode_png, probe};
/// A 3 x 2 image with a different colour per pixel, one of them half transparent.
fn sample() -> Raster {
let px: [[u8; 4]; 6] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 0, 255], [0, 255, 255, 128], [10, 20, 30, 255]];
Raster::new(3, 2, px.concat()).unwrap()
}
#[test]
fn png_round_trips_pixel_for_pixel() {
let r = sample();
let png = encode_png(&r).unwrap();
assert_eq!(probe(&png).unwrap(), (3, 2));
let back = decode(&png).unwrap();
assert_eq!(back, r);
assert!(!back.opaque());
assert_eq!(back.pixel(1, 1), Some([0, 255, 255, 128]));
assert_eq!(back.pixel(3, 0), None);
}
#[test]
fn hostile_image_does_not_panic() {
let png = encode_png(&sample()).unwrap();
// Empty, random, not an image, and every truncation of a real file.
for bytes in [&b""[..], &[0u8; 64], b"0\nSECTION\n2\nHEADER\n", &[0xff; 4096]] {
assert!(decode(bytes).is_err());
assert!(probe(bytes).is_err());
}
for n in 0..png.len() {
let _ = probe(&png[..n]);
let _ = decode(&png[..n]);
}
assert!(decode(&png[..png.len() / 2]).is_err());
// Every byte flipped in turn: an error or an image, never a panic.
for i in 0..png.len() {
let mut b = png.clone();
b[i] ^= 0xff;
let _ = decode(&b);
}
}
#[test]
fn an_absurd_declared_size_is_refused_before_decoding() {
// A PNG declaring 60000 x 60000 pixels (3.6 gigapixels) with no pixel data.
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
let mut ihdr = b"IHDR".to_vec();
ihdr.extend(60000u32.to_be_bytes());
ihdr.extend(60000u32.to_be_bytes());
ihdr.extend([8, 6, 0, 0, 0]);
for chunk in [&ihdr[..], b"IDAT", b"IEND"] {
png.extend(u32::try_from(chunk.len() - 4).unwrap().to_be_bytes());
png.extend(chunk);
png.extend(crc32(chunk).to_be_bytes());
}
assert!(u64::from(60000u32) * 60000 > MAX_IMAGE_PIXELS);
let e = decode(&png).unwrap_err().to_string();
assert!(e.contains("megapixels"), "{e}");
}
fn crc32(data: &[u8]) -> u32 {
let mut c = !0u32;
for b in data {
c ^= u32::from(*b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
#[test]
fn raster_new_checks_the_buffer() {
assert!(Raster::new(0, 4, Vec::new()).is_none());
assert!(Raster::new(2, 2, vec![0; 15]).is_none());
assert!(Raster::new(u32::MAX, u32::MAX, vec![0; 16]).is_none());
assert!(Raster::new(2, 2, vec![255; 16]).unwrap().opaque());
}
/// `r` written in `format` by the image library.
fn encoded(r: &Raster, format: image::ImageFormat, color: image::ExtendedColorType, bytes: &[u8]) -> Vec<u8> {
let mut out = std::io::Cursor::new(Vec::new());
image::write_buffer_with_format(&mut out, bytes, r.width(), r.height(), color, format).unwrap();
out.into_inner()
}
#[test]
fn bmp_tiff_and_jpeg_files_are_read() {
// Opaque colours in 8 x 8 blocks (JPEG works in blocks of that size).
let (w, h) = (16u32, 8u32);
let rgb: Vec<u8> = (0..h).flat_map(|_| (0..w).flat_map(|x| if x < 8 { [200u8, 30, 30] } else { [30, 30, 200] })).collect();
let rgba: Vec<u8> = rgb.chunks(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect();
let r = Raster::new(w, h, rgba).unwrap();
for format in [image::ImageFormat::Bmp, image::ImageFormat::Tiff] {
let file = encoded(&r, format, image::ExtendedColorType::Rgb8, &rgb);
assert_eq!(probe(&file).unwrap(), (w, h), "{format:?}");
let back = decode(&file).unwrap();
assert_eq!(back, r, "{format:?} keeps every pixel");
assert!(back.opaque());
}
// JPEG loses a little: the colours are close, the size exact.
let file = encoded(&r, image::ImageFormat::Jpeg, image::ExtendedColorType::Rgb8, &rgb);
let back = decode(&file).unwrap();
assert_eq!((back.width(), back.height()), (w, h));
for (x, want) in [(2, [200u8, 30, 30]), (13, [30, 30, 200])] {
let got = back.pixel(x, 4).unwrap();
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 12) && got[3] == 255, "{got:?} at {x}");
}
// Grey and grey-with-alpha files become RGBA.
let grey = Raster::new(2, 1, vec![0, 0, 0, 255, 255, 255, 255, 255]).unwrap();
assert_eq!(decode(&encoded(&grey, image::ImageFormat::Png, image::ExtendedColorType::L8, &[0, 255])).unwrap(), grey);
let grey_alpha = Raster::new(2, 1, vec![9, 9, 9, 0, 255, 255, 255, 128]).unwrap();
assert_eq!(decode(&encoded(&grey_alpha, image::ImageFormat::Png, image::ExtendedColorType::La8, &[9, 0, 255, 128])).unwrap(), grey_alpha);
}
#[test]
fn damaged_files_of_every_format_do_not_panic() {
let (w, h) = (16u32, 8u32);
let rgb = vec![120u8; (w * h * 3) as usize];
let r = Raster::new(w, h, vec![255; (w * h * 4) as usize]).unwrap();
for format in [image::ImageFormat::Bmp, image::ImageFormat::Tiff, image::ImageFormat::Jpeg] {
let file = encoded(&r, format, image::ExtendedColorType::Rgb8, &rgb);
for n in 0..file.len() {
let _ = probe(&file[..n]);
let _ = decode(&file[..n]);
}
for i in 0..file.len() {
let mut b = file.clone();
b[i] ^= 0xff;
let _ = decode(&b);
b[i] = 0;
let _ = decode(&b);
}
}
}
#[test]
fn the_pixels_never_reach_the_drawing_file() {
use cadcraft_doc::{Common, Drawing, EntityKind, Image, Space};
use cadcraft_geom::{Vec2, Vec3};
let mut d = Drawing::new_metric();
let i = Image {
insert: Vec3::new(3.0, 4.0, 0.0),
u: Vec3::new(0.5, 0.0, 0.0),
v: Vec3::new(0.0, 0.5, 0.0),
size: Vec2::new(3.0, 2.0),
path: "maps/site.png".into(),
name: "site".into(),
..Image::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Image(i.clone())).unwrap();
d.rasters.insert("maps/site.png", std::sync::Arc::new(sample()));
let text = cadcraft_io::write_dxf(&d);
assert!(text.contains("maps/site.png"));
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
assert!(back.rasters.is_empty(), "a drawing just read has no pixels");
let got: Vec<&Image> = back.model.iter().filter_map(|e| if let EntityKind::Image(i) = &e.kind { Some(i) } else { None }).collect();
// An unclipped image is written with the boundary of the whole image, switched off.
let i = Image { clip: vec![Vec2::new(-0.5, -0.5), Vec2::new(2.5, 1.5)], ..i };
assert_eq!(got, [&i]);
// Loaded pixels don't make two drawings differ.
let mut bare = d.clone();
bare.rasters.remove("maps/site.png");
assert!(bare.rasters.is_empty() && d.rasters.len() == 1);
assert_eq!(bare, d);
}
+222
View File
@@ -0,0 +1,222 @@
//! Raster image files CADCraft did not write itself (#956): files made by another program, and
//! files laid out by hand from the formats' public descriptions, so reading is not only checked
//! against the library that also does the writing.
//!
//! The four embedded files hold our own synthetic picture (16 x 8 pixels: the left half
//! (200, 30, 30), the right half (30, 30, 200)), converted from a hand-built PNG with the macOS
//! command `sips` (`-s format jpeg|tiff|bmp|png`, colour profile removed). They contain nothing
//! but those pixels and the encoder's headers.
use cadcraft_doc::Raster;
use cadcraft_io::raster::{decode, probe};
/// JPEG, baseline, quality 90, with JFIF and Exif headers.
const SIPS_JPEG: &str = "ffd8ffe000104a46494600010100004800480000ffe1004c4578696600004d4d002a00000008000187690004000000010000001a000000000003a001 \
000300000001ffff0000a00200040000000100000010a0030004000000010000000800000000ffed003850686f746f73686f7020332e30003842494d \
04040000000000003842494d0425000000000010d41d8cd98f00b204e9800998ecf8427effc00011080008001003012200021101031101ffc4001f00 \
00010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d0102030004110512213141 \
0613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758 \
595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8 \
c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f01000301010101010101010100000000000001020304050607 \
08090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272 \
d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a8283848586 \
8788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3 \
f4f5f6f7f8f9faffdb004300020202020202030202030503030305060505050506080606060606080a0808080808080a0a0a0a0a0a0a0a0c0c0c0c0c \
0c0e0e0e0e0e0f0f0f0f0f0f0f0f0f0fffdb00430102030304040407040407100b090b10101010101010101010101010101010101010101010101010 \
10101010101010101010101010101010101010101010101010ffdd00040001ffda000c03010002110311003f00f90ebe7fafa02be7fafdc7e8f3ff00 \
331ffb87ff00b79fd1de3aff00cc07fdbfff00b69fffd9";
/// TIFF, big-endian, LZW compressed.
const SIPS_TIFF_LZW: &str = "4d4d002a000000680002a00200040000000100000010a0030004000000010000000800000000803203c1e004160d0784424ac00554261d0f814121f1 \
38342e1b148a44631148b46e271a8f43a3b2184c82490791c9e0d2695002532d964aa5f2a98c9e672780800000100100000300000001001000000101 \
0003000000010008000001020003000000030000012e010300030000000100050000010600030000000100020000010a000300000001000100000111 \
00040000000100000026011200030000000100010000011500030000000100030000011600030000000100080000011700040000000100000041011c \
00030000000100010000012800030000000100020000013d000300000001000200000153000300000003000001348769000400000001000000080000 \
0000000800080008000100010001";
/// TIFF, big-endian, not compressed.
const SIPS_TIFF_PLAIN: &str = "4d4d002a000001a60002a00200040000000100000010a0030004000000010000000800000000c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \
1e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e \
1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e \
1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec8 \
1e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \
1e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e \
1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e \
1ec8000f0100000300000001001000000101000300000001000800000102000300000003000002600103000300000001000100000106000300000001 \
00020000010a000300000001000100000111000400000001000000260112000300000001000100000115000300000001000300000116000300000001 \
00080000011700040000000100000180011c000300000001000100000128000300000001000200000153000300000003000002668769000400000001 \
0000000800000000000800080008000100010001";
/// BMP, 24 bits, rows stored top-down (negative height).
const SIPS_BMP: &str = "424db601000000000000360000002800000010000000f8ffffff010018000000000080010000000000000000000000000000000000001e1ec81e1ec8 \
1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \
1e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1e \
c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e \
c81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec8 \
1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8 \
1e1ec81e1ec8c81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1e1e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec81e1ec8c81e1ec81e1e \
c81e1ec81e1ec81e1ec81e1ec81e1ec81e1e";
/// PNG as that program writes it (its own chunks and compression).
const SIPS_PNG: &str = "89504e470d0a1a0a0000000d49484452000000100000000808020000007f14e8c00000000467414d410000b18f0bfc6105000000206348524d00007a \
26000080840000fa00000080e8000075300000ea6000003a98000017709cba513c00000038655849664d4d002a000000080001876900040000000100 \
00001a000000000002a00200040000000100000010a0030004000000010000000800000000470d8f8b000001c869545874584d4c3a636f6d2e61646f \
62652e786d7000000000003c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f2220783a786d70746b3d22584d5020 \
436f726520362e302e30223e0a2020203c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30 \
322f32322d7264662d73796e7461782d6e7323223e0a2020202020203c7264663a4465736372697074696f6e207264663a61626f75743d22220a2020 \
20202020202020202020786d6c6e733a657869663d22687474703a2f2f6e732e61646f62652e636f6d2f657869662f312e302f223e0a202020202020 \
2020203c657869663a436f6c6f7253706163653e313c2f657869663a436f6c6f7253706163653e0a2020202020202020203c657869663a506978656c \
5844696d656e73696f6e3e31363c2f657869663a506978656c5844696d656e73696f6e3e0a2020202020202020203c657869663a506978656c594469 \
6d656e73696f6e3e383c2f657869663a506978656c5944696d656e73696f6e3e0a2020202020203c2f7264663a4465736372697074696f6e3e0a2020 \
203c2f7264663a5244463e0a3c2f783a786d706d6574613e0ae6ba92b400000021494441541819633c2127c7800d8431acc226ccc08455148fe0a806 \
3c8103970200086302148ee3f7130000000049454e44ae426082";
fn bytes(hex: &str) -> Vec<u8> {
let digits: Vec<u8> = hex.bytes().filter(u8::is_ascii_hexdigit).collect();
digits.chunks(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()).collect()
}
const LEFT: [u8; 3] = [200, 30, 30];
const RIGHT: [u8; 3] = [30, 30, 200];
/// The picture is the two-colour 16 x 8 one, each colour within `tolerance`.
#[track_caller]
fn is_the_picture(r: &Raster, tolerance: u8, what: &str) {
assert_eq!((r.width(), r.height()), (16, 8), "{what}");
assert!(r.opaque(), "{what}");
for y in 0..8 {
for x in 0..16 {
// A lossy file blends the columns around the colour change (JPEG stores colour at
// half resolution).
if tolerance > 3 && (6..=9).contains(&x) {
continue;
}
let want = if x < 8 { LEFT } else { RIGHT };
let got = r.pixel(x, y).unwrap();
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= tolerance), "{what}: {got:?} at {x},{y}, wanted {want:?}");
}
}
}
#[test]
fn files_written_by_another_program_are_read() {
for (hex, tolerance, what) in [
(SIPS_PNG, 3, "PNG"),
(SIPS_BMP, 3, "top-down BMP"),
(SIPS_TIFF_LZW, 3, "LZW TIFF"),
(SIPS_TIFF_PLAIN, 3, "plain TIFF"),
(SIPS_JPEG, 14, "JPEG"),
] {
let file = bytes(hex);
assert_eq!(probe(&file).unwrap(), (16, 8), "{what}");
is_the_picture(&decode(&file).unwrap(), tolerance, what);
// Cut short or damaged, they are refused or read: never a panic.
for n in 0..file.len() {
let _ = decode(&file[..n]);
}
for i in 0..file.len() {
let mut b = file.clone();
b[i] ^= 0x55;
let _ = decode(&b);
}
}
}
/// An uncompressed RGB TIFF of one strip, laid out by hand: header, pixels, then the directory.
fn tiff_by_hand(big_endian: bool, w: u32, h: u32, rgb: &[u8]) -> Vec<u8> {
let u16b = |v: u16| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() };
let u32b = |v: u32| if big_endian { v.to_be_bytes() } else { v.to_le_bytes() };
let mut f: Vec<u8> = if big_endian { b"MM".to_vec() } else { b"II".to_vec() };
f.extend(u16b(42));
let pixels_at = 8u32;
let bits_at = pixels_at + rgb.len() as u32;
let dir_at = bits_at + 6;
f.extend(u32b(dir_at));
f.extend(rgb);
for _ in 0..3 {
f.extend(u16b(8));
}
// (tag, type: 3 short / 4 long, count, value)
let entries: [(u16, u16, u32, u32); 10] = [
(256, 4, 1, w),
(257, 4, 1, h),
(258, 3, 3, bits_at),
(259, 3, 1, 1),
(262, 3, 1, 2),
(273, 4, 1, pixels_at),
(277, 3, 1, 3),
(278, 4, 1, h),
(279, 4, 1, rgb.len() as u32),
(284, 3, 1, 1),
];
f.extend(u16b(entries.len() as u16));
for (tag, kind, count, value) in entries {
f.extend(u16b(tag));
f.extend(u16b(kind));
f.extend(u32b(count));
// A single short sits in the first two bytes of the value field.
if kind == 3 && count == 1 {
f.extend(u16b(value as u16));
f.extend([0, 0]);
} else {
f.extend(u32b(value));
}
}
f.extend(u32b(0));
f
}
/// A 24-bit BMP laid out by hand: rows bottom-up, blue-green-red, each padded to four bytes.
fn bmp_by_hand(w: u32, h: u32, rgb: &[u8]) -> Vec<u8> {
let row = (w as usize * 3).div_ceil(4) * 4;
let size = 54 + row * h as usize;
let mut f = b"BM".to_vec();
f.extend((size as u32).to_le_bytes());
f.extend([0; 4]);
f.extend(54u32.to_le_bytes());
f.extend(40u32.to_le_bytes());
f.extend((w as i32).to_le_bytes());
f.extend((h as i32).to_le_bytes());
f.extend(1u16.to_le_bytes());
f.extend(24u16.to_le_bytes());
f.extend([0; 24]);
for y in (0..h as usize).rev() {
let line = &rgb[y * w as usize * 3..(y + 1) * w as usize * 3];
for p in line.chunks(3) {
f.extend([p[2], p[1], p[0]]);
}
f.extend(std::iter::repeat_n(0, row - w as usize * 3));
}
f
}
#[test]
fn files_laid_out_by_hand_are_read_pixel_for_pixel() {
// 3 x 2: an odd width, so BMP rows are padded; every pixel different.
let rgb: Vec<u8> = (0..18u8).map(|i| i * 13 + 5).collect();
let rgba: Vec<u8> = rgb.chunks(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect();
let want = Raster::new(3, 2, rgba).unwrap();
for (file, what) in [
(tiff_by_hand(false, 3, 2, &rgb), "little-endian TIFF"),
(tiff_by_hand(true, 3, 2, &rgb), "big-endian TIFF"),
(bmp_by_hand(3, 2, &rgb), "bottom-up BMP"),
] {
assert_eq!(probe(&file).unwrap(), (3, 2), "{what}");
assert_eq!(decode(&file).unwrap(), want, "{what}");
}
}
#[test]
fn hand_made_files_that_lie_about_their_size_are_refused() {
let rgb = [9u8; 18];
// More pixels declared than the file holds.
for file in [tiff_by_hand(false, 300, 200, &rgb), tiff_by_hand(true, 3, 2000, &rgb), bmp_by_hand(3, 2, &rgb)[..60].to_vec()] {
assert!(decode(&file).is_err());
}
// Sizes past the limit, before any pixel is read.
for file in [tiff_by_hand(false, 60_000, 60_000, &rgb), tiff_by_hand(true, u32::MAX, u32::MAX, &rgb)] {
assert!(decode(&file).is_err());
}
let mut bmp = bmp_by_hand(3, 2, &rgb);
bmp[18..22].copy_from_slice(&60_000i32.to_le_bytes());
bmp[22..26].copy_from_slice(&60_000i32.to_le_bytes());
assert!(decode(&bmp).is_err());
// A zero-sized image.
assert!(decode(&tiff_by_hand(false, 0, 0, &[])).is_err());
}
+168
View File
@@ -0,0 +1,168 @@
//! 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);
}
+17 -9
View File
@@ -56,13 +56,15 @@ 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);
}
}
@@ -73,6 +75,17 @@ 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;
@@ -83,7 +96,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 = ctx.d.block(&ins.block).and_then(|blk| blk.entities.iter().find(|be| attdef_tag(be, &a.tag)));
let def = defs.get(&a.tag.to_ascii_lowercase()).copied();
let (ink, lw, shown, t) = match (def, a.props.is_empty()) {
(None, true) => (rgb, resolve(ctx, e, b.plotting).1, true, reference),
(def, _) => {
@@ -117,8 +130,3 @@ fn by_block() -> cadcraft_doc::Common {
..cadcraft_doc::Common::default()
}
}
/// The attribute definition with tag `tag` (any case).
fn attdef_tag(e: &Entity, tag: &str) -> bool {
matches!(&e.kind, EntityKind::AttDef(d) if d.tag.eq_ignore_ascii_case(tag))
}
+2 -2
View File
@@ -1,13 +1,13 @@
use super::display::Kind;
use super::draw::stroke_with;
use super::props::default_lineweight;
use super::text_entity::{layout_mtext_entity, place_text_entity, text_style};
use super::text_entity::{layout_mtext_entity, place_text_entity, style_font, 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: cadcraft_fonts::TextFont::resolve(&ts.font), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique }
DimText { font: style_font(&ts), fixed_height: ts.height, width_factor: ts.width_factor, oblique: ts.oblique }
}
/// Geometry of a dimension in a drawing: its style, overrides and text font.
+52
View File
@@ -22,6 +22,51 @@ 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)]
@@ -46,6 +91,8 @@ 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.
@@ -135,14 +182,19 @@ 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);
+19 -9
View File
@@ -89,7 +89,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
b.polyline(ctx, rgb, lw, &dash);
}
}
// Text embedded in the linetype (shapes need SHX files and are not drawn).
// Text and shapes embedded in the linetype (shapes when their SHX file is found).
if b.opts.text {
for sh in linetype_text::texts(ctx.d, pts, lt, ltscale, min) {
b.shaped(ctx, rgb, lw, &sh);
@@ -193,14 +193,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
}
}
EntityKind::Wipeout(w) => draw_wipeout(b, ctx, w, 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::Image(i) => draw_image(b, ctx, i, rgb, lw),
EntityKind::Table(t) => table(b, ctx, t, rgb, lw),
EntityKind::Tolerance(t) => tolerance::draw(b, ctx, e, t, lw, tol),
EntityKind::Leader(l) => {
@@ -289,6 +282,23 @@ 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.
+59 -3
View File
@@ -186,6 +186,45 @@ 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);
}
@@ -302,6 +341,23 @@ 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};
@@ -315,12 +371,12 @@ mod text_entity;
mod viewports;
pub use dim_draw::{dim_text, dimension_in};
pub use display::{DPrim, DisplayList, Kind, MAX_TRANSPARENCY, NO_LAYER, PlotText, VIEWPORT_CONTENT, alpha};
pub use display::{DImage, 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, text_align};
pub use viewports::{viewport_clip, viewport_content};
pub use text_entity::{layout_mtext_entity, place_text_entity, style_font, text_align};
pub use viewports::{clip_list, viewport_clip, viewport_content};
#[cfg(test)]
mod tests;
+145 -18
View File
@@ -1,9 +1,10 @@
//! Text embedded in complex linetypes (a gas line's `---- GAS ---- GAS ----`).
//! Text and shapes embedded in complex linetypes (a gas line's `---- GAS ---- GAS ----`, a
//! fence's crosses).
//!
//! 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`]):
//! 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`]):
//! - 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
@@ -11,17 +12,24 @@
//! - rotation (`50`): relative to the line direction, or absolute (`74` bit 1);
//! - font, width factor, oblique angle and mirroring from the text style (`340`).
//!
//! Shape elements (`74` bit 4) need SHX shape files, which CADCraft does not read: they draw
//! nothing and leave their gap empty.
//! 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.
use cadcraft_doc::{Drawing, Linetype};
use cadcraft_fonts::{Shaped, TextFont, TextParams};
use cadcraft_fonts::{Shaped, TextParams};
use cadcraft_geom::Vec2;
/// Upper bound on the texts drawn along one run (hostile scales; `apply` caps the dashes).
/// Upper bound on the texts and shapes 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 element of the pattern, shaped once at the origin.
/// One text or shape 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,
@@ -31,12 +39,12 @@ struct Element {
offset: Vec2,
}
/// 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).
/// 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).
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())) {
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()) {
return out;
}
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
@@ -51,10 +59,12 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
}
let mut walk = Walk { pts, i: 0, start: 0.0 };
let mut base = 0.0;
let mut points = 0usize;
'repeats: while base <= len {
for el in &elements {
let s = base + el.at;
if s > len + 1e-9 || out.len() >= MAX_TEXTS {
points = points.saturating_add(point_count(&el.local));
if s > len + 1e-9 || out.len() >= MAX_TEXTS || points > MAX_POINTS {
break 'repeats;
}
let Some((p, dir)) = walk.at(s) else { break 'repeats };
@@ -69,12 +79,45 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
out
}
/// The text elements of `lt`, scaled by `scale`, in pattern order.
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.
fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
let mut out = Vec::new();
let mut at = 0.0;
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
for el in &lt.pattern {
at += el.length.abs() * scale;
if el.text.is_none()
&& let Some(local) = shape(d, el, scale)
{
out.push(Element {
at,
local,
rotation: finite(el.rotation),
absolute: el.absolute,
offset: Vec2::new(finite(el.offset.x), finite(el.offset.y)) * scale,
});
continue;
}
let Some(text) = el.text.as_deref().filter(|t| !t.trim().is_empty()) else { continue };
let ts = d.text_style(el.style.as_deref().unwrap_or("Standard")).cloned().unwrap_or_default();
let k = if el.scale.is_finite() && el.scale > 0.0 { el.scale } else { 1.0 };
@@ -83,7 +126,7 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
if !(height.is_finite() && height > 0.0) {
continue;
}
let font = TextFont::resolve(&ts.font);
let font = crate::style_font(&ts);
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 },
@@ -92,7 +135,6 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
..TextParams::new(Vec2::ZERO, height)
};
let (local, _) = cadcraft_fonts::place(&font, text, &params);
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
out.push(Element {
at,
local,
@@ -175,6 +217,91 @@ 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),
],
}
}
#[test]
fn shape_elements_draw_their_shape_at_each_repeat() {
register_shapes();
let d = Drawing::new_imperial();
// An unnamed shape-file style reads as its file name (DXF); scale 0.1 per vector unit.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &fence("cadcraft-render-lt.shx"), 1.0, 0.0);
assert_eq!(t.len(), 2);
let x = starts(&t);
assert!((x[0] - 0.7).abs() < 1e-9 && (x[1] - 1.65).abs() < 1e-9, "{x:?}");
let ys = t[0].strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
assert!((ys.0).abs() < 1e-9 && (ys.1 - 0.2).abs() < 1e-9, "{ys:?}");
// Along a vertical line the shape turns with it; absolute rotation keeps it level.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(0.0, 2.0)], &fence("cadcraft-render-lt.shx"), 2.0, 0.0);
assert_eq!(t.len(), 1);
let xs = t[0].strokes.iter().flatten().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)));
assert!((xs.0 + 0.4).abs() < 1e-9 && xs.1.abs() < 1e-9, "{xs:?}");
// A named style whose font is the shape file.
let mut d = Drawing::new_imperial();
d.text_styles.push(cadcraft_doc::TextStyle { name: "SHAPES".into(), font: "cadcraft-render-lt.shx".into(), ..Default::default() });
assert_eq!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &fence("SHAPES"), 1.0, 0.0).len(), 2);
// A shape file that isn't found, or a shape it lacks, draws nothing.
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &fence("cadcraft-no-such-file.shx"), 1.0, 0.0).is_empty());
let mut lt = fence("cadcraft-render-lt.shx");
lt.pattern[1].shape = Some(131);
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &lt, 1.0, 0.0).is_empty());
}
#[test]
fn a_large_shape_repeated_many_times_is_bounded() {
// A shape of 1,000 one-unit vectors there and back: about 1,000 points each time.
let body: Vec<&str> = (0..500).flat_map(|_| ["010", "018"]).collect();
let src = format!("*131,1002,ZIGZAG\n1,{},0\n", body.join(","));
cadcraft_fonts::shx::register("cadcraft-render-big.shx", &cadcraft_fonts::shx::compile(&src).unwrap()).unwrap();
let d = Drawing::new_imperial();
let mut lt = fence("cadcraft-render-big.shx");
lt.pattern[1].shape = Some(131);
// 3,000 repeats would hold about 3 million points.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(2850.0, 0.0)], &lt, 1.0, 0.0);
let points: usize = t.iter().map(point_count).sum();
assert!(!t.is_empty() && t.len() < 3000 && points <= MAX_POINTS, "{} shapes, {points} points", t.len());
}
#[test]
fn text_in_an_shx_style_uses_the_font_metrics() {
// A text font written for this test: A is 4 wide with a gap of 2, cap height 6.
let src = "*0,4,render test\n6,2,0,0\n*65,7,UCA\n1,064,040,06C,2,020,0\n";
cadcraft_fonts::shx::register("cadcraft-render-text.shx", &cadcraft_fonts::shx::compile(src).unwrap()).unwrap();
let mut d = Drawing::new_imperial();
d.text_styles.push(cadcraft_doc::TextStyle { name: "SHX".into(), font: "cadcraft-render-text.shx".into(), ..Default::default() });
let t = cadcraft_doc::Text {
insert: cadcraft_geom::Vec3::new(0.0, 0.0, 0.0),
align_pt: None,
height: 3.0,
value: "AA".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "SHX".into(),
halign: Default::default(),
valign: Default::default(),
};
let (sh, bb) = crate::place_text_entity(&d, &t, "AA");
assert!((sh.width - 6.0).abs() < 1e-9 && (bb.width() - 6.0).abs() < 1e-9);
let maxy = sh.strokes.iter().flatten().map(|p| p.y).fold(f64::MIN, f64::max);
assert!((maxy - 3.0).abs() < 1e-9);
}
#[test]
fn hostile_values_never_panic() {
let d = Drawing::new_imperial();
+28 -4
View File
@@ -106,10 +106,12 @@ 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)));
let mut at: HashMap<Key, Vec<usize>> = HashMap::new();
// 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();
for (i, (a, b)) in segs.iter().enumerate() {
at.entry(key(*a)).or_default().push(i);
at.entry(key(*b)).or_default().push(i);
at.entry(key(*a)).or_default().0.push(i);
at.entry(key(*b)).or_default().0.push(i);
}
let mut used = vec![false; segs.len()];
let mut out = Vec::new();
@@ -125,7 +127,15 @@ 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(&key(end)).and_then(|ids| ids.iter().copied().find(|&i| !used.get(i).copied().unwrap_or(true)));
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 Some(i) = next else { break };
let Some(&(p, q)) = segs.get(i) else { break };
if let Some(u) = used.get_mut(i) {
@@ -147,6 +157,20 @@ 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));
+97 -1
View File
@@ -4,7 +4,7 @@ use cadcraft_color::Rgb;
use cadcraft_geom::{Bounds2, Vec2};
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
use crate::{DisplayList, Kind};
use crate::{DImage, DisplayList, Kind};
/// A 2D view: world point at the image centre and pixels per drawing unit.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -147,6 +147,13 @@ 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());
@@ -253,6 +260,95 @@ pub fn blank_outside_pixels(pm: &mut Pixmap, top_left: (f64, f64), bottom_right:
}
}
/// Draw `img` with the object opacity `alpha`, cut to its clip outlines.
fn image(pm: &mut Pixmap, img: &DImage, view: &View, alpha: u8, antialias: bool) {
let r = &img.raster;
let Some(to_screen) = grid_to_screen(img, view) else { return };
let Ok(mask) = clip_mask(pm, img, view, antialias) else { return };
// tiny-skia wants premultiplied pixels; opaque ones already are.
let owned;
let pixels = if r.opaque() {
tiny_skia::PixmapRef::from_bytes(r.rgba(), r.width(), r.height())
} else {
owned = premultiplied(r);
owned.as_ref().map(Pixmap::as_ref)
};
let Some(pixels) = pixels else { return };
let paint = Paint {
shader: tiny_skia::Pattern::new(
pixels,
tiny_skia::SpreadMode::Pad,
tiny_skia::FilterQuality::Bilinear,
img.opacity(alpha),
Transform::identity(),
),
anti_alias: antialias,
..Paint::default()
};
// The picture's own rectangle in its pixel grid, carried to the screen by `to_screen`.
// Filled as a path: tiny-skia's anti-aliased `fill_rect` trips a debug assertion on narrow
// rects (see the points above), which a one-pixel image drawn at its own size would be.
if let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, r.width() as f32, r.height() as f32) {
pm.fill_path(&PathBuilder::from_rect(rect), &paint, FillRule::Winding, to_screen, mask.as_ref());
}
}
/// The transform from `img`'s pixel grid (x right, y down) to screen pixels; `None` when the
/// image has no area on screen or lies out of number range.
fn grid_to_screen(img: &DImage, view: &View) -> Option<Transform> {
let s = view.scale;
let (x, y) = view.to_screen(img.origin);
// Screen y runs down, world y up.
let ts = Transform::from_row((img.right.x * s) as f32, (-img.right.y * s) as f32, (img.down.x * s) as f32, (-img.down.y * s) as f32, x, y);
(ts.is_finite() && ts.invert().is_some()).then_some(ts)
}
/// The image has a clip outline without area: nothing of it shows.
struct Hidden;
/// The part of the picture `img`'s clip outlines leave visible: `None` for all of it.
fn clip_mask(pm: &Pixmap, img: &DImage, view: &View, antialias: bool) -> Result<Option<tiny_skia::Mask>, Hidden> {
let mut mask: Option<tiny_skia::Mask> = None;
for outline in &img.clips {
let path = outline_path(outline, view).ok_or(Hidden)?;
match &mut mask {
// The first outline starts the mask; each further one narrows it.
None => {
let mut m = tiny_skia::Mask::new(pm.width(), pm.height()).ok_or(Hidden)?;
m.fill_path(&path, FillRule::EvenOdd, antialias, Transform::identity());
mask = Some(m);
}
Some(m) => m.intersect_path(&path, FillRule::EvenOdd, antialias, Transform::identity()),
}
}
Ok(mask)
}
/// A closed world-space outline as a screen path.
fn outline_path(outline: &[Vec2], view: &View) -> Option<tiny_skia::Path> {
let mut pb = PathBuilder::new();
for (i, q) in outline.iter().enumerate() {
let (x, y) = view.to_screen(*q);
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
pb.close();
pb.finish()
}
/// The pixels of `r` with each colour multiplied by its alpha.
fn premultiplied(r: &cadcraft_doc::Raster) -> Option<Pixmap> {
let mut data = Vec::with_capacity(r.rgba().len());
for [red, green, blue, a] in r.rgba().as_chunks::<4>().0 {
let m = |c: &u8| ((u16::from(*c) * u16::from(*a) + 127) / 255) as u8;
data.extend_from_slice(&[m(red), m(green), m(blue), *a]);
}
Pixmap::from_vec(data, tiny_skia::IntSize::from_wh(r.width(), r.height())?)
}
/// Render and encode as PNG.
pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Vec<u8>> {
render(list, view, o)?.encode_png().ok()
+1 -1
View File
@@ -375,7 +375,7 @@ pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, t: &Table, rgb: Ink, lw: f32) {
}
for tx in &g.texts {
let ts = crate::text_style(ctx.d, &tx.style);
let font = cadcraft_fonts::TextFont::resolve(&ts.font);
let font = crate::style_font(&ts);
// Cell content is MTEXT (`\P`, `{\Q15;…}`, `\F…;`): laid out as such, at the cell's
// alignment point (its numbering is MTEXT's attachment).
let mp = cadcraft_fonts::MTextParams {
+1
View File
@@ -137,6 +137,7 @@ 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,
}
}
+8 -2
View File
@@ -5,10 +5,16 @@ 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 = cadcraft_fonts::TextFont::resolve(&ts.font);
let font = style_font(&ts);
let (h, v) = align(t.halign, t.valign);
let p = cadcraft_fonts::TextParams {
insert: t.insert.xy(),
@@ -36,7 +42,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: cadcraft_fonts::TextFont::resolve(&ts.font),
font: style_font(&ts),
width_factor: ts.width_factor,
oblique: ts.oblique,
columns: t.columns.as_ref().map(|c| cadcraft_fonts::MTextColumnSpec {
+43 -6
View File
@@ -1,6 +1,6 @@
#[cfg(not(target_arch = "wasm32"))]
use super::display::append;
use super::display::{DPrim, DisplayList, Kind, PlotText, VIEWPORT_CONTENT};
use super::display::{DImage, DPrim, DisplayList, Kind, PlotText, VIEWPORT_CONTENT};
use super::draw::entity;
#[cfg(not(target_arch = "wasm32"))]
use super::pipeline::PARALLEL_CHUNK;
@@ -88,11 +88,12 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
if !ok(s) {
return;
}
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);
// 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);
// 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 };
@@ -187,6 +188,14 @@ 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).
@@ -229,6 +238,11 @@ 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)
@@ -247,6 +261,29 @@ 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,
+3 -1
View File
@@ -95,7 +95,9 @@ 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 };
let inner = run.iter().filter(|x| x.s > a && x.s < b).copied();
// 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 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 {
@@ -167,6 +167,7 @@ fn viewport_contents_get_their_pens() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
let space = Space::Paper("Layout1".into());
d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap();
+1
View File
@@ -25,6 +25,7 @@ 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
+1
View File
@@ -63,6 +63,7 @@ fn dimensions_take_the_viewport_layer_colour() {
layer_colors: vec![("a".into(), Color::Index(3))],
clip: None,
on: true,
twist: 0.0,
};
d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap();
let l = build(&d, &Space::Paper("Layout1".into()), &Options::default());
@@ -21,6 +21,7 @@ fn drawing() -> Drawing {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
d.add(&Space::Paper("Layout1".into()), Common { layer: "VPORTS".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap();
d

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