mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 02:46:51 -06:00
Linux: default to XWayland for file drag-and-drop on Wayland desktops (#159)
* DIMANGULAR: accept polyline segments (fixes #10) Picking a side of a rectangle or a PLINE segment was rejected with "Select a line or an arc." because only LINE and ARC entities were accepted. The picked object is now resolved to the segment under the cursor: straight polyline segments work like lines and arc segments like arcs. * Redraw edits made while a command is still running (fixes #4) LINE and PLINE add geometry on every point, but the canvas only rebuilt its display list when the drawing revision or the document pointer changed. The revision was bumped when a command finished, and the pointer changed only on the first copy-on-write edit, so only the first segment appeared until the command ended. Session::feed now bumps the revision after each input once the running command has changed the drawing. * Fix #14: include DWG in the Open drawing filter * Blocks window: click a block to insert it (#8) Each block name in the Blocks window is now a button. Clicking it starts INSERT with that block already chosen, so the command line asks for the insertion point. * SCALE: Reference option (#17) SCALE > Reference printed "not available yet". It now asks for the reference length (typed, or two points) and the new length (typed, or a point measured from the base point), and scales by new / reference. Zero, negative or non-finite lengths are rejected and asked again. * Print and Ctrl+P ask where to save the PDF (#5) PLOT/EXPORTPDF started from the menu, the toolbar, Ctrl+P or the command line had no path, so the PDF was built in memory and dropped. They now open the save dialog (PDF filter first) and write the file there. JSON calls from scripts, the control channel and MCP still never open a dialog. Ctrl+P was shown in the menu but not bound; it is now. * Scripts: split a line at keyword prompts so POLYGON 6 200,20 I 15 works (#43) * Layer Properties Manager: stop the window growing every frame (fixes #29) The vertical separator between the filter tree and the table takes all the height it is offered, and a resizable window offers its previous height, so the window grew 30px per frame until it filled the screen. Cap the row at the table's fixed height. Regression test runs the dialog headless for 30 frames and checks the height stays put. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ui.render: fit the drawing and return pngBase64 like the headless render (#49, #50) * Linux: default to XWayland for file drag-and-drop on Wayland desktops * Fonts: per-character fallback, FONTALT and .ttc for CJK text in SHX styles (#130) CJK text in a style that uses an SHX font (romans.shx + chineset.shx big font) drew every ideograph as an empty box: the SHX name resolves to our stroke font, which has no CJK glyphs, and the font scan skipped .ttc collections, where most CJK system fonts live. - Per-character fallback: a character the text's font lacks (stroke font or TrueType) is drawn from an installed font that has it, checked by cmap. Wide-coverage fonts go first (Noto Sans CJK, Source Han, WenQuanYi, Droid Sans Fallback, PingFang, Microsoft JhengHei/YaHei, MS Gothic, ...), regular weights before others. Fallback glyphs are sized like TrueType text (cap height = text height). Lookups are cached; the search is bounded (24 files, 160 MiB, 256 faces per character, files over 96 MiB never read) and skips spaces, controls and private-use code points. No fallback: the box as before. - FONTALT (sysvar, seeded from CADCRAFT_FONTALT): font used for a style font that can't be found (an SHX file, a missing TTF). Empty = our stroke font. - FONTFALLBACK (sysvar, seeded from CADCRAFT_FONTFALLBACK): fonts tried first for missing characters, e.g. "Noto Sans CJK TC". - .ttc/.otc are scanned; TextFont::Outline carries a ttf::Face (bytes + face index). Fonts are also found by family name among files with a similar stem, so "Noto Sans CJK TC" picks face 3 of NotoSansCJK-Regular.ttc. - Cap height falls back to the height of "H" when OS/2 has none (DejaVu Sans), which fixes ttf_tests::system_font_outlines_when_available and render's truetype_text_is_filled_or_outlined on machines without Arial. - README: a Fonts section for the two settings; ROADMAP M4 note. Native SHX / big-font rendering stays on the roadmap (M4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fonts: cap the total bytes of system font files loaded from disk A drawing naming many text styles could otherwise load every installed font into memory through family-name lookups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pan with a two-finger trackpad swipe; keep wheel and pinch zoom Trackpad two-finger swipes arrive as scroll events, and the canvas turned every scroll into a zoom, so trackpad users had no realtime pan (middle-drag needs a middle button). Scroll now follows the device: precise point deltas (trackpad, Magic Mouse) pan the view by x and y; notched line deltas (mouse wheel) zoom about the cursor as before. Pinch and Cmd+scroll still zoom through zoom_delta, and middle-drag still pans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Add Tru10 to contributors/people.toml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Trackpad pan: only on macOS, where point deltas mean a trackpad Browsers and Wayland smooth-scroll mice report ordinary wheels in points too, so the wheel keeps zooming there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep arc-length dimensions as arc-length through DXF save and reopen (#100) * PLINE: Halfwidth is centre-to-edge, and Width answers taper the next segment (fixes #83, #104) Interactive PLINE kept one width value: Halfwidth answers were stored as the total width, and the ending-width answer overwrote the starting one, so a taper became a constant width. The next segment's starting and ending widths are now tracked separately, Halfwidth answers are doubled, and each new segment (including the closing one) gets its widths on its first vertex; later segments are uniform at the ending width, as in AutoCAD. Uniform polylines are still stored as const_width, so their DXF output is unchanged. Fixes #83. Fixes #104. * Arc-length dimensions: write a standard aligned record plus CADCraft xdata DXF has no arc-length DIMENSION type, so write type 1 and mark the kind and arc centre in CADCRAFT xdata (like ASSOC). Other programs read an aligned dimension; CADCraft restores the arc length, through DXF and DWG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error!/ log::info! in the workspace was discarded: a broken app icon, the GPU canvas set-up, the engine's command-line echo at debug level. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/cadcraft/src/logging.rs (no new dependency): - standard error plus <settings dir>/logs/cadcraft.log; each launch rotates the previous log to cadcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for cadcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink; a poisoned lock is tolerated CADCraft had no per-user directory yet. config_dir in main.rs adds one: CADCRAFT_CONFIG_DIR, else $XDG_CONFIG_HOME/cadcraft or ~/.config/cadcraft (Linux, BSDs), ~/Library/Application Support/CADCraft (macOS), %APPDATA%\CADCraft (Windows). The logger is installed first in main and the file is opened after argument parsing (--version leaves no file). The control server's eprintln!s now go through the logger. A --control or CADCRAFT_CONTROL_PORT value that is not a port is now logged instead of silently leaving the control channel off. Documented in README.md (Quick start > Logs). --------- Co-authored-by: SBaksa <sabaksa11@gmail.com> Co-authored-by: DWAA1660 <dwatnip123@gmail.com> Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Co-authored-by: lloyd <34131158+LloydNicholson@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: vangel76 <vangel1976@googlemail.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Tru10 <133776756+Tru10@users.noreply.github.com> Co-authored-by: Gert Pellin <gert@pellin.be>
This commit is contained in:
co-authored by
SBaksa
DWAA1660
MarekSpodBiedry
lloyd
Claude Opus 5.5
vangel76
Brandon Thomas
Tru10
Gert Pellin
parent
3886add967
commit
3e7e0bdf2a
Generated
+1
@@ -551,6 +551,7 @@ dependencies = [
|
||||
"muda",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
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();
|
||||
|
||||
@@ -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: `<settings dir>/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.<KEEP>.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<LevelFilter> {
|
||||
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` → … → `.<KEEP>.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: `<timestamp> <LEVEL> [<thread>] <target>: <message>\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<File>,
|
||||
pending: Vec<String>,
|
||||
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<Sink>,
|
||||
}
|
||||
|
||||
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<PathBuf, String> {
|
||||
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<AppLogger> = 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);
|
||||
}
|
||||
}
|
||||
+121
-2
@@ -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 `<settings dir>/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<std::path::PathBuf> {
|
||||
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<std::ffi::OsString>) -> Option<std::path::PathBuf> {
|
||||
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 <port>` 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<String>) -> Option<u16> {
|
||||
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<u16> = 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<OsString> + '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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -444,19 +444,40 @@ impl Interactive for LineM {
|
||||
struct PlineM {
|
||||
verts: Vec<PolyVertex>,
|
||||
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<cadcraft_doc::Handle>,
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ const SESSION_VARS: &[&str] = &[
|
||||
"GRIPSIZE",
|
||||
"CURSORSIZE",
|
||||
"LASTPOINT",
|
||||
"FONTALT",
|
||||
"FONTFALLBACK",
|
||||
];
|
||||
|
||||
pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
@@ -49,6 +51,9 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"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 {
|
||||
|
||||
@@ -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)]));
|
||||
}
|
||||
|
||||
+144
-37
@@ -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<f64> = None;
|
||||
let mut over_start: Option<f64> = 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<Vec2> = 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<Vec<u8>>),
|
||||
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<Vec<V
|
||||
/// Shape one line in `font` (baseline at y = 0, x from 0). TrueType fonts that fail to parse
|
||||
/// fall back to the stroke font.
|
||||
pub fn shape_line(font: &TextFont, s: &str, height: f64, width_factor: f64, oblique: f64) -> 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<String> =
|
||||
["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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,8 +207,8 @@ fn font_switch(a: &str) -> Option<TextFont> {
|
||||
};
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+471
-85
@@ -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<String, Arc<Vec<u8>>>;
|
||||
/// One face of a font file (a TrueType collection holds several).
|
||||
#[derive(Clone)]
|
||||
pub struct Face {
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
/// Face index inside a collection (0 for a plain font file).
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
fn db() -> &'static Mutex<Option<Db>> {
|
||||
static DB: OnceLock<Mutex<Option<Db>>> = 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<Vec<u8>>, index: u32) -> Option<Face> {
|
||||
FontRef::from_index(&bytes, index).ok()?;
|
||||
Some(Face { bytes, index })
|
||||
}
|
||||
fn font(&self) -> Option<FontRef<'_>> {
|
||||
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<String> {
|
||||
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::<Vec<_>>())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Faces of a font file: every face of a collection (capped), or the single font.
|
||||
pub fn faces(bytes: &Arc<Vec<u8>>) -> Vec<Face> {
|
||||
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<String, PathBuf>,
|
||||
/// Loaded or registered font file bytes, by lower-case file stem.
|
||||
files: HashMap<String, Arc<Vec<u8>>>,
|
||||
/// Name lookups already answered, found or not.
|
||||
resolved: HashMap<String, Option<Face>>,
|
||||
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<Vec<String>>,
|
||||
/// Next candidate in `order` to load.
|
||||
next: usize,
|
||||
files: usize,
|
||||
bytes: usize,
|
||||
/// Faces of the loaded candidates, in candidate order.
|
||||
faces: Vec<Face>,
|
||||
/// Character → index in `faces` (None: nothing covers it).
|
||||
by_char: HashMap<char, Option<usize>>,
|
||||
}
|
||||
|
||||
fn lock() -> MutexGuard<'static, Db> {
|
||||
static DB: OnceLock<Mutex<Db>> = 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<std::path::PathBuf> = Vec::new();
|
||||
fn scan() -> HashMap<String, PathBuf> {
|
||||
let mut out = HashMap::new();
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = std::env::var_os("HOME") {
|
||||
dirs.push(std::path::PathBuf::from(h).join("Library/Fonts"));
|
||||
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<String, PathBuf> {
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static PATHS: OnceLock<Mutex<HashMap<String, std::path::PathBuf>>> = OnceLock::new();
|
||||
fn read_file(path: &std::path::Path) -> Option<Vec<u8>> {
|
||||
/// 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<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Bytes of the font file with this stem, loading it on first use.
|
||||
fn file(&mut self, stem: &str) -> Option<Arc<Vec<u8>>> {
|
||||
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<Face> {
|
||||
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<Face> {
|
||||
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<String> = 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<usize> {
|
||||
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<String> {
|
||||
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<u8>) {
|
||||
let mut g = db().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.get_or_insert_with(Db::new).insert(name.to_ascii_lowercase(), Arc::new(bytes));
|
||||
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<Arc<Vec<u8>>> {
|
||||
/// 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<Face> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
fn prefs() -> MutexGuard<'static, Prefs> {
|
||||
static P: OnceLock<Mutex<Prefs>> = 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<String> {
|
||||
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<Face> {
|
||||
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<Vec<Vec2>>,
|
||||
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<Vec<Vec2>>,
|
||||
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<GlyphOutline>>;
|
||||
/// 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<GlyphOutline>>;
|
||||
|
||||
fn glyph_cache() -> &'static Mutex<GlyphCache> {
|
||||
static C: OnceLock<Mutex<GlyphCache>> = 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<GlyphOutline> {
|
||||
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<GlyphOutline> {
|
||||
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<GlyphOutline> {
|
||||
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<Arc<GlyphOutline>> {
|
||||
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<GlyphOutline> {
|
||||
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<Vec<Vec2>> {
|
||||
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<Shaped> {
|
||||
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<Shaped> {
|
||||
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<Vec<Vec2>> = 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<f64> {
|
||||
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<f64> {
|
||||
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<Run> {
|
||||
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<Run> {
|
||||
let sh = shape(face, s, height, width_factor, oblique)?;
|
||||
let mut strokes: Vec<Vec<Vec2>> = sh.glyphs.into_iter().flatten().collect();
|
||||
strokes.extend(sh.strokes);
|
||||
Some(Run { strokes, width: sh.width })
|
||||
|
||||
@@ -336,6 +336,20 @@ pub(crate) fn assoc_xdata(assoc: &[DimAssoc]) -> Vec<Tag> {
|
||||
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<Tag> {
|
||||
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<cadcraft_geom::Vec3> {
|
||||
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<Tag> {
|
||||
let mut v = vec![Tag::s(1000, "VPFROZEN"), Tag::s(1002, "{")];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ pub struct CanvasState {
|
||||
pub cursor: Option<Vec2>,
|
||||
pub polar_angle: Option<f64>,
|
||||
pan_last: Option<Pos2>,
|
||||
/// 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<HotGrip>,
|
||||
/// 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;
|
||||
|
||||
Reference in New Issue
Block a user