diff --git a/Cargo.lock b/Cargo.lock index f572aa69..4c95b3de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,6 +551,7 @@ dependencies = [ "muda", "rfd", "serde_json", + "winit", "winresource", ] diff --git a/README.md b/README.md index 44cb0f93..4e8c8a4b 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ numbers): | Modify | ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, STRETCH, OFFSET, TRIM, EXTEND, FILLET, CHAMFER, BREAK, JOIN, EXPLODE, rectangular/polar ARRAY, draw order, OVERKILL | | Precision | Object snaps (endpoint, midpoint, center, geometric center, node, quadrant, intersection, insertion, perpendicular, tangent, nearest; deferred tangent/perpendicular for the first point of a line, e.g. belt lines tangent to two circles), polar tracking, ortho, grid snap | | Layers & properties | Layers palette and Layer Properties Manager (on/off, freeze, lock, plot, colour, linetype), layer tools (isolate, freeze, off, lock, match, previous), Properties palette with per-object editing, linetypes, lineweights, colour index and true colour | -| Annotation | All DIM* commands with full DIMSTYLE variables, overrides and every arrowhead, associative dimensions that follow geometry, MLEADER, TABLE, TrueType fonts, MTEXT formatting codes (fonts, heights, colours, stacked fractions), our own single-stroke drafting font | +| Annotation | All DIM* commands with full DIMSTYLE variables, overrides and every arrowhead, associative dimensions that follow geometry, MLEADER, TABLE, TrueType fonts (`.ttf`, `.otf`, `.ttc` collections), MTEXT formatting codes (fonts, heights, colours, stacked fractions), our own single-stroke drafting font | | Hatch & blocks | Pick-point hatch boundaries with islands, pattern, solid and gradient fills from our own pattern library, BLOCK/INSERT, attributes and nested blocks | | Files | DXF read/write (R12–2018, including dimension styles, associativity, tables and constraints), DWG read/write (R13–2018, via the acadrust library), PDF plotting, SVG and PNG export | | Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF | @@ -132,6 +132,39 @@ zoom e zoom to extents The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trunkrs.dev)). +### Fonts + +CADCraft never ships fonts; it uses the TrueType/OpenType fonts installed on your system. SHX +fonts (including big fonts such as `chineset.shx`) aren't read yet: text in an SHX style is drawn +with our own stroke font, and any character a text's font lacks (CJK, for example) is drawn from +an installed font that has it, preferring wide-coverage fonts such as Noto Sans CJK, Source Han +Sans, WenQuanYi, PingFang or Microsoft JhengHei. Two settings, set with `SETVAR` or (for every +run) an environment variable, steer this: + +| Variable | Environment | Meaning | +|---|---|---| +| `FONTALT` | `CADCRAFT_FONTALT` | Font used instead of a text style font that can't be found (an SHX file, a missing TTF), by file or family name, e.g. `Noto Sans CJK TC`. Empty (default) = our stroke font. | +| `FONTFALLBACK` | `CADCRAFT_FONTFALLBACK` | Fonts tried first for characters a text's font lacks, comma-separated, e.g. `Noto Sans CJK TC, msjh.ttc`. | + +`.` clears either one. + +### Logs + +The desktop app writes its `log` records to standard error and to `logs/cadcraft.log` in the +settings directory (Linux and the BSDs `~/.config/cadcraft/logs/`, or +`$XDG_CONFIG_HOME/cadcraft/logs/`; macOS `~/Library/Application Support/CADCraft/logs/`; Windows +`%APPDATA%\CADCraft\logs\`; or under `CADCRAFT_CONFIG_DIR`). A start from a desktop menu or the +Dock has no terminal, so this file is what to attach to a bug report. Each launch moves the +previous log to `cadcraft.1.log` (and that one to `cadcraft.2.log`), so the log of a run that +crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file. + +| Variable | Effect | +|---|---| +| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`) | +| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for CADCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,cadcraft_io=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`cadcraft*=debug`). `cadcraft_engine=debug` also logs every command-line message. | + +The logger is `apps/cadcraft/src/logging.rs`; the web build logs to the browser console instead. + ## Drive it from agents, MCP and the CLI ```sh diff --git a/ROADMAP.md b/ROADMAP.md index f975cdd6..b84f3bca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -64,7 +64,7 @@ edge cases, dialogs), which is why it is lower. | 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 | ~75% (Layer Properties Manager with VP overrides/states/filters, QSELECT, Quick Properties) | 10 | -| M4 | Annotation: DIM* commands, DIMSTYLE manager, associative dimensions, MLEADER + styles, in-place MTEXT editor, fields, tables + table styles, annotative scaling, TrueType fonts, SHX reader | ~50% (TrueType text, MTEXT formatting codes, full DIMSTYLE variables + overrides, all arrowheads, associative dimensions, TABLE editing; DXF round trip; not yet: native MULTILEADER objects, jogged/ordinate polish, tolerance frames, fields, annotative scaling, SHX) | 30 | +| M4 | Annotation: DIM* commands, DIMSTYLE manager, associative dimensions, MLEADER + styles, in-place MTEXT editor, fields, tables + table styles, annotative scaling, TrueType fonts, SHX reader | ~50% (TrueType text incl. `.ttc`, per-character font fallback and FONTALT for unread SHX fonts, MTEXT formatting codes, full DIMSTYLE variables + overrides, all arrowheads, associative dimensions, TABLE editing; DXF round trip; not yet: native MULTILEADER objects, jogged/ordinate polish, tolerance frames, fields, annotative scaling, SHX) | 30 | | 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 | ~35% (pick-point boundaries with islands, gradients, BLOCK/INSERT, attributes, nested blocks) | 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 | ~55% (layouts, viewports, MSPACE/PSPACE through viewports, page setup, PLOT/EXPORTPDF) | 25 | | 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 | ~55% (DXF round trip of dimension styles, associativity, tables and constraints) | 55 | diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index b0803c69..a59177f4 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -22,6 +22,9 @@ log = { workspace = true } [build-dependencies] winresource = "0.1" +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +winit = { version = "0.30", default-features = false, features = ["x11"] } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } diff --git a/apps/cadcraft/src/control_server.rs b/apps/cadcraft/src/control_server.rs index ff519aa6..b83c4914 100644 --- a/apps/cadcraft/src/control_server.rs +++ b/apps/cadcraft/src/control_server.rs @@ -14,11 +14,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { - eprintln!("cadcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; - eprintln!("cadcraft: control server listening on 127.0.0.1:{port}"); + log::info!("control server listening on 127.0.0.1:{port}"); std::thread::spawn(move || { for stream in listener.incoming().flatten() { let tx = tx.clone(); diff --git a/apps/cadcraft/src/logging.rs b/apps/cadcraft/src/logging.rs new file mode 100644 index 00000000..6678dec3 --- /dev/null +++ b/apps/cadcraft/src/logging.rs @@ -0,0 +1,569 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! Without a logger every `log::warn!`/`log::error!`/`log::info!` in the workspace (a broken app +//! icon, the GPU canvas set-up, the control server) vanished. A launch from a desktop menu has no +//! terminal, so the file is what a bug report can attach: `/logs/cadcraft.log` +//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; see `config_dir` in `main.rs`). +//! Each start moves the previous log to `cadcraft.1.log` (and that one to `.2`), so the log of a +//! run that crashed survives the next launch. +//! +//! Levels: `info` for CADCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, +//! `warn,cadcraft_io=trace`, `wgpu_core=info`. A directive ending in `*` matches every target +//! that starts with it (`cadcraft*=debug`). `RUST_LOG=cadcraft_engine=debug` also logs every +//! command-line message (`Session::echo`). +//! +//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. A record takes the sink lock only for one +//! `write_all` of an already formatted line (standard error is written outside the lock). + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log directory. +pub const LOG_FILE: &str = "cadcraft.log"; +/// The log directory inside the settings directory. +pub const LOG_DIR: &str = "logs"; +/// How many previous logs are kept (`cadcraft.1.log` … `cadcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,cadcraft*=info"; + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + 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) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("cadcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.capped { + return; + } + if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file`, first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + /// No file will come: drop the kept lines and keep none from now on. + pub fn close(&mut self) { + self.file = None; + self.pending = Vec::new(); + self.capped = true; + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink (and + // stop buffering, since no file will come). + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, +} + +impl AppLogger { + pub fn new(filter: Filter, stderr: bool) -> AppLogger { + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + } + + /// Rotate the logs in `dir` and send records to the fresh file; returns its path. + /// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]). + pub fn attach_dir(&self, dir: &Path) -> Result { + match rotate(dir) { + Ok((path, file)) => { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file); + Ok(path) + } + Err(e) => { + self.stderr_only(); + Err(format!("{}: {e}", dir.display())) + } + } + } + + /// No log file this run (no settings directory, or it can't be used): stop keeping records for one. + pub fn stderr_only(&self) { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).close(); + } +} + +impl log::Log for AppLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + // Format before taking the lock, so the lock covers one write only. + let thread = std::thread::current(); + let line = + format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + if self.stderr { + let _ = std::io::stderr().write_all(line.as_bytes()); + } + self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line); + } + + fn flush(&self) {} +} + +/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another +/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known. +pub fn install() -> Option<&'static AppLogger> { + static LOGGER: OnceLock = OnceLock::new(); + let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty()); + let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER)); + let max = filter.max(); + let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true)); + log::set_logger(logger).ok()?; + log::set_max_level(max); + Some(logger) +} + +#[cfg(test)] +mod tests { + use super::*; + use log::Log; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, UNIX_EPOCH}; + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("cadcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") + } + + fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) { + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + } + + #[test] + fn the_default_filter_shows_cadcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("cadcraft"), LevelFilter::Info); + assert_eq!(f.level_for("cadcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("cadcraft_ui_egui::gpu"), LevelFilter::Info); + assert_eq!(f.level_for("cadcraft_engine"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + } + + #[test] + fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,cadcraft_io=trace,cadcraft_io::dxf_ext=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("cadcraft_io::svg"), LevelFilter::Trace); + assert_eq!(f.level_for("cadcraft_io::dxf_ext"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("warn,cadcraft_ui*=debug").level_for("cadcraft_ui_egui::gpu"), LevelFilter::Debug); + assert_eq!(Filter::parse("warn,cadcraft_ui*=debug").level_for("cadcraft_engine"), LevelFilter::Warn); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive; the last bare level wins. + assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn); + } + + #[test] + fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "a".repeat(100_000); + let specs = [ + "", + ",,,", + "=", + "==", + "=debug", + "cadcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=debug", + "a=b=c", + "warn,,cadcraft=DEBUG", + " \t ", + "\u{0}", + "é*=trace", + &long, + ]; + for spec in specs { + let f = Filter::parse(spec); + for target in ["cadcraft", "", "🦀", "é", "a::b"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,cadcraft=DEBUG").level_for("cadcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped"); + assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target"); + assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 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. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("cadcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("cadcraft.2.log")), "run 2"); + assert!(!dir.join("cadcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + assert!(rotate(&dir.join("below")).is_err()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"), + "2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n" + ); + // A multi-line message keeps its lines; the record still ends in exactly one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn exactly_max_pending_lines_drop_nothing() { + let dir = temp_dir("exact"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "cadcraft", "CADCraft starting"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Debug, "cadcraft_engine", "a command-line echo"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("cadcraft: CADCraft starting")), "{text}"); + assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("cadcraft_ui_egui::gpu").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // A second launch rotates: the first log becomes `.1`. + let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE)); + assert_eq!(read(&dir.join("cadcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = AppLogger::new(Filter::parse("info"), false); + let path = logger.attach_dir(&dir).expect("attach"); + std::thread::scope(|s| { + for _ in 0..4 { + s.spawn(|| { + for _ in 0..100 { + record(&logger, log::Level::Info, "cadcraft", "from a worker"); + } + }); + } + }); + let text = read(&path); + assert_eq!(text.lines().count(), 400); + assert!(text.lines().all(|l| l.ends_with("cadcraft: from a worker")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + let e = logger.attach_dir(&dir).expect_err("a file is not a log directory"); + assert!(e.contains(&dir.display().to_string()), "{e}"); + record(&logger, log::Level::Error, "x", "still fine"); + let sink = logger.sink.lock().expect("sink"); + assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come"); + drop(sink); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn stderr_only_stops_keeping_records_for_a_file() { + let logger = AppLogger::new(Filter::parse("info"), false); + record(&logger, log::Level::Info, "cadcraft", "before"); + logger.stderr_only(); + record(&logger, log::Level::Info, "cadcraft", "after"); + let sink = logger.sink.lock().expect("sink"); + assert!(sink.file.is_none() && sink.pending.is_empty(), "no memory is held for a file that will not come"); + } + + #[test] + fn a_poisoned_lock_does_not_stop_logging() { + let dir = temp_dir("poison"); + let logger = AppLogger::new(Filter::parse("info"), false); + let path = logger.attach_dir(&dir).expect("attach"); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = logger.sink.lock(); + panic!("poison the sink"); + })); + assert!(logger.sink.is_poisoned()); + record(&logger, log::Level::Info, "cadcraft", "after the panic"); + assert!(read(&path).contains("after the panic")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index e3539844..7662f972 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -8,12 +8,16 @@ //! //! `CADCRAFT_VSYNC=1` presents with vsync on Linux/BSD, where the default is the low-latency //! present mode (`CADCRAFT_VSYNC=0` selects that elsewhere); see `cadcraft_ui_egui::gpu::surface_config`. +//! +//! `log` records go to standard error and `/logs/cadcraft.log` (`RUST_LOG` sets the +//! levels); see [`logging`] and [`config_dir`]. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; #[cfg(any(target_os = "windows", test))] mod graphics; +mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -85,6 +89,30 @@ fn services() -> Services { } } +/// The per-user settings directory (today it holds `logs/`): `CADCRAFT_CONFIG_DIR` if set, else +/// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs, +/// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows. +fn config_dir() -> Option { + config_dir_from(|k| std::env::var_os(k)) +} + +/// [`config_dir`] with the environment passed in (`var` looks a variable up). An empty variable +/// counts as unset, so a blank value never means the working directory. +fn config_dir_from(var: impl Fn(&str) -> Option) -> Option { + let set = |k: &str| var(k).filter(|v| !v.is_empty()).map(std::path::PathBuf::from); + if let Some(dir) = set("CADCRAFT_CONFIG_DIR") { + return Some(dir); + } + if cfg!(target_os = "macos") { + set("HOME").map(|h| h.join("Library/Application Support/CADCraft")) + } else if cfg!(windows) { + set("APPDATA").map(|a| a.join("CADCraft")) + } else { + // The XDG spec ignores a relative XDG_CONFIG_HOME. + set("XDG_CONFIG_HOME").filter(|p| p.is_absolute()).or_else(|| set("HOME").map(|h| h.join(".config"))).map(|c| c.join("cadcraft")) + } +} + const APP_ID: &str = "ai.storyteller.cadcraft"; #[cfg(target_os = "macos")] @@ -101,14 +129,29 @@ fn version_string() -> String { format!("cadcraft {} ({sha})", env!("CARGO_PKG_VERSION")) } +/// The control port from `--control ` or `CADCRAFT_CONTROL_PORT` (`source`); a value that is +/// not a port is logged and ignored instead of silently starting no control channel. +fn control_port_from(source: &str, value: Option) -> Option { + let value = value?; + let port = value.trim().parse().ok(); + if port.is_none() { + log::warn!("{source}: {value:?} is not a port number; the control channel is off"); + } + port +} + fn main() -> eframe::Result { - let mut control_port: Option = std::env::var("CADCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + // First, so every start-up record is captured; see `logging`. + let logger = logging::install(); + // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored + let mut control_port = + control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned())); let mut files = Vec::new(); let mut sample = false; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--control" => control_port = control_port_from("--control", args.next()), "--sample" => sample = true, "--version" | "-V" => { println!("{}", version_string()); @@ -117,6 +160,18 @@ fn main() -> eframe::Result { _ => files.push(a), } } + // The log file lives under the settings directory; opened after the arguments, so `--version` + // leaves no file behind. Records logged until now are written to it first. + if let Some(logger) = logger { + match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) { + Some(Ok(path)) => log::info!("{} ({} {}), log file {}", version_string(), std::env::consts::OS, std::env::consts::ARCH, path.display()), + Some(Err(e)) => log::warn!("no log file: {e}"), + None => { + logger.stderr_only(); + log::warn!("no log file: no settings directory (set CADCRAFT_CONFIG_DIR)"); + } + } + } install_io(); let surface = cadcraft_ui_egui::gpu::surface_config(std::env::var("CADCRAFT_VSYNC").ok().as_deref(), cfg!(all(unix, not(target_os = "macos")))); let mut options = eframe::NativeOptions { @@ -141,6 +196,16 @@ fn main() -> eframe::Result { // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). #[cfg(target_os = "windows")] graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file + // manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1 + // keeps the native Wayland backend. + #[cfg(all(unix, not(target_os = "macos")))] + if std::env::var_os("DISPLAY").is_some() && std::env::var_os("CADCRAFT_WAYLAND").is_none() { + options.event_loop_builder = Some(Box::new(|b| { + use winit::platform::x11::EventLoopBuilderExtX11; + b.with_x11(); + })); + } eframe::run_native( "CADCraft", options, @@ -177,3 +242,57 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + use super::{config_dir_from, control_port_from}; + use std::ffi::OsString; + use std::path::PathBuf; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v)) + } + + #[test] + fn control_port_values_that_are_not_ports_are_ignored() { + assert_eq!(control_port_from("--control", Some("7979".into())), Some(7979)); + assert_eq!(control_port_from("--control", Some(" 7979 ".into())), Some(7979)); + assert_eq!(control_port_from("--control", None), None); + for bad in ["", "abc", "-1", "65536", "99999999999999999999", "\u{0}"] { + assert_eq!(control_port_from("--control", Some(bad.into())), None, "{bad:?}"); + } + } + + #[test] + fn the_override_variable_wins_and_empty_values_are_unset() { + let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")]; + assert_eq!(config_dir_from(env(&vars)), Some(PathBuf::from("/tmp/cc"))); + let empty = [("CADCRAFT_CONFIG_DIR", ""), ("HOME", ""), ("XDG_CONFIG_HOME", ""), ("APPDATA", "")]; + assert_eq!(config_dir_from(env(&empty)), None, "no usable variable means no directory, not the working directory"); + assert_eq!(config_dir_from(env(&[])), None); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_and_bsd_follow_xdg_config_home() { + assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "/x"), ("HOME", "/home/u")])), Some(PathBuf::from("/x/cadcraft"))); + assert_eq!(config_dir_from(env(&[("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft"))); + // The XDG spec says a relative XDG_CONFIG_HOME is ignored. + assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "rel"), ("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft"))); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_uses_application_support() { + assert_eq!(config_dir_from(env(&[("HOME", "/Users/u")])), Some(PathBuf::from("/Users/u/Library/Application Support/CADCraft"))); + } + + #[cfg(windows)] + #[test] + fn windows_uses_appdata() { + assert_eq!( + config_dir_from(env(&[("APPDATA", "C:\\Users\\u\\AppData\\Roaming")])), + Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft")) + ); + } +} diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db20..8ef40d37 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,7 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.Tru10] +display_name = "by Truitt" +sync_github_name = false diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 0bea1cff..a12847df 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -444,19 +444,40 @@ impl Interactive for LineM { struct PlineM { verts: Vec, arc_mode: bool, - width: f64, + /// Total width at the start and end of the next segment (Width / Halfwidth answers). + start_w: f64, + end_w: f64, handle: Option, + /// 1 = asking the starting width, 2 = the ending width. asking_width: u8, + /// The widths are being asked as half-widths (centre line to edge). + half: bool, } impl PlineM { fn entity(&self, closed: bool) -> EntityKind { let mut k = lwpoly(self.verts.clone(), closed); if let EntityKind::LwPolyline(pl) = &mut k { - pl.const_width = self.width; + // Every segment the same uniform width: store it as the constant width, as before. + let segs = if closed { pl.vertices.len() } else { pl.vertices.len().saturating_sub(1) }; + let w = pl.vertices.first().map_or(0.0, |v| v.start_width); + if pl.vertices.iter().take(segs).all(|v| v.start_width == w && v.end_width == w) { + pl.const_width = w; + for v in &mut pl.vertices { + (v.start_width, v.end_width) = (0.0, 0.0); + } + } } k } + /// The next segment, starting at the last vertex, takes the requested widths (#104); the + /// segments after it are uniform at the ending width, as in AutoCAD. + fn begin_segment(&mut self) { + if let Some(last) = self.verts.last_mut() { + (last.start_width, last.end_width) = (self.start_w, self.end_w); + self.start_w = self.end_w; + } + } fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> { if self.verts.len() < 2 { if let Some(h) = self.handle.take() { @@ -502,8 +523,10 @@ impl Interactive for PlineM { } fn prompt(&self, _s: &Session) -> Prompt { if self.asking_width > 0 { - return Prompt::new(if self.asking_width == 1 { "Specify starting width" } else { "Specify ending width" }, Accept::NUMBER) - .default(format!("{:.4}", self.width)); + let unit = if self.half { "half-width" } else { "width" }; + let (which, w) = if self.asking_width == 1 { ("starting", self.start_w) } else { ("ending", self.end_w) }; + let shown = if self.half { w / 2.0 } else { w }; + return Prompt::new(format!("Specify {which} {unit}"), Accept::NUMBER).default(format!("{shown:.4}")); } let base = self.verts.last().map(|v| v.p); match (self.verts.len(), self.arc_mode) { @@ -524,20 +547,31 @@ impl Interactive for PlineM { if self.asking_width > 0 { match i { Input::Text(t) => { - let w = number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; - self.width = w.max(0.0); + let w = number(&t).filter(|w| w.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; + // Halfwidth is centre line to edge: the total width is twice the answer (#83). + let w = w.max(0.0) * if self.half { 2.0 } else { 1.0 }; + if self.asking_width == 1 { + self.start_w = w; + } else { + self.end_w = w; + } } Input::Enter => {} _ => return Ok(Step::Continue), } + if self.asking_width == 1 { + // The ending width defaults to the starting width. + self.end_w = self.start_w; + } self.asking_width = if self.asking_width == 1 { 2 } else { 0 }; return Ok(Step::Continue); } match i { Input::Point(p) => { if self.verts.is_empty() { - s.echo(format!("Current line-width is {:.4}", self.width)); + s.echo(format!("Current line-width is {:.4}", self.start_w)); } + self.begin_segment(); if self.arc_mode && !self.verts.is_empty() { let b = self.tangent_bulge(p); if let Some(last) = self.verts.last_mut() { @@ -566,6 +600,8 @@ impl Interactive for PlineM { last.bulge = b; } } + // The closing segment starts at the last vertex. + self.begin_segment(); self.sync(s, true)?; Ok(Step::Done) } @@ -573,11 +609,13 @@ impl Interactive for PlineM { self.verts.pop(); if let Some(l) = self.verts.last_mut() { l.bulge = 0.0; + (l.start_width, l.end_width) = (0.0, 0.0); } self.sync(s, false)?; Ok(Step::Continue) } "Width" | "Halfwidth" => { + self.half = k == "Halfwidth"; self.asking_width = 1; Ok(Step::Continue) } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 5a3994b0..78798415 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -24,6 +24,8 @@ const SESSION_VARS: &[&str] = &[ "GRIPSIZE", "CURSORSIZE", "LASTPOINT", + "FONTALT", + "FONTFALLBACK", ]; pub fn get(s: &Session, name: &str) -> Option { @@ -49,6 +51,9 @@ pub fn get(s: &Session, name: &str) -> Option { "GRIPSIZE" => json!(st.gripsize), "CURSORSIZE" => json!(st.cursorsize), "LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]), + // Process-wide font substitution (profile settings, not saved in the drawing). + "FONTALT" => json!(cadcraft_fonts::ttf::font_alt()), + "FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()), "CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()), "DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()), "DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)), @@ -116,6 +121,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?, "GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0), "CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0), + "FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?), + "FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?), _ => { let d = s.doc_mut()?; let val = match v { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0abd994a..65dd97f7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -449,6 +449,13 @@ fn sysvars_roundtrip() { assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 2.5); assert!(s.execute("setvar", &json!({"name": "ORTHOMODE", "value": "x"})).is_err()); assert_eq!(sysvars::get(&s, "orthomode"), Some(json!(0))); + // FONTALT is a process-wide font setting, not a drawing header variable. + s.execute("setvar", &json!({"name": "FONTALT", "value": "no-such-font-130"})).unwrap(); + assert_eq!(sysvars::get(&s, "fontalt"), Some(json!("no-such-font-130"))); + assert!(s.doc().unwrap().header.get("FONTALT").is_none()); + assert!(s.execute("setvar", &json!({"name": "FONTALT", "value": 3})).is_err()); + s.execute("setvar", &json!({"name": "FONTALT", "value": "."})).unwrap(); + assert_eq!(sysvars::get(&s, "FONTALT"), Some(json!(""))); } #[test] @@ -933,3 +940,57 @@ fn arc_refuses_zero_radius() { other => panic!("{other:?}"), } } + +/// The single polyline in a fresh drawing after typing `script`, then pressing Enter. +fn typed_pline(script: &str) -> cadcraft_doc::LwPolyline { + let mut s = Session::new(); + for line in script.split('\n') { + s.cmdline(line).unwrap(); + } + s.cmdline("").unwrap(); + let d = s.doc().unwrap(); + let mut it = d.model.iter(); + let pl = match &it.next().unwrap().kind { + EntityKind::LwPolyline(p) => p.clone(), + other => panic!("{other:?}"), + }; + assert!(it.next().is_none(), "one polyline"); + pl +} + +/// (const_width, [(start_width, end_width) per vertex]). +fn widths(p: &cadcraft_doc::LwPolyline) -> (f64, Vec<(f64, f64)>) { + (p.const_width, p.vertices.iter().map(|v| (v.start_width, v.end_width)).collect()) +} + +#[test] +fn pline_halfwidth_is_centre_to_edge() { + // #83: half-width 0.5 at both prompts is a band 1.0 wide; Width keeps the total width. + assert_eq!(typed_pline("pline 0,0 h 0.5 0.5 10,0").const_width, 1.0); + assert_eq!(typed_pline("pline 0,0 w 0.5 0.5 10,0").const_width, 0.5); + assert_eq!(typed_pline("pline 0,0 w 1 1 10,0").const_width, 1.0); + // The ending half-width defaults to the starting one. + assert_eq!(typed_pline("pline 0,0 h 0.5\n\n10,0").const_width, 1.0); + // A half-width taper. + assert_eq!(widths(&typed_pline("pline 0,0 h 0.5 0.1 10,0")), (0.0, vec![(1.0, 0.2), (0.0, 0.0)])); +} + +#[test] +fn pline_width_answers_taper_the_next_segment() { + // #104: the starting and ending widths are the segment's own, not one constant width. + assert_eq!(widths(&typed_pline("pline 0,0 w 1 0.2 10,0")), (0.0, vec![(1.0, 0.2), (0.0, 0.0)])); + assert_eq!(widths(&typed_pline("pline 0,0 w 0.2 1 10,0")), (0.0, vec![(0.2, 1.0), (0.0, 0.0)])); + // Equal answers, or Enter at the ending prompt, stay one constant width. + assert_eq!(widths(&typed_pline("pline 0,0 w 1 1 10,0")), (1.0, vec![(0.0, 0.0), (0.0, 0.0)])); + assert_eq!(widths(&typed_pline("pline 0,0 w 1\n\n10,0")), (1.0, vec![(0.0, 0.0), (0.0, 0.0)])); + // After a taper, later segments are uniform at the ending width. + let p = typed_pline("pline 0,0 w 1 0.2 10,0 20,0 30,0"); + assert_eq!(widths(&p), (0.0, vec![(1.0, 0.2), (0.2, 0.2), (0.2, 0.2), (0.0, 0.0)])); + // A width set mid-polyline applies from that segment on; the closing segment takes it too. + let p = typed_pline("pline 0,0 10,0 w 2 2 10,10 c"); + assert!(p.closed); + assert_eq!(widths(&p), (0.0, vec![(0.0, 0.0), (2.0, 2.0), (2.0, 2.0)])); + // Undo drops the taper with its segment. + let p = typed_pline("pline 0,0 10,0 w 1 0.2 20,0 u"); + assert_eq!(widths(&p), (0.0, vec![(0.0, 0.0), (0.0, 0.0)])); +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a8bd1690..aeb32063 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -1,9 +1,10 @@ //! CADCraft text layout. //! //! Turns TEXT and MTEXT into stroke polylines with the built-in single-stroke font -//! ("CADCraft Stroke"). Handles the `%%` control codes, alignment modes, width factor, -//! obliquing, MTEXT inline formatting (paragraphs, stacking, height changes), word wrap and -//! attachment points. +//! ("CADCraft Stroke") or glyph outlines of installed TrueType fonts ([`ttf`]), with a +//! per-character fallback to installed fonts for characters the chosen font lacks. Handles the +//! `%%` control codes, alignment modes, width factor, obliquing, MTEXT inline formatting +//! (paragraphs, stacking, height changes), word wrap and attachment points. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -11,8 +12,6 @@ mod mtext; mod stroke; pub mod ttf; -use std::sync::Arc; - use cadcraft_geom::{Bounds2, Vec2}; pub use mtext::{MTextColor, MTextLayout, MTextParams, MTextPiece, layout_mtext, layout_mtext_with, plain_mtext}; @@ -78,43 +77,66 @@ pub fn char_advance(c: char) -> f64 { } } -/// Lay out a single line at `height`, with `width_factor` and `oblique` (radians). -pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run { +/// Advance of `c` in the stroke font at height `h` and width factor `wf`; characters the +/// stroke font lacks take a fallback font's advance when one has them. +fn stroke_advance(c: char, h: f64, wf: f64) -> f64 { + if stroke::glyph(c).is_none() + && !c.is_whitespace() + && let Some(g) = ttf::fallback_glyph(c) + { + return g.advance * h / g.cap * wf; + } + char_advance(c) * h * wf +} + +/// Shape a single line in the stroke font at `height`, with `width_factor` and `oblique` +/// (radians). Characters the stroke font lacks are drawn from an installed font that has them +/// (glyph outlines, sized like TrueType text), else as a missing-glyph box. +pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; let sc = h / stroke::CAP; let shear = oblique.tan().clamp(-10.0, 10.0); - let mut run = Run::default(); + let mut out = Shaped::default(); let mut x = 0.0; let mut under_start: Option = None; let mut over_start: Option = None; for (c, under, over) in decode_controls(s) { - let adv = char_advance(c) * h * wf; + let mut adv = char_advance(c) * h * wf; match stroke::glyph(c) { Some((_, spec)) => { for st in stroke::strokes(spec) { let pts: Vec = st.iter().map(|(gx, gy)| Vec2::new(x + (gx * sc * wf) + gy * sc * shear, gy * sc)).collect(); if pts.len() == 1 { if let Some(p) = pts.first() { - run.strokes.push(vec![*p, *p + Vec2::new(sc * 0.2, 0.0)]); + out.strokes.push(vec![*p, *p + Vec2::new(sc * 0.2, 0.0)]); } } else { - run.strokes.push(pts); + out.strokes.push(pts); } } } - None if !c.is_whitespace() => { - // Unknown glyph: a small box, like a missing-glyph rectangle. - let w = 4.0 * sc * wf; - run.strokes.push(vec![Vec2::new(x, 0.0), Vec2::new(x + w, 0.0), Vec2::new(x + w, h), Vec2::new(x, h), Vec2::new(x, 0.0)]); - } + None if !c.is_whitespace() => match ttf::fallback_glyph(c) { + Some(g) => { + let contours = ttf::place_glyph(&g, x, h, wf, shear); + if !contours.is_empty() { + out.glyphs.push(contours); + } + adv = g.advance * h / g.cap * wf; + } + None => { + // Unknown glyph: a small box, like a missing-glyph rectangle. + let w = 4.0 * sc * wf; + out.strokes.push(vec![Vec2::new(x, 0.0), Vec2::new(x + w, 0.0), Vec2::new(x + w, h), Vec2::new(x, h), Vec2::new(x, 0.0)]); + } + }, None => {} } // Decorations. match (under, under_start) { (true, None) => under_start = Some(x), (false, Some(sx)) => { - run.strokes.push(vec![Vec2::new(sx, -h * 0.2), Vec2::new(x, -h * 0.2)]); + out.strokes.push(vec![Vec2::new(sx, -h * 0.2), Vec2::new(x, -h * 0.2)]); under_start = None; } _ => {} @@ -122,7 +144,7 @@ pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run match (over, over_start) { (true, None) => over_start = Some(x), (false, Some(sx)) => { - run.strokes.push(vec![Vec2::new(sx, h * 1.2), Vec2::new(x, h * 1.2)]); + out.strokes.push(vec![Vec2::new(sx, h * 1.2), Vec2::new(x, h * 1.2)]); over_start = None; } _ => {} @@ -131,34 +153,52 @@ pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run } let end = (x - stroke::GAP * sc * wf).max(0.0); if let Some(sx) = under_start { - run.strokes.push(vec![Vec2::new(sx, -h * 0.2), Vec2::new(end, -h * 0.2)]); + out.strokes.push(vec![Vec2::new(sx, -h * 0.2), Vec2::new(end, -h * 0.2)]); } if let Some(sx) = over_start { - run.strokes.push(vec![Vec2::new(sx, h * 1.2), Vec2::new(end, h * 1.2)]); + out.strokes.push(vec![Vec2::new(sx, h * 1.2), Vec2::new(end, h * 1.2)]); } - run.width = end; - run + out.width = end; + out +} + +/// Lay out a single line at `height`, with `width_factor` and `oblique` (radians), in the +/// stroke font (fallback glyphs become closed outlines). +pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run { + let sh = shape_stroke(s, height, width_factor, oblique); + let mut strokes = sh.strokes; + strokes.extend(sh.glyphs.into_iter().flatten()); + Run { strokes, width: sh.width } } /// Width of a line without building strokes. pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 { - let n: f64 = decode_controls(s).iter().map(|(c, _, _)| char_advance(*c)).sum(); - (n * height * width_factor - stroke::GAP / stroke::CAP * height * width_factor).max(0.0) + let n: f64 = decode_controls(s).iter().map(|(c, _, _)| stroke_advance(*c, height, width_factor)).sum(); + (n - stroke::GAP / stroke::CAP * height * width_factor).max(0.0) } /// The font a piece of text is set in: the built-in stroke font or an installed TrueType / -/// OpenType font (outlines). +/// OpenType face (outlines). Either way, characters the font lacks come from a fallback font. #[derive(Clone, Debug, Default)] pub enum TextFont { #[default] Stroke, - Outline(Arc>), + Outline(ttf::Face), } impl TextFont { - /// Resolve a text style font name: an installed TTF/OTF when found, else the stroke font. + /// Resolve a text style font name: an installed TTF/OTF/TTC when found; else, for a font we + /// can't find (an SHX file, a missing TTF), the FONTALT font ([`ttf::font_alt`]) when set; + /// else the stroke font. pub fn resolve(name: &str) -> TextFont { - ttf::find(name).map(TextFont::Outline).unwrap_or(TextFont::Stroke) + if let Some(f) = ttf::find(name) { + return TextFont::Outline(f); + } + let n = name.trim(); + if n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT) { + return TextFont::Stroke; + } + ttf::find(&ttf::font_alt()).map(TextFont::Outline).unwrap_or(TextFont::Stroke) } pub fn is_outline(&self) -> bool { matches!(self, TextFont::Outline(_)) @@ -237,19 +277,18 @@ pub(crate) fn decorations(spans: &[(f64, f64, bool, bool)], h: f64) -> Vec Shaped { - if let TextFont::Outline(bytes) = font - && let Some(sh) = ttf::shape(bytes, s, height, width_factor, oblique) + if let TextFont::Outline(face) = font + && let Some(sh) = ttf::shape(face, s, height, width_factor, oblique) { return sh; } - let run = layout_line(s, height, width_factor, oblique); - Shaped { strokes: run.strokes, glyphs: Vec::new(), width: run.width } + shape_stroke(s, height, width_factor, oblique) } /// Width of one line in `font`. pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f64 { - if let TextFont::Outline(bytes) = font - && let Some(w) = ttf::width(bytes, s, height, width_factor) + if let TextFont::Outline(face) = font + && let Some(w) = ttf::width(face, s, height, width_factor) { return w; } @@ -451,10 +490,10 @@ 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 { + let Some(face) = ["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(); + let run = crate::ttf::layout_line(&face, "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); @@ -465,6 +504,74 @@ mod ttf_tests { 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()); + let junk = crate::ttf::Face { bytes: std::sync::Arc::new(b"not a font".to_vec()), index: 0 }; + assert!(crate::ttf::layout_line(&junk, "x", 1.0, 1.0, 0.0).is_none()); + } + + #[test] + fn fallback_ranking_prefers_wide_coverage_regular_fonts() { + let stems: Vec = + ["zapfino", "dejavusans", "notosanscjk-bold", "notosanscjk-regular", "wqy-zenhei", "notosansarabic-regular", "arial"] + .map(String::from) + .to_vec(); + let r = crate::ttf::rank_fallback(&stems); + assert_eq!(r.first().map(String::as_str), Some("notosanscjk-regular")); + assert_eq!(r.get(1).map(String::as_str), Some("notosanscjk-bold")); + assert_eq!(r.get(2).map(String::as_str), Some("wqy-zenhei")); + assert_eq!(r.last().map(String::as_str), Some("zapfino")); + assert_eq!(r.len(), stems.len()); + } + + #[test] + fn missing_glyph_without_fallback_draws_a_box() { + // Private-use code points never take a fallback font: the stroke font's box, no panic. + let sh = crate::shape_stroke("A\u{E000}", 1.0, 1.0, 0.0); + let sh2 = crate::shape_stroke("A\u{E000}B", 1.0, 1.0, 0.0); + assert!(sh.glyphs.is_empty() && sh2.glyphs.is_empty()); + let boxed = sh.strokes.iter().any(|s| s.len() == 5 && s.first() == s.last() && s.iter().any(|p| (p.y - 1.0).abs() < 1e-9)); + assert!(boxed, "missing-glyph box"); + assert!((crate::line_width("A\u{E000}B", 1.0, 1.0) - sh2.width).abs() < 1e-9); + } + + #[test] + fn hostile_collections_never_panic() { + // A TTC header claiming many faces with bogus or truncated offsets. + let mut ttc = b"ttcf\x00\x01\x00\x00\x7f\xff\xff\xff".to_vec(); + ttc.extend_from_slice(&[0xff; 16]); + for bytes in [ttc.clone(), ttc[..10].to_vec(), b"ttcf".to_vec(), Vec::new()] { + let arc = std::sync::Arc::new(bytes); + assert!(crate::ttf::faces(&arc).is_empty()); + assert!(crate::ttf::Face::new(arc.clone(), 3).is_none()); + assert!(!crate::ttf::Face { bytes: arc, index: u32::MAX }.has_glyph('a')); + } + crate::ttf::register("cadcraft-hostile-test.ttc", ttc); + assert!(crate::ttf::find("cadcraft-hostile-test.ttc").is_none()); + let _ = crate::ttf::fallback_for('\u{4E2D}'); + } + + #[test] + fn font_alt_that_is_not_installed_keeps_the_stroke_font() { + crate::ttf::set_fallback_fonts(" no-such-font-130 ; . ,other-missing "); + assert_eq!(crate::ttf::fallback_fonts(), "no-such-font-130, other-missing"); + crate::ttf::set_fallback_fonts(""); + crate::ttf::set_font_alt("no-such-font-130"); + assert_eq!(crate::ttf::font_alt(), "no-such-font-130"); + assert!(!crate::TextFont::resolve("romans.shx").is_outline()); + crate::ttf::set_font_alt("."); + assert_eq!(crate::ttf::font_alt(), ""); + } + + #[test] + fn cjk_falls_back_to_an_installed_font_when_available() { + // Skips cleanly when no installed font covers CJK. + if crate::ttf::fallback_for('\u{4E2D}').is_none() { + return; + } + let sh = crate::shape_stroke("AB\u{4E2D}\u{6587}", 1.0, 1.0, 0.0); + assert_eq!(sh.glyphs.len(), 2, "two fallback glyphs"); + assert!(!sh.strokes.iter().any(|s| s.len() == 5 && s.first() == s.last()), "no missing-glyph boxes"); + let maxy = sh.glyphs.iter().flatten().flatten().map(|p| p.y).fold(f64::MIN, f64::max); + assert!(maxy > 0.8 && maxy < 1.8, "sized like TrueType text (cap height = text height), got {maxy}"); + assert!((crate::line_width("AB\u{4E2D}\u{6587}", 1.0, 1.0) - sh.width).abs() < 1e-9); } } diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index eea30eb9..1fabdf66 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -207,8 +207,8 @@ fn font_switch(a: &str) -> Option { }; if let Some(st) = style { for cand in [format!("{name} {st}"), format!("{name}-{}", st.replace(' ', "")), format!("{name}{}", if bold { "bd" } else { "i" })] { - if let Some(b) = crate::ttf::find(&cand) { - return Some(TextFont::Outline(b)); + if let Some(f) = crate::ttf::find(&cand) { + return Some(TextFont::Outline(f)); } } } diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index ca60178f..69358782 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -1,40 +1,175 @@ //! TrueType/OpenType text: system fonts found at run time (never bundled), glyph outlines as //! closed polylines in CAD text units (text height = cap height). +//! +//! Fonts are found by file stem (`arial.ttf`, `msjh.ttc`) and, failing that, by family name +//! (`Noto Sans CJK TC`) among installed files with a similar name. TrueType collections +//! (`.ttc` / `.otc`) are scanned too; a stem lookup takes their first face. +//! +//! Two process-wide settings (like AutoCAD's profile variables) control substitution: +//! - [`font_alt`] (`FONTALT`): the font used for a text style font that can't be found (an SHX +//! file we don't read, a missing TTF). Empty = the built-in stroke font. +//! - [`fallback_fonts`] (`FONTFALLBACK`): fonts tried first for characters the text's font +//! lacks (CJK in an SHX big font, for example), before the automatic system font search. +//! +//! On native builds both start from the `CADCRAFT_FONTALT` / `CADCRAFT_FONTFALLBACK` +//! environment variables. use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; use cadcraft_geom::Vec2; use skrifa::instance::{LocationRef, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::FileRef; use skrifa::{FontRef, MetadataProvider}; use crate::{Run, Shaped}; -/// Font bytes by lower-case family/file stem. -type Db = HashMap>>; +/// One face of a font file (a TrueType collection holds several). +#[derive(Clone)] +pub struct Face { + pub bytes: Arc>, + /// Face index inside a collection (0 for a plain font file). + pub index: u32, +} -fn db() -> &'static Mutex> { - static DB: OnceLock>> = OnceLock::new(); - DB.get_or_init(|| Mutex::new(None)) +impl std::fmt::Debug for Face { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Face").field("len", &self.bytes.len()).field("index", &self.index).finish() + } +} + +impl Face { + /// Face `index` of font file `bytes`, if it parses. + pub fn new(bytes: Arc>, index: u32) -> Option { + FontRef::from_index(&bytes, index).ok()?; + Some(Face { bytes, index }) + } + fn font(&self) -> Option> { + FontRef::from_index(&self.bytes, self.index).ok() + } + /// Whether the face maps `c` to a glyph. + pub fn has_glyph(&self, c: char) -> bool { + self.font().is_some_and(|f| f.charmap().map(c).is_some()) + } + fn names(&self) -> Vec { + let Some(f) = self.font() else { return Vec::new() }; + [skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME] + .into_iter() + .flat_map(|id| f.localized_strings(id).map(|s| norm(&s.to_string())).collect::>()) + .collect() + } +} + +/// Faces of a font file: every face of a collection (capped), or the single font. +pub fn faces(bytes: &Arc>) -> Vec { + match FileRef::new(bytes) { + Ok(FileRef::Collection(c)) => (0..c.len().min(MAX_FACES_PER_FILE)).filter_map(|i| Face::new(bytes.clone(), i)).collect(), + Ok(FileRef::Font(_)) => vec![Face { bytes: bytes.clone(), index: 0 }], + Err(_) => Vec::new(), + } +} + +const MAX_FACES_PER_FILE: u32 = 32; +/// Bounds of the missing-glyph search: files loaded, bytes loaded, faces checked per character. +const MAX_FALLBACK_FILES: usize = 24; +const MAX_FALLBACK_BYTES: usize = 160 << 20; +const MAX_FALLBACK_FACES: usize = 256; +/// Files loaded by one family-name lookup. +const MAX_FAMILY_FILES: usize = 6; +/// Total bytes of system font files loaded from disk: a drawing naming many styles can't pull +/// every installed font into memory. +const MAX_LOADED_BYTES: usize = 256 << 20; +const MAX_CACHED_NAMES: usize = 4096; +const MAX_CACHED_CHARS: usize = 50_000; + +/// Fonts known to cover many scripts (CJK first), tried first for missing glyphs. Matched as +/// substrings of the normalised file stem. +const PREFERRED_FALLBACK: &[&str] = &[ + "notosanscjk", + "sourcehansans", + "notoserifcjk", + "sourcehanserif", + "wqyzenhei", + "wqymicrohei", + "droidsansfallback", + "pingfang", + "hiraginosans", + "stheiti", + "msjh", + "msyh", + "yugoth", + "msgothic", + "malgun", + "simsun", + "mingliu", + "arialuni", + "notosans", + "dejavusans", + "segoeui", + "arial", +]; +const WEIGHT_WORDS: &[&str] = &["thin", "light", "medium", "semibold", "bold", "black", "heavy", "italic", "oblique", "mono", "condensed"]; + +#[derive(Default)] +struct Db { + scanned: bool, + /// Font files found by the scan, by lower-case file stem. + paths: HashMap, + /// Loaded or registered font file bytes, by lower-case file stem. + files: HashMap>>, + /// Name lookups already answered, found or not. + resolved: HashMap>, + fallback: Fallback, + /// Bytes of font files loaded from disk so far (bounded by `MAX_LOADED_BYTES`). + loaded_bytes: usize, +} + +/// State of the missing-glyph search. +#[derive(Default)] +struct Fallback { + /// Candidate file stems, best first (built on first use). + order: Option>, + /// Next candidate in `order` to load. + next: usize, + files: usize, + bytes: usize, + /// Faces of the loaded candidates, in candidate order. + faces: Vec, + /// Character → index in `faces` (None: nothing covers it). + by_char: HashMap>, +} + +fn lock() -> MutexGuard<'static, Db> { + static DB: OnceLock> = OnceLock::new(); + let mut g = DB.get_or_init(|| Mutex::new(Db::default())).lock().unwrap_or_else(PoisonError::into_inner); + if !g.scanned { + g.scanned = true; + g.paths = scan(); + } + g } #[cfg(not(target_arch = "wasm32"))] -fn scan() -> Db { - let mut out = Db::new(); - let mut dirs: Vec = Vec::new(); +fn scan() -> HashMap { + let mut out = HashMap::new(); + let mut dirs: Vec = 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")); + dirs.push(PathBuf::from(h).join("Library/Fonts")); } } else if cfg!(windows) { dirs.push("C:\\Windows\\Fonts".into()); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } } 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")); + dirs.push(PathBuf::from(&h).join(".fonts")); + dirs.push(PathBuf::from(h).join(".local/share/fonts")); } } let mut stack = dirs; @@ -52,79 +187,292 @@ fn scan() -> Db { continue; } let ext = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - if ext != "ttf" && ext != "otf" { + if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") { 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.entry(stem).or_insert(p); } } } out } +#[cfg(target_arch = "wasm32")] +fn scan() -> HashMap { + HashMap::new() +} + #[cfg(not(target_arch = "wasm32"))] -static PATHS: OnceLock>> = OnceLock::new(); +fn read_file(path: &std::path::Path) -> Option> { + /// Font files larger than this are never read. + const MAX_FILE_BYTES: u64 = 96 << 20; + if std::fs::metadata(path).ok()?.len() > MAX_FILE_BYTES { + return None; + } + std::fs::read(path).ok() +} #[cfg(target_arch = "wasm32")] -fn scan() -> Db { - Db::new() +fn read_file(_path: &std::path::Path) -> Option> { + None +} + +impl Db { + /// Bytes of the font file with this stem, loading it on first use. + fn file(&mut self, stem: &str) -> Option>> { + if let Some(b) = self.files.get(stem) { + return Some(b.clone()); + } + if self.loaded_bytes >= MAX_LOADED_BYTES { + return None; + } + let bytes = Arc::new(read_file(self.paths.get(stem)?)?); + self.loaded_bytes = self.loaded_bytes.saturating_add(bytes.len()); + self.files.insert(stem.to_string(), bytes.clone()); + Some(bytes) + } + + fn lookup(&mut self, k: &str) -> Option { + let candidates = [k.to_string(), k.replace(' ', ""), format!("{k} regular"), format!("{}-regular", k.replace(' ', ""))]; + for c in &candidates { + if let Some(face) = self.file(c).and_then(|b| faces(&b).into_iter().next()) { + return Some(face); + } + } + self.by_family(k) + } + + /// A face whose family or full name is `k`, among files with a similar stem. + fn by_family(&mut self, k: &str) -> Option { + let want = norm(k); + if want.len() < 5 { + return None; + } + let mut stems: Vec<(usize, usize, String)> = self + .paths + .keys() + .chain(self.files.keys()) + .map(|s| (weight_penalty(s, &want), common_prefix(&norm(s), &want), s.clone())) + .filter(|(_, p, _)| *p >= 5) + .collect(); + stems.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2))); + stems.dedup_by(|a, b| a.2 == b.2); + for (_, _, stem) in stems.into_iter().take(MAX_FAMILY_FILES) { + let Some(bytes) = self.file(&stem) else { continue }; + if let Some(face) = faces(&bytes).into_iter().find(|f| f.names().iter().any(|n| *n == want || format!("{n}regular") == want)) { + return Some(face); + } + } + None + } + + /// Load the next fallback candidate file. False when none is left or a bound is reached. + fn load_next_fallback(&mut self) -> bool { + if self.fallback.order.is_none() { + let stems: Vec = self.paths.keys().chain(self.files.keys()).cloned().collect(); + self.fallback.order = Some(rank_fallback(&stems)); + } + loop { + if self.fallback.files >= MAX_FALLBACK_FILES || self.fallback.bytes >= MAX_FALLBACK_BYTES { + return false; + } + let Some(stem) = self.fallback.order.as_ref().and_then(|o| o.get(self.fallback.next)).cloned() else { return false }; + self.fallback.next += 1; + let Some(bytes) = self.file(&stem) else { continue }; + self.fallback.files += 1; + self.fallback.bytes = self.fallback.bytes.saturating_add(bytes.len()); + let fs = faces(&bytes); + if !fs.is_empty() { + self.fallback.faces.extend(fs); + return true; + } + } + } + + /// Index in `fallback.faces` of the first face that has `c`. + fn search_fallback(&mut self, c: char) -> Option { + if let Some(r) = self.fallback.by_char.get(&c) { + return *r; + } + let mut found = None; + let mut i = 0; + while i < MAX_FALLBACK_FACES { + if i >= self.fallback.faces.len() && !self.load_next_fallback() { + break; + } + if self.fallback.faces.get(i).is_some_and(|f| f.has_glyph(c)) { + found = Some(i); + break; + } + i += 1; + } + if self.fallback.by_char.len() >= MAX_CACHED_CHARS { + self.fallback.by_char.clear(); + } + self.fallback.by_char.insert(c, found); + found + } +} + +/// Lower-case, without spaces, hyphens and underscores. +fn norm(s: &str) -> String { + s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect() +} + +fn common_prefix(a: &str, b: &str) -> usize { + a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count() +} + +/// Weight / style words in a file name that the wanted name doesn't ask for. +fn weight_penalty(stem: &str, want: &str) -> usize { + let n = norm(stem); + WEIGHT_WORDS.iter().filter(|w| n.contains(*w) && !want.contains(*w)).count() +} + +/// Order font file stems for the missing-glyph search: known wide-coverage (CJK) fonts first, +/// regular weights before bold, light and the like, then by name. +pub(crate) fn rank_fallback(stems: &[String]) -> Vec { + let mut v: Vec<(usize, usize, String)> = stems + .iter() + .map(|s| { + let n = norm(s); + let pref = PREFERRED_FALLBACK.iter().position(|p| n.contains(p)).unwrap_or(PREFERRED_FALLBACK.len()); + (pref, weight_penalty(s, ""), s.clone()) + }) + .collect(); + v.sort(); + v.dedup_by(|a, b| a.2 == b.2); + v.into_iter().map(|(_, _, s)| s).collect() } /// Register font bytes under a name (web builds and tests). pub fn register(name: &str, bytes: Vec) { - 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)); + let mut g = lock(); + g.files.insert(key(name), Arc::new(bytes)); + g.resolved.clear(); + g.fallback = Fallback::default(); } /// 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() + n.trim_end_matches(".ttf").trim_end_matches(".otf").trim_end_matches(".ttc").trim_end_matches(".otc").to_string() } -/// Bytes of a font by name, if installed. Stroke-font names (".shx", empty) return `None`. -pub fn find(name: &str) -> Option>> { +/// Names that mean a stroke font: never looked up as TrueType. +fn is_stroke_name(k: &str) -> bool { + k.is_empty() || k.ends_with(".shx") || k == crate::BUILTIN_FONT.to_ascii_lowercase() || k == "txt" || k == "simplex" || k == "romans" +} + +/// A face of an installed (or registered) font by file name or family name. Stroke-font names +/// (".shx", empty) return `None`. +pub fn find(name: &str) -> Option { 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" { + if is_stroke_name(&k) { 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); - } + let mut g = lock(); + if let Some(r) = g.resolved.get(&k) { + return r.clone(); + } + let r = g.lookup(&k); + if g.resolved.len() >= MAX_CACHED_NAMES { + g.resolved.clear(); + } + g.resolved.insert(k, r.clone()); + r +} + +struct Prefs { + alt: String, + fallback: Vec, +} + +fn prefs() -> MutexGuard<'static, Prefs> { + static P: OnceLock> = OnceLock::new(); + P.get_or_init(|| { + #[cfg(not(target_arch = "wasm32"))] + let env = |k: &str| std::env::var(k).unwrap_or_default(); + #[cfg(target_arch = "wasm32")] + let env = |_: &str| String::new(); + Mutex::new(Prefs { alt: clean_name(&env("CADCRAFT_FONTALT")), fallback: split_list(&env("CADCRAFT_FONTFALLBACK")) }) + }) + .lock() + .unwrap_or_else(PoisonError::into_inner) +} + +/// A font name setting: trimmed, capped, "." = none. +fn clean_name(s: &str) -> String { + let s = s.trim(); + if s == "." { String::new() } else { s.chars().take(255).collect() } +} + +fn split_list(s: &str) -> Vec { + s.split([',', ';']).map(clean_name).filter(|n| !n.is_empty()).take(16).collect() +} + +/// FONTALT: the font used for a text style font that can't be found. Empty = the built-in +/// stroke font. +pub fn font_alt() -> String { + prefs().alt.clone() +} + +/// Set FONTALT ("" or "." clears it). +pub fn set_font_alt(name: &str) { + prefs().alt = clean_name(name); +} + +/// FONTFALLBACK: fonts tried first for characters a text's font lacks, comma-separated. +pub fn fallback_fonts() -> String { + prefs().fallback.join(", ") +} + +/// Set FONTFALLBACK from a comma- or semicolon-separated list ("" or "." clears it). +pub fn set_fallback_fonts(list: &str) { + prefs().fallback = split_list(list); +} + +/// Characters worth a fallback search (not spaces, controls or private-use code points). +fn wants_fallback(c: char) -> bool { + let u = u32::from(c); + !(c.is_whitespace() || c.is_control() || (0xE000..=0xF8FF).contains(&u) || u >= 0xF_0000 || (u & 0xFFFE) == 0xFFFE) +} + +/// A face that has `c`, for text whose own font lacks it: FONTFALLBACK fonts, then FONTALT, +/// then installed fonts (wide-coverage CJK fonts first). Bounded and cached. +pub fn fallback_for(c: char) -> Option { + if !wants_fallback(c) { + return None; + } + let (list, alt) = { + let p = prefs(); + (p.fallback.clone(), p.alt.clone()) + }; + for n in list.iter().chain(std::iter::once(&alt)) { + if let Some(f) = find(n).filter(|f| f.has_glyph(c)) { + return Some(f); } } - None + let mut g = lock(); + let i = g.search_fallback(c)?; + g.fallback.faces.get(i).cloned() } -/// A glyph outline in font units (at 1000 units per em), flattened, plus its advance. -struct GlyphOutline { - contours: Vec>, - advance: f64, +/// A glyph outline in font units (at 1000 units per em), flattened, plus its advance and the +/// font's cap height. +#[derive(Default)] +pub(crate) struct GlyphOutline { + pub(crate) contours: Vec>, + pub(crate) advance: f64, + pub(crate) cap: f64, + /// The font maps the character (false: the missing-glyph shape). + mapped: bool, } -/// Cached glyph outlines keyed by (font bytes address, length, char). Fonts live for the whole -/// process in the font table, so the address identifies them. -type GlyphCache = HashMap<(usize, usize, char), Arc>; +/// Cached glyph outlines keyed by (font bytes address, length, face, char). Fonts live for the +/// whole process in the font table, so the address identifies them. +type GlyphCache = HashMap<(usize, usize, u32, char), Arc>; fn glyph_cache() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); @@ -191,25 +539,36 @@ impl OutlinePen for Pen { } } -/// Cap height of a font in font units at 1000 units per em. +/// Cap height of a font in font units at 1000 units per em: the OS/2 value, else the height +/// of its "H", else an estimate from the ascent. fn cap_height(f: &FontRef) -> f64 { let metrics = f.metrics(Size::new(UPEM), LocationRef::default()); - f64::from(metrics.cap_height.filter(|c| *c > 0.0).unwrap_or(metrics.ascent * 0.72).max(1.0)) + let measured = || { + let gid = f.charmap().map('H')?; + let b = f.glyph_metrics(Size::new(UPEM), LocationRef::default()).bounds(gid)?; + Some(b.y_max).filter(|y| y.is_finite() && *y > 0.0) + }; + let cap = metrics.cap_height.filter(|c| c.is_finite() && *c > 0.0).or_else(measured).unwrap_or(metrics.ascent * 0.72); + f64::from(cap).max(1.0) } -fn glyph(font: &[u8], f: &FontRef, c: char) -> Arc { - let key = (font.as_ptr() as usize, font.len(), c); +/// The outline of `c` in `face` (its missing-glyph shape when unmapped; empty when the face +/// doesn't parse). +pub(crate) fn glyph(face: &Face, c: char) -> Arc { + let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, c); if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) { return g.clone(); } - let gid = f.charmap().map(c).unwrap_or_default(); + let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) }; + let mapped = f.charmap().map(c); + let gid = mapped.unwrap_or_default(); let mut pen = Pen { contours: Vec::new(), cur: Vec::new() }; if let Some(g) = f.outline_glyphs().get(gid) { let _ = g.draw(DrawSettings::unhinted(Size::new(UPEM), LocationRef::default()), &mut pen); pen.flush(); } let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5)); - let g = Arc::new(GlyphOutline { contours: pen.contours, advance }); + let g = Arc::new(GlyphOutline { contours: pen.contours, advance, cap: cap_height(&f), mapped: mapped.is_some() }); let mut cache = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner); if cache.len() > MAX_CACHED_GLYPHS { cache.clear(); @@ -218,37 +577,57 @@ fn glyph(font: &[u8], f: &FontRef, c: char) -> Arc { g } -/// Shape one line with a TrueType font: closed glyph contours grouped per glyph (baseline at +/// The outline of `c` from a fallback font, if any installed font has it. +pub(crate) fn fallback_glyph(c: char) -> Option> { + let f = fallback_for(c)?; + Some(glyph(&f, c)).filter(|g| g.mapped) +} + +/// The glyph of `c` in `face`, or from a fallback font when `face` lacks it. +fn glyph_or_fallback(face: &Face, c: char) -> Arc { + let g = glyph(face, c); + if g.mapped || c.is_whitespace() { + return g; + } + fallback_glyph(c).unwrap_or(g) +} + +/// Contours of a glyph scaled so its font's cap height is `h`, at pen position `x`. +pub(crate) fn place_glyph(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64) -> Vec> { + let scale = h / g.cap; + g.contours + .iter() + .map(|ct| { + ct.iter() + .map(|p| { + let y = p.y * scale; + Vec2::new(x + p.x * scale * wf + y * shear, y) + }) + .collect() + }) + .collect() +} + +/// Shape one line with a TrueType face: closed glyph contours grouped per glyph (baseline at /// y = 0, x from 0) plus `%%u`/`%%o` decorations as strokes. Text height = cap height. -pub fn shape(font: &[u8], s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { - let f = FontRef::new(font).ok()?; +/// Characters the face lacks come from a fallback font, scaled to its own cap height. +pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { + face.font()?; let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; - let scale = h / cap_height(&f); let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; let shear = oblique.tan().clamp(-10.0, 10.0); let mut out = Shaped::default(); let mut spans = Vec::new(); let mut x = 0.0; for (c, under, over) in crate::decode_controls(s) { - let g = glyph(font, &f, c); + let g = glyph_or_fallback(face, c); if !c.is_whitespace() { - let contours: Vec> = g - .contours - .iter() - .map(|ct| { - ct.iter() - .map(|p| { - let y = p.y * scale; - Vec2::new(x + p.x * scale * wf + y * shear, y) - }) - .collect() - }) - .collect(); + let contours = place_glyph(&g, x, h, wf, shear); if !contours.is_empty() { out.glyphs.push(contours); } } - let adv = g.advance * scale * wf; + let adv = g.advance * h / g.cap * wf; spans.push((x, x + adv, under, over)); x += adv; } @@ -257,17 +636,24 @@ pub fn shape(font: &[u8], s: &str, height: f64, width_factor: f64, oblique: f64) Some(out) } -/// Width of one line set in a TrueType font. -pub fn width(font: &[u8], s: &str, height: f64, width_factor: f64) -> Option { - let f = FontRef::new(font).ok()?; - let scale = height / cap_height(&f); +/// Width of one line set in a TrueType face. +pub fn width(face: &Face, s: &str, height: f64, width_factor: f64) -> Option { + face.font()?; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; - Some(crate::decode_controls(s).iter().map(|(c, _, _)| glyph(font, &f, *c).advance * scale * wf).sum()) + Some( + crate::decode_controls(s) + .iter() + .map(|(c, _, _)| { + let g = glyph_or_fallback(face, *c); + g.advance * height / g.cap * wf + }) + .sum(), + ) } -/// 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 { - let sh = shape(font, s, height, width_factor, oblique)?; +/// Lay out one line with a TrueType face: closed glyph contours (baseline at y = 0). +pub fn layout_line(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { + let sh = shape(face, s, height, width_factor, oblique)?; let mut strokes: Vec> = sh.glyphs.into_iter().flatten().collect(); strokes.extend(sh.strokes); Some(Run { strokes, width: sh.width }) diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 548cfad0..a7af272f 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -336,6 +336,20 @@ pub(crate) fn assoc_xdata(assoc: &[DimAssoc]) -> Vec { v } +/// An arc-length dimension as CADCraft xdata: `1000 ARCLEN`, `1002 {`, the arc centre as an xdata +/// point (`1010`/`1020`/`1030`), `1002 }`. The record itself stays a standard aligned dimension +/// (DXF has no arc-length DIMENSION type), so other programs still read it. +pub(crate) fn arclen_xdata(center: cadcraft_geom::Vec3) -> Vec { + vec![Tag::s(1000, "ARCLEN"), Tag::s(1002, "{"), Tag::f(1010, center.x), Tag::f(1020, center.y), Tag::f(1030, center.z), Tag::s(1002, "}")] +} + +/// The arc centre written by [`arclen_xdata`]; None for other dimensions and other writers. +pub(crate) fn read_arclen(tags: &[Tag]) -> Option { + let list = xdata_list(xdata(tags, APP), "ARCLEN"); + let g = |c: i32| list.iter().find(|t| t.code == c).map(Tag::f64).filter(|v| v.is_finite()); + Some(cadcraft_geom::Vec3::new(g(1010)?, g(1020)?, g(1030).unwrap_or(0.0))) +} + /// A viewport's frozen layer names as CADCraft xdata: `1000 VPFROZEN`, `1002 {`, one `1000` per layer, `1002 }`. pub(crate) fn frozen_xdata(layers: &[String]) -> Vec { let mut v = vec![Tag::s(1000, "VPFROZEN"), Tag::s(1002, "{")]; diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 131a7da1..d9c60743 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -263,6 +263,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { 4 => DimKind::Radius, 5 => DimKind::Angular3P, 6 => DimKind::Ordinate { x_type: t.i(70).unwrap_or(0) & 64 != 0 }, + 8 => DimKind::ArcLength, _ => DimKind::Aligned, }; EntityKind::Dimension(Dimension { @@ -551,6 +552,10 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m ("DIMENSION", EntityKind::Dimension(dm)) => { dm.overrides = crate::dxf_ext::read_dstyle(tags, &rx.styles, &rx.brs); dm.assoc = crate::dxf_ext::read_assoc(tags); + if let Some(center) = crate::dxf_ext::read_arclen(tags) { + dm.kind = DimKind::ArcLength; + dm.p15 = center; + } } ("ACAD_TABLE", EntityKind::Table(_)) => { let t = T(tags); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 0650a515..b27a3c81 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -162,9 +162,16 @@ fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) { w.xdata(ov.t); w.s(1002, "}"); } + let mut ours = Vec::new(); + if matches!(dm.kind, DimKind::ArcLength) { + ours.extend(dxf_ext::arclen_xdata(dm.p15)); + } if !dm.assoc.is_empty() { + ours.extend(dxf_ext::assoc_xdata(&dm.assoc)); + } + if !ours.is_empty() { w.s(1001, dxf_ext::APP); - w.xdata(dxf_ext::assoc_xdata(&dm.assoc)); + w.xdata(ours); } } @@ -555,6 +562,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx DimKind::Radius => 4, DimKind::Angular3P => 5, DimKind::Ordinate { x_type } => 6 | if x_type { 64 } else { 0 }, + // Written as aligned, marked by CADCraft xdata (see `dim_xdata`). DimKind::ArcLength => 1, }; w.i(70, ty | 32 | if dm.user_text_pos { 128 } else { 0 }); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 9f11318d..96ffced5 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1287,3 +1287,31 @@ fn dimension_text_rotation_roundtrips() { let rot = b.entities.iter().find_map(|e| if let EntityKind::MText(t) = &e.kind { Some(t.rotation) } else { None }); assert!(rot.is_some_and(|r| (r - dm.text_rotation).abs() < 1e-9), "block text rotation {rot:?}"); } + +#[test] +fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() { + let mut d = Drawing::new_metric(); + // Arc of radius 10 centred off the origin, so a missing centre would read back as zero. + let mut al = dim(DimKind::ArcLength, Vec3::new(15.0, 5.0, 0.0), Vec3::new(5.0, 15.0, 0.0)); + al.p15 = Vec3::new(5.0, 5.0, 0.0); + let h = d.add(&Space::Model, Default::default(), EntityKind::Dimension(al)).unwrap(); + let text = write_dxf(&d); + // A standard aligned DIMENSION record (type 1 | 32), so other programs can read it. + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + assert!(lines.windows(2).any(|w| w == ["70", "33"]) && !lines.windows(2).any(|w| w == ["70", "40"]), "{text}"); + let back = read_dxf(text.as_bytes()).unwrap(); + match &back.entity(h).unwrap().kind { + EntityKind::Dimension(x) => { + assert!(matches!(x.kind, DimKind::ArcLength)); + assert_eq!(x.p15, Vec3::new(5.0, 5.0, 0.0)); + } + _ => panic!("not a dimension"), + } + // And through DWG. + let dwg = cadcraft_dwg::dxf_to_dwg(text.as_bytes()).unwrap(); + let back = read_dxf(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap().as_bytes()).unwrap(); + let dims: Vec<_> = back.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect(); + assert_eq!(dims.len(), 1); + assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind); + assert!((dims[0].p15 - Vec3::new(5.0, 5.0, 0.0)).len() < 1e-9); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56be5664..a864d434 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -100,6 +100,9 @@ pub struct CanvasState { pub cursor: Option, pub polar_angle: Option, pan_last: Option, + /// True when the latest scroll came from a trackpad (or other precise-scrolling device): + /// scrolling then pans and pinch zooms. A notched mouse wheel clears it so the wheel zooms. + scroll_pans: bool, /// A hot (clicked) grip being dragged: entity, grip index, grip position, mode. pub hot_grip: Option, /// Zoom to extents once the canvas size is known (after opening a drawing). @@ -511,7 +514,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let (hover_pos, scroll, mods, middle_down, pressed_primary, pressed_secondary, dbl_middle, dbl_primary) = ui.input(|i| { ( i.pointer.hover_pos(), - i.smooth_scroll_delta.y, + i.smooth_scroll_delta, i.modifiers, i.pointer.middle_down(), i.pointer.primary_clicked(), @@ -521,14 +524,36 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { ) }); let inside = hover_pos.is_some_and(|p| rect.contains(p)) && resp.hovered(); - // Zoom with the wheel about the cursor. - if inside - && scroll.abs() > 0.0 - && let Some(hp) = hover_pos - { - let f = (f64::from(scroll) / 300.0).exp(); - let about = xf.to_world(hp); - let _ = app.session.zoom_about(f, about); + // Which device is scrolling? On macOS, trackpads (and Magic Mouse) report precise point + // deltas; notched mouse wheels report lines. The answer is sticky because egui spreads a wheel + // notch over several frames. Elsewhere (browsers, Wayland smooth-scroll mice) ordinary wheels + // also report points, so the wheel keeps zooming there. + ui.input(|i| { + for e in &i.raw.events { + if let egui::Event::MouseWheel { unit, .. } = e { + app.canvas.scroll_pans = cfg!(target_os = "macos") && *unit == egui::MouseWheelUnit::Point; + } + } + }); + if inside && scroll != egui::Vec2::ZERO { + if app.canvas.scroll_pans { + // Two-finger swipe pans: the drawing follows the fingers, like a middle-drag. + // Pinch (and Cmd+scroll) still zoom via `zoom_delta` below. + if let Ok(st) = app.session.state_mut() { + let v = st.view(); + st.set_view_quiet(cadcraft_engine::View { + center: v.center - Vec2::new(f64::from(scroll.x) / scale, -f64::from(scroll.y) / scale), + height: v.height, + }); + } + } else if scroll.y.abs() > 0.0 + && let Some(hp) = hover_pos + { + // Zoom with the mouse wheel about the cursor. + let f = (f64::from(scroll.y) / 300.0).exp(); + let about = xf.to_world(hp); + let _ = app.session.zoom_about(f, about); + } } let zoom_pinch = ui.input(|i| i.zoom_delta()); if inside @@ -537,7 +562,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { { let _ = app.session.zoom_about(f64::from(zoom_pinch), xf.to_world(hp)); } - // Pan with the middle button (or Shift+right-drag / two-finger drag on trackpads is scroll). + // Pan with the middle button (trackpads pan with a two-finger swipe, above). if middle_down && let Some(hp) = hover_pos { if let Some(last) = app.canvas.pan_last { let d = hp - last;