mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-23 02:34:02 -04:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f988f630d | ||
|
|
acd109d97c | ||
|
|
f4e6873130 | ||
|
|
e4214f7180 | ||
|
|
35934718ea | ||
|
|
916d440bed | ||
|
|
632967aa78 | ||
|
|
f15be2305c | ||
|
|
f2dadec23a | ||
|
|
00acec952e | ||
|
|
78254b4d3f | ||
|
|
8094d7746f | ||
|
|
4ad4f4f598 | ||
|
|
477eec5026 | ||
|
|
38750c6eb8 | ||
|
|
75d9d72703 | ||
|
|
82424e47c9 | ||
|
|
4d826ed91f | ||
|
|
a7bb28d722 | ||
|
|
27bff9cc30 | ||
|
|
d600a80354 | ||
|
|
81f0aa53ff | ||
|
|
27861ad0ed | ||
|
|
b72680cfb7 | ||
|
|
d9adce038c | ||
|
|
4c75b0c458 | ||
|
|
8a2b8f4bdf |
No files matched your search
@@ -30,6 +30,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
|
||||
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
|
||||
|
||||
|
||||
Generated
+28
-16
@@ -547,11 +547,12 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-ui-egui",
|
||||
"chrono",
|
||||
"eframe",
|
||||
"egui",
|
||||
"hidapi",
|
||||
@@ -569,7 +570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -580,14 +581,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -597,7 +598,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -608,14 +609,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -624,7 +625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -644,7 +645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust",
|
||||
@@ -654,14 +655,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -681,7 +682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -692,7 +693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -706,7 +707,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -714,6 +715,7 @@ dependencies = [
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -728,7 +730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -825,6 +827,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -4811,8 +4822,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -32,6 +32,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
|
||||
# rendering / formats
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.cadcraft.png" width="128" height="128" alt="CADCraft icon: a drafting set square and compass arc on a blue grid tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">CADCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -285,6 +289,14 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
|
||||
|
||||
**Download CADCraft** from GitHub: the [latest release](https://github.com/storytold/cadcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/cadcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
> [!WARNING]
|
||||
> Download official CADCraft builds from the [storytold/cadcraft releases](https://github.com/storytold/cadcraft/releases). Verify that the repository owner is `storytold`. Before you run a download, check that its checksum matches the line for that file in the release's `SHA256SUMS.txt`:
|
||||
>
|
||||
> ```sh
|
||||
> shasum -a 256 cadcraft-<ver>-macos-universal.dmg # macOS (on Linux: sha256sum FILE)
|
||||
> Get-FileHash .\cadcraft-<ver>-windows-x64.msi # Windows PowerShell
|
||||
> ```
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|
||||
@@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -61,6 +61,16 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
|
||||
/// example while the process has no file descriptors left, and retrying at once would keep a
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
|
||||
@@ -80,7 +90,7 @@ impl Drop for Slot {
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
@@ -222,6 +232,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_read_up_to_the_limit() {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
@@ -93,7 +93,10 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
|
||||
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
|
||||
/// output never changes for a value the old formatter accepted and logging never panics.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
@@ -102,20 +105,25 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math.
|
||||
/// 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -385,8 +393,20 @@ mod tests {
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -752,6 +752,8 @@ pub struct PageSetup {
|
||||
pub landscape: bool,
|
||||
/// "layout" | "extents" | "display" | "window" | "limits".
|
||||
pub plot_area: String,
|
||||
/// Plot window corners `[x1, y1, x2, y2]` (DXF plot layout groups 48, 49, 140, 141); used when `plot_area` is "window".
|
||||
pub plot_window: [f64; 4],
|
||||
pub scale_to_fit: bool,
|
||||
/// Paper units per drawing unit.
|
||||
pub scale: f64,
|
||||
@@ -779,6 +781,7 @@ impl Default for PageSetup {
|
||||
margins_mm: [6.35, 6.35, 6.35, 6.35],
|
||||
landscape: true,
|
||||
plot_area: "layout".into(),
|
||||
plot_window: [0.0; 4],
|
||||
scale_to_fit: false,
|
||||
scale: 1.0,
|
||||
plot_style_table: String::new(),
|
||||
|
||||
@@ -494,6 +494,16 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]:
|
||||
/// the handle read with the DXF needn't be the style's in the document, in which case the DWG
|
||||
/// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650).
|
||||
fn current_text_style(doc: &mut acadrust::CadDocument) {
|
||||
let name = doc.header.current_text_style_name.clone();
|
||||
if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) {
|
||||
doc.header.current_text_style_handle = h;
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||
let data = dxf.to_vec();
|
||||
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
|
||||
@@ -512,6 +522,7 @@ mod native {
|
||||
dimension_flags(&mut doc, true);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
|
||||
.map_err(|e| format!("DWG write: {e}"))
|
||||
@@ -585,6 +596,22 @@ mod tests {
|
||||
assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018"));
|
||||
}
|
||||
|
||||
/// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not
|
||||
/// Standard: the DXF names the style, and the handle read with it was Standard's (#1650).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn current_text_style_reaches_the_dwg() {
|
||||
let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n";
|
||||
let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n";
|
||||
let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n");
|
||||
let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n");
|
||||
let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
|
||||
let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle);
|
||||
assert!(romans.is_some(), "ROMANS is missing from the DWG");
|
||||
assert_eq!(Some(doc.header.current_text_style_handle), romans);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_an_error_not_a_crash() {
|
||||
assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err());
|
||||
|
||||
@@ -427,13 +427,13 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
|
||||
let d = s.doc().ok()?;
|
||||
let cur = d.header.str("CMLEADERSTYLE", "Standard");
|
||||
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
|
||||
// An annotative style's sizes are paper sizes: the annotation scale applies when drawn.
|
||||
let k = if st.annotative { 1.0 } else { d.header.f64("DIMSCALE", 1.0) };
|
||||
let th = st.text_height * k;
|
||||
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
|
||||
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
|
||||
let th = st.text_height;
|
||||
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 };
|
||||
let attach = if dir > 0.0 { 4 } else { 6 };
|
||||
let dogleg = if landing { st.dogleg * k } else { 0.0 };
|
||||
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0);
|
||||
let dogleg = if landing { st.dogleg } else { 0.0 };
|
||||
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
|
||||
Some(EntityKind::MLeader(MLeader {
|
||||
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
|
||||
landing: v3(*landing_pt),
|
||||
@@ -451,7 +451,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
|
||||
columns: None,
|
||||
}),
|
||||
style: st.name.clone(),
|
||||
arrow_size: st.arrow_size * k,
|
||||
arrow_size: st.arrow_size,
|
||||
spline: false,
|
||||
block: None,
|
||||
}))
|
||||
|
||||
@@ -307,11 +307,32 @@ fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R {
|
||||
}
|
||||
Ok(Value::t())
|
||||
}
|
||||
/// `=` and `/=`: numbers and strings compare as [`compare`] does, and any other atom compares by
|
||||
/// what it is, as `eq` does. AutoLISP answers `(= (type x) 'LIST)` -- the idiom for a type test,
|
||||
/// since `type` returns a symbol -- where coercing that symbol to a number failed the whole
|
||||
/// routine with "bad argument type" instead (#1597). A mismatch is `nil`, not an error, so
|
||||
/// `(= 1 "1")` and `(= '(1) '(1))` answer too. The ordering comparisons stay numbers and strings
|
||||
/// only, as AutoLISP has them.
|
||||
fn eq_chain(a: &[Value], want_equal: bool) -> R {
|
||||
arity(a, 1, usize::MAX)?;
|
||||
for w in a.windows(2) {
|
||||
let [x, y] = w else { continue };
|
||||
let same = match (x, y) {
|
||||
(Value::Str(_), Value::Str(_)) => compare(x, y)?.is_eq(),
|
||||
_ if x.as_number().is_some() && y.as_number().is_some() => compare(x, y)?.is_eq(),
|
||||
_ => eq(x, y),
|
||||
};
|
||||
if same != want_equal {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
}
|
||||
Ok(Value::t())
|
||||
}
|
||||
fn num_eq(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_eq())
|
||||
eq_chain(a, true)
|
||||
}
|
||||
fn num_ne(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_ne())
|
||||
eq_chain(a, false)
|
||||
}
|
||||
fn lt(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_lt())
|
||||
|
||||
@@ -67,6 +67,12 @@ impl GetRequest {
|
||||
let mut p = Prompt::new(message, accept).base_opt(base);
|
||||
p.keywords = self.initget.keywords.clone();
|
||||
p.raw = true;
|
||||
// `entsel`/`nentsel` pick one object. The routine writes the message, so the prompt says
|
||||
// so itself rather than being read for the word "Select" -- with a message of its own
|
||||
// ("\nSelect the second line: ", or no mention of selecting at all) the pick box went
|
||||
// missing, while the same call without one got it from the default "Select object: "
|
||||
// (#1598).
|
||||
p.picks_one = matches!(self.kind, GetKind::EntSel);
|
||||
p
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,39 @@ fn arithmetic_follows_autolisp() {
|
||||
assert_eq!(eval(&mut s, "(+ 1 nil)"), Err("bad argument type: numberp: nil".into()));
|
||||
}
|
||||
|
||||
/// `=` answers for the symbols `type` returns, which is how AutoLISP routines test a type:
|
||||
/// `(= (type x) 'INT)`. Coercing the symbol to a number failed the whole routine with "bad
|
||||
/// argument type" instead, so a routine written for AutoLISP stopped at its first type test
|
||||
/// (#1597). The ordering comparisons are still numbers and strings, as AutoLISP has them.
|
||||
#[test]
|
||||
fn equality_compares_symbols_as_autolisp_does() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "(= (type 1) 'INT)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= (type 1.0) 'INT)"), "nil");
|
||||
assert_eq!(ok(&mut s, "(= (type '(1 2)) 'LIST)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= (type \"a\") 'STR)"), "T");
|
||||
assert_eq!(ok(&mut s, "(/= (type 1) 'REAL)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= 'a 'a 'a)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= 'a 'b)"), "nil");
|
||||
|
||||
// The routine from the report runs.
|
||||
ok(&mut s, "(defun isint (a) (if (= (type a) 'INT) T nil))");
|
||||
assert_eq!(ok(&mut s, "(isint 3)"), "T");
|
||||
assert_eq!(ok(&mut s, "(isint 3.0)"), "nil");
|
||||
assert_eq!(ok(&mut s, "(isint \"3\")"), "nil");
|
||||
|
||||
// A mismatch of kinds is nil, not an error; numbers and strings are unchanged.
|
||||
assert_eq!(ok(&mut s, "(= 1 \"1\")"), "nil");
|
||||
assert_eq!(ok(&mut s, "(= '(1) '(1))"), "nil", "`=` is not `equal`");
|
||||
assert_eq!(ok(&mut s, "(= nil nil)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= 1 1.0)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= \"a\" \"a\" \"a\")"), "T");
|
||||
assert_eq!(ok(&mut s, "(= 1 1 2)"), "nil");
|
||||
|
||||
// Ordering still refuses what AutoLISP refuses.
|
||||
assert_eq!(eval(&mut s, "(< 'a 'b)"), Err("bad argument type: numberp: A".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_and_strings() {
|
||||
let mut s = session();
|
||||
@@ -452,6 +485,37 @@ fn hostile_lisp_never_panics() {
|
||||
assert!(!s.lisp_running());
|
||||
}
|
||||
|
||||
/// `entsel` and `nentsel` pick one object, so the cursor shows the pick box at their prompt. It
|
||||
/// was read off the message's first word, and a routine's own message begins with a newline -- so
|
||||
/// the same call showed the pick box without a message and lost it with one (#1598).
|
||||
#[test]
|
||||
fn an_entsel_prompt_picks_objects_whatever_its_message_says() {
|
||||
let mut s = session();
|
||||
// No message: the default "Select object: ".
|
||||
s.cmdline("(nentsel)").unwrap();
|
||||
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
|
||||
// The reported case: the routine's own message, starting with a newline.
|
||||
s.cmdline("(nentsel \"\nSelect second line or polyline: \")").unwrap();
|
||||
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
|
||||
// Wording that never says "select" picks an object just the same.
|
||||
s.cmdline("(entsel \"\nPick a line: \")").unwrap();
|
||||
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
|
||||
// A point prompt is not a pick, and keeps its point modifiers -- including one whose own
|
||||
// message happens to begin with the word the built-in commands are read by. `picks_one` says
|
||||
// which calls pick, so the wording is only ever read where the engine wrote it (#1635 review).
|
||||
for point in ["(getpoint \"\nSpecify a point: \")", "(getpoint \"\nSelect a point: \")", "(getpoint \"Select a point: \")"] {
|
||||
s.cmdline(point).unwrap();
|
||||
assert!(!s.current_prompt().unwrap().picks_objects(), "{point}: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_routine_is_a_prompt_with_a_rubber_band() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -62,6 +62,10 @@ pub struct Prompt {
|
||||
/// pointer boxes.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub distance: bool,
|
||||
/// The prompt picks one object whatever it says: an AutoLISP `entsel`/`nentsel`, whose message
|
||||
/// the routine writes, so it cannot be read for the word "Select" (#1598).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub picks_one: bool,
|
||||
}
|
||||
|
||||
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
|
||||
@@ -128,11 +132,14 @@ impl Prompt {
|
||||
self.default = Some(d.into());
|
||||
self
|
||||
}
|
||||
/// The prompt picks objects: a "Select objects" prompt, or a point prompt whose click selects
|
||||
/// one object ("Select object to offset", "Select first line"…, the commands that pick a
|
||||
/// single object themselves). The UI shows the pick box there.
|
||||
/// The prompt picks objects: a "Select objects" prompt, one that says it picks a single object
|
||||
/// ([`Prompt::picks_one`]), or a point prompt whose click selects one ("Select object to
|
||||
/// offset", "Select first line"…, the commands that pick a single object themselves). The UI
|
||||
/// shows the pick box there. The wording is only read where the engine wrote it: a routine
|
||||
/// writes its own message (`raw`), which may open with "Select" and still ask for a point,
|
||||
/// and says through `picks_one` when it picks instead (#1635 review).
|
||||
pub fn picks_objects(&self) -> bool {
|
||||
self.accept.select || (self.accept.point && self.message.starts_with("Select"))
|
||||
self.picks_one || self.accept.select || (self.accept.point && !self.raw && self.message.starts_with("Select"))
|
||||
}
|
||||
/// "Specify next point or [Undo/Close] <default>:".
|
||||
pub fn display(&self) -> String {
|
||||
|
||||
@@ -121,3 +121,20 @@ fn json_form_takes_leader_type_and_block() {
|
||||
assert!(s.execute("mleader", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// DIMSCALE belongs to dimension styles: a multileader keeps its style's text height, arrowhead
|
||||
/// and dogleg whatever DIMSCALE is (#1648).
|
||||
#[test]
|
||||
fn mleader_sizes_ignore_dimscale() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleaderstyle", &json!({"name": "M5", "textHeight": 5, "arrowSize": 5, "current": true})).unwrap();
|
||||
s.execute("setvar", &json!({"name": "DIMSCALE", "value": 20})).unwrap();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [30, 20]], "text": "CADCRAFT"})).unwrap();
|
||||
let m = last_mleader(&s);
|
||||
assert_eq!(m.arrow_size, 5.0);
|
||||
let st = s.doc().unwrap().mleader_styles.iter().find(|x| x.name == "M5").unwrap().clone();
|
||||
assert_eq!(m.dogleg, st.dogleg);
|
||||
let t = m.text.unwrap();
|
||||
assert_eq!(t.height, 5.0);
|
||||
assert!((t.insert.x - (30.0 + st.dogleg + st.landing_gap)).abs() < 1e-9, "{}", t.insert.x);
|
||||
}
|
||||
@@ -437,6 +437,10 @@ pub fn bulge_to_arc(a: Vec2, b: Vec2, bulge: f64) -> Option<(Arc, bool)> {
|
||||
return None;
|
||||
}
|
||||
let theta = 4.0 * bulge.atan(); // signed included angle
|
||||
// A sweep at or below EPS would be read back as a full circle by `ccw_sweep`; it is a straight segment.
|
||||
if theta.abs() <= EPS {
|
||||
return None;
|
||||
}
|
||||
let chord = a.dist(b);
|
||||
let r = chord / (2.0 * (theta / 2.0).sin().abs());
|
||||
let mid = a.mid(b);
|
||||
|
||||
@@ -74,6 +74,16 @@ fn bulge_quarter_arc() {
|
||||
assert!(close(arc.sweep(), PI / 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_bulge_is_not_a_full_circle() {
|
||||
let a = Vec2::new(0.0, 0.0);
|
||||
let b = Vec2::new(10.0, 0.0);
|
||||
let p = Polyline { vertices: vec![PolyVertex::with_bulge(a, 1e-10), PolyVertex::new(b)], closed: false };
|
||||
assert!((p.len() - 10.0).abs() < 1e-6, "len={}", p.len());
|
||||
let bb = p.bounds();
|
||||
assert!(bb.max.x < 10.001 && bb.max.y < 0.001 && bb.min.y > -0.001, "bounds={bb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_area_and_length() {
|
||||
let p = Polyline::from_points(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)], true);
|
||||
|
||||
@@ -51,8 +51,6 @@ const MAX_OBJECTS: usize = 1_000_000;
|
||||
const MAX_XDATA_GROUPS: usize = 16_384;
|
||||
/// Longest ownership chain followed.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Handles above this are not believed (hostile files); references to them are dropped.
|
||||
const MAX_HANDLE: u64 = 1 << 48;
|
||||
|
||||
/// Record types that structure a file: never written as an entity of another type.
|
||||
const STRUCTURAL: &[&str] = &["", "SECTION", "ENDSEC", "EOF", "TABLE", "ENDTAB", "BLOCK", "ENDBLK", "SEQEND", "VERTEX", "ATTRIB", "CLASS"];
|
||||
@@ -85,9 +83,10 @@ fn tag(r: &RawTag) -> Tag {
|
||||
Tag::new(r.code, value.unwrap_or_else(|| Value::Str(r.value.clone())))
|
||||
}
|
||||
|
||||
/// A handle value we believe: hexadecimal, not null, not absurdly large.
|
||||
/// A handle value we believe: hexadecimal and not null. DXF handles are up to 16 hex digits and
|
||||
/// AutoCAD writes some above 2^48 (e.g. 0xDAD1…), so the whole 64-bit range is believed.
|
||||
fn handle(s: &str) -> Option<Handle> {
|
||||
Handle::parse_hex(s).filter(|h| h.0 != 0 && h.0 <= MAX_HANDLE)
|
||||
Handle::parse_hex(s).filter(|h| h.0 != 0)
|
||||
}
|
||||
|
||||
/// Group codes whose values are handles of other objects (DXF Reference, group code ranges).
|
||||
|
||||
@@ -1425,7 +1425,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
match kind.as_str() {
|
||||
"LAYER" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
if name.is_empty() || d.layers.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
let aci = t.i(62).unwrap_or(7);
|
||||
@@ -1462,7 +1462,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
}
|
||||
"LTYPE" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() || d.linetype(&name).is_some() {
|
||||
if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
let pattern = ltype_pattern(&tg, <ype_styles);
|
||||
@@ -1470,7 +1470,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
}
|
||||
"STYLE" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
// 70 bit 4: vertical; 71 bits 2/4: backwards / upside down.
|
||||
@@ -1552,7 +1552,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
}
|
||||
"DIMSTYLE" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
// Every variable with a DIMSTYLE group code; values the style can't take are
|
||||
@@ -2248,6 +2248,7 @@ fn page_setup(t: &T) -> PageSetup {
|
||||
_ => "layout",
|
||||
}
|
||||
.into();
|
||||
p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 });
|
||||
p.plot_style_table = t.s(7).unwrap_or_default();
|
||||
p
|
||||
}
|
||||
|
||||
@@ -1441,8 +1441,11 @@ fn table_size(t: &Table) -> (usize, usize) {
|
||||
|
||||
/// The anonymous `*T` block of a table: cell fills, borders and texts as its cell styles draw
|
||||
/// them, for readers that only draw the block.
|
||||
fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
|
||||
let g = cadcraft_render::table_geometry(d, t);
|
||||
///
|
||||
/// The block is relative to its own origin: the ACAD_TABLE reference adds the insertion point.
|
||||
pub(crate) fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
|
||||
let at_origin = Table { insert: Vec3::ZERO, ..t.clone() };
|
||||
let g = cadcraft_render::table_geometry(d, &at_origin);
|
||||
let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() };
|
||||
let mut out = Vec::new();
|
||||
for f in &g.fills {
|
||||
@@ -2565,10 +2568,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
|
||||
(45, page.height_mm),
|
||||
(46, 0.0),
|
||||
(47, 0.0),
|
||||
(48, 0.0),
|
||||
(49, 0.0),
|
||||
(140, 0.0),
|
||||
(141, 0.0),
|
||||
(48, page.plot_window[0]),
|
||||
(49, page.plot_window[1]),
|
||||
(140, page.plot_window[2]),
|
||||
(141, page.plot_window[3]),
|
||||
(142, scale),
|
||||
(143, 1.0),
|
||||
] {
|
||||
|
||||
@@ -1343,6 +1343,23 @@ fn tables_roundtrip_with_title_header_and_merges() {
|
||||
assert_eq!(again.blocks.len(), back.blocks.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_block_is_relative_to_the_insertion_point() {
|
||||
let d = Drawing::new_imperial();
|
||||
let t = table_sample();
|
||||
let ents = crate::dxf_write::table_block_entities(&d, &t, "0");
|
||||
let mut xs = Vec::new();
|
||||
for e in &ents {
|
||||
if let EntityKind::Line(l) = &e.kind {
|
||||
xs.extend([l.a.x, l.b.x]);
|
||||
}
|
||||
}
|
||||
assert!(!xs.is_empty());
|
||||
let (min, max) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), &x| (lo.min(x), hi.max(x)));
|
||||
// Columns total 8 wide; the block starts at its own origin, not at the insert x of 5.
|
||||
assert!(min.abs() < 1e-9 && (max - 8.0).abs() < 1e-9, "block x range {min}..{max}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_flags_roundtrip() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
|
||||
@@ -536,3 +536,29 @@ fn dragging_a_distance_grip_changes_the_distance() {
|
||||
assert!(drag_grip(&mut d, h("30"), 2, GripPart::End, Vec2::new(130.0, 0.0)).is_err(), "not a reference");
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
/// The DOOR drawing with every handle moved above 2^48: AutoCAD writes such handles (seen in
|
||||
/// drawings whose handles start at 0xDAD1…), and the dynamic block's objects must still be kept.
|
||||
fn far_handles(src: &str) -> String {
|
||||
const SHIFT: u64 = 0x000D_AD10_0000_0000;
|
||||
let is_handle = |code: i32| matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480 | 481 | 1005);
|
||||
let mut out = String::new();
|
||||
for line in src.lines() {
|
||||
let (code, value) = line.trim().split_once(' ').unwrap_or((line.trim(), ""));
|
||||
let moved = match (code.parse::<i32>(), u64::from_str_radix(value, 16)) {
|
||||
(Ok(c), Ok(h)) if is_handle(c) && h != 0 => format!("{:X}", h + SHIFT),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
out.push_str(&format!("{code} {moved}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_block_with_handles_above_2_pow_48_is_kept() {
|
||||
let src = far_handles(&fixture());
|
||||
let d = read_dxf(dxf(&src).as_bytes()).unwrap();
|
||||
let before = dynamic_block(&d, "Door").expect("the dynamic block with far handles");
|
||||
let again = read_dxf(write_dxf(&d).as_bytes()).unwrap();
|
||||
assert_eq!(by_node(dynamic_block(&again, "Door").unwrap()), by_node(before));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! #1667: a layout whose page setup plots a window (DXF plot type 4) lost the window corners
|
||||
//! (groups 48, 49, 140, 141) on save: they were written as zero.
|
||||
|
||||
use cadcraft_doc::{Drawing, Layout, PageSetup};
|
||||
use cadcraft_io::{read, write_dxf};
|
||||
|
||||
#[test]
|
||||
fn plot_window_corners_round_trip_through_dxf() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let mut l = Layout::new("WIN", 3);
|
||||
l.page = PageSetup {
|
||||
width_mm: 297.0,
|
||||
height_mm: 420.0,
|
||||
margins_mm: [0.0; 4],
|
||||
plot_area: "window".into(),
|
||||
plot_window: [0.0, -297.0, 420.0, 0.0],
|
||||
..PageSetup::default()
|
||||
};
|
||||
d.layouts.push(l);
|
||||
|
||||
let back = read(write_dxf(&d).as_bytes(), "win.dxf").unwrap();
|
||||
let page = &back.layouts.iter().find(|l| l.name == "WIN").unwrap().page;
|
||||
assert_eq!(page.plot_area, "window");
|
||||
assert_eq!(page.plot_window, [0.0, -297.0, 420.0, 0.0]);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ egui-wgpu = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -111,23 +111,36 @@ impl CadApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC).
|
||||
/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). Past chrono's year 262143 range the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch.
|
||||
fn utc(secs: u64) -> String {
|
||||
let days = (secs / 86_400) as i64;
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
let (y, mo, d) = civil_from_days(secs / 86_400);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}")
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// A recovery file's mtime can in principle land past `NaiveDate`'s ±262143-year range; the old
|
||||
/// hand-rolled converter still produced a date there, so falling back to the epoch would change
|
||||
/// the shown time. The Gregorian calendar repeats every 400 years (146_097 days), so chrono
|
||||
/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then
|
||||
/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old
|
||||
/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// The Drawing Recovery window, while autosave files from a crash are waiting.
|
||||
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
if app.recovery.items.is_empty() {
|
||||
@@ -233,5 +246,9 @@ mod tests {
|
||||
assert_eq!(super::utc(0), "1970-01-01 00:00");
|
||||
assert_eq!(super::utc(951_782_400 + 3_660), "2000-02-29 01:01");
|
||||
assert_eq!(super::utc(1_791_676_800), "2026-10-11 00:00");
|
||||
assert_eq!(super::utc(253_402_300_800), "10000-01-01 00:00");
|
||||
// Past chrono's range the proleptic Gregorian date is kept, not the epoch (old output).
|
||||
assert_eq!(super::utc(10_000_000_000_000), "318857-05-20 17:46");
|
||||
assert_eq!(super::utc(u64::MAX), "584554051223-11-09 07:00");
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,4 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
+40
-18
@@ -28,25 +28,39 @@ pub fn leaves(catalog: &str) -> Vec<String> {
|
||||
/// Where the generated checklist goes.
|
||||
pub const OUT: &str = "docs/parity-checklist.md";
|
||||
|
||||
/// Today's UTC date as `YYYY-MM-DD` (from the system clock; no date crate needed).
|
||||
/// Today's UTC date as `YYYY-MM-DD` (from the system clock).
|
||||
fn today() -> String {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let (y, m, d) = civil_from_days((secs / 86_400) as i64);
|
||||
date(std::time::SystemTime::now())
|
||||
}
|
||||
|
||||
/// `t` as a UTC `YYYY-MM-DD`. A clock before 1970 reads as 1970-01-01 (the `unwrap_or(0)` clamp);
|
||||
/// past chrono's year 262143 range the proleptic Gregorian date is kept via the 400-year cycle
|
||||
/// rather than falling back to the epoch.
|
||||
fn date(t: std::time::SystemTime) -> String {
|
||||
let secs = t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let (y, m, d) = civil_from_days(secs / 86_400);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day) in the proleptic Gregorian calendar.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
(y, m, d)
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `date` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run past
|
||||
/// `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years (146_097
|
||||
/// days), so chrono converts the day within one cycle and whole cycles go back into the year (an
|
||||
/// `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and reproducing
|
||||
/// the old `civil_from_days` result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
@@ -116,9 +130,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn civil_dates() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
|
||||
assert_eq!(civil_from_days(20_736), (2026, 10, 10));
|
||||
let day = |d: u64| date(std::time::UNIX_EPOCH + std::time::Duration::from_secs(d * 86_400));
|
||||
assert_eq!(day(0), "1970-01-01");
|
||||
assert_eq!(day(11_016), "2000-02-29");
|
||||
assert_eq!(day(20_736), "2026-10-10");
|
||||
assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01");
|
||||
// Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`).
|
||||
assert_eq!(day(115_740_740), "318857-05-20");
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(date(far), "292277026596-12-04");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user