mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-28 12:56:47 -06:00
M1/M2/M6/M10: draw/modify depth, grips API, layouts/viewports/PLOT to PDF, GPU canvas, R-tree index
- All 2D Draw-menu items: ARC variants, CIRCLE TTR/TTT, DIVIDE/MEASURE, REVCLOUD, WIPEOUT, 3DPOLY, MLINE, HELIX, SPLINE CV, CENTERMARK/CENTERLINE, XLINE and RECTANG options. - PEDIT, SPLINEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, NCOPY, CHSPACE, FLATTEN, ARRAYPATH, TEXTEDIT; FILLET with arcs and polylines; TRIM ellipses and splines; grip editing commands. - Layouts, MVIEW/VPORTS, viewport scale/lock/layers, PAGESETUP, PLOT/EXPORTPDF with our own PDF writer; paper-space rendering through viewports. - GPU canvas (wgpu batches, CPU fallback); rstar spatial index for pick/select/osnap; cadcraft-cli perf harness. - CAL, FIND/replace, MASSPROP, MEASUREGEOM; TrueType outlines in cadcraft-fonts; contributors standard (xtask contributors, About window credits); docs/mcp.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4c74c5591c
commit
3da0219ac9
Generated
+61
-4
@@ -601,6 +601,7 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"log",
|
||||
"rstar",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -611,6 +612,7 @@ name = "cadcraft-fonts"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"skrifa 0.47.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -630,6 +632,8 @@ dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"miniz_oxide 0.8.9",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -665,8 +669,10 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"log",
|
||||
"naga",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1173,7 +1179,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"self_cell",
|
||||
"skrifa",
|
||||
"skrifa 0.44.0",
|
||||
"smallvec",
|
||||
"unicode-general-category",
|
||||
"unicode-segmentation",
|
||||
@@ -1442,7 +1448,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
"skrifa",
|
||||
"skrifa 0.44.0",
|
||||
"smallvec",
|
||||
"vello_common",
|
||||
]
|
||||
@@ -1568,10 +1574,19 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"read-fonts",
|
||||
"read-fonts 0.41.0",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1592,6 +1607,16 @@ dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2845,6 +2870,17 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
@@ -2905,6 +2941,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rstar"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb"
|
||||
dependencies = [
|
||||
"heapless",
|
||||
"num-traits",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -3111,7 +3158,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts",
|
||||
"read-fonts 0.41.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.44.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+12
-12
@@ -8,9 +8,9 @@ agent control over MCP, a scriptable CLI, a web build and a free licence.
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| Menu breadth (`cargo xtask parity`, [docs/parity.md](docs/parity.md)) | **119 / 491 reference menu items (24%)** |
|
||||
| Registered commands | 130 (41 with interactive prompts) |
|
||||
| **Estimated overall feature parity (weighted by how much each area matters)** | **≈ 12%** |
|
||||
| Menu breadth (`cargo xtask parity`, [docs/parity.md](docs/parity.md)) | **204 / 491 reference menu items (42%)** |
|
||||
| Registered commands | 227 (104 with interactive prompts) |
|
||||
| **Estimated overall feature parity (weighted by how much each area matters)** | **≈ 20%** |
|
||||
| Tests | geometry, colour, document, fonts, render, DXF, engine (commands, prompts, snaps, selection, undo, scripts, hostile input), MCP agent tasks, xtask |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm — green |
|
||||
|
||||
@@ -24,24 +24,24 @@ interactive DIM commands yet).
|
||||
| # | Milestone | Status | Estimate (Opus 5.5 wall-clock hours) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice: workspace, AutoCAD-style UI, command line, draw/modify basics, DXF, CLI, MCP, web | **done** | — |
|
||||
| M1 | Drafting core: every Draw-menu 2D command and option, dynamic input fields, object snap tracking, temporary snap overrides, TTR/TTT circles, REVCLOUD, WIPEOUT, REGION, BOUNDARY | in progress (~60%) | 20 |
|
||||
| M2 | Modify: grips (stretch/move/rotate/scale/mirror, multifunctional), PEDIT, SPLINEDIT, LENGTHEN, BLEND, ALIGN, associative arrays, trim/extend for all curve types, MATCHPROP UI, Quick Properties | ~45% | 30 |
|
||||
| M1 | Drafting core: every Draw-menu 2D command and option, dynamic input fields, object snap tracking, temporary snap overrides | ~85% (all 2D Draw items exist) | 8 |
|
||||
| M2 | Modify: grips (stretch/move/rotate/scale/mirror, multifunctional), PEDIT, SPLINEDIT, LENGTHEN, BLEND, ALIGN, associative arrays, trim/extend for all curve types, MATCHPROP UI, Quick Properties | ~65% (PEDIT, LENGTHEN, ALIGN, BLEND, grips API, TRIM on splines) | 18 |
|
||||
| M3 | Layers & properties: full Layer Properties Manager (filters, VP overrides, states), linetype manager, lineweight display, transparency, QSELECT dialog, Properties for every object type | ~45% | 20 |
|
||||
| M4 | Annotation: DIM* commands, DIMSTYLE manager, associative dimensions, MLEADER + styles, in-place MTEXT editor, fields, tables + table styles, annotative scaling, TrueType fonts, SHX reader | ~15% | 60 |
|
||||
| M5 | Hatch & blocks: pick-point boundary detection, islands, gradients, BLOCK/WBLOCK/INSERT dialogs, attributes (ATTDEF/ATTEDIT/BATTMAN), block editor, dynamic block parameters, xrefs, groups, Blocks palette | ~20% | 70 |
|
||||
| M6 | Layouts & plotting: paper space, viewports (rect/polygonal/object, scale, lock, per-VP layers), page setups, PLOT to PDF/PNG/SVG, plot styles (CTB/STB), PUBLISH | ~5% | 60 |
|
||||
| M7 | Files: DWG read (R2000–2018), DWG write, DXF fidelity (all object types, round-trip of unknown data), RECOVER/AUDIT, PURGE, templates, autosave, ETRANSMIT, PDF/raster underlays | ~15% | 120 |
|
||||
| M6 | Layouts & plotting: paper space, viewports (rect/polygonal/object, scale, lock, per-VP layers), page setups, PLOT to PDF/PNG/SVG, plot styles (CTB/STB), PUBLISH | ~45% (layouts, viewports, page setup, PLOT/EXPORTPDF) | 30 |
|
||||
| M7 | Files: DWG read/write (done via acadrust), DXF fidelity (all object types, round-trip of unknown data), RECOVER/AUDIT, PURGE, templates, autosave, ETRANSMIT, PDF/raster underlays | ~45% | 60 |
|
||||
| M8 | Inquiry & utilities: MEASUREGEOM, MASSPROP, QuickCalc, Find/Replace, spell check, COUNT, DWG Compare, Settings/OPTIONS, CUI-style customisation, alias editor | ~20% | 30 |
|
||||
| M9 | Parametric: geometric + dimensional constraints, AutoConstrain, Parameters Manager (constraint solver, see plan/adr/0001) | 0% | 50 |
|
||||
| M10 | Performance: GPU canvas (wgpu batches), R-tree spatial index, incremental regen, 1M-entity drawings at 60 fps | ~10% | 30 |
|
||||
| M10 | Performance: GPU canvas (wgpu batches), R-tree spatial index, incremental regen, 1M-entity drawings at 60 fps | ~60% (GPU canvas, R-tree: pick 50 ms → 0.001 ms at 200k entities) | 15 |
|
||||
| M11 | 3D: UCS, orbit, visual styles, solids (box…loft, booleans, fillet edges), meshes, surfaces, sections, rendering | 0% | 200 |
|
||||
| M12 | Automation: an embedded safe AutoLISP-compatible interpreter, action recorder, sheet sets, CLI/MCP parity tests | ~25% | 60 |
|
||||
| M13 | 1.0 polish: preferences, workspaces, themes, localisation, accessibility, signed packages for every platform, docs | ~10% | 40 |
|
||||
| | **Remaining total** | | **≈ 790 hours** |
|
||||
| | **Remaining total** | | **≈ 680 hours** |
|
||||
|
||||
At roughly 790 more hours of Opus 5.5 wall-clock work (with parallel agents this compresses to
|
||||
about 200–250 hours of elapsed time), CADCraft would reach broad AutoCAD parity. 2D drafting parity
|
||||
(M1–M8) is the first ≈ 410 hours.
|
||||
At roughly 680 more hours of Opus 5.5 wall-clock work (with parallel agents this compresses to
|
||||
about 170–220 hours of elapsed time), CADCraft would reach broad AutoCAD parity. 2D drafting parity
|
||||
(M1–M8) is the first ≈ 300 hours.
|
||||
|
||||
## Current focus
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png]
|
||||
//! cadcraft-cli commands [FILTER] command catalog (JSON)
|
||||
//! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app)
|
||||
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -20,12 +21,14 @@ const USAGE: &str = "usage:
|
||||
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg)]
|
||||
cadcraft-cli commands [FILTER]
|
||||
cadcraft-cli mcp [--connect HOST:PORT]
|
||||
cadcraft-cli perf [N]
|
||||
cadcraft-cli --version";
|
||||
|
||||
fn install_io() {
|
||||
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::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: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,6 +106,140 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (hi - lo) * self.next()
|
||||
}
|
||||
}
|
||||
|
||||
/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread
|
||||
/// over a square whose side grows with sqrt(n), so density stays constant.
|
||||
fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, String> {
|
||||
use cadcraft_engine::doc::{self as d, Common, EntityKind, Space};
|
||||
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
|
||||
let mut dr = d::Drawing::new_imperial();
|
||||
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0);
|
||||
for i in 0..n {
|
||||
let (x, y) = (r.range(0.0, side), r.range(0.0, side));
|
||||
let s = r.range(0.5, 8.0);
|
||||
let kind = match i % 5 {
|
||||
0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }),
|
||||
1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }),
|
||||
2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }),
|
||||
3 => EntityKind::LwPolyline(d::LwPolyline {
|
||||
vertices: (0..6)
|
||||
.map(|k| PolyVertex {
|
||||
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3),
|
||||
bulge: if k == 2 { 0.4 } else { 0.0 },
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
})
|
||||
.collect(),
|
||||
closed: i % 2 == 0,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
}),
|
||||
_ => EntityKind::Text(d::Text {
|
||||
insert: v3(x, y),
|
||||
align_pt: None,
|
||||
height: s * 0.2,
|
||||
value: format!("T{i}"),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: d::HAlign::Left,
|
||||
valign: d::VAlign::Baseline,
|
||||
}),
|
||||
};
|
||||
dr.add(&Space::Model, Common::default(), kind).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(dr)
|
||||
}
|
||||
|
||||
fn perf(args: &[String]) -> Result<(), String> {
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{select, snap};
|
||||
use std::time::Instant;
|
||||
let n: usize = match args.first() {
|
||||
Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?,
|
||||
None => 200_000,
|
||||
};
|
||||
let n = n.clamp(1, 20_000_000);
|
||||
let side = (n as f64).sqrt() * 10.0;
|
||||
let mut rows: Vec<(String, f64, String)> = Vec::new();
|
||||
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let t = Instant::now();
|
||||
let d = synthetic(n, side)?;
|
||||
rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities")));
|
||||
|
||||
// Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel).
|
||||
let px = side / 1600.0;
|
||||
let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() };
|
||||
let t = Instant::now();
|
||||
let list = cadcraft_engine::render::build(&d, &Space::Model, &opts);
|
||||
rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count())));
|
||||
|
||||
// Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px).
|
||||
let ap = 0.1;
|
||||
let mut r = Rng(42);
|
||||
let pts: Vec<Vec2> = (0..1000).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
|
||||
let t = Instant::now();
|
||||
let first = select::pick(&d, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
|
||||
rows.push(("first pick (incl. index build)".into(), ms(t), format!("{first:?}")));
|
||||
let t = Instant::now();
|
||||
let hits = pts.iter().filter(|p| select::pick(&d, &Space::Model, **p, ap).is_some()).count();
|
||||
let total = ms(t);
|
||||
rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0)));
|
||||
|
||||
let mut sel = 0usize;
|
||||
let t = Instant::now();
|
||||
for k in 0..20 {
|
||||
let c = Vec2::new(r.range(0.0, side), r.range(0.0, side));
|
||||
let half = side * 0.05;
|
||||
sel += select::select_window(&d, &Space::Model, Bounds2::new(c - Vec2::new(half, half), c + Vec2::new(half, half)), k % 2 == 1).len();
|
||||
}
|
||||
rows.push(("window/crossing select x20 (10% side)".into(), ms(t), format!("{sel} selected")));
|
||||
|
||||
let t = Instant::now();
|
||||
let fence: Vec<Vec2> = (0..4).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
|
||||
let fsel = select::select_fence(&d, &Space::Model, &fence).len();
|
||||
rows.push(("fence select (3 legs)".into(), ms(t), format!("{fsel} selected")));
|
||||
|
||||
let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT;
|
||||
let t = Instant::now();
|
||||
let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None).is_some()).count();
|
||||
let total = ms(t);
|
||||
rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0)));
|
||||
|
||||
let t = Instant::now();
|
||||
let bytes = cadcraft_io::write(&d, "perf.dxf").map_err(|e| e.to_string())?;
|
||||
rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6)));
|
||||
let t = Instant::now();
|
||||
let back = cadcraft_io::read(&bytes, "perf.dxf").map_err(|e| e.to_string())?;
|
||||
rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count())));
|
||||
|
||||
println!("{:<40} {:>12} notes", "operation", "ms");
|
||||
println!("{}", "-".repeat(80));
|
||||
for (name, t, note) in rows {
|
||||
println!("{name:<40} {t:>12.2} {note}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
install_io();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -117,6 +254,7 @@ fn main() -> ExitCode {
|
||||
_ => Err(USAGE.into()),
|
||||
},
|
||||
Some("run") => run(&rest),
|
||||
Some("perf") => perf(&rest),
|
||||
Some("commands") => {
|
||||
let s = Session::new();
|
||||
let f = rest.first().map(|x| x.to_ascii_lowercase());
|
||||
|
||||
@@ -35,6 +35,7 @@ mod web {
|
||||
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::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: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
@@ -52,8 +53,11 @@ mod web {
|
||||
.start(
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
Box::new(move |cc| {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
let _ = app.run("ui.sample", serde_json::json!({}));
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ pub fn install_io() {
|
||||
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::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: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,6 +121,9 @@ fn main() -> eframe::Result {
|
||||
Box::new(move |cc| {
|
||||
let mut app = CadApp::new(Session::empty(), services());
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
sha url login date additions deletions pr models
|
||||
7e4583b3406de501e48dce5e9accaade8b608e85 https://github.com/storytold/cadcraft/commit/7e4583b3406de501e48dce5e9accaade8b608e85 echelon 2026-10-07T02:15:47Z 7 0
|
||||
70f0489e0ce753fdf46d03d59ea47f974bb0b26c https://github.com/storytold/cadcraft/commit/70f0489e0ce753fdf46d03d59ea47f974bb0b26c echelon 2026-10-07T05:22:25Z 30261 5 anthropic/claude-opus/5.5
|
||||
1565bfd5c70760fcdd007874f9fa4ba5e6658549 https://github.com/storytold/cadcraft/commit/1565bfd5c70760fcdd007874f9fa4ba5e6658549 echelon 2026-10-07T05:31:44Z 2191 44 anthropic/claude-opus/5.5
|
||||
125803d25823a1eb1f14ea46dfdb617d08bc27b8 https://github.com/storytold/cadcraft/commit/125803d25823a1eb1f14ea46dfdb617d08bc27b8 echelon 2026-10-07T05:43:22Z 236 6 anthropic/claude-opus/5.5
|
||||
e68e81a89ab7752cacd32acb0c93020b739b3624 https://github.com/storytold/cadcraft/commit/e68e81a89ab7752cacd32acb0c93020b739b3624 echelon 2026-10-07T05:50:51Z 1714 5 anthropic/claude-opus/5.5
|
||||
|
@@ -0,0 +1,6 @@
|
||||
# Maps Co-Authored-By trailer names to AI models.
|
||||
id company model version trailer
|
||||
anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5
|
||||
anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5 (1M context)
|
||||
anthropic/claude-sonnet/5.5 Anthropic Claude Sonnet 5.5 Claude Sonnet 5.5
|
||||
anthropic/claude-fable/5.1 Anthropic Claude Fable 5.1 Claude Fable 5.1
|
||||
|
@@ -0,0 +1,3 @@
|
||||
# Consent and opt-in fields (craftrules standards/contributors.md). Never written by xtask.
|
||||
login consent consent_date consent_source display_name real_name email
|
||||
echelon yes 2026-10-06 owner Brandon Thomas Brandon Thomas
|
||||
|
@@ -0,0 +1 @@
|
||||
number url login merged_at additions deletions commits models
|
||||
|
@@ -189,6 +189,11 @@ impl EntityStore {
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// True when `other` shares every chunk with `self` (same draw order, same entities). Holding
|
||||
/// a clone of a store makes this exact: any edit to the original copies the touched chunk.
|
||||
pub fn same_as(&self, other: &EntityStore) -> bool {
|
||||
self.len == other.len && self.chunks.len() == other.chunks.len() && self.chunks.iter().zip(&other.chunks).all(|(a, b)| Arc::ptr_eq(a, b))
|
||||
}
|
||||
/// The last entity in draw order (AutoCAD's "Last" selection).
|
||||
pub fn last(&self) -> Option<&Arc<Entity>> {
|
||||
self.chunks.last().and_then(|c| c.items.last())
|
||||
|
||||
@@ -18,3 +18,5 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0).
|
||||
rstar = { version = "0.12", default-features = false }
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
//! Shared curve geometry for the M1/M2 draw and modify commands: arc constructions, tangent
|
||||
//! circles (TTR/TTT), length parametrisation of curves, spline splitting and picking helpers.
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle, Prim};
|
||||
use cadcraft_geom::{Arc, Circle, EPS, Line, PolyVertex, Polyline, Segment, Spline, TAU, Vec2, circle_circle, line_circle, line_line_infinite};
|
||||
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Hard cap on generated vertices/points from user-sized inputs.
|
||||
pub const MAX_GEN: usize = 20_000;
|
||||
|
||||
/// A keyword-only prompt: spaces separate inputs (like Enter), as at AutoCAD's option prompts.
|
||||
pub const KW: crate::Accept = crate::Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
|
||||
// ---------------- picking ----------------
|
||||
|
||||
pub fn pick_aperture(s: &Session) -> f64 {
|
||||
s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5
|
||||
}
|
||||
|
||||
/// The object under `p` in the current space.
|
||||
pub fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let space = s.space();
|
||||
crate::select::pick(s.doc().ok()?, &space, p, pick_aperture(s))
|
||||
}
|
||||
|
||||
pub fn entity(s: &Session, h: Handle) -> Result<Entity> {
|
||||
s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))
|
||||
}
|
||||
|
||||
pub fn handle_value(v: &serde_json::Value) -> Option<Handle> {
|
||||
v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))
|
||||
}
|
||||
|
||||
pub fn handle_param(p: &serde_json::Value, key: &str) -> Option<Handle> {
|
||||
p.get(key).and_then(handle_value)
|
||||
}
|
||||
|
||||
/// Is the entity on a locked layer?
|
||||
pub fn is_locked(s: &Session, h: Handle) -> bool {
|
||||
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
|
||||
}
|
||||
|
||||
// ---------------- arc constructions ----------------
|
||||
|
||||
fn finite_arc(a: Arc) -> Option<Arc> {
|
||||
(a.center.is_finite() && a.radius.is_finite() && a.radius > EPS && a.start.is_finite() && a.end.is_finite()).then_some(a)
|
||||
}
|
||||
|
||||
/// Arc from start, center and a signed included angle (CCW positive).
|
||||
pub fn arc_sca(st: Vec2, c: Vec2, angle: f64) -> Option<Arc> {
|
||||
let r = st.dist(c);
|
||||
if !angle.is_finite() || angle.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let a0 = c.angle_to(st);
|
||||
let a = angle.clamp(-TAU, TAU);
|
||||
finite_arc(if a > 0.0 { Arc::new(c, r, a0, a0 + a) } else { Arc::new(c, r, a0 + a, a0) })
|
||||
}
|
||||
|
||||
/// Arc from start, center and chord length (positive: CCW minor arc, negative: CCW major arc).
|
||||
pub fn arc_scl(st: Vec2, c: Vec2, chord: f64) -> Option<Arc> {
|
||||
let r = st.dist(c);
|
||||
if r <= EPS || !chord.is_finite() || chord.abs() < 1e-12 || chord.abs() > 2.0 * r + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let theta = 2.0 * (chord.abs() / (2.0 * r)).clamp(-1.0, 1.0).asin();
|
||||
let sweep = if chord > 0.0 { theta } else { TAU - theta };
|
||||
arc_sca(st, c, sweep)
|
||||
}
|
||||
|
||||
/// Arc from start and end points with a signed included angle (CCW positive).
|
||||
pub fn arc_sea(st: Vec2, en: Vec2, angle: f64) -> Option<Arc> {
|
||||
if !angle.is_finite() || angle.abs() < 1e-9 || angle.abs() >= TAU - 1e-9 || st.near(en, EPS) {
|
||||
return None;
|
||||
}
|
||||
let (a, b, th) = if angle > 0.0 { (st, en, angle) } else { (en, st, -angle) };
|
||||
let chord = a.dist(b);
|
||||
let r = chord / (2.0 * (th / 2.0).sin());
|
||||
let h = (chord / 2.0) / (th / 2.0).tan();
|
||||
let c = a.mid(b) + (b - a).normalized().perp() * h;
|
||||
finite_arc(Arc::new(c, r.abs(), c.angle_to(a), c.angle_to(b)))
|
||||
}
|
||||
|
||||
/// Arc from start and end points with the tangent direction at the start.
|
||||
pub fn arc_sed(st: Vec2, en: Vec2, dir: Vec2) -> Option<Arc> {
|
||||
let d = dir.normalized();
|
||||
if d == Vec2::ZERO || st.near(en, EPS) {
|
||||
return None;
|
||||
}
|
||||
let n = d.perp();
|
||||
let v = en - st;
|
||||
let den = 2.0 * n.dot(v);
|
||||
if den.abs() < 1e-12 * v.len().max(1.0) {
|
||||
return None;
|
||||
}
|
||||
let t = v.len2() / den;
|
||||
let c = st + n * t;
|
||||
let r = c.dist(st);
|
||||
finite_arc(if t > 0.0 { Arc::new(c, r, c.angle_to(st), c.angle_to(en)) } else { Arc::new(c, r, c.angle_to(en), c.angle_to(st)) })
|
||||
}
|
||||
|
||||
/// Arc from start and end points with a radius (CCW; negative radius gives the major arc).
|
||||
pub fn arc_ser(st: Vec2, en: Vec2, radius: f64) -> Option<Arc> {
|
||||
let r = radius.abs();
|
||||
let chord = st.dist(en);
|
||||
if !radius.is_finite() || chord < EPS || r < chord / 2.0 - 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let h = (r * r - chord * chord / 4.0).max(0.0).sqrt();
|
||||
let left = (en - st).normalized().perp();
|
||||
let c = if radius > 0.0 { st.mid(en) + left * h } else { st.mid(en) - left * h };
|
||||
finite_arc(Arc::new(c, r, c.angle_to(st), c.angle_to(en)))
|
||||
}
|
||||
|
||||
/// End point and outgoing unit tangent of the last line/arc/open polyline in the current space
|
||||
/// (ARC "Continue" and LINE continuation).
|
||||
pub fn last_curve_end(s: &Session) -> Option<(Vec2, Vec2)> {
|
||||
let d = s.doc().ok()?;
|
||||
let space = s.space();
|
||||
let store = d.space(&space)?;
|
||||
for e in store.iter().collect::<Vec<_>>().into_iter().rev() {
|
||||
match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
let dir = (l.b.xy() - l.a.xy()).normalized();
|
||||
if dir != Vec2::ZERO {
|
||||
return Some((l.b.xy(), dir));
|
||||
}
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
return Some((g.end_point(), Vec2::from_angle(g.end).perp()));
|
||||
}
|
||||
EntityKind::LwPolyline(p) if !p.closed => {
|
||||
let segs = Polyline { vertices: p.vertices.clone(), closed: false }.segments();
|
||||
if let Some(sg) = segs.last() {
|
||||
return Some((sg.end(), sg.tangent(1.0)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Signed bulge of the arc from `a` through `m` to `b` (0 when collinear).
|
||||
pub fn bulge_through(a: Vec2, m: Vec2, b: Vec2) -> f64 {
|
||||
let Some(arc) = Arc::from_3_points(a, m, b) else { return 0.0 };
|
||||
let sw = arc.sweep();
|
||||
if arc.start_point().near(a, 1e-6 * arc.radius.max(1.0)) { cadcraft_geom::arc_to_bulge(sw) } else { cadcraft_geom::arc_to_bulge(-sw) }
|
||||
}
|
||||
|
||||
/// Bulge of the arc from `a` to `b` leaving `a` along `tangent`.
|
||||
pub fn bulge_from_tangent(a: Vec2, b: Vec2, tangent: Vec2) -> f64 {
|
||||
let c = b - a;
|
||||
let t = tangent.normalized();
|
||||
if c.len() < EPS || t == Vec2::ZERO {
|
||||
return 0.0;
|
||||
}
|
||||
let alpha = t.cross(c).atan2(t.dot(c));
|
||||
cadcraft_geom::arc_to_bulge(2.0 * alpha)
|
||||
}
|
||||
|
||||
// ---------------- tangent circles ----------------
|
||||
|
||||
/// A tangency target: an infinite line or a full circle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum TanObj {
|
||||
Line(Line),
|
||||
Circle(Circle),
|
||||
}
|
||||
|
||||
impl TanObj {
|
||||
/// From an entity and the pick point (polylines use the picked segment).
|
||||
pub fn from_kind(k: &EntityKind, pick: Vec2) -> Option<TanObj> {
|
||||
match k {
|
||||
EntityKind::Line(l) => {
|
||||
let ln = Line::new(l.a.xy(), l.b.xy());
|
||||
(ln.len() > EPS).then_some(TanObj::Line(ln))
|
||||
}
|
||||
EntityKind::Circle(c) => Some(TanObj::Circle(Circle::new(c.center.xy(), c.radius))),
|
||||
EntityKind::Arc(a) => Some(TanObj::Circle(Circle::new(a.center.xy(), a.radius))),
|
||||
EntityKind::Ray(r) | EntityKind::XLine(r) => Some(TanObj::Line(Line::new(r.base.xy(), r.base.xy() + r.dir.xy()))),
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let segs = Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments();
|
||||
let sg = segs.iter().min_by(|a, b| a.closest(pick).dist(pick).total_cmp(&b.closest(pick).dist(pick)))?;
|
||||
Some(match sg {
|
||||
Segment::Line(l) => TanObj::Line(*l),
|
||||
Segment::Arc { arc, .. } => TanObj::Circle(Circle::new(arc.center, arc.radius)),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The point where a circle `c` tangent to this object touches it.
|
||||
pub fn touch(&self, c: &Circle) -> Vec2 {
|
||||
match self {
|
||||
TanObj::Line(l) => l.project(c.center),
|
||||
TanObj::Circle(k) => {
|
||||
let d = (c.center - k.center).normalized();
|
||||
let d = if d == Vec2::ZERO { Vec2::X } else { d };
|
||||
let p1 = k.center + d * k.radius;
|
||||
let p2 = k.center - d * k.radius;
|
||||
if (p1.dist(c.center) - c.radius).abs() <= (p2.dist(c.center) - c.radius).abs() { p1 } else { p2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Tangency residual of circle `(c, r)`: zero when tangent.
|
||||
fn residual(&self, c: Vec2, r: f64) -> f64 {
|
||||
match self {
|
||||
TanObj::Line(l) => l.dist_inf(c) - r,
|
||||
TanObj::Circle(k) => {
|
||||
let d = c.dist(k.center);
|
||||
(d - (k.radius + r)).abs().min((d - (k.radius - r).abs()).abs())
|
||||
}
|
||||
}
|
||||
}
|
||||
fn offsets(&self, r: f64) -> Vec<TanObj> {
|
||||
match self {
|
||||
TanObj::Line(l) => vec![TanObj::Line(l.offset(r)), TanObj::Line(l.offset(-r))],
|
||||
TanObj::Circle(k) => {
|
||||
let mut v = vec![TanObj::Circle(Circle::new(k.center, k.radius + r))];
|
||||
if (k.radius - r).abs() > EPS {
|
||||
v.push(TanObj::Circle(Circle::new(k.center, (k.radius - r).abs())));
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait InfDist {
|
||||
fn dist_inf(&self, p: Vec2) -> f64;
|
||||
}
|
||||
impl InfDist for Line {
|
||||
fn dist_inf(&self, p: Vec2) -> f64 {
|
||||
self.project(p).dist(p)
|
||||
}
|
||||
}
|
||||
|
||||
fn intersect_tan(a: &TanObj, b: &TanObj) -> Vec<Vec2> {
|
||||
match (a, b) {
|
||||
(TanObj::Line(x), TanObj::Line(y)) => line_line_infinite(x.a, x.b, y.a, y.b).map(|r| vec![r.0]).unwrap_or_default(),
|
||||
(TanObj::Line(l), TanObj::Circle(c)) | (TanObj::Circle(c), TanObj::Line(l)) => line_circle(l, c).into_iter().map(|x| x.0).collect(),
|
||||
(TanObj::Circle(x), TanObj::Circle(y)) => circle_circle(x, y),
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a candidate: how far its tangent points are from the picks.
|
||||
fn score(c: &Circle, objs: &[(TanObj, Vec2)]) -> f64 {
|
||||
objs.iter().map(|(o, p)| o.touch(c).dist(*p)).sum()
|
||||
}
|
||||
|
||||
/// Circle of radius `r` tangent to two objects, nearest the pick points.
|
||||
pub fn ttr(o1: TanObj, p1: Vec2, o2: TanObj, p2: Vec2, r: f64) -> Option<Circle> {
|
||||
if !(r.is_finite() && r > EPS) {
|
||||
return None;
|
||||
}
|
||||
let objs = [(o1, p1), (o2, p2)];
|
||||
let mut best: Option<(f64, Circle)> = None;
|
||||
for a in o1.offsets(r) {
|
||||
for b in o2.offsets(r) {
|
||||
for c in intersect_tan(&a, &b) {
|
||||
let ci = Circle::new(c, r);
|
||||
if !c.is_finite() || o1.residual(c, r).abs() > 1e-6 * r.max(1.0) || o2.residual(c, r).abs() > 1e-6 * r.max(1.0) {
|
||||
continue;
|
||||
}
|
||||
let sc = score(&ci, &objs);
|
||||
if best.is_none_or(|(b, _)| sc < b) {
|
||||
best = Some((sc, ci));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, c)| c)
|
||||
}
|
||||
|
||||
/// Solve a 3×3 linear system (Cramer's rule).
|
||||
fn solve3(m: [[f64; 3]; 3], b: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let det = |m: &[[f64; 3]; 3]| {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
};
|
||||
let d = det(&m);
|
||||
if !d.is_finite() || d.abs() < 1e-14 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0.0; 3];
|
||||
for (k, o) in out.iter_mut().enumerate() {
|
||||
let mut mk = m;
|
||||
for (row, bv) in mk.iter_mut().zip(b.iter()) {
|
||||
row[k] = *bv;
|
||||
}
|
||||
*o = det(&mk) / d;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A signed tangency equation f(x, y, r) = 0 with its gradient.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Eq3 {
|
||||
/// n·p + c = s r (unit normal n).
|
||||
Line { n: Vec2, c: f64, s: f64 },
|
||||
/// |p − k| = R + s r (s = ±1), or r − R when `enclose`.
|
||||
Circle { k: Vec2, rr: f64, s: f64, enclose: bool },
|
||||
}
|
||||
|
||||
impl Eq3 {
|
||||
fn eval(&self, x: [f64; 3]) -> (f64, [f64; 3]) {
|
||||
let p = Vec2::new(x[0], x[1]);
|
||||
let r = x[2];
|
||||
match *self {
|
||||
Eq3::Line { n, c, s } => (n.dot(p) + c - s * r, [n.x, n.y, -s]),
|
||||
Eq3::Circle { k, rr, s, enclose } => {
|
||||
let d = p - k;
|
||||
let l = d.len().max(1e-12);
|
||||
let g = [d.x / l, d.y / l];
|
||||
if enclose { (l - (r - rr), [g[0], g[1], -1.0]) } else { (l - (rr + s * r), [g[0], g[1], -s]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn eq_variants(o: &TanObj) -> Vec<Eq3> {
|
||||
match o {
|
||||
TanObj::Line(l) => {
|
||||
let n = (l.b - l.a).normalized().perp();
|
||||
let c = -n.dot(l.a);
|
||||
vec![Eq3::Line { n, c, s: 1.0 }, Eq3::Line { n, c, s: -1.0 }]
|
||||
}
|
||||
TanObj::Circle(k) => vec![
|
||||
Eq3::Circle { k: k.center, rr: k.radius, s: 1.0, enclose: false },
|
||||
Eq3::Circle { k: k.center, rr: k.radius, s: -1.0, enclose: false },
|
||||
Eq3::Circle { k: k.center, rr: k.radius, s: 0.0, enclose: true },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Circle tangent to three objects, nearest the pick points (Newton on all sign combinations).
|
||||
pub fn ttt(objs: [(TanObj, Vec2); 3]) -> Option<Circle> {
|
||||
let [(o1, p1), (o2, p2), (o3, p3)] = objs;
|
||||
let mut starts: Vec<[f64; 3]> = Vec::new();
|
||||
let cen = (p1 + p2 + p3) / 3.0;
|
||||
let rad = (cen.dist(p1) + cen.dist(p2) + cen.dist(p3)) / 3.0;
|
||||
starts.push([cen.x, cen.y, rad.max(1e-3)]);
|
||||
if let Some(c) = Circle::from_3_points(p1, p2, p3) {
|
||||
starts.push([c.center.x, c.center.y, c.radius]);
|
||||
}
|
||||
let mut best: Option<(f64, Circle)> = None;
|
||||
let scale = rad.max(p1.dist(p2)).max(1e-6);
|
||||
for e1 in eq_variants(&o1) {
|
||||
for e2 in eq_variants(&o2) {
|
||||
for e3 in eq_variants(&o3) {
|
||||
for st in &starts {
|
||||
let mut x = *st;
|
||||
let mut converged = false;
|
||||
for _ in 0..60 {
|
||||
let (f1, g1) = e1.eval(x);
|
||||
let (f2, g2) = e2.eval(x);
|
||||
let (f3, g3) = e3.eval(x);
|
||||
if f1.abs().max(f2.abs()).max(f3.abs()) < 1e-11 * scale {
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
let Some(dx) = solve3([g1, g2, g3], [-f1, -f2, -f3]) else { break };
|
||||
for (xi, di) in x.iter_mut().zip(dx) {
|
||||
*xi += di;
|
||||
}
|
||||
if !x.iter().all(|v| v.is_finite()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !converged || x[2] <= 1e-9 * scale || x[2] > 1e6 * scale {
|
||||
continue;
|
||||
}
|
||||
let c = Circle::new(Vec2::new(x[0], x[1]), x[2]);
|
||||
let sc = score(&c, &objs);
|
||||
if best.is_none_or(|(b, _)| sc < b - 1e-12) {
|
||||
best = Some((sc, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, c)| c)
|
||||
}
|
||||
|
||||
// ---------------- curves as segment chains ----------------
|
||||
|
||||
/// A curve flattened to lines/arcs (exact for lines, arcs, circles and polylines; tessellated for
|
||||
/// ellipses, splines and 3D polylines).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Chain {
|
||||
pub segs: Vec<Segment>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl Chain {
|
||||
pub fn of(k: &EntityKind) -> Option<Chain> {
|
||||
let tess = |pts: Vec<Vec2>, closed: bool| Chain {
|
||||
segs: pts.windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).filter(|s| s.len() > 1e-15).collect(),
|
||||
closed,
|
||||
};
|
||||
let c = match k {
|
||||
EntityKind::Line(l) => Chain { segs: vec![Segment::Line(Line::new(l.a.xy(), l.b.xy()))], closed: false },
|
||||
EntityKind::Arc(a) => {
|
||||
Chain { segs: vec![Segment::Arc { arc: Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }], closed: false }
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
let o = c.center.xy();
|
||||
Chain {
|
||||
segs: vec![
|
||||
Segment::Arc { arc: Arc::new(o, c.radius, 0.0, std::f64::consts::PI), ccw: true },
|
||||
Segment::Arc { arc: Arc::new(o, c.radius, std::f64::consts::PI, TAU), ccw: true },
|
||||
],
|
||||
closed: true,
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(p) => Chain { segs: Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments(), closed: p.closed },
|
||||
EntityKind::Polyline3d(p) => {
|
||||
let mut pts: Vec<Vec2> = p.points.iter().map(|q| q.xy()).collect();
|
||||
if p.closed
|
||||
&& let Some(f) = pts.first().copied()
|
||||
{
|
||||
pts.push(f);
|
||||
}
|
||||
tess(pts, p.closed)
|
||||
}
|
||||
EntityKind::Ellipse(e) => {
|
||||
let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end };
|
||||
let mut pts = Vec::new();
|
||||
ge.tessellate(e.major.xy().len() * 1e-4, &mut pts);
|
||||
tess(pts, ge.is_full())
|
||||
}
|
||||
EntityKind::Spline(sp) => tess(sp.tessellate(1e-4), sp.closed),
|
||||
_ => return None,
|
||||
};
|
||||
(!c.segs.is_empty()).then_some(c)
|
||||
}
|
||||
pub fn len(&self) -> f64 {
|
||||
self.segs.iter().map(Segment::len).sum()
|
||||
}
|
||||
pub fn start(&self) -> Option<Vec2> {
|
||||
self.segs.first().map(Segment::start)
|
||||
}
|
||||
pub fn end(&self) -> Option<Vec2> {
|
||||
self.segs.last().map(Segment::end)
|
||||
}
|
||||
/// Point and unit tangent at arc length `d` from the start (clamped).
|
||||
pub fn at_length(&self, d: f64) -> Option<(Vec2, Vec2)> {
|
||||
let mut acc = 0.0;
|
||||
for s in &self.segs {
|
||||
let l = s.len();
|
||||
if d <= acc + l + 1e-12 && l > 0.0 {
|
||||
let t = ((d - acc) / l).clamp(0.0, 1.0);
|
||||
return Some((s.at(t), s.tangent(t)));
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
self.segs.last().map(|s| (s.end(), s.tangent(1.0)))
|
||||
}
|
||||
/// Arc length of the point on the chain nearest `p`.
|
||||
pub fn length_of(&self, p: Vec2) -> f64 {
|
||||
let mut acc = 0.0;
|
||||
let mut best = (f64::INFINITY, 0.0);
|
||||
for s in &self.segs {
|
||||
let c = s.closest(p);
|
||||
let dd = c.dist(p);
|
||||
if dd < best.0 {
|
||||
best = (dd, acc + seg_param(s, c) * s.len());
|
||||
}
|
||||
acc += s.len();
|
||||
}
|
||||
best.1
|
||||
}
|
||||
/// Reversed copy.
|
||||
pub fn reversed(&self) -> Chain {
|
||||
Chain { segs: self.segs.iter().rev().map(Segment::reversed).collect(), closed: self.closed }
|
||||
}
|
||||
/// Polyline vertices for the chain.
|
||||
pub fn vertices(&self) -> Vec<PolyVertex> {
|
||||
let mut vs: Vec<PolyVertex> = self
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| PolyVertex {
|
||||
p: s.start(),
|
||||
bulge: match s {
|
||||
Segment::Line(_) => 0.0,
|
||||
Segment::Arc { arc, ccw } => cadcraft_geom::arc_to_bulge(if *ccw { arc.sweep() } else { -arc.sweep() }),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
if !self.closed
|
||||
&& let Some(e) = self.end()
|
||||
{
|
||||
vs.push(PolyVertex::new(e));
|
||||
}
|
||||
vs
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameter (0..1 along travel) of a point on a segment.
|
||||
pub fn seg_param(s: &Segment, p: Vec2) -> f64 {
|
||||
match s {
|
||||
Segment::Line(l) => l.param_of(p).clamp(0.0, 1.0),
|
||||
Segment::Arc { arc, ccw } => {
|
||||
let t = (cadcraft_geom::norm_angle(arc.center.angle_to(p) - arc.start) / arc.sweep().max(1e-15)).clamp(0.0, 1.0);
|
||||
if *ccw { t } else { 1.0 - t }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from `p` to an entity's geometry (prims; text and blocks by insertion point).
|
||||
pub fn kind_distance(k: &EntityKind, p: Vec2) -> f64 {
|
||||
k.prims()
|
||||
.iter()
|
||||
.map(|pr| match pr {
|
||||
Prim::Seg(s) => s.closest(p).dist(p),
|
||||
Prim::Circle(c) => c.closest(p).dist(p),
|
||||
Prim::Ellipse(e) => e.closest(p).dist(p),
|
||||
Prim::Spline(sp) => {
|
||||
let pts = sp.tessellate(1e-3);
|
||||
pts.windows(2).filter_map(|w| Some(Line::new(*w.first()?, *w.get(1)?).closest(p).dist(p))).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
Prim::Point(q) => q.dist(p),
|
||||
Prim::Infinite { base, dir, .. } => Line::new(*base, *base + *dir).project(p).dist(p),
|
||||
Prim::Fill(f) => {
|
||||
let n = f.len();
|
||||
(0..n).filter_map(|i| Some(Line::new(*f.get(i)?, *f.get((i + 1) % n)?).closest(p).dist(p))).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
// ---------------- splines ----------------
|
||||
|
||||
/// Insert knot `t` once (Boehm), in homogeneous coordinates.
|
||||
fn insert_knot(sp: &Spline, t: f64) -> Option<Spline> {
|
||||
if !sp.is_valid() {
|
||||
return None;
|
||||
}
|
||||
let p = sp.degree;
|
||||
let n = sp.control.len();
|
||||
let w = |i: usize| if sp.weights.is_empty() { 1.0 } else { sp.weights.get(i).copied().unwrap_or(1.0) };
|
||||
// Span k with knots[k] <= t < knots[k+1].
|
||||
let (lo, hi) = sp.domain();
|
||||
let t = t.clamp(lo, hi);
|
||||
let mut k = p;
|
||||
for i in p..n {
|
||||
if sp.knots.get(i).copied()? <= t {
|
||||
k = i;
|
||||
}
|
||||
}
|
||||
let mut ctrl: Vec<(Vec2, f64)> = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
let pt = if i + p <= k {
|
||||
let c = sp.control.get(i).copied()?;
|
||||
(c * w(i), w(i))
|
||||
} else if i > k {
|
||||
let c = sp.control.get(i - 1).copied()?;
|
||||
(c * w(i - 1), w(i - 1))
|
||||
} else {
|
||||
let ki = sp.knots.get(i).copied()?;
|
||||
let kip = sp.knots.get(i + p).copied()?;
|
||||
let a = if (kip - ki).abs() < 1e-300 { 0.0 } else { (t - ki) / (kip - ki) };
|
||||
let c0 = sp.control.get(i - 1).copied()?;
|
||||
let c1 = sp.control.get(i).copied()?;
|
||||
let (h0, w0) = (c0 * w(i - 1), w(i - 1));
|
||||
let (h1, w1) = (c1 * w(i), w(i));
|
||||
(h0 * (1.0 - a) + h1 * a, w0 * (1.0 - a) + w1 * a)
|
||||
};
|
||||
ctrl.push(pt);
|
||||
}
|
||||
let mut knots = sp.knots.clone();
|
||||
knots.insert(k + 1, t);
|
||||
let rational = !sp.weights.is_empty();
|
||||
Some(Spline {
|
||||
degree: p,
|
||||
knots,
|
||||
control: ctrl.iter().map(|(h, w)| if w.abs() > 1e-300 { *h / *w } else { *h }).collect(),
|
||||
weights: if rational { ctrl.iter().map(|(_, w)| *w).collect() } else { Vec::new() },
|
||||
fit: Vec::new(),
|
||||
closed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Split a spline at parameter `t` into (left, right). `None` at the ends.
|
||||
pub fn split_spline(sp: &Spline, t: f64) -> Option<(Spline, Spline)> {
|
||||
let (lo, hi) = sp.domain();
|
||||
if !(t > lo + 1e-12 && t < hi - 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let mut s = sp.clone();
|
||||
s.fit.clear();
|
||||
let mult = |s: &Spline| s.knots.iter().filter(|k| (**k - t).abs() < 1e-12).count();
|
||||
let mut guard = 0;
|
||||
while mult(&s) < s.degree && guard < 64 {
|
||||
s = insert_knot(&s, t)?;
|
||||
guard += 1;
|
||||
}
|
||||
let p = s.degree;
|
||||
// Knots equal to t now have multiplicity p; first index of t:
|
||||
let first = s.knots.iter().position(|k| (*k - t).abs() < 1e-12)?;
|
||||
// Left: control points 0..first (count = first), knots 0..first+p ... + t
|
||||
let left_n = first;
|
||||
let mut lk: Vec<f64> = s.knots.get(..first)?.to_vec();
|
||||
lk.extend(std::iter::repeat_n(t, p + 1));
|
||||
let lc = s.control.get(..left_n)?.to_vec();
|
||||
let lw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(..left_n)?.to_vec() };
|
||||
let right_start = first.checked_sub(1)?;
|
||||
let mut rk = vec![t; p + 1];
|
||||
rk.extend(s.knots.get(first + p..)?.iter().copied());
|
||||
let rc = s.control.get(right_start..)?.to_vec();
|
||||
let rw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(right_start..)?.to_vec() };
|
||||
let l = Spline { degree: p, knots: lk, control: lc, weights: lw, fit: Vec::new(), closed: false };
|
||||
let r = Spline { degree: p, knots: rk, control: rc, weights: rw, fit: Vec::new(), closed: false };
|
||||
(l.is_valid() && r.is_valid()).then_some((l, r))
|
||||
}
|
||||
|
||||
/// The part of a spline between parameters `a < b`.
|
||||
pub fn sub_spline(sp: &Spline, a: f64, b: f64) -> Option<Spline> {
|
||||
let (lo, hi) = sp.domain();
|
||||
let mut s = sp.clone();
|
||||
s.fit.clear();
|
||||
s.closed = false;
|
||||
if b < hi - 1e-12 {
|
||||
s = split_spline(&s, b)?.0;
|
||||
}
|
||||
if a > lo + 1e-12 {
|
||||
s = split_spline(&s, a)?.1;
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Parameter of the point on the spline nearest `p`.
|
||||
pub fn spline_param_of(sp: &Spline, p: Vec2) -> f64 {
|
||||
let (lo, hi) = sp.domain();
|
||||
let n = 400;
|
||||
let mut best = (f64::INFINITY, lo);
|
||||
for i in 0..=n {
|
||||
let t = lo + (hi - lo) * i as f64 / n as f64;
|
||||
let d = sp.eval(t).dist(p);
|
||||
if d < best.0 {
|
||||
best = (d, t);
|
||||
}
|
||||
}
|
||||
let step = (hi - lo) / n as f64;
|
||||
let (mut a, mut b) = ((best.1 - step).max(lo), (best.1 + step).min(hi));
|
||||
for _ in 0..60 {
|
||||
let m1 = a + (b - a) / 3.0;
|
||||
let m2 = b - (b - a) / 3.0;
|
||||
if sp.eval(m1).dist(p) < sp.eval(m2).dist(p) {
|
||||
b = m2;
|
||||
} else {
|
||||
a = m1;
|
||||
}
|
||||
}
|
||||
(a + b) / 2.0
|
||||
}
|
||||
|
||||
/// Reverse a spline's direction (control, weights, fit points and knots).
|
||||
pub fn reverse_spline(sp: &Spline) -> Spline {
|
||||
let (lo, hi) = (sp.knots.first().copied().unwrap_or(0.0), sp.knots.last().copied().unwrap_or(1.0));
|
||||
Spline {
|
||||
degree: sp.degree,
|
||||
knots: sp.knots.iter().rev().map(|k| lo + hi - k).collect(),
|
||||
control: sp.control.iter().rev().copied().collect(),
|
||||
weights: sp.weights.iter().rev().copied().collect(),
|
||||
fit: sp.fit.iter().rev().copied().collect(),
|
||||
closed: sp.closed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse an LWPOLYLINE's vertex order, keeping its shape (bulges and widths move with segments).
|
||||
pub fn reverse_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
|
||||
let n = vs.len();
|
||||
if n < 2 {
|
||||
return vs.to_vec();
|
||||
}
|
||||
(0..n)
|
||||
.map(|k| {
|
||||
let p = vs.get(n - 1 - k).map(|v| v.p).unwrap_or_default();
|
||||
let seg = (2 * n - 2 - k) % n; // old segment index reversed into new segment k
|
||||
let has_seg = closed || k + 1 < n;
|
||||
match (has_seg, vs.get(seg)) {
|
||||
(true, Some(o)) => PolyVertex { p, bulge: -o.bulge, start_width: o.end_width, end_width: o.start_width },
|
||||
_ => PolyVertex::new(p),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arc_builders() {
|
||||
let a = arc_sca(Vec2::new(1.0, 0.0), Vec2::ZERO, std::f64::consts::FRAC_PI_2).unwrap();
|
||||
assert!(a.end_point().near(Vec2::new(0.0, 1.0), 1e-9));
|
||||
let b = arc_sea(Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), std::f64::consts::PI).unwrap();
|
||||
assert!(b.center.near(Vec2::ZERO, 1e-9));
|
||||
assert!(b.mid_point().near(Vec2::new(0.0, 1.0), 1e-9));
|
||||
let c = arc_sed(Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), Vec2::Y).unwrap();
|
||||
assert!(c.mid_point().near(Vec2::new(0.0, 1.0), 1e-9));
|
||||
let d = arc_ser(Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), 1.0).unwrap();
|
||||
assert!(d.center.near(Vec2::ZERO, 1e-9));
|
||||
let e = arc_scl(Vec2::new(1.0, 0.0), Vec2::ZERO, 2f64.sqrt()).unwrap();
|
||||
assert!(e.end_point().near(Vec2::new(0.0, 1.0), 1e-9));
|
||||
assert!(arc_scl(Vec2::new(1.0, 0.0), Vec2::ZERO, 5.0).is_none());
|
||||
assert!((bulge_through(Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0)) - 1.0).abs() < 1e-9);
|
||||
assert!((bulge_through(Vec2::new(-1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(1.0, 0.0)) + 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_circles() {
|
||||
let l1 = TanObj::Line(Line::new(Vec2::ZERO, Vec2::new(10.0, 0.0)));
|
||||
let l2 = TanObj::Line(Line::new(Vec2::ZERO, Vec2::new(0.0, 10.0)));
|
||||
let c = ttr(l1, Vec2::new(3.0, 0.0), l2, Vec2::new(0.0, 3.0), 1.0).unwrap();
|
||||
assert!(c.center.near(Vec2::new(1.0, 1.0), 1e-9));
|
||||
let l3 = TanObj::Line(Line::new(Vec2::new(4.0, 0.0), Vec2::new(0.0, 3.0)));
|
||||
let inc = ttt([(l1, Vec2::new(2.0, 0.0)), (l2, Vec2::new(0.0, 1.0)), (l3, Vec2::new(2.0, 1.5))]).unwrap();
|
||||
assert!(inc.center.near(Vec2::new(1.0, 1.0), 1e-6), "{inc:?}");
|
||||
assert!((inc.radius - 1.0).abs() < 1e-6);
|
||||
let k = TanObj::Circle(Circle::new(Vec2::new(5.0, 0.0), 2.0));
|
||||
let c2 = ttr(l2, Vec2::new(0.0, 0.0), k, Vec2::new(3.0, 0.0), 1.5).unwrap();
|
||||
assert!((c2.center.x - 1.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_split_and_reverse() {
|
||||
let sp = Spline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 2.0), Vec2::new(4.0, 0.0)], 3);
|
||||
let (lo, hi) = sp.domain();
|
||||
let t = lo + (hi - lo) * 0.3;
|
||||
let (l, r) = split_spline(&sp, t).unwrap();
|
||||
let p = sp.eval(t);
|
||||
assert!(l.eval(l.domain().1).near(p, 1e-9));
|
||||
assert!(r.eval(r.domain().0).near(p, 1e-9));
|
||||
assert!(r.eval(r.domain().1).near(Vec2::new(4.0, 0.0), 1e-9));
|
||||
let mid = sp.eval(lo + (hi - lo) * 0.6);
|
||||
assert!(r.eval(r.domain().0 + (r.domain().1 - r.domain().0) * (0.3 / 0.7)).near(mid, 1e-9));
|
||||
let rv = reverse_spline(&sp);
|
||||
assert!(rv.eval(rv.domain().0).near(Vec2::new(4.0, 0.0), 1e-9));
|
||||
assert!((spline_param_of(&sp, p) - t).abs() < 1e-6);
|
||||
let sub = sub_spline(&sp, lo + 0.2 * (hi - lo), lo + 0.8 * (hi - lo)).unwrap();
|
||||
assert!(sub.eval(sub.domain().0).near(sp.eval(lo + 0.2 * (hi - lo)), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_polyline_vertices() {
|
||||
let vs = vec![PolyVertex::with_bulge(Vec2::ZERO, 0.5), PolyVertex::new(Vec2::new(1.0, 0.0)), PolyVertex::new(Vec2::new(1.0, 1.0))];
|
||||
let r = reverse_vertices(&vs, false);
|
||||
assert!(r[0].p.near(Vec2::new(1.0, 1.0), 0.0));
|
||||
assert_eq!(r[0].bulge, 0.0);
|
||||
assert!((r[1].bulge + 0.5).abs() < 1e-12);
|
||||
assert_eq!(r[2].bulge, 0.0);
|
||||
let r2 = reverse_vertices(&r, false);
|
||||
assert_eq!(r2, vs);
|
||||
}
|
||||
}
|
||||
+30
-193
@@ -31,12 +31,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["Draw", "Arc", "3 Points"])
|
||||
.alias(&["a"])
|
||||
.params("{p1, p2, p3} | {center, radius, start, end (degrees)} | {start, center, end}")
|
||||
.interactive(|_| Ok(Box::new(ArcM::default()))),
|
||||
CommandSpec::new("rectang", "Rectangle", run_rectang)
|
||||
.interactive(|_| Ok(super::draw2::arc_machine())),
|
||||
CommandSpec::new("rectang", "Rectangle", super::draw2::run_rectang2)
|
||||
.menu(&["Draw", "Rectangle"])
|
||||
.alias(&["rec", "rectangle"])
|
||||
.params("{p1, p2, fillet?, chamfer?: [d1, d2], width?}")
|
||||
.interactive(|_| Ok(Box::new(RectM::default()))),
|
||||
.params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?}")
|
||||
.interactive(|_| Ok(Box::new(super::draw2::RectM2::default()))),
|
||||
CommandSpec::new("polygon", "Polygon", run_polygon)
|
||||
.menu(&["Draw", "Polygon"])
|
||||
.alias(&["pol"])
|
||||
@@ -56,11 +56,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["Draw", "Point", "Multiple Point"])
|
||||
.params("{points: [...]}")
|
||||
.interactive(|_| Ok(Box::new(PointM { multiple: true }))),
|
||||
CommandSpec::new("xline", "Construction Line", run_xline)
|
||||
CommandSpec::new("xline", "Construction Line", super::draw2::run_xline2)
|
||||
.menu(&["Draw", "Construction Line"])
|
||||
.alias(&["xl"])
|
||||
.params("{base, through} | {base, angle (degrees)}")
|
||||
.interactive(|_| Ok(Box::new(XlineM { ray: false, base: None, fixed_angle: None }))),
|
||||
.params("{base, through} | {base, angle (degrees)} | {base, hor|ver: true} | {vertex, start, end} (bisect) | {handle, distance, side} | {handle, through} (offset)")
|
||||
.interactive(|_| Ok(Box::new(super::draw2::XlineM2::default()))),
|
||||
CommandSpec::new("ray", "Ray", run_ray)
|
||||
.menu(&["Draw", "Ray"])
|
||||
.params("{base, through}")
|
||||
@@ -175,22 +175,6 @@ fn run_arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
added(s.add_entity(arc(&a))?)
|
||||
}
|
||||
|
||||
fn run_rectang(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("rectang", p, "p1")?;
|
||||
let b = point_req("rectang", p, "p2")?;
|
||||
let ch = p
|
||||
.get("chamfer")
|
||||
.and_then(Value::as_array)
|
||||
.map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let vs = rect_with_corners(a, b, f64_or(p, "fillet", 0.0), ch);
|
||||
let mut k = lwpoly(vs, true);
|
||||
if let EntityKind::LwPolyline(pl) = &mut k {
|
||||
pl.const_width = f64_or(p, "width", 0.0).max(0.0);
|
||||
}
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
|
||||
fn run_polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = p.get("sides").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
if !(3..=1024).contains(&n) {
|
||||
@@ -238,18 +222,6 @@ fn run_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "handles": hs }))
|
||||
}
|
||||
|
||||
fn run_xline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = point_req("xline", p, "base")?;
|
||||
let dir = match point_param(p, "through") {
|
||||
Some(t) => (t - b).normalized(),
|
||||
None => Vec2::from_angle(f64_req("xline", p, "angle")?.to_radians()),
|
||||
};
|
||||
if dir == Vec2::ZERO {
|
||||
return Err(bad("xline", "through point equals base"));
|
||||
}
|
||||
added(s.add_entity(EntityKind::XLine(RayLine { base: v3(b), dir: v3(dir) }))?)
|
||||
}
|
||||
|
||||
fn run_ray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = point_req("ray", p, "base")?;
|
||||
let t = point_req("ray", p, "through")?;
|
||||
@@ -581,6 +553,8 @@ struct CircleM {
|
||||
mode: u8, // 0 center-radius, 1 = 3P, 2 = 2P, 3 = TTR
|
||||
pts: Vec<Vec2>,
|
||||
diameter: bool,
|
||||
/// The `Ttr` option hands over to the tangent-circle machine.
|
||||
delegate: Option<Box<dyn Interactive>>,
|
||||
}
|
||||
|
||||
impl Interactive for CircleM {
|
||||
@@ -588,6 +562,9 @@ impl Interactive for CircleM {
|
||||
"CIRCLE"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if let Some(d) = &self.delegate {
|
||||
return d.prompt(s);
|
||||
}
|
||||
let n = self.pts.len();
|
||||
let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
|
||||
match (self.mode, n) {
|
||||
@@ -611,6 +588,9 @@ impl Interactive for CircleM {
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(d) = &mut self.delegate {
|
||||
return d.input(s, i);
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) => {
|
||||
self.mode = match k.as_str() {
|
||||
@@ -621,8 +601,8 @@ impl Interactive for CircleM {
|
||||
self.mode
|
||||
}
|
||||
_ => {
|
||||
s.echo("Ttr: not available yet");
|
||||
self.mode
|
||||
self.delegate = Some(Box::new(super::draw2::TanCircleM::new(true)));
|
||||
3
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
@@ -667,7 +647,10 @@ impl Interactive for CircleM {
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some(d) = &self.delegate {
|
||||
return d.preview(s, c);
|
||||
}
|
||||
match (self.mode, self.pts.as_slice()) {
|
||||
(0, [center]) => {
|
||||
let r = if self.diameter { center.dist(c) / 2.0 } else { center.dist(c) };
|
||||
@@ -689,161 +672,6 @@ fn finish_circle(s: &mut Session, c: Circle) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ArcM {
|
||||
pts: Vec<Vec2>,
|
||||
center_first: bool,
|
||||
center: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl Interactive for ArcM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ARC"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.center_first {
|
||||
return match (self.center, self.pts.len()) {
|
||||
(None, _) => Prompt::new("Specify center point of arc", Accept::POINT),
|
||||
(Some(c), 0) => Prompt::new("Specify start point of arc", Accept::POINT).base(c),
|
||||
(Some(c), _) => {
|
||||
Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).kw(&["Angle", "chord Length"]).base(c)
|
||||
}
|
||||
};
|
||||
}
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify start point of arc", Accept::POINT).kw(&["Center"]),
|
||||
1 => Prompt::new("Specify second point of arc", Accept::POINT).kw(&["Center", "End"]).base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new("Specify end point of arc", Accept::POINT).base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Keyword(k) if k == "Center" => {
|
||||
self.center_first = true;
|
||||
if !self.pts.is_empty() {
|
||||
// Start point already given: S,C,E.
|
||||
self.center = None;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
if self.center_first {
|
||||
if self.center.is_none() && self.pts.is_empty() {
|
||||
self.center = Some(p);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.center.is_none() {
|
||||
self.center = Some(p);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.pts.push(p);
|
||||
if let (Some(c), 2) = (self.center, self.pts.len()) {
|
||||
s.add_entity(arc(&Arc::from_start_center_end(self.pts[0], c, self.pts[1])))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.pts.push(p);
|
||||
if self.pts.len() == 3 {
|
||||
match Arc::from_3_points(self.pts[0], self.pts[1], self.pts[2]) {
|
||||
Some(a) => {
|
||||
s.add_entity(arc(&a))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
None => {
|
||||
self.pts.pop();
|
||||
return Err(crate::EngineError::Other("Points are collinear.".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.center_first {
|
||||
return match (self.center, self.pts.as_slice()) {
|
||||
(Some(ctr), [st]) => vec![arc(&Arc::from_start_center_end(*st, ctr, c))],
|
||||
(Some(ctr), []) => vec![line(ctr, c)],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
}
|
||||
match self.pts.as_slice() {
|
||||
[a, b] => Arc::from_3_points(*a, *b, c).map(|ar| vec![arc(&ar)]).unwrap_or_else(|| vec![line(*a, c)]),
|
||||
[a] => vec![line(*a, c)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RectM {
|
||||
first: Option<Vec2>,
|
||||
fillet: f64,
|
||||
chamfer: (f64, f64),
|
||||
asking: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Interactive for RectM {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECTANG"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(a) = self.asking {
|
||||
return Prompt::new(a, Accept::NUMBER);
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Chamfer", "Elevation", "Fillet", "Thickness", "Width"]),
|
||||
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).base(p),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking {
|
||||
if let Input::Text(t) = &i {
|
||||
let v = number(t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
|
||||
match a {
|
||||
"Specify fillet radius for rectangles" => self.fillet = v.max(0.0),
|
||||
"Specify first chamfer distance for rectangles" => {
|
||||
self.chamfer.0 = v.max(0.0);
|
||||
self.asking = Some("Specify second chamfer distance for rectangles");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
_ => self.chamfer.1 = v.max(0.0),
|
||||
}
|
||||
}
|
||||
self.asking = None;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Fillet" => self.asking = Some("Specify fillet radius for rectangles"),
|
||||
"Chamfer" => self.asking = Some("Specify first chamfer distance for rectangles"),
|
||||
_ => s.echo(format!("{k}: not available yet")),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => match self.first {
|
||||
None => {
|
||||
self.first = Some(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some(a) => {
|
||||
s.add_entity(lwpoly(rect_with_corners(a, p, self.fillet, self.chamfer), true))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
},
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.first.map(|a| vec![lwpoly(rect_with_corners(a, c, self.fillet, self.chamfer), true)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PolygonM {
|
||||
sides: Option<usize>,
|
||||
@@ -932,6 +760,11 @@ impl Interactive for PolygonM {
|
||||
}
|
||||
}
|
||||
|
||||
/// ELLIPSE prompt machine starting in axis-end or center mode, optionally for an elliptical arc.
|
||||
pub(crate) fn ellipse_machine(center: bool, arc: bool) -> Box<dyn Interactive> {
|
||||
Box::new(EllipseM { center_mode: center, arc_mode: arc, ..Default::default() })
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EllipseM {
|
||||
center_mode: bool,
|
||||
@@ -1036,6 +869,10 @@ impl Interactive for EllipseM {
|
||||
if let Some((c, m)) = self.axis()
|
||||
&& let Some(e) = self.build(c + m.normalized().perp() * d)
|
||||
{
|
||||
if self.arc_mode {
|
||||
self.ellipse = Some(e);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
|
||||
center: v3(e.center),
|
||||
major: v3(e.major),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,680 @@
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_geom::{Arc, Vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Input, Session};
|
||||
|
||||
fn last(s: &Session) -> EntityKind {
|
||||
s.doc().unwrap().model.last().unwrap().kind.clone()
|
||||
}
|
||||
|
||||
fn kinds(s: &Session) -> Vec<&'static str> {
|
||||
s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect()
|
||||
}
|
||||
|
||||
fn as_arc(k: &EntityKind) -> Arc {
|
||||
match k {
|
||||
EntityKind::Arc(a) => Arc::new(a.center.xy(), a.radius, a.start, a.end),
|
||||
other => panic!("not an arc: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_circle(k: &EntityKind) -> (Vec2, f64) {
|
||||
match k {
|
||||
EntityKind::Circle(c) => (c.center.xy(), c.radius),
|
||||
other => panic!("not a circle: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn h(r: &serde_json::Value) -> String {
|
||||
r["handle"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn near(a: Vec2, x: f64, y: f64) -> bool {
|
||||
a.near(Vec2::new(x, y), 1e-6)
|
||||
}
|
||||
|
||||
// ---------------- ARC ----------------
|
||||
|
||||
#[test]
|
||||
fn arc_variants_json() {
|
||||
let mut s = Session::new();
|
||||
let quarter =
|
||||
|a: &Arc| near(a.center, 0.0, 0.0) && (a.radius - 1.0).abs() < 1e-9 && near(a.start_point(), 1.0, 0.0) && near(a.end_point(), 0.0, 1.0);
|
||||
for (id, p) in [
|
||||
("arc.sce", json!({"start": [1, 0], "center": [0, 0], "end": [0, 5]})),
|
||||
("arc.sca", json!({"start": [1, 0], "center": [0, 0], "angle": 90})),
|
||||
("arc.scl", json!({"start": [1, 0], "center": [0, 0], "length": 2f64.sqrt()})),
|
||||
("arc.sea", json!({"start": [1, 0], "end": [0, 1], "angle": 90})),
|
||||
("arc.sed", json!({"start": [1, 0], "end": [0, 1], "direction": 90})),
|
||||
("arc.ser", json!({"start": [1, 0], "end": [0, 1], "radius": 1})),
|
||||
("arc.cse", json!({"center": [0, 0], "start": [1, 0], "end": [0, 3]})),
|
||||
("arc.csa", json!({"center": [0, 0], "start": [1, 0], "angle": 90})),
|
||||
("arc.csl", json!({"center": [0, 0], "start": [1, 0], "length": 2f64.sqrt()})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert!(quarter(&as_arc(&last(&s))), "{id}: {:?}", last(&s));
|
||||
}
|
||||
// Negative angle: clockwise from the start.
|
||||
s.execute("arc.sca", &json!({"start": [1, 0], "center": [0, 0], "angle": -90})).unwrap();
|
||||
let a = as_arc(&last(&s));
|
||||
assert!(near(a.start_point(), 0.0, -1.0) && near(a.end_point(), 1.0, 0.0));
|
||||
// Negative radius / chord: the major arc.
|
||||
s.execute("arc.ser", &json!({"start": [1, 0], "end": [0, 1], "radius": -1})).unwrap();
|
||||
assert!((as_arc(&last(&s)).sweep() - 1.5 * std::f64::consts::PI).abs() < 1e-9);
|
||||
s.execute("arc.scl", &json!({"start": [1, 0], "center": [0, 0], "length": -(2f64.sqrt())})).unwrap();
|
||||
assert!((as_arc(&last(&s)).sweep() - 1.5 * std::f64::consts::PI).abs() < 1e-9);
|
||||
// Direction as a vector, and bad input.
|
||||
s.execute("arc.sed", &json!({"start": [1, 0], "end": [0, 1], "direction": [0, 1]})).unwrap();
|
||||
assert!(quarter(&as_arc(&last(&s))));
|
||||
assert!(s.execute("arc.ser", &json!({"start": [0, 0], "end": [10, 0], "radius": 1})).is_err());
|
||||
assert!(s.execute("arc.scl", &json!({"start": [1, 0], "center": [0, 0], "length": 5})).is_err());
|
||||
assert!(s.execute("arc.sea", &json!({"start": [1, 0], "end": [1, 0], "angle": 90})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_variants_command_line() {
|
||||
for (cmd, expect_quarter) in [
|
||||
("arc.sce 1,0 0,0 0,2", true),
|
||||
("arc.sca 1,0 0,0 90", true),
|
||||
("arc.scl 1,0 0,0 1.41421356237", true),
|
||||
("arc.sea 1,0 0,1 90", true),
|
||||
("arc.sed 1,0 0,1 90", true),
|
||||
("arc.ser 1,0 0,1 1", true),
|
||||
("arc.cse 0,0 1,0 0,4", true),
|
||||
("arc.csa 0,0 1,0 90", true),
|
||||
("arc.csl 0,0 1,0 1.41421356237", true),
|
||||
// Angle/direction given by a point.
|
||||
("arc.sed 1,0 0,1 1,5", true),
|
||||
("arc.csa 0,0 1,0 0,7", true),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.cmdline(cmd).unwrap();
|
||||
assert!(s.running.is_none(), "{cmd} still running: {}", s.prompt_text());
|
||||
let a = as_arc(&last(&s));
|
||||
if expect_quarter {
|
||||
assert!(near(a.center, 0.0, 0.0) && near(a.start_point(), 1.0, 0.0) && near(a.end_point(), 0.0, 1.0), "{cmd}: {a:?}");
|
||||
}
|
||||
assert_eq!(s.state().unwrap().undo.len(), 1, "{cmd}: one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_continue_from_last_line() {
|
||||
let mut s = Session::new();
|
||||
// Nothing to continue from: the command ends politely.
|
||||
s.cmdline("arc.continue").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.execute("arc.continue", &json!({"end": [2, 1]})).is_err());
|
||||
s.cmdline("line 0,0 1,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("arc.continue 2,1").unwrap();
|
||||
let a = as_arc(&last(&s));
|
||||
assert!(near(a.center, 1.0, 1.0), "{a:?}");
|
||||
// JSON continues from that arc's end (2,1) heading +Y.
|
||||
s.execute("arc.continue", &json!({"end": [1, 2]})).unwrap();
|
||||
let b = as_arc(&last(&s));
|
||||
assert!(near(b.center, 1.0, 1.0), "{b:?}");
|
||||
assert!(s.execute("arc.continue", &json!({"end": [-5, 2]})).is_err(), "end on the tangent line");
|
||||
// Explicit start and direction.
|
||||
s.execute("arc.continue", &json!({"start": [0, 0], "direction": 0, "end": [1, 1]})).unwrap();
|
||||
assert!(near(as_arc(&last(&s)).center, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// ---------------- CIRCLE ----------------
|
||||
|
||||
#[test]
|
||||
fn circle_variants_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle.cd", &json!({"center": [1, 1], "diameter": 4})).unwrap();
|
||||
assert_eq!(as_circle(&last(&s)).1, 2.0);
|
||||
s.execute("circle.2p", &json!({"p1": [0, 0], "p2": [6, 0]})).unwrap();
|
||||
let (c, r) = as_circle(&last(&s));
|
||||
assert!(near(c, 3.0, 0.0) && (r - 3.0).abs() < 1e-9);
|
||||
s.execute("circle.3p", &json!({"p1": [1, 0], "p2": [0, 1], "p3": [-1, 0]})).unwrap();
|
||||
let (c, r) = as_circle(&last(&s));
|
||||
assert!(near(c, 0.0, 0.0) && (r - 1.0).abs() < 1e-9);
|
||||
assert!(s.execute("circle.3p", &json!({"p1": [0, 0], "p2": [1, 0], "p3": [2, 0]})).is_err());
|
||||
assert!(s.execute("circle.cd", &json!({"center": [0, 0], "diameter": 0})).is_err());
|
||||
|
||||
s.cmdline("circle.cd 5,5 3").unwrap();
|
||||
assert_eq!(as_circle(&last(&s)).1, 1.5);
|
||||
s.cmdline("circle.cd 5,5 5,9").unwrap();
|
||||
assert_eq!(as_circle(&last(&s)).1, 2.0);
|
||||
s.cmdline("circle.2p 0,0 0,4").unwrap();
|
||||
assert!(near(as_circle(&last(&s)).0, 0.0, 2.0));
|
||||
s.cmdline("circle.3p 0,0 2,2 4,0").unwrap();
|
||||
assert!(near(as_circle(&last(&s)).0, 2.0, 0.0));
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
fn line_h(s: &mut Session, pts: serde_json::Value) -> String {
|
||||
s.execute("line", &json!({ "points": pts })).unwrap()["handles"][0].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn corner(s: &mut Session) -> (String, String) {
|
||||
(line_h(s, json!([[0, 0], [10, 0]])), line_h(s, json!([[0, 0], [0, 10]])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circle_ttr_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = corner(&mut s);
|
||||
s.execute("circle.ttr", &json!({"h1": a, "p1": [3, 0], "h2": b, "p2": [0, 3], "radius": 1})).unwrap();
|
||||
let (c, r) = as_circle(&last(&s));
|
||||
assert!(near(c, 1.0, 1.0) && (r - 1.0).abs() < 1e-9);
|
||||
// Circle-circle and line-circle.
|
||||
let k = h(&s.execute("circle", &json!({"center": [20, 0], "radius": 2})).unwrap());
|
||||
let k2 = h(&s.execute("circle", &json!({"center": [26, 0], "radius": 2})).unwrap());
|
||||
s.execute("circle.ttr", &json!({"h1": k, "p1": [21, 1], "h2": k2, "p2": [25, 1], "radius": 2})).unwrap();
|
||||
let (c, r) = as_circle(&last(&s));
|
||||
assert!((c.x - 23.0).abs() < 1e-9 && c.y > 0.0 && (r - 2.0).abs() < 1e-9, "{c:?}");
|
||||
assert!(s.execute("circle.ttr", &json!({"h1": k, "p1": [21, 1], "h2": k2, "p2": [25, 1], "radius": 0.1})).is_err());
|
||||
// Command line: picks, then the radius.
|
||||
s.cmdline("circle.ttr 5,0 0,5 2").unwrap();
|
||||
let (c, r) = as_circle(&last(&s));
|
||||
assert!(near(c, 2.0, 2.0) && (r - 2.0).abs() < 1e-9, "{c:?} {r}");
|
||||
// CIRCLE's Ttr option.
|
||||
s.cmdline("circle ttr 6,0 0,6 0.5").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let (c, r) = as_circle(&last(&s));
|
||||
assert!(near(c, 0.5, 0.5) && (r - 0.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circle_ttt_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = corner(&mut s);
|
||||
let hyp = s.execute("line", &json!({"points": [[4, 0], [0, 3]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
|
||||
s.execute("circle.ttt", &json!({"h1": a, "p1": [2, 0], "h2": b, "p2": [0, 1], "h3": hyp, "p3": [2, 1.5]})).unwrap();
|
||||
let (c, r) = as_circle(&last(&s));
|
||||
assert!(near(c, 1.0, 1.0) && (r - 1.0).abs() < 1e-6, "{c:?} {r}");
|
||||
s.cmdline("circle.ttt 2,0 0,1 2,1.5").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let (c, _) = as_circle(&last(&s));
|
||||
assert!(near(c, 1.0, 1.0));
|
||||
// Three circles: the small circle in the middle of three touching circles.
|
||||
let mut s = Session::new();
|
||||
let r = 1.0;
|
||||
let cs: Vec<String> =
|
||||
[[0.0, 0.0], [2.0, 0.0], [1.0, 3f64.sqrt()]].iter().map(|c| h(&s.execute("circle", &json!({"center": c, "radius": r})).unwrap())).collect();
|
||||
let centroid = Vec2::new(1.0, 3f64.sqrt() / 3.0);
|
||||
let picks: Vec<Vec2> = [[0.0, 0.0], [2.0, 0.0], [1.0, 3f64.sqrt()]]
|
||||
.iter()
|
||||
.map(|c| Vec2::new(c[0], c[1]) + (centroid - Vec2::new(c[0], c[1])).normalized())
|
||||
.collect();
|
||||
s.execute(
|
||||
"circle.ttt",
|
||||
&json!({"h1": cs[0], "p1": [picks[0].x, picks[0].y], "h2": cs[1], "p2": [picks[1].x, picks[1].y], "h3": cs[2], "p3": [picks[2].x, picks[2].y]}),
|
||||
)
|
||||
.unwrap();
|
||||
let (c, rr) = as_circle(&last(&s));
|
||||
assert!(c.near(centroid, 1e-6) && (rr - (2.0 / 3f64.sqrt() - 1.0)).abs() < 1e-6, "{c:?} {rr}");
|
||||
}
|
||||
|
||||
// ---------------- ELLIPSE ----------------
|
||||
|
||||
#[test]
|
||||
fn ellipse_axis_and_arc() {
|
||||
let mut s = Session::new();
|
||||
s.execute("ellipse.axis", &json!({"p1": [0, 0], "p2": [4, 0], "distance": 1})).unwrap();
|
||||
match last(&s) {
|
||||
EntityKind::Ellipse(e) => {
|
||||
assert!(near(e.center.xy(), 2.0, 0.0) && (e.ratio - 0.5).abs() < 1e-9);
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
s.execute("ellipse.arc", &json!({"center": [0, 0], "major": [2, 0], "ratio": 0.5, "start": 0, "end": 90})).unwrap();
|
||||
match last(&s) {
|
||||
EntityKind::Ellipse(e) => assert!((e.end - std::f64::consts::FRAC_PI_2).abs() < 1e-9 && e.start.abs() < 1e-9),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
assert!(s.execute("ellipse.arc", &json!({"p1": [0, 0], "p2": [4, 0], "distance": 1})).is_err());
|
||||
s.cmdline("ellipse.axis 0,0 6,0 1").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Ellipse(e) if (e.ratio - 1.0 / 3.0).abs() < 1e-9));
|
||||
s.cmdline("ellipse.arc 0,0 4,0 1 0 90").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(matches!(last(&s), EntityKind::Ellipse(e) if (e.end - e.start).abs() > 0.1 && (e.end - e.start).abs() < 6.0));
|
||||
}
|
||||
|
||||
// ---------------- DIVIDE / MEASURE ----------------
|
||||
|
||||
#[test]
|
||||
fn divide_and_measure_json() {
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
|
||||
let r = s.execute("divide", &json!({"handle": l, "segments": 5})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 4);
|
||||
let pts: Vec<Vec2> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();
|
||||
assert!(near(pts[0], 2.0, 0.0) && near(pts[3], 8.0, 0.0));
|
||||
let r = s.execute("measure", &json!({"handle": l, "length": 3})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 3);
|
||||
// From the other end.
|
||||
s.execute("measure", &json!({"handle": l, "length": 4, "from": [10, 0]})).unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Point(p) if near(p.p.xy(), 2.0, 0.0)));
|
||||
// A circle divides into n points including the start.
|
||||
let c = h(&s.execute("circle", &json!({"center": [20, 0], "radius": 1})).unwrap());
|
||||
assert_eq!(s.execute("divide", &json!({"handle": c, "segments": 6})).unwrap()["handles"].as_array().unwrap().len(), 6);
|
||||
// Blocks, aligned with the object.
|
||||
let tick = s.execute("line", &json!({"points": [[0, -0.1], [0, 0.1]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
|
||||
s.execute("block", &json!({"name": "TICK", "base": [0, 0], "handles": [tick], "keep": "delete"})).unwrap();
|
||||
let arc = h(&s.execute("arc", &json!({"center": [0, 20], "radius": 2, "start": 0, "end": 180})).unwrap());
|
||||
let r = s.execute("divide", &json!({"handle": arc, "segments": 2, "block": "TICK"})).unwrap();
|
||||
let ins = s.doc().unwrap().entity(cadcraft_doc::Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()).unwrap().kind.clone();
|
||||
assert!(matches!(ins, EntityKind::Insert(i) if near(i.insert.xy(), 0.0, 22.0) && (i.rotation - std::f64::consts::PI).abs() < 1e-6));
|
||||
assert!(s.execute("divide", &json!({"handle": l, "segments": 3, "block": "NOPE"})).is_err());
|
||||
assert!(s.execute("divide", &json!({"handle": l, "segments": 1})).is_err());
|
||||
assert!(s.execute("measure", &json!({"handle": l, "length": 0})).is_err());
|
||||
assert!(s.execute("measure", &json!({"handle": l, "length": 1e-300})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_and_measure_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.cmdline("divide 5,0 4").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(kinds(&s).iter().filter(|k| **k == "Point").count(), 3);
|
||||
s.cmdline("measure 9,0 2.5").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
// Measured from the end nearest the pick.
|
||||
assert!(matches!(last(&s), EntityKind::Point(p) if near(p.p.xy(), 2.5, 0.0)));
|
||||
// Block option.
|
||||
let tick = s.execute("circle", &json!({"center": [50, 50], "radius": 0.1})).unwrap();
|
||||
s.execute("block", &json!({"name": "DOT", "base": [50, 50], "handles": [h(&tick)], "keep": "delete"})).unwrap();
|
||||
s.cmdline("divide 6,0 b DOT n 2").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(matches!(last(&s), EntityKind::Insert(i) if near(i.insert.xy(), 5.0, 0.0) && i.rotation == 0.0));
|
||||
// Invalid count re-prompts.
|
||||
s.cmdline("divide 6,0 1").unwrap();
|
||||
assert!(s.running.is_some());
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
// ---------------- REVCLOUD ----------------
|
||||
|
||||
#[test]
|
||||
fn revcloud_json() {
|
||||
let mut s = Session::new();
|
||||
s.execute("revcloud", &json!({"p1": [0, 0], "p2": [4, 2], "arcLength": 1})).unwrap();
|
||||
match last(&s) {
|
||||
EntityKind::LwPolyline(p) => {
|
||||
assert!(p.closed);
|
||||
assert_eq!(p.vertices.len(), 12);
|
||||
assert!(p.vertices.iter().all(|v| v.bulge > 0.0));
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
s.execute("revcloud.polygonal", &json!({"points": [[0, 0], [0, 3], [3, 0]], "arcLength": 0.5})).unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.vertices.len() > 10));
|
||||
s.execute("revcloud.freehand", &json!({"freehand": [[0, 0], [5, 0], [5, 5], [0, 5]], "arcLength": 2, "style": "calligraphy"})).unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.vertices.len() == 10 && p.vertices[0].end_width > 0.0));
|
||||
let c = h(&s.execute("circle", &json!({"center": [10, 10], "radius": 1})).unwrap());
|
||||
let n = s.doc().unwrap().model.len();
|
||||
s.execute("revcloud", &json!({"handle": c, "arcLength": 0.5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), n, "the circle is replaced");
|
||||
assert!(s.execute("revcloud", &json!({"points": [[0, 0], [1, 1], [2, 2]]})).is_err());
|
||||
assert!(s.execute("revcloud", &json!({"p1": [0, 0], "p2": [1e300, 1e300], "arcLength": 1e-300})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revcloud_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("revcloud a 1").unwrap();
|
||||
s.cmdline("0,0 4,2").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.vertices.len() == 12));
|
||||
s.cmdline("revcloud.polygonal 0,0 4,0 4,3").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(kinds(&s), vec!["Polyline", "Polyline"]);
|
||||
s.cmdline("revcloud.freehand 0,0 3,0 3,3 0,3").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(kinds(&s).len(), 3);
|
||||
// Object option converts a closed polyline.
|
||||
s.execute("rectang", &json!({"p1": [10, 10], "p2": [12, 12]})).unwrap();
|
||||
s.cmdline("revcloud o 10,11").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(kinds(&s).len(), 4);
|
||||
}
|
||||
|
||||
// ---------------- WIPEOUT ----------------
|
||||
|
||||
#[test]
|
||||
fn wipeout_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("wipeout", &json!({"points": [[0, 0], [2, 0], [2, 2]]})).unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Wipeout(w) if w.boundary.len() == 3));
|
||||
let r = h(&s.execute("rectang", &json!({"p1": [5, 5], "p2": [6, 6]})).unwrap());
|
||||
s.execute("wipeout", &json!({"handle": r, "erase": true})).unwrap();
|
||||
assert_eq!(kinds(&s), vec!["Wipeout", "Wipeout"]);
|
||||
s.execute("wipeout", &json!({"frames": "off"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.i64("WIPEOUTFRAME", 1), 0);
|
||||
assert!(s.execute("wipeout", &json!({"points": [[0, 0], [1, 1]]})).is_err());
|
||||
assert!(s.execute("wipeout", &json!({"frames": "sideways"})).is_err());
|
||||
|
||||
s.cmdline("wipeout 0,0 3,0 3,3").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(kinds(&s).len(), 3);
|
||||
s.execute("rectang", &json!({"p1": [20, 20], "p2": [22, 22]})).unwrap();
|
||||
s.cmdline("wipeout p 20,21 y").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(kinds(&s), vec!["Wipeout"; 4]);
|
||||
s.cmdline("wipeout f on").unwrap();
|
||||
assert_eq!(s.doc().unwrap().header.i64("WIPEOUTFRAME", 0), 1);
|
||||
}
|
||||
|
||||
// ---------------- XLINE ----------------
|
||||
|
||||
fn xline_dir(k: &EntityKind) -> (Vec2, Vec2) {
|
||||
match k {
|
||||
EntityKind::XLine(r) => (r.base.xy(), r.dir.xy()),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xline_options_json() {
|
||||
let mut s = Session::new();
|
||||
s.execute("xline", &json!({"vertex": [0, 0], "start": [1, 0], "end": [0, 1]})).unwrap();
|
||||
let (_, d) = xline_dir(&last(&s));
|
||||
assert!(d.near(Vec2::new(1.0, 1.0).normalized(), 1e-9));
|
||||
let l = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
|
||||
s.execute("xline", &json!({"handle": l, "distance": 2, "side": [5, -1]})).unwrap();
|
||||
let (b, d) = xline_dir(&last(&s));
|
||||
assert!((b.y + 2.0).abs() < 1e-9 && d.near(Vec2::X, 1e-9));
|
||||
s.execute("xline", &json!({"handle": l, "through": [3, 7]})).unwrap();
|
||||
assert!(near(xline_dir(&last(&s)).0, 3.0, 7.0));
|
||||
s.execute("xline", &json!({"base": [1, 1], "ver": true})).unwrap();
|
||||
assert!(xline_dir(&last(&s)).1.near(Vec2::Y, 1e-9));
|
||||
s.execute("xline", &json!({"base": [1, 1], "angle": 30})).unwrap();
|
||||
s.execute("xline", &json!({"base": [1, 1], "through": [2, 2]})).unwrap();
|
||||
assert!(s.execute("xline", &json!({"base": [1, 1], "through": [1, 1]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xline_options_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("xline b 0,0 1,0 0,1 -1,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(kinds(&s), vec!["Xline", "Xline"]);
|
||||
s.cmdline("xline a 45 3,3").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(xline_dir(&last(&s)).1.near(Vec2::new(1.0, 1.0).normalized(), 1e-9));
|
||||
s.execute("line", &json!({"points": [[20, 0], [30, 0]]})).unwrap();
|
||||
s.cmdline("xline o 2 25,0 25,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let (b, _) = xline_dir(&last(&s));
|
||||
assert!((b.y - 2.0).abs() < 1e-9);
|
||||
s.cmdline("xline o t 25,0 0,9").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(near(xline_dir(&last(&s)).0, 0.0, 9.0));
|
||||
s.cmdline("xline a r 25,0 90 4,4").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(xline_dir(&last(&s)).1.near(Vec2::Y, 1e-9));
|
||||
s.cmdline("xline h 0,7").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
// ---------------- RECTANG ----------------
|
||||
|
||||
fn rect_pts(s: &Session) -> Vec<Vec2> {
|
||||
match last(s) {
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| v.p).collect(),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectang_options_json() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 3]})).unwrap();
|
||||
assert!(near(rect_pts(&s)[2], 4.0, 3.0));
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "dimensions": [3, 2], "p2": [-1, -1]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, -3.0, -2.0) && near(b.max, 0.0, 0.0));
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "area": 20, "length": 5})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.max, 5.0, 4.0));
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "dimensions": [2, 1], "rotation": 90})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, -1.0, 0.0) && near(b.max, 0.0, 2.0), "{b:?}");
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [4, 4], "fillet": 1, "width": 0.1, "thickness": 2})).unwrap();
|
||||
assert_eq!(rect_pts(&s).len(), 8);
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 2.0);
|
||||
assert!(s.execute("rectang", &json!({"p1": [0, 0], "area": -1, "length": 1})).is_err());
|
||||
assert!(s.execute("rectang", &json!({"p1": [0, 0], "p2": [0, 5]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectang_options_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("rectang 0,0 a 20 l 5").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(near(cadcraft_geom::Bounds2::from_points(rect_pts(&s)).max, 5.0, 4.0));
|
||||
s.cmdline("rectang 0,0 a 12 w 3").unwrap();
|
||||
assert!(near(cadcraft_geom::Bounds2::from_points(rect_pts(&s)).max, 4.0, 3.0));
|
||||
s.cmdline("rectang 0,0 d 3 2 -1,-1").unwrap();
|
||||
assert!(near(cadcraft_geom::Bounds2::from_points(rect_pts(&s)).min, -3.0, -2.0));
|
||||
s.cmdline("rectang 0,0 r 90 2,1").unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 0.0, 0.0) && near(b.max, 2.0, 1.0), "{b:?}");
|
||||
s.cmdline("rectang 0,0 r p 0,0 1,1 d 2 2 5,5").unwrap();
|
||||
let pts = rect_pts(&s);
|
||||
assert!(pts.iter().any(|p| near(*p, 0.0, 8f64.sqrt())), "{pts:?}");
|
||||
s.cmdline("rectang w 0.5 c 0.5 0.5 0,0 4,4").unwrap();
|
||||
match last(&s) {
|
||||
EntityKind::LwPolyline(p) => assert!(p.const_width == 0.5 && p.vertices.len() == 8),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
s.cmdline("rectang f 1 e 2 t 3 0,0 4,4").unwrap();
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => assert!(p.elevation == 2.0 && p.vertices.len() == 8),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 3DPOLY / MLINE / HELIX / SPLINE CV ----------------
|
||||
|
||||
#[test]
|
||||
fn poly3d_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("3dpoly", &json!({"points": [[0, 0, 0], [1, 0, 2], [1, 1, 3]], "closed": true})).unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Polyline3d(p) if p.points.len() == 3 && p.points[2].z == 3.0 && p.closed));
|
||||
assert!(s.execute("3dpoly", &json!({"points": [[0, 0]]})).is_err());
|
||||
s.cmdline("3dpoly 0,0 1,0 1,1 u 2,2 c").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(last(&s), EntityKind::Polyline3d(p) if p.points.len() == 3 && p.closed));
|
||||
assert_eq!(s.state().unwrap().undo.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mline_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("mline", &json!({"points": [[0, 0], [10, 0], [10, 10]], "scale": 2, "justification": "zero"})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
|
||||
let ys: Vec<f64> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p.vertices[0].p.y) } else { None }).collect();
|
||||
assert!(ys.contains(&1.0) && ys.contains(&-1.0), "{ys:?}");
|
||||
assert!(s.execute("mline", &json!({"points": [[0, 0], [1, 0]], "justification": "middle"})).is_err());
|
||||
let mut s = Session::new();
|
||||
s.cmdline("mline j b s 3 0,0 10,0 10,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let ys: Vec<f64> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p.vertices[0].p.y) } else { None }).collect();
|
||||
assert_eq!(ys.len(), 2);
|
||||
assert!(ys.contains(&0.0) && ys.contains(&3.0), "{ys:?}");
|
||||
s.cmdline("mline 0,20 5,20 5,25 c").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helix_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("helix", &json!({"center": [0, 0], "baseRadius": 2, "topRadius": 1, "height": 6, "turns": 2})).unwrap();
|
||||
match last(&s) {
|
||||
EntityKind::Polyline3d(p) => {
|
||||
assert!(near(p.points[0].xy(), 2.0, 0.0));
|
||||
let top = p.points.last().unwrap();
|
||||
assert!((top.z - 6.0).abs() < 1e-9 && near(top.xy(), 1.0, 0.0));
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
assert!(s.execute("helix", &json!({"center": [0, 0], "baseRadius": 1, "turns": 1e9})).is_err());
|
||||
s.cmdline("helix 0,0 1 2 t 4 w cw 5").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
match last(&s) {
|
||||
EntityKind::Polyline3d(p) => {
|
||||
let top = p.points.last().unwrap();
|
||||
assert!((top.z - 5.0).abs() < 1e-9 && near(top.xy(), 2.0, 0.0));
|
||||
assert!(p.points[1].y < 0.0, "clockwise twist");
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_cv_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("spline.cv", &json!({"control": [[0, 0], [1, 2], [3, 2], [4, 0]], "degree": 2})).unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Spline(sp) if sp.degree == 2 && sp.fit.is_empty()));
|
||||
assert!(s.execute("spline.cv", &json!({"control": [[0, 0]]})).is_err());
|
||||
s.cmdline("spline.cv d 3 0,0 1,1 2,0 3,1").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Spline(sp) if sp.control.len() == 4 && sp.degree == 3));
|
||||
s.cmdline("spline.cv 0,0 1,1 2,0 c").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Spline(sp) if sp.closed));
|
||||
}
|
||||
|
||||
// ---------------- CENTER MARK / CENTER LINE ----------------
|
||||
|
||||
#[test]
|
||||
fn centermark_and_centerline() {
|
||||
let mut s = Session::new();
|
||||
let c = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap());
|
||||
let r = s.execute("centermark", &json!({"handle": c})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
|
||||
let e = (**s.doc().unwrap().model.last().unwrap()).clone();
|
||||
assert_eq!(e.common.linetype, "CENTER");
|
||||
assert!(s.doc().unwrap().linetype("CENTER").is_some());
|
||||
assert!(matches!(e.kind, EntityKind::Line(l) if (l.b.y - 2.12).abs() < 1e-9));
|
||||
let a = s.execute("line", &json!({"points": [[10, 0], [20, 0]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
|
||||
let b = s.execute("line", &json!({"points": [[20, 4], [10, 4]]})).unwrap()["handles"][0].as_str().unwrap().to_string();
|
||||
s.execute("centerline", &json!({"h1": a, "h2": b})).unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Line(l) if (l.a.y - 2.0).abs() < 1e-9 && (l.a.x - 9.88).abs() < 1e-9));
|
||||
assert!(s.execute("centerline", &json!({"h1": a, "h2": c})).is_err());
|
||||
assert!(s.execute("centermark", &json!({"handle": a})).is_err());
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap();
|
||||
s.execute("arc", &json!({"center": [10, 0], "radius": 1, "start": 0, "end": 90})).unwrap();
|
||||
s.cmdline("centermark 2,0 11,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(kinds(&s).iter().filter(|k| **k == "Line").count(), 4);
|
||||
s.execute("line", &json!({"points": [[0, 10], [10, 10]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 12], [10, 12]]})).unwrap();
|
||||
s.cmdline("centerline 5,10 5,12").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(last(&s), EntityKind::Line(l) if (l.a.y - 11.0).abs() < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_draw_commands_survive_garbage_input() {
|
||||
let ids = [
|
||||
"arc.sce",
|
||||
"arc.sca",
|
||||
"arc.scl",
|
||||
"arc.sea",
|
||||
"arc.sed",
|
||||
"arc.ser",
|
||||
"arc.cse",
|
||||
"arc.csa",
|
||||
"arc.csl",
|
||||
"arc.continue",
|
||||
"circle.cd",
|
||||
"circle.2p",
|
||||
"circle.3p",
|
||||
"circle.ttr",
|
||||
"circle.ttt",
|
||||
"ellipse.axis",
|
||||
"ellipse.arc",
|
||||
"divide",
|
||||
"measure",
|
||||
"revcloud",
|
||||
"revcloud.polygonal",
|
||||
"revcloud.freehand",
|
||||
"wipeout",
|
||||
"3dpoly",
|
||||
"mline",
|
||||
"helix",
|
||||
"spline.cv",
|
||||
"centermark",
|
||||
"centerline",
|
||||
"xline",
|
||||
"rectang",
|
||||
];
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
for id in ids {
|
||||
for p in [
|
||||
json!({"start": [0, 0], "center": [0, 0], "end": [0, 0], "angle": 1e308, "length": -1e308, "radius": 0, "direction": [0, 0]}),
|
||||
json!({"p1": [0, 0], "p2": [0, 0], "p3": [0, 0], "distance": -0.0, "diameter": 1e308, "radius": f64::MAX}),
|
||||
json!({"handle": "100", "segments": 1e18, "length": 1e-320, "block": ""}),
|
||||
json!({"points": [[0, 0], [1e308, 1e308], [-1e308, 1e308]], "arcLength": 1e-300, "scale": 1e308}),
|
||||
json!({"center": [0, 0], "baseRadius": 1e308, "turns": 499, "height": 1e308}),
|
||||
json!({"control": [[0, 0], [0, 0]], "degree": 1e9}),
|
||||
json!({"p1": [0, 0], "dimensions": [1e308, -1e308], "rotation": 1e308, "area": 1e308, "length": 1e-308}),
|
||||
json!({"h1": "100", "p1": [0, 0], "h2": "100", "p2": [0, 0], "h3": "100", "p3": [0, 0], "radius": 1}),
|
||||
json!({"vertex": [0, 0], "start": [0, 0], "end": [0, 0]}),
|
||||
] {
|
||||
let _ = s.execute(id, &p);
|
||||
}
|
||||
// Interactive: garbage text and points.
|
||||
s.start(id).unwrap();
|
||||
for t in ["", "x", "1e999", "-5", "0,0", "0,0", "nan", "b", "a", "1", "2,2", ""] {
|
||||
if s.running.is_none() {
|
||||
break;
|
||||
}
|
||||
let _ = s.cmdline(t);
|
||||
}
|
||||
let _ = s.preview(Vec2::new(1e300, -1e300));
|
||||
s.cancel();
|
||||
let _ = s.input(Input::Cancel);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_command_options() {
|
||||
for (cmd, center) in [
|
||||
("arc 1,0 0,1 -1,0", (0.0, 0.0)),
|
||||
("arc c 0,0 1,0 0,1", (0.0, 0.0)),
|
||||
("arc c 0,0 1,0 a 90", (0.0, 0.0)),
|
||||
("arc c 0,0 1,0 l 1.41421356237", (0.0, 0.0)),
|
||||
("arc 1,0 c 0,0 0,1", (0.0, 0.0)),
|
||||
("arc 1,0 c 0,0 a 90", (0.0, 0.0)),
|
||||
("arc 1,0 e 0,1 0,0", (0.0, 0.0)),
|
||||
("arc 1,0 e 0,1 a 90", (0.0, 0.0)),
|
||||
("arc 1,0 e 0,1 d 90", (0.0, 0.0)),
|
||||
("arc 1,0 e 0,1 r 1", (0.0, 0.0)),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.cmdline(cmd).unwrap();
|
||||
assert!(s.running.is_none(), "{cmd}: {}", s.prompt_text());
|
||||
let a = as_arc(&last(&s));
|
||||
assert!(near(a.center, center.0, center.1) && (a.radius - 1.0).abs() < 1e-6, "{cmd}: {a:?}");
|
||||
}
|
||||
// Enter at the first prompt continues from the last line.
|
||||
let mut s = Session::new();
|
||||
s.cmdline("line 0,0 1,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("arc").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().contains("end point"), "{}", s.prompt_text());
|
||||
s.cmdline("2,1").unwrap();
|
||||
assert!(near(as_arc(&last(&s)).center, 1.0, 1.0));
|
||||
assert!(s.prompt_text() == "Command:");
|
||||
}
|
||||
@@ -9,10 +9,15 @@ use serde_json::{Value, json};
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Plot a space to PDF bytes with JSON options (`{paper?, landscape?, fit?, lineweights?, …}`).
|
||||
pub type PlotHook = fn(&Drawing, &cadcraft_doc::Space, &Value) -> std::result::Result<Vec<u8>, String>;
|
||||
|
||||
/// File format hooks (installed by the app so the engine stays I/O-agnostic and wasm-safe).
|
||||
pub struct IoHooks {
|
||||
pub read: fn(&[u8], &str) -> std::result::Result<Drawing, String>,
|
||||
pub write: fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>,
|
||||
/// PLOT / EXPORTPDF (optional: builds without a plotter report "not available").
|
||||
pub plot: Option<PlotHook>,
|
||||
}
|
||||
|
||||
static IO: OnceLock<IoHooks> = OnceLock::new();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! `grip.*` commands: the undoable, scriptable form of grip editing (see `crate::grips`).
|
||||
|
||||
use cadcraft_doc::Handle;
|
||||
use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::curves;
|
||||
use super::*;
|
||||
use crate::grips::{GripMode, mode_matrix, stretch_grip};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("grip.move", "Grip Stretch", run_grip_move)
|
||||
.params("{handle, index, to} (stretch the grip) | {handles?, index, baseHandle?, to, mode: \"move\", copy?} (move about the grip)"),
|
||||
CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params("{handle, index, to}"),
|
||||
CommandSpec::new("grip.rotate", "Grip Rotate", run_grip_rotate).params("{handles?, base | baseHandle+index, angle (degrees) | to, copy?}"),
|
||||
CommandSpec::new("grip.scale", "Grip Scale", run_grip_scale).params("{handles?, base | baseHandle+index, factor | to, copy?}"),
|
||||
CommandSpec::new("grip.mirror", "Grip Mirror", run_grip_mirror)
|
||||
.params("{handles?, base | baseHandle+index, to (second mirror point), copy?}"),
|
||||
]
|
||||
}
|
||||
|
||||
fn index_param(cmd: &str, p: &Value) -> Result<usize> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`index` (grip number) is required"))?;
|
||||
usize::try_from(i).map_err(|_| bad(cmd, "index out of range"))
|
||||
}
|
||||
|
||||
fn stretch(s: &mut Session, cmd: &str, p: &Value) -> Result<Value> {
|
||||
let h = curves::handle_param(p, "handle")
|
||||
.or_else(|| targets(s, p).ok().and_then(|t| t.first().copied()))
|
||||
.ok_or_else(|| bad(cmd, "`handle` is required"))?;
|
||||
let i = index_param(cmd, p)?;
|
||||
let to = point_req(cmd, p, "to")?;
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(EngineError::Other("The object is on a locked layer.".into()));
|
||||
}
|
||||
let e = curves::entity(s, h)?;
|
||||
let k = stretch_grip(&e.kind, i, to).ok_or_else(|| bad(cmd, "no such grip, or the result would be degenerate"))?;
|
||||
s.doc_mut()?.modify_entity(h, |e| e.kind = k)?;
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
}
|
||||
|
||||
/// Base point: `base`, or grip `index` of `baseHandle` (default: the first target).
|
||||
fn base_point(s: &Session, cmd: &str, p: &Value, hs: &[Handle]) -> Result<Vec2> {
|
||||
if let Some(b) = point_param(p, "base") {
|
||||
return Ok(b);
|
||||
}
|
||||
let h = curves::handle_param(p, "baseHandle")
|
||||
.or_else(|| hs.first().copied())
|
||||
.ok_or_else(|| bad(cmd, "`base` or `baseHandle` + `index` is required"))?;
|
||||
let i = index_param(cmd, p)?;
|
||||
let g = curves::entity(s, h)?.kind.grips();
|
||||
g.get(i).copied().ok_or_else(|| bad(cmd, "no such grip"))
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Value> {
|
||||
let r = super::modify::transform_entities(s, hs, m, copy)?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn run_grip_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
stretch(s, "grip.stretch", p)
|
||||
}
|
||||
|
||||
fn run_grip_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode_move = str_param(p, "mode").is_some_and(|m| m.eq_ignore_ascii_case("move")) || (p.get("index").is_none() && p.get("base").is_some());
|
||||
if !mode_move {
|
||||
return stretch(s, "grip.move", p);
|
||||
}
|
||||
let hs = targets(s, p)?;
|
||||
let base = base_point(s, "grip.move", p, &hs)?;
|
||||
let to = point_req("grip.move", p, "to")?;
|
||||
let m = mode_matrix(GripMode::Move, base, to).unwrap_or(Mat3::IDENTITY);
|
||||
apply(s, &hs, &m, bool_or(p, "copy", false))
|
||||
}
|
||||
|
||||
fn run_grip_rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let base = base_point(s, "grip.rotate", p, &hs)?;
|
||||
let m = match p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()) {
|
||||
Some(a) => Mat3::rotate_about(base, a.to_radians()),
|
||||
None => mode_matrix(GripMode::Rotate, base, point_req("grip.rotate", p, "to")?).ok_or_else(|| bad("grip.rotate", "invalid point"))?,
|
||||
};
|
||||
apply(s, &hs, &m, bool_or(p, "copy", false))
|
||||
}
|
||||
|
||||
fn run_grip_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let base = base_point(s, "grip.scale", p, &hs)?;
|
||||
let m = match p.get("factor").and_then(Value::as_f64).filter(|a| a.is_finite()) {
|
||||
Some(f) if f > 0.0 => Mat3::scale_about(base, f),
|
||||
Some(_) => return Err(bad("grip.scale", "factor must be positive")),
|
||||
None => {
|
||||
mode_matrix(GripMode::Scale, base, point_req("grip.scale", p, "to")?).ok_or_else(|| bad("grip.scale", "scale factor would be zero"))?
|
||||
}
|
||||
};
|
||||
apply(s, &hs, &m, bool_or(p, "copy", false))
|
||||
}
|
||||
|
||||
fn run_grip_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let base = base_point(s, "grip.mirror", p, &hs)?;
|
||||
let to = point_req("grip.mirror", p, "to")?;
|
||||
let m = mode_matrix(GripMode::Mirror, base, to).ok_or_else(|| bad("grip.mirror", "the mirror line needs two distinct points"))?;
|
||||
apply(s, &hs, &m, bool_or(p, "copy", false))
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy),
|
||||
//! MVIEW / VPORTS, viewport scale/lock/layer settings, PAGESETUP, PLOT and EXPORTPDF.
|
||||
//!
|
||||
//! Plotting goes through the host's [`super::file::IoHooks::plot`] hook so the engine stays
|
||||
//! I/O-agnostic.
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, EntityStore, Handle, Layout, PageSetup, Space, Viewport};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::file::{base64_encode, io};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("layout", "Layout", run_layout)
|
||||
.alias(&["lo"])
|
||||
.params("{option: new|copy|delete|rename|set|list, name?, to?}"),
|
||||
CommandSpec::new("layout.new", "New Layout", run_new)
|
||||
.menu(&["Insert", "Layout", "New Layout"])
|
||||
.params("{name?, viewport?: bool (default true)} → {name, viewport}"),
|
||||
CommandSpec::new("layout.delete", "Delete Layout", run_delete).params("{name}"),
|
||||
CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"),
|
||||
CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"),
|
||||
CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(),
|
||||
CommandSpec::new("mview", "Viewports", run_mview)
|
||||
.alias(&["mv"])
|
||||
.params("{p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, layout?}")
|
||||
.interactive(|_| Ok(Box::new(MviewM::new(1, true)))),
|
||||
CommandSpec::new("vports", "New Viewports...", run_mview)
|
||||
.menu(&["View", "Viewports", "New Viewports..."])
|
||||
.params("{count?: 1-4, arrangement?, p1?, p2?, layout?}")
|
||||
.interactive(|_| Ok(Box::new(MviewM::new(1, true)))),
|
||||
CommandSpec::new("vports.1", "1 Viewport", |s, p| run_mview_n(s, p, 1))
|
||||
.menu(&["View", "Viewports", "1 Viewport"])
|
||||
.params("{p1?, p2?, layout?}")
|
||||
.interactive(|_| Ok(Box::new(MviewM::new(1, false)))),
|
||||
CommandSpec::new("vports.2", "2 Viewports", |s, p| run_mview_n(s, p, 2))
|
||||
.menu(&["View", "Viewports", "2 Viewports"])
|
||||
.params("{p1?, p2?, arrangement?: vertical|horizontal, layout?}")
|
||||
.interactive(|_| Ok(Box::new(MviewM::new(2, false)))),
|
||||
CommandSpec::new("vports.3", "3 Viewports", |s, p| run_mview_n(s, p, 3))
|
||||
.menu(&["View", "Viewports", "3 Viewports"])
|
||||
.params("{p1?, p2?, arrangement?: right|left|above|below|vertical|horizontal, layout?}")
|
||||
.interactive(|_| Ok(Box::new(MviewM::new(3, false)))),
|
||||
CommandSpec::new("vports.4", "4 Viewports", |s, p| run_mview_n(s, p, 4))
|
||||
.menu(&["View", "Viewports", "4 Viewports"])
|
||||
.params("{p1?, p2?, layout?}")
|
||||
.interactive(|_| Ok(Box::new(MviewM::new(4, false)))),
|
||||
CommandSpec::new("viewport.set", "Viewport Properties", run_viewport_set).params(
|
||||
"{handle? (default: selected viewports), scale?: paper units per model unit | \"1:50\", viewHeight?, center?: [x,y], locked?, freeze?: [layer], thaw?: [layer]}",
|
||||
),
|
||||
CommandSpec::new("vplayer", "Viewport Layer Freeze", run_viewport_set).params("{handle?, freeze?: [layer], thaw?: [layer]}"),
|
||||
CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup)
|
||||
.menu(&["File", "Page Setup Manager..."])
|
||||
.params(
|
||||
"{layout?: current, paper?: \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?}",
|
||||
),
|
||||
CommandSpec::new("plot", "Print...", |s, p| run_plot(s, p, "plot"))
|
||||
.menu(&["File", "Print..."])
|
||||
.key("Cmd+P")
|
||||
.alias(&["print"])
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?, lineweights?: bool}")
|
||||
.noundo(),
|
||||
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, p, "exportpdf"))
|
||||
.menu(&["File", "Export to PDF..."])
|
||||
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, lineweights?}")
|
||||
.noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- layouts ----------
|
||||
|
||||
/// Characters AutoCAD does not allow in layout names.
|
||||
const BAD_CHARS: &[char] = &['<', '>', '/', '\\', '"', ':', ';', '?', '*', '|', ',', '=', '`'];
|
||||
|
||||
fn check_name(cmd: &str, d: &Drawing, name: &str, except: Option<&str>) -> Result<String> {
|
||||
let n = name.trim();
|
||||
if n.is_empty() {
|
||||
return Err(bad(cmd, "layout name is empty"));
|
||||
}
|
||||
if n.chars().count() > 255 {
|
||||
return Err(bad(cmd, "layout name is longer than 255 characters"));
|
||||
}
|
||||
if n.contains(BAD_CHARS) || n.chars().any(char::is_control) {
|
||||
return Err(bad(cmd, format!("invalid layout name `{n}`")));
|
||||
}
|
||||
if n.eq_ignore_ascii_case("model") {
|
||||
return Err(bad(cmd, "`Model` is reserved"));
|
||||
}
|
||||
if d.layouts.iter().any(|l| l.name.eq_ignore_ascii_case(n) && except.is_none_or(|e| !l.name.eq_ignore_ascii_case(e))) {
|
||||
return Err(bad(cmd, format!("layout `{n}` already exists")));
|
||||
}
|
||||
Ok(n.to_string())
|
||||
}
|
||||
|
||||
fn layout_name(cmd: &str, d: &Drawing, name: &str) -> Result<String> {
|
||||
d.layout(name).map(|l| l.name.clone()).ok_or_else(|| bad(cmd, format!("no layout `{name}`")))
|
||||
}
|
||||
|
||||
fn next_layout_name(d: &Drawing) -> String {
|
||||
(1..=10_000).map(|i| format!("Layout{i}")).find(|n| d.layout(n).is_none()).unwrap_or_else(|| "Layout".into())
|
||||
}
|
||||
|
||||
/// The page setup new layouts get: ANSI A landscape (imperial) or ISO A4 landscape (metric).
|
||||
fn default_page(d: &Drawing) -> PageSetup {
|
||||
let mut p = PageSetup::default();
|
||||
if cadcraft_render::paper::paper_unit_mm(d) == 1.0
|
||||
&& let Some(a4) = cadcraft_render::paper_size("A4")
|
||||
{
|
||||
p.paper = a4.name.into();
|
||||
p.width_mm = a4.width_mm;
|
||||
p.height_mm = a4.height_mm;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
let name = match str_param(p, "name") {
|
||||
Some(n) => check_name("layout.new", d, n, None)?,
|
||||
None => next_layout_name(d),
|
||||
};
|
||||
let page = default_page(d);
|
||||
let order = d.layouts.iter().map(|l| l.tab_order).max().unwrap_or(0).saturating_add(1);
|
||||
s.doc_mut()?.layouts.push(Layout { page, ..Layout::new(&name, order) });
|
||||
let mut vp = Value::Null;
|
||||
if bool_or(p, "viewport", true) {
|
||||
// AutoCAD behaviour: one viewport over the printable area, zoomed to the model extents.
|
||||
let space = Space::Paper(name.clone());
|
||||
let rect = printable(s, &name)?;
|
||||
if let Some(h) = add_viewports(s, &space, rect, 1, "")?.first() {
|
||||
vp = json!(h.hex());
|
||||
}
|
||||
}
|
||||
Ok(json!({ "name": name, "viewport": vp }))
|
||||
}
|
||||
|
||||
fn run_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("layout.delete", "`name` is required"))?;
|
||||
let name = layout_name("layout.delete", s.doc()?, name)?;
|
||||
if s.doc()?.layouts.len() <= 1 {
|
||||
return Err(bad("layout.delete", "a drawing must keep at least one layout"));
|
||||
}
|
||||
s.doc_mut()?.layouts.retain(|l| l.name != name);
|
||||
let gone = Space::Paper(name.clone());
|
||||
let st = s.state_mut()?;
|
||||
st.views.retain(|(sp, _)| sp != &gone);
|
||||
if st.space == gone {
|
||||
st.space = Space::Model;
|
||||
st.selection.clear();
|
||||
}
|
||||
s.touch();
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
|
||||
fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = match (str_param(p, "from"), s.space()) {
|
||||
(Some(f), _) => f.to_string(),
|
||||
(None, Space::Paper(n)) => n,
|
||||
(None, Space::Model) => return Err(bad("layout.rename", "`from` is required in the Model tab")),
|
||||
};
|
||||
let from = layout_name("layout.rename", s.doc()?, &from)?;
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("layout.rename", "`to` is required"))?;
|
||||
let to = check_name("layout.rename", s.doc()?, to, Some(&from))?;
|
||||
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == from) {
|
||||
l.name = to.clone();
|
||||
}
|
||||
let (old, new) = (Space::Paper(from.clone()), Space::Paper(to.clone()));
|
||||
let st = s.state_mut()?;
|
||||
for (sp, _) in st.views.iter_mut() {
|
||||
if *sp == old {
|
||||
*sp = new.clone();
|
||||
}
|
||||
}
|
||||
if st.space == old {
|
||||
st.space = new;
|
||||
}
|
||||
s.touch();
|
||||
Ok(json!({ "from": from, "to": to }))
|
||||
}
|
||||
|
||||
fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = str_param(p, "from").ok_or_else(|| bad("layout.copy", "`from` is required"))?;
|
||||
let from = layout_name("layout.copy", s.doc()?, from)?;
|
||||
let to = match str_param(p, "to") {
|
||||
Some(t) => check_name("layout.copy", s.doc()?, t, None)?,
|
||||
None => {
|
||||
let d = s.doc()?;
|
||||
(2..=10_000).map(|i| format!("{from} ({i})")).find(|n| d.layout(n).is_none()).ok_or_else(|| bad("layout.copy", "no free name"))?
|
||||
}
|
||||
};
|
||||
let d = s.doc_mut()?;
|
||||
let Some(src) = d.layouts.iter().find(|l| l.name == from).cloned() else { return Err(bad("layout.copy", "no such layout")) };
|
||||
let order = d.layouts.iter().map(|l| l.tab_order).max().unwrap_or(0).saturating_add(1);
|
||||
let mut store = EntityStore::new();
|
||||
for e in src.entities.iter() {
|
||||
let h = d.new_handle();
|
||||
store.push(Entity { handle: h, ..Entity::clone(e) });
|
||||
}
|
||||
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view });
|
||||
Ok(json!({ "name": to }))
|
||||
}
|
||||
|
||||
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
let mut ls: Vec<&Layout> = d.layouts.iter().collect();
|
||||
ls.sort_by_key(|l| l.tab_order);
|
||||
let current = s.space();
|
||||
Ok(Value::Array(
|
||||
ls.iter()
|
||||
.map(|l| {
|
||||
json!({
|
||||
"name": l.name,
|
||||
"tabOrder": l.tab_order,
|
||||
"current": current == Space::Paper(l.name.clone()),
|
||||
"page": serde_json::to_value(&l.page).unwrap_or(Value::Null),
|
||||
"viewports": l.entities.iter().filter(|e| matches!(&e.kind, EntityKind::Viewport(v) if v.id != 1)).map(|e| e.handle.hex()).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `-LAYOUT`-style dispatcher.
|
||||
fn run_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let opt = str_param(p, "option").unwrap_or("list").to_ascii_lowercase();
|
||||
match opt.as_str() {
|
||||
"new" | "n" => run_new(s, p),
|
||||
"copy" | "c" => {
|
||||
let mut q = p.clone();
|
||||
if q.get("from").is_none()
|
||||
&& let (Some(n), Some(o)) = (p.get("name").cloned(), q.as_object_mut())
|
||||
{
|
||||
o.insert("from".into(), n);
|
||||
}
|
||||
run_copy(s, &q)
|
||||
}
|
||||
"delete" | "d" => run_delete(s, p),
|
||||
"rename" | "r" => {
|
||||
let mut q = p.clone();
|
||||
if q.get("from").is_none()
|
||||
&& let (Some(n), Some(o)) = (p.get("name").cloned(), q.as_object_mut())
|
||||
{
|
||||
o.insert("from".into(), n);
|
||||
}
|
||||
run_rename(s, &q)
|
||||
}
|
||||
"set" | "s" => {
|
||||
let set = find_command("layout.set").ok_or_else(|| bad("layout", "layout.set is missing"))?;
|
||||
(set.run)(s, p)
|
||||
}
|
||||
"list" | "?" => run_list(s, p),
|
||||
_ => Err(bad("layout", "option must be new, copy, delete, rename, set or list")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- viewports ----------
|
||||
|
||||
/// The printable area of a layout, in paper units.
|
||||
fn printable(s: &Session, layout: &str) -> Result<Bounds2> {
|
||||
cadcraft_render::sheet(s.doc()?, layout).map(|sh| sh.printable).ok_or_else(|| bad("mview", format!("no layout `{layout}`")))
|
||||
}
|
||||
|
||||
/// The layout a viewport command acts on: `layout` param or the current layout.
|
||||
fn target_layout(s: &Session, cmd: &str, p: &Value) -> Result<Space> {
|
||||
if let Some(n) = str_param(p, "layout") {
|
||||
return Ok(Space::Paper(layout_name(cmd, s.doc()?, n)?));
|
||||
}
|
||||
match s.space() {
|
||||
Space::Model => Err(bad(cmd, "** Command not allowed in Model Tab ** (switch to a layout or pass `layout`)")),
|
||||
sp => Ok(sp),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a rectangle into `count` viewport rectangles (AutoCAD's VPORTS arrangements).
|
||||
fn split(r: Bounds2, count: usize, arrangement: &str) -> Vec<Bounds2> {
|
||||
let (x0, y0, x1, y1) = (r.min.x, r.min.y, r.max.x, r.max.y);
|
||||
let xm = (x0 + x1) / 2.0;
|
||||
let ym = (y0 + y1) / 2.0;
|
||||
let b = |a: f64, b: f64, c: f64, d: f64| Bounds2::new(Vec2::new(a, b), Vec2::new(c, d));
|
||||
let a = arrangement.to_ascii_lowercase();
|
||||
match count {
|
||||
2 if a.starts_with('h') => vec![b(x0, ym, x1, y1), b(x0, y0, x1, ym)],
|
||||
2 => vec![b(x0, y0, xm, y1), b(xm, y0, x1, y1)],
|
||||
3 => {
|
||||
let (t1, t2) = (x0 + (x1 - x0) / 3.0, x0 + 2.0 * (x1 - x0) / 3.0);
|
||||
let (u1, u2) = (y0 + (y1 - y0) / 3.0, y0 + 2.0 * (y1 - y0) / 3.0);
|
||||
match a.as_str() {
|
||||
"left" | "l" => vec![b(x0, y0, xm, y1), b(xm, ym, x1, y1), b(xm, y0, x1, ym)],
|
||||
"above" | "a" => vec![b(x0, ym, x1, y1), b(x0, y0, xm, ym), b(xm, y0, x1, ym)],
|
||||
"below" | "b" => vec![b(x0, y0, x1, ym), b(x0, ym, xm, y1), b(xm, ym, x1, y1)],
|
||||
"vertical" | "v" => vec![b(x0, y0, t1, y1), b(t1, y0, t2, y1), b(t2, y0, x1, y1)],
|
||||
"horizontal" | "h" => vec![b(x0, u2, x1, y1), b(x0, u1, x1, u2), b(x0, y0, x1, u1)],
|
||||
// Right (the default): two stacked on the left, the large one on the right.
|
||||
_ => vec![b(x0, ym, xm, y1), b(x0, y0, xm, ym), b(xm, y0, x1, y1)],
|
||||
}
|
||||
}
|
||||
4 => vec![b(x0, ym, xm, y1), b(xm, ym, x1, y1), b(x0, y0, xm, ym), b(xm, y0, x1, ym)],
|
||||
_ => vec![r],
|
||||
}
|
||||
}
|
||||
|
||||
/// View height that shows `ext` in a viewport of the given size (zoom extents).
|
||||
fn fit_view(d: &Drawing, w: f64, h: f64) -> (Vec2, f64) {
|
||||
let mut ext = d.extents(&Space::Model);
|
||||
if ext.is_empty() {
|
||||
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
|
||||
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
|
||||
ext = Bounds2::new(lo, hi);
|
||||
}
|
||||
let aspect = (w / h.max(1e-12)).max(1e-12);
|
||||
let vh = (ext.height().max(ext.width() / aspect) * 1.02).max(1e-9);
|
||||
let c = ext.center();
|
||||
if vh.is_finite() && c.is_finite() { (c, vh) } else { (Vec2::ZERO, 1.0) }
|
||||
}
|
||||
|
||||
/// Add `count` viewports tiling `rect` in a layout, each zoomed to the model extents.
|
||||
fn add_viewports(s: &mut Session, space: &Space, rect: Bounds2, count: usize, arrangement: &str) -> Result<Vec<Handle>> {
|
||||
if !(1..=4).contains(&count) {
|
||||
return Err(bad("mview", "count must be 1, 2, 3 or 4"));
|
||||
}
|
||||
if rect.is_empty() || !rect.min.is_finite() || !rect.max.is_finite() || rect.width() < 1e-6 || rect.height() < 1e-6 {
|
||||
return Err(bad("mview", "the viewport has no area"));
|
||||
}
|
||||
if rect.width() > 1e7 || rect.height() > 1e7 {
|
||||
return Err(bad("mview", "the viewport is too large"));
|
||||
}
|
||||
let common = s.current_common();
|
||||
let d = s.doc_mut()?;
|
||||
let mut id = d
|
||||
.layouts
|
||||
.iter()
|
||||
.flat_map(|l| l.entities.iter())
|
||||
.filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some(v.id) } else { None })
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let mut out = Vec::new();
|
||||
for r in split(rect, count, arrangement) {
|
||||
let (view_center, view_height) = fit_view(d, r.width(), r.height());
|
||||
id = id.saturating_add(1);
|
||||
let vp = Viewport {
|
||||
center: r.center().to3(0.0),
|
||||
width: r.width(),
|
||||
height: r.height(),
|
||||
view_center,
|
||||
view_height,
|
||||
id,
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
};
|
||||
out.push(d.add(space, common.clone(), EntityKind::Viewport(vp))?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn mview(s: &mut Session, p: &Value, count: usize, cmd: &str) -> Result<Value> {
|
||||
let space = target_layout(s, cmd, p)?;
|
||||
let Space::Paper(name) = &space else { return Err(bad(cmd, "not a layout")) };
|
||||
let rect = match (point_param(p, "p1"), point_param(p, "p2")) {
|
||||
(Some(a), Some(b)) => Bounds2::new(a, b),
|
||||
_ => printable(s, name)?,
|
||||
};
|
||||
let arrangement = str_param(p, "arrangement").unwrap_or("");
|
||||
let hs = add_viewports(s, &space, rect, count, arrangement)?;
|
||||
Ok(json!({ "viewports": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn run_mview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let count = match p.get("count") {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("mview", "count must be 1, 2, 3 or 4"))?,
|
||||
};
|
||||
mview(s, p, count, "mview")
|
||||
}
|
||||
|
||||
fn run_mview_n(s: &mut Session, p: &Value, n: usize) -> Result<Value> {
|
||||
mview(s, p, n, "vports")
|
||||
}
|
||||
|
||||
/// MVIEW / VPORTS prompts: first corner (or Fit, or a viewport count), opposite corner.
|
||||
struct MviewM {
|
||||
count: usize,
|
||||
/// Offer the 2/3/4 keywords (MVIEW) rather than a fixed count (View > Viewports > n).
|
||||
choose: bool,
|
||||
first: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl MviewM {
|
||||
fn new(count: usize, choose: bool) -> Self {
|
||||
MviewM { count, choose, first: None }
|
||||
}
|
||||
fn create(&self, s: &mut Session, rect: Option<Bounds2>) -> Result<Step> {
|
||||
let mut p = json!({ "count": self.count });
|
||||
if let (Some(r), Some(o)) = (rect, p.as_object_mut()) {
|
||||
o.insert("p1".into(), json!([r.min.x, r.min.y]));
|
||||
o.insert("p2".into(), json!([r.max.x, r.max.y]));
|
||||
}
|
||||
let r = mview(s, &p, self.count, "mview")?;
|
||||
let n = r.get("viewports").and_then(Value::as_array).map(Vec::len).unwrap_or(0);
|
||||
s.echo(format!("{n} viewport(s) created, zoomed to the model extents."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for MviewM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MVIEW"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if s.space() == Space::Model {
|
||||
s.echo("** Command not allowed in Model Tab ** (switch to a layout first)");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.first {
|
||||
None if self.choose && self.count == 1 => {
|
||||
Prompt::new("Specify corner of viewport", Accept::POINT).kw(&["Fit", "2", "3", "4"]).default("Fit")
|
||||
}
|
||||
None => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
|
||||
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.first, i) {
|
||||
(None, Input::Enter) => self.create(s, None),
|
||||
(None, Input::Keyword(k)) | (None, Input::Text(k)) => match k.trim().to_ascii_lowercase().as_str() {
|
||||
"fit" | "f" => self.create(s, None),
|
||||
n @ ("2" | "3" | "4") if self.choose => {
|
||||
self.count = n.parse().unwrap_or(1);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Err(crate::EngineError::Other("Requires a point or option keyword.".into())),
|
||||
},
|
||||
(None, Input::Point(p)) => {
|
||||
self.first = Some(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(a), Input::Point(b)) => self.create(s, Some(Bounds2::new(a, b))),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let Some(a) = self.first else { return Vec::new() };
|
||||
split(Bounds2::new(a, c), self.count, "")
|
||||
.into_iter()
|
||||
.map(|r| super::helpers::lwpoly(super::helpers::rect_vertices(r.min, r.max), true))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A plot scale: a number (paper units per model unit) or `"a:b"` / `"a/b"`.
|
||||
fn parse_scale(v: &Value) -> Option<f64> {
|
||||
let s = match v {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
Value::String(t) => {
|
||||
let t = t.trim();
|
||||
match t.split_once([':', '/']) {
|
||||
Some((a, b)) => {
|
||||
let a: f64 = a.trim().parse().ok()?;
|
||||
let b: f64 = b.trim().parse().ok()?;
|
||||
Some(a / b)
|
||||
}
|
||||
None => t.trim_end_matches(['x', 'X']).parse().ok(),
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}?;
|
||||
(s.is_finite() && s > 0.0).then_some(s)
|
||||
}
|
||||
|
||||
fn layer_list(p: &Value, key: &str) -> Vec<String> {
|
||||
match p.get(key) {
|
||||
Some(Value::String(s)) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).take(10_000).collect(),
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).take(10_000).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn viewport_json(h: Handle, v: &Viewport) -> Value {
|
||||
json!({
|
||||
"handle": h.hex(),
|
||||
"id": v.id,
|
||||
"center": [v.center.x, v.center.y],
|
||||
"width": v.width,
|
||||
"height": v.height,
|
||||
"viewCenter": [v.view_center.x, v.view_center.y],
|
||||
"viewHeight": v.view_height,
|
||||
"scale": if v.view_height > 0.0 { v.height / v.view_height } else { 0.0 },
|
||||
"locked": v.locked,
|
||||
"frozenLayers": v.frozen_layers,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_viewport_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "viewport.set";
|
||||
let d = s.doc()?;
|
||||
let hs: Vec<Handle> =
|
||||
targets(s, p)?.into_iter().filter(|h| d.entity(*h).is_some_and(|e| matches!(&e.kind, EntityKind::Viewport(v) if v.id != 1))).collect();
|
||||
if hs.is_empty() {
|
||||
return Err(bad(cmd, "no viewport given (pass `handle` or select a viewport)"));
|
||||
}
|
||||
let scale = match p.get("scale") {
|
||||
Some(v) => Some(parse_scale(v).ok_or_else(|| bad(cmd, "`scale` must be a positive number or \"a:b\""))?),
|
||||
None => None,
|
||||
};
|
||||
let view_height = match p.get("viewHeight") {
|
||||
Some(v) => Some(v.as_f64().filter(|x| x.is_finite() && *x > 0.0).ok_or_else(|| bad(cmd, "`viewHeight` must be positive"))?),
|
||||
None => None,
|
||||
};
|
||||
let center = match p.get("center") {
|
||||
Some(v) => Some(point_value(v).ok_or_else(|| bad(cmd, "`center` must be [x, y]"))?),
|
||||
None => None,
|
||||
};
|
||||
let locked = p.get("locked").and_then(Value::as_bool);
|
||||
let freeze = layer_list(p, "freeze");
|
||||
let thaw = layer_list(p, "thaw");
|
||||
let changes_view = scale.is_some() || view_height.is_some() || center.is_some();
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
let Some(EntityKind::Viewport(cur)) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue };
|
||||
if changes_view && cur.locked && locked != Some(false) {
|
||||
return Err(bad(cmd, format!("viewport {} is locked", h.hex())));
|
||||
}
|
||||
let mut v = cur;
|
||||
if let Some(sc) = scale {
|
||||
let vh = v.height / sc;
|
||||
if vh.is_finite() && vh > 0.0 {
|
||||
v.view_height = vh;
|
||||
}
|
||||
}
|
||||
if let Some(vh) = view_height {
|
||||
v.view_height = vh;
|
||||
}
|
||||
if let Some(c) = center {
|
||||
v.view_center = c;
|
||||
}
|
||||
if let Some(l) = locked {
|
||||
v.locked = l;
|
||||
}
|
||||
for f in &freeze {
|
||||
if !v.frozen_layers.iter().any(|x| x.eq_ignore_ascii_case(f)) {
|
||||
v.frozen_layers.push(f.clone());
|
||||
}
|
||||
}
|
||||
v.frozen_layers.retain(|x| !thaw.iter().any(|t| t.eq_ignore_ascii_case(x)));
|
||||
out.push(viewport_json(h, &v));
|
||||
s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::Viewport(v)).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
// ---------- page setup ----------
|
||||
|
||||
fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "pagesetup";
|
||||
let keys = ["paper", "width", "height", "landscape", "margins", "lineweights", "plotArea", "scale", "scaleToFit", "center", "plotStyleTable"];
|
||||
let changes = keys.iter().any(|k| p.get(k).is_some());
|
||||
let name = match (str_param(p, "layout"), s.space()) {
|
||||
(Some(n), _) => Some(layout_name(cmd, s.doc()?, n)?),
|
||||
(None, Space::Paper(n)) => Some(n),
|
||||
(None, Space::Model) => None,
|
||||
};
|
||||
let Some(name) = name else {
|
||||
if changes {
|
||||
return Err(bad(cmd, "pass `layout` (model-space page setups are not stored yet)"));
|
||||
}
|
||||
// Report every layout's page setup.
|
||||
let d = s.doc()?;
|
||||
return Ok(json!({
|
||||
"layouts": d.layouts.iter().map(|l| json!({"name": l.name, "page": serde_json::to_value(&l.page).unwrap_or(Value::Null)})).collect::<Vec<_>>(),
|
||||
"papers": cadcraft_render::PAPER_SIZES.iter().map(|p| p.name).collect::<Vec<_>>(),
|
||||
}));
|
||||
};
|
||||
let mut page = s.doc()?.layout(&name).map(|l| l.page.clone()).unwrap_or_default();
|
||||
if let Some(paper) = p.get("paper") {
|
||||
let n = paper.as_str().ok_or_else(|| bad(cmd, "`paper` must be a paper name"))?;
|
||||
let ps = cadcraft_render::paper_size(n).ok_or_else(|| {
|
||||
let known: Vec<&str> = cadcraft_render::PAPER_SIZES.iter().filter_map(|p| p.aliases.first().copied()).collect();
|
||||
bad(cmd, format!("unknown paper size `{n}` (known: {})", known.join(", ")))
|
||||
})?;
|
||||
page.paper = ps.name.into();
|
||||
page.width_mm = ps.width_mm;
|
||||
page.height_mm = ps.height_mm;
|
||||
}
|
||||
let mm = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0 && *v <= 5080.0);
|
||||
match (mm("width"), mm("height")) {
|
||||
(Some(w), Some(h)) => {
|
||||
page.paper = format!("User ({w:.2} x {h:.2} MM)");
|
||||
page.width_mm = w.min(h);
|
||||
page.height_mm = w.max(h);
|
||||
}
|
||||
(None, None) if p.get("width").is_none() && p.get("height").is_none() => {}
|
||||
_ => return Err(bad(cmd, "`width` and `height` (mm, 0-5080) are both required for a custom size")),
|
||||
}
|
||||
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
|
||||
page.landscape = l;
|
||||
}
|
||||
if let Some(m) = p.get("margins") {
|
||||
let a: Vec<f64> = m.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
match a[..] {
|
||||
[l, b, r, t] if a.iter().all(|v| v.is_finite() && (0.0..=1000.0).contains(v)) => page.margins_mm = [l, b, r, t],
|
||||
_ => return Err(bad(cmd, "`margins` must be [left, bottom, right, top] in mm")),
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("lineweights").and_then(Value::as_bool) {
|
||||
page.lineweights = v;
|
||||
}
|
||||
if let Some(v) = p.get("scaleToFit").and_then(Value::as_bool) {
|
||||
page.scale_to_fit = v;
|
||||
}
|
||||
if let Some(v) = p.get("center").and_then(Value::as_bool) {
|
||||
page.center = v;
|
||||
}
|
||||
if let Some(v) = p.get("scale") {
|
||||
page.scale = parse_scale(v).ok_or_else(|| bad(cmd, "`scale` must be positive"))?;
|
||||
}
|
||||
if let Some(v) = str_param(p, "plotArea") {
|
||||
let v = v.to_ascii_lowercase();
|
||||
if !["layout", "extents", "display", "window", "limits"].contains(&v.as_str()) {
|
||||
return Err(bad(cmd, "`plotArea` must be layout, extents, display, window or limits"));
|
||||
}
|
||||
page.plot_area = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "plotStyleTable") {
|
||||
page.plot_style_table = v.chars().take(260).collect();
|
||||
}
|
||||
if changes {
|
||||
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == name) {
|
||||
l.page = page.clone();
|
||||
}
|
||||
s.touch();
|
||||
}
|
||||
Ok(json!({ "layout": name, "page": serde_json::to_value(&page).unwrap_or(Value::Null) }))
|
||||
}
|
||||
|
||||
// ---------- plotting ----------
|
||||
|
||||
fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let hook = io().and_then(|h| h.plot).ok_or_else(|| bad(cmd, "plotting is not available in this build"))?;
|
||||
let space = match str_param(p, "layout") {
|
||||
Some(n) if n.eq_ignore_ascii_case("model") => Space::Model,
|
||||
Some(n) => Space::Paper(layout_name(cmd, s.doc()?, n)?),
|
||||
None => s.space(),
|
||||
};
|
||||
let opts = if p.is_object() { p.clone() } else { json!({}) };
|
||||
let bytes = hook(s.doc()?, &space, &opts).map_err(|e| bad(cmd, e))?;
|
||||
let layout = match &space {
|
||||
Space::Model => "Model".to_string(),
|
||||
Space::Paper(n) => n.clone(),
|
||||
};
|
||||
match str_param(p, "path") {
|
||||
Some(_path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = _path;
|
||||
let tmp = format!("{path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout }))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad(cmd, "paths are not available on the web; omit `path` to get the PDF as base64 `data`"))
|
||||
}
|
||||
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout })),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session_with_model() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("line 0,0 100,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("circle 50,25 20").unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn viewports(s: &Session, layout: &str) -> Vec<(Handle, Viewport)> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.layout(layout)
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some((e.handle, v.clone())) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layout_gets_viewport_zoomed_to_extents() {
|
||||
let mut s = session_with_model();
|
||||
let r = s.execute("layout.new", &json!({})).unwrap();
|
||||
assert_eq!(r["name"], "Layout3");
|
||||
let vps = viewports(&s, "Layout3");
|
||||
assert_eq!(vps.len(), 1);
|
||||
let (_, v) = &vps[0];
|
||||
let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout3").unwrap();
|
||||
assert!((v.width - sheet.printable.width()).abs() < 1e-9 && (v.height - sheet.printable.height()).abs() < 1e-9);
|
||||
assert!(v.id >= 2);
|
||||
// Model extents (0,0)..(100,45) are centred and fully visible.
|
||||
assert!(v.view_center.near(Vec2::new(50.0, 22.5), 1e-6));
|
||||
let model_w = v.view_height * v.width / v.height;
|
||||
assert!(model_w >= 100.0 && v.view_height >= 45.0);
|
||||
// The render shows model geometry inside the viewport only.
|
||||
let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout3".into()), &cadcraft_render::Options::default());
|
||||
let rect = Bounds2::new(v.center.xy() - Vec2::new(v.width, v.height) / 2.0, v.center.xy() + Vec2::new(v.width, v.height) / 2.0).expand(1e-9);
|
||||
let content: Vec<_> = list.prims.iter().filter(|p| p.handle == cadcraft_render::VIEWPORT_CONTENT).collect();
|
||||
assert!(content.len() >= 2);
|
||||
assert!(content.iter().all(|p| list.points(p).iter().all(|q| rect.contains(*q))));
|
||||
assert!(list.sheet.is_some());
|
||||
// Undo removes the layout.
|
||||
s.undo().unwrap();
|
||||
assert!(s.doc().unwrap().layout("Layout3").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_copy_delete() {
|
||||
let mut s = session_with_model();
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
s.execute("layout.rename", &json!({"to": "Sheet A"})).unwrap();
|
||||
assert_eq!(s.space(), Space::Paper("Sheet A".into()));
|
||||
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "Layout2"})).is_err(), "duplicate");
|
||||
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "a/b"})).is_err(), "bad char");
|
||||
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "model"})).is_err(), "reserved");
|
||||
s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap();
|
||||
let r = s.execute("layout.copy", &json!({"from": "Sheet A"})).unwrap();
|
||||
assert_eq!(r["name"], "Sheet A (2)");
|
||||
let a = viewports(&s, "Sheet A");
|
||||
let b = viewports(&s, "Sheet A (2)");
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(b.len(), 1);
|
||||
assert_ne!(a[0].0, b[0].0, "copied entities get new handles");
|
||||
s.execute("layout.delete", &json!({"name": "sheet a"})).unwrap();
|
||||
assert_eq!(s.space(), Space::Model, "deleting the current layout returns to Model");
|
||||
s.execute("layout", &json!({"option": "delete", "name": "Layout2"})).unwrap();
|
||||
assert!(s.execute("layout.delete", &json!({"name": "Sheet A (2)"})).is_err(), "last layout stays");
|
||||
let l = s.execute("layout", &json!({"option": "list"})).unwrap();
|
||||
assert_eq!(l.as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("layout.delete", &json!({"name": "Model"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mview_arrangements_and_interactive() {
|
||||
let mut s = session_with_model();
|
||||
assert!(s.execute("mview", &json!({"p1": [0, 0], "p2": [1, 1]})).is_err(), "not in model space");
|
||||
s.execute("mview", &json!({"layout": "Layout2", "count": 4})).unwrap();
|
||||
assert_eq!(viewports(&s, "Layout2").len(), 4);
|
||||
s.execute("vports.3", &json!({"layout": "Layout2", "p1": [0, 0], "p2": [9, 6]})).unwrap();
|
||||
let v = viewports(&s, "Layout2");
|
||||
assert_eq!(v.len(), 7);
|
||||
let total: f64 = v[4..].iter().map(|(_, v)| v.width * v.height).sum();
|
||||
assert!((total - 54.0).abs() < 1e-9, "three viewports tile the window");
|
||||
// Interactive MVIEW: two corners.
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
s.cmdline("mview").unwrap();
|
||||
s.cmdline("1,1").unwrap();
|
||||
s.cmdline("6,4").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let v = viewports(&s, "Layout1");
|
||||
assert_eq!(v.len(), 1);
|
||||
assert!((v[0].1.width - 5.0).abs() < 1e-9 && (v[0].1.height - 3.0).abs() < 1e-9);
|
||||
// MVIEW 2 → Fit (Enter).
|
||||
s.cmdline("mview").unwrap();
|
||||
s.cmdline("2").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(viewports(&s, "Layout1").len(), 3);
|
||||
// One undo step per MVIEW.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(viewports(&s, "Layout1").len(), 1);
|
||||
// In model space the interactive command ends at once.
|
||||
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
|
||||
s.cmdline("vports.4").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_scale_lock_and_layers() {
|
||||
let mut s = session_with_model();
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
let r = s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap();
|
||||
let h = r["viewports"][0].as_str().unwrap().to_string();
|
||||
let out = s.execute("viewport.set", &json!({"handle": h, "scale": "1:20", "center": [50, 20]})).unwrap();
|
||||
assert!((out[0]["viewHeight"].as_f64().unwrap() - 60.0).abs() < 1e-9);
|
||||
assert!((out[0]["scale"].as_f64().unwrap() - 0.05).abs() < 1e-12);
|
||||
s.execute("viewport.set", &json!({"handle": h, "locked": true})).unwrap();
|
||||
assert!(s.execute("viewport.set", &json!({"handle": h, "scale": 0.1})).is_err(), "locked");
|
||||
s.execute("viewport.set", &json!({"handle": h, "scale": 0.1, "locked": false})).unwrap();
|
||||
s.execute("vplayer", &json!({"handle": h, "freeze": ["0", "Walls"]})).unwrap();
|
||||
s.execute("vplayer", &json!({"handle": h, "thaw": "walls"})).unwrap();
|
||||
let v = viewports(&s, "Layout1");
|
||||
assert_eq!(v[0].1.frozen_layers, vec!["0".to_string()]);
|
||||
assert!((v[0].1.view_height - 30.0).abs() < 1e-9);
|
||||
// Layer 0 frozen in the viewport: nothing of the model shows.
|
||||
let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout1".into()), &cadcraft_render::Options::default());
|
||||
assert!(list.prims.iter().all(|p| p.handle != cadcraft_render::VIEWPORT_CONTENT));
|
||||
for bad_p in [
|
||||
json!({"handle": h, "scale": "0:0"}),
|
||||
json!({"handle": h, "scale": -1}),
|
||||
json!({"handle": h, "viewHeight": 0}),
|
||||
json!({"handle": "FFFFFF"}),
|
||||
] {
|
||||
assert!(s.execute("viewport.set", &bad_p).is_err(), "{bad_p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagesetup_paper_table() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "A3", "landscape": true, "margins": [5, 5, 5, 5]})).unwrap();
|
||||
assert_eq!(r["page"]["widthMm"], 297.0);
|
||||
let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout1").unwrap();
|
||||
// Imperial drawing: paper units are inches.
|
||||
assert!((sheet.size.x - 420.0 / 25.4).abs() < 1e-9 && (sheet.size.y - 297.0 / 25.4).abs() < 1e-9);
|
||||
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "Z12"})).is_err());
|
||||
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "margins": [1, 2]})).is_err());
|
||||
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "width": 1e308, "height": 5})).is_err());
|
||||
assert!(s.execute("pagesetup", &json!({"paper": "A4"})).is_err(), "model tab needs a layout");
|
||||
let report = s.execute("pagesetup", &Value::Null).unwrap();
|
||||
assert!(report["papers"].as_array().unwrap().len() >= 10);
|
||||
s.execute("pagesetup", &json!({"layout": "Layout2", "width": 500, "height": 300})).unwrap();
|
||||
let p = &s.doc().unwrap().layout("Layout2").unwrap().page;
|
||||
assert_eq!((p.width_mm, p.height_mm), (300.0, 500.0));
|
||||
}
|
||||
|
||||
fn fake_plot(d: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
|
||||
let _ = d;
|
||||
Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_goes_through_the_hook() {
|
||||
super::super::file::set_io(super::super::file::IoHooks {
|
||||
read: |_, _| Err("no reader in tests".into()),
|
||||
write: |_, _| Err("no writer in tests".into()),
|
||||
plot: Some(fake_plot),
|
||||
});
|
||||
let mut s = session_with_model();
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return; // another test installed hooks without a plotter first
|
||||
}
|
||||
let r = s.execute("plot", &json!({"layout": "Layout1", "paper": "A4"})).unwrap();
|
||||
let data = super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap();
|
||||
let text = String::from_utf8(data).unwrap();
|
||||
assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4"));
|
||||
let r = s.execute("exportpdf", &json!({})).unwrap();
|
||||
assert_eq!(r["layout"], "Model");
|
||||
assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-plot-test-{}.pdf", std::process::id()));
|
||||
let ps = path.to_string_lossy().to_string();
|
||||
let r = s.execute("plot", &json!({"path": ps})).unwrap();
|
||||
assert!(r["bytes"].as_u64().unwrap() > 0);
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_layout_params() {
|
||||
let mut s = session_with_model();
|
||||
let long = "x".repeat(300);
|
||||
for (c, p) in [
|
||||
("layout.new", json!({"name": ""})),
|
||||
("layout.new", json!({"name": long})),
|
||||
("layout.new", json!({"name": "a\u{0}b"})),
|
||||
("layout.new", json!({"name": "Layout1"})),
|
||||
("mview", json!({"layout": "Layout1", "p1": [1e308, -1e308], "p2": [-1e308, 1e308]})),
|
||||
("mview", json!({"layout": "Layout1", "p1": [1, 1], "p2": [1, 1]})),
|
||||
("mview", json!({"layout": "Layout1", "count": 99})),
|
||||
("mview", json!({"layout": "Layout1", "count": -1})),
|
||||
("layout.rename", json!({"from": "Layout1"})),
|
||||
("layout.copy", json!({"from": "nope"})),
|
||||
("viewport.set", json!({"handles": ["zz"]})),
|
||||
] {
|
||||
assert!(s.execute(c, &p).is_err(), "{c} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,22 @@
|
||||
mod annotate;
|
||||
mod blocks;
|
||||
mod draw;
|
||||
mod draw2;
|
||||
mod edit;
|
||||
pub mod file;
|
||||
mod gripcmds;
|
||||
mod hatch;
|
||||
mod inquiry;
|
||||
mod layer;
|
||||
mod layout;
|
||||
mod modify;
|
||||
mod modify2;
|
||||
mod props;
|
||||
mod settings;
|
||||
mod utility;
|
||||
mod view;
|
||||
|
||||
pub mod curves;
|
||||
pub mod helpers;
|
||||
pub mod machines;
|
||||
|
||||
@@ -142,14 +148,19 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(edit::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(draw::specs());
|
||||
v.extend(draw2::specs());
|
||||
v.extend(annotate::specs());
|
||||
v.extend(hatch::specs());
|
||||
v.extend(blocks::specs());
|
||||
v.extend(modify::specs());
|
||||
v.extend(modify2::specs());
|
||||
v.extend(gripcmds::specs());
|
||||
v.extend(layer::specs());
|
||||
v.extend(layout::specs());
|
||||
v.extend(props::specs());
|
||||
v.extend(inquiry::specs());
|
||||
v.extend(settings::specs());
|
||||
v.extend(utility::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("fillet", "Fillet", run_fillet)
|
||||
.menu(&["Modify", "Fillet"])
|
||||
.alias(&["f"])
|
||||
.params("{h1, p1, h2, p2, radius?}")
|
||||
.params("{h1, p1, h2, p2, radius?} (lines, arcs, circles) | {handle, polyline: true, radius?}")
|
||||
.interactive(|s| Ok(Box::new(FilletM::new(s, false)))),
|
||||
CommandSpec::new("chamfer", "Chamfer", run_chamfer)
|
||||
.menu(&["Modify", "Chamfer"])
|
||||
@@ -549,7 +549,7 @@ pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Handl
|
||||
keep.into_iter().collect()
|
||||
}
|
||||
EntityKind::LwPolyline(p) => trim_polyline(p, &cut, pick)?,
|
||||
_ => return Err(EngineError::Other("Cannot trim that object.".into())),
|
||||
k => super::modify2::trim_other(k, &cut, pick)?,
|
||||
};
|
||||
let common = e.common.clone();
|
||||
let space = s.space();
|
||||
@@ -711,7 +711,7 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Han
|
||||
}
|
||||
lwpoly(vs, false)
|
||||
}
|
||||
_ => return Err(EngineError::Other("Cannot extend that object.".into())),
|
||||
k => super::modify2::extend_other(k, &cut, pick)?,
|
||||
};
|
||||
s.doc_mut()?.modify_entity(h, |e| e.kind = new_kind)?;
|
||||
Ok(())
|
||||
@@ -760,7 +760,10 @@ pub(crate) fn fillet_lines(
|
||||
let e1 = d.entity(h1).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let e2 = d.entity(h2).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let (Some(l1), Some(l2)) = (as_line(&e1), as_line(&e2)) else {
|
||||
return Err(EngineError::Other("Fillet/chamfer currently works on lines.".into()));
|
||||
if chamfer.is_none() {
|
||||
return super::modify2::fillet_curves(s, h1, p1, h2, p2, radius);
|
||||
}
|
||||
return Err(EngineError::Other("Chamfer currently works on lines.".into()));
|
||||
};
|
||||
let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?;
|
||||
// Keep the far end on the picked side of the corner.
|
||||
@@ -820,6 +823,12 @@ fn h_param(p: &Value, k: &str) -> Option<Handle> {
|
||||
}
|
||||
|
||||
fn run_fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "polyline", false) {
|
||||
let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("fillet", "`handle` (polyline) is required"))?;
|
||||
let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0));
|
||||
let n = super::modify2::fillet_polyline(s, h, r)?;
|
||||
return Ok(json!({ "filleted": n }));
|
||||
}
|
||||
let h1 = h_param(p, "h1").ok_or_else(|| bad("fillet", "`h1` is required"))?;
|
||||
let h2 = h_param(p, "h2").ok_or_else(|| bad("fillet", "`h2` is required"))?;
|
||||
let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0));
|
||||
@@ -1752,11 +1761,12 @@ struct FilletM {
|
||||
chamfer: bool,
|
||||
first: Option<(Handle, Vec2)>,
|
||||
asking: bool,
|
||||
polyline: bool,
|
||||
}
|
||||
|
||||
impl FilletM {
|
||||
fn new(_s: &Session, chamfer: bool) -> Self {
|
||||
FilletM { chamfer, first: None, asking: false }
|
||||
FilletM { chamfer, first: None, asking: false, polyline: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1779,6 +1789,9 @@ impl Interactive for FilletM {
|
||||
if self.asking {
|
||||
return Prompt::new(if self.chamfer { "Specify first chamfer distance" } else { "Specify fillet radius" }, Accept::NUMBER);
|
||||
}
|
||||
if self.polyline {
|
||||
return Prompt::new("Select 2D polyline", Accept::POINT);
|
||||
}
|
||||
match (self.first, self.chamfer) {
|
||||
(None, false) => Prompt::new("Select first object", Accept::POINT).kw(&["Undo", "Polyline", "Radius", "Trim", "Multiple"]),
|
||||
(None, true) => {
|
||||
@@ -1808,6 +1821,10 @@ impl Interactive for FilletM {
|
||||
self.asking = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Polyline" && !self.chamfer => {
|
||||
self.polyline = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
@@ -1815,6 +1832,12 @@ impl Interactive for FilletM {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
if self.polyline {
|
||||
let r = s.doc()?.header.f64("FILLETRAD", 0.0);
|
||||
let n = super::modify2::fillet_polyline(s, h, r)?;
|
||||
s.echo(format!("{n} lines were filleted"));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
match self.first {
|
||||
None => {
|
||||
self.first = Some((h, p));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,607 @@
|
||||
use cadcraft_doc::{EntityKind, Handle, Space};
|
||||
use cadcraft_geom::{Arc, Polyline, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn last(s: &Session) -> EntityKind {
|
||||
s.doc().unwrap().model.last().unwrap().kind.clone()
|
||||
}
|
||||
|
||||
fn kind(s: &Session, h: &str) -> EntityKind {
|
||||
s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind.clone()
|
||||
}
|
||||
|
||||
fn h(r: &Value) -> String {
|
||||
r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn near(a: Vec2, x: f64, y: f64) -> bool {
|
||||
a.near(Vec2::new(x, y), 1e-6)
|
||||
}
|
||||
|
||||
fn pline(s: &mut Session, pts: Value, closed: bool) -> String {
|
||||
h(&s.execute("pline", &json!({"vertices": pts, "closed": closed})).unwrap())
|
||||
}
|
||||
|
||||
fn poly(k: &EntityKind) -> cadcraft_doc::LwPolyline {
|
||||
match k {
|
||||
EntityKind::LwPolyline(p) => p.clone(),
|
||||
k => panic!("not a polyline: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn arc_of(k: &EntityKind) -> Arc {
|
||||
match k {
|
||||
EntityKind::Arc(a) => Arc::new(a.center.xy(), a.radius, a.start, a.end),
|
||||
k => panic!("not an arc: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- PEDIT ----------------
|
||||
|
||||
#[test]
|
||||
fn pedit_json_options() {
|
||||
let mut s = Session::new();
|
||||
let p = pline(&mut s, json!([[0, 0], [10, 0], [10, 10], [0, 10]]), false);
|
||||
s.execute("pedit", &json!({"handle": p, "option": "close"})).unwrap();
|
||||
assert!(poly(&kind(&s, &p)).closed);
|
||||
s.execute("pedit", &json!({"handle": p, "option": "open"})).unwrap();
|
||||
assert!(!poly(&kind(&s, &p)).closed);
|
||||
s.execute("pedit", &json!({"handle": p, "option": "width", "width": 0.5})).unwrap();
|
||||
assert_eq!(poly(&kind(&s, &p)).const_width, 0.5);
|
||||
s.execute("pedit", &json!({"handle": p, "option": "fit"})).unwrap();
|
||||
let f = poly(&kind(&s, &p));
|
||||
assert_eq!(f.vertices.len(), 7);
|
||||
assert!(f.vertices.iter().take(6).all(|v| v.bulge.abs() > 1e-6), "fit curve is all arcs");
|
||||
// Tangent continuity at an original vertex.
|
||||
let segs = Polyline { vertices: f.vertices.clone(), closed: false }.segments();
|
||||
assert!(segs[1].tangent(1.0).near(segs[2].tangent(0.0), 1e-6));
|
||||
assert!(near(f.vertices[2].p, 10.0, 0.0), "original vertices are kept");
|
||||
s.execute("pedit", &json!({"handle": p, "option": "decurve"})).unwrap();
|
||||
assert!(poly(&kind(&s, &p)).vertices.iter().all(|v| v.bulge == 0.0));
|
||||
let q = pline(&mut s, json!([[0, 20], [10, 20], [10, 30], [0, 30]]), false);
|
||||
s.execute("pedit", &json!({"handle": q, "option": "spline"})).unwrap();
|
||||
let sp = poly(&kind(&s, &q));
|
||||
assert_eq!(sp.vertices.len(), 25);
|
||||
assert!(near(sp.vertices[0].p, 0.0, 20.0) && near(sp.vertices[24].p, 0.0, 30.0));
|
||||
s.execute("pedit", &json!({"handle": q, "option": "reverse"})).unwrap();
|
||||
assert!(near(poly(&kind(&s, &q)).vertices[0].p, 0.0, 30.0));
|
||||
s.execute("pedit", &json!({"handle": q, "option": "ltypegen"})).unwrap();
|
||||
assert!(poly(&kind(&s, &q)).plinegen);
|
||||
// Vertex editing.
|
||||
let r = pline(&mut s, json!([[0, 50], [5, 50], [10, 50]]), false);
|
||||
s.execute("pedit", &json!({"handle": r, "option": "vertex", "action": "move", "index": 1, "to": [5, 55]})).unwrap();
|
||||
assert!(near(poly(&kind(&s, &r)).vertices[1].p, 5.0, 55.0));
|
||||
s.execute("pedit", &json!({"handle": r, "option": "vertex", "action": "insert", "index": 0, "to": [2, 52]})).unwrap();
|
||||
assert_eq!(poly(&kind(&s, &r)).vertices.len(), 4);
|
||||
s.execute("pedit", &json!({"handle": r, "option": "vertex", "action": "width", "index": 0, "startWidth": 1, "endWidth": 0})).unwrap();
|
||||
assert_eq!(poly(&kind(&s, &r)).vertices[0].start_width, 1.0);
|
||||
assert!(s.execute("pedit", &json!({"handle": r, "option": "vertex", "index": 99, "to": [0, 0]})).is_err());
|
||||
assert!(s.execute("pedit", &json!({"handle": r, "option": "bogus"})).is_err());
|
||||
// A line is converted, then joined with a touching arc and polyline.
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 100], [10, 100]]})).unwrap());
|
||||
let a = h(&s.execute("arc", &json!({"center": [10, 105], "radius": 5, "start": -90, "end": 90})).unwrap());
|
||||
let far = h(&s.execute("line", &json!({"points": [[50, 50], [60, 60]]})).unwrap());
|
||||
let tail = pline(&mut s, json!([[10, 110], [0, 110]]), false);
|
||||
let r = s.execute("pedit", &json!({"handle": l, "option": "join", "handles2": [a, tail, far]})).unwrap();
|
||||
assert_eq!(r["joined"], 2);
|
||||
let j = poly(&kind(&s, &l));
|
||||
assert_eq!(j.vertices.len(), 4);
|
||||
assert!(j.vertices[1].bulge > 0.9);
|
||||
assert!(s.doc().unwrap().entity(Handle::parse_hex(&far).unwrap()).is_some(), "unconnected objects stay");
|
||||
let c = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap());
|
||||
assert!(s.execute("pedit", &json!({"handle": c, "option": "close"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pedit_command_line() {
|
||||
let mut s = Session::new();
|
||||
pline(&mut s, json!([[0, 0], [10, 0], [10, 10]]), false);
|
||||
s.cmdline("pedit 5,0 c").unwrap();
|
||||
assert!(poly(&last(&s)).closed);
|
||||
s.cmdline("w 0.25").unwrap();
|
||||
s.cmdline("f").unwrap();
|
||||
assert!(poly(&last(&s)).vertices.len() > 3);
|
||||
s.cmdline("d").unwrap();
|
||||
assert_eq!(poly(&last(&s)).vertices.len(), 3, "decurve restores the frame");
|
||||
s.cmdline("u").unwrap();
|
||||
assert!(poly(&last(&s)).vertices.len() > 3, "undo the decurve");
|
||||
s.cmdline("u u").unwrap();
|
||||
s.cmdline("o").unwrap();
|
||||
assert!(!poly(&last(&s)).closed);
|
||||
s.cmdline("e n m 10,-5 x").unwrap();
|
||||
assert!(near(poly(&last(&s)).vertices[1].p, 10.0, -5.0));
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.state().unwrap().undo.len(), 2, "one undo step for the PEDIT session");
|
||||
// Line conversion and join.
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[5, 0], [5, 5]]})).unwrap();
|
||||
s.cmdline("pedit 2,0 y j 5,3").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
assert_eq!(poly(&last(&s)).vertices.len(), 3);
|
||||
}
|
||||
|
||||
// ---------------- SPLINEDIT ----------------
|
||||
|
||||
#[test]
|
||||
fn splinedit_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({"fit": [[0, 0], [2, 2], [4, 0], [6, 2]]})).unwrap());
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "close"})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.closed && x.fit.len() == 5));
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "open"})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if !x.closed && x.fit.len() == 4));
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "reverse"})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if near(x.fit[0], 6.0, 2.0)));
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "move", "index": 0, "to": [7, 3]})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if near(x.fit[0], 7.0, 3.0)));
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "refit", "fit": [[0, 0], [1, 1], [2, 0]]})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.fit.len() == 3));
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "purge"})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.fit.is_empty()));
|
||||
assert!(s.execute("splinedit", &json!({"handle": sp, "option": "refit"})).is_err());
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "move", "index": 1, "to": [1, 5]})).unwrap();
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "polyline", "precision": 4})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::LwPolyline(p) if p.vertices.len() > 4));
|
||||
assert!(s.execute("splinedit", &json!({"handle": sp, "option": "close"})).is_err());
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("spline", &json!({"control": [[0, 0], [2, 3], [4, 0], [6, 3]]})).unwrap();
|
||||
s.cmdline("splinedit 0,0 c").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Spline(x) if x.closed));
|
||||
s.cmdline("o r u").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Spline(x) if !x.closed && near(x.control[0], 0.0, 0.0)));
|
||||
s.cmdline("f p p").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(last(&s), EntityKind::LwPolyline(_)));
|
||||
}
|
||||
|
||||
// ---------------- LENGTHEN ----------------
|
||||
|
||||
#[test]
|
||||
fn lengthen_json() {
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
assert_eq!(s.execute("lengthen", &json!({"handle": l})).unwrap()["length"], 10.0);
|
||||
s.execute("lengthen", &json!({"handle": l, "delta": 2})).unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.b.x - 12.0).abs() < 1e-9));
|
||||
s.execute("lengthen", &json!({"handle": l, "delta": -2, "pick": [0, 0]})).unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.a.x - 2.0).abs() < 1e-9));
|
||||
s.execute("lengthen", &json!({"handle": l, "percent": 50})).unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.b.x - 7.0).abs() < 1e-9));
|
||||
s.execute("lengthen", &json!({"handle": l, "total": 3})).unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.b.x - 5.0).abs() < 1e-9));
|
||||
s.execute("lengthen", &json!({"handle": l, "to": [9, 4]})).unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.b.x - 9.0).abs() < 1e-9 && x.b.y == 0.0));
|
||||
assert!(s.execute("lengthen", &json!({"handle": l, "delta": -100})).is_err());
|
||||
assert!(s.execute("lengthen", &json!({"handle": l, "deltaAngle": 10})).is_err());
|
||||
let a = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 1, "start": 0, "end": 90})).unwrap());
|
||||
s.execute("lengthen", &json!({"handle": a, "deltaAngle": 90})).unwrap();
|
||||
assert!((arc_of(&kind(&s, &a)).sweep() - std::f64::consts::PI).abs() < 1e-9);
|
||||
s.execute("lengthen", &json!({"handle": a, "totalAngle": 45, "pick": [1, 0]})).unwrap();
|
||||
let g = arc_of(&kind(&s, &a));
|
||||
assert!((g.sweep() - std::f64::consts::FRAC_PI_4).abs() < 1e-9 && near(g.end_point(), -1.0, 0.0));
|
||||
s.execute("lengthen", &json!({"handle": a, "total": std::f64::consts::FRAC_PI_2})).unwrap();
|
||||
assert!((arc_of(&kind(&s, &a)).sweep() - std::f64::consts::FRAC_PI_2).abs() < 1e-9);
|
||||
assert!(s.execute("lengthen", &json!({"handle": a, "percent": 1000})).is_err());
|
||||
let p = pline(&mut s, json!([[0, 10], [5, 10], [5, 15]]), false);
|
||||
s.execute("lengthen", &json!({"handle": p, "delta": 5})).unwrap();
|
||||
assert!(near(poly(&kind(&s, &p)).vertices[2].p, 5.0, 20.0));
|
||||
s.execute("lengthen", &json!({"handle": p, "total": 20, "pick": [0, 10]})).unwrap();
|
||||
assert!(near(poly(&kind(&s, &p)).vertices[0].p, -5.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengthen_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.cmdline("lengthen 5,0").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("Current length: 10.0000")));
|
||||
s.cmdline("de 2 9,0 9,0 u").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Line(x) if (x.b.x - 12.0).abs() < 1e-9));
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
s.cmdline("lengthen t 4 1,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Line(x) if (x.a.x - 8.0).abs() < 1e-9));
|
||||
s.cmdline("lengthen p 50 11,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Line(x) if (x.b.x - 10.0).abs() < 1e-9));
|
||||
s.cmdline("lengthen dy 9.5,0 20,3").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Line(x) if (x.b.x - 20.0).abs() < 1e-9));
|
||||
s.execute("arc", &json!({"center": [50, 0], "radius": 1, "start": 0, "end": 90})).unwrap();
|
||||
s.cmdline("lengthen de a 90 50,1").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!((arc_of(&last(&s)).sweep() - std::f64::consts::PI).abs() < 1e-9);
|
||||
}
|
||||
|
||||
// ---------------- ALIGN / BLEND / REVERSE ----------------
|
||||
|
||||
#[test]
|
||||
fn align_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap());
|
||||
s.execute("align", &json!({"handles": [l], "s1": [0, 0], "d1": [5, 5], "s2": [2, 0], "d2": [5, 9], "scale": true})).unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if near(x.a.xy(), 5.0, 5.0) && near(x.b.xy(), 5.0, 9.0)));
|
||||
s.execute("align", &json!({"handles": [l], "s1": [5, 5], "d1": [0, 0]})).unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if near(x.a.xy(), 0.0, 0.0) && near(x.b.xy(), 0.0, 4.0)));
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap();
|
||||
s.cmdline("align 1,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("0,0 10,10 2,0 10,11").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(matches!(last(&s), EntityKind::Line(x) if near(x.a.xy(), 10.0, 10.0) && near(x.b.xy(), 10.0, 12.0)));
|
||||
// One pair only: a move.
|
||||
s.cmdline("align 10,11").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("10,10 0,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(last(&s), EntityKind::Line(x) if near(x.a.xy(), 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let a = h(&s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap());
|
||||
let b = h(&s.execute("line", &json!({"points": [[10, 3], [15, 3]]})).unwrap());
|
||||
let r = s.execute("blend", &json!({"h1": a, "p1": [4, 0], "h2": b, "p2": [11, 3]})).unwrap();
|
||||
match kind(&s, &h(&r)) {
|
||||
EntityKind::Spline(sp) => {
|
||||
assert_eq!(sp.degree, 3);
|
||||
assert!(near(sp.control[0], 5.0, 0.0) && near(sp.control[3], 10.0, 3.0));
|
||||
assert!(sp.control[1].y.abs() < 1e-9 && sp.control[1].x > 5.0, "tangent to the first line");
|
||||
assert!((sp.control[2].y - 3.0).abs() < 1e-9 && sp.control[2].x < 10.0, "tangent to the second line");
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
let r = s.execute("blend", &json!({"h1": a, "p1": [1, 0], "h2": b, "p2": [14, 3], "continuity": "smooth"})).unwrap();
|
||||
assert!(matches!(kind(&s, &h(&r)), EntityKind::Spline(sp) if sp.degree == 5 && near(sp.control[0], 0.0, 0.0)));
|
||||
let c = h(&s.execute("circle", &json!({"center": [30, 0], "radius": 1})).unwrap());
|
||||
assert!(s.execute("blend", &json!({"h1": a, "p1": [1, 0], "h2": c, "p2": [31, 0]})).is_err());
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap();
|
||||
s.execute("arc", &json!({"center": [10, 0], "radius": 2, "start": 90, "end": 180})).unwrap();
|
||||
s.cmdline("blend con s 4,0 8,0").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(matches!(last(&s), EntityKind::Spline(sp) if sp.degree == 5 && near(*sp.control.last().unwrap(), 8.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap());
|
||||
let p = pline(&mut s, json!([{"p": [0, 5], "bulge": 1}, [2, 5], [4, 6]]), false);
|
||||
let sp = h(&s.execute("spline", &json!({"control": [[0, 10], [1, 12], [3, 12], [4, 10]]})).unwrap());
|
||||
let p3 = h(&s.execute("3dpoly", &json!({"points": [[0, 20, 0], [1, 20, 1]]})).unwrap());
|
||||
let c = h(&s.execute("circle", &json!({"center": [0, 30], "radius": 1})).unwrap());
|
||||
let before = Polyline { vertices: poly(&kind(&s, &p)).vertices, closed: false }.tessellate(1e-3);
|
||||
let r = s.execute("reverse", &json!({"handles": [l, p, sp, p3, c]})).unwrap();
|
||||
assert_eq!(r["reversed"], 4);
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if near(x.a.xy(), 5.0, 0.0)));
|
||||
let after = Polyline { vertices: poly(&kind(&s, &p)).vertices, closed: false }.tessellate(1e-3);
|
||||
assert!(after.first().unwrap().near(*before.last().unwrap(), 1e-9));
|
||||
let mid_b = before[before.len() / 2];
|
||||
assert!(after.iter().any(|q| q.near(mid_b, 1e-2)), "same shape");
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if near(x.eval(x.domain().0), 4.0, 10.0)));
|
||||
assert!(matches!(kind(&s, &p3), EntityKind::Polyline3d(x) if x.points[0].z == 1.0));
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap();
|
||||
s.cmdline("reverse 2,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(last(&s), EntityKind::Line(x) if near(x.a.xy(), 5.0, 0.0)));
|
||||
}
|
||||
|
||||
// ---------------- NCOPY / CHSPACE / FLATTEN ----------------
|
||||
|
||||
fn make_block(s: &mut Session) -> String {
|
||||
let a = h(&s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap());
|
||||
let c = h(&s.execute("circle", &json!({"center": [3, 0], "radius": 0.5})).unwrap());
|
||||
s.execute("block", &json!({"name": "PART", "base": [0, 0], "handles": [a, c], "keep": "delete"})).unwrap();
|
||||
h(&s.execute("insert", &json!({"name": "PART", "at": [10, 10], "rotation": 90})).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncopy_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let ins = make_block(&mut s);
|
||||
let r = s.execute("ncopy", &json!({"handle": ins, "pick": [10, 13]})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 1);
|
||||
assert!(matches!(last(&s), EntityKind::Circle(c) if near(c.center.xy(), 10.0, 13.0)));
|
||||
let r = s.execute("ncopy", &json!({"handle": ins, "delta": [5, 0]})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
|
||||
assert!(matches!(last(&s), EntityKind::Circle(c) if near(c.center.xy(), 15.0, 13.0)));
|
||||
let line = h(&s.execute("line", &json!({"points": [[50, 50], [51, 50]]})).unwrap());
|
||||
assert!(s.execute("ncopy", &json!({"handle": line})).is_err());
|
||||
|
||||
let mut s = Session::new();
|
||||
make_block(&mut s);
|
||||
s.cmdline("ncopy 10,10.5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("0,0 20,0").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(matches!(last(&s), EntityKind::Line(l) if near(l.a.xy(), 30.0, 10.0) && near(l.b.xy(), 30.0, 11.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chspace_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
// A viewport on Layout1 showing model (0,0) at paper (5,5) at scale 1:2.
|
||||
let vp = cadcraft_doc::Viewport {
|
||||
center: cadcraft_geom::Vec3::new(5.0, 5.0, 0.0),
|
||||
width: 8.0,
|
||||
height: 6.0,
|
||||
view_center: Vec2::ZERO,
|
||||
view_height: 12.0,
|
||||
id: 2,
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
};
|
||||
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Default::default(), EntityKind::Viewport(vp)).unwrap();
|
||||
let r = s.execute("chspace", &json!({"handles": [l]})).unwrap();
|
||||
assert_eq!(r["moved"], 1);
|
||||
assert_eq!(r["to"], "Layout1");
|
||||
let hh = Handle::parse_hex(&l).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.space_of(hh), Some(Space::Paper("Layout1".into())));
|
||||
assert!(matches!(&d.entity(hh).unwrap().kind, EntityKind::Line(x) if near(x.a.xy(), 5.0, 5.0) && near(x.b.xy(), 10.0, 5.0)));
|
||||
// Back to model space from the layout.
|
||||
s.state_mut().unwrap().space = Space::Paper("Layout1".into());
|
||||
s.execute("chspace", &json!({"handles": [l]})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.space_of(hh), Some(Space::Model));
|
||||
assert!(matches!(&d.entity(hh).unwrap().kind, EntityKind::Line(x) if near(x.b.xy(), 10.0, 0.0)));
|
||||
s.state_mut().unwrap().space = Space::Model;
|
||||
assert!(s.execute("chspace", &json!({"handles": [l], "to": "Nope"})).is_err());
|
||||
|
||||
s.cmdline("chspace 5,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(s.doc().unwrap().space_of(hh), Some(Space::Paper("Layout1".into())));
|
||||
assert!(s.log.iter().any(|l| l.contains("changed from MODEL space to PAPER space")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let p3 = h(&s.execute("3dpoly", &json!({"points": [[0, 0, 5], [1, 0, 6], [1, 1, 7]]})).unwrap());
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 5], [3, 5]]})).unwrap());
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.modify_entity(Handle::parse_hex(&l).unwrap(), |e| {
|
||||
if let EntityKind::Line(x) = &mut e.kind {
|
||||
x.a.z = 4.0;
|
||||
}
|
||||
e.common.thickness = 2.0;
|
||||
})
|
||||
.unwrap();
|
||||
let r = s.execute("flatten", &json!({"handles": [p3, l]})).unwrap();
|
||||
assert_eq!(r["flattened"], 2);
|
||||
assert!(matches!(kind(&s, &p3), EntityKind::LwPolyline(p) if p.vertices.len() == 3));
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if x.a.z == 0.0));
|
||||
assert_eq!(s.doc().unwrap().entity(Handle::parse_hex(&l).unwrap()).unwrap().common.thickness, 0.0);
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("3dpoly", &json!({"points": [[0, 0, 5], [4, 0, 6]]})).unwrap();
|
||||
s.cmdline("flatten 2,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(last(&s), EntityKind::LwPolyline(_)));
|
||||
}
|
||||
|
||||
// ---------------- FILLET extensions ----------------
|
||||
|
||||
#[test]
|
||||
fn fillet_line_arc_and_arcs() {
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[-10, 0], [10, 0]]})).unwrap());
|
||||
let a = h(&s.execute("arc", &json!({"center": [0, 4], "radius": 3, "start": 180, "end": 360})).unwrap());
|
||||
// Arc bottom is at y=1; a fillet of radius 1 between the line (below) and the arc.
|
||||
let r = s.execute("fillet", &json!({"h1": l, "p1": [8, 0], "h2": a, "p2": [3, 4], "radius": 1})).unwrap();
|
||||
let fa = arc_of(&kind(&s, r["arc"].as_str().unwrap()));
|
||||
assert!((fa.radius - 1.0).abs() < 1e-9 && (fa.center.y - 1.0).abs() < 1e-9 && fa.center.x > 0.0, "{fa:?}");
|
||||
// Tangent points: the line now ends under the fillet centre; the arc ends where it touches.
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if (x.a.x - fa.center.x).abs() < 1e-9 && x.b.x == 10.0));
|
||||
let ga = arc_of(&kind(&s, &a));
|
||||
assert!((ga.start_point().dist(fa.center) - 1.0).abs() < 1e-6 || (ga.end_point().dist(fa.center) - 1.0).abs() < 1e-6);
|
||||
// Endpoints connect.
|
||||
assert!(fa.start_point().near(Vec2::new(fa.center.x, 0.0), 1e-6) || fa.end_point().near(Vec2::new(fa.center.x, 0.0), 1e-6));
|
||||
|
||||
// Two arcs.
|
||||
let mut s = Session::new();
|
||||
let a1 = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 2, "start": 0, "end": 90})).unwrap());
|
||||
let a2 = h(&s.execute("arc", &json!({"center": [5, 0], "radius": 2, "start": 90, "end": 180})).unwrap());
|
||||
let r = s.execute("fillet", &json!({"h1": a1, "p1": [1.4, 1.4], "h2": a2, "p2": [3.6, 1.4], "radius": 1})).unwrap();
|
||||
let fa = arc_of(&kind(&s, r["arc"].as_str().unwrap()));
|
||||
assert!((fa.center.x - 2.5).abs() < 1e-9 && fa.center.y > 0.0);
|
||||
let g1 = arc_of(&kind(&s, &a1));
|
||||
let g2 = arc_of(&kind(&s, &a2));
|
||||
assert!(
|
||||
fa.start_point().near(g1.start_point(), 1e-6) || fa.start_point().near(g1.end_point(), 1e-6) || fa.end_point().near(g1.end_point(), 1e-6)
|
||||
);
|
||||
assert!(
|
||||
fa.start_point().near(g2.start_point(), 1e-6) || fa.end_point().near(g2.start_point(), 1e-6) || fa.end_point().near(g2.end_point(), 1e-6)
|
||||
);
|
||||
// Zero radius: sharp corner between a line and an arc.
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[-10, 0], [-5, 0]]})).unwrap());
|
||||
let a = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 3, "start": 90, "end": 150})).unwrap());
|
||||
s.execute("fillet", &json!({"h1": l, "p1": [-8, 0], "h2": a, "p2": [0, 3], "radius": 0})).unwrap();
|
||||
assert!(matches!(kind(&s, &l), EntityKind::Line(x) if near(x.b.xy(), -3.0, 0.0)));
|
||||
assert!(near(arc_of(&kind(&s, &a)).end_point(), -3.0, 0.0));
|
||||
// Unsupported pairs report an error.
|
||||
let t = h(&s.execute("text", &json!({"at": [0, 0], "text": "x"})).unwrap());
|
||||
assert!(s.execute("fillet", &json!({"h1": l, "p1": [-8, 0], "h2": t, "p2": [0, 0], "radius": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_polyline_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let p = pline(&mut s, json!([[0, 0], [10, 0], [10, 10], [0, 10]]), true);
|
||||
let r = s.execute("fillet", &json!({"handle": p, "polyline": true, "radius": 1})).unwrap();
|
||||
assert_eq!(r["filleted"], 4);
|
||||
let pl = poly(&kind(&s, &p));
|
||||
assert_eq!(pl.vertices.len(), 8);
|
||||
let area = Polyline { vertices: pl.vertices.clone(), closed: true }.area();
|
||||
assert!((area - (100.0 - 4.0 + std::f64::consts::PI)).abs() < 1e-3, "{area}");
|
||||
// Too-large radius: corners that do not fit are skipped.
|
||||
let q = pline(&mut s, json!([[0, 20], [2, 20], [2, 22]]), false);
|
||||
assert_eq!(s.execute("fillet", &json!({"handle": q, "polyline": true, "radius": 5})).unwrap()["filleted"], 0);
|
||||
|
||||
let mut s = Session::new();
|
||||
pline(&mut s, json!([[0, 0], [10, 0], [10, 10]]), false);
|
||||
s.cmdline("fillet r 2").unwrap();
|
||||
s.cmdline("p 5,0").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(poly(&last(&s)).vertices.len(), 4);
|
||||
assert!(s.log.iter().any(|l| l.contains("1 lines were filleted")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_command_line_line_and_arc() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[-10, 0], [10, 0]]})).unwrap();
|
||||
s.execute("arc", &json!({"center": [0, 4], "radius": 3, "start": 180, "end": 360})).unwrap();
|
||||
s.cmdline("fillet r 1").unwrap();
|
||||
s.cmdline("8,0 3,4").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(last(&s), EntityKind::Arc(a) if (a.radius - 1.0).abs() < 1e-9));
|
||||
}
|
||||
|
||||
// ---------------- TRIM / EXTEND for ellipses and splines ----------------
|
||||
|
||||
#[test]
|
||||
fn trim_and_extend_ellipse_and_spline() {
|
||||
let mut s = Session::new();
|
||||
let e = h(&s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap());
|
||||
s.execute("line", &json!({"points": [[0, -5], [0, 5]]})).unwrap();
|
||||
let r = s.execute("trim", &json!({"handle": e, "pick": [4, 0]})).unwrap();
|
||||
let nh = r["handles"][0].as_str().unwrap().to_string();
|
||||
match kind(&s, &nh) {
|
||||
EntityKind::Ellipse(x) => {
|
||||
let ge = cadcraft_geom::Ellipse { center: x.center.xy(), major: x.major.xy(), ratio: x.ratio, start: x.start, end: x.end };
|
||||
assert!((ge.sweep() - std::f64::consts::PI).abs() < 1e-6);
|
||||
assert!(ge.at_param(ge.start + ge.sweep() / 2.0).x < 0.0, "the right half was removed");
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
// Extend the elliptical arc back to a new edge.
|
||||
s.execute("line", &json!({"points": [[3, -5], [3, 5]]})).unwrap();
|
||||
let mut s2 = Session::new();
|
||||
let ea = h(&s2.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5, "start": 90, "end": 180})).unwrap());
|
||||
s2.execute("line", &json!({"points": [[2, -5], [2, 5]]})).unwrap();
|
||||
s2.execute("extend", &json!({"handle": ea, "pick": [0, 2]})).unwrap();
|
||||
match kind(&s2, &ea) {
|
||||
EntityKind::Ellipse(x) => {
|
||||
let ge = cadcraft_geom::Ellipse { center: x.center.xy(), major: x.major.xy(), ratio: x.ratio, start: x.start, end: x.end };
|
||||
assert!((ge.at_param(ge.start).x - 2.0).abs() < 1e-6, "{:?}", ge.at_param(ge.start));
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
// Splines.
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({"control": [[0, 0], [3, 3], [6, -3], [9, 0]]})).unwrap());
|
||||
s.execute("line", &json!({"points": [[4.5, -5], [4.5, 5]]})).unwrap();
|
||||
let r = s.execute("trim", &json!({"handle": sp, "pick": [8.9, 0.0]})).unwrap();
|
||||
let nh = r["handles"][0].as_str().unwrap().to_string();
|
||||
match kind(&s, &nh) {
|
||||
EntityKind::Spline(x) => {
|
||||
assert!(near(x.eval(x.domain().0), 0.0, 0.0));
|
||||
assert!((x.eval(x.domain().1).x - 4.5).abs() < 1e-6);
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
assert!(s.execute("extend", &json!({"handle": nh, "pick": [4.5, 0]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_modify_commands_survive_garbage_input() {
|
||||
let ids = ["pedit", "splinedit", "lengthen", "align", "blend", "reverse", "ncopy", "chspace", "flatten", "fillet", "trim", "extend"];
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
let sp = h(&s.execute("spline", &json!({"control": [[0, 1], [1, 2], [2, 1]]})).unwrap());
|
||||
let e = h(&s.execute("ellipse", &json!({"center": [5, 5], "major": [1, 0], "ratio": 0.5})).unwrap());
|
||||
for id in ids {
|
||||
for p in [
|
||||
json!({"handle": l, "option": "vertex", "action": "insert", "index": u64::MAX, "to": [1e308, 1e308]}),
|
||||
json!({"handle": sp, "option": "move", "index": 1e10, "to": [0, 0]}),
|
||||
json!({"handle": l, "delta": 1e308, "percent": -1, "to": [1e308, -1e308]}),
|
||||
json!({"handles": [l, sp, e], "s1": [0, 0], "d1": [0, 0], "s2": [0, 0], "d2": [1e308, 0], "scale": true}),
|
||||
json!({"h1": l, "p1": [0, 0], "h2": l, "p2": [0, 0], "radius": 1e308}),
|
||||
json!({"handle": e, "pick": [1e308, 0], "polyline": true}),
|
||||
json!({"handle": sp, "pick": [0, 0]}),
|
||||
json!({"handles": ["zz"], "to": ""}),
|
||||
] {
|
||||
let _ = s.execute(id, &p);
|
||||
}
|
||||
s.start(id).unwrap();
|
||||
for t in ["5,0", "", "x", "1e999", "c", "e", "n", "m", "0,0", "5,5", "u", "x", "", ""] {
|
||||
if s.running.is_none() {
|
||||
break;
|
||||
}
|
||||
let _ = s.cmdline(t);
|
||||
}
|
||||
let _ = s.preview(Vec2::new(-1e300, 1e300));
|
||||
s.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arraypath_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let dot = h(&s.execute("circle", &json!({"center": [0, 1], "radius": 0.25})).unwrap());
|
||||
let path = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
let r = s.execute("arraypath", &json!({"handles": [dot], "path": path, "count": 6})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 5);
|
||||
assert!(matches!(last(&s), EntityKind::Circle(c) if near(c.center.xy(), 10.0, 1.0)));
|
||||
let r = s.execute("arraypath", &json!({"handles": [dot], "path": path, "spacing": 4})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
|
||||
// Aligned along an arc: the copy turns with the tangent.
|
||||
let arc = h(&s.execute("arc", &json!({"center": [0, 20], "radius": 5, "start": 0, "end": 180})).unwrap());
|
||||
let tick = h(&s.execute("line", &json!({"points": [[5, 20], [6, 20]]})).unwrap());
|
||||
s.execute("arraypath", &json!({"handles": [tick], "path": arc, "count": 2})).unwrap();
|
||||
assert!(matches!(last(&s), EntityKind::Line(l) if near(l.a.xy(), -5.0, 20.0) && near(l.b.xy(), -6.0, 20.0)));
|
||||
assert!(s.execute("arraypath", &json!({"handles": [dot], "path": dot})).is_err());
|
||||
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 1], "radius": 0.25})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
s.cmdline("arraypath 0.25,1").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("5,0 3").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(s.doc().unwrap().model.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textedit_json_and_command_line() {
|
||||
let mut s = Session::new();
|
||||
let t = h(&s.execute("text", &json!({"at": [0, 0], "text": "old"})).unwrap());
|
||||
s.execute("textedit", &json!({"handle": t, "text": "new"})).unwrap();
|
||||
assert!(matches!(kind(&s, &t), EntityKind::Text(x) if x.value == "new"));
|
||||
let m = h(&s.execute("mtext", &json!({"at": [0, 5], "text": "a"})).unwrap());
|
||||
s.execute("textedit", &json!({"handle": m, "text": "x\ny"})).unwrap();
|
||||
assert!(matches!(kind(&s, &m), EntityKind::MText(x) if x.contents == "x\\Py"));
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 10], [1, 10]]})).unwrap());
|
||||
assert!(s.execute("textedit", &json!({"handle": l, "text": "x"})).is_err());
|
||||
s.cmdline("textedit 0,0").unwrap();
|
||||
s.cmdline("hello world").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert!(matches!(kind(&s, &t), EntityKind::Text(x) if x.value == "hello world"));
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Utilities: CAL (expression calculator), FIND (find and replace text), MASSPROP, MEASUREGEOM.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Polyline, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("cal", "QuickCalc", run_cal)
|
||||
.menu(&["Tools", "Inquiry", "Quick"])
|
||||
.alias(&["quickcalc", "qc"])
|
||||
.params("{expr: \"(3+4)*2^2\", vars?: {x: 1}}")
|
||||
.enabled(always)
|
||||
.noundo()
|
||||
.transparent(),
|
||||
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
|
||||
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
|
||||
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
|
||||
.params("{handles?}")
|
||||
.noundo(),
|
||||
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom).params("{mode: distance|radius|angle|area, ...}").noundo(),
|
||||
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
|
||||
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------- CAL ----------------
|
||||
|
||||
struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
vars: &'a serde_json::Map<String, Value>,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn ws(&mut self) {
|
||||
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
fn peek(&mut self) -> Option<u8> {
|
||||
self.ws();
|
||||
self.s.get(self.i).copied()
|
||||
}
|
||||
fn expr(&mut self) -> std::result::Result<f64, String> {
|
||||
self.depth += 1;
|
||||
if self.depth > 200 {
|
||||
return Err("expression too deeply nested".into());
|
||||
}
|
||||
let mut v = self.term()?;
|
||||
while let Some(c) = self.peek() {
|
||||
match c {
|
||||
b'+' => {
|
||||
self.i += 1;
|
||||
v += self.term()?;
|
||||
}
|
||||
b'-' => {
|
||||
self.i += 1;
|
||||
v -= self.term()?;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
self.depth -= 1;
|
||||
Ok(v)
|
||||
}
|
||||
fn term(&mut self) -> std::result::Result<f64, String> {
|
||||
let mut v = self.unary()?;
|
||||
while let Some(c) = self.peek() {
|
||||
match c {
|
||||
b'*' => {
|
||||
self.i += 1;
|
||||
v *= self.unary()?;
|
||||
}
|
||||
b'/' => {
|
||||
self.i += 1;
|
||||
let d = self.unary()?;
|
||||
if d == 0.0 {
|
||||
return Err("division by zero".into());
|
||||
}
|
||||
v /= d;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
fn power(&mut self) -> std::result::Result<f64, String> {
|
||||
let b = self.atom()?;
|
||||
if self.peek() == Some(b'^') {
|
||||
self.i += 1;
|
||||
let e = self.unary()?;
|
||||
return Ok(b.powf(e));
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
fn unary(&mut self) -> std::result::Result<f64, String> {
|
||||
match self.peek() {
|
||||
Some(b'-') => {
|
||||
self.i += 1;
|
||||
Ok(-self.unary()?)
|
||||
}
|
||||
Some(b'+') => {
|
||||
self.i += 1;
|
||||
self.unary()
|
||||
}
|
||||
_ => self.power(),
|
||||
}
|
||||
}
|
||||
fn atom(&mut self) -> std::result::Result<f64, String> {
|
||||
match self.peek() {
|
||||
Some(b'(') => {
|
||||
self.i += 1;
|
||||
let v = self.expr()?;
|
||||
if self.peek() != Some(b')') {
|
||||
return Err("missing )".into());
|
||||
}
|
||||
self.i += 1;
|
||||
Ok(v)
|
||||
}
|
||||
Some(c) if c.is_ascii_digit() || c == b'.' => {
|
||||
let st = self.i;
|
||||
while self.s.get(self.i).is_some_and(|c| c.is_ascii_digit() || *c == b'.' || *c == b'e' || *c == b'E') {
|
||||
self.i += 1;
|
||||
}
|
||||
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
|
||||
t.parse::<f64>().map_err(|_| format!("bad number `{t}`"))
|
||||
}
|
||||
Some(c) if c.is_ascii_alphabetic() => {
|
||||
let st = self.i;
|
||||
while self.s.get(self.i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
|
||||
self.i += 1;
|
||||
}
|
||||
let name = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("").to_ascii_lowercase();
|
||||
if self.peek() == Some(b'(') {
|
||||
self.i += 1;
|
||||
let a = self.expr()?;
|
||||
let b = if self.peek() == Some(b',') {
|
||||
self.i += 1;
|
||||
Some(self.expr()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.peek() != Some(b')') {
|
||||
return Err("missing )".into());
|
||||
}
|
||||
self.i += 1;
|
||||
return Ok(match (name.as_str(), b) {
|
||||
("sin", _) => a.to_radians().sin(),
|
||||
("cos", _) => a.to_radians().cos(),
|
||||
("tan", _) => a.to_radians().tan(),
|
||||
("asin", _) => a.asin().to_degrees(),
|
||||
("acos", _) => a.acos().to_degrees(),
|
||||
("atan", None) => a.atan().to_degrees(),
|
||||
("atan", Some(x)) => a.atan2(x).to_degrees(),
|
||||
("sqrt", _) => a.sqrt(),
|
||||
("sqr", _) => a * a,
|
||||
("abs", _) => a.abs(),
|
||||
("ln", _) => a.ln(),
|
||||
("log", _) => a.log10(),
|
||||
("exp", _) => a.exp(),
|
||||
("round", _) => a.round(),
|
||||
("trunc", _) => a.trunc(),
|
||||
("r2d", _) => a.to_degrees(),
|
||||
("d2r", _) => a.to_radians(),
|
||||
("min", Some(x)) => a.min(x),
|
||||
("max", Some(x)) => a.max(x),
|
||||
("pow", Some(x)) => a.powf(x),
|
||||
_ => return Err(format!("unknown function `{name}`")),
|
||||
});
|
||||
}
|
||||
match name.as_str() {
|
||||
"pi" => Ok(std::f64::consts::PI),
|
||||
"e" => Ok(std::f64::consts::E),
|
||||
_ => self.vars.get(&name).and_then(Value::as_f64).ok_or_else(|| format!("unknown name `{name}`")),
|
||||
}
|
||||
}
|
||||
_ => Err("expected a number".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate an arithmetic expression (degrees for trig, `^` power, functions, pi).
|
||||
pub fn eval(expr: &str, vars: &serde_json::Map<String, Value>) -> std::result::Result<f64, String> {
|
||||
if expr.len() > 10_000 {
|
||||
return Err("expression too long".into());
|
||||
}
|
||||
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0 };
|
||||
let v = p.expr()?;
|
||||
if p.peek().is_some() {
|
||||
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
|
||||
}
|
||||
if !v.is_finite() {
|
||||
return Err("result is not a finite number".into());
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn run_cal(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e = str_param(p, "expr").ok_or_else(|| bad("cal", "`expr` is required"))?;
|
||||
let vars = p.get("vars").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let v = eval(e, &vars).map_err(|m| bad("cal", m))?;
|
||||
Ok(json!({ "value": v, "message": format!("{v}") }))
|
||||
}
|
||||
|
||||
// ---------------- FIND ----------------
|
||||
|
||||
fn replace_in(text: &str, find: &str, rep: &str, case: bool, whole: bool) -> (String, usize) {
|
||||
if find.is_empty() {
|
||||
return (text.to_string(), 0);
|
||||
}
|
||||
let hay = if case { text.to_string() } else { text.to_lowercase() };
|
||||
let needle = if case { find.to_string() } else { find.to_lowercase() };
|
||||
let mut out = String::new();
|
||||
let mut n = 0;
|
||||
let mut last = 0;
|
||||
let mut start = 0;
|
||||
while let Some(pos) = hay.get(start..).and_then(|h| h.find(&needle)).map(|p| p + start) {
|
||||
let end = pos + needle.len();
|
||||
let boundary = |i: usize| hay.get(..i).and_then(|s| s.chars().last()).is_none_or(|c| !c.is_alphanumeric());
|
||||
let after = hay.get(end..).and_then(|s| s.chars().next()).is_none_or(|c| !c.is_alphanumeric());
|
||||
if !whole || (boundary(pos) && after) {
|
||||
out.push_str(text.get(last..pos).unwrap_or(""));
|
||||
out.push_str(rep);
|
||||
last = end;
|
||||
n += 1;
|
||||
}
|
||||
start = end.max(pos + 1);
|
||||
if start >= hay.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_str(text.get(last..).unwrap_or(""));
|
||||
(out, n)
|
||||
}
|
||||
|
||||
fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let find = str_param(p, "find").ok_or_else(|| bad("find", "`find` is required"))?.to_string();
|
||||
let rep = str_param(p, "replace").map(str::to_string);
|
||||
let case = bool_or(p, "matchCase", false);
|
||||
let whole = bool_or(p, "wholeWord", false);
|
||||
let space = s.space();
|
||||
let d = s.doc()?;
|
||||
let hs: Vec<Handle> = d.space(&space).map(|st| st.handles()).unwrap_or_default();
|
||||
let mut found: Vec<Value> = Vec::new();
|
||||
let mut total = 0;
|
||||
let mut edits: Vec<(Handle, EntityKind)> = Vec::new();
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let mut k = e.kind.clone();
|
||||
let mut hits = 0;
|
||||
let r = rep.as_deref().unwrap_or(&find);
|
||||
let mut apply = |t: &mut String| {
|
||||
let (nt, n) = replace_in(t, &find, r, case, whole);
|
||||
if n > 0 {
|
||||
hits += n;
|
||||
if rep.is_some() {
|
||||
*t = nt;
|
||||
}
|
||||
}
|
||||
};
|
||||
match &mut k {
|
||||
EntityKind::Text(t) => apply(&mut t.value),
|
||||
EntityKind::MText(t) => apply(&mut t.contents),
|
||||
EntityKind::Insert(i) => i.attribs.iter_mut().for_each(|a| apply(&mut a.text.value)),
|
||||
EntityKind::Dimension(dm) => apply(&mut dm.text),
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = &mut m.text {
|
||||
apply(&mut t.contents)
|
||||
}
|
||||
}
|
||||
EntityKind::Table(t) => t.cells.iter_mut().flatten().for_each(|c| apply(&mut c.text)),
|
||||
_ => {}
|
||||
}
|
||||
if hits > 0 {
|
||||
total += hits;
|
||||
found.push(json!({ "handle": h.hex(), "type": e.kind.type_name(), "count": hits }));
|
||||
if rep.is_some() {
|
||||
edits.push((h, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = edits.len();
|
||||
if rep.is_some() {
|
||||
let doc = s.doc_mut()?;
|
||||
for (h, k) in edits {
|
||||
doc.modify_entity(h, |e| {
|
||||
e.kind = k;
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.block = None;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
let sel: Vec<Handle> = found.iter().filter_map(|v| v["handle"].as_str().and_then(Handle::parse_hex)).collect();
|
||||
s.set_selection(sel);
|
||||
}
|
||||
let msg =
|
||||
if rep.is_some() { format!("{total} replacement(s) in {n} object(s)") } else { format!("{total} match(es) in {} object(s)", found.len()) };
|
||||
Ok(json!({ "matches": found, "total": total, "message": msg }))
|
||||
}
|
||||
|
||||
// ---------------- MASSPROP / MEASUREGEOM ----------------
|
||||
|
||||
/// Area, perimeter, centroid and second moments of a closed boundary.
|
||||
fn region_props(pts: &[Vec2]) -> Option<(f64, f64, Vec2, f64, f64)> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return None;
|
||||
}
|
||||
let (mut a, mut cx, mut cy, mut ixx, mut iyy, mut per) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let (p, q) = (*pts.get(i)?, *pts.get((i + 1) % n)?);
|
||||
let c = p.cross(q);
|
||||
a += c;
|
||||
cx += (p.x + q.x) * c;
|
||||
cy += (p.y + q.y) * c;
|
||||
ixx += (p.y * p.y + p.y * q.y + q.y * q.y) * c;
|
||||
iyy += (p.x * p.x + p.x * q.x + q.x * q.x) * c;
|
||||
per += p.dist(q);
|
||||
}
|
||||
a /= 2.0;
|
||||
if a.abs() < 1e-15 {
|
||||
return None;
|
||||
}
|
||||
let c = Vec2::new(cx / (6.0 * a), cy / (6.0 * a));
|
||||
Some((a.abs(), per, c, (ixx / 12.0).abs(), (iyy / 12.0).abs()))
|
||||
}
|
||||
|
||||
fn closed_points(k: &EntityKind) -> Option<Vec<Vec2>> {
|
||||
match k {
|
||||
EntityKind::Circle(c) => {
|
||||
let mut v = Vec::new();
|
||||
cadcraft_geom::Circle::new(c.center.xy(), c.radius).tessellate(c.radius * 1e-5, &mut v);
|
||||
v.pop();
|
||||
Some(v)
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let mut v = Polyline { vertices: p.vertices.clone(), closed: true }.tessellate(1e-5);
|
||||
if v.len() > 1 && v.first().zip(v.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
|
||||
v.pop();
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
EntityKind::Hatch(h) => h.loops.first().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(1e-5)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let mut out = Vec::new();
|
||||
let mut lines = Vec::new();
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let Some((a, per, c, ixx, iyy)) = closed_points(&e.kind).and_then(|v| region_props(&v)) else { continue };
|
||||
lines.push(format!("---------------- REGIONS ----------------\nArea: {a:.4}\nPerimeter: {per:.4}\nCentroid: X: {:.4} Y: {:.4}\nMoments of inertia: X: {ixx:.4} Y: {iyy:.4}", c.x, c.y));
|
||||
out.push(json!({ "handle": h.hex(), "area": a, "perimeter": per, "centroid": [c.x, c.y], "ixx": ixx, "iyy": iyy }));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("Select closed objects (regions, closed polylines, circles).".into()));
|
||||
}
|
||||
Ok(json!({ "objects": out, "message": lines.join("\n") }))
|
||||
}
|
||||
|
||||
fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("radius.measure", "`handle` is required"))?;
|
||||
let r = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::Circle(c)) => c.radius,
|
||||
Some(EntityKind::Arc(a)) => a.radius,
|
||||
_ => return Err(bad("radius.measure", "select an arc or circle")),
|
||||
};
|
||||
Ok(json!({ "radius": r, "diameter": 2.0 * r, "message": format!("Radius = {r:.4}\nDiameter = {:.4}", 2.0 * r) }))
|
||||
}
|
||||
|
||||
fn run_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (v, a, b) = if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "p1"), point_param(p, "p2")) {
|
||||
(v, a, b)
|
||||
} else {
|
||||
let hp = |k: &str| p.get(k).and_then(Value::as_str).and_then(Handle::parse_hex);
|
||||
let (Some(h1), Some(h2)) = (hp("h1"), hp("h2")) else { return Err(bad("angle.measure", "give {vertex,p1,p2} or {h1,h2}")) };
|
||||
let d = s.doc()?;
|
||||
let line = |h: Handle| match d.entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::Line(l)) => Some((l.a.xy(), l.b.xy())),
|
||||
_ => None,
|
||||
};
|
||||
let ((a1, a2), (b1, b2)) =
|
||||
(line(h1).ok_or_else(|| bad("angle.measure", "h1 is not a line"))?, line(h2).ok_or_else(|| bad("angle.measure", "h2 is not a line"))?);
|
||||
let (x, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2).ok_or_else(|| bad("angle.measure", "lines are parallel"))?;
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(x) > q.dist(x) { p } else { q };
|
||||
(x, far(a1, a2), far(b1, b2))
|
||||
};
|
||||
let mut ang = (cadcraft_geom::norm_angle(v.angle_to(b) - v.angle_to(a))).to_degrees();
|
||||
if ang > 180.0 {
|
||||
ang = 360.0 - ang;
|
||||
}
|
||||
Ok(json!({ "angle": ang, "message": format!("Angle = {ang:.4}°") }))
|
||||
}
|
||||
|
||||
fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "mode").unwrap_or("distance").to_ascii_lowercase().as_str() {
|
||||
"distance" | "d" => s.execute("dist", p),
|
||||
"radius" | "r" => run_radius(s, p),
|
||||
"angle" | "a" => run_angle(s, p),
|
||||
"area" | "ar" => s.execute("area", p),
|
||||
_ => Err(bad("measuregeom", "mode must be distance, radius, angle or area")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn calculator() {
|
||||
let v = serde_json::Map::new();
|
||||
assert_eq!(eval("(3+4)*2^2", &v), Ok(28.0));
|
||||
assert!((eval("sin(30)", &v).unwrap() - 0.5).abs() < 1e-12);
|
||||
assert!((eval("2*pi", &v).unwrap() - std::f64::consts::TAU).abs() < 1e-12);
|
||||
assert_eq!(eval("-2^2", &v), Ok(-4.0));
|
||||
assert!(eval("1/0", &v).is_err());
|
||||
assert!(eval("((((1", &v).is_err());
|
||||
assert!(eval("foo(1)", &v).is_err());
|
||||
assert!(eval(&"(".repeat(5000), &v).is_err());
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("x".into(), json!(3));
|
||||
assert_eq!(eval("x*x", &vars), Ok(9.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_replace() {
|
||||
assert_eq!(replace_in("Bolt bolt BOLTS", "bolt", "Pin", false, true), ("Pin Pin BOLTS".to_string(), 2));
|
||||
assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3));
|
||||
assert_eq!(replace_in("x", "", "y", true, false).1, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_mass_properties() {
|
||||
let (a, per, c, _, _) = region_props(&[Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(4.0, 2.0), Vec2::new(0.0, 2.0)]).unwrap();
|
||||
assert!((a - 8.0).abs() < 1e-12);
|
||||
assert!((per - 12.0).abs() < 1e-12);
|
||||
assert!(c.near(Vec2::new(2.0, 1.0), 1e-12));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Grip editing: dragging an object's grips (the points from [`EntityKind::grips`]) the way
|
||||
//! AutoCAD does, plus the multi-functional grip modes (move, rotate, scale, mirror about a grip).
|
||||
//!
|
||||
//! The UI calls [`Session::grip_edit`]; every edit runs through a `grip.*` command so it is
|
||||
//! undoable and reachable from scripts, the control channel and MCP.
|
||||
|
||||
use cadcraft_doc::{DimKind, EntityKind, Handle, LwPolyline};
|
||||
use cadcraft_geom::{Arc, EPS, Mat3, Polyline, Spline, Vec2, Vec3, norm_angle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// What dragging a hot grip does (AutoCAD's grip modes, cycled with Space/Enter).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GripMode {
|
||||
/// Move the grip itself (the object's shape changes).
|
||||
#[default]
|
||||
Stretch,
|
||||
/// Move the whole object by the grip displacement.
|
||||
Move,
|
||||
/// Rotate the object about the grip; the angle is from the grip to the new point.
|
||||
Rotate,
|
||||
/// Scale the object about the grip; the factor is the distance from the grip to the new point.
|
||||
Scale,
|
||||
/// Mirror the object about the line from the grip to the new point.
|
||||
Mirror,
|
||||
}
|
||||
|
||||
fn set_xy(p: &mut Vec3, q: Vec2) {
|
||||
p.x = q.x;
|
||||
p.y = q.y;
|
||||
}
|
||||
|
||||
fn translated(k: &EntityKind, d: Vec2) -> EntityKind {
|
||||
let mut k = k.clone();
|
||||
k.transform(&Mat3::translate(d));
|
||||
k
|
||||
}
|
||||
|
||||
/// What a polyline grip index refers to (matches the order of `EntityKind::grips`).
|
||||
enum PolyGrip {
|
||||
Vertex(usize),
|
||||
/// Midpoint of the segment starting at this vertex.
|
||||
Mid(usize),
|
||||
}
|
||||
|
||||
fn poly_grip(p: &LwPolyline, index: usize) -> Option<PolyGrip> {
|
||||
let n = p.vertices.len();
|
||||
// Start vertices of the non-degenerate segments, in the same order as Polyline::segments().
|
||||
let count = if p.closed { n } else { n.saturating_sub(1) };
|
||||
let mut seg_starts = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let (Some(v), Some(w)) = (p.vertices.get(i), p.vertices.get((i + 1) % n.max(1))) else { continue };
|
||||
if v.p.near(w.p, EPS) && v.bulge.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
seg_starts.push(i);
|
||||
}
|
||||
let mut k = 0usize;
|
||||
for i in 0..n {
|
||||
if k == index {
|
||||
return Some(PolyGrip::Vertex(i));
|
||||
}
|
||||
k += 1;
|
||||
if let Some(start) = seg_starts.get(i) {
|
||||
if k == index {
|
||||
return Some(PolyGrip::Mid(*start));
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The shape after dragging grip `index` to `to` (STRETCH mode). `None` when the grip does not
|
||||
/// exist or the result would be degenerate.
|
||||
pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityKind> {
|
||||
if !to.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let grips = kind.grips();
|
||||
let from = *grips.get(index)?;
|
||||
let delta = to - from;
|
||||
let mut k = kind.clone();
|
||||
match &mut k {
|
||||
EntityKind::Line(l) => match index {
|
||||
0 => set_xy(&mut l.a, to),
|
||||
1 => return Some(translated(kind, delta)),
|
||||
_ => set_xy(&mut l.b, to),
|
||||
},
|
||||
EntityKind::Point(_) | EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) | EntityKind::Image(_) | EntityKind::Table(_) => {
|
||||
return Some(translated(kind, delta));
|
||||
}
|
||||
EntityKind::Hatch(_) => return Some(translated(kind, delta)),
|
||||
EntityKind::Circle(c) => {
|
||||
if index == 0 {
|
||||
return Some(translated(kind, delta));
|
||||
}
|
||||
let r = c.center.xy().dist(to);
|
||||
if r <= EPS {
|
||||
return None;
|
||||
}
|
||||
c.radius = r;
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
let (mut s, mut m, mut e) = (g.start_point(), g.mid_point(), g.end_point());
|
||||
match index {
|
||||
0 => s = to,
|
||||
1 => m = to,
|
||||
2 => e = to,
|
||||
_ => return Some(translated(kind, delta)),
|
||||
}
|
||||
let na = Arc::from_3_points(s, m, e)?;
|
||||
return Some(crate::cmd::helpers::arc(&na));
|
||||
}
|
||||
EntityKind::Ellipse(e) => {
|
||||
let c = e.center.xy();
|
||||
let mj = e.major.xy();
|
||||
let a = mj.len();
|
||||
if a <= EPS {
|
||||
return None;
|
||||
}
|
||||
let b = a * e.ratio;
|
||||
match index {
|
||||
0 => return Some(translated(kind, delta)),
|
||||
1 | 3 => {
|
||||
let v = if index == 1 { to - c } else { c - to };
|
||||
let l = v.len();
|
||||
if l <= EPS {
|
||||
return None;
|
||||
}
|
||||
if l >= b {
|
||||
e.major = v.to3(0.0);
|
||||
e.ratio = (b / l).clamp(1e-9, 1.0);
|
||||
} else {
|
||||
e.major = (v.normalized().perp() * b).to3(0.0);
|
||||
e.ratio = (l / b).clamp(1e-9, 1.0);
|
||||
shift_params(e, -std::f64::consts::FRAC_PI_2);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let u = mj / a;
|
||||
let l = (to - c).dot(u.perp()).abs();
|
||||
if l <= EPS {
|
||||
return None;
|
||||
}
|
||||
if l <= a {
|
||||
e.ratio = (l / a).clamp(1e-9, 1.0);
|
||||
} else {
|
||||
e.major = (u.perp() * l).to3(0.0);
|
||||
e.ratio = (a / l).clamp(1e-9, 1.0);
|
||||
shift_params(e, -std::f64::consts::FRAC_PI_2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let n = p.vertices.len();
|
||||
match poly_grip(p, index)? {
|
||||
PolyGrip::Vertex(i) => p.vertices.get_mut(i)?.p = to,
|
||||
PolyGrip::Mid(i) => {
|
||||
let j = (i + 1) % n.max(1);
|
||||
let (a, b) = (p.vertices.get(i)?.p, p.vertices.get(j)?.p);
|
||||
if p.vertices.get(i)?.bulge.abs() > 1e-12 {
|
||||
// Arc segment: reshape through the dragged midpoint.
|
||||
p.vertices.get_mut(i)?.bulge = crate::cmd::curves::bulge_through(a, to, b);
|
||||
} else {
|
||||
p.vertices.get_mut(i)?.p = a + delta;
|
||||
p.vertices.get_mut(j)?.p = b + delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Polyline3d(p) => set_xy(p.points.get_mut(index)?, to),
|
||||
EntityKind::Spline(sp) => {
|
||||
if sp.fit.is_empty() {
|
||||
*sp.control.get_mut(index)? = to;
|
||||
} else {
|
||||
let mut fit = sp.fit.clone();
|
||||
*fit.get_mut(index)? = to;
|
||||
let closed = sp.closed;
|
||||
let mut n = Spline::from_fit_points(&fit);
|
||||
n.closed = closed;
|
||||
*sp = n;
|
||||
}
|
||||
}
|
||||
EntityKind::Ray(r) | EntityKind::XLine(r) => {
|
||||
if index == 0 {
|
||||
return Some(translated(kind, delta));
|
||||
}
|
||||
let d = (to - r.base.xy()).normalized();
|
||||
if d == Vec2::ZERO {
|
||||
return None;
|
||||
}
|
||||
r.dir = d.to3(0.0);
|
||||
}
|
||||
EntityKind::Insert(i) => {
|
||||
if index == 0 {
|
||||
return Some(translated(kind, delta));
|
||||
}
|
||||
let a = i.attribs.get_mut(index - 1)?;
|
||||
let ip = a.text.insert.xy() + delta;
|
||||
set_xy(&mut a.text.insert, ip);
|
||||
if let Some(al) = &mut a.text.align_pt {
|
||||
let q = al.xy() + delta;
|
||||
set_xy(al, q);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
match index {
|
||||
0 => set_xy(&mut d.p13, to),
|
||||
1 => set_xy(&mut d.p14, to),
|
||||
2 => {
|
||||
set_xy(&mut d.defpt, to);
|
||||
if matches!(d.kind, DimKind::Radius | DimKind::Diameter) {
|
||||
d.user_text_pos = false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
set_xy(&mut d.text_mid, to);
|
||||
d.user_text_pos = true;
|
||||
}
|
||||
}
|
||||
d.block = None;
|
||||
}
|
||||
EntityKind::Leader(l) => set_xy(l.vertices.get_mut(index)?, to),
|
||||
EntityKind::MLeader(m) => {
|
||||
let mut idx = 0usize;
|
||||
let mut done = false;
|
||||
'outer: for l in &mut m.leaders {
|
||||
for v in l.iter_mut() {
|
||||
if idx == index {
|
||||
set_xy(v, to);
|
||||
done = true;
|
||||
break 'outer;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
if !done {
|
||||
// The landing grip moves the landing and its text.
|
||||
let ld = m.landing.xy() + delta;
|
||||
set_xy(&mut m.landing, ld);
|
||||
if let Some(t) = &mut m.text {
|
||||
let q = t.insert.xy() + delta;
|
||||
set_xy(&mut t.insert, q);
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Solid(s) | EntityKind::Trace(s) => set_xy(s.corners.get_mut(index)?, to),
|
||||
EntityKind::Face3d(f) => set_xy(f.corners.get_mut(index)?, to),
|
||||
EntityKind::Viewport(v) => {
|
||||
let opposite = *grips.get((index + 2) % 4)?;
|
||||
let w = (to.x - opposite.x).abs();
|
||||
let h = (to.y - opposite.y).abs();
|
||||
if w <= EPS || h <= EPS {
|
||||
return None;
|
||||
}
|
||||
let c = to.mid(opposite);
|
||||
set_xy(&mut v.center, c);
|
||||
v.width = w;
|
||||
v.height = h;
|
||||
}
|
||||
EntityKind::Wipeout(w) => *w.boundary.get_mut(index)? = to,
|
||||
EntityKind::Unknown(_) => return None,
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
|
||||
fn shift_params(e: &mut cadcraft_doc::Ellipse, by: f64) {
|
||||
let full = (cadcraft_geom::ccw_sweep(e.start, e.end) - cadcraft_geom::TAU).abs() < 1e-9;
|
||||
if !full {
|
||||
e.start = norm_angle(e.start + by);
|
||||
e.end = norm_angle(e.end + by);
|
||||
}
|
||||
}
|
||||
|
||||
/// The transform for a multi-functional grip mode about `base` with the dragged point `to`.
|
||||
pub fn mode_matrix(mode: GripMode, base: Vec2, to: Vec2) -> Option<Mat3> {
|
||||
match mode {
|
||||
GripMode::Stretch => None,
|
||||
GripMode::Move => Some(Mat3::translate(to - base)),
|
||||
GripMode::Rotate => Some(Mat3::rotate_about(base, base.angle_to(to))),
|
||||
GripMode::Scale => {
|
||||
let f = base.dist(to);
|
||||
(f > EPS && f.is_finite()).then(|| Mat3::scale_about(base, f))
|
||||
}
|
||||
GripMode::Mirror => (!base.near(to, EPS)).then(|| Mat3::mirror(base, to)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Drag grip `grip_index` of `handle` to `new_point` in `mode` (one undo step).
|
||||
/// The base point of the rotate/scale/mirror modes is the grip itself.
|
||||
pub fn grip_edit(&mut self, handle: Handle, grip_index: usize, new_point: Vec2, mode: GripMode) -> Result<()> {
|
||||
let to = [new_point.x, new_point.y];
|
||||
let r = match mode {
|
||||
GripMode::Stretch => self.execute("grip.move", &json!({ "handle": handle.hex(), "index": grip_index, "to": to })),
|
||||
GripMode::Move => self.execute("grip.move", &json!({ "handles": [handle.hex()], "index": grip_index, "to": to, "mode": "move" })),
|
||||
GripMode::Rotate => {
|
||||
self.execute("grip.rotate", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to }))
|
||||
}
|
||||
GripMode::Scale => {
|
||||
self.execute("grip.scale", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to }))
|
||||
}
|
||||
GripMode::Mirror => {
|
||||
self.execute("grip.mirror", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to }))
|
||||
}
|
||||
};
|
||||
r.map(|_| ())
|
||||
}
|
||||
|
||||
/// Grip points of an object (the indices `grip_edit` takes).
|
||||
pub fn grips_of(&self, handle: Handle) -> Result<Vec<Vec2>> {
|
||||
self.doc()?.entity(handle).map(|e| e.kind.grips()).ok_or_else(|| EngineError::Other("no such object".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Polyline helper kept public for the UI: segment midpoints are grips too.
|
||||
pub fn polyline_grip_count(p: &LwPolyline) -> usize {
|
||||
p.vertices.len() + Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments().len().min(p.vertices.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "grips_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,224 @@
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Arc, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
fn h(r: &Value) -> Handle {
|
||||
Handle::parse_hex(r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn kind(s: &Session, h: Handle) -> EntityKind {
|
||||
s.doc().unwrap().entity(h).unwrap().kind.clone()
|
||||
}
|
||||
|
||||
fn near(a: Vec2, x: f64, y: f64) -> bool {
|
||||
a.near(Vec2::new(x, y), 1e-6)
|
||||
}
|
||||
|
||||
fn v(x: f64, y: f64) -> Vec2 {
|
||||
Vec2::new(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_circle_grips() {
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap());
|
||||
s.grip_edit(l, 0, v(1.0, 1.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.a.xy(), 1.0, 1.0) && near(x.b.xy(), 10.0, 0.0)));
|
||||
// Midpoint grip moves the whole line.
|
||||
let mid = s.grips_of(l).unwrap()[1];
|
||||
s.grip_edit(l, 1, mid + v(0.0, 5.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.a.xy(), 1.0, 6.0) && near(x.b.xy(), 10.0, 5.0)));
|
||||
s.execute("grip.move", &json!({"handle": l.hex(), "index": 2, "to": [20, 5]})).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 20.0, 5.0)));
|
||||
// Each edit is one undo step.
|
||||
s.undo().unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 10.0, 5.0)));
|
||||
|
||||
let c = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap());
|
||||
s.grip_edit(c, 2, v(0.0, 5.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, c), EntityKind::Circle(x) if (x.radius - 5.0).abs() < 1e-9));
|
||||
s.grip_edit(c, 0, v(3.0, 3.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, c), EntityKind::Circle(x) if near(x.center.xy(), 3.0, 3.0) && (x.radius - 5.0).abs() < 1e-9));
|
||||
assert!(s.grip_edit(c, 1, v(3.0, 3.0), GripMode::Stretch).is_err(), "zero radius refused");
|
||||
assert!(s.grip_edit(c, 99, v(3.0, 3.0), GripMode::Stretch).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_and_ellipse_grips() {
|
||||
let mut s = Session::new();
|
||||
let a = h(&s.execute("arc", &json!({"center": [0, 0], "radius": 1, "start": 0, "end": 180})).unwrap());
|
||||
// Midpoint grip: three-point reshape through the new point.
|
||||
s.grip_edit(a, 1, v(0.0, 2.0), GripMode::Stretch).unwrap();
|
||||
let EntityKind::Arc(x) = kind(&s, a) else { panic!() };
|
||||
let g = Arc::new(x.center.xy(), x.radius, x.start, x.end);
|
||||
assert!(near(g.start_point(), 1.0, 0.0) && near(g.end_point(), -1.0, 0.0) && near(g.mid_point(), 0.0, 2.0));
|
||||
// End grip.
|
||||
s.grip_edit(a, 2, v(-3.0, 0.0), GripMode::Stretch).unwrap();
|
||||
let EntityKind::Arc(x) = kind(&s, a) else { panic!() };
|
||||
assert!(near(Arc::new(x.center.xy(), x.radius, x.start, x.end).end_point(), -3.0, 0.0));
|
||||
// Center grip moves.
|
||||
let c0 = s.grips_of(a).unwrap()[3];
|
||||
s.grip_edit(a, 3, c0 + v(10.0, 0.0), GripMode::Stretch).unwrap();
|
||||
let EntityKind::Arc(y) = kind(&s, a) else { panic!() };
|
||||
assert!((y.center.x - x.center.x - 10.0).abs() < 1e-9 && (y.radius - x.radius).abs() < 1e-9);
|
||||
|
||||
let e = h(&s.execute("ellipse", &json!({"center": [0, 0], "major": [4, 0], "ratio": 0.5})).unwrap());
|
||||
s.grip_edit(e, 2, v(0.0, 1.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, e), EntityKind::Ellipse(x) if (x.ratio - 0.25).abs() < 1e-9));
|
||||
s.grip_edit(e, 1, v(0.0, 6.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, e), EntityKind::Ellipse(x) if near(x.major.xy(), 0.0, 6.0) && (x.ratio - 1.0 / 6.0).abs() < 1e-9));
|
||||
// Dragging the minor grip past the major length swaps the axes.
|
||||
s.grip_edit(e, 2, v(-8.0, 0.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, e), EntityKind::Ellipse(x) if (x.major.xy().len() - 8.0).abs() < 1e-9 && (x.ratio - 0.75).abs() < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_grips() {
|
||||
let mut s = Session::new();
|
||||
let p = h(&s.execute("pline", &json!({"vertices": [[0, 0], {"p": [10, 0], "bulge": 0.0}, {"p": [10, 10], "bulge": 1.0}, [0, 10]]})).unwrap());
|
||||
// Grips: v0, m0, v1, m1, v2, m2, v3.
|
||||
assert_eq!(s.grips_of(p).unwrap().len(), 7);
|
||||
s.grip_edit(p, 0, v(-1.0, -1.0), GripMode::Stretch).unwrap();
|
||||
let EntityKind::LwPolyline(pl) = kind(&s, p) else { panic!() };
|
||||
assert!(near(pl.vertices[0].p, -1.0, -1.0));
|
||||
// Line segment midpoint: moves the segment.
|
||||
let m1 = s.grips_of(p).unwrap()[3];
|
||||
s.grip_edit(p, 3, m1 + v(2.0, 0.0), GripMode::Stretch).unwrap();
|
||||
let EntityKind::LwPolyline(pl) = kind(&s, p) else { panic!() };
|
||||
assert!(near(pl.vertices[1].p, 12.0, 0.0) && near(pl.vertices[2].p, 12.0, 10.0));
|
||||
// Arc segment midpoint: adjusts the bulge so the arc passes through the point.
|
||||
s.grip_edit(p, 5, v(6.0, 12.0), GripMode::Stretch).unwrap();
|
||||
let EntityKind::LwPolyline(pl) = kind(&s, p) else { panic!() };
|
||||
assert!(near(pl.vertices[2].p, 12.0, 10.0) && near(pl.vertices[3].p, 0.0, 10.0));
|
||||
let segs = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: false }.segments();
|
||||
assert!(segs[2].mid().near(v(6.0, 12.0), 1e-6), "{:?}", segs[2].mid());
|
||||
assert!(s.grip_edit(p, 7, v(0.0, 0.0), GripMode::Stretch).is_err());
|
||||
// Closed polylines have a closing-segment midpoint grip.
|
||||
let r = h(&s.execute("rectang", &json!({"p1": [20, 0], "p2": [24, 2]})).unwrap());
|
||||
assert_eq!(s.grips_of(r).unwrap().len(), 8);
|
||||
s.grip_edit(r, 7, v(19.0, 1.0), GripMode::Stretch).unwrap();
|
||||
let EntityKind::LwPolyline(pl) = kind(&s, r) else { panic!() };
|
||||
assert!(near(pl.vertices[3].p, 19.0, 2.0) && near(pl.vertices[0].p, 19.0, 0.0));
|
||||
// A zero-length segment keeps grip indices aligned with EntityKind::grips().
|
||||
let d = h(&s.execute("pline", &json!({"vertices": [[0, 50], [0, 50], [5, 50]]})).unwrap());
|
||||
let g = s.grips_of(d).unwrap();
|
||||
assert_eq!(g.len(), 4);
|
||||
s.grip_edit(d, 3, v(5.0, 55.0), GripMode::Stretch).unwrap();
|
||||
let EntityKind::LwPolyline(pl) = kind(&s, d) else { panic!() };
|
||||
assert!(near(pl.vertices[2].p, 5.0, 55.0), "{pl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_text_insert_and_dimension_grips() {
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({"fit": [[0, 0], [2, 2], [4, 0]]})).unwrap());
|
||||
s.grip_edit(sp, 1, v(2.0, 4.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, sp), EntityKind::Spline(x) if near(x.fit[1], 2.0, 4.0) && near(x.eval(x.domain().0), 0.0, 0.0)));
|
||||
let cv = h(&s.execute("spline", &json!({"control": [[0, 10], [2, 12], [4, 10]]})).unwrap());
|
||||
s.grip_edit(cv, 2, v(5.0, 9.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, cv), EntityKind::Spline(x) if near(x.control[2], 5.0, 9.0)));
|
||||
|
||||
let t = h(&s.execute("text", &json!({"at": [1, 1], "text": "Hi", "justify": "C"})).unwrap());
|
||||
s.grip_edit(t, 0, v(3.0, 4.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, t), EntityKind::Text(x) if near(x.insert.xy(), 3.0, 4.0) && near(x.align_pt.unwrap().xy(), 3.0, 4.0)));
|
||||
|
||||
let dot = h(&s.execute("circle", &json!({"center": [0, 0], "radius": 0.1})).unwrap());
|
||||
s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": [dot.hex()], "keep": "delete"})).unwrap();
|
||||
let ins = h(&s.execute("insert", &json!({"name": "B", "at": [5, 5]})).unwrap());
|
||||
s.grip_edit(ins, 0, v(6.0, 7.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, ins), EntityKind::Insert(x) if near(x.insert.xy(), 6.0, 7.0)));
|
||||
|
||||
let d = h(&s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [10, 0], "at": [5, 2]})).unwrap());
|
||||
let g = s.grips_of(d).unwrap();
|
||||
assert_eq!(g.len(), 4);
|
||||
s.grip_edit(d, 3, v(5.0, 6.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, d), EntityKind::Dimension(x) if x.user_text_pos && near(x.text_mid.xy(), 5.0, 6.0)));
|
||||
s.grip_edit(d, 1, v(12.0, 0.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, d), EntityKind::Dimension(x) if near(x.p14.xy(), 12.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_entity_grips() {
|
||||
let mut s = Session::new();
|
||||
let x = h(&s.execute("xline", &json!({"base": [0, 0], "angle": 0})).unwrap());
|
||||
s.grip_edit(x, 1, v(0.0, 3.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, x), EntityKind::XLine(r) if near(r.dir.xy(), 0.0, 1.0)));
|
||||
let p3 = h(&s.execute("3dpoly", &json!({"points": [[0, 0, 4], [1, 0, 5]]})).unwrap());
|
||||
s.grip_edit(p3, 1, v(2.0, 2.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, p3), EntityKind::Polyline3d(p) if near(p.points[1].xy(), 2.0, 2.0) && p.points[1].z == 5.0));
|
||||
let w = h(&s.execute("wipeout", &json!({"points": [[0, 0], [2, 0], [2, 2]]})).unwrap());
|
||||
s.grip_edit(w, 2, v(3.0, 3.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, w), EntityKind::Wipeout(p) if near(p.boundary[2], 3.0, 3.0)));
|
||||
let pt = h(&s.execute("point", &json!({"at": [1, 1]})).unwrap());
|
||||
s.grip_edit(pt, 0, v(2.0, 2.0), GripMode::Stretch).unwrap();
|
||||
assert!(matches!(kind(&s, pt), EntityKind::Point(p) if near(p.p.xy(), 2.0, 2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grip_modes() {
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap());
|
||||
// Move about grip 0.
|
||||
s.grip_edit(l, 0, v(1.0, 1.0), GripMode::Move).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.a.xy(), 1.0, 1.0) && near(x.b.xy(), 3.0, 1.0)));
|
||||
// Rotate about grip 0 by the angle to the new point (90°).
|
||||
s.grip_edit(l, 0, v(1.0, 5.0), GripMode::Rotate).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 1.0, 3.0)));
|
||||
// Scale about grip 0 by the distance (3).
|
||||
s.grip_edit(l, 0, v(4.0, 1.0), GripMode::Scale).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 1.0, 7.0)));
|
||||
// Mirror about the line from grip 0 to (2, 1): a horizontal line through y=1.
|
||||
s.grip_edit(l, 0, v(2.0, 1.0), GripMode::Mirror).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 1.0, -5.0)));
|
||||
assert_eq!(s.state().unwrap().undo.len(), 5);
|
||||
// JSON forms with explicit base, angle, factor and copy.
|
||||
let r = s.execute("grip.rotate", &json!({"handles": [l.hex()], "base": [1, 1], "angle": 180, "copy": true})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2);
|
||||
s.execute("grip.scale", &json!({"handles": [l.hex()], "base": [1, 1], "factor": 0.5})).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 1.0, -2.0)));
|
||||
s.execute("grip.move", &json!({"handles": [l.hex()], "base": [1, 1], "to": [2, 2]})).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.a.xy(), 2.0, 2.0)));
|
||||
s.execute("grip.mirror", &json!({"handles": [l.hex()], "baseHandle": l.hex(), "index": 0, "to": [3, 2]})).unwrap();
|
||||
s.execute("grip.stretch", &json!({"handle": l.hex(), "index": 2, "to": [9, 9]})).unwrap();
|
||||
assert!(matches!(kind(&s, l), EntityKind::Line(x) if near(x.b.xy(), 9.0, 9.0)));
|
||||
assert!(s.execute("grip.scale", &json!({"handles": [l.hex()], "base": [1, 1], "factor": 0})).is_err());
|
||||
assert!(s.execute("grip.mirror", &json!({"handles": [l.hex()], "base": [1, 1], "to": [1, 1]})).is_err());
|
||||
assert!(s.execute("grip.move", &json!({"handle": l.hex(), "to": [1, 1]})).is_err(), "stretch needs an index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grips_on_locked_layers_and_hostile_values() {
|
||||
let mut s = Session::new();
|
||||
let l = h(&s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap());
|
||||
for (i, p) in [(0usize, v(f64::NAN, 0.0)), (1, v(f64::INFINITY, 1.0)), (usize::MAX, v(1.0, 1.0))] {
|
||||
assert!(s.grip_edit(l, i, p, GripMode::Stretch).is_err());
|
||||
}
|
||||
for mode in [GripMode::Move, GripMode::Rotate, GripMode::Scale, GripMode::Mirror] {
|
||||
let _ = s.grip_edit(l, 0, v(0.0, 0.0), mode);
|
||||
let _ = s.grip_edit(l, 42, v(1e308, -1e308), mode);
|
||||
}
|
||||
s.execute("layer.new", &json!({"name": "L"})).ok();
|
||||
s.doc_mut().unwrap().ensure_layer("LOCKED");
|
||||
s.doc_mut().unwrap().layer_mut("LOCKED").unwrap().locked = true;
|
||||
s.doc_mut().unwrap().modify_entity(l, |e| e.common.layer = "LOCKED".into()).unwrap();
|
||||
assert!(s.grip_edit(l, 0, v(5.0, 5.0), GripMode::Stretch).is_err());
|
||||
// Every kind accepts every grip index without panicking.
|
||||
let mut s = Session::new();
|
||||
crate::cmd::command_specs();
|
||||
let sample = crate::sample::bracket();
|
||||
s.open_drawing(sample, "sample", None);
|
||||
let hs: Vec<(Handle, usize)> = s.doc().unwrap().model.iter().map(|e| (e.handle, e.kind.grips().len())).collect();
|
||||
for (hd, n) in hs {
|
||||
for i in 0..n + 1 {
|
||||
for to in [v(0.0, 0.0), v(1e9, -1e9), v(3.0, 4.0)] {
|
||||
if let Some(k) = s.doc().unwrap().entity(hd).map(|e| e.kind.clone()) {
|
||||
let _ = stretch_grip(&k, i, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod cmd;
|
||||
pub mod grips;
|
||||
pub mod prompt;
|
||||
pub mod sample;
|
||||
pub mod select;
|
||||
pub mod snap;
|
||||
pub mod spatial;
|
||||
pub mod sysvars;
|
||||
pub mod units;
|
||||
|
||||
|
||||
+64
-12
@@ -3,6 +3,8 @@
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Prim, Space, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Line, Vec2};
|
||||
|
||||
use crate::spatial::{self, SpatialIndex};
|
||||
|
||||
/// Polylines approximating an entity for hit testing, tessellated at `tol`.
|
||||
pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
match &e.kind {
|
||||
@@ -83,18 +85,57 @@ fn selectable(d: &Drawing, e: &Entity) -> bool {
|
||||
d.is_visible(e)
|
||||
}
|
||||
|
||||
/// Entities to test, in the order to test them, each with its bounds when already known.
|
||||
pub(crate) type Cands<'a> = Box<dyn Iterator<Item = (&'a Entity, Option<Bounds2>)> + 'a>;
|
||||
|
||||
pub(crate) fn bounds_of(d: &Drawing, e: &Entity, known: Option<Bounds2>) -> Bounds2 {
|
||||
known.unwrap_or_else(|| entity_bounds(d, e, 0))
|
||||
}
|
||||
|
||||
pub(crate) fn is_infinite(e: &Entity) -> bool {
|
||||
matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_))
|
||||
}
|
||||
|
||||
/// Candidates from the spatial index touching `query` (draw order, reversed when `rev`), or every
|
||||
/// entity of the space when it is too small to index (or the query box isn't finite).
|
||||
pub(crate) fn candidates<'a>(
|
||||
d: &'a Drawing,
|
||||
space: &Space,
|
||||
ix: &'a Option<std::sync::Arc<SpatialIndex>>,
|
||||
query: &Bounds2,
|
||||
infinite: bool,
|
||||
rev: bool,
|
||||
) -> Option<Cands<'a>> {
|
||||
if let Some(ix) = ix
|
||||
&& let Some(c) = ix.query(query, infinite)
|
||||
{
|
||||
let it = move |i: u32| ix.entity(i).map(|e| (e.as_ref(), Some(ix.bounds(i))));
|
||||
return Some(if rev { Box::new(c.into_iter().rev().filter_map(it)) } else { Box::new(c.into_iter().filter_map(it)) });
|
||||
}
|
||||
let store = d.space(space)?;
|
||||
if rev {
|
||||
let ents: Vec<&Entity> = store.iter().map(|e| e.as_ref()).collect();
|
||||
Some(Box::new(ents.into_iter().rev().map(|e| (e, None))))
|
||||
} else {
|
||||
Some(Box::new(store.iter().map(|e| (e.as_ref(), None))))
|
||||
}
|
||||
}
|
||||
|
||||
/// The topmost entity within `aperture` of `p`.
|
||||
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let store = d.space(space)?;
|
||||
let ix = spatial::index(d, space);
|
||||
pick_with(d, space, &ix, p, aperture)
|
||||
}
|
||||
|
||||
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let mut best: Option<(f64, Handle)> = None;
|
||||
let ents: Vec<_> = store.iter().collect();
|
||||
for e in ents.iter().rev() {
|
||||
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
|
||||
if !selectable(d, e) {
|
||||
continue;
|
||||
}
|
||||
let infinite = matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_));
|
||||
if !infinite && !entity_bounds(d, e, 0).expand(aperture).intersects(&probe) {
|
||||
let infinite = is_infinite(e);
|
||||
if !infinite && !bounds_of(d, e, known).expand(aperture).intersects(&probe) {
|
||||
continue;
|
||||
}
|
||||
let dist = entity_distance(d, e, p, aperture / 4.0);
|
||||
@@ -118,15 +159,20 @@ fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool {
|
||||
|
||||
/// Window selection (entirely inside) or crossing selection (inside or touching).
|
||||
pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
let Some(store) = d.space(space) else { return Vec::new() };
|
||||
let ix = spatial::index(d, space);
|
||||
select_window_with(d, space, &ix, bx, crossing)
|
||||
}
|
||||
|
||||
pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() };
|
||||
let tol = (bx.width() + bx.height()).max(1e-9) / 2000.0;
|
||||
let mut out = Vec::new();
|
||||
for e in store.iter() {
|
||||
for (e, known) in cands {
|
||||
if !selectable(d, e) {
|
||||
continue;
|
||||
}
|
||||
let infinite = matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_));
|
||||
let eb = entity_bounds(d, e, 0);
|
||||
let infinite = is_infinite(e);
|
||||
let eb = bounds_of(d, e, known);
|
||||
if !infinite && bx.contains_box(&eb) {
|
||||
out.push(e.handle);
|
||||
continue;
|
||||
@@ -152,12 +198,18 @@ pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) ->
|
||||
|
||||
/// Fence selection: entities crossed by the fence polyline.
|
||||
pub fn select_fence(d: &Drawing, space: &Space, fence: &[Vec2]) -> Vec<Handle> {
|
||||
let Some(store) = d.space(space) else { return Vec::new() };
|
||||
let ix = spatial::index(d, space);
|
||||
select_fence_with(d, space, &ix, fence)
|
||||
}
|
||||
|
||||
pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, fence: &[Vec2]) -> Vec<Handle> {
|
||||
let fb = Bounds2::from_points(fence.iter().copied());
|
||||
let tol = (fb.width() + fb.height()).max(1e-9) / 2000.0;
|
||||
let probe = fb.expand(tol);
|
||||
let Some(cands) = candidates(d, space, ix, &probe, false, false) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for e in store.iter() {
|
||||
if !selectable(d, e) || !entity_bounds(d, e, 0).intersects(&fb.expand(tol)) {
|
||||
for (e, known) in cands {
|
||||
if !selectable(d, e) || !bounds_of(d, e, known).intersects(&probe) {
|
||||
continue;
|
||||
}
|
||||
let polys = hit_polylines(d, e, tol);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Object snaps (OSNAP), polar tracking and ortho.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Prim, Space, entity_bounds};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Prim, Space};
|
||||
use cadcraft_geom::{Bounds2, Circle, PI, Segment, Vec2, intersect_ext};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -79,17 +79,32 @@ pub fn osnap(d: &Drawing, space: &Space, cursor: Vec2, aperture: f64, osmode: u3
|
||||
if osmode == 0 || osmode & mode::OFF != 0 {
|
||||
return None;
|
||||
}
|
||||
let store = d.space(space)?;
|
||||
let ix = crate::spatial::index(d, space);
|
||||
osnap_with(d, space, &ix, cursor, aperture, osmode, base)
|
||||
}
|
||||
|
||||
pub(crate) fn osnap_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
|
||||
cursor: Vec2,
|
||||
aperture: f64,
|
||||
osmode: u32,
|
||||
base: Option<Vec2>,
|
||||
) -> Option<SnapHit> {
|
||||
if osmode == 0 || osmode & mode::OFF != 0 {
|
||||
return None;
|
||||
}
|
||||
let probe = Bounds2::new(cursor, cursor).expand(aperture);
|
||||
let mut cands: Vec<(u32, Vec2)> = Vec::new();
|
||||
let mut near_prims: Vec<Prim> = Vec::new();
|
||||
let mut count = 0usize;
|
||||
for e in store.iter() {
|
||||
for (e, known) in crate::select::candidates(d, space, ix, &probe.expand(aperture), true, false)? {
|
||||
if !d.is_visible(e) {
|
||||
continue;
|
||||
}
|
||||
let inf = matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_));
|
||||
if !inf && !entity_bounds(d, e, 0).expand(aperture).intersects(&probe) {
|
||||
let inf = crate::select::is_infinite(e);
|
||||
if !inf && !crate::select::bounds_of(d, e, known).expand(aperture).intersects(&probe) {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! R-tree spatial index over entity bounds, used by picking, window/crossing/fence selection and
|
||||
//! object snaps so they look only at entities near the query instead of scanning the space.
|
||||
//!
|
||||
//! The index is cached per drawing space and keyed by *content identity*, not by a revision
|
||||
//! number: the cache holds a clone of the space's [`EntityStore`] (a cheap copy of chunk
|
||||
//! pointers) and of the block table, and an entry is reused only while the live store still
|
||||
//! shares every chunk with it ([`EntityStore::same_as`]). Because the cache holds a reference,
|
||||
//! any edit copies the touched chunk, so a stale index can never be returned — whether the edit
|
||||
//! came from a command, an interactive prompt mid-command, undo/redo or a script. Undoing back to
|
||||
//! a cached state reuses its index.
|
||||
//!
|
||||
//! Queries return candidates in draw order; callers then run exactly the same tests as the
|
||||
//! brute-force scan, so results are identical (see the parity tests).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds};
|
||||
use cadcraft_geom::Bounds2;
|
||||
use rstar::primitives::{GeomWithData, Rectangle};
|
||||
use rstar::{AABB, RTree};
|
||||
|
||||
/// Spaces with fewer entities than this are scanned directly (building an index costs more).
|
||||
pub const MIN_INDEXED: usize = 256;
|
||||
/// Indexes kept alive (open drawings × spaces, plus a few undo states).
|
||||
const CACHE_SIZE: usize = 4;
|
||||
|
||||
type Item = GeomWithData<Rectangle<[f64; 2]>, u32>;
|
||||
|
||||
/// An R-tree over one space's entity bounds.
|
||||
pub struct SpatialIndex {
|
||||
store: EntityStore,
|
||||
blocks: BTreeMap<String, Arc<Block>>,
|
||||
dim_styles: Vec<DimStyle>,
|
||||
dimscale: f64,
|
||||
ents: Vec<Arc<Entity>>,
|
||||
bounds: Vec<Bounds2>,
|
||||
tree: RTree<Item>,
|
||||
/// Draw indices of xlines and rays (unbounded; always candidates for pick and crossing).
|
||||
infinite: Vec<u32>,
|
||||
}
|
||||
|
||||
impl SpatialIndex {
|
||||
/// Build an index over a store (no caching).
|
||||
pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex {
|
||||
let mut ents = Vec::with_capacity(store.len());
|
||||
let mut bounds = Vec::with_capacity(store.len());
|
||||
let mut items = Vec::with_capacity(store.len());
|
||||
let mut infinite = Vec::new();
|
||||
for (i, e) in store.iter().enumerate() {
|
||||
let Ok(idx) = u32::try_from(i) else { break };
|
||||
let b = entity_bounds(d, e, 0);
|
||||
if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) {
|
||||
infinite.push(idx);
|
||||
}
|
||||
if !b.is_empty() {
|
||||
items.push(GeomWithData::new(Rectangle::from_corners([b.min.x, b.min.y], [b.max.x, b.max.y]), idx));
|
||||
}
|
||||
ents.push(e.clone());
|
||||
bounds.push(b);
|
||||
}
|
||||
SpatialIndex {
|
||||
store: store.clone(),
|
||||
blocks: d.blocks.clone(),
|
||||
dim_styles: d.dim_styles.clone(),
|
||||
dimscale: d.header.f64("DIMSCALE", 1.0),
|
||||
ents,
|
||||
bounds,
|
||||
tree: RTree::bulk_load(items),
|
||||
infinite,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, d: &Drawing, store: &EntityStore) -> bool {
|
||||
self.store.same_as(store)
|
||||
&& self.blocks.len() == d.blocks.len()
|
||||
&& self.blocks.iter().zip(&d.blocks).all(|((ka, a), (kb, b))| ka == kb && Arc::ptr_eq(a, b))
|
||||
&& self.dimscale.to_bits() == d.header.f64("DIMSCALE", 1.0).to_bits()
|
||||
&& self.dim_styles == d.dim_styles
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.ents.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.ents.is_empty()
|
||||
}
|
||||
/// The entity at draw index `i`.
|
||||
pub fn entity(&self, i: u32) -> Option<&Arc<Entity>> {
|
||||
self.ents.get(i as usize)
|
||||
}
|
||||
/// The bounds of the entity at draw index `i` (as [`entity_bounds`] computes them).
|
||||
pub fn bounds(&self, i: u32) -> Bounds2 {
|
||||
self.bounds.get(i as usize).copied().unwrap_or(Bounds2::EMPTY)
|
||||
}
|
||||
|
||||
/// Draw indices (ascending) of entities whose bounds touch `b`, optionally with the infinite
|
||||
/// lines. Returns `None` for a non-finite query box (callers fall back to a full scan).
|
||||
pub fn query(&self, b: &Bounds2, with_infinite: bool) -> Option<Vec<u32>> {
|
||||
if b.is_empty() {
|
||||
return Some(if with_infinite { self.infinite.clone() } else { Vec::new() });
|
||||
}
|
||||
if !(b.min.is_finite() && b.max.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// Pad a little so rounding in the callers' own predicates never loses a candidate.
|
||||
let pad = (b.width() + b.height()) * 1e-9 + (b.min.x.abs() + b.min.y.abs() + b.max.x.abs() + b.max.y.abs()) * 1e-12 + 1e-300;
|
||||
let env = AABB::from_corners([b.min.x - pad, b.min.y - pad], [b.max.x + pad, b.max.y + pad]);
|
||||
let mut out: Vec<u32> = self.tree.locate_in_envelope_intersecting(&env).map(|it| it.data).collect();
|
||||
if with_infinite {
|
||||
out.extend_from_slice(&self.infinite);
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
static CACHE: Mutex<Vec<Arc<SpatialIndex>>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The cached index for a space, building it when the space changed. `None` when the space
|
||||
/// doesn't exist or is small enough to scan directly.
|
||||
pub fn index(d: &Drawing, space: &Space) -> Option<Arc<SpatialIndex>> {
|
||||
let store = d.space(space)?;
|
||||
if store.len() < MIN_INDEXED {
|
||||
return None;
|
||||
}
|
||||
Some(index_store(d, store))
|
||||
}
|
||||
|
||||
/// The cached index for a store regardless of its size.
|
||||
pub fn index_store(d: &Drawing, store: &EntityStore) -> Arc<SpatialIndex> {
|
||||
{
|
||||
let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(pos) = cache.iter().position(|ix| ix.matches(d, store)) {
|
||||
let ix = cache.remove(pos);
|
||||
cache.push(ix.clone());
|
||||
return ix;
|
||||
}
|
||||
}
|
||||
// Build outside the lock so other threads aren't blocked.
|
||||
let ix = Arc::new(SpatialIndex::build(d, store));
|
||||
let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Drop entries whose store this one supersedes cheaply: keep the most recent ones.
|
||||
while cache.len() >= CACHE_SIZE {
|
||||
cache.remove(0);
|
||||
}
|
||||
cache.push(ix.clone());
|
||||
ix
|
||||
}
|
||||
|
||||
/// Drop every cached index (frees memory held by closed drawings).
|
||||
pub fn clear_cache() {
|
||||
CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{select, snap};
|
||||
use cadcraft_doc::{Common, Insert, Text};
|
||||
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn f(&mut self) -> f64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
(self.0 >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn r(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (hi - lo) * self.f()
|
||||
}
|
||||
}
|
||||
|
||||
fn v3(x: f64, y: f64) -> Vec3 {
|
||||
Vec3::new(x, y, 0.0)
|
||||
}
|
||||
|
||||
/// A random drawing mixing every kind the selection code special-cases, overlapping heavily.
|
||||
fn drawing(seed: u64, n: usize) -> Drawing {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut blk = Block {
|
||||
name: "B".into(),
|
||||
base: Vec3::default(),
|
||||
entities: EntityStore::new(),
|
||||
description: String::new(),
|
||||
anonymous: false,
|
||||
xref_path: None,
|
||||
explodable: true,
|
||||
units: 0,
|
||||
};
|
||||
blk.entities
|
||||
.push(Entity::new(cadcraft_doc::Handle(1_000_000), EntityKind::Circle(cadcraft_doc::Circle { center: v3(0.0, 0.0), radius: 1.0 })));
|
||||
d.blocks.insert("B".into(), Arc::new(blk));
|
||||
d.ensure_layer("OFF");
|
||||
if let Some(l) = d.layer_mut("OFF") {
|
||||
l.on = false;
|
||||
}
|
||||
let mut r = Rng(seed.max(1));
|
||||
for i in 0..n {
|
||||
let (x, y) = (r.r(0.0, 100.0), r.r(0.0, 100.0));
|
||||
let s = r.r(0.2, 6.0);
|
||||
let kind = match i % 10 {
|
||||
0 | 1 => EntityKind::Line(cadcraft_doc::Line { a: v3(x, y), b: v3(x + r.r(-s, s), y + r.r(-s, s)) }),
|
||||
2 => EntityKind::Circle(cadcraft_doc::Circle { center: v3(x, y), radius: s * 0.5 }),
|
||||
3 => EntityKind::Arc(cadcraft_doc::Arc { center: v3(x, y), radius: s * 0.5, start: r.r(0.0, 3.0), end: r.r(3.2, 6.2) }),
|
||||
4 => EntityKind::LwPolyline(cadcraft_doc::LwPolyline {
|
||||
vertices: (0..5)
|
||||
.map(|k| PolyVertex {
|
||||
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.r(-s, s) * 0.3),
|
||||
bulge: if k == 1 { 0.5 } else { 0.0 },
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
})
|
||||
.collect(),
|
||||
closed: i % 3 == 0,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
}),
|
||||
5 => EntityKind::Text(Text {
|
||||
insert: v3(x, y),
|
||||
align_pt: None,
|
||||
height: s * 0.3,
|
||||
value: format!("T{i}"),
|
||||
rotation: r.r(0.0, 1.0),
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
}),
|
||||
6 => EntityKind::Point(cadcraft_doc::Point { p: v3(x, y), angle: 0.0 }),
|
||||
7 => EntityKind::Insert(Insert {
|
||||
block: "B".into(),
|
||||
insert: v3(x, y),
|
||||
scale: Vec3::new(s * 0.3, s * 0.3, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
8 if i % 40 == 8 => EntityKind::XLine(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(r.r(-1.0, 1.0), 1.0) }),
|
||||
8 if i % 40 == 18 => EntityKind::Ray(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(1.0, r.r(-1.0, 1.0)) }),
|
||||
_ => EntityKind::Circle(cadcraft_doc::Circle { center: v3(x, y), radius: s * 0.1 }),
|
||||
};
|
||||
let common = if i % 17 == 0 { Common { layer: "OFF".into(), ..Common::default() } } else { Common::default() };
|
||||
d.add(&Space::Model, common, kind).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn forced(d: &Drawing) -> Option<Arc<SpatialIndex>> {
|
||||
Some(Arc::new(SpatialIndex::build(d, &d.model)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_parity_with_scan() {
|
||||
for seed in [3, 11, 97] {
|
||||
let d = drawing(seed, 600);
|
||||
let ix = forced(&d);
|
||||
let mut r = Rng(seed * 7 + 1);
|
||||
let mut hits = 0;
|
||||
for k in 0..400 {
|
||||
let p = Vec2::new(r.r(-5.0, 105.0), r.r(-5.0, 105.0));
|
||||
let ap = [0.05, 0.3, 1.5][k % 3];
|
||||
let a = select::pick_with(&d, &Space::Model, &ix, p, ap);
|
||||
let b = select::pick_with(&d, &Space::Model, &None, p, ap);
|
||||
assert_eq!(a, b, "seed {seed} p {p:?} ap {ap}");
|
||||
hits += usize::from(a.is_some());
|
||||
}
|
||||
assert!(hits > 20, "test should exercise hits ({hits})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_and_fence_parity_with_scan() {
|
||||
for seed in [5, 23] {
|
||||
let d = drawing(seed, 500);
|
||||
let ix = forced(&d);
|
||||
let mut r = Rng(seed + 100);
|
||||
for k in 0..60 {
|
||||
let a = Vec2::new(r.r(-10.0, 110.0), r.r(-10.0, 110.0));
|
||||
let b = a + Vec2::new(r.r(-40.0, 40.0), r.r(-40.0, 40.0));
|
||||
let bx = Bounds2::new(a, b);
|
||||
for crossing in [false, true] {
|
||||
let x = select::select_window_with(&d, &Space::Model, &ix, bx, crossing);
|
||||
let y = select::select_window_with(&d, &Space::Model, &None, bx, crossing);
|
||||
assert_eq!(x, y, "seed {seed} box {bx:?} crossing {crossing}");
|
||||
}
|
||||
let fence: Vec<Vec2> = (0..(2 + k % 3)).map(|_| Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))).collect();
|
||||
assert_eq!(select::select_fence_with(&d, &Space::Model, &ix, &fence), select::select_fence_with(&d, &Space::Model, &None, &fence));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osnap_parity_with_scan() {
|
||||
let d = drawing(41, 500);
|
||||
let ix = forced(&d);
|
||||
let mut r = Rng(9);
|
||||
let modes = [
|
||||
snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::INT,
|
||||
snap::mode::QUA | snap::mode::NEA | snap::mode::INS | snap::mode::NOD,
|
||||
0x3fff,
|
||||
];
|
||||
let mut hits = 0;
|
||||
for k in 0..300 {
|
||||
let p = Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0));
|
||||
let base = if k % 2 == 0 { Some(Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))) } else { None };
|
||||
let m = modes[k % 3];
|
||||
let a = snap::osnap_with(&d, &Space::Model, &ix, p, 1.0, m, base);
|
||||
let b = snap::osnap_with(&d, &Space::Model, &None, p, 1.0, m, base);
|
||||
assert_eq!(a, b, "p {p:?} mode {m}");
|
||||
hits += usize::from(a.is_some());
|
||||
}
|
||||
assert!(hits > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_queries_match_scan() {
|
||||
let d = drawing(77, 300);
|
||||
let ix = forced(&d);
|
||||
for (p, ap) in
|
||||
[(Vec2::new(f64::NAN, 1.0), 1.0), (Vec2::new(1.0, 1.0), f64::NAN), (Vec2::new(f64::INFINITY, 0.0), 1.0), (Vec2::new(50.0, 50.0), 1e300)]
|
||||
{
|
||||
assert_eq!(select::pick_with(&d, &Space::Model, &ix, p, ap), select::pick_with(&d, &Space::Model, &None, p, ap));
|
||||
}
|
||||
let bx = Bounds2 { min: Vec2::new(f64::NEG_INFINITY, 0.0), max: Vec2::new(f64::INFINITY, 50.0) };
|
||||
assert_eq!(select::select_window_with(&d, &Space::Model, &ix, bx, true), select::select_window_with(&d, &Space::Model, &None, bx, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_index_follows_edits() {
|
||||
let mut d = drawing(13, 400);
|
||||
let first = index(&d, &Space::Model).unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &index(&d, &Space::Model).unwrap()), "unchanged drawing reuses its index");
|
||||
// Move a line far away: the cached index must not be reused.
|
||||
let h = d.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_)) && d.is_visible(e)).unwrap().handle;
|
||||
d.modify_entity(h, |e| {
|
||||
if let EntityKind::Line(l) = &mut e.kind {
|
||||
l.a = v3(500.0, 500.0);
|
||||
l.b = v3(501.0, 500.0);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let second = index(&d, &Space::Model).unwrap();
|
||||
assert!(!Arc::ptr_eq(&first, &second));
|
||||
assert_eq!(select::pick(&d, &Space::Model, Vec2::new(500.5, 500.0), 0.1), Some(h));
|
||||
// A new entity is found too.
|
||||
let nh = d.add(&Space::Model, Common::default(), EntityKind::Circle(cadcraft_doc::Circle { center: v3(-300.0, 0.0), radius: 1.0 })).unwrap();
|
||||
assert_eq!(select::pick(&d, &Space::Model, Vec2::new(-299.0, 0.0), 0.1), Some(nh));
|
||||
// Redefining the block changes insert bounds: the index is rebuilt.
|
||||
let before = index(&d, &Space::Model).unwrap();
|
||||
let mut blk = (**d.blocks.get("B").unwrap()).clone();
|
||||
blk.entities
|
||||
.push(Entity::new(cadcraft_doc::Handle(2_000_000), EntityKind::Circle(cadcraft_doc::Circle { center: v3(0.0, 0.0), radius: 50.0 })));
|
||||
d.blocks.insert("B".into(), Arc::new(blk));
|
||||
assert!(!Arc::ptr_eq(&before, &index(&d, &Space::Model).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_spaces_are_scanned() {
|
||||
let d = drawing(1, MIN_INDEXED - 1);
|
||||
assert!(index(&d, &Space::Model).is_none());
|
||||
assert!(index(&d, &Space::Paper("nope".into())).is_none());
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
cadcraft-geom = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
mod mtext;
|
||||
mod stroke;
|
||||
pub mod ttf;
|
||||
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
|
||||
@@ -282,3 +283,26 @@ mod tests {
|
||||
assert!((bb.max.y - 1.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ttf_tests {
|
||||
#[test]
|
||||
fn system_font_outlines_when_available() {
|
||||
// Skips cleanly when no common system font is installed.
|
||||
let Some(bytes) = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].iter().find_map(|n| crate::ttf::find(n)) else {
|
||||
return;
|
||||
};
|
||||
let run = crate::ttf::layout_line(&bytes, "CAD", 1.0, 1.0, 0.0).unwrap();
|
||||
assert!(run.strokes.len() >= 3, "glyph contours");
|
||||
assert!(run.width > 1.5 && run.width < 4.0, "width {}", run.width);
|
||||
let maxy = run.strokes.iter().flatten().map(|p| p.y).fold(f64::MIN, f64::max);
|
||||
assert!((maxy - 1.0).abs() < 0.1, "cap height ~ text height, got {maxy}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_names_are_not_ttf() {
|
||||
assert!(crate::ttf::find("txt.shx").is_none());
|
||||
assert!(crate::ttf::find("").is_none());
|
||||
assert!(crate::ttf::layout_line(b"not a font", "x", 1.0, 1.0, 0.0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! TrueType/OpenType text: system fonts found at run time (never bundled), glyph outlines as
|
||||
//! closed polylines in CAD text units (text height = cap height).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{FontRef, MetadataProvider};
|
||||
|
||||
use crate::Run;
|
||||
|
||||
/// Font bytes by lower-case family/file stem.
|
||||
type Db = HashMap<String, Arc<Vec<u8>>>;
|
||||
|
||||
fn db() -> &'static Mutex<Option<Db>> {
|
||||
static DB: OnceLock<Mutex<Option<Db>>> = OnceLock::new();
|
||||
DB.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan() -> Db {
|
||||
let mut out = Db::new();
|
||||
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = std::env::var_os("HOME") {
|
||||
dirs.push(std::path::PathBuf::from(h).join("Library/Fonts"));
|
||||
}
|
||||
} else if cfg!(windows) {
|
||||
dirs.push("C:\\Windows\\Fonts".into());
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
if let Some(h) = std::env::var_os("HOME") {
|
||||
dirs.push(std::path::PathBuf::from(&h).join(".fonts"));
|
||||
dirs.push(std::path::PathBuf::from(h).join(".local/share/fonts"));
|
||||
}
|
||||
}
|
||||
let mut stack = dirs;
|
||||
let mut seen = 0usize;
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
seen += 1;
|
||||
if seen > 20_000 {
|
||||
return out;
|
||||
}
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
if ext != "ttf" && ext != "otf" {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = p.file_stem().map(|s| s.to_string_lossy().to_ascii_lowercase()) {
|
||||
out.entry(stem).or_insert_with(|| Arc::new(Vec::new()));
|
||||
// Load lazily: store the path in a side table by using an empty marker.
|
||||
PATHS
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.insert(p.file_stem().map(|s| s.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(), p.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static PATHS: OnceLock<Mutex<HashMap<String, std::path::PathBuf>>> = OnceLock::new();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn scan() -> Db {
|
||||
Db::new()
|
||||
}
|
||||
|
||||
/// Register font bytes under a name (web builds and tests).
|
||||
pub fn register(name: &str, bytes: Vec<u8>) {
|
||||
let mut g = db().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.get_or_insert_with(Db::new).insert(name.to_ascii_lowercase(), Arc::new(bytes));
|
||||
}
|
||||
|
||||
/// Normalise a style font name ("arial.ttf", "Arial", "ARIALBD.TTF") to a lookup key.
|
||||
fn key(name: &str) -> String {
|
||||
let n = name.trim().to_ascii_lowercase();
|
||||
n.trim_end_matches(".ttf").trim_end_matches(".otf").trim_end_matches(".ttc").to_string()
|
||||
}
|
||||
|
||||
/// Bytes of a font by name, if installed. Stroke-font names (".shx", empty) return `None`.
|
||||
pub fn find(name: &str) -> Option<Arc<Vec<u8>>> {
|
||||
let k = key(name);
|
||||
if k.is_empty() || k.ends_with(".shx") || k == crate::BUILTIN_FONT.to_ascii_lowercase() || k == "txt" || k == "simplex" || k == "romans" {
|
||||
return None;
|
||||
}
|
||||
let mut g = db().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let d = g.get_or_insert_with(scan);
|
||||
let candidates = [k.clone(), k.replace(' ', ""), format!("{k} regular"), format!("{}-regular", k.replace(' ', ""))];
|
||||
for c in &candidates {
|
||||
if let Some(b) = d.get(c) {
|
||||
if !b.is_empty() {
|
||||
return Some(b.clone());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = PATHS.get().and_then(|m| m.lock().unwrap_or_else(PoisonError::into_inner).get(c).cloned())?;
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let arc = Arc::new(bytes);
|
||||
d.insert(c.clone(), arc.clone());
|
||||
return Some(arc);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
struct Pen {
|
||||
contours: Vec<Vec<Vec2>>,
|
||||
cur: Vec<Vec2>,
|
||||
scale: f64,
|
||||
dx: f64,
|
||||
wf: f64,
|
||||
shear: f64,
|
||||
}
|
||||
|
||||
impl Pen {
|
||||
fn pt(&self, x: f32, y: f32) -> Vec2 {
|
||||
let y = f64::from(y) * self.scale;
|
||||
Vec2::new(self.dx + f64::from(x) * self.scale * self.wf + y * self.shear, y)
|
||||
}
|
||||
fn flush(&mut self) {
|
||||
if self.cur.len() > 1 {
|
||||
if let Some(f) = self.cur.first().copied() {
|
||||
self.cur.push(f);
|
||||
}
|
||||
self.contours.push(std::mem::take(&mut self.cur));
|
||||
} else {
|
||||
self.cur.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutlinePen for Pen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.flush();
|
||||
let p = self.pt(x, y);
|
||||
self.cur.push(p);
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
let p = self.pt(x, y);
|
||||
self.cur.push(p);
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
let a = self.cur.last().copied().unwrap_or_default();
|
||||
let c = self.pt(cx0, cy0);
|
||||
let b = self.pt(x, y);
|
||||
for i in 1..=6 {
|
||||
let t = f64::from(i) / 6.0;
|
||||
let u = 1.0 - t;
|
||||
self.cur.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t));
|
||||
}
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
let a = self.cur.last().copied().unwrap_or_default();
|
||||
let c0 = self.pt(cx0, cy0);
|
||||
let c1 = self.pt(cx1, cy1);
|
||||
let b = self.pt(x, y);
|
||||
for i in 1..=8 {
|
||||
let t = f64::from(i) / 8.0;
|
||||
let u = 1.0 - t;
|
||||
self.cur.push(a * (u * u * u) + c0 * (3.0 * u * u * t) + c1 * (3.0 * u * t * t) + b * (t * t * t));
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out one line with a TrueType font: closed glyph contours (baseline at y = 0).
|
||||
pub fn layout_line(font: &[u8], s: &str, height: f64, width_factor: f64, oblique: f64) -> Option<Run> {
|
||||
let f = FontRef::new(font).ok()?;
|
||||
let upem = 1000.0f32;
|
||||
let metrics = f.metrics(Size::new(upem), LocationRef::default());
|
||||
let cap = metrics.cap_height.filter(|c| *c > 0.0).unwrap_or(metrics.ascent * 0.72).max(1.0);
|
||||
let scale = height / f64::from(cap);
|
||||
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
|
||||
let gm = f.glyph_metrics(Size::new(upem), LocationRef::default());
|
||||
let charmap = f.charmap();
|
||||
let outlines = f.outline_glyphs();
|
||||
let mut pen = Pen { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, wf, shear: oblique.tan().clamp(-10.0, 10.0) };
|
||||
for (c, _, _) in crate::decode_controls(s) {
|
||||
let gid = charmap.map(c).unwrap_or_default();
|
||||
if let Some(g) = outlines.get(gid) {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::new(upem), LocationRef::default()), &mut pen);
|
||||
pen.flush();
|
||||
}
|
||||
pen.dx += f64::from(gm.advance_width(gid).unwrap_or(upem * 0.5)) * scale * wf;
|
||||
}
|
||||
Some(Run { strokes: pen.contours, width: pen.dx })
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cadcraft-io"
|
||||
description = "CADCraft file formats: DXF read/write mapping, SVG and PNG export"
|
||||
description = "CADCraft file formats: DXF read/write mapping, SVG, PNG and PDF export"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -16,3 +16,5 @@ cadcraft-dxf = { workspace = true }
|
||||
cadcraft-dwg = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
|
||||
@@ -674,7 +674,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
d.layouts.clear();
|
||||
let mut block_records: HashMap<String, String> = HashMap::new();
|
||||
let mut blocks: Vec<(String, Vec3, Vec<(Option<String>, bool, Entity)>, bool)> = Vec::new();
|
||||
let mut layout_objs: Vec<(String, u32, String)> = Vec::new();
|
||||
let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new();
|
||||
let mut entities = Vec::new();
|
||||
for s in &secs {
|
||||
match s.name.as_str() {
|
||||
@@ -712,7 +712,8 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
let t = T(tail);
|
||||
// LAYOUT has two 330s: owner dict and the block record; take the last.
|
||||
let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default();
|
||||
layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase()));
|
||||
let plot = T(tg.get(..start).unwrap_or(&[]));
|
||||
layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -722,11 +723,11 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
// Layouts.
|
||||
let mut paper_block_for: HashMap<String, String> = HashMap::new();
|
||||
layout_objs.sort_by_key(|l| l.1);
|
||||
for (name, order, br) in &layout_objs {
|
||||
for (name, order, br, page) in &layout_objs {
|
||||
if name.eq_ignore_ascii_case("Model") {
|
||||
continue;
|
||||
}
|
||||
d.layouts.push(Layout::new(name, *order));
|
||||
d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) });
|
||||
if let Some(bname) = block_records.get(br) {
|
||||
paper_block_for.insert(bname.to_ascii_uppercase(), name.clone());
|
||||
}
|
||||
@@ -789,3 +790,30 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Page setup from a LAYOUT object's AcDbPlotSettings fields (sizes and margins in mm).
|
||||
fn page_setup(t: &T) -> PageSetup {
|
||||
let mut p = PageSetup::default();
|
||||
let mm = |code: i32, d: f64| {
|
||||
let v = t.fd(code, d);
|
||||
if v.is_finite() && (0.0..=100_000.0).contains(&v) { v } else { d }
|
||||
};
|
||||
let (w, h) = (mm(44, 0.0), mm(45, 0.0));
|
||||
if w > 0.0 && h > 0.0 {
|
||||
p.width_mm = w.min(h);
|
||||
p.height_mm = w.max(h);
|
||||
}
|
||||
p.margins_mm = [mm(40, p.margins_mm[0]), mm(41, p.margins_mm[1]), mm(42, p.margins_mm[2]), mm(43, p.margins_mm[3])];
|
||||
// Plot rotation 1 or 3 turns the media a quarter turn; media defined wider than tall is
|
||||
// already landscape.
|
||||
let rotated = matches!(t.i(73), Some(1 | 3));
|
||||
if w > 0.0 && h > 0.0 {
|
||||
p.landscape = rotated != (w > h);
|
||||
} else if t.i(73).is_some() {
|
||||
p.landscape = rotated;
|
||||
}
|
||||
if let Some(name) = t.s(4).filter(|n| !n.is_empty()) {
|
||||
p.paper = name.replace('_', " ");
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
@@ -535,6 +535,8 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, dim_bloc
|
||||
w.i(69, i64::from(v.id));
|
||||
w.p2(12, v.view_center);
|
||||
w.f(45, v.view_height);
|
||||
// Status flags: 16384 = display locked.
|
||||
w.i(90, if v.locked { 16384 } else { 0 });
|
||||
}
|
||||
// Not yet written: images, wipeouts, tables, multileaders, unknown objects.
|
||||
_ => {}
|
||||
@@ -918,7 +920,7 @@ pub fn write(d: &Drawing) -> String {
|
||||
w.s(100, "AcDbPlotSettings");
|
||||
w.s(1, "");
|
||||
w.s(2, "none_device");
|
||||
w.s(4, "");
|
||||
w.s(4, page.paper.replace(' ', "_"));
|
||||
w.s(6, "");
|
||||
for (c, v) in [
|
||||
(40, page.margins_mm[0]),
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! CADCraft file formats: DXF read/write, SVG and PNG export.
|
||||
//! CADCraft file formats: DXF read/write, SVG, PNG and PDF export (plotting).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod dxf_read;
|
||||
mod dxf_write;
|
||||
pub mod pdf;
|
||||
pub mod svg;
|
||||
|
||||
use cadcraft_doc::{Drawing, Space};
|
||||
|
||||
pub use pdf::{PdfOptions, pdf, plot};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IoError {
|
||||
#[error("{0}")]
|
||||
@@ -42,6 +45,7 @@ pub fn write(d: &Drawing, name: &str) -> Result<Vec<u8>> {
|
||||
"dwg" => cadcraft_dwg::dxf_to_dwg(dxf_write::write(d).as_bytes()).map_err(IoError::Format),
|
||||
"svg" => Ok(svg::export(d, &Space::Model).into_bytes()),
|
||||
"png" => png(d, &Space::Model, 2400, 1600),
|
||||
"pdf" => pdf::pdf(d, &Space::Model, &PdfOptions { compress: true, ..PdfOptions::default() }),
|
||||
e => Err(IoError::Unsupported(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Vector PDF plotting (PLOT / EXPORTPDF): a minimal PDF 1.4 writer of our own.
|
||||
//!
|
||||
//! One page per plot: model-space extents fitted to a sheet, or a layout at 1:1 on its paper
|
||||
//! (paper units are millimetres in metric drawings, inches otherwise). Lines are stroked paths
|
||||
//! (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white
|
||||
//! paper, and line widths come from lineweights when enabled.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use cadcraft_color::{Rgb, display_rgb};
|
||||
use cadcraft_doc::{Drawing, PageSetup, Space};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use cadcraft_render::{Kind, Sheet, clip, paper};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
/// PDF points per millimetre.
|
||||
const PT_PER_MM: f64 = 72.0 / 25.4;
|
||||
/// Largest sheet side we accept, in millimetres (PDF viewers cap pages at 200 inches anyway).
|
||||
const MAX_SHEET_MM: f64 = 5080.0;
|
||||
|
||||
/// Plot settings. `None` fields fall back to the layout's page setup (or sensible defaults for
|
||||
/// model space).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PdfOptions {
|
||||
/// Paper name from [`cadcraft_render::PAPER_SIZES`] (`"A4"`, `"Letter"`, `"ANSI B"`…).
|
||||
pub paper: Option<String>,
|
||||
/// Custom paper size in millimetres (portrait width, height).
|
||||
pub paper_mm: Option<(f64, f64)>,
|
||||
pub landscape: Option<bool>,
|
||||
/// Fit the drawing to the printable area. Default: on for model space, off for layouts (1:1).
|
||||
pub fit: Option<bool>,
|
||||
/// Plot scale in paper units per drawing unit (when not fitting).
|
||||
pub scale: Option<f64>,
|
||||
/// Plot object lineweights. Default: the layout's setting; on for model space.
|
||||
pub lineweights: Option<bool>,
|
||||
/// Flate-compress the content stream.
|
||||
pub compress: bool,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl PdfOptions {
|
||||
/// Parse `{paper?, width?, height?, landscape?, fit?, scale?, lineweights?, compress?, title?}`.
|
||||
pub fn from_json(v: &Value) -> PdfOptions {
|
||||
let num = |k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||||
let paper_mm = match (num("width"), num("height")) {
|
||||
(Some(w), Some(h)) if w > 0.0 && h > 0.0 => Some((w, h)),
|
||||
_ => None,
|
||||
};
|
||||
PdfOptions {
|
||||
paper: v.get("paper").and_then(Value::as_str).map(str::to_string),
|
||||
paper_mm,
|
||||
landscape: v.get("landscape").and_then(Value::as_bool),
|
||||
fit: v.get("fit").and_then(Value::as_bool),
|
||||
scale: num("scale").filter(|s| *s > 0.0),
|
||||
lineweights: v.get("lineweights").and_then(Value::as_bool),
|
||||
compress: v.get("compress").and_then(Value::as_bool).unwrap_or(true),
|
||||
title: v.get("title").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plot with JSON options (the engine's `plot` hook).
|
||||
pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
pdf(d, space, &PdfOptions::from_json(opts))
|
||||
}
|
||||
|
||||
/// The page setup a plot of `space` uses, with overrides applied.
|
||||
pub fn page_for(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<PageSetup> {
|
||||
let mut page = match space {
|
||||
Space::Paper(n) => d.layout(n).map(|l| l.page.clone()).ok_or_else(|| IoError::Format(format!("no layout `{n}`")))?,
|
||||
Space::Model => {
|
||||
let mut p = PageSetup::default();
|
||||
if paper::paper_unit_mm(d) == 1.0
|
||||
&& let Some(a4) = paper::paper_size("A4")
|
||||
{
|
||||
p.paper = a4.name.into();
|
||||
p.width_mm = a4.width_mm;
|
||||
p.height_mm = a4.height_mm;
|
||||
}
|
||||
p
|
||||
}
|
||||
};
|
||||
if let Some(name) = &o.paper {
|
||||
let ps = paper::paper_size(name).ok_or_else(|| IoError::Format(format!("unknown paper size `{name}`")))?;
|
||||
page.paper = ps.name.into();
|
||||
page.width_mm = ps.width_mm;
|
||||
page.height_mm = ps.height_mm;
|
||||
}
|
||||
if let Some((w, h)) = o.paper_mm {
|
||||
page.paper = format!("User ({w:.2} x {h:.2} MM)");
|
||||
page.width_mm = w.min(MAX_SHEET_MM);
|
||||
page.height_mm = h.min(MAX_SHEET_MM);
|
||||
}
|
||||
if let Some(l) = o.landscape {
|
||||
page.landscape = l;
|
||||
}
|
||||
page.width_mm = page.width_mm.clamp(1.0, MAX_SHEET_MM);
|
||||
page.height_mm = page.height_mm.clamp(1.0, MAX_SHEET_MM);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
/// World → paper-unit mapping: `p' = (p - from) * s + to`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Map {
|
||||
from: Vec2,
|
||||
s: f64,
|
||||
to: Vec2,
|
||||
/// Points per paper unit.
|
||||
k: f64,
|
||||
}
|
||||
|
||||
impl Map {
|
||||
fn pt(&self, p: Vec2) -> Vec2 {
|
||||
((p - self.from) * self.s + self.to) * self.k
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a one-page vector PDF of a space.
|
||||
pub fn pdf(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let page = page_for(d, space, o)?;
|
||||
let unit_mm = paper::paper_unit_mm(d);
|
||||
let sheet = Sheet::from_page(&page, unit_mm);
|
||||
let lineweights = o.lineweights.unwrap_or(match space {
|
||||
Space::Paper(_) => page.lineweights,
|
||||
Space::Model => true,
|
||||
});
|
||||
let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights };
|
||||
let k = unit_mm * PT_PER_MM;
|
||||
let fit = o.fit.unwrap_or(matches!(space, Space::Model));
|
||||
// Chord tolerance: about 0.05 mm on paper.
|
||||
let est = plot_scale(&d.extents(space), &sheet, fit, o.scale);
|
||||
let tol = 0.05 / unit_mm / est.max(1e-300);
|
||||
let tolerance = if tol.is_finite() && tol > 0.0 { tol } else { ropts.tolerance };
|
||||
let list = cadcraft_render::build_plot(d, space, &cadcraft_render::Options { tolerance, ..ropts });
|
||||
let b = list.bounds;
|
||||
let s = plot_scale(&b, &sheet, fit, o.scale);
|
||||
let map = if fit || matches!(space, Space::Model) {
|
||||
let from = if b.is_empty() { Vec2::ZERO } else { b.center() };
|
||||
Map { from, s, to: sheet.printable.center(), k }
|
||||
} else {
|
||||
Map { from: Vec2::ZERO, s: 1.0, to: Vec2::ZERO, k }
|
||||
};
|
||||
let media = sheet.size * k;
|
||||
let clip_pt = if fit || matches!(space, Space::Model) {
|
||||
Bounds2::new(sheet.printable.min * k, sheet.printable.max * k)
|
||||
} else {
|
||||
Bounds2::new(Vec2::ZERO, media)
|
||||
};
|
||||
let content = content_stream(&list, &map, &clip_pt);
|
||||
let title = if o.title.is_empty() {
|
||||
match space {
|
||||
Space::Model => "Model".to_string(),
|
||||
Space::Paper(n) => n.clone(),
|
||||
}
|
||||
} else {
|
||||
o.title.clone()
|
||||
};
|
||||
Ok(assemble(media, content.as_bytes(), o.compress, &title))
|
||||
}
|
||||
|
||||
/// Paper units per drawing unit for a plot.
|
||||
fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f64>) -> f64 {
|
||||
if let Some(s) = scale.filter(|s| s.is_finite() && *s > 0.0)
|
||||
&& !fit
|
||||
{
|
||||
return s;
|
||||
}
|
||||
if !fit || b.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
let pw = sheet.printable.width();
|
||||
let ph = sheet.printable.height();
|
||||
let s = (pw / b.width().max(1e-12)).min(ph / b.height().max(1e-12));
|
||||
if s.is_finite() && s > 0.0 { s } else { 1.0 }
|
||||
}
|
||||
|
||||
/// A number for a content stream: short, finite, no exponent.
|
||||
fn num(out: &mut String, v: f64) {
|
||||
let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
|
||||
let mut s = format!("{v:.3}");
|
||||
if s.contains('.') {
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
if s.ends_with('.') {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
if s == "-0" {
|
||||
s = "0".into();
|
||||
}
|
||||
out.push_str(&s);
|
||||
}
|
||||
|
||||
fn pt(out: &mut String, p: Vec2) {
|
||||
num(out, p.x);
|
||||
out.push(' ');
|
||||
num(out, p.y);
|
||||
}
|
||||
|
||||
fn color(out: &mut String, c: Rgb, op: &str) {
|
||||
for v in [c.0, c.1, c.2] {
|
||||
num(out, f64::from(v) / 255.0);
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(op);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2) -> String {
|
||||
let white = Rgb(255, 255, 255);
|
||||
let mut c = String::new();
|
||||
c.push_str("q\n1 J 1 j\n");
|
||||
// Clip to the plot area.
|
||||
pt(&mut c, clip_pt.min);
|
||||
c.push(' ');
|
||||
num(&mut c, clip_pt.width());
|
||||
c.push(' ');
|
||||
num(&mut c, clip_pt.height());
|
||||
c.push_str(" re W n\n");
|
||||
let mut stroke: Option<Rgb> = None;
|
||||
let mut fill: Option<Rgb> = None;
|
||||
let mut width: Option<f64> = None;
|
||||
let mut pending = false;
|
||||
let flush = |c: &mut String, pending: &mut bool| {
|
||||
if *pending {
|
||||
c.push_str("S\n");
|
||||
*pending = false;
|
||||
}
|
||||
};
|
||||
for p in &list.prims {
|
||||
let rgb = display_rgb(p.color, white);
|
||||
let raw = list.points(p);
|
||||
if raw.iter().any(|q| !q.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let w = f64::from(p.lw) * PT_PER_MM;
|
||||
let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 };
|
||||
match p.kind {
|
||||
Kind::Polyline | Kind::Infinite { .. } => {
|
||||
let pts: Vec<Vec2> = match p.kind {
|
||||
Kind::Infinite { ray } => match (raw.first(), raw.get(1)) {
|
||||
(Some(base), Some(dir)) => {
|
||||
let a = map.pt(*base);
|
||||
let dir_pt = map.pt(*base + *dir) - a;
|
||||
match clip::clip_infinite(a, dir_pt, ray, clip_pt) {
|
||||
Some((x, y)) => vec![x, y],
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
},
|
||||
_ => raw.iter().map(|q| map.pt(*q)).collect(),
|
||||
};
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if stroke != Some(rgb) || width != Some(w) {
|
||||
flush(&mut c, &mut pending);
|
||||
if stroke != Some(rgb) {
|
||||
color(&mut c, rgb, "RG");
|
||||
stroke = Some(rgb);
|
||||
}
|
||||
if width != Some(w) {
|
||||
num(&mut c, w);
|
||||
c.push_str(" w\n");
|
||||
width = Some(w);
|
||||
}
|
||||
}
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
pt(&mut c, *q);
|
||||
c.push_str(if i == 0 { " m\n" } else { " l\n" });
|
||||
}
|
||||
pending = true;
|
||||
}
|
||||
Kind::Tris => {
|
||||
flush(&mut c, &mut pending);
|
||||
if fill != Some(rgb) {
|
||||
color(&mut c, rgb, "rg");
|
||||
fill = Some(rgb);
|
||||
}
|
||||
let mut any = false;
|
||||
for t in raw.as_chunks::<3>().0 {
|
||||
for (i, q) in t.iter().enumerate() {
|
||||
pt(&mut c, map.pt(*q));
|
||||
c.push_str(if i == 0 { " m\n" } else { " l\n" });
|
||||
}
|
||||
c.push_str("h\n");
|
||||
any = true;
|
||||
}
|
||||
if any {
|
||||
c.push_str("f\n");
|
||||
}
|
||||
}
|
||||
Kind::Point => {
|
||||
let Some(q) = raw.first() else { continue };
|
||||
flush(&mut c, &mut pending);
|
||||
if fill != Some(rgb) {
|
||||
color(&mut c, rgb, "rg");
|
||||
fill = Some(rgb);
|
||||
}
|
||||
let half = (w.max(0.5)) / 2.0;
|
||||
pt(&mut c, map.pt(*q) - Vec2::new(half, half));
|
||||
c.push(' ');
|
||||
num(&mut c, half * 2.0);
|
||||
c.push(' ');
|
||||
num(&mut c, half * 2.0);
|
||||
c.push_str(" re f\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut c, &mut pending);
|
||||
c.push_str("Q\n");
|
||||
c
|
||||
}
|
||||
|
||||
/// A PDF literal string with `(`, `)` and `\` escaped; non-ASCII becomes `?`.
|
||||
fn pdf_string(s: &str) -> String {
|
||||
let mut out = String::from("(");
|
||||
for ch in s.chars().take(200) {
|
||||
match ch {
|
||||
'(' | ')' | '\\' => {
|
||||
out.push('\\');
|
||||
out.push(ch);
|
||||
}
|
||||
c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
|
||||
_ => out.push('?'),
|
||||
}
|
||||
}
|
||||
out.push(')');
|
||||
out
|
||||
}
|
||||
|
||||
/// Assemble the file: header, five objects, cross-reference table and trailer.
|
||||
fn assemble(media: Vec2, content: &[u8], compress: bool, title: &str) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = Vec::with_capacity(content.len() + 1024);
|
||||
out.extend_from_slice(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
|
||||
let mut offsets: Vec<usize> = Vec::new();
|
||||
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
|
||||
offsets.push(out.len());
|
||||
let n = offsets.len();
|
||||
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
let mut mb = String::new();
|
||||
num(&mut mb, media.x);
|
||||
mb.push(' ');
|
||||
num(&mut mb, media.y);
|
||||
obj(&mut out, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {mb}] /Resources << >> /Contents 4 0 R >>").as_bytes());
|
||||
let (data, filter) =
|
||||
if compress { (miniz_oxide::deflate::compress_to_vec_zlib(content, 6), " /Filter /FlateDecode") } else { (content.to_vec(), "") };
|
||||
let mut stream = format!("<< /Length {}{filter} >>\nstream\n", data.len()).into_bytes();
|
||||
stream.extend_from_slice(&data);
|
||||
stream.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &stream);
|
||||
obj(&mut out, format!("<< /Producer (CADCraft) /Creator (CADCraft) /Title {} >>", pdf_string(title)).as_bytes());
|
||||
let xref = out.len();
|
||||
let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1);
|
||||
for o in &offsets {
|
||||
let _ = writeln!(x, "{o:010} 00000 n ");
|
||||
}
|
||||
let _ = write!(x, "trailer\n<< /Size {} /Root 1 0 R /Info 5 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1);
|
||||
out.extend_from_slice(x.as_bytes());
|
||||
out
|
||||
}
|
||||
@@ -219,3 +219,142 @@ fn svg_and_png_export() {
|
||||
let png = write(&d, "a.png").unwrap();
|
||||
assert_eq!(&png[1..4], b"PNG");
|
||||
}
|
||||
|
||||
/// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`.
|
||||
fn check_pdf(bytes: &[u8]) -> String {
|
||||
assert!(bytes.starts_with(b"%PDF-1.4"));
|
||||
let find = |pat: &[u8]| bytes.windows(pat.len()).position(|w| w == pat);
|
||||
let rfind = |pat: &[u8]| bytes.windows(pat.len()).rposition(|w| w == pat);
|
||||
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string();
|
||||
assert!(tail.trim_end().ends_with("%%EOF"));
|
||||
let sx = rfind(b"startxref\n").unwrap();
|
||||
let after = String::from_utf8_lossy(&bytes[sx + 10..]).to_string();
|
||||
let xref_off: usize = after.lines().next().unwrap().trim().parse().unwrap();
|
||||
assert!(bytes[xref_off..].starts_with(b"xref\n"));
|
||||
let xref = String::from_utf8_lossy(&bytes[xref_off..]).to_string();
|
||||
let mut lines = xref.lines().skip(1);
|
||||
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
|
||||
let objs = bytes.windows(7).filter(|w| w == b" 0 obj\n").count();
|
||||
assert_eq!(count, objs + 1, "xref size = objects + free entry");
|
||||
assert_eq!(bytes.windows(6).filter(|w| w == b"endobj").count(), objs);
|
||||
let entries: Vec<&str> = lines.take(count).collect();
|
||||
assert!(entries[0].starts_with("0000000000 65535 f"));
|
||||
for (i, e) in entries.iter().enumerate().skip(1) {
|
||||
let off: usize = e[..10].parse().unwrap();
|
||||
assert!(bytes[off..].starts_with(format!("{i} 0 obj").as_bytes()), "object {i} offset");
|
||||
}
|
||||
assert!(xref.contains(&format!("/Size {count}")));
|
||||
// The content stream's /Length matches its data.
|
||||
let sp = find(b"/Length ").unwrap();
|
||||
let after = String::from_utf8_lossy(&bytes[sp + 8..sp + 30]).to_string();
|
||||
let len: usize = after.split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
|
||||
let start = find(b"stream\n").unwrap() + 7;
|
||||
assert!(bytes[start + len..].starts_with(b"\nendstream"));
|
||||
let compressed = find(b"/FlateDecode").is_some();
|
||||
let data = &bytes[start..start + len];
|
||||
if compressed {
|
||||
String::from_utf8(miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()).unwrap()
|
||||
} else {
|
||||
String::from_utf8(data.to_vec()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn media_box(bytes: &[u8]) -> (f64, f64) {
|
||||
let text = String::from_utf8_lossy(bytes).to_string();
|
||||
let i = text.find("/MediaBox [0 0 ").unwrap() + 15;
|
||||
let v: Vec<f64> = text[i..].split(']').next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
|
||||
(v[0], v[1])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_model_fitted_to_sheet() {
|
||||
let d = sample();
|
||||
let bytes = plot(&d, &Space::Model, &serde_json::json!({"paper": "A4", "landscape": true})).unwrap();
|
||||
let content = check_pdf(&bytes);
|
||||
let (w, h) = media_box(&bytes);
|
||||
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "A4 landscape in points: {w} x {h}");
|
||||
assert!(content.contains(" m\n") && content.contains(" l\n") && content.contains("S\n"));
|
||||
// Walls layer is red; colour 7 prints black.
|
||||
assert!(content.contains("1 0 0 RG"));
|
||||
assert!(content.contains("0 0 0 RG"));
|
||||
// Uncompressed output is plain text and also valid.
|
||||
let raw = plot(&d, &Space::Model, &serde_json::json!({"paper": "Letter", "compress": false, "landscape": false})).unwrap();
|
||||
let c2 = check_pdf(&raw);
|
||||
assert!(!String::from_utf8_lossy(&raw).contains("FlateDecode"));
|
||||
assert_eq!(media_box(&raw), (612.0, 792.0));
|
||||
assert!(c2.contains("re W n"));
|
||||
// write() picks PDF by extension.
|
||||
assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layout_one_to_one_with_viewport() {
|
||||
let mut d = sample();
|
||||
let paper = Space::Paper("Layout1".into());
|
||||
d.layouts[0].page.lineweights = true;
|
||||
d.add(
|
||||
&paper,
|
||||
Common::default(),
|
||||
EntityKind::Viewport(Viewport {
|
||||
center: Vec3::new(5.0, 4.0, 0.0),
|
||||
width: 8.0,
|
||||
height: 6.0,
|
||||
view_center: Vec2::new(5.0, 2.5),
|
||||
view_height: 12.0,
|
||||
id: 2,
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = plot(&d, &paper, &serde_json::json!({})).unwrap();
|
||||
let content = check_pdf(&bytes);
|
||||
// ANSI A landscape (inches → points).
|
||||
assert_eq!(media_box(&bytes), (792.0, 612.0));
|
||||
// Viewport border at 1:1: x from 1in to 9in = 72pt..648pt.
|
||||
assert!(content.contains("72 72 m") || content.contains("72 72 l"), "{content}");
|
||||
// Lineweights on: default 0.25 mm = 0.709 pt.
|
||||
assert!(content.contains("0.709 w"));
|
||||
let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap();
|
||||
assert!(check_pdf(&off).contains("0 w"));
|
||||
assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err());
|
||||
assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_hostile_options_and_empty() {
|
||||
let d = Drawing::new_metric();
|
||||
let bytes = plot(&d, &Space::Model, &serde_json::json!({})).unwrap();
|
||||
check_pdf(&bytes);
|
||||
let (w, h) = media_box(&bytes);
|
||||
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "metric default is A4 landscape");
|
||||
let s = sample();
|
||||
for o in [
|
||||
serde_json::json!({"width": 1e308, "height": -5}),
|
||||
serde_json::json!({"width": 1e308, "height": 1e308, "scale": 1e308, "fit": false}),
|
||||
serde_json::json!({"scale": -1, "fit": false, "title": "a(b)\\c\u{e9}"}),
|
||||
serde_json::json!(null),
|
||||
serde_json::json!([1, 2]),
|
||||
] {
|
||||
check_pdf(&plot(&s, &Space::Model, &o).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_roundtrips_page_setup() {
|
||||
let mut d = sample();
|
||||
let a3 = cadcraft_render::paper_size("A3").unwrap();
|
||||
{
|
||||
let p = &mut d.layouts[0].page;
|
||||
p.paper = a3.name.into();
|
||||
p.width_mm = a3.width_mm;
|
||||
p.height_mm = a3.height_mm;
|
||||
p.landscape = false;
|
||||
p.margins_mm = [5.0, 6.0, 7.0, 8.0];
|
||||
}
|
||||
let back = read_dxf(write_dxf(&d).as_bytes()).unwrap();
|
||||
let p = &back.layout("Layout1").unwrap().page;
|
||||
assert_eq!((p.width_mm, p.height_mm, p.landscape), (297.0, 420.0, false));
|
||||
assert_eq!(p.margins_mm, [5.0, 6.0, 7.0, 8.0]);
|
||||
assert_eq!(p.paper, a3.name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Clipping against axis-aligned rectangles (viewport contents, plot sheets).
|
||||
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
|
||||
/// Clip the segment `a`→`b` to `r` (Liang–Barsky). Returns the parameter range kept.
|
||||
fn clip_params(a: Vec2, d: Vec2, r: &Bounds2, mut t0: f64, mut t1: f64) -> Option<(f64, f64)> {
|
||||
for (p, q) in [(-d.x, a.x - r.min.x), (d.x, r.max.x - a.x), (-d.y, a.y - r.min.y), (d.y, r.max.y - a.y)] {
|
||||
if p == 0.0 {
|
||||
if q < 0.0 {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let t = q / p;
|
||||
if p < 0.0 {
|
||||
if t > t1 {
|
||||
return None;
|
||||
}
|
||||
if t > t0 {
|
||||
t0 = t;
|
||||
}
|
||||
} else {
|
||||
if t < t0 {
|
||||
return None;
|
||||
}
|
||||
if t < t1 {
|
||||
t1 = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some((t0, t1))
|
||||
}
|
||||
|
||||
/// Clip a segment to `r`.
|
||||
pub fn clip_segment(a: Vec2, b: Vec2, r: &Bounds2) -> Option<(Vec2, Vec2)> {
|
||||
if r.is_empty() || !a.is_finite() || !b.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let d = b - a;
|
||||
let (t0, t1) = clip_params(a, d, r, 0.0, 1.0)?;
|
||||
Some((a + d * t0, a + d * t1))
|
||||
}
|
||||
|
||||
/// Clip an infinite line (or a ray when `ray`) through `base` along `dir` to `r`.
|
||||
pub fn clip_infinite(base: Vec2, dir: Vec2, ray: bool, r: &Bounds2) -> Option<(Vec2, Vec2)> {
|
||||
if r.is_empty() || !base.is_finite() || !dir.is_finite() || dir == Vec2::ZERO {
|
||||
return None;
|
||||
}
|
||||
let far = (r.width() + r.height() + (base - r.center()).len()) * 2.0 / dir.len().max(1e-300);
|
||||
let (t0, t1) = clip_params(base, dir, r, if ray { 0.0 } else { -far }, far)?;
|
||||
Some((base + dir * t0, base + dir * t1))
|
||||
}
|
||||
|
||||
/// Clip a polyline to `r`; returns the visible pieces (each with at least two points).
|
||||
pub fn clip_polyline(pts: &[Vec2], r: &Bounds2) -> Vec<Vec<Vec2>> {
|
||||
let mut out: Vec<Vec<Vec2>> = Vec::new();
|
||||
if r.is_empty() {
|
||||
return out;
|
||||
}
|
||||
// Fast path: everything inside.
|
||||
if pts.iter().all(|p| r.contains(*p)) {
|
||||
if pts.len() >= 2 {
|
||||
out.push(pts.to_vec());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let mut cur: Vec<Vec2> = Vec::new();
|
||||
for w in pts.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
match clip_segment(*a, *b, r) {
|
||||
Some((p, q)) => {
|
||||
let joined = cur.last().is_some_and(|l| l.near(p, 1e-12));
|
||||
if !joined {
|
||||
if cur.len() >= 2 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.clear();
|
||||
cur.push(p);
|
||||
}
|
||||
cur.push(q);
|
||||
// Leaving the rectangle ends the piece.
|
||||
if !q.near(*b, 1e-12) {
|
||||
if cur.len() >= 2 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.clear();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if cur.len() >= 2 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.len() >= 2 {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Clip a convex polygon to `r` (Sutherland–Hodgman).
|
||||
pub fn clip_polygon(poly: &[Vec2], r: &Bounds2) -> Vec<Vec2> {
|
||||
if r.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut cur: Vec<Vec2> = poly.to_vec();
|
||||
// Each edge: inside test and intersection with the boundary line.
|
||||
let edges: [(u8, f64); 4] = [(0, r.min.x), (1, r.max.x), (2, r.min.y), (3, r.max.y)];
|
||||
for (side, v) in edges {
|
||||
let inside = |p: &Vec2| match side {
|
||||
0 => p.x >= v,
|
||||
1 => p.x <= v,
|
||||
2 => p.y >= v,
|
||||
_ => p.y <= v,
|
||||
};
|
||||
let cross = |a: Vec2, b: Vec2| -> Vec2 {
|
||||
let t = match side {
|
||||
0 | 1 => (v - a.x) / (b.x - a.x),
|
||||
_ => (v - a.y) / (b.y - a.y),
|
||||
};
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
a + (b - a) * t
|
||||
};
|
||||
let mut next = Vec::with_capacity(cur.len() + 2);
|
||||
let n = cur.len();
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (cur.get(i).copied(), cur.get((i + 1) % n).copied()) else { continue };
|
||||
match (inside(&a), inside(&b)) {
|
||||
(true, true) => next.push(b),
|
||||
(true, false) => next.push(cross(a, b)),
|
||||
(false, true) => {
|
||||
next.push(cross(a, b));
|
||||
next.push(b);
|
||||
}
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
cur = next;
|
||||
if cur.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
/// Clip a triangle list (three vertices per triangle) to `r`, re-triangulating as fans.
|
||||
pub fn clip_triangles(tris: &[Vec2], r: &Bounds2) -> Vec<Vec2> {
|
||||
let mut out = Vec::new();
|
||||
for t in tris.as_chunks::<3>().0 {
|
||||
if t.iter().all(|p| r.contains(*p)) {
|
||||
out.extend_from_slice(t);
|
||||
continue;
|
||||
}
|
||||
let poly = clip_polygon(t, r);
|
||||
if let Some(first) = poly.first().copied() {
|
||||
for w in poly.get(1..).unwrap_or(&[]).windows(2) {
|
||||
if let (Some(b), Some(c)) = (w.first(), w.get(1)) {
|
||||
out.extend_from_slice(&[first, *b, *c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn r() -> Bounds2 {
|
||||
Bounds2::new(Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segments_and_polylines() {
|
||||
let (a, b) = clip_segment(Vec2::new(-5.0, 5.0), Vec2::new(15.0, 5.0), &r()).unwrap();
|
||||
assert!(a.near(Vec2::new(0.0, 5.0), 1e-9) && b.near(Vec2::new(10.0, 5.0), 1e-9));
|
||||
assert!(clip_segment(Vec2::new(-5.0, -5.0), Vec2::new(-1.0, 20.0), &r()).is_none());
|
||||
// In, out, back in: two pieces.
|
||||
let pieces = clip_polyline(&[Vec2::new(5.0, 5.0), Vec2::new(15.0, 5.0), Vec2::new(15.0, 8.0), Vec2::new(5.0, 8.0)], &r());
|
||||
assert_eq!(pieces.len(), 2);
|
||||
let (a, b) = clip_infinite(Vec2::new(5.0, 5.0), Vec2::new(1.0, 0.0), false, &r()).unwrap();
|
||||
assert!(a.near(Vec2::new(0.0, 5.0), 1e-9) && b.near(Vec2::new(10.0, 5.0), 1e-9));
|
||||
let (a, _) = clip_infinite(Vec2::new(5.0, 5.0), Vec2::new(1.0, 0.0), true, &r()).unwrap();
|
||||
assert!(a.near(Vec2::new(5.0, 5.0), 1e-9));
|
||||
assert!(clip_segment(Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 1.0), &r()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangles() {
|
||||
let t = clip_triangles(&[Vec2::new(-5.0, 0.0), Vec2::new(5.0, 0.0), Vec2::new(5.0, 10.0)], &r());
|
||||
assert!(!t.is_empty() && t.len().is_multiple_of(3));
|
||||
assert!(t.iter().all(|p| p.x >= -1e-9));
|
||||
}
|
||||
}
|
||||
+157
-28
@@ -7,10 +7,12 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod clip;
|
||||
mod dim;
|
||||
mod fill;
|
||||
mod hatch;
|
||||
mod linetype;
|
||||
pub mod paper;
|
||||
pub mod raster;
|
||||
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
@@ -19,6 +21,11 @@ use cadcraft_geom::{Bounds2, Mat3, Polyline, Vec2};
|
||||
|
||||
pub use dim::dimension_geometry;
|
||||
pub use fill::triangulate_evenodd;
|
||||
pub use paper::{PAPER_SIZES, PaperSize, Sheet, paper_size, sheet};
|
||||
|
||||
/// Handle carried by model-space geometry drawn inside a paper-space viewport (it belongs to no
|
||||
/// single paper-space entity, so selection highlighting never matches it).
|
||||
pub const VIEWPORT_CONTENT: Handle = Handle(0);
|
||||
|
||||
/// What a display-list primitive draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -50,6 +57,9 @@ pub struct DisplayList {
|
||||
pub verts: Vec<Vec2>,
|
||||
pub tris: Vec<Vec2>,
|
||||
pub bounds: Bounds2,
|
||||
/// The paper sheet when the list was built for a layout (paper space); the UI draws the
|
||||
/// white sheet, its shadow and the printable-area outline from it.
|
||||
pub sheet: Option<Sheet>,
|
||||
}
|
||||
|
||||
impl DisplayList {
|
||||
@@ -96,11 +106,15 @@ struct Ctx<'a> {
|
||||
block_ltype: String,
|
||||
depth: usize,
|
||||
top: Handle,
|
||||
/// Layers frozen in the viewport being drawn.
|
||||
frozen: &'a [String],
|
||||
}
|
||||
|
||||
struct Builder<'a> {
|
||||
list: DisplayList,
|
||||
opts: &'a Options,
|
||||
/// Plotting: skip layers marked "do not plot".
|
||||
plotting: bool,
|
||||
}
|
||||
|
||||
impl Builder<'_> {
|
||||
@@ -143,52 +157,166 @@ impl Builder<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the display list for a space.
|
||||
/// Build the display list for a space. For a layout (paper space) this includes the model-space
|
||||
/// geometry seen through each viewport, clipped to the viewport, and [`DisplayList::sheet`].
|
||||
pub fn build(d: &Drawing, space: &Space, opts: &Options) -> DisplayList {
|
||||
let mut b = Builder { list: DisplayList::default(), opts };
|
||||
build_space(d, space, opts, false)
|
||||
}
|
||||
|
||||
/// Like [`build`] but for plotting: layers marked "do not plot" (such as `Defpoints`) are left out.
|
||||
pub fn build_plot(d: &Drawing, space: &Space, opts: &Options) -> DisplayList {
|
||||
build_space(d, space, opts, true)
|
||||
}
|
||||
|
||||
fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String]) -> Ctx<'a> {
|
||||
Ctx {
|
||||
d,
|
||||
xf,
|
||||
block_color: Color::Index(7),
|
||||
block_layer: None,
|
||||
block_lw: Lineweight::Default,
|
||||
block_ltype: "Continuous".into(),
|
||||
depth: 0,
|
||||
top,
|
||||
frozen,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList {
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting };
|
||||
if let Space::Paper(name) = space {
|
||||
b.list.sheet = paper::sheet(d, name);
|
||||
}
|
||||
if let Some(store) = d.space(space) {
|
||||
let paper = matches!(space, Space::Paper(_));
|
||||
let mut viewports = 0usize;
|
||||
for e in store.iter() {
|
||||
let ctx = Ctx {
|
||||
d,
|
||||
xf: Mat3::IDENTITY,
|
||||
block_color: Color::Index(7),
|
||||
block_layer: None,
|
||||
block_lw: Lineweight::Default,
|
||||
block_ltype: "Continuous".into(),
|
||||
depth: 0,
|
||||
top: e.handle,
|
||||
};
|
||||
entity(&mut b, &ctx, e);
|
||||
if paper && let EntityKind::Viewport(vp) = &e.kind {
|
||||
if viewports < MAX_VIEWPORTS {
|
||||
viewports += 1;
|
||||
viewport(&mut b, d, e, vp);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[]), e);
|
||||
}
|
||||
}
|
||||
b.list
|
||||
}
|
||||
|
||||
/// Upper bound on viewports drawn per layout (hostile files).
|
||||
const MAX_VIEWPORTS: usize = 256;
|
||||
|
||||
/// A paper-space viewport: its border (as an ordinary entity) and model space seen through it.
|
||||
/// Viewport id 1 is the paper-space view itself and is not drawn.
|
||||
fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewport) {
|
||||
if vp.id == 1 || !e.common.visible {
|
||||
return;
|
||||
}
|
||||
// Border (on the viewport's layer; layer off hides only the border).
|
||||
entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[]), e);
|
||||
let center = vp.center.xy();
|
||||
let ok = |v: f64| v.is_finite() && v > 0.0;
|
||||
if !(ok(vp.width) && ok(vp.height) && ok(vp.view_height) && center.is_finite() && vp.view_center.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let s = vp.height / vp.view_height;
|
||||
if !ok(s) {
|
||||
return;
|
||||
}
|
||||
let half = Vec2::new(vp.width / 2.0, vp.height / 2.0);
|
||||
let rect = Bounds2::new(center - half, center + half);
|
||||
let xf = Mat3::translate(center).then_before(Mat3::scale(s, s)).then_before(Mat3::translate(-vp.view_center));
|
||||
let mhalf = half / s;
|
||||
// Model window with slack: entity bounds are approximate (text, dimensions).
|
||||
let win = Bounds2::new(vp.view_center - mhalf, vp.view_center + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
|
||||
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting };
|
||||
for me in d.model.iter() {
|
||||
if !matches!(me.kind, EntityKind::Ray(_) | EntityKind::XLine(_) | EntityKind::Viewport(_)) {
|
||||
let eb = cadcraft_doc::entity_bounds(d, me, 0);
|
||||
if !eb.is_empty() && !eb.intersects(&win) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if matches!(me.kind, EntityKind::Viewport(_)) {
|
||||
continue;
|
||||
}
|
||||
entity(&mut sub, &top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers), me);
|
||||
}
|
||||
append_clipped(&mut b.list, &sub.list, &rect);
|
||||
}
|
||||
|
||||
/// Append `src` to `dst`, clipped to `rect`.
|
||||
fn append_clipped(dst: &mut DisplayList, src: &DisplayList, rect: &Bounds2) {
|
||||
for p in &src.prims {
|
||||
let pts = src.points(p);
|
||||
match p.kind {
|
||||
Kind::Polyline => {
|
||||
for piece in clip::clip_polyline(pts, rect) {
|
||||
push_raw(dst, p, Kind::Polyline, &piece);
|
||||
}
|
||||
}
|
||||
Kind::Tris => {
|
||||
let t = clip::clip_triangles(pts, rect);
|
||||
push_raw(dst, p, Kind::Tris, &t);
|
||||
}
|
||||
Kind::Point => {
|
||||
if let Some(q) = pts.first()
|
||||
&& rect.contains(*q)
|
||||
{
|
||||
push_raw(dst, p, Kind::Point, &[*q]);
|
||||
}
|
||||
}
|
||||
Kind::Infinite { ray } => {
|
||||
if let (Some(base), Some(dir)) = (pts.first(), pts.get(1))
|
||||
&& let Some((a, c)) = clip::clip_infinite(*base, *dir, ray, rect)
|
||||
{
|
||||
push_raw(dst, p, Kind::Polyline, &[a, c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) {
|
||||
let min = match kind {
|
||||
Kind::Polyline => 2,
|
||||
Kind::Tris => 3,
|
||||
_ => 1,
|
||||
};
|
||||
if pts.len() < min {
|
||||
return;
|
||||
}
|
||||
let tris = kind == Kind::Tris;
|
||||
let start = if tris { dst.tris.len() } else { dst.verts.len() } as u32;
|
||||
for q in pts {
|
||||
dst.bounds.add(*q);
|
||||
}
|
||||
if tris {
|
||||
dst.tris.extend_from_slice(pts);
|
||||
} else {
|
||||
dst.verts.extend_from_slice(pts);
|
||||
}
|
||||
dst.prims.push(DPrim { handle: p.handle, color: p.color, lw: p.lw, kind, start, len: pts.len() as u32 });
|
||||
}
|
||||
|
||||
/// Build the display list for a set of loose entities (previews, rubber bands).
|
||||
pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents: I, opts: &Options) -> DisplayList {
|
||||
let mut b = Builder { list: DisplayList::default(), opts };
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting: false };
|
||||
for e in ents {
|
||||
let ctx = Ctx {
|
||||
d,
|
||||
xf: Mat3::IDENTITY,
|
||||
block_color: Color::Index(7),
|
||||
block_layer: None,
|
||||
block_lw: Lineweight::Default,
|
||||
block_ltype: "Continuous".into(),
|
||||
depth: 0,
|
||||
top: e.handle,
|
||||
};
|
||||
entity(&mut b, &ctx, e);
|
||||
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[]), e);
|
||||
}
|
||||
b.list
|
||||
}
|
||||
|
||||
fn resolve(ctx: &Ctx, e: &Entity) -> (Rgb, f32, Option<cadcraft_doc::Linetype>, f64, bool) {
|
||||
fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Rgb, f32, Option<cadcraft_doc::Linetype>, f64, bool) {
|
||||
let d = ctx.d;
|
||||
// Layer "0" inside a block takes the insert's layer.
|
||||
let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() };
|
||||
let layer = d.layer(layer_name);
|
||||
let visible = e.common.visible && layer.is_none_or(|l| l.visible());
|
||||
let visible = e.common.visible
|
||||
&& layer.is_none_or(|l| l.visible() && (!plotting || l.plot))
|
||||
&& !ctx.frozen.iter().any(|f| f.eq_ignore_ascii_case(layer_name));
|
||||
let layer_color = layer.map(|l| l.color).unwrap_or(Color::Index(7));
|
||||
let rgb = e.common.color.resolve(layer_color, ctx.block_color);
|
||||
let lw = match e.common.lineweight {
|
||||
@@ -211,7 +339,7 @@ fn resolve(ctx: &Ctx, e: &Entity) -> (Rgb, f32, Option<cadcraft_doc::Linetype>,
|
||||
}
|
||||
|
||||
fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
|
||||
let (rgb, lw, lt, ltscale, visible) = resolve(ctx, e);
|
||||
let (rgb, lw, lt, ltscale, visible) = resolve(ctx, e, b.plotting);
|
||||
if !visible {
|
||||
return;
|
||||
}
|
||||
@@ -511,6 +639,7 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> {
|
||||
block_ltype: lt,
|
||||
depth: ctx.depth + 1,
|
||||
top: ctx.top,
|
||||
frozen: ctx.frozen,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Paper sizes and the layout sheet (paper rectangle, printable area) for paper space.
|
||||
//!
|
||||
//! Paper-space units are millimetres in metric drawings (`MEASUREMENT` = 1) and inches
|
||||
//! otherwise. The sheet's lower-left corner is the paper-space origin.
|
||||
|
||||
use cadcraft_doc::{Drawing, PageSetup};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
|
||||
/// A standard paper size (portrait: `width_mm <= height_mm`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PaperSize {
|
||||
/// Canonical media name, as shown in page setup.
|
||||
pub name: &'static str,
|
||||
/// Short names accepted by commands (`A4`, `Letter`, `ANSI B`…).
|
||||
pub aliases: &'static [&'static str],
|
||||
pub width_mm: f64,
|
||||
pub height_mm: f64,
|
||||
/// Inch-based (ANSI/ARCH) rather than ISO.
|
||||
pub imperial: bool,
|
||||
}
|
||||
|
||||
const fn iso(name: &'static str, aliases: &'static [&'static str], w: f64, h: f64) -> PaperSize {
|
||||
PaperSize { name, aliases, width_mm: w, height_mm: h, imperial: false }
|
||||
}
|
||||
const fn inch(name: &'static str, aliases: &'static [&'static str], w: f64, h: f64) -> PaperSize {
|
||||
PaperSize { name, aliases, width_mm: w * 25.4, height_mm: h * 25.4, imperial: true }
|
||||
}
|
||||
|
||||
/// Common ISO and ANSI/ARCH sheet sizes.
|
||||
pub const PAPER_SIZES: &[PaperSize] = &[
|
||||
iso("ISO A0 (841.00 x 1189.00 MM)", &["A0", "ISO A0"], 841.0, 1189.0),
|
||||
iso("ISO A1 (594.00 x 841.00 MM)", &["A1", "ISO A1"], 594.0, 841.0),
|
||||
iso("ISO A2 (420.00 x 594.00 MM)", &["A2", "ISO A2"], 420.0, 594.0),
|
||||
iso("ISO A3 (297.00 x 420.00 MM)", &["A3", "ISO A3"], 297.0, 420.0),
|
||||
iso("ISO A4 (210.00 x 297.00 MM)", &["A4", "ISO A4"], 210.0, 297.0),
|
||||
iso("ISO A5 (148.00 x 210.00 MM)", &["A5", "ISO A5"], 148.0, 210.0),
|
||||
iso("ISO B4 (250.00 x 353.00 MM)", &["B4", "ISO B4"], 250.0, 353.0),
|
||||
iso("ISO B5 (176.00 x 250.00 MM)", &["B5", "ISO B5"], 176.0, 250.0),
|
||||
inch("ANSI A (8.50 x 11.00 Inches)", &["ANSI A", "Letter", "A-size"], 8.5, 11.0),
|
||||
inch("Legal (8.50 x 14.00 Inches)", &["Legal"], 8.5, 14.0),
|
||||
inch("ANSI B (11.00 x 17.00 Inches)", &["ANSI B", "Tabloid", "Ledger", "B-size"], 11.0, 17.0),
|
||||
inch("ANSI C (17.00 x 22.00 Inches)", &["ANSI C", "C-size"], 17.0, 22.0),
|
||||
inch("ANSI D (22.00 x 34.00 Inches)", &["ANSI D", "D-size"], 22.0, 34.0),
|
||||
inch("ANSI E (34.00 x 44.00 Inches)", &["ANSI E", "E-size"], 34.0, 44.0),
|
||||
inch("ARCH A (9.00 x 12.00 Inches)", &["ARCH A"], 9.0, 12.0),
|
||||
inch("ARCH B (12.00 x 18.00 Inches)", &["ARCH B"], 12.0, 18.0),
|
||||
inch("ARCH C (18.00 x 24.00 Inches)", &["ARCH C"], 18.0, 24.0),
|
||||
inch("ARCH D (24.00 x 36.00 Inches)", &["ARCH D"], 24.0, 36.0),
|
||||
inch("ARCH E1 (30.00 x 42.00 Inches)", &["ARCH E1"], 30.0, 42.0),
|
||||
inch("ARCH E (36.00 x 48.00 Inches)", &["ARCH E"], 36.0, 48.0),
|
||||
];
|
||||
|
||||
/// Look up a paper size by canonical name or alias (case- and space-insensitive).
|
||||
pub fn paper_size(name: &str) -> Option<&'static PaperSize> {
|
||||
let norm = |s: &str| s.chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect::<String>().to_ascii_lowercase();
|
||||
let n = norm(name);
|
||||
if n.is_empty() {
|
||||
return None;
|
||||
}
|
||||
PAPER_SIZES.iter().find(|p| norm(p.name) == n || p.aliases.iter().any(|a| norm(a) == n))
|
||||
}
|
||||
|
||||
/// Millimetres per paper-space unit for a drawing (1 for metric, 25.4 for imperial).
|
||||
pub fn paper_unit_mm(d: &Drawing) -> f64 {
|
||||
if d.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 25.4 }
|
||||
}
|
||||
|
||||
/// The paper sheet of a layout, in paper-space units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sheet {
|
||||
/// Sheet size (already rotated for landscape). The sheet spans `(0,0)..size`.
|
||||
pub size: Vec2,
|
||||
/// Printable area (inside the margins).
|
||||
pub printable: Bounds2,
|
||||
/// Millimetres per paper-space unit.
|
||||
pub unit_mm: f64,
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
pub fn bounds(&self) -> Bounds2 {
|
||||
Bounds2::new(Vec2::ZERO, self.size)
|
||||
}
|
||||
/// The sheet for a page setup in a drawing whose paper units are `unit_mm` millimetres.
|
||||
pub fn from_page(page: &PageSetup, unit_mm: f64) -> Sheet {
|
||||
let sane = |v: f64, d: f64| if v.is_finite() && v > 0.0 { v.min(100_000.0) } else { d };
|
||||
let w = sane(page.width_mm, 215.9);
|
||||
let h = sane(page.height_mm, 279.4);
|
||||
let (w, h) = if page.landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) };
|
||||
let u = if unit_mm.is_finite() && unit_mm > 0.0 { unit_mm } else { 1.0 };
|
||||
let size = Vec2::new(w / u, h / u);
|
||||
let m = |i: usize| page.margins_mm.get(i).copied().filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0) / u;
|
||||
// Margins: left, bottom, right, top (DXF group codes 40..43).
|
||||
let (l, b, r, t) = (m(0), m(1), m(2), m(3));
|
||||
let printable = if l + r < size.x && b + t < size.y {
|
||||
Bounds2::new(Vec2::new(l, b), Vec2::new(size.x - r, size.y - t))
|
||||
} else {
|
||||
Bounds2::new(Vec2::ZERO, size)
|
||||
};
|
||||
Sheet { size, printable, unit_mm: u }
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet of a named layout (`None` if there is no such layout).
|
||||
pub fn sheet(d: &Drawing, layout: &str) -> Option<Sheet> {
|
||||
let l = d.layouts.iter().find(|l| l.name == layout).or_else(|| d.layout(layout))?;
|
||||
Some(Sheet::from_page(&l.page, paper_unit_mm(d)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lookup_and_sheet() {
|
||||
assert_eq!(paper_size("a4").map(|p| p.width_mm), Some(210.0));
|
||||
assert_eq!(paper_size("ansi b").map(|p| p.height_mm), Some(17.0 * 25.4));
|
||||
assert_eq!(paper_size("Letter").map(|p| p.name), Some("ANSI A (8.50 x 11.00 Inches)"));
|
||||
assert!(paper_size("").is_none());
|
||||
assert!(paper_size("nope").is_none());
|
||||
let page = PageSetup { width_mm: 210.0, height_mm: 297.0, margins_mm: [5.0; 4], landscape: true, ..PageSetup::default() };
|
||||
let s = Sheet::from_page(&page, 1.0);
|
||||
assert_eq!(s.size, Vec2::new(297.0, 210.0));
|
||||
assert_eq!(s.printable, Bounds2::new(Vec2::new(5.0, 5.0), Vec2::new(292.0, 205.0)));
|
||||
let hostile = PageSetup { width_mm: f64::NAN, height_mm: -1.0, margins_mm: [1e9; 4], ..PageSetup::default() };
|
||||
let s = Sheet::from_page(&hostile, 0.0);
|
||||
assert!(s.size.x > 0.0 && s.printable == s.bounds());
|
||||
}
|
||||
}
|
||||
@@ -115,3 +115,71 @@ fn raster_rejects_bad_size() {
|
||||
let l = DisplayList::default();
|
||||
assert!(render(&l, &View { center: Vec2::ZERO, scale: 1.0, width: 0, height: 10 }, &RasterOptions::default()).is_none());
|
||||
}
|
||||
|
||||
fn layout_with_viewport(vp: cadcraft_doc::Viewport) -> Drawing {
|
||||
let mut d = sample(); // line (0,0)-(10,0) and red circle at (5,5) r3
|
||||
let paper = Space::Paper("Layout1".into());
|
||||
d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn vp(center: Vec2, w: f64, h: f64, view_center: Vec2, view_height: f64, id: u32) -> cadcraft_doc::Viewport {
|
||||
cadcraft_doc::Viewport { center: center.to3(0.0), width: w, height: h, view_center, view_height, id, locked: false, frozen_layers: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_viewport_shows_model_clipped() {
|
||||
// Viewport 4x4 paper units centred at (5,4), showing model window centred at (5,5), 8 high:
|
||||
// scale 0.5, so model (5,5) → paper (5,4); the visible model window is (1,1)..(9,9).
|
||||
let d = layout_with_viewport(vp(Vec2::new(5.0, 4.0), 4.0, 4.0, Vec2::new(5.0, 5.0), 8.0, 2));
|
||||
let l = build(&d, &Space::Paper("Layout1".into()), &Options { tolerance: 0.01, ..Default::default() });
|
||||
let sheet = l.sheet.unwrap();
|
||||
assert!(sheet.size.x > sheet.size.y, "landscape sheet");
|
||||
let rect = Bounds2::new(Vec2::new(3.0, 2.0), Vec2::new(7.0, 6.0)).expand(1e-9);
|
||||
let content: Vec<&DPrim> = l.prims.iter().filter(|p| p.handle == VIEWPORT_CONTENT).collect();
|
||||
assert!(!content.is_empty(), "model geometry is drawn in the viewport");
|
||||
for p in &content {
|
||||
for q in l.points(p) {
|
||||
assert!(rect.contains(*q), "{q:?} outside the viewport");
|
||||
}
|
||||
}
|
||||
// The circle (red) is visible, scaled by 0.5 about the view centre: radius 1.5 around (5,4).
|
||||
let red: Vec<Vec2> = content.iter().filter(|p| p.color == Rgb(255, 0, 0)).flat_map(|p| l.points(p).to_vec()).collect();
|
||||
assert!(!red.is_empty());
|
||||
assert!(red.iter().all(|q| (q.dist(Vec2::new(5.0, 4.0)) - 1.5).abs() < 0.02));
|
||||
// The line y=0 maps to paper y=1.5, below the viewport: clipped away entirely.
|
||||
assert!(content.iter().filter(|p| p.color != Rgb(255, 0, 0)).all(|p| l.points(p).iter().all(|q| q.y > 1.9)));
|
||||
// The border is drawn with the viewport's own handle.
|
||||
assert!(l.prims.iter().any(|p| p.handle != VIEWPORT_CONTENT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_viewport_frozen_layer_and_id1() {
|
||||
let mut v = vp(Vec2::new(5.0, 4.0), 20.0, 20.0, Vec2::new(5.0, 5.0), 20.0, 2);
|
||||
v.frozen_layers = vec!["0".into()];
|
||||
let d = layout_with_viewport(v);
|
||||
let l = build(&d, &Space::Paper("Layout1".into()), &Options::default());
|
||||
assert!(l.prims.iter().all(|p| p.handle != VIEWPORT_CONTENT), "layer 0 is frozen in this viewport");
|
||||
// Viewport id 1 (the paper-space view) draws nothing.
|
||||
let d = layout_with_viewport(vp(Vec2::new(5.0, 4.0), 20.0, 20.0, Vec2::new(5.0, 5.0), 20.0, 1));
|
||||
assert!(build(&d, &Space::Paper("Layout1".into()), &Options::default()).prims.is_empty());
|
||||
// Model space never draws viewport contents; plotting skips non-plot layers.
|
||||
let mut d = sample();
|
||||
d.layer_mut("0").unwrap().plot = false;
|
||||
assert!(build_plot(&d, &Space::Model, &Options::default()).prims.is_empty());
|
||||
assert!(!build(&d, &Space::Model, &Options::default()).prims.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_viewports_do_not_panic() {
|
||||
for v in [
|
||||
vp(Vec2::new(f64::NAN, 0.0), 1.0, 1.0, Vec2::ZERO, 1.0, 2),
|
||||
vp(Vec2::ZERO, -1.0, 1.0, Vec2::ZERO, 1.0, 2),
|
||||
vp(Vec2::ZERO, 1.0, 1.0, Vec2::ZERO, 0.0, 2),
|
||||
vp(Vec2::ZERO, 1e308, 1e308, Vec2::new(1e308, -1e308), 1e-308, 2),
|
||||
vp(Vec2::ZERO, 1.0, 1.0, Vec2::new(f64::INFINITY, 0.0), 1.0, 2),
|
||||
] {
|
||||
let d = layout_with_viewport(v);
|
||||
let _ = build(&d, &Space::Paper("Layout1".into()), &Options::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@ cadcraft-doc = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
cadcraft-engine = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
# GPU canvas paint callback (same egui-wgpu/wgpu eframe uses with its "wgpu" feature).
|
||||
egui-wgpu = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses).
|
||||
naga = { version = "30", features = ["wgsl-in", "glsl-out"] }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Compile the contributor credits (contributors/*.tsv, craftrules standards/contributors.md)
|
||||
//! into the About window. Missing or unreadable files give empty tables and a warning.
|
||||
|
||||
fn main() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default());
|
||||
for f in ["commits.tsv", "prs.tsv", "models.tsv", "people.tsv"] {
|
||||
let p = root.join(f);
|
||||
println!("cargo::rerun-if-changed={}", p.display());
|
||||
let text = std::fs::read_to_string(&p).unwrap_or_else(|e| {
|
||||
println!("cargo::warning=contributors/{f}: {e}; the About window shows an empty table");
|
||||
String::new()
|
||||
});
|
||||
if let Err(e) = std::fs::write(out.join(f), text) {
|
||||
println!("cargo::warning=writing {f}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! The About window's credits: contributors (GitHub logins; names only with recorded consent)
|
||||
//! and AI models, from the TSVs compiled in by build.rs.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const COMMITS: &str = include_str!(concat!(env!("OUT_DIR"), "/commits.tsv"));
|
||||
const PRS: &str = include_str!(concat!(env!("OUT_DIR"), "/prs.tsv"));
|
||||
const MODELS: &str = include_str!(concat!(env!("OUT_DIR"), "/models.tsv"));
|
||||
const PEOPLE: &str = include_str!(concat!(env!("OUT_DIR"), "/people.tsv"));
|
||||
|
||||
fn rows(t: &str) -> Vec<Vec<&str>> {
|
||||
t.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')).skip(1).map(|l| l.split('\t').collect()).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Person {
|
||||
pub login: String,
|
||||
pub display: Option<String>,
|
||||
pub real: Option<String>,
|
||||
pub first: String,
|
||||
pub last: String,
|
||||
pub commits: usize,
|
||||
pub prs: usize,
|
||||
pub added: u64,
|
||||
pub removed: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Model {
|
||||
pub id: String,
|
||||
pub company: String,
|
||||
pub model: String,
|
||||
pub version: String,
|
||||
pub commits: usize,
|
||||
pub added: u64,
|
||||
pub removed: u64,
|
||||
}
|
||||
|
||||
pub fn people() -> Vec<Person> {
|
||||
let mut by: BTreeMap<String, Person> = BTreeMap::new();
|
||||
for r in rows(COMMITS) {
|
||||
let (Some(login), Some(date)) = (r.get(2), r.get(3)) else { continue };
|
||||
let p = by.entry(login.to_string()).or_insert_with(|| Person {
|
||||
login: login.to_string(),
|
||||
first: date.to_string(),
|
||||
last: date.to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
p.commits += 1;
|
||||
p.added += r.get(4).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
p.removed += r.get(5).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
if *date < p.first.as_str() {
|
||||
p.first = date.to_string();
|
||||
}
|
||||
if *date > p.last.as_str() {
|
||||
p.last = date.to_string();
|
||||
}
|
||||
}
|
||||
for r in rows(PRS) {
|
||||
if let Some(p) = r.get(2).and_then(|l| by.get_mut(*l)) {
|
||||
p.prs += 1;
|
||||
}
|
||||
}
|
||||
for r in rows(PEOPLE) {
|
||||
if r.get(1) == Some(&"yes")
|
||||
&& let Some(p) = r.first().and_then(|l| by.get_mut(*l))
|
||||
{
|
||||
p.display = r.get(4).filter(|s| !s.is_empty()).map(|s| s.to_string());
|
||||
p.real = r.get(5).filter(|s| !s.is_empty()).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
let mut v: Vec<Person> = by.into_values().filter(|p| !p.login.ends_with("[bot]")).collect();
|
||||
v.sort_by(|a, b| a.first.cmp(&b.first));
|
||||
v
|
||||
}
|
||||
|
||||
pub fn models() -> (Vec<Model>, usize) {
|
||||
let meta: BTreeMap<String, (String, String, String)> = rows(MODELS)
|
||||
.iter()
|
||||
.filter_map(|r| Some((r.first()?.to_string(), (r.get(1)?.to_string(), r.get(2)?.to_string(), r.get(3).unwrap_or(&"").to_string()))))
|
||||
.collect();
|
||||
let mut by: BTreeMap<String, Model> = BTreeMap::new();
|
||||
let all = rows(COMMITS);
|
||||
for r in &all {
|
||||
for id in r.get(7).unwrap_or(&"").split(';').filter(|s| !s.is_empty()) {
|
||||
let (company, model, version) = meta.get(id).cloned().unwrap_or_default();
|
||||
let m = by.entry(id.to_string()).or_insert_with(|| Model { id: id.to_string(), company, model, version, ..Default::default() });
|
||||
m.commits += 1;
|
||||
m.added += r.get(4).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
m.removed += r.get(5).and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
(by.into_values().collect(), all.len())
|
||||
}
|
||||
@@ -38,7 +38,17 @@ impl Xf {
|
||||
#[derive(Default)]
|
||||
pub struct CanvasState {
|
||||
pub list: Option<DisplayList>,
|
||||
key: (u64, u64, i32),
|
||||
key: (u64, u64, i32, bool),
|
||||
/// Set when the app runs on wgpu: entities are drawn by [`crate::gpu`]; otherwise on the CPU.
|
||||
pub gpu: Option<crate::gpu::GpuTarget>,
|
||||
/// The mesh last handed to the GPU (see [`crate::gpu::CanvasCallback::key`]).
|
||||
mesh_key: Option<u64>,
|
||||
mesh_slot: crate::gpu::MeshSlot,
|
||||
mesh_origin: Vec2,
|
||||
/// Indices of infinite-line primitives in `list` (drawn on the CPU in GPU mode).
|
||||
infinite: Vec<usize>,
|
||||
/// Milliseconds spent converting the display list to vertex data (last rebuild).
|
||||
pub mesh_ms: f64,
|
||||
pub rect: Option<Rect>,
|
||||
pub xf: Option<Xf>,
|
||||
pub hover: Option<Handle>,
|
||||
@@ -62,7 +72,7 @@ fn ensure_list(app: &mut CadApp, px: f64) {
|
||||
let Ok(st) = app.session.state() else { return };
|
||||
// Rebuild when the drawing changes or the zoom moves by more than 2x (tessellation band).
|
||||
let band = px.max(1e-300).log2().floor() as i32;
|
||||
let key = (st.uid, st.revision, band);
|
||||
let key = (st.uid, st.revision, band, app.session.settings.lwdisplay);
|
||||
if app.canvas.list.is_some() && app.canvas.key == key {
|
||||
return;
|
||||
}
|
||||
@@ -208,6 +218,51 @@ fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay:
|
||||
p.extend(shapes);
|
||||
}
|
||||
|
||||
/// Draw the display list through the GPU paint callback (uploading it when it changed), plus
|
||||
/// the infinite lines, which are clipped to the view on the CPU every frame.
|
||||
fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdisplay: bool) {
|
||||
let Some(list) = &c.list else { return };
|
||||
let origin = crate::gpu::choose_origin(&list.bounds, xf.center, f64::from(xf.rect.height()) / xf.scale.max(1e-300));
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(c.key, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
if c.mesh_key != Some(key) {
|
||||
let t0 = crate::now_ms();
|
||||
let mesh = crate::gpu::build_mesh(list, origin, bg, lwdisplay);
|
||||
if let Ok(mut slot) = c.mesh_slot.lock() {
|
||||
*slot = Some(mesh);
|
||||
}
|
||||
c.infinite = list.prims.iter().enumerate().filter(|(_, pr)| matches!(pr.kind, Kind::Infinite { .. })).map(|(i, _)| i).collect();
|
||||
c.mesh_key = Some(key);
|
||||
c.mesh_origin = origin;
|
||||
c.mesh_ms = crate::now_ms() - t0;
|
||||
}
|
||||
let center = xf.rect.center();
|
||||
p.add(egui_wgpu::Callback::new_paint_callback(
|
||||
xf.rect,
|
||||
crate::gpu::CanvasCallback {
|
||||
key,
|
||||
slot: c.mesh_slot.clone(),
|
||||
offset: [(c.mesh_origin.x - xf.center.x) as f32, (c.mesh_origin.y - xf.center.y) as f32],
|
||||
scale: xf.scale as f32,
|
||||
center: [center.x, center.y],
|
||||
},
|
||||
));
|
||||
let mut shapes = Vec::new();
|
||||
for prim in c.infinite.iter().filter_map(|i| list.prims.get(*i)) {
|
||||
let pts = list.points(prim);
|
||||
if let (Kind::Infinite { ray }, Some(b), Some(d)) = (prim.kind, pts.first(), pts.get(1))
|
||||
&& let Some(seg) = clip_infinite(xf, *b, *d, ray)
|
||||
{
|
||||
shapes.push(Shape::line_segment(seg, Stroke::new(1.0, color32(display_rgb(prim.color, bg)))));
|
||||
}
|
||||
}
|
||||
p.extend(shapes);
|
||||
}
|
||||
|
||||
/// Draw selected/hovered entities as highlight overlays.
|
||||
fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, handles: &[Handle], color: Color32, width: f32, dashed: bool) {
|
||||
if handles.is_empty() {
|
||||
@@ -476,8 +531,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
ensure_list(app, 1.0 / scale);
|
||||
let bg = Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b());
|
||||
let sel = app.session.selection();
|
||||
if let Some(list) = &app.canvas.list {
|
||||
if app.canvas.gpu.is_some() {
|
||||
draw_list_gpu(&mut app.canvas, &painter, &xf, bg, app.session.settings.lwdisplay);
|
||||
} else if let Some(list) = &app.canvas.list {
|
||||
draw_list(&painter, &xf, list, bg, app.session.settings.lwdisplay);
|
||||
}
|
||||
if let Some(list) = &app.canvas.list {
|
||||
if let Some(h) = app.canvas.hover
|
||||
&& !sel.contains(&h)
|
||||
{
|
||||
|
||||
@@ -99,7 +99,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
|
||||
"cursor": app.canvas.cursor.map(|c| [c.x, c.y]),
|
||||
"snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})),
|
||||
"session": app.session.summary(),
|
||||
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())},
|
||||
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+103
-12
@@ -169,16 +169,104 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
|
||||
fn about(ctx: &egui::Context, open: &mut bool) {
|
||||
let t = Tokens::get();
|
||||
egui::Window::new("About CADCraft").open(open).resizable(false).collapsible(false).show(ctx, |ui| {
|
||||
ui.heading("CADCraft");
|
||||
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
|
||||
ui.label("Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust.");
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new("One of the Crafting Apps by the ArtCraft team and community.").color(t.text_dim));
|
||||
ui.hyperlink_to("getartcraft.com/apps/cadcraft", "https://getartcraft.com/apps/cadcraft");
|
||||
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new("MIT OR Apache-2.0. Not affiliated with Autodesk, Inc.").small().color(t.text_faint));
|
||||
let tab_id = egui::Id::new("about_tab");
|
||||
let sort_id = egui::Id::new("about_sort");
|
||||
let name_id = egui::Id::new("about_names");
|
||||
egui::Window::new("About CADCraft").open(open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| {
|
||||
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in ["About", "Contributors", "Models"].iter().enumerate() {
|
||||
if ui.selectable_label(tab == i as u8, *l).clicked() {
|
||||
tab = i as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(tab_id, tab));
|
||||
ui.separator();
|
||||
match tab {
|
||||
1 => {
|
||||
let mut names = ui.data_mut(|d| d.get_temp::<u8>(name_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Show:");
|
||||
for (i, l) in ["GitHub username", "Display name", "Real name"].iter().enumerate() {
|
||||
if ui.selectable_label(names == i as u8, *l).clicked() {
|
||||
names = i as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(name_id, names));
|
||||
let mut sort: (u8, bool) = ui.data_mut(|d| d.get_temp(sort_id)).unwrap_or((1, true));
|
||||
let mut people = crate::about::people();
|
||||
let name_of = |p: &crate::about::Person| match names {
|
||||
1 => p.display.clone().unwrap_or_else(|| format!("@{}", p.login)),
|
||||
2 => p.real.clone().unwrap_or_else(|| format!("@{}", p.login)),
|
||||
_ => format!("@{}", p.login),
|
||||
};
|
||||
people.sort_by(|a, b| {
|
||||
let o = match sort.0 {
|
||||
0 => name_of(a).trim_start_matches('@').to_lowercase().cmp(&name_of(b).trim_start_matches('@').to_lowercase()),
|
||||
1 => a.first.cmp(&b.first),
|
||||
2 => a.last.cmp(&b.last),
|
||||
3 => a.prs.cmp(&b.prs),
|
||||
4 => a.commits.cmp(&b.commits),
|
||||
5 => a.added.cmp(&b.added),
|
||||
_ => a.removed.cmp(&b.removed),
|
||||
};
|
||||
if sort.1 { o } else { o.reverse() }
|
||||
});
|
||||
egui::Grid::new("contributors").striped(true).num_columns(7).show(ui, |ui| {
|
||||
for (i, h) in ["Name", "First", "Last", "PRs", "Commits", "Added", "Removed"].iter().enumerate() {
|
||||
let arrow = if sort.0 == i as u8 { if sort.1 { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{h}{arrow}")).strong()).clicked() {
|
||||
sort = if sort.0 == i as u8 { (sort.0, !sort.1) } else { (i as u8, true) };
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for p in &people {
|
||||
ui.hyperlink_to(name_of(p), format!("https://github.com/{}", p.login));
|
||||
ui.label(p.first.get(..10).unwrap_or(&p.first));
|
||||
ui.label(p.last.get(..10).unwrap_or(&p.last));
|
||||
ui.label(p.prs.to_string());
|
||||
ui.label(p.commits.to_string());
|
||||
ui.label(p.added.to_string());
|
||||
ui.label(p.removed.to_string());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(sort_id, sort));
|
||||
}
|
||||
2 => {
|
||||
let (models, total) = crate::about::models();
|
||||
let assisted: usize = models.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::Grid::new("models").striped(true).num_columns(7).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of assisted", "% of all", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in &models {
|
||||
ui.label(&m.company);
|
||||
ui.label(&m.model);
|
||||
ui.label(&m.version);
|
||||
ui.label(m.commits.to_string());
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / assisted as f64));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / total.max(1) as f64));
|
||||
ui.label(format!("+{} / −{}", m.added, m.removed));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
ui.heading("CADCraft");
|
||||
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
|
||||
ui.label("Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust.");
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new("One of the Crafting Apps by the ArtCraft team and community.").color(t.text_dim));
|
||||
ui.hyperlink_to("getartcraft.com/apps/cadcraft", "https://getartcraft.com/apps/cadcraft");
|
||||
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new("MIT OR Apache-2.0. Not affiliated with Autodesk, Inc.").small().color(t.text_faint));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,7 +318,8 @@ fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
|
||||
/// The multiline text editor shown while MTEXT asks for its contents.
|
||||
fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let active = app.session.running.as_ref().is_some_and(|r| r.id == "mtext") && app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point);
|
||||
let active = app.session.running.as_ref().is_some_and(|r| r.id == "mtext")
|
||||
&& app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point);
|
||||
let id = egui::Id::new("mtext_editor_buffer");
|
||||
if !active {
|
||||
ctx.data_mut(|d| d.remove::<String>(id));
|
||||
@@ -262,7 +351,9 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) {
|
||||
buf.push_str("%%c");
|
||||
}
|
||||
});
|
||||
let r = ui.add(egui::TextEdit::multiline(&mut buf).desired_rows(6).desired_width(f32::INFINITY).hint_text("Type text; Enter starts a new paragraph"));
|
||||
let r = ui.add(
|
||||
egui::TextEdit::multiline(&mut buf).desired_rows(6).desired_width(f32::INFINITY).hint_text("Type text; Enter starts a new paragraph"),
|
||||
);
|
||||
if !r.has_focus() && buf.is_empty() {
|
||||
r.request_focus();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
//! GPU canvas: draws the drawing's display list with an `egui_wgpu` paint callback.
|
||||
//!
|
||||
//! The display list is converted once per (document, revision, tessellation band, background,
|
||||
//! lineweight display, vertex origin) into vertex data ([`MeshData`]) and uploaded to wgpu
|
||||
//! buffers; each frame only a small view uniform changes. Positions are stored as f32 relative to
|
||||
//! a per-build origin so large world coordinates keep their precision; at deep zoom the origin
|
||||
//! follows the view (see [`choose_origin`]).
|
||||
//!
|
||||
//! Batches: filled triangles (TriangleList), 1-px hairlines (LineList), and instanced screen-space
|
||||
//! quads for wide lineweights and point markers. Fills are drawn first, then lines on top. The
|
||||
//! shader only uses vertex buffers and one uniform buffer, so it runs on WebGPU and WebGL2.
|
||||
//!
|
||||
//! Infinite lines (xline/ray) and all overlays stay egui shapes (see `canvas.rs`), and the
|
||||
//! canvas falls back to the CPU path when no wgpu render state was handed to the app.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cadcraft_color::{Rgb, display_rgb};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use cadcraft_render::{DisplayList, Kind};
|
||||
use egui_wgpu::wgpu;
|
||||
use egui_wgpu::wgpu::util::DeviceExt;
|
||||
|
||||
/// Line/triangle vertex: position (2 × f32, relative to the origin) + sRGB colour (4 × u8).
|
||||
const VERTEX_SIZE: usize = 12;
|
||||
/// Wide-line / point instance: a, b (2 × f32 each) + colour (4 × u8) + width in points (f32).
|
||||
const INSTANCE_SIZE: usize = 24;
|
||||
/// Vertices (or instances) per GPU buffer; divisible by 2 and 3 so no primitive straddles two.
|
||||
const CHUNK: usize = 4_194_300;
|
||||
/// Point markers are 1.5 × 1.5 points (as on the CPU path).
|
||||
const POINT_SIZE: f32 = 1.5;
|
||||
|
||||
/// The wgpu target the app renders into, recorded by [`install`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GpuTarget {
|
||||
pub format: wgpu::TextureFormat,
|
||||
}
|
||||
|
||||
/// CPU-side vertex data for one display list, waiting to be uploaded.
|
||||
#[derive(Default)]
|
||||
pub struct MeshData {
|
||||
pub lines: Vec<u8>,
|
||||
pub tris: Vec<u8>,
|
||||
pub quads: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MeshData {
|
||||
pub fn line_vertices(&self) -> usize {
|
||||
self.lines.len() / VERTEX_SIZE
|
||||
}
|
||||
pub fn tri_vertices(&self) -> usize {
|
||||
self.tris.len() / VERTEX_SIZE
|
||||
}
|
||||
pub fn quad_instances(&self) -> usize {
|
||||
self.quads.len() / INSTANCE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(p: Vec2, origin: Vec2) -> [f32; 2] {
|
||||
[(p.x - origin.x) as f32, (p.y - origin.y) as f32]
|
||||
}
|
||||
|
||||
fn push_vertex(out: &mut Vec<u8>, p: [f32; 2], c: [u8; 4]) {
|
||||
out.extend_from_slice(&p[0].to_le_bytes());
|
||||
out.extend_from_slice(&p[1].to_le_bytes());
|
||||
out.extend_from_slice(&c);
|
||||
}
|
||||
|
||||
fn push_instance(out: &mut Vec<u8>, a: [f32; 2], b: [f32; 2], c: [u8; 4], width: f32) {
|
||||
for v in [a[0], a[1], b[0], b[1]] {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&c);
|
||||
out.extend_from_slice(&width.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Lineweight in screen points for a primitive (as on the CPU path), or `None` for a hairline.
|
||||
pub fn wide_width(lw: f32, lwdisplay: bool) -> Option<f32> {
|
||||
if !lwdisplay || lw <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let w = (lw * 3.78).clamp(1.0, 12.0);
|
||||
(w > 1.0).then_some(w)
|
||||
}
|
||||
|
||||
/// Convert a display list into vertex data relative to `origin`, colours resolved against `bg`.
|
||||
pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> MeshData {
|
||||
let mut m = MeshData {
|
||||
lines: Vec::with_capacity(list.segment_count().saturating_mul(2 * VERTEX_SIZE)),
|
||||
tris: Vec::with_capacity(list.tris.len().saturating_mul(VERTEX_SIZE)),
|
||||
quads: Vec::new(),
|
||||
};
|
||||
for prim in &list.prims {
|
||||
let c = display_rgb(prim.color, bg);
|
||||
let col = [c.0, c.1, c.2, 255];
|
||||
let pts = list.points(prim);
|
||||
match prim.kind {
|
||||
Kind::Polyline => match wide_width(prim.lw, lwdisplay) {
|
||||
Some(w) => {
|
||||
for s in pts.windows(2) {
|
||||
if let [a, b] = s {
|
||||
push_instance(&mut m.quads, rel(*a, origin), rel(*b, origin), col, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for s in pts.windows(2) {
|
||||
if let [a, b] = s {
|
||||
push_vertex(&mut m.lines, rel(*a, origin), col);
|
||||
push_vertex(&mut m.lines, rel(*b, origin), col);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Kind::Tris => {
|
||||
for t in pts.as_chunks::<3>().0 {
|
||||
for q in t {
|
||||
push_vertex(&mut m.tris, rel(*q, origin), col);
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Point => {
|
||||
if let Some(q) = pts.first() {
|
||||
let p = rel(*q, origin);
|
||||
push_instance(&mut m.quads, p, p, col, POINT_SIZE);
|
||||
}
|
||||
}
|
||||
// Drawn on the CPU each frame (clipped to the view).
|
||||
Kind::Infinite { .. } => {}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The vertex origin for a display list seen at `view_center` / `view_height`: the list's centre
|
||||
/// normally; at deep zoom (the view much smaller than the drawing) a point near the view, snapped
|
||||
/// to a coarse grid so panning re-uploads only every few dozen screens.
|
||||
pub fn choose_origin(bounds: &Bounds2, view_center: Vec2, view_height: f64) -> Vec2 {
|
||||
let size = bounds.width().max(bounds.height());
|
||||
if bounds.is_empty()
|
||||
|| !view_center.is_finite()
|
||||
|| view_height.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater)
|
||||
|| view_height * 2000.0 >= size
|
||||
{
|
||||
return if bounds.is_empty() { Vec2::ZERO } else { bounds.center() };
|
||||
}
|
||||
let step = view_height * 64.0;
|
||||
Vec2::new((view_center.x / step).round() * step, (view_center.y / step).round() * step)
|
||||
}
|
||||
|
||||
/// Pending upload shared between the canvas (producer) and the paint callback (consumer).
|
||||
pub type MeshSlot = Arc<Mutex<Option<MeshData>>>;
|
||||
|
||||
/// Per-frame parameters for the paint callback.
|
||||
pub struct CanvasCallback {
|
||||
/// Identifies the mesh in `slot`; the GPU buffers are rebuilt when it changes.
|
||||
pub key: u64,
|
||||
pub slot: MeshSlot,
|
||||
/// (origin − view centre) in world units.
|
||||
pub offset: [f32; 2],
|
||||
/// Points per world unit.
|
||||
pub scale: f32,
|
||||
/// Canvas rect centre in points.
|
||||
pub center: [f32; 2],
|
||||
}
|
||||
|
||||
struct Batch {
|
||||
buffer: wgpu::Buffer,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
struct Resources {
|
||||
line_pipeline: wgpu::RenderPipeline,
|
||||
tri_pipeline: wgpu::RenderPipeline,
|
||||
quad_pipeline: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
linear_out: bool,
|
||||
key: Option<u64>,
|
||||
lines: Vec<Batch>,
|
||||
tris: Vec<Batch>,
|
||||
quads: Vec<Batch>,
|
||||
}
|
||||
|
||||
const UNIFORM_SIZE: u64 = 48;
|
||||
|
||||
const SHADER: &str = r#"
|
||||
struct View {
|
||||
a: vec4<f32>, // offset x, offset y (world), scale (device px per unit), 1 = linear output
|
||||
b: vec4<f32>, // canvas centre x, y (device px), screen w, h (device px)
|
||||
c: vec4<f32>, // pixels per point
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> view: View;
|
||||
|
||||
struct VOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) color: vec4<f32>,
|
||||
};
|
||||
|
||||
fn to_px(p: vec2<f32>) -> vec2<f32> {
|
||||
let w = (p + view.a.xy) * view.a.z;
|
||||
return vec2<f32>(view.b.x + w.x, view.b.y - w.y);
|
||||
}
|
||||
|
||||
fn px_to_clip(px: vec2<f32>) -> vec4<f32> {
|
||||
return vec4<f32>(px.x / view.b.z * 2.0 - 1.0, 1.0 - px.y / view.b.w * 2.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
fn out_color(c: vec4<f32>) -> vec4<f32> {
|
||||
if (view.a.w > 0.5) {
|
||||
let lo = c.rgb / 12.92;
|
||||
let hi = pow((c.rgb + vec3<f32>(0.055)) / 1.055, vec3<f32>(2.4));
|
||||
return vec4<f32>(select(hi, lo, c.rgb <= vec3<f32>(0.04045)), c.a);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_line(@location(0) pos: vec2<f32>, @location(1) color: vec4<f32>) -> VOut {
|
||||
var o: VOut;
|
||||
o.pos = px_to_clip(to_px(pos));
|
||||
o.color = out_color(color);
|
||||
return o;
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_quad(
|
||||
@builtin(vertex_index) vi: u32,
|
||||
@location(0) a: vec2<f32>,
|
||||
@location(1) b: vec2<f32>,
|
||||
@location(2) color: vec4<f32>,
|
||||
@location(3) width: f32,
|
||||
) -> VOut {
|
||||
let pa = to_px(a);
|
||||
let pb = to_px(b);
|
||||
let d = pb - pa;
|
||||
let len = length(d);
|
||||
var dir = vec2<f32>(1.0, 0.0);
|
||||
if (len > 0.0001) {
|
||||
dir = d / len;
|
||||
}
|
||||
let n = vec2<f32>(-dir.y, dir.x);
|
||||
// Width arrives in points.
|
||||
let hw = 0.5 * width * view.c.x;
|
||||
let t = select(0.0, 1.0, vi == 1u || vi == 4u || vi == 5u);
|
||||
let s = select(-1.0, 1.0, vi == 2u || vi == 3u || vi == 5u);
|
||||
let along = mix(pa - dir * hw, pb + dir * hw, t);
|
||||
var o: VOut;
|
||||
o.pos = px_to_clip(along + n * hw * s);
|
||||
o.color = out_color(color);
|
||||
return o;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(v: VOut) -> @location(0) vec4<f32> {
|
||||
return v.color;
|
||||
}
|
||||
"#;
|
||||
|
||||
fn vertex_layout() -> wgpu::VertexBufferLayout<'static> {
|
||||
const ATTRS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Unorm8x4];
|
||||
wgpu::VertexBufferLayout { array_stride: VERTEX_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &ATTRS }
|
||||
}
|
||||
|
||||
fn instance_layout() -> wgpu::VertexBufferLayout<'static> {
|
||||
const ATTRS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Unorm8x4, 3 => Float32];
|
||||
wgpu::VertexBufferLayout { array_stride: INSTANCE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &ATTRS }
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
fn new(device: &wgpu::Device, target: GpuTarget) -> Self {
|
||||
let module =
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("cad_canvas"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
let entry = |binding: u32| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
};
|
||||
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("cad_canvas"), entries: &[entry(0)] });
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("cad_canvas"),
|
||||
bind_group_layouts: &[Some(&bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("cad_canvas_view"),
|
||||
size: UNIFORM_SIZE,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("cad_canvas"),
|
||||
layout: &bgl,
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
|
||||
});
|
||||
let pipeline = |label: &str, vs: &str, topology: wgpu::PrimitiveTopology, buffer: wgpu::VertexBufferLayout<'static>| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(vs),
|
||||
buffers: &[Some(buffer)],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState { topology, ..Default::default() },
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false },
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: target.format,
|
||||
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
Resources {
|
||||
line_pipeline: pipeline("cad_lines", "vs_line", wgpu::PrimitiveTopology::LineList, vertex_layout()),
|
||||
tri_pipeline: pipeline("cad_fills", "vs_line", wgpu::PrimitiveTopology::TriangleList, vertex_layout()),
|
||||
quad_pipeline: pipeline("cad_quads", "vs_quad", wgpu::PrimitiveTopology::TriangleList, instance_layout()),
|
||||
uniform,
|
||||
bind_group,
|
||||
linear_out: target.format.is_srgb(),
|
||||
key: None,
|
||||
lines: Vec::new(),
|
||||
tris: Vec::new(),
|
||||
quads: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage: wgpu::BufferUsages) -> Vec<Batch> {
|
||||
bytes
|
||||
.chunks(CHUNK * stride)
|
||||
.filter(|c| c.len() >= stride)
|
||||
.map(|c| Batch {
|
||||
buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: c, usage }),
|
||||
count: u32::try_from(c.len() / stride).unwrap_or(0),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create the canvas pipelines in the renderer's callback resources. Call once with the app's
|
||||
/// render state (eframe `CreationContext::wgpu_render_state`).
|
||||
pub fn install(rs: &egui_wgpu::RenderState) -> GpuTarget {
|
||||
let target = GpuTarget { format: rs.target_format };
|
||||
let res = Resources::new(&rs.device, target);
|
||||
rs.renderer.write().callback_resources.insert(res);
|
||||
log::info!("gpu canvas: target {:?}", rs.target_format);
|
||||
target
|
||||
}
|
||||
|
||||
impl egui_wgpu::CallbackTrait for CanvasCallback {
|
||||
fn prepare(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
screen: &egui_wgpu::ScreenDescriptor,
|
||||
_encoder: &mut wgpu::CommandEncoder,
|
||||
resources: &mut egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
|
||||
if res.key != Some(self.key) {
|
||||
let pending = self.slot.lock().ok().and_then(|mut s| s.take());
|
||||
if let Some(m) = pending {
|
||||
let usage = wgpu::BufferUsages::VERTEX;
|
||||
res.lines = upload(device, "cad_lines", &m.lines, VERTEX_SIZE, usage);
|
||||
res.tris = upload(device, "cad_fills", &m.tris, VERTEX_SIZE, usage);
|
||||
res.quads = upload(device, "cad_quads", &m.quads, INSTANCE_SIZE, usage);
|
||||
res.key = Some(self.key);
|
||||
}
|
||||
}
|
||||
let ppp = screen.pixels_per_point;
|
||||
let [w, h] = screen.size_in_pixels;
|
||||
let a = [self.offset[0], self.offset[1], self.scale * ppp, if res.linear_out { 1.0 } else { 0.0 }];
|
||||
let b = [self.center[0] * ppp, self.center[1] * ppp, w.max(1) as f32, h.max(1) as f32];
|
||||
let c = [ppp, 0.0, 0.0, 0.0];
|
||||
let bytes: Vec<u8> = a.iter().chain(&b).chain(&c).flat_map(|v| v.to_le_bytes()).collect();
|
||||
queue.write_buffer(&res.uniform, 0, &bytes);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) {
|
||||
let Some(res) = resources.get::<Resources>() else { return };
|
||||
if res.key != Some(self.key) {
|
||||
return;
|
||||
}
|
||||
let [sw, sh] = info.screen_size_px;
|
||||
if sw == 0 || sh == 0 {
|
||||
return;
|
||||
}
|
||||
// Whole-screen device pixels; egui already set the scissor to the canvas clip rect.
|
||||
pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, 0.0, 1.0);
|
||||
pass.set_bind_group(0, &res.bind_group, &[]);
|
||||
pass.set_pipeline(&res.tri_pipeline);
|
||||
for b in &res.tris {
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..b.count, 0..1);
|
||||
}
|
||||
pass.set_pipeline(&res.line_pipeline);
|
||||
for b in &res.lines {
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..b.count, 0..1);
|
||||
}
|
||||
pass.set_pipeline(&res.quad_pipeline);
|
||||
for b in &res.quads {
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..6, 0..b.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_doc::Handle;
|
||||
use cadcraft_render::DPrim;
|
||||
|
||||
fn list() -> DisplayList {
|
||||
let mut l = DisplayList {
|
||||
verts: vec![Vec2::new(1e6, 1e6), Vec2::new(1e6 + 1.0, 1e6), Vec2::new(1e6 + 1.0, 1e6 + 1.0), Vec2::new(5.0, 5.0)],
|
||||
tris: vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)],
|
||||
..Default::default()
|
||||
};
|
||||
let p = |kind, start, len, lw| DPrim { handle: Handle(1), color: Rgb(255, 255, 255), lw, kind, start, len };
|
||||
l.prims =
|
||||
vec![p(Kind::Polyline, 0, 3, 0.0), p(Kind::Tris, 0, 3, 0.0), p(Kind::Point, 3, 1, 0.0), p(Kind::Infinite { ray: false }, 0, 2, 0.0)];
|
||||
for v in l.verts.clone() {
|
||||
l.bounds.add(v);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_counts_and_precision() {
|
||||
let l = list();
|
||||
let origin = Vec2::new(1e6, 1e6);
|
||||
let m = build_mesh(&l, origin, Rgb(255, 255, 255), false);
|
||||
assert_eq!(m.line_vertices(), 4, "two segments");
|
||||
assert_eq!(m.tri_vertices(), 3);
|
||||
assert_eq!(m.quad_instances(), 1, "the point marker");
|
||||
// First vertex is stored relative to the origin, so it's exactly zero.
|
||||
assert_eq!(&m.lines[0..8], &[0u8; 8]);
|
||||
// White on a white background inverts to black (display_rgb).
|
||||
assert_eq!(&m.lines[8..12], &[0, 0, 0, 255]);
|
||||
let x = f32::from_le_bytes([m.lines[12], m.lines[13], m.lines[14], m.lines[15]]);
|
||||
assert_eq!(x, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineweights_become_quads() {
|
||||
let mut l = list();
|
||||
if let Some(p) = l.prims.first_mut() {
|
||||
p.lw = 0.7;
|
||||
}
|
||||
let m = build_mesh(&l, Vec2::ZERO, Rgb(0, 0, 0), true);
|
||||
assert_eq!(m.line_vertices(), 0);
|
||||
assert_eq!(m.quad_instances(), 3);
|
||||
assert_eq!(wide_width(0.25, true), None, "thin lineweights stay hairlines");
|
||||
assert_eq!(wide_width(0.7, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn shader_is_valid_for_webgpu_and_webgl2() {
|
||||
let module = naga::front::wgsl::parse_str(SHADER).unwrap();
|
||||
let info = naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
|
||||
for (stage, entry) in
|
||||
[(naga::ShaderStage::Vertex, "vs_line"), (naga::ShaderStage::Vertex, "vs_quad"), (naga::ShaderStage::Fragment, "fs_main")]
|
||||
{
|
||||
let options = naga::back::glsl::Options { version: naga::back::glsl::Version::new_gles(300), ..Default::default() };
|
||||
let pipeline = naga::back::glsl::PipelineOptions { shader_stage: stage, entry_point: entry.into(), multiview: None };
|
||||
let mut out = String::new();
|
||||
let mut w =
|
||||
naga::back::glsl::Writer::new(&mut out, &module, &info, &options, &pipeline, naga::proc::BoundsCheckPolicies::default()).unwrap();
|
||||
w.write().unwrap();
|
||||
assert!(out.contains("#version 300 es"), "{entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_follows_deep_zoom() {
|
||||
let b = Bounds2::new(Vec2::new(0.0, 0.0), Vec2::new(1e6, 1e6));
|
||||
assert_eq!(choose_origin(&b, Vec2::new(10.0, 10.0), 1e5), b.center());
|
||||
let o = choose_origin(&b, Vec2::new(123_456.7, 654_321.2), 1.0);
|
||||
assert!((o.x - 123_456.7).abs() <= 32.0 && (o.y - 654_321.2).abs() <= 32.0);
|
||||
assert_eq!(choose_origin(&Bounds2::EMPTY, Vec2::new(1.0, 1.0), 1.0), Vec2::ZERO);
|
||||
assert_eq!(choose_origin(&b, Vec2::new(f64::NAN, 0.0), 1.0), b.center());
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod about;
|
||||
pub mod canvas;
|
||||
pub mod chrome;
|
||||
pub mod cmdline;
|
||||
pub mod control;
|
||||
pub mod dialogs;
|
||||
pub mod gpu;
|
||||
pub mod icons;
|
||||
pub mod menus;
|
||||
pub mod palettes;
|
||||
@@ -119,6 +121,12 @@ impl CadApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the canvas on the GPU with the app's wgpu render state (eframe's
|
||||
/// `CreationContext::wgpu_render_state`). Without it the canvas draws on the CPU.
|
||||
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState) {
|
||||
self.canvas.gpu = Some(gpu::install(rs));
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
self.control_rx = Some(rx);
|
||||
self
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# CADCraft and AI agents (MCP)
|
||||
|
||||
CADCraft speaks the [Model Context Protocol](https://modelcontextprotocol.io) over stdio.
|
||||
|
||||
## Start it
|
||||
|
||||
```sh
|
||||
# Headless: an in-process drawing session, no window.
|
||||
cadcraft-cli mcp
|
||||
|
||||
# Bridged to the running app (watch the agent draw):
|
||||
cadcraft --control 7979 &
|
||||
cadcraft-cli mcp --connect 127.0.0.1:7979
|
||||
```
|
||||
|
||||
Example client configuration (Claude Code / Claude Desktop):
|
||||
|
||||
```json
|
||||
{ "mcpServers": { "cadcraft": { "command": "cadcraft-cli", "args": ["mcp"] } } }
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `command_line {text}` | Type at the command line exactly like a person: `line 0,0 @10,0 @0,5 c`, `circle 5,2 1`, `offset 0.5`, `zoom e`. Spaces act as Enter; empty text is Enter. Returns the new prompt, keywords and output. |
|
||||
| `script {text}` | A multi-line script (like `.scr` files). |
|
||||
| `execute {command, params}` | Any command with JSON parameters, never a dialog. `list_commands` shows ids and parameter docs. |
|
||||
| `list_commands {filter?}` | The command catalog. |
|
||||
| `inspect_drawing {entities?, limit?}` | Counts, extents, layers, styles, blocks, layouts, selection, undo history and entities. |
|
||||
| `query_entities {type?, layer?, window?}` | Filtered entities with handles and geometry. |
|
||||
| `render {width?, height?, fit?}` | A PNG image of the drawing. |
|
||||
| `new_drawing {metric?}`, `open {path}`, `save {path?}`, `cancel` | Files and Escape. |
|
||||
| `screenshot`, `ui_inspect`, `ui_click {x, y}` | Only when connected to the app. |
|
||||
|
||||
Resources: `cadcraft://drawing` (inspect JSON) and `cadcraft://commands` (catalog).
|
||||
|
||||
## A typical session
|
||||
|
||||
1. `command_line {"text": "rectang 0,0 100,60"}`
|
||||
2. `execute {"command": "circle", "params": {"center": [10, 10], "radius": 4}}`
|
||||
3. `command_line {"text": "dimlinear 0,0 100,0 50,-8"}`
|
||||
4. `inspect_drawing {}` — verify counts and geometry.
|
||||
5. `render {}` — look at it.
|
||||
|
||||
The end-to-end test in `crates/mcp/src/tests.rs` runs a session like this and checks the result
|
||||
only through MCP.
|
||||
+9
-62
@@ -2,19 +2,19 @@
|
||||
|
||||
Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see ROADMAP.md for the weighted estimate.
|
||||
|
||||
**Menu breadth: 151 / 491 items (31%)** · 159 registered commands.
|
||||
**Menu breadth: 204 / 491 items (42%)** · 227 registered commands.
|
||||
|
||||
| Menu | Items | Covered | % |
|
||||
|---|---|---|---|
|
||||
| File | 22 | 6 | 27% |
|
||||
| Edit | 13 | 9 | 69% |
|
||||
| View | 75 | 22 | 29% |
|
||||
| Insert | 11 | 1 | 9% |
|
||||
| File | 22 | 9 | 41% |
|
||||
| Edit | 13 | 10 | 77% |
|
||||
| View | 75 | 27 | 36% |
|
||||
| Insert | 11 | 2 | 18% |
|
||||
| Format | 27 | 21 | 78% |
|
||||
| Tools | 85 | 19 | 22% |
|
||||
| Draw | 87 | 26 | 30% |
|
||||
| Dimension | 28 | 18 | 64% |
|
||||
| Modify | 113 | 27 | 24% |
|
||||
| Tools | 85 | 23 | 27% |
|
||||
| Draw | 87 | 54 | 62% |
|
||||
| Dimension | 28 | 19 | 68% |
|
||||
| Modify | 113 | 37 | 33% |
|
||||
| Window | 22 | 2 | 9% |
|
||||
| Help | 8 | 0 | 0% |
|
||||
|
||||
@@ -26,20 +26,16 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- File > Import...
|
||||
- File > Attach...
|
||||
- File > Export...
|
||||
- File > Export to PDF...
|
||||
- File > Export Layout to Model...
|
||||
- File > Package Drawing...
|
||||
- File > DWG Compare...
|
||||
- File > Markup Import...
|
||||
- File > Page Setup Manager...
|
||||
- File > Plot Styles...
|
||||
- File > Print...
|
||||
- File > Batch Publish...
|
||||
- File > Share...
|
||||
- Edit > Undo Group of commands
|
||||
- Edit > Cut with Base Point
|
||||
- Edit > Paste as Block
|
||||
- Edit > Find...
|
||||
- View > Zoom > Dynamic
|
||||
- View > Pan > Point
|
||||
- View > Orbit > Constrained Orbit
|
||||
@@ -47,11 +43,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- View > Camera > Adjust Distance
|
||||
- View > Camera > Swivel
|
||||
- View > Viewports > Named Viewports...
|
||||
- View > Viewports > New Viewports...
|
||||
- View > Viewports > 1 Viewport
|
||||
- View > Viewports > 2 Viewports
|
||||
- View > Viewports > 3 Viewports
|
||||
- View > Viewports > 4 Viewports
|
||||
- View > Viewports > Polygonal Viewport
|
||||
- View > 3D Views > Viewpoint
|
||||
- View > 3D Views > Plan View > Current UCS
|
||||
@@ -98,7 +89,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Insert > Raster Image Reference...
|
||||
- Insert > Field...
|
||||
- Insert > Data Link...
|
||||
- Insert > Layout > New Layout
|
||||
- Insert > Layout > Layout from Template...
|
||||
- Insert > ACIS File...
|
||||
- Insert > Drawing Exchange Binary...
|
||||
@@ -120,10 +110,7 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Tools > Isolate > Isolate Objects
|
||||
- Tools > Isolate > Hide Objects
|
||||
- Tools > Isolate > End Object Isolation
|
||||
- Tools > Inquiry > Quick
|
||||
- Tools > Inquiry > Angle
|
||||
- Tools > Inquiry > Volume
|
||||
- Tools > Inquiry > Region/Mass Properties
|
||||
- Tools > Update Fields
|
||||
- Tools > Block Editor
|
||||
- Tools > Edit Xref in Place
|
||||
@@ -173,7 +160,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Tools > Express Tools > Replace Block...
|
||||
- Tools > Express Tools > Super Hatch > Image...
|
||||
- Tools > Express Tools > Super Hatch > Xref...
|
||||
- Tools > Express Tools > Super Hatch > Wipeout
|
||||
- Tools > Express Tools > Super Hatch > Select Existing Object
|
||||
- Draw > 3D Modeling > Polysolid
|
||||
- Draw > 3D Modeling > Box
|
||||
@@ -190,7 +176,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Draw > 3D Modeling > Section Plane
|
||||
- Draw > 3D Modeling > Surfaces > Planar
|
||||
- Draw > 3D Modeling > Surfaces > Network
|
||||
- Draw > 3D Modeling > Surfaces > Blend
|
||||
- Draw > 3D Modeling > Surfaces > Patch
|
||||
- Draw > 3D Modeling > Meshes > Primitives > Box
|
||||
- Draw > 3D Modeling > Meshes > Primitives > Wedge
|
||||
@@ -208,34 +193,7 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Draw > 3D Modeling > Setup > Drawing
|
||||
- Draw > 3D Modeling > Setup > View
|
||||
- Draw > 3D Modeling > Setup > Profile
|
||||
- Draw > 3D Polyline
|
||||
- Draw > Spline > Control Vertices
|
||||
- Draw > Multiline
|
||||
- Draw > Arc > Start, Center, End
|
||||
- Draw > Arc > Start, Center, Angle
|
||||
- Draw > Arc > Start, Center, Length
|
||||
- Draw > Arc > Start, End, Angle
|
||||
- Draw > Arc > Start, End, Direction
|
||||
- Draw > Arc > Start, End, Radius
|
||||
- Draw > Arc > Center, Start, End
|
||||
- Draw > Arc > Center, Start, Angle
|
||||
- Draw > Arc > Center, Start, Length
|
||||
- Draw > Circle > Center, Diameter
|
||||
- Draw > Circle > 2 Points
|
||||
- Draw > Circle > Tan, Tan, Radius
|
||||
- Draw > Circle > Tan, Tan, Tan
|
||||
- Draw > Ellipse > Axis, End
|
||||
- Draw > Ellipse > Arc
|
||||
- Draw > Revision Cloud > Rectangular
|
||||
- Draw > Revision Cloud > Polygonal
|
||||
- Draw > Revision Cloud > Freehand
|
||||
- Draw > Point > Divide
|
||||
- Draw > Point > Measure
|
||||
- Draw > Helix
|
||||
- Draw > Wipeout
|
||||
- Draw > Table...
|
||||
- Draw > Center Line
|
||||
- Draw > Center Mark
|
||||
- Dimension > Jogged
|
||||
- Dimension > Dimension Space
|
||||
- Dimension > Dimension Break
|
||||
@@ -244,7 +202,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Dimension > Jogged Linear
|
||||
- Dimension > Oblique
|
||||
- Dimension > Align Text > Home
|
||||
- Dimension > Align Text > Angle
|
||||
- Dimension > Reassociate Dimensions
|
||||
- Modify > Object > External Reference > Bind...
|
||||
- Modify > Object > External Reference > Frame
|
||||
@@ -252,16 +209,13 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Modify > Object > Image > Quality
|
||||
- Modify > Object > Image > Transparency
|
||||
- Modify > Object > Image > Frame
|
||||
- Modify > Object > Spline
|
||||
- Modify > Object > Array
|
||||
- Modify > Object > Multileader > Add Leader
|
||||
- Modify > Object > Multileader > Remove Leader
|
||||
- Modify > Object > Multileader > Align
|
||||
- Modify > Object > Multileader > Collect
|
||||
- Modify > Object > Multiline...
|
||||
- Modify > Object > Attribute > Global
|
||||
- Modify > Object > Block Description...
|
||||
- Modify > Object > Edit Text...
|
||||
- Modify > Clip > Image
|
||||
- Modify > Clip > Xref
|
||||
- Modify > Clip > Viewport
|
||||
@@ -269,13 +223,8 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Modify > Annotative Object Scale > Delete Current Scale
|
||||
- Modify > Annotative Object Scale > Add/Delete Scales...
|
||||
- Modify > Annotative Object Scale > Synchronize Multiple-scale Positions
|
||||
- Modify > Array > Path Array
|
||||
- Modify > Copy Nested Objects
|
||||
- Modify > Lengthen
|
||||
- Modify > Blend
|
||||
- Modify > 3D Operations > 3D Move
|
||||
- Modify > 3D Operations > 3D Rotate
|
||||
- Modify > 3D Operations > Align
|
||||
- Modify > 3D Operations > 3D Align
|
||||
- Modify > 3D Operations > 3D Mirror
|
||||
- Modify > 3D Operations > 3D Array
|
||||
@@ -330,8 +279,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Modify > Mesh Editing > Convert to Faceted Surface
|
||||
- Modify > Mesh Editing > Convert to Smooth Solid
|
||||
- Modify > Mesh Editing > Convert to Smooth Surface
|
||||
- Modify > Change Space
|
||||
- Modify > Flatten Objects
|
||||
- Window > Minimize
|
||||
- Window > Minimize All
|
||||
- Window > Zoom
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! `cargo xtask contributors`: refresh contributors/commits.tsv and prs.tsv from the local git
|
||||
//! history and GitHub (via `gh`), per craftrules standards/contributors.md. Never writes
|
||||
//! people.tsv or models.tsv. Git author names and emails are never recorded.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const REPO: &str = "storytold/cadcraft";
|
||||
|
||||
fn run(root: &Path, cmd: &str, args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new(cmd).current_dir(root).args(args).output().map_err(|e| format!("{cmd}: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("{cmd} {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
fn trailer_names(models: &str) -> HashMap<String, String> {
|
||||
models
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#') && !l.starts_with("id\t"))
|
||||
.filter_map(|l| {
|
||||
let c: Vec<&str> = l.split('\t').collect();
|
||||
Some((c.get(4)?.to_string(), c.first()?.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run_cmd(root: &Path) -> Result<(), String> {
|
||||
let models = std::fs::read_to_string(root.join("contributors/models.tsv")).unwrap_or_default();
|
||||
let map = trailer_names(&models);
|
||||
// Commit → GitHub login (paginated; the only network step).
|
||||
let mut logins: HashMap<String, String> = HashMap::new();
|
||||
for page in 1..=200 {
|
||||
let path = format!("repos/{REPO}/commits?per_page=100&page={page}");
|
||||
let Ok(out) = run(root, "gh", &["api", &path, "--jq", ".[] | [.sha, (.author.login // \"unknown\")] | @tsv"]) else { break };
|
||||
if out.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
for l in out.lines() {
|
||||
if let Some((sha, login)) = l.split_once('\t') {
|
||||
logins.insert(sha.to_string(), login.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// PR numbers by merge commit.
|
||||
let mut prs_rows = Vec::new();
|
||||
let mut pr_of: HashMap<String, String> = HashMap::new();
|
||||
if let Ok(out) = run(
|
||||
root,
|
||||
"gh",
|
||||
&[
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
REPO,
|
||||
"--state",
|
||||
"merged",
|
||||
"--limit",
|
||||
"1000",
|
||||
"--json",
|
||||
"number,url,author,mergedAt,additions,deletions,commits,mergeCommit",
|
||||
"--jq",
|
||||
".[] | [.number, .url, .author.login, .mergedAt, .additions, .deletions, (.commits|length), .mergeCommit.oid, ([.commits[].oid]|join(\",\"))] | @tsv",
|
||||
],
|
||||
) {
|
||||
for l in out.lines() {
|
||||
let c: Vec<&str> = l.split('\t').collect();
|
||||
if c.len() < 9 {
|
||||
continue;
|
||||
}
|
||||
for sha in c[8].split(',') {
|
||||
pr_of.insert(sha.to_string(), c[0].to_string());
|
||||
}
|
||||
prs_rows.push(c[..7].join("\t"));
|
||||
}
|
||||
}
|
||||
// Local history with numstat and trailers.
|
||||
let out = Command::new("git")
|
||||
.current_dir(root)
|
||||
.env("TZ", "UTC")
|
||||
.args([
|
||||
"log",
|
||||
"--no-merges",
|
||||
"--date=format-local:%Y-%m-%dT%H:%M:%SZ",
|
||||
"--format=@@%H\t%cd\t%(trailers:key=Co-Authored-By,valueonly,separator=;)",
|
||||
"--numstat",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("git: {e}"))?;
|
||||
let log = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let mut rows: Vec<(String, String)> = Vec::new();
|
||||
let mut cur: Option<(String, String, String, u64, u64)> = None;
|
||||
let flush = |cur: &mut Option<(String, String, String, u64, u64)>, rows: &mut Vec<(String, String)>| {
|
||||
if let Some((sha, date, trailers, add, del)) = cur.take() {
|
||||
let login = logins.get(&sha).cloned().unwrap_or_else(|| "unknown".into());
|
||||
let mut ms: Vec<String> = trailers
|
||||
.split(';')
|
||||
.filter_map(|t| {
|
||||
let name = t.trim().split('<').next()?.trim().to_string();
|
||||
map.get(&name).cloned()
|
||||
})
|
||||
.collect();
|
||||
ms.sort();
|
||||
ms.dedup();
|
||||
let pr = pr_of.get(&sha).cloned().unwrap_or_default();
|
||||
rows.push((
|
||||
date.clone(),
|
||||
format!("{sha}\thttps://github.com/{REPO}/commit/{sha}\t{login}\t{date}\t{add}\t{del}\t{pr}\t{}", ms.join(";")),
|
||||
));
|
||||
}
|
||||
};
|
||||
for l in log.lines() {
|
||||
if let Some(rest) = l.strip_prefix("@@") {
|
||||
flush(&mut cur, &mut rows);
|
||||
let c: Vec<&str> = rest.splitn(3, '\t').collect();
|
||||
cur = Some((c.first().unwrap_or(&"").to_string(), c.get(1).unwrap_or(&"").to_string(), c.get(2).unwrap_or(&"").to_string(), 0, 0));
|
||||
} else if let Some(c) = cur.as_mut() {
|
||||
let p: Vec<&str> = l.split('\t').collect();
|
||||
if p.len() == 3 {
|
||||
c.3 += p[0].parse::<u64>().unwrap_or(0);
|
||||
c.4 += p[1].parse::<u64>().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut cur, &mut rows);
|
||||
rows.sort();
|
||||
let mut commits = String::from("sha\turl\tlogin\tdate\tadditions\tdeletions\tpr\tmodels\n");
|
||||
for (_, r) in &rows {
|
||||
commits.push_str(r);
|
||||
commits.push('\n');
|
||||
}
|
||||
std::fs::write(root.join("contributors/commits.tsv"), commits).map_err(|e| e.to_string())?;
|
||||
let mut prs = String::from("number\turl\tlogin\tmerged_at\tadditions\tdeletions\tcommits\tmodels\n");
|
||||
prs_rows.sort();
|
||||
for r in prs_rows {
|
||||
prs.push_str(&r);
|
||||
prs.push_str("\t\n");
|
||||
}
|
||||
std::fs::write(root.join("contributors/prs.tsv"), prs).map_err(|e| e.to_string())?;
|
||||
println!("contributors: {} commits", rows.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod assets;
|
||||
mod contributors;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod parity;
|
||||
@@ -25,6 +26,7 @@ commands:
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity recompute the AutoCAD feature-parity summary in docs/parity.md
|
||||
contributors refresh contributors/commits.tsv and prs.tsv (git + gh)
|
||||
ico <out.ico> <in.png>...
|
||||
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
|
||||
";
|
||||
@@ -42,6 +44,7 @@ fn main() -> ExitCode {
|
||||
Some("parity") => parity::run(&root()),
|
||||
Some("ico") => ico::run(&rest),
|
||||
Some("version") => version::run(&root(), &rest),
|
||||
Some("contributors") => contributors::run_cmd(&root()),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user