diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d47d45eb55..0027e664a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -268,7 +268,9 @@ a conflict. `crates/doc/src/format/pending/README.md` has an example. **At release time** the maintainer runs `scripts/fold-format-pending.sh`, which moves the pending files into `crates/doc/src/format/v/` and fixes that number for good (see -[docs/releasing.md](docs/releasing.md)). Never add a file to a `v/` folder afterwards. +[docs/releasing.md](docs/releasing.md)). Never add a file to a `v/` folder afterwards. Drawings +follow the same rules with their own numbers, from 1, in `crates/doc/src/drawing/format/` (see its +`pending/README.md`), and the same script folds them. ## Lists many changes add to diff --git a/crates/doc/build.rs b/crates/doc/build.rs index 18bea3206b..3179af80bc 100644 --- a/crates/doc/build.rs +++ b/crates/doc/build.rs @@ -1,15 +1,40 @@ -//! Lists the design-format changes in `src/format/`: one file per change, in `v/` once a -//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The list is +//! Lists the format changes of each family of files versioned by `src/format.rs`: designs in +//! `src/format/`, drawings in `src/drawing/format/` (#3890). One file per change, in `v/` once a +//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The lists are //! generated so that a change adds a file and never edits a line another change also edits. use std::fmt::Write as _; use std::path::{Path, PathBuf}; -/// The last format whose upgrade steps are written out in `format.rs` (`JSON_STEPS`). -const HAND_WRITTEN: u32 = 22; +/// A family's change files: their folder, the last format before them, and the prefix of the +/// family's lists in `format.rs`. +struct Family { + folder: &'static str, + base: u32, + list_prefix: &'static str, +} + +/// Every family, each read by `scripts/fold-format-pending.sh` too. A design's upgrade steps are +/// written out in `format.rs` up to 22 (`JSON_STEPS`); a drawing's first format is 1. +const FAMILIES: [Family; 2] = + [Family { folder: "src/format", base: 22, list_prefix: "" }, Family { folder: "src/drawing/format", base: 1, list_prefix: "DRAWING_" }]; fn main() { - let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/format"); + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let mut out = String::new(); + let mut mods = String::new(); + for family in &FAMILIES { + lists(family, &manifest, &mut out, &mut mods); + } + out.push_str(&mods); + let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs"); + std::fs::write(dest, out).unwrap(); +} + +/// A family's `FOLDED` and `PENDING` lists; its folded formats follow on from its base. +fn lists(family: &Family, manifest: &Path, out: &mut String, mods: &mut String) { + let Family { folder, base, list_prefix } = family; + let root = manifest.join(folder); println!("cargo:rerun-if-changed={}", root.display()); let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(&root) .unwrap_or_else(|e| panic!("{}: {e}", root.display())) @@ -21,23 +46,24 @@ fn main() { .collect(); folded.sort(); for (i, (version, _)) in folded.iter().enumerate() { - let expected = HAND_WRITTEN + 1 + i as u32; - assert_eq!(*version, expected, "src/format/: expected v{expected}/ next, found v{version}/ (versions are contiguous)"); + let expected = base + 1 + i as u32; + assert_eq!(*version, expected, "{folder}/: expected v{expected}/ next, found v{version}/ (versions are contiguous)"); } - let mut out = String::new(); - let mut mods = String::new(); - out.push_str("/// The changes of each format after the hand-written ones, oldest first.\nconst FOLDED: &[(u32, &[Part])] = &[\n"); + // Module names start with the family's (`drawing_pending_…`), so two families' changes of one + // name never clash. + let module = list_prefix.to_ascii_lowercase(); + let _ = writeln!( + out, + "/// The changes of each format after the base, oldest first (`{folder}/v/`).\nconst {list_prefix}FOLDED: &[(u32, &[Part])] = &[" + ); for (version, dir) in &folded { let _ = writeln!(out, " ({version}, &["); - parts(dir, &format!("v{version}"), &mut out, &mut mods); + parts(dir, &format!("{module}v{version}"), out, mods); out.push_str(" ]),\n"); } - out.push_str("];\n\n/// The changes no release has a number for yet (`src/format/pending/`).\nconst PENDING: &[Part] = &[\n"); - parts(&root.join("pending"), "pending", &mut out, &mut mods); - out.push_str("];\n"); - out.push_str(&mods); - let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs"); - std::fs::write(dest, out).unwrap(); + let _ = writeln!(out, "];\n\n/// The changes no release has a number for yet (`{folder}/pending/`).\nconst {list_prefix}PENDING: &[Part] = &["); + parts(&root.join("pending"), &format!("{module}pending"), out, mods); + out.push_str("];\n\n"); } /// The `Part`s of the change files in `dir`, in file-name order, and a module for each. diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 0ca382f854..7775f2afe9 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -124,6 +124,48 @@ fn identity() -> Mat { IDENTITY } +/// How a component counts in the bill of materials of an assembly it is placed in. It is a part +/// property (the `bom_type` key of the component's [`Metadata`](crate::Metadata)); a component +/// without one is [`BomType::Normal`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BomType { + /// A row of its own; every occurrence adds to its quantity. + #[default] + Normal, + /// No row of its own: what it holds counts as if placed directly in its parent. + Phantom, + /// Shown, but counted nowhere, and nothing inside it is counted either. + Reference, + /// Bought: one row, with nothing inside it counted or listed. + Purchased, + /// Made as one item that doesn't come apart (a welded frame): one row in the assembly it is + /// placed in. What it holds is listed in its own bill of materials, and its purchased parts + /// also in the parts-only list of the assembly it is placed in. + Inseparable, +} + +impl BomType { + /// Every type, in the order commands and documentation list them. + pub const ALL: [BomType; 5] = [BomType::Normal, BomType::Phantom, BomType::Reference, BomType::Purchased, BomType::Inseparable]; + + /// The word the design file and commands use. + pub fn as_str(self) -> &'static str { + match self { + BomType::Normal => "normal", + BomType::Phantom => "phantom", + BomType::Reference => "reference", + BomType::Purchased => "purchased", + BomType::Inseparable => "inseparable", + } + } + + /// The type a word names, if it names one. + pub fn parse(word: &str) -> Option { + BomType::ALL.into_iter().find(|t| t.as_str() == word) + } +} + /// How deep and how many placements [`Document::placements`] walks. pub const MAX_PLACEMENT_DEPTH: usize = 64; pub const MAX_PLACEMENTS: usize = 100_000; diff --git a/crates/doc/src/drawing.rs b/crates/doc/src/drawing.rs new file mode 100644 index 0000000000..2c7dfb4b95 --- /dev/null +++ b/crates/doc/src/drawing.rs @@ -0,0 +1,305 @@ +//! A drawing (#3890): sheets of paper that draw designs saved in their own files. A drawing is a +//! file of its own, `"format": "solidcraft-drawing/N"`, versioned by the same code as designs +//! ([`DRAWING`]); format 1 is this record. +//! +//! - **Sources** are the designs it draws: an id and the design file's path, relative to the +//! drawing's folder once the drawing is saved, else as given, with `/` between folders (a linked +//! design's [`DesignLink`](crate::DesignLink) rule). What a design was when the drawing last saw +//! it is not saved here. +//! - **Sheets**: the paper, a copy of its drawing border, the resolution its pictures are rendered +//! at, the component it documents, and whether it is left out of the sheet count or of printing. +//! The border is a copy of the profile it was made with, never a name, so a later built-in never +//! changes a saved drawing. +//! - **Units** follow each view's design or are one length unit for the whole drawing. The +//! **language** is the content's, a BCP 47 tag chosen when the drawing is made, whatever the +//! interface's is. **Properties** follow [`Metadata`]'s rules. +//! +//! The rules, which every later kind of record (views, annotations, tables) keeps too: +//! - Every object has an id of [`crate::ids`]: random, so objects added side by side never take +//! the same one, and written as 13 characters of base32. No two objects share an id, and a run +//! never gives a removed object's id again ([`Drawing::allocate_id`]). +//! - Every reference between records is an id and must resolve: a file with one that doesn't is +//! damaged and refused, never patched. +//! - 1 to [`MAX_SHEETS`] sheets and at most [`MAX_SOURCES`] sources; names of 1 to +//! [`MAX_NAME_BYTES`] bytes; paths of 1 to [`MAX_PATH_BYTES`] bytes, without NUL. +//! - Nothing derived is saved, and a drawing always saves as the same bytes: fields in their +//! order, maps sorted. +//! - A later change to the record is one file in `src/drawing/format/pending/` (its README). +//! +//! [`Drawing::from_json`] reads a file in this order: at most [`MAX_DRAWING_BYTES`] of text, the +//! family's format check, every number in range, the upgrade, the typed record, then +//! [`Drawing::validate`]. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile}; + +use crate::format::DRAWING; +use crate::ids::{self, IdSource}; +use crate::{DocError, Metadata, Result, expr}; + +/// The most sheets a drawing may have. +pub const MAX_SHEETS: usize = 1000; +/// The most designs a drawing may draw. +pub const MAX_SOURCES: usize = 64; +/// The longest name of a drawing or a sheet, in bytes. +pub const MAX_NAME_BYTES: usize = 256; +/// The longest path to a design, in bytes. +pub const MAX_PATH_BYTES: usize = 4096; +/// The longest language tag: what RFC 5646 asks every reader to take (§4.4.1). +pub const MAX_LANGUAGE_CHARS: usize = 35; +/// The largest drawing read, as JSON text. +pub const MAX_DRAWING_BYTES: usize = 16 << 20; +/// The resolution a sheet's pictures (its shaded views) are rendered at unless it says, in dots +/// per inch. +pub const DEFAULT_DPI: f64 = 300.0; +/// The lowest resolution a sheet may ask for. +pub const MIN_DPI: f64 = 25.0; +/// The highest resolution a sheet may ask for. +pub const MAX_DPI: f64 = 2400.0; + +/// A drawing: its sheets and the designs they draw. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Drawing { + /// `solidcraft-drawing/N`: the format this build writes, whatever the file said. + pub format: String, + /// The changes of `format` this drawing has while they are pending + /// ([`Family::pending_names`](crate::format::Family::pending_names)). Empty, and not saved, + /// otherwise. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub format_pending: Vec, + pub name: String, + /// The designs the drawing draws. + pub sources: Vec, + /// The drawing's properties and other tools' data, kept as they are. + #[serde(default, skip_serializing_if = "Metadata::is_empty")] + pub properties: Metadata, + #[serde(default)] + pub units: Units, + /// The language of the drawing's content, a BCP 47 tag (`en-GB`). + pub language: String, + /// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change. + pub sheets: Vec>, +} + +/// A design the drawing draws. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Source { + #[serde(with = "crate::ids")] + pub id: u64, + /// The design file: relative to the drawing's folder once the drawing is saved, else as given + /// (`/` separates folders). + pub path: String, +} + +/// The length unit a drawing gives its measurements in. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum Units { + /// Each view's design's own units. + #[default] + FollowModel, + /// One length unit for the whole drawing, by its name in [`expr::UNITS`] (`mm`, `in`). + Fixed { unit: String }, +} + +/// One sheet of a drawing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Sheet { + #[serde(with = "crate::ids")] + pub id: u64, + pub name: String, + /// The paper: its size and the way it lies. + pub sheet: drawing_sheet::Sheet, + /// A copy of the drawing border's profile (`None`: no drawing border). + pub border: Option, + /// The resolution its pictures (its shaded views) are rendered at, in dots per inch. + #[serde(default = "default_dpi")] + pub dpi: f64, + /// The component the sheet documents, which its title block reads. + pub documents: Option, + #[serde(default)] + pub excluded: Excluded, +} + +/// A component of one of the drawing's designs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SheetComponent { + /// The [`Source`]'s id. + #[serde(with = "crate::ids")] + pub source: u64, + /// The component's id in that design, as the design writes it (0: the design itself). + pub component: u64, +} + +/// What a sheet is left out of. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Excluded { + /// The sheet count ("sheet 2 of 3" on the other sheets). + pub count: bool, + /// Printing and exporting every sheet. + pub print: bool, +} + +fn default_dpi() -> f64 { + DEFAULT_DPI +} + +impl Drawing { + /// A drawing of no designs yet, named `name` and written in `language`, with one sheet named + /// `sheet`: the paper `paper` with a copy of `border`, its id from `id_source`. + pub fn new( + name: &str, + language: &str, + sheet: &str, + paper: drawing_sheet::Sheet, + border: Option, + id_source: &mut IdSource, + ) -> Result { + let id = id_source.next(&BTreeSet::new())?; + let first = Sheet { id, name: sheet.into(), sheet: paper, border, dpi: DEFAULT_DPI, documents: None, excluded: Excluded::default() }; + let drawing = Drawing { + format: DRAWING.format_string(DRAWING.current()), + format_pending: DRAWING.pending_names(), + name: name.into(), + sources: Vec::new(), + properties: Metadata::default(), + units: Units::default(), + language: language.into(), + sheets: vec![Arc::new(first)], + }; + drawing.validate()?; + Ok(drawing) + } + + /// Read a drawing file's JSON (any format this build knows; older ones are upgraded). A file + /// that breaks a rule of the record is damaged and refused, never patched. + pub fn from_json(s: &str) -> Result { + if s.len() > MAX_DRAWING_BYTES { + return Err(DocError::Invalid(format!("drawing too large (at most {} MiB)", MAX_DRAWING_BYTES >> 20))); + } + let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?; + let (version, applied) = DRAWING.take_version(&mut v)?; + DRAWING.check_numbers(&v, "properties")?; + DRAWING.upgrade(&mut v, version, applied.as_deref())?; + let d: Drawing = serde_json::from_value(v).map_err(damaged)?; + d.validate().map_err(damaged)?; + Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d }) + } + + /// The drawing's JSON: the same bytes for the same drawing. JSON longer than a read takes + /// ([`MAX_DRAWING_BYTES`]) is refused, so every drawing saved opens again: other tools' + /// properties are kept as they came, and indenting them can make a drawing that read in + /// under the limit save over it. + pub fn to_json(&self) -> Result { + let text = serde_json::to_string_pretty(self).map_err(|e| DocError::Invalid(format!("drawing not saved: {e}")))?; + require(text.len() <= MAX_DRAWING_BYTES, || format!("drawing too large to save (at most {} MiB)", MAX_DRAWING_BYTES >> 20))?; + Ok(text) + } + + /// An id for a new object: one no object of the drawing has, from `id_source`, which never + /// gives one twice. + pub fn allocate_id(&self, id_source: &mut IdSource) -> Result { + id_source.next(&self.ids().collect()) + } + + /// Every object's id, of every kind: what a new id must differ from. Each later kind adds its + /// own here. + fn ids(&self) -> impl Iterator { + self.sources.iter().map(|s| s.id).chain(self.sheets.iter().map(|s| s.id)) + } + + /// The record's rules (the module's notes): what reading a file checks, and what every edit + /// keeps. + pub fn validate(&self) -> Result<()> { + require((1..=MAX_SHEETS).contains(&self.sheets.len()), || format!("a drawing has 1 to {MAX_SHEETS} sheets"))?; + require(self.sources.len() <= MAX_SOURCES, || format!("a drawing draws at most {MAX_SOURCES} designs"))?; + check_name(&self.name, "the drawing's")?; + require(is_language_tag(&self.language), || { + format!("the language is not a BCP 47 tag of 2 to {MAX_LANGUAGE_CHARS} characters (such as `en` or `en-GB`)") + })?; + if let Units::Fixed { unit } = &self.units { + // A length unit of the expression unit table, written as the table writes it. + let length = unit.trim() == unit.as_str() && expr::unit_info(unit).is_some_and(|(kind, _)| kind == expr::Kind::Length); + require(length, || "the drawing's unit is not a length unit (such as `mm` or `in`)".into())?; + } + let mut seen = BTreeSet::new(); + for id in self.ids() { + require(id != 0, || "an object has the id 0, which is never one".into())?; + require(seen.insert(id), || format!("two objects have the id {}", ids::encode(id)))?; + } + for source in &self.sources { + let path = &source.path; + require(!path.is_empty() && path.len() <= MAX_PATH_BYTES && !path.contains('\0'), || { + format!("source {}: a design's path is 1 to {MAX_PATH_BYTES} bytes, without NUL", ids::encode(source.id)) + })?; + } + for sheet in &self.sheets { + self.check_sheet(sheet).map_err(|e| DocError::Invalid(format!("sheet {}: {e}", ids::encode(sheet.id))))?; + } + Ok(()) + } + + /// A sheet's own rules, and that the source it documents is one of the drawing's. + fn check_sheet(&self, sheet: &Sheet) -> Result<()> { + check_name(&sheet.name, "its")?; + sheet.sheet.size_mm().map_err(from_sheet)?; + if let Some(border) = &sheet.border { + border.validate().map_err(from_sheet)?; + } + require((MIN_DPI..=MAX_DPI).contains(&sheet.dpi), || format!("its pictures are {MIN_DPI} to {MAX_DPI} dpi"))?; + match sheet.documents { + Some(SheetComponent { source, .. }) => { + let named = || format!("it documents source {}, which the drawing doesn't have", ids::encode(source)); + require(self.sources.iter().any(|s| s.id == source), named) + } + None => Ok(()), + } + } +} + +/// `Ok` when `ok`, else the rule `broken` names. +fn require(ok: bool, broken: impl FnOnce() -> String) -> Result<()> { + if ok { Ok(()) } else { Err(DocError::Invalid(broken())) } +} + +/// A name: 1 to [`MAX_NAME_BYTES`] bytes, not only spaces, without control characters. +fn check_name(name: &str, whose: &str) -> Result<()> { + require(!name.trim().is_empty() && name.len() <= MAX_NAME_BYTES && !name.chars().any(char::is_control), || { + format!("{whose} name is 1 to {MAX_NAME_BYTES} bytes, not only spaces, without control characters") + }) +} + +/// A well-formed language tag, as far as a drawing needs (RFC 5646 §2.1): 2 to +/// [`MAX_LANGUAGE_CHARS`] characters, subtags of 1 to 8 ASCII letters and digits joined by `-`, +/// the first of 2 to 8 letters (`en`, `en-GB`, `zh-Hans-CN`). Whether the subtags are registered +/// isn't checked, and a private-use tag alone (`x-…`) isn't taken. +fn is_language_tag(tag: &str) -> bool { + let mut subtags = tag.split('-'); + let first = subtags.next().unwrap_or_default(); + (2..=MAX_LANGUAGE_CHARS).contains(&tag.len()) + && (2..=8).contains(&first.len()) + && first.bytes().all(|b| b.is_ascii_alphabetic()) + && subtags.all(|s| (1..=8).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric())) +} + +/// A file that breaks a rule of the record. +fn damaged(e: impl std::fmt::Display) -> DocError { + DocError::Invalid(format!("damaged drawing: {e}")) +} + +fn from_sheet(e: drawing_sheet::Invalid) -> DocError { + DocError::Invalid(e.to_string()) +} + +#[cfg(test)] +#[path = "drawing_tests.rs"] +mod tests; diff --git a/crates/doc/src/drawing/format/pending/README.md b/crates/doc/src/drawing/format/pending/README.md new file mode 100644 index 0000000000..29e16f40fc --- /dev/null +++ b/crates/doc/src/drawing/format/pending/README.md @@ -0,0 +1,30 @@ +# Pending drawing-format changes + +One file per change to what a drawing file saves that no release has shipped yet. Drawings +(`"format": "solidcraft-drawing/N"`, #3890) are versioned by the same code as designs +(`crates/doc/src/format.rs`, `Family`), from their own first format, 1. Adding a file here is the +whole format bump: never edit the `DRAWING` family in `format.rs`, another change's file or a +`../v/` folder. + +- Name it after the change, lowercase with dashes: `sheet-views.rs`. +- Start it with a `//!` note: what is saved, what an older drawing gets without it, and what + the last release does with a drawing that has it (it refuses the newer format number). +- If older drawings must be rewritten as they open, add the step to the same file: + + ```rust + //! …the note… + use serde_json::Value; + + pub fn step(v: &mut Value) -> crate::Result<()> { + // rewrite the drawing's JSON here + Ok(()) + } + ``` + + Steps run in file-name order, after the steps of every older format. + +While this folder has a change in it, the build writes the next drawing format number (the last +folded one + 1, which no release reads), and lists the changes a drawing has in its +`format_pending` field, so a later build applies only the steps that drawing is missing. At +release time `scripts/fold-format-pending.sh` moves these files into `../v/`, as it does the +design format's (`crates/doc/src/format/pending/README.md`). diff --git a/crates/doc/src/drawing_tests.rs b/crates/doc/src/drawing_tests.rs new file mode 100644 index 0000000000..d51e8fd274 --- /dev/null +++ b/crates/doc/src/drawing_tests.rs @@ -0,0 +1,553 @@ +use serde_json::json; +use solidcraft_geom::drawing_sheet::{Margins, Orientation, PaperSize, SheetSize}; + +use super::*; + +/// The seed every test drawing's ids come from. +const SEED: u64 = 3890; + +/// A profile of its own: ISO 5457's filing margin and trimming marks, which the built-in leaves off. +fn own_border() -> DrawingBorderProfile { + DrawingBorderProfile { filing_margin: true, trimming_mark_mm: Some([10.0, 5.0]), ..DrawingBorderProfile::iso() } +} + +/// Three designs on two sheets: the first sheet (A3, the ISO border) documents component 7 of the +/// first design; the second (A4 portrait, a border of its own, 600 dpi, left out of printing) +/// documents the second design. The third design is on no sheet yet. +fn plate() -> Drawing { + let mut id_source = IdSource::seeded(SEED); + let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape); + let mut d = Drawing::new("Plate assembly", "en-GB", "Assembly", a3, Some(DrawingBorderProfile::iso()), &mut id_source).unwrap(); + for path in ["plate.sc", "../parts/bolt.sc", "bracket.sc"] { + let id = d.allocate_id(&mut id_source).unwrap(); + d.sources.push(Source { id, path: path.into() }); + } + let (plate_id, bolt_id) = (d.sources[0].id, d.sources[1].id); + let id = d.allocate_id(&mut id_source).unwrap(); + d.sheets.push(Arc::new(Sheet { + id, + name: "Bolt".into(), + sheet: drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Portrait), + border: Some(own_border()), + dpi: 600.0, + documents: Some(SheetComponent { source: bolt_id, component: 0 }), + excluded: Excluded { count: false, print: true }, + })); + Arc::make_mut(&mut d.sheets[0]).documents = Some(SheetComponent { source: plate_id, component: 7 }); + d.properties.set(crate::metadata::Owner::Design, "author", json!("Jane Doe")).unwrap(); + d.units = Units::Fixed { unit: "in".into() }; + d.validate().unwrap(); + d +} + +/// One A4 sheet that documents nothing, no sources: the base the caps grow from. +fn bare() -> Drawing { + let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape); + Drawing::new("Bare", "en", "Sheet", a4, None, &mut IdSource::seeded(SEED)).unwrap() +} + +fn file(d: &Drawing) -> Value { + serde_json::from_str(&d.to_json().unwrap()).unwrap() +} + +fn read(file: &Value) -> Result { + Drawing::from_json(&file.to_string()) +} + +/// Why `file` is refused, or "read". +fn refusal(file: &Value) -> String { + read(file).map_or_else(|e| e.to_string(), |_| "read".into()) +} + +/// Why `d`'s file, changed by `edit`, is refused, or "read". +fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String { + let mut v = file(d); + edit(&mut v); + refusal(&v) +} + +/// A drawing reads back as it was saved, and save, open and save again writes the same bytes, as +/// does another run from the same seed. The file holds each field under the record's name for it, +/// ids as their 13 characters, and no counter. +#[test] +fn a_drawing_reads_back_as_saved_and_saves_the_same_bytes_again() { + let d = plate(); + let saved = d.to_json().unwrap(); + let reopened = Drawing::from_json(&saved).unwrap(); + assert_eq!(reopened, d); + assert_eq!(reopened.to_json().unwrap(), saved); + assert_eq!(plate().to_json().unwrap(), saved, "a seeded run makes the same drawing"); + let v = file(&d); + let [plate_id, bolt_id, bracket_id] = [0, 1, 2].map(|i| ids::encode(d.sources[i].id)); + let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id)); + assert_eq!(assembly_id, "1ge30wkvq38r0", "the seed's first id, as splitmix64 gives it"); + assert_eq!(v["format"], DRAWING.format_string(DRAWING.current())); + assert_eq!(v.get("format_pending").is_some(), !DRAWING.pending_names().is_empty()); + assert_eq!(v.get("next_id"), None); + assert_eq!((&v["name"], &v["language"]), (&json!("Plate assembly"), &json!("en-GB"))); + assert_eq!( + v["sources"], + json!([{"id": plate_id, "path": "plate.sc"}, {"id": bolt_id, "path": "../parts/bolt.sc"}, {"id": bracket_id, "path": "bracket.sc"}]) + ); + assert_eq!(v["properties"], json!({"author": "Jane Doe"})); + assert_eq!(v["units"], json!({"fixed": {"unit": "in"}})); + assert_eq!((&v["sheets"][0]["id"], &v["sheets"][0]["dpi"]), (&json!(assembly_id), &json!(300.0))); + assert_eq!(v["sheets"][0]["sheet"], json!({"size": {"standard": "a3"}, "orientation": "landscape"})); + assert_eq!(v["sheets"][0]["documents"], json!({"source": plate_id, "component": 7})); + assert_eq!(v["sheets"][0]["excluded"], json!({"count": false, "print": false})); + assert_eq!( + v["sheets"][1], + json!({ + "id": bolt_sheet_id, "name": "Bolt", "sheet": {"size": {"standard": "a4"}, "orientation": "portrait"}, + "border": serde_json::to_value(own_border()).unwrap(), "dpi": 600.0, + "documents": {"source": bolt_id, "component": 0}, "excluded": {"count": false, "print": true} + }) + ); +} + +/// A hand-written file may leave out what has a default, and reads as a new drawing has it; the +/// rest is needed. +#[test] +fn a_file_may_leave_out_what_has_a_default() { + let minimal = json!({ + "format": DRAWING.format_string(1), + "name": "Bracket", + "sources": [], + "language": "de", + "sheets": [{"id": "0000000000001", "name": "Blatt 1", "sheet": {"size": {"standard": "a3"}}}] + }); + let d = read(&minimal).unwrap(); + assert_eq!((d.sheets[0].id, &d.units, d.properties.is_empty()), (1, &Units::FollowModel, true)); + let sheet = &d.sheets[0]; + assert_eq!(sheet.sheet, drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape)); + assert_eq!((sheet.border, sheet.dpi, sheet.documents, sheet.excluded), (None, DEFAULT_DPI, None, Excluded::default())); + // The format as this build writes it, however the file spelt it. + let mut spelt = minimal.clone(); + spelt["format"] = json!("solidcraft-drawing/+1"); + assert_eq!(read(&spelt).unwrap().format, DRAWING.format_string(DRAWING.current())); + for key in ["name", "sources", "language", "sheets"] { + let mut v = minimal.clone(); + v.as_object_mut().unwrap().remove(key); + let why = refusal(&v); + assert!(why.starts_with("damaged drawing: ") && why.contains(&format!("missing field `{key}`")), "{why}"); + } +} + +/// Every cap on both sides: sheets, sources, the bytes of paths and names (two-byte characters too). +#[test] +fn every_cap_holds_on_both_sides() { + let base = file(&bare()); + let with = |key: &str, value: Value| { + let mut v = base.clone(); + v[key] = value; + v + }; + let sheet = base["sheets"][0].clone(); + let sheets = |n: u64| { + let one = |id: u64| { + let mut s = sheet.clone(); + s["id"] = json!(ids::encode(id)); + s + }; + Value::Array((1..=n).map(one).collect()) + }; + assert_eq!(read(&with("sheets", sheets(1000))).unwrap().sheets.len(), 1000); + for n in [0, 1001] { + assert_eq!(refusal(&with("sheets", sheets(n))), "damaged drawing: a drawing has 1 to 1000 sheets", "{n} sheets"); + } + // Sources from id 1: the sheet's id is the seed's, far from them. + let sources = |n: u64| Value::Array((1..=n).map(|id| json!({"id": ids::encode(id), "path": format!("part{id}.sc")})).collect()); + assert_eq!(read(&with("sources", sources(64))).unwrap().sources.len(), 64); + assert_eq!(refusal(&with("sources", sources(65))), "damaged drawing: a drawing draws at most 64 designs"); + let path = |p: &str| refusal(&with("sources", json!([{"id": ids::encode(1), "path": p}]))); + for fits in ["a".repeat(4096), "é".repeat(2048), "/".into()] { + assert_eq!(path(fits.as_str()), "read", "{} bytes", fits.len()); + } + let rule = "damaged drawing: source 0000000000001: a design's path is 1 to 4096 bytes, without NUL"; + for broken in ["a".repeat(4097), "é".repeat(2048) + "a", String::new(), "plate\0.sc".into()] { + assert_eq!(path(broken.as_str()), rule, "{} bytes", broken.len()); + } + let names = |name: &str| { + let mut sheet_named = base.clone(); + sheet_named["sheets"][0]["name"] = json!(name); + (refusal(&with("name", json!(name))), refusal(&sheet_named)) + }; + for fits in ["x".repeat(256), "é".repeat(128), "A".into(), "Blatt 1 – Übersicht".into()] { + assert_eq!(names(fits.as_str()), ("read".to_string(), "read".to_string()), "{} bytes", fits.len()); + } + let rule = "name is 1 to 256 bytes, not only spaces, without control characters"; + let sheet_id = ids::encode(bare().sheets[0].id); + for broken in ["x".repeat(257), "é".repeat(128) + "a", String::new(), " ".into(), "two\nlines".into(), "bell\u{7}".into(), "nul\0".into()] { + let expected = (format!("damaged drawing: the drawing's {rule}"), format!("damaged drawing: sheet {sheet_id}: its {rule}")); + assert_eq!(names(broken.as_str()), expected, "{broken:?}"); + } +} + +/// The content language is a well-formed BCP 47 tag of 2 to 35 characters. +#[test] +fn the_language_is_a_bcp_47_tag_of_2_to_35_characters() { + let base = file(&bare()); + let why = |tag: &str| { + let mut v = base.clone(); + v["language"] = json!(tag); + refusal(&v) + }; + let longest = "en-abcdefgh-abcdefgh-abcdefgh-abcde"; + assert_eq!(longest.len(), 35); + for tag in ["en", "de", "ast", "en-GB", "EN-gb", "zh-Hans-CN", "sr-Latn-RS", "es-419", longest] { + assert_eq!(why(tag), "read", "{tag}"); + } + let rule = "damaged drawing: the language is not a BCP 47 tag of 2 to 35 characters (such as `en` or `en-GB`)"; + let too_long = format!("{longest}f"); + let broken = + ["e", "", too_long.as_str(), "en_GB", "en-", "-en", "en--GB", "e1", "x-private", "en-abcdefghi", "abcdefghi", "日本", "en GB", "en\n"]; + for tag in broken { + assert_eq!(why(tag), rule, "{tag:?}"); + } +} + +/// A fixed unit is a length unit of the expression unit table, written as the table writes it. +#[test] +fn a_fixed_unit_is_a_length_unit_of_the_expression_table() { + let base = file(&bare()); + let units = |units: Value| { + let mut v = base.clone(); + v["units"] = units; + read(&v).map(|d| d.units) + }; + for (unit, kind, _) in expr::UNITS { + assert_eq!(units(json!({"fixed": {"unit": unit}})).is_ok(), kind == expr::Kind::Length, "{unit}"); + } + assert_eq!(units(json!({"fixed": {"unit": "in"}})).unwrap(), Units::Fixed { unit: "in".into() }); + assert_eq!(units(json!("follow_model")).unwrap(), Units::FollowModel); + let rule = "damaged drawing: the drawing's unit is not a length unit (such as `mm` or `in`)"; + for unit in ["furlong", "deg", "°", "none", "", " in", "in ", "IN"] { + assert_eq!(units(json!({"fixed": {"unit": unit}})).unwrap_err().to_string(), rule, "{unit:?}"); + } + for broken in [json!("follow_design"), json!({"fixed": "in"}), json!({"fixed": {}}), json!(null)] { + assert!(units(broken.clone()).is_err(), "{broken}"); + } +} + +/// A sheet's border is saved as a copy of its profile, field by field, and read back as saved, so +/// a later built-in never changes it; a profile's name in its place is refused. +#[test] +fn a_border_is_saved_as_a_copy_of_its_profile() { + let v = file(&plate()); + assert_eq!(v["sheets"][0]["border"], serde_json::to_value(DrawingBorderProfile::iso()).unwrap()); + assert_eq!(v["sheets"][1]["border"], serde_json::to_value(own_border()).unwrap()); + assert_eq!(read(&v).unwrap().sheets[1].border, Some(own_border())); + let mut edited = v.clone(); + edited["sheets"][0]["border"]["margins"]["left_mm"] = json!(15.0); + let iso = DrawingBorderProfile::iso(); + let wider = DrawingBorderProfile { margins: Margins { left_mm: 15.0, ..iso.margins }, ..iso }; + assert_eq!(read(&edited).unwrap().sheets[0].border, Some(wider)); + edited["sheets"][0]["border"] = json!("iso"); + assert!(refusal(&edited).starts_with("damaged drawing: ")); + edited["sheets"][0]["border"] = Value::Null; + assert_eq!(read(&edited).unwrap().sheets[0].border, None, "no drawing border"); +} + +/// What reading refuses: each damage the rules name, refused rather than patched, and files this +/// build can't read. +#[test] +fn damaged_files_are_refused_and_never_patched() { + let d = plate(); + let plate_id = ids::encode(d.sources[0].id); + let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id)); + // Two objects with one id, of one kind and of two kinds. + assert_eq!(refused(&d, |v| v["sources"][1]["id"] = json!(plate_id)), format!("damaged drawing: two objects have the id {plate_id}")); + assert_eq!(refused(&d, |v| v["sheets"][1]["id"] = json!(assembly_id)), format!("damaged drawing: two objects have the id {assembly_id}")); + assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(bolt_sheet_id)), format!("damaged drawing: two objects have the id {bolt_sheet_id}")); + // An id that isn't one, where an object has it and where a reference names it; no number form, + // and no counter. + let not_an_id = "damaged drawing: an id is 13 characters of lowercase Crockford base32, not all 0"; + for text in ["0000000000000", "FZZZZZZZZZZZZ", "000000000001", "0000000000001 ", "000000000000o", "g000000000000"] { + assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(text)), not_an_id, "{text:?}"); + assert_eq!(refused(&d, |v| v["sheets"][1]["documents"]["source"] = json!(text)), not_an_id, "{text:?}"); + } + assert!(refused(&d, |v| v["sources"][2]["id"] = json!(5)).starts_with("damaged drawing: invalid type")); + assert!(refused(&d, |v| v["next_id"] = json!(6)).contains("unknown field `next_id`")); + let mut in_memory = d.clone(); + in_memory.sources[2].id = 0; + assert_eq!(in_memory.validate().unwrap_err().to_string(), "an object has the id 0, which is never one"); + // A reference to nothing, or to an object of another kind; a source a sheet documents removed. + for missing in [ids::encode(99), assembly_id.clone()] { + let expected = format!("damaged drawing: sheet {bolt_sheet_id}: it documents source {missing}, which the drawing doesn't have"); + assert_eq!(refused(&d, |v| v["sheets"][1]["documents"]["source"] = json!(missing)), expected); + } + let without_plate = refused(&d, |v| { + v["sources"].as_array_mut().unwrap().remove(0); + }); + assert_eq!(without_plate, format!("damaged drawing: sheet {assembly_id}: it documents source {plate_id}, which the drawing doesn't have")); + // The paper, its border and its resolution, through the sheet's own rules. + let custom = |sides: Value| refused(&d, |v| v["sheets"][0]["sheet"]["size"] = json!({"custom": {"sides_mm": sides}})); + assert_eq!(custom(json!([5000.0, 0.5])), "read"); + for sides in [json!([5000.1, 100.0]), json!([0.0, 100.0]), json!([-1.0, 100.0])] { + let expected = format!("damaged drawing: sheet {assembly_id}: drawing sheet: a custom side is finite, above 0 and at most 5000 mm"); + assert_eq!(custom(sides), expected); + } + let why = custom(json!([1e10, 100.0])); + assert!(why.starts_with("damaged drawing: the number ") && why.ends_with(" is out of range"), "checked before the record: {why}"); + let nan = d.to_json().unwrap().replacen("600.0", "NaN", 1); + assert!(Drawing::from_json(&nan).unwrap_err().to_string().starts_with("not a SolidCraft drawing: "), "NaN isn't JSON"); + assert_eq!( + refused(&d, |v| v["sheets"][1]["border"]["margins"]["top_mm"] = json!(-1.0)), + format!("damaged drawing: sheet {bolt_sheet_id}: drawing sheet: a margin is finite and 0 to 5000 mm") + ); + for dpi in [25.0, 2400.0] { + assert_eq!(refused(&d, |v| v["sheets"][1]["dpi"] = json!(dpi)), "read", "{dpi}"); + } + for dpi in [24.9, 2400.1, 0.0, -300.0] { + let expected = format!("damaged drawing: sheet {bolt_sheet_id}: its pictures are 25 to 2400 dpi"); + assert_eq!(refused(&d, |v| v["sheets"][1]["dpi"] = json!(dpi)), expected, "{dpi}"); + } + // In memory, where NaN can be, the same rules. + let mut in_memory = plate(); + Arc::make_mut(&mut in_memory.sheets[1]).dpi = f64::NAN; + assert_eq!(in_memory.validate().unwrap_err().to_string(), format!("sheet {bolt_sheet_id}: its pictures are 25 to 2400 dpi")); + let sheet = Arc::make_mut(&mut in_memory.sheets[1]); + sheet.dpi = DEFAULT_DPI; + sheet.sheet.size = SheetSize::Custom { sides_mm: [f64::NAN, 100.0] }; + assert!(in_memory.validate().unwrap_err().to_string().starts_with(&format!("sheet {bolt_sheet_id}: drawing sheet: a custom side"))); + // An unknown field anywhere. + let unknown: [fn(&mut Value); 8] = [ + |v| v["views"] = json!([]), + |v| v["sources"][0]["name"] = json!("Plate"), + |v| v["sheets"][0]["scale"] = json!(1), + |v| v["sheets"][0]["sheet"]["colour"] = json!("white"), + |v| v["sheets"][0]["border"]["style"] = json!("iso"), + |v| v["sheets"][0]["documents"]["view"] = json!(1), + |v| v["sheets"][0]["excluded"]["export"] = json!(true), + |v| v["units"]["fixed"]["precision"] = json!(2), + ]; + for (i, edit) in unknown.into_iter().enumerate() { + let why = refused(&d, edit); + assert!(why.starts_with("damaged drawing: ") && why.contains("unknown field"), "{i}: {why}"); + } + // A newer build's file is refused as one before anything in it is read: a field this build + // doesn't know, or a number out of its range. + let (current, newer) = (DRAWING.current(), DRAWING.current() + 1); + let why = refused(&d, |v| { + v["format"] = json!(DRAWING.format_string(newer)); + v["views"] = json!([]); + v["sheets"][0]["dpi"] = json!(1e10); + }); + assert_eq!( + why, + format!( + "this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it" + ) + ); + // A list of pending changes means nothing beside a format made of none; beside the pending + // number, a change this build doesn't know is a newer build's. + let why = refused(&d, |v| v["format_pending"] = json!(["a-later-change"])); + if DRAWING.pending_names().is_empty() { + assert_eq!(why, "read"); + } else { + assert!(why.contains("newer SolidCraft"), "{why}"); + } + // A design is never read as a drawing, nor a drawing as a design. + let design = crate::Document::new("Plate").to_json(); + let design_format = crate::format::format_string(crate::format::FORMAT); + assert_eq!(Drawing::from_json(&design).unwrap_err().to_string(), format!("not a SolidCraft drawing (format `{design_format}`)")); + let drawing_format = DRAWING.format_string(current); + assert_eq!( + crate::Document::from_json(&d.to_json().unwrap()).unwrap_err().to_string(), + format!("not a SolidCraft design (format `{drawing_format}`)") + ); + // Not an object, not JSON, nested too deeply, too large. + assert_eq!(Drawing::from_json("[]").unwrap_err().to_string(), "not a SolidCraft drawing (expected a JSON object)"); + assert!(Drawing::from_json("").unwrap_err().to_string().starts_with("not a SolidCraft drawing: ")); + let deep: Value = serde_json::from_str(&format!("{}1{}", "[".repeat(100), "]".repeat(100))).unwrap(); + assert_eq!(refused(&d, |v| v["views"] = deep), "damaged drawing: nested too deeply"); + let deeper = format!("{{\"format\": \"{drawing_format}\", \"views\": {}1{}}}", "[".repeat(200), "]".repeat(200)); + assert!(Drawing::from_json(&deeper).unwrap_err().to_string().starts_with("not a SolidCraft drawing: ")); + let text = d.to_json().unwrap(); + let at_cap = text.clone() + &" ".repeat(MAX_DRAWING_BYTES - text.len()); + assert_eq!(Drawing::from_json(&at_cap).unwrap(), d); + assert_eq!(Drawing::from_json(&(at_cap + " ")).unwrap_err().to_string(), "drawing too large (at most 16 MiB)"); +} + +/// A run never gives an id twice: not one in use, and not a removed object's. Only another run from +/// the same seed, which tests and replays start from the same file, can give a removed id again. +#[test] +fn a_run_never_gives_an_id_twice() { + let mut id_source = IdSource::seeded(SEED); + let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape); + let mut d = Drawing::new("Ids", "en", "Sheet", a4, None, &mut id_source).unwrap(); + let mut given = BTreeSet::from([d.sheets[0].id]); + for _ in 0..MAX_SOURCES { + let id = d.allocate_id(&mut id_source).unwrap(); + d.sources.push(Source { id, path: "part.sc".into() }); + assert!(given.insert(id), "{id:#x} given twice"); + } + let removed = d.sources.pop().unwrap().id; + for _ in 0..10_000 { + let id = d.allocate_id(&mut id_source).unwrap(); + assert!(id != removed && given.insert(id), "{id:#x} given twice"); + } + // The same seed, from the start, over a file it made: every draw up to the removed id is in use. + let reopened = Drawing::from_json(&d.to_json().unwrap()).unwrap(); + let again = reopened.allocate_id(&mut IdSource::seeded(SEED)).unwrap(); + assert!(!reopened.ids().any(|id| id == again)); + assert_eq!(again, removed); +} + +/// Properties follow the design metadata's rules: other tools' data and a well-known key of another +/// kind are kept as they are, never read as numbers, while the same number elsewhere is refused. +#[test] +fn properties_are_kept_as_they_are() { + let mut v = file(&plate()); + v["properties"]["com.example.pdm"] = json!({"huge": 1e300, "ids": [1, 2, 3]}); + v["properties"]["revision"] = json!(3); + let d = read(&v).unwrap(); + assert_eq!(d.properties.get("com.example.pdm"), Some(&json!({"huge": 1e300, "ids": [1, 2, 3]}))); + assert_eq!((d.properties.revision(), d.properties.get("revision")), (None, Some(&json!(3)))); + assert_eq!(read(&file(&d)).unwrap(), d); + v["sheets"][0]["dpi"] = json!(1e300); + let why = refusal(&v); + assert!(why.starts_with("damaged drawing: the number ") && why.ends_with(" is out of range"), "{why}"); +} + +/// A drawing whose JSON would be over the read limit isn't saved. Other tools' properties are kept +/// as they came, and indenting them can grow a file read in under the limit past it, so the save +/// refuses rather than write a file that won't open: every drawing saved opens again. +#[test] +fn a_drawing_too_large_to_reopen_is_not_saved() { + let file = |zeros: usize| { + let items = vec!["0"; zeros].join(","); + let sheet = r#"{"id":"0000000000001","name":"S","sheet":{"size":{"standard":"a4"}}}"#; + let format = DRAWING.format_string(1); + format!( + r#"{{"format":"{format}","name":"A","sources":[],"language":"en","sheets":[{sheet}],"properties":{{"com.example":{{"items":[{items}]}}}}}}"# + ) + }; + // The case found in review (#3891): 2,000,000 zeros read in at about 4 MB and indent past 16 MiB. + let big = Drawing::from_json(&file(2_000_000)).unwrap(); + assert_eq!(big.to_json().unwrap_err().to_string(), "drawing too large to save (at most 16 MiB)"); + let small = Drawing::from_json(&file(100_000)).unwrap(); + assert_eq!(Drawing::from_json(&small.to_json().unwrap()).unwrap(), small); +} + +/// A new drawing has one sheet, its id the source's first, follows its designs' units and renders +/// pictures at 300 dpi; a name, language or border that breaks the rules is refused. +#[test] +fn a_new_drawing_has_one_sheet_and_keeps_the_rules() { + let d = bare(); + let first = IdSource::seeded(SEED).next(&BTreeSet::new()).unwrap(); + assert_eq!((d.sheets.len(), d.sheets[0].id, d.sheets[0].dpi, &d.units), (1, first, DEFAULT_DPI, &Units::FollowModel)); + assert_eq!((d.format, d.format_pending), (DRAWING.format_string(DRAWING.current()), DRAWING.pending_names())); + let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape); + let thin = DrawingBorderProfile { drawing_border_line_mm: 0.0, ..DrawingBorderProfile::iso() }; + let broken = [("", "en", "Sheet", None), ("Bare", "en_GB", "Sheet", None), ("Bare", "en", "\n", None), ("Bare", "en", "Sheet", Some(thin))]; + for (name, language, sheet, border) in broken { + let made = Drawing::new(name, language, sheet, a4, border, &mut IdSource::seeded(SEED)); + assert!(made.is_err(), "{name:?} {language:?} {sheet:?} {border:?}"); + } +} + +/// Hostile input never panics: a valid file's text cut, overwritten and given lies, and its JSON +/// values replaced, repeated and dropped. Each read gives an error, or a drawing that keeps the +/// rules and reads back as it saves. +#[test] +fn mutated_files_give_an_error_or_a_valid_drawing_never_a_panic() { + let good = plate().to_json().unwrap(); + let tree: Value = serde_json::from_str(&good).unwrap(); + let nodes = count(&tree) as u64; + let lies = [ + json!(null), + json!(0), + json!(-1), + json!(1), + json!(1e10), + json!(u64::MAX), + json!(""), + json!("x".repeat(300)), + json!("0000000000000"), + json!("0000000000001"), + json!("fzzzzzzzzzzzz"), + json!("FZZZZZZZZZZZZ"), + json!([]), + json!({}), + json!(true), + json!("follow_model"), + json!({"fixed": {"unit": "deg"}}), + ]; + let words = ["NaN", "1e400", "-0", "18446744073709551616", "\"", "{", "]", ",", "null", "\u{0}", "é", "o", "I"]; + let mut seed: u64 = 0x9e37_79b9_7f4a_7c15; + let mut rnd = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut read_back = 0; + for i in 0..10_000 { + let text = if i % 2 == 0 { + let mut v = tree.clone(); + let lie = &lies[(rnd() % lies.len() as u64) as usize]; + mutate(&mut v, &mut ((rnd() % nodes) as usize), lie, rnd() % 3); + v.to_string() + } else { + let mut s = good.clone().into_bytes(); + for _ in 0..1 + rnd() % 3 { + // A run of up to 15 bytes taken out, overwritten by one byte or replaced by a lie. + let at = (rnd() % (s.len() as u64 + 1)) as usize; + let cut = ((rnd() % 16) as usize).min(s.len() - at); + let byte = [(rnd() >> 56) as u8]; + let put: &[u8] = match rnd() % 3 { + 0 => &[], + 1 => &byte, + _ => words[(rnd() % words.len() as u64) as usize].as_bytes(), + }; + let next = [&s[..at], put, &s[at + cut..]].concat(); + s = next; + if rnd() % 8 == 0 { + s.truncate(at); + } + } + String::from_utf8_lossy(&s).into_owned() + }; + if let Ok(d) = Drawing::from_json(&text) { + assert!(d.validate().is_ok() && d.ids().all(|id| id != 0), "{text}"); + assert_eq!(Drawing::from_json(&d.to_json().unwrap()).unwrap(), d, "{text}"); + read_back += 1; + } + } + assert!(read_back > 50, "only {read_back} mutated files read: the mutations hardly reach the record's rules"); +} + +/// The number of values in `v`, itself included. +fn count(v: &Value) -> usize { + 1 + match v { + Value::Array(a) => a.iter().map(count).sum::(), + Value::Object(o) => o.values().map(count).sum(), + _ => 0, + } +} + +/// Change the `pick`th value of `v`, depth first: `how` 1 repeats a list's first element (two +/// objects with one id), 2 drops an object's first field, and otherwise `lie` takes its place. +fn mutate(v: &mut Value, pick: &mut usize, lie: &Value, how: u64) -> bool { + if *pick == 0 { + match (v, how) { + (Value::Array(a), 1) if !a.is_empty() => { + let first = a[0].clone(); + a.push(first); + } + (Value::Object(o), 2) if !o.is_empty() => { + let key = o.keys().next().cloned().unwrap_or_default(); + o.remove(&key); + } + (v, _) => *v = lie.clone(), + } + return true; + } + *pick -= 1; + match v { + Value::Array(a) => a.iter_mut().any(|x| mutate(x, pick, lie, how)), + Value::Object(o) => o.values_mut().any(|x| mutate(x, pick, lie, how)), + _ => false, + } +} diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index f7fb17000d..2e94d4a6e3 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -485,7 +485,7 @@ fn mirror_y(r: &Region2) -> Region2 { .iter() .map(|s| match *s { Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep }, - other => sheet::map_controls(&other, f), + other => other.map_controls(f), }) .collect(); Loop2 { segs }.ccw() diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index ff4005e1d4..72d2af11bc 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -232,6 +232,11 @@ //! this build writes the next number (which no release reads), with the names of these changes //! in `format_pending`, so a later build applies just the steps a file is missing. At release //! time `scripts/fold-format-pending.sh` moves them into the next `v/`. +//! +//! Drawings (`solidcraft-drawing/N`, #3890) are versioned by the same code ([`Family`]) from their +//! own first format, 1 (the record in [`crate::drawing`]): each later change is one file in +//! `src/drawing/format/`, in `v/` or `pending/` as above, and the release script folds that +//! folder too. use serde_json::Value; @@ -244,11 +249,11 @@ pub const FORMAT: u32 = FORMATS.current(); /// of [`Part`]s (`src/format/`, listed by `build.rs`). const HAND_WRITTEN: u32 = 22; -/// One change to the file format: a file in `src/format/v/` or `src/format/pending/`. +/// One change to a family's file format: a file in its `v/` or `pending/` folder. pub(crate) struct Part { /// The file's name, which a file written while the change was pending lists. pub name: &'static str, - /// How a design from before the change is rewritten as it opens, if it is. + /// How a file from before the change is rewritten as it opens, if it is. pub step: Option, } @@ -257,13 +262,102 @@ include!(concat!(env!("OUT_DIR"), "/format_parts.rs")); /// Every format this build knows and how to upgrade a design through them. const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: PENDING }; -/// The names of the pending changes this build writes into a design (`format_pending`). -pub fn pending_names() -> Vec { - FORMATS.pending.iter().map(|p| p.name.to_string()).collect() +/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has +/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them); +/// reading a file's format, refusing a file a newer build saved and the upgrade are the same code +/// for every family. +pub struct Family<'a> { + /// What a file of the family is called in messages. + noun: &'static str, + /// The tag before the number in a file's `format`. + prefix: &'static str, + /// The tag files saved before the rename carry: read, never written (docs/compatibility.md). + legacy_prefix: Option<&'static str>, + /// The format of a file that names none: designs saved before format strings are format 1; a + /// drawing always names its own. + untagged: Option, + formats: Formats<'a>, } -/// The upgrade path: the hand-written steps up to [`HAND_WRITTEN`], the folded formats after it -/// (each a list of changes) and the pending changes, which make the next format. +/// Designs (`solidcraft/N`): the upgrade steps written out up to format 22, then the change files +/// in `src/format/`. +pub const DESIGN: Family<'static> = + Family { noun: "design", prefix: "solidcraft/", legacy_prefix: Some(LEGACY_FORMAT_PREFIX), untagged: Some(1), formats: FORMATS }; + +/// Drawings (`solidcraft-drawing/N`, #3890): format 1 is the first drawing record, and every later +/// change is a file in `src/drawing/format/`. +pub const DRAWING: Family<'static> = Family { + noun: "drawing", + prefix: "solidcraft-drawing/", + legacy_prefix: None, + untagged: None, + formats: Formats { hand: &[], folded: DRAWING_FOLDED, pending: DRAWING_PENDING }, +}; + +impl Family<'_> { + /// The format this build writes: the last folded one, or the next while changes are pending. + pub const fn current(&self) -> u32 { + self.formats.current() + } + + /// The names of the pending changes this build writes into a file (`format_pending`). + pub fn pending_names(&self) -> Vec { + self.formats.pending.iter().map(|p| p.name.to_string()).collect() + } + + /// The format tag of format `n`. + pub fn format_string(&self, n: u32) -> String { + format!("{}{n}", self.prefix) + } + + /// The version in one of this family's format tags; `None` for any other tag, another + /// family's included. + pub fn version_of(&self, s: &str) -> Option { + s.strip_prefix(self.prefix).or_else(|| self.legacy_prefix.and_then(|p| s.strip_prefix(p)))?.parse().ok() + } + + /// Read a file's format and take its `format_pending` out of its JSON, before anything else + /// in it is read: a file that isn't this family's, or that a newer build saved, is refused + /// here, so nothing a newer build saved is judged by this build's rules. + pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option>)> { + let noun = self.noun; + let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})")); + let Some(obj) = v.as_object() else { return Err(not_ours("expected a JSON object".into())) }; + let version = match obj.get("format") { + None => self.untagged.ok_or_else(|| not_ours("no format".into()))?, + Some(Value::String(f)) => self.version_of(f).ok_or_else(|| not_ours(format!("format `{f}`")))?, + Some(_) => return Err(not_ours("format is not a string".into())), + }; + let applied = take_names(v, "format_pending", noun)?; + self.formats.check(noun, version, applied.as_deref())?; + Ok((version, applied)) + } + + /// Bring a file of format `version` up to this build's; `applied` is its `format_pending` + /// (both from [`Family::take_version`]). + pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> { + self.formats.upgrade(self.noun, v, version, applied) + } + + /// Every number a file of the family holds is in range ([`check_numbers`]), but for its + /// top-level `kept` field: other tools' data (a design's `metadata`, a drawing's `properties`), + /// kept as it is and never read as a number (#1015); parsing already bounds its nesting. + pub(crate) fn check_numbers(&self, v: &Value, kept: &str) -> Result<()> { + match v { + Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != kept).try_for_each(|(_, x)| check_numbers(x, self.noun, 1)), + v => check_numbers(v, self.noun, 0), + } + } +} + +/// The names of the pending changes this build writes into a design (`format_pending`). +pub fn pending_names() -> Vec { + DESIGN.pending_names() +} + +/// A family's upgrade path: the steps written out by hand (designs' up to [`HAND_WRITTEN`]), the +/// folded formats after them (each a list of changes) and the pending changes, which make the +/// next format. struct Formats<'a> { hand: &'a [JsonStep], folded: &'a [(u32, &'a [Part])], @@ -300,26 +394,41 @@ impl Formats<'_> { } } - /// Bring a design of format `version` up to [`Formats::current`]. `applied` is the file's - /// `format_pending`: the changes of its format it already has, when it was written while they - /// were pending; the others' steps run first. Recognize changes across the entire history: - /// a branch's pending change may have landed at a later number. Unknown changes are refused - /// even after folding, and already-applied changes never run again at their later number. - fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> { - if version == 0 || version > self.current() { - return Err(DocError::Invalid(format!("not a SolidCraft design (format {version})"))); + /// Refuse a `noun` of format `version` that this build can't read: format 0, a newer number, + /// or a change `applied` (its `format_pending`) names that this build doesn't have anywhere in + /// its history, which only a newer build's file can. Changes are recognized across the entire + /// history: a branch's pending change may have landed at a later number. A list beside a format + /// that isn't made of changes means nothing (a tool or a test set an older number on a newer + /// file). + fn check(&self, noun: &str, version: u32, applied: Option<&[String]>) -> Result<()> { + let newer = |what: String| DocError::Invalid(format!("this {noun} was saved by a newer SolidCraft ({what}): update SolidCraft to open it")); + let current = self.current(); + if version == 0 { + return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)"))); } - // A list beside a format that isn't made of changes means nothing (a tool or a test set - // an older number on a newer file): it is ignored, like any field an upgrade drops. + if version > current { + return Err(newer(format!("file format {version}; this version reads formats up to {current}"))); + } + let applied = applied.filter(|_| self.parts(version).is_some()).unwrap_or_default(); + match applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) { + Some(unknown) => Err(newer(format!("file format {version} with `{unknown}`, which this version doesn't know"))), + None => Ok(()), + } + } + + /// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's + /// `format_pending`: the changes of its format it already has, when it was written while they + /// were pending; the others' steps run first. A file [`Formats::check`] refuses is refused, + /// whoever calls, before anything is changed; already-applied changes never run again at + /// their later number. + fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> { + self.check(noun, version, applied)?; + // A list beside a format that isn't made of changes is ignored, like any field an upgrade + // drops. let applied = applied.filter(|_| self.parts(version).is_some()); let already_applied = |part: &Part| applied.is_some_and(|names| names.iter().any(|name| self.canonical_applied_name(version, name) == part.name)); - if let Some(applied) = applied { - if let Some(unknown) = applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) { - return Err(DocError::Invalid(format!( - "this design was saved by a newer SolidCraft (file format {version} with `{unknown}`, which this version doesn't know): update SolidCraft to open it" - ))); - } + if applied.is_some() { for part in self.parts(version).unwrap_or_default().iter().filter(|part| !already_applied(part)) { part.step.map_or(Ok(()), |step| step(v))?; } @@ -338,25 +447,26 @@ impl Formats<'_> { } } -/// Take a file's `format_pending` (the pending changes it has) out of its JSON. -fn take_pending(v: &mut Value) -> Result>> { - let Some(list) = v.as_object_mut().and_then(|o| o.remove("format_pending")) else { return Ok(None) }; - let invalid = || DocError::Invalid("damaged design: `format_pending` is not a short list of names".into()); +/// Take a short list of names out of a file's JSON: its `format_pending` (the pending changes it +/// has). +fn take_names(v: &mut Value, key: &str, noun: &str) -> Result>> { + let Some(list) = v.as_object_mut().and_then(|o| o.remove(key)) else { return Ok(None) }; + let invalid = || DocError::Invalid(format!("damaged {noun}: `{key}` is not a short list of names")); let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?; list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::>().map(Some) } pub fn format_string(n: u32) -> String { - format!("solidcraft/{n}") + DESIGN.format_string(n) } /// The format tag of designs saved before the rename. Compatibility shim (docs/compatibility.md): /// read, never written. pub const LEGACY_FORMAT_PREFIX: &str = "solvecraft/"; -/// The version in a format string (`solidcraft/N`, or the legacy `solvecraft/N`). +/// The version in a design's format string (`solidcraft/N`, or the legacy `solvecraft/N`). pub fn version_of(s: &str) -> Option { - s.strip_prefix("solidcraft/").or_else(|| s.strip_prefix(LEGACY_FORMAT_PREFIX))?.parse().ok() + DESIGN.version_of(s) } /// JSON-level upgrades up to [`HAND_WRITTEN`]: entry `i` turns format `i + 1` into `i + 2`. @@ -566,23 +676,7 @@ fn v2_to_v3(v: &mut Value) -> Result<()> { /// Read a design file's JSON, upgrading older formats. pub fn read(s: &str) -> Result { let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?; - let Some(obj) = v.as_object() else { - return Err(DocError::Invalid("not a SolidCraft design (expected a JSON object)".into())); - }; - let version = match obj.get("format") { - None => 1, - Some(Value::String(f)) => version_of(f).ok_or_else(|| DocError::Invalid(format!("not a SolidCraft design (format `{f}`)")))?, - Some(_) => return Err(DocError::Invalid("not a SolidCraft design (format is not a string)".into())), - }; - let applied = take_pending(&mut v)?; - if version == 0 { - return Err(DocError::Invalid("not a SolidCraft design (format 0)".into())); - } - if version > FORMAT { - return Err(DocError::Invalid(format!( - "this design was saved by a newer SolidCraft (file format {version}; this version reads formats up to {FORMAT}): update SolidCraft to open it" - ))); - } + let (version, applied) = DESIGN.take_version(&mut v)?; check_design_numbers(&v)?; crate::config::validate_rules_json(&v)?; crate::config::validate_styles_json(&v)?; @@ -594,7 +688,7 @@ pub fn read(s: &str) -> Result { crate::format_offsets::validate_raw(&v)?; let legacy_wires = validate_wires(&v, version)?; validate_body_transfers(&v)?; - FORMATS.upgrade(&mut v, version, applied.as_deref())?; + DESIGN.upgrade(&mut v, version, applied.as_deref())?; let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?; check_typed_numbers(&d)?; let mut legacy_by_feature = std::collections::BTreeMap::>::new(); @@ -1060,22 +1154,22 @@ const MAX_REAL: f64 = 1e9; /// already bounds its nesting. Engineering SI constants have their own bounded admission /// (e.g. E in Pa): only the exact root user-material card field bypasses the coordinate bound. fn check_design_numbers(v: &Value) -> Result<()> { - let Some(root) = v.as_object() else { return check_numbers(v, 0) }; + let Some(root) = v.as_object() else { return check_numbers(v, "design", 0) }; for (key, value) in root { if key == "metadata" { continue; } if key != "user_materials" { - check_numbers(value, 1)?; + check_numbers(value, "design", 1)?; continue; } - let Some(materials) = value.as_array() else { return check_numbers(value, 1) }; + let Some(materials) = value.as_array() else { return check_numbers(value, "design", 1) }; if materials.len() > crate::MAX_USER_MATERIALS { return Err(DocError::Invalid("document exceeds 1000 user materials".into())); } for material in materials { let Some(fields) = material.as_object() else { - check_numbers(material, 2)?; + check_numbers(material, "design", 2)?; continue; }; for (field, data) in fields { @@ -1084,7 +1178,7 @@ fn check_design_numbers(v: &Value) -> Result<()> { if field == "physical_card" && !data.is_null() { crate::physical_material::validate_card(data)?; } else { - check_numbers(data, 3)?; + check_numbers(data, "design", 3)?; } } } @@ -1102,21 +1196,22 @@ fn check_typed_numbers(d: &Document) -> Result<()> { check_design_numbers(&typed) } -/// Every non-integer number outside separately validated engineering cards is finite and within ±`MAX_REAL` (a damaged or hostile -/// file is refused instead of overflowing the geometry later). -fn check_numbers(v: &Value, depth: usize) -> Result<()> { +/// Every non-integer number in a `noun`'s file, outside separately validated engineering cards, +/// is finite and within ±`MAX_REAL` (a damaged or hostile file is refused instead of overflowing +/// the geometry later). +fn check_numbers(v: &Value, noun: &str, depth: usize) -> Result<()> { if depth > 64 { - return Err(DocError::Invalid("damaged design: nested too deeply".into())); + return Err(DocError::Invalid(format!("damaged {noun}: nested too deeply"))); } match v { Value::Number(n) if !(n.is_u64() || n.is_i64()) => { let x = n.as_f64().unwrap_or(f64::NAN); if !(x.is_finite() && x.abs() <= MAX_REAL) { - return Err(DocError::Invalid(format!("damaged design: the number {n} is out of range"))); + return Err(DocError::Invalid(format!("damaged {noun}: the number {n} is out of range"))); } } - Value::Array(a) => a.iter().try_for_each(|x| check_numbers(x, depth + 1))?, - Value::Object(m) => m.values().try_for_each(|x| check_numbers(x, depth + 1))?, + Value::Array(a) => a.iter().try_for_each(|x| check_numbers(x, noun, depth + 1))?, + Value::Object(m) => m.values().try_for_each(|x| check_numbers(x, noun, depth + 1))?, _ => {} } Ok(()) @@ -1224,25 +1319,25 @@ mod tests { assert_eq!(formats.current(), next); for version in [1, HAND_WRITTEN, next - 1] { let mut v = serde_json::json!({"features": [], "assembly": {"joints": [{"flip": false}]}}); - formats.upgrade(&mut v, version, None).unwrap(); + formats.upgrade("design", &mut v, version, None).unwrap(); assert_eq!(trace_of(&v), ["a", "b"], "from format {version}"); // The hand-written steps ran too: format 3's joints were flipped on the way up. assert_eq!(v["assembly"]["joints"][0]["flip"], Value::Bool(version < 4), "from format {version}"); } // A design of the pending number written before `b-second` existed gets just that step. let mut v = serde_json::json!({}); - formats.upgrade(&mut v, next, Some(&["a-first".to_string()])).unwrap(); + formats.upgrade("design", &mut v, next, Some(&["a-first".to_string()])).unwrap(); assert_eq!(trace_of(&v), ["b"]); // One that has both, or that this build wrote, needs nothing. let mut v = serde_json::json!({}); - formats.upgrade(&mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap(); + formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap(); assert_eq!(trace_of(&v), Vec::<&str>::new()); // A change this build doesn't know is a newer build's: refused, not guessed at. - let err = formats.upgrade(&mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err(); + let err = formats.upgrade("design", &mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err(); assert!(err.to_string().contains("newer SolidCraft"), "{err}"); // A list on a format that isn't made of changes means nothing. let mut v = serde_json::json!({}); - formats.upgrade(&mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap(); + formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap(); assert_eq!(trace_of(&v), ["a", "b"]); } @@ -1257,13 +1352,13 @@ mod tests { let formats = Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] }; assert_eq!(formats.current(), next); let mut old = serde_json::json!({}); - formats.upgrade(&mut old, HAND_WRITTEN, None).unwrap(); + formats.upgrade("design", &mut old, HAND_WRITTEN, None).unwrap(); assert_eq!(trace_of(&old), ["a", "b"]); let mut nightly = serde_json::json!({}); - formats.upgrade(&mut nightly, next, Some(&["a-first".into()])).unwrap(); + formats.upgrade("design", &mut nightly, next, Some(&["a-first".into()])).unwrap(); assert_eq!(trace_of(&nightly), ["b"]); let mut released = serde_json::json!({}); - formats.upgrade(&mut released, next, None).unwrap(); + formats.upgrade("design", &mut released, next, None).unwrap(); assert_eq!(trace_of(&released), Vec::<&str>::new()); } @@ -1276,7 +1371,7 @@ mod tests { let folded = Formats { hand: &hand, folded: &[(29, &first), (30, &cards)], pending: &[] }; for formats in [pending, folded] { let mut file = serde_json::json!({"trace": ["b"]}); - formats.upgrade(&mut file, 29, Some(&["physical-material-cards".into()])).unwrap(); + formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap(); assert_eq!(trace_of(&file), ["b", "a"], "never replay existing card semantics"); } } @@ -1288,7 +1383,7 @@ mod tests { let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] }; let mut file = serde_json::json!({"trace": [], "sentinel": 7}); let original = file.clone(); - let error = formats.upgrade(&mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err(); + let error = formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err(); assert!(error.to_string().contains("physical-material-cards"), "{error}"); assert_eq!(file, original); } @@ -1303,7 +1398,7 @@ mod tests { for version in [28, 30] { let mut file = serde_json::json!({"trace": []}); let original = file.clone(); - let error = formats.upgrade(&mut file, version, Some(&["physical-material-cards".into()])).unwrap_err(); + let error = formats.upgrade("design", &mut file, version, Some(&["physical-material-cards".into()])).unwrap_err(); assert!(error.to_string().contains("physical-material-cards"), "{error}"); assert_eq!(file, original); } @@ -1320,7 +1415,7 @@ mod tests { { let mut file = serde_json::json!({"trace": ["b"]}); let original = file.clone(); - let error = formats.upgrade(&mut file, 29, Some(&names)).unwrap_err(); + let error = formats.upgrade("design", &mut file, 29, Some(&names)).unwrap_err(); assert!(error.to_string().contains("unknown-card-codec"), "{error}"); assert_eq!(file, original); } @@ -1337,7 +1432,7 @@ mod tests { vec!["material-engineering-cards".into(), "physical-material-cards".into()], ] { let mut file = serde_json::json!({"trace": ["b"]}); - formats.upgrade(&mut file, 29, Some(&names)).unwrap(); + formats.upgrade("design", &mut file, 29, Some(&names)).unwrap(); assert_eq!(trace_of(&file), ["b", "a"]); } } @@ -1350,7 +1445,7 @@ mod tests { let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }; let mut file = serde_json::json!({"trace": ["a"], "metadata": {"format_pending": ["physical-material-cards"]}}); let metadata = file["metadata"].clone(); - formats.upgrade(&mut file, 29, None).unwrap(); + formats.upgrade("design", &mut file, 29, None).unwrap(); assert_eq!(trace_of(&file), ["a", "b"]); assert_eq!(file["metadata"], metadata); } @@ -1365,7 +1460,7 @@ mod tests { let formats = Formats { hand: &[], folded: &[(2, &parts)], pending }; let mut file = serde_json::json!({"trace": []}); let original = file.clone(); - let err = formats.upgrade(&mut file, 2, Some(&["experimental-card".into()])).unwrap_err(); + let err = formats.upgrade("design", &mut file, 2, Some(&["experimental-card".into()])).unwrap_err(); assert!(err.to_string().contains("experimental-card") && err.to_string().contains("update SolidCraft"), "{err}"); assert_eq!(file, original, "refuse before applying even a known migration"); } @@ -1381,14 +1476,192 @@ mod tests { let folded = Formats { hand: &[], folded: &[(2, &first), (3, &later)], pending: &[] }; for formats in [pending, folded] { let mut experimental = serde_json::json!({"trace": ["b"]}); - formats.upgrade(&mut experimental, 2, Some(&["b-second".into()])).unwrap(); + formats.upgrade("design", &mut experimental, 2, Some(&["b-second".into()])).unwrap(); assert_eq!(trace_of(&experimental), ["b", "a"]); let mut released = serde_json::json!({"trace": ["a"]}); - formats.upgrade(&mut released, 2, None).unwrap(); + formats.upgrade("design", &mut released, 2, None).unwrap(); assert_eq!(trace_of(&released), ["a", "b"], "ordinary released files still get later migrations"); } } + /// A design naming a pending change this build doesn't have is a newer build's, refused as + /// such before anything else in it is checked (as a newer number is), so a newer build's + /// fields never read as damage. + #[test] + fn a_newer_builds_pending_change_is_refused_before_the_design_is_checked() { + let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap(); + file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]); + file["features"] = serde_json::json!([{"type": "emboss", "face": {"body": "Body", "at": [0, 0, 0], "a_newer_key": true}}]); + let error = read(&file.to_string()).unwrap_err().to_string(); + if PENDING.is_empty() { + assert!(error.contains("emboss face"), "a list beside a folded format means nothing: {error}"); + } else { + assert_eq!( + error, + format!( + "this design was saved by a newer SolidCraft (file format {FORMAT} with `not-a-change-anyone-made`, which this version doesn't know): update SolidCraft to open it" + ) + ); + } + } + + /// Drawings follow the same rules from their own first format: folded formats follow on from 1 + /// (`v2/`, `v3/`, …), the next number is written while changes are pending, and the build lists + /// every change file in the drawing's folder, in file-name order. + #[test] + fn drawing_formats_follow_on_from_one_and_every_change_file_is_listed() { + for (i, (version, parts)) in DRAWING_FOLDED.iter().enumerate() { + assert_eq!(*version, 2 + i as u32); + assert!(!parts.is_empty(), "drawing format {version} has no changes"); + } + assert_eq!(DRAWING.current(), 1 + DRAWING_FOLDED.len() as u32 + u32::from(!DRAWING_PENDING.is_empty())); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/drawing/format"); + let names = |dir: &str| -> Vec { + let mut names: Vec = std::fs::read_dir(root.join(dir)) + .map(|d| d.filter_map(|e| e.ok()?.file_name().to_str()?.strip_suffix(".rs").map(str::to_string)).collect()) + .unwrap_or_default(); + names.sort(); + names + }; + assert_eq!(names("pending"), DRAWING.pending_names()); + for (version, parts) in DRAWING_FOLDED { + assert_eq!(names(&format!("v{version}")), parts.iter().map(|p| p.name).collect::>()); + } + } + + /// A design's format tag never reads as a drawing's, nor a drawing's as a design's, at any + /// number: no family's tag starts another's, the legacy tag is a design's only, and each family + /// refuses the other's files by name. + #[test] + fn a_design_never_reads_as_a_drawing_or_the_other_way_round() { + let families = [&DESIGN, &DRAWING]; + for a in families { + assert!(a.prefix.len() > 1 && a.prefix.ends_with('/'), "{}", a.prefix); + for b in families.into_iter().filter(|b| b.prefix != a.prefix) { + assert!(!a.prefix.starts_with(b.prefix), "{} starts {}", b.prefix, a.prefix); + } + } + for n in [0, 1, FORMAT, FORMAT + 1, DRAWING.current(), DRAWING.current() + 1, u32::MAX] { + assert_eq!(DESIGN.version_of(&DESIGN.format_string(n)), Some(n)); + assert_eq!(DRAWING.version_of(&DRAWING.format_string(n)), Some(n)); + assert_eq!(DRAWING.version_of(&DESIGN.format_string(n)), None, "{n}"); + assert_eq!(DESIGN.version_of(&DRAWING.format_string(n)), None, "{n}"); + assert_eq!(DRAWING.version_of(&format!("{LEGACY_FORMAT_PREFIX}{n}")), None, "{n}: the legacy tag is a design's"); + } + for tag in [ + "", + "solidcraft-drawing/", + "solidcraft-drawing", + "solidcraft-drawing/x", + "solidcraft-drawing/-1", + "solidcraft-drawing/4294967296", + "Solidcraft-drawing/1", + "solidcraft-drawing/1 ", + "solidcraft-drawings/1", + "solidcraft/drawing/1", + ] { + assert_eq!(DRAWING.version_of(tag), None, "{tag:?}"); + } + // The number reads as Rust reads a `u32`, a leading `+` included, as in a design's tag. + assert_eq!(DRAWING.version_of("solidcraft-drawing/+1"), Some(1)); + let error = DRAWING.take_version(&mut serde_json::json!({"format": format_string(FORMAT)})).unwrap_err().to_string(); + assert_eq!(error, format!("not a SolidCraft drawing (format `solidcraft/{FORMAT}`)")); + let error = read(&serde_json::json!({"format": DRAWING.format_string(1)}).to_string()).unwrap_err().to_string(); + assert_eq!(error, "not a SolidCraft design (format `solidcraft-drawing/1`)"); + } + + /// Every refusal names its family: a design's read exactly as they did before drawings had one + /// (the troubleshooting guide quotes them), a drawing's say "drawing". A file that names no + /// format is a design from before format strings, never a drawing. + #[test] + fn refusals_name_the_family_and_a_designs_read_as_before() { + use serde_json::json; + fn refusal(family: &Family<'_>, mut file: Value) -> String { + family.take_version(&mut file).map_or_else(|e| e.to_string(), |opened| format!("read as {opened:?}")) + } + fn tagged(family: &Family<'_>, n: u32) -> Value { + serde_json::json!({"format": family.format_string(n)}) + } + fn newer(noun: &str, n: u32, current: u32) -> String { + format!( + "this {noun} was saved by a newer SolidCraft (file format {n}; this version reads formats up to {current}): update SolidCraft to open it" + ) + } + assert_eq!(refusal(&DESIGN, tagged(&DESIGN, FORMAT + 1)), newer("design", FORMAT + 1, FORMAT)); + assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap_err().to_string(), newer("design", FORMAT + 1, FORMAT)); + let current = DRAWING.current(); + assert!(matches!(DRAWING.take_version(&mut tagged(&DRAWING, current)), Ok((v, None)) if v == current)); + for n in [current + 1, u32::MAX] { + assert_eq!(refusal(&DRAWING, tagged(&DRAWING, n)), newer("drawing", n, current)); + } + for (family, noun) in [(&DESIGN, "design"), (&DRAWING, "drawing")] { + assert_eq!(refusal(family, tagged(family, 0)), format!("not a SolidCraft {noun} (format 0)")); + assert_eq!(refusal(family, json!([])), format!("not a SolidCraft {noun} (expected a JSON object)")); + assert_eq!(refusal(family, json!({"format": 1})), format!("not a SolidCraft {noun} (format is not a string)")); + assert_eq!(refusal(family, json!({"format": ""})), format!("not a SolidCraft {noun} (format ``)")); + // `format_pending`'s caps, on both sides of each. + let mut file = tagged(family, 1); + file["format_pending"] = vec!["x".repeat(128); 1000].into(); + assert!(family.take_version(&mut file).is_ok(), "{noun}: 1,000 names of 128 bytes"); + for damaged in [vec!["x".repeat(128); 1001], vec!["x".repeat(129)]] { + let mut file = tagged(family, 1); + file["format_pending"] = damaged.into(); + assert_eq!(refusal(family, file), format!("damaged {noun}: `format_pending` is not a short list of names")); + } + } + // A change this build doesn't know, in a design of the pending number. + let pending = [traced("a-first")]; + let design = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN }; + let next = design.current(); + let file = json!({"format": format_string(next), "format_pending": ["c-later"]}); + assert_eq!( + refusal(&design, file), + format!( + "this design was saved by a newer SolidCraft (file format {next} with `c-later`, which this version doesn't know): update SolidCraft to open it" + ) + ); + assert!(matches!(DESIGN.take_version(&mut json!({})), Ok((1, None)))); + assert_eq!(refusal(&DRAWING, json!({})), "not a SolidCraft drawing (no format)"); + } + + /// One drawing change pending, as a drawing's first change will be: the build writes format 2 + /// and names the change in `format_pending`. Its step runs for a drawing without the change and + /// never for one with it; a drawing naming a change this build lacks, or of format 3, is a newer + /// build's. + #[test] + fn a_pending_drawing_change_makes_format_two() { + use serde_json::json; + let pending = [traced("a-first")]; + let drawing = Family { formats: Formats { hand: &[], folded: &[], pending: &pending }, ..DRAWING }; + assert_eq!((drawing.current(), drawing.format_string(drawing.current())), (2, "solidcraft-drawing/2".to_string())); + assert_eq!(drawing.pending_names(), ["a-first"]); + let open = |mut file: Value| -> Result> { + let (version, applied) = drawing.take_version(&mut file)?; + assert!(file.get("format_pending").is_none(), "taken out of the JSON before the record is read"); + drawing.upgrade(&mut file, version, applied.as_deref())?; + Ok(trace_of(&file).into_iter().map(str::to_string).collect()) + }; + assert_eq!(open(json!({"format": "solidcraft-drawing/1"})).unwrap(), ["a"]); + assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": []})).unwrap(), ["a"]); + assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first"]})).unwrap(), Vec::::new()); + // A list beside format 1 means nothing: the step still runs. + assert_eq!(open(json!({"format": "solidcraft-drawing/1", "format_pending": ["c-later"]})).unwrap(), ["a"]); + let error = open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first", "c-later"]})).unwrap_err().to_string(); + assert_eq!( + error, + "this drawing was saved by a newer SolidCraft (file format 2 with `c-later`, which this version doesn't know): update SolidCraft to open it" + ); + let error = open(json!({"format": "solidcraft-drawing/3"})).unwrap_err().to_string(); + assert_eq!( + error, + "this drawing was saved by a newer SolidCraft (file format 3; this version reads formats up to 2): update SolidCraft to open it" + ); + // An upgrade called without `take_version` refuses in the drawing's words too, format 0 included. + let error = drawing.upgrade(&mut json!({}), 3, None).unwrap_err().to_string(); + assert!(error.starts_with("this drawing was saved by a newer SolidCraft (file format 3;"), "{error}"); + assert_eq!(drawing.upgrade(&mut json!({}), 0, None).unwrap_err().to_string(), "not a SolidCraft drawing (format 0)"); + } + /// `format_pending` is read and checked: a damaged list is refused, a list of changes this /// build doesn't have is a newer build's, and saving writes this build's own list. #[test] diff --git a/crates/doc/src/ids.rs b/crates/doc/src/ids.rs new file mode 100644 index 0000000000..2e741ff2ea --- /dev/null +++ b/crates/doc/src/ids.rs @@ -0,0 +1,195 @@ +//! Object ids (#1017, #3890): random, so objects added side by side, on two branches of a +//! repository or in two copies of a file, never take the same id, and no counter changes with +//! every addition. Drawings use them from their first format; designs keep their numbered ids +//! until they move over too (#626 Phase 3). +//! +//! - In memory an id is a nonzero `u64`. 0 is never one (it stands for a design's root +//! component). +//! - An [`IdSource`] gives them: splitmix64 from a seed, each draw checked against the ids in +//! use and drawn again when it is one. [`IdSource::seeded`] gives the same ids for the same +//! seed, for tests and replays, and still a new one in a file that seed made; +//! [`IdSource::from_entropy`] is what a session uses. +//! - In a file an id is 13 characters of Crockford's base32, lowercase: digits and letters without +//! i, l, o and u, the first `0` to `f` (`0000000000001`, `fzzzzzzzzzzzz`). [`encode`] writes it +//! and [`decode`] reads only that spelling, so an id has one form in a file's text. A field +//! holding an id says `#[serde(with = "crate::ids")]`. + +use std::collections::BTreeSet; +use std::collections::hash_map::RandomState; +use std::hash::BuildHasher; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Deserializer, Serializer}; + +use crate::{DocError, Result}; + +/// The characters of an id in a file: 64 bits in 5-bit digits, the first holding the top four. +pub const ID_CHARS: usize = 13; +/// Crockford's base32 digits, lowercase. +const DIGITS: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz"; +/// splitmix64's step: odd, so the state passes every `u64` before it comes round again. +const GAMMA: u64 = 0x9e37_79b9_7f4a_7c15; + +/// Gives ids from a seed. +#[derive(Clone, Debug)] +pub struct IdSource { + state: u64, +} + +impl IdSource { + /// The same ids for the same seed, for tests and replays. + pub fn seeded(seed: u64) -> IdSource { + IdSource { state: seed } + } + + /// Ids another run gives only by chance: seeded from the standard library's hash keys (random + /// where the platform gives it randomness), the time, and a count of sources made, so two + /// sources made together differ. + pub fn from_entropy() -> IdSource { + static MADE: AtomicU64 = AtomicU64::new(0); + let nanos = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + IdSource::seeded(RandomState::new().hash_one((nanos, MADE.fetch_add(1, Ordering::Relaxed)))) + } + + /// The next id that isn't 0 and isn't `in_use`. A run never draws a number twice, so at most + /// one draw per id in use, and one 0, come before a free one. + pub fn next(&mut self, in_use: &BTreeSet) -> Result { + for _ in 0..in_use.len().saturating_add(2) { + let id = self.draw(); + if id != 0 && !in_use.contains(&id) { + return Ok(id); + } + } + Err(DocError::Invalid("no free id".into())) + } + + /// splitmix64: its mixing is one-to-one, so a run never gives one number twice. + fn draw(&mut self) -> u64 { + self.state = self.state.wrapping_add(GAMMA); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } +} + +/// `id` as a file writes it: [`ID_CHARS`] base32 digits, the most significant first. +pub fn encode(id: u64) -> String { + (0..ID_CHARS).rev().map(|k| DIGITS.get(((id >> (5 * k)) & 31) as usize).map_or('0', |&d| char::from(d))).collect() +} + +/// The id `text` writes, if it is one in [`encode`]'s spelling: anything else, 0 included, is not. +pub fn decode(text: &str) -> Option { + if text.len() != ID_CHARS { + return None; + } + // A first digit above `f` overflows the 64 bits. + let id = text.bytes().try_fold(0u64, |id, b| id.checked_mul(32)?.checked_add(DIGITS.iter().position(|&d| d == b)? as u64))?; + (id != 0).then_some(id) +} + +/// serde's `with` for an id field: written as [`encode`] writes it, read as [`decode`] reads it. +pub fn serialize(id: &u64, s: S) -> std::result::Result { + s.serialize_str(&encode(*id)) +} + +/// See [`serialize`]. +pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result { + let text = String::deserialize(d)?; + decode(&text).ok_or_else(|| serde::de::Error::custom(format!("an id is {ID_CHARS} characters of lowercase Crockford base32, not all 0"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn none() -> BTreeSet { + BTreeSet::new() + } + + /// Every boundary of the spelling: 1, the last value of one digit and the first of two, the + /// top digit alone and the largest id, and splitmix64's first output for seed 0. + #[test] + fn an_id_is_written_as_13_digits_and_read_back() { + let pinned = [ + (1, "0000000000001"), + (31, "000000000000z"), + (32, "0000000000010"), + (1 << 60, "1000000000000"), + (u64::MAX, "fzzzzzzzzzzzz"), + (0xe220_a839_7b1d_cdaf, "e485875xhvkdf"), + ]; + for (id, text) in pinned { + assert_eq!((encode(id), decode(text)), (text.to_string(), Some(id)), "{id:#x}"); + } + let mut source = IdSource::seeded(1); + for _ in 0..10_000 { + let id = source.next(&none()).unwrap(); + assert_eq!(decode(&encode(id)), Some(id), "{id:#x}"); + } + } + + /// Only [`encode`]'s spelling is an id: the wrong length, 0, a first digit past `f`, capitals + /// (case-sensitive, so an id has one spelling to search for), the letters Crockford leaves + /// out, and anything not ASCII. + #[test] + fn only_the_written_spelling_is_an_id() { + let not_ids = [ + "", + "000000000001", + "00000000000001", + "0000000000000", + "g000000000000", + "zzzzzzzzzzzzz", + "Fzzzzzzzzzzzz", + "000000000000A", + "000000000000i", + "000000000000l", + "000000000000o", + "000000000000u", + "00000000000é", + "000000000000-", + " 000000000001", + "000000000001\n", + ]; + for text in not_ids { + assert_eq!(decode(text), None, "{text:?}"); + } + } + + /// A seed gives the same ids every time (splitmix64's published first outputs for seed 0), and + /// a run never gives one twice. + #[test] + fn a_seeded_source_repeats_its_ids_and_never_gives_one_twice() { + let mut zero = IdSource::seeded(0); + assert_eq!([zero.draw(), zero.draw(), zero.draw()], [0xe220_a839_7b1d_cdaf, 0x6e78_9e6a_a1b9_65f4, 0x06c4_5d18_8009_454f]); + let run = |seed| { + let mut source = IdSource::seeded(seed); + (0..10_000).map(|_| source.next(&none()).unwrap()).collect::>() + }; + assert_eq!(run(7), run(7)); + assert_ne!(run(7), run(8)); + assert_eq!(run(7).into_iter().collect::>().len(), 10_000); + } + + /// A draw that is in use, or 0, is drawn again: a file the same seed made, reopened with that + /// seed, collides on every one of its ids in turn and still gets a new one. + #[test] + fn a_taken_id_or_0_is_drawn_again() { + let mut seven = IdSource::seeded(7); + let made: Vec = (0..101).map(|_| seven.draw()).collect(); + let in_use: BTreeSet = made.iter().take(100).copied().collect(); + assert_eq!(IdSource::seeded(7).next(&in_use).unwrap(), made[100]); + // The state just before 0's: its first draw is 0, and the next is seed 0's first. + let before_zero = 0u64.wrapping_sub(GAMMA); + assert_eq!(IdSource::seeded(before_zero).draw(), 0); + assert_eq!(IdSource::seeded(before_zero).next(&none()).unwrap(), IdSource::seeded(0).draw()); + } + + /// Two sources made together still give different ids. + #[test] + fn sources_from_entropy_differ() { + let (mut a, mut b) = (IdSource::from_entropy(), IdSource::from_entropy()); + assert_ne!(a.next(&none()).unwrap(), b.next(&none()).unwrap()); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 327918ef23..9a5920d312 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -23,6 +23,7 @@ pub mod construct; pub mod display_quality; pub mod display_units; mod document; +pub mod drawing; mod eval; pub mod expr; pub mod face_anchor; @@ -34,6 +35,7 @@ mod format_offsets; pub mod frame; mod history_names; pub mod hole_reference; +pub mod ids; pub use format_offsets::whole_profile_selection_from_value; pub mod joints; mod lineage; @@ -69,8 +71,8 @@ pub mod sheet; pub mod text_params; pub use assembly::{ - IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, - mat_mul, rigid, + BomType, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, map_point_expr, + mat_inverse, mat_mul, rigid, }; pub use document::*; pub use eval::{ diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 5d99c24e61..8e0122b6f3 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -6,6 +6,7 @@ //! each may be set (the design, a component or both), how its value is checked and its label. //! It keeps every other key exactly as it was through open, edit and save. Other tools keep //! their own data under one reverse-DNS key each (`com.example.tool`), holding an object. +//! A component's part properties have one more key: its bill of materials type (`bom_type`). //! //! Reading never validates: a well-known key holding something other than text is kept as it //! is and reads as absent, so a file another tool wrote always opens. Writing through @@ -20,7 +21,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use crate::{DocError, Result}; +use crate::{BomType, DocError, Result}; pub const PROJECT: &str = "project"; pub const PART_NUMBER: &str = "part_number"; @@ -92,6 +93,11 @@ pub static KEYS: &[Key] = &[ /// own, and the Bill of Materials window edits them. pub static BOM_KEYS: &[&str] = &[PART_NUMBER, DESCRIPTION, REVISION]; +/// A component's [`BomType`] in the bill of materials (one of [`BomType::as_str`]'s words). It is +/// a part property of a component only, not a row of [`KEYS`], so the Design Properties dialog +/// doesn't list it and a design refuses it. +pub const BOM_TYPE: &str = "bom_type"; + /// Longest key, in bytes. pub const MAX_KEY_BYTES: usize = 128; /// Longest well-known text value, in bytes. @@ -114,9 +120,10 @@ pub fn keys(owner: Owner) -> impl Iterator { KEYS.iter().filter(move |k| k.scope.covers(owner)) } -/// The names of [`keys`]`(owner)`, as refusals list them. +/// The names of [`keys`]`(owner)`, and a component's [`BOM_TYPE`], as refusals list them. pub fn key_list(owner: Owner) -> String { - keys(owner).map(|k| k.name).collect::>().join(", ") + let bom_type = (owner == Owner::Component).then_some(BOM_TYPE); + keys(owner).map(|k| k.name).chain(bom_type).collect::>().join(", ") } /// One of SolidCraft's keys, by name. @@ -126,7 +133,7 @@ pub fn key(name: &str) -> Option<&'static Key> { /// A key SolidCraft reads as a design or part property. pub fn is_well_known(name: &str) -> bool { - key(name).is_some() + key(name).is_some() || name == BOM_TYPE } /// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of @@ -162,6 +169,19 @@ fn text(key: &str, value: Value) -> Result> { } } +/// The bill of materials type's check: one of [`BomType`]'s words, which empty text (like +/// `null`) removes, so the component reads as normal again. +fn bom_type(value: Value) -> Result> { + let Some(word) = text(BOM_TYPE, value)? else { return Ok(None) }; + let s = word.as_str().unwrap_or_default(); + if BomType::parse(s).is_none() { + let shown: String = s.chars().take(64).collect(); + let words: Vec<&str> = BomType::ALL.iter().map(|t| t.as_str()).collect(); + return Err(invalid(format!("`{BOM_TYPE}` is one of {}, not `{shown}`", words.join(", ")))); + } + Ok(Some(word)) +} + fn depth(v: &Value) -> usize { // Iterative, so a hostile value can't overflow the stack here. let mut deepest = 0; @@ -223,6 +243,11 @@ impl Metadata { pub fn license(&self) -> Option<&str> { self.text(LICENSE) } + /// The component's bill of materials type: normal when the key is missing, or holds a word + /// this build doesn't know (a newer SolidCraft's type reads as normal and is kept as it is). + pub fn bom_type(&self) -> BomType { + self.text(BOM_TYPE).and_then(BomType::parse).unwrap_or_default() + } /// The keys SolidCraft doesn't read (other tools' data, or keys a newer SolidCraft added), /// kept as they are. @@ -260,6 +285,9 @@ impl Metadata { /// `value` as `key` may hold it in `owner`'s metadata (`None`: remove the key), or why it can't. fn check(owner: Owner, key: &str, value: Value) -> Result> { + if key == BOM_TYPE && owner == Owner::Component { + return bom_type(value); + } if let Some(k) = self::key(key).filter(|k| k.scope.covers(owner)) { return (k.check)(key, value); } @@ -333,6 +361,30 @@ mod tests { assert_eq!(m.0.keys().collect::>(), ["description"]); } + #[test] + fn a_bom_type_takes_one_of_its_words_and_reads_as_normal_otherwise() { + let mut m = Metadata::default(); + assert_eq!(m.bom_type(), BomType::Normal, "no key: normal"); + for t in BomType::ALL { + assert!(m.set(Component, BOM_TYPE, json!(t.as_str())).is_ok(), "{t:?}"); + assert_eq!(m.bom_type(), t); + assert_eq!(BomType::parse(t.as_str()), Some(t)); + } + for bad in [json!("Phantom"), json!("kit"), json!(" phantom"), json!(1), json!({"type": "phantom"}), json!("a\u{7}")] { + assert!(m.set(Component, BOM_TYPE, bad.clone()).is_err(), "{bad}"); + } + assert_eq!(m.bom_type(), BomType::Inseparable, "a refused value leaves the old one"); + assert!(m.set(Component, BOM_TYPE, json!("")).unwrap(), "empty text removes it"); + assert_eq!(m.bom_type(), BomType::Normal); + assert!(m.others().next().is_none(), "SolidCraft reads it, so it isn't another tool's key"); + // A word a newer build wrote is kept as it is and reads as normal here. + let newer: Metadata = serde_json::from_value(json!({"bom_type": "kit"})).unwrap(); + assert_eq!(newer.bom_type(), BomType::Normal); + assert_eq!(newer.get(BOM_TYPE), Some(&json!("kit"))); + assert!(!keys(Design).any(|k| k.name == BOM_TYPE), "the Design Properties dialog doesn't list it"); + assert!(Metadata::default().set(Design, BOM_TYPE, json!("phantom")).is_err(), "a design has no bill of materials type"); + } + #[test] fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() { let mut m = Metadata::default(); @@ -370,7 +422,7 @@ mod tests { } // Every key SolidCraft took on the design and on a component before the table still is. assert_eq!(key_list(Design), "project, part_number, revision, author, organization, description, license"); - assert_eq!(key_list(Component), key_list(Design)); + assert_eq!(key_list(Component), format!("{}, {BOM_TYPE}", key_list(Design)), "a component also takes its bill of materials type"); } #[test] @@ -402,7 +454,7 @@ mod tests { assert_eq!( refusal(Component, "colour"), "`colour` is not a part property: SolidCraft's are project, part_number, revision, author, organization, \ - description, license; another tool's data goes under a reverse-DNS key such as `com.example.tool`" + description, license, bom_type; another tool's data goes under a reverse-DNS key such as `com.example.tool`" ); for owner in [Design, Component] { let word = owner.names().0; diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index ed8cf03a3f..d529700f8a 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -114,21 +114,6 @@ fn chorded_region(region: &Region2) -> Result { }) } -/// A line or free-form segment with its points mapped by `f`: exact under any affine map (a -/// conic keeps its weight). An arc is given by angles, not points, and stays as it is. -pub(crate) fn map_controls(seg: &Seg2, f: impl Fn(Vec2) -> Vec2) -> Seg2 { - match *seg { - Seg2::Line { a, b } => Seg2::Line { a: f(a), b: f(b) }, - Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: f(p0), p1: f(p1), p2: f(p2), p3: f(p3) }, - Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: f(a), apex: f(apex), b: f(b), w }, - Seg2::Bezier { n, mut p } => { - p.iter_mut().take(usize::from(n) + 1).for_each(|q| *q = f(*q)); - Seg2::Bezier { n, p } - } - arc @ Seg2::Arc { .. } => arc, - } -} - fn planar_region(region: &Region2) -> Result { if region.holes.len() > MAX_FLAT_PIECES { return Err(flat_budget_error()); @@ -4315,7 +4300,7 @@ impl SheetBody { } } // Free-form segments map exactly, through their control points. - other => map_controls(&other, map), + other => other.map_controls(map), } }; let map_loop = |lp: &Loop2| Loop2 { segs: lp.segs.iter().map(seg).collect() }; @@ -4676,7 +4661,7 @@ fn flip_x_loop(l: &Loop2) -> Loop2 { Seg2::Arc { center, radius, start, sweep } => { Seg2::Arc { center: flip_x(center), radius, start: std::f64::consts::PI - start, sweep: -sweep } } - other => map_controls(&other, flip_x), + other => other.map_controls(flip_x), }) .collect(); Loop2 { segs }.reversed() @@ -5325,7 +5310,7 @@ mod tests { // right: the same ellipse on the sheet, every curve through its mirrored controls. let below = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y * -1.0 }; let mirror = |p: Vec2| Vec2::new(p.x, -p.y); - let seen = Region2 { outer: Loop2 { segs: hole.outer.segs.iter().map(|x| map_controls(x, mirror)).collect() }, holes: vec![] }; + let seen = Region2 { outer: Loop2 { segs: hole.outer.segs.iter().map(|x| x.map_controls(mirror)).collect() }, holes: vec![] }; let mapped = s.region_to_flat(None, &below, &seen).unwrap(); assert!(mapped.outer.is_ccw() && mapped.outer.segs.len() == 4 && mapped.outer.segs.iter().all(|x| hole.outer.segs.contains(x)), "{mapped:?}"); // A cubic and a quartic closed by a line, clockwise in that plane: counter-clockwise on diff --git a/crates/engine/src/cmd/bom.rs b/crates/engine/src/cmd/bom.rs index ff5d6cbe8e..e1e5ff7347 100644 --- a/crates/engine/src/cmd/bom.rs +++ b/crates/engine/src/cmd/bom.rs @@ -5,11 +5,17 @@ //! `assembly.bom` counts the placed components of the design, or of one component, and lists //! them as rows, optionally written to a CSV or JSON file. With `group_children`, a part with a //! part number keeps the parts placed inside it (a bolt's washers and nut) in its own row. +//! +//! A component's bill of materials type (`bom_type`, a part property) decides how it counts: a +//! phantom has no row and what it holds counts in its parent; a reference counts nowhere, and +//! nothing inside it does; a purchased or inseparable component is one row, and of what an +//! inseparable one holds only its purchased parts reach the parts-only list. use std::collections::{BTreeMap, HashSet}; use serde_json::{Map, Value, json}; -use solidcraft_doc::metadata::{BOM_KEYS, MAX_KEYS, Owner, check_text, key_list}; +use solidcraft_doc::BomType; +use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, MAX_KEYS, Owner, check_text, key_list}; use super::CommandSpec; use crate::params::{bad, str_}; @@ -18,13 +24,14 @@ use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.metadata", "Part Properties", read).icon("settings").noundo().doc_only().params( "component: id or name (0 or \"root\": the design's own properties) → component, name, part_number, description, revision, \ - metadata (as saved, other tools' keys too), properties: {name: text}", + bom_type (components only), metadata (as saved, other tools' keys too), properties: {name: text}", ), CommandSpec::new("component.metadata.set", "Change Part Properties", set).icon("settings").doc_only().params( "component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \ - removes); values?: {key: value} (SolidCraft's keys, as document.metadata's properties lists them, or a reverse-DNS key \ - holding an object); properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step \ - → component, metadata, properties, changed: [keys]", + removes); bom_type?: normal|phantom|reference|purchased|inseparable (empty or null: normal); values?: {key: value} \ + (SolidCraft's keys, as document.metadata's properties lists them, bom_type, or a reverse-DNS key holding an object); \ + properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step → component, \ + metadata, properties, changed: [keys]", ), CommandSpec::new("assembly.bom", "Bill of Materials", bom).at("SOLID", "INSPECT").icon("bom").noundo().params( "component?: id or name (default the design); levels?: flat (default: every part at any depth, counted through its \ @@ -32,8 +39,10 @@ pub static COMMANDS: &[CommandSpec] = &[ (default; components without a part number get a row per definition) | [\"definition\"]; group_children?: true (a component \ with a part number is one row, the parts placed inside it listed under it as children: [{item, part_number, description, \ revision, each, count, definition}], not as rows of their own); path?: write a .csv or .json file (children as items 1.1, \ - 1.2…); format?: csv|json (default from the path) → rows: [{item, part_number, description, revision, count, definition, \ - components, properties, children?}] sorted by part number, total_occurrences (children included)", + 1.2…); format?: csv|json (default from the path). Each component's bom_type counts: phantom has no row (what it holds \ + counts here), reference counts nowhere, purchased and inseparable are one row (in flat, an inseparable one's purchased \ + parts are listed too) → rows: [{item, part_number, description, revision, bom_type, count, definition, components, \ + properties, children?}] sorted by part number, total_occurrences (children included)", ), ]; @@ -72,6 +81,10 @@ fn read(s: &mut Session, p: &Value) -> Result { for k in BOM_KEYS { out.insert(k.to_string(), json!(m.text(k))); } + // A bill of materials type is a component's; the design has none. + if id != 0 { + out.insert(BOM_TYPE.into(), json!(m.bom_type().as_str())); + } out.insert("metadata".into(), json!(m)); out.insert("properties".into(), properties); Ok(Value::Object(out)) @@ -111,7 +124,7 @@ fn set(s: &mut Session, p: &Value) -> Result { Some(Value::Object(_)) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))), Some(_) => return Err(bad(cmd, "`values` must be an object: {key: value, …}")), } - for k in BOM_KEYS { + for k in BOM_KEYS.iter().chain([&BOM_TYPE]) { if let Some(v) = p.get(*k) { entries.push((k.to_string(), v.clone())); } @@ -123,7 +136,7 @@ fn set(s: &mut Session, p: &Value) -> Result { Some(_) => return Err(bad(cmd, "`properties` must be an object: {name: text, …}")), }; if entries.is_empty() && props.is_empty() { - return Err(bad(cmd, format!("give {}, values or properties ({})", BOM_KEYS.join(", "), key_list(Owner::Component)))); + return Err(bad(cmd, format!("give {}, {BOM_TYPE}, values or properties ({})", BOM_KEYS.join(", "), key_list(Owner::Component)))); } let mut changed: Vec = Vec::new(); for (k, v) in entries { @@ -176,6 +189,7 @@ struct Row { part_number: String, description: String, revision: String, + bom_type: BomType, count: u64, components: Vec, names: Vec, @@ -225,6 +239,8 @@ struct Index { owners: HashSet, /// Components with a part number: with `group_children`, one row however much is inside. numbered: HashSet, + /// Components whose bill of materials type isn't normal. + types: BTreeMap, } impl Index { @@ -234,8 +250,22 @@ impl Index { children.entry(o.parent).or_default().push(o.component); } let numbered = s.doc.components.iter().filter(|c| c.metadata.part_number().is_some_and(|n| !n.is_empty())).map(|c| c.id).collect(); - Index { children, owners: s.doc.body_owners(&s.model.state()), numbered } + let types = s.doc.components.iter().map(|c| (c.id, c.metadata.bom_type())).filter(|(_, t)| *t != BomType::Normal).collect(); + Index { children, owners: s.doc.body_owners(&s.model.state()), numbered, types } } + + fn bom_type(&self, component: u64) -> BomType { + self.types.get(&component).copied().unwrap_or_default() + } +} + +/// What a walk below a component lists. +#[derive(Clone, Copy, PartialEq)] +enum Walk { + /// What the bill of materials lists. + Listed, + /// Inside an inseparable component, in parts only: just its purchased parts. + Purchased, } fn group_key(g: &str, cmd: &str) -> Result { @@ -246,63 +276,88 @@ fn group_key(g: &str, cmd: &str) -> Result { } } -/// How many times each component is placed below `root`: in `Top`, its direct children; in -/// `Flat`, every placement at any depth (a part in a subassembly placed twice counts twice), -/// not below a numbered part when `fold`. +/// How many times each component is listed below `root`, by its bill of materials type: in +/// `Top`, the direct children; in `Flat`, the parts at any depth (a part in a subassembly placed +/// twice counts twice), not below a purchased part, nor below a numbered part when `fold`, and of +/// what an inseparable part holds only its purchased parts. A phantom's contents count where the +/// phantom is placed; a reference counts nowhere, and nothing inside it does. fn counts(ix: &Index, root: u64, levels: Levels, fold: bool, cmd: &str) -> Result> { let mut out: BTreeMap = BTreeMap::new(); let mut placed = 0usize; - let mut stack: Vec<(u64, usize)> = vec![(root, 0)]; - while let Some((comp, depth)) = stack.pop() { + let mut stack: Vec<(u64, usize, Walk)> = vec![(root, 0, Walk::Listed)]; + while let Some((comp, depth, walk)) = stack.pop() { for child in ix.children.get(&comp).into_iter().flatten() { placed += 1; if depth >= MAX_DEPTH || placed > MAX_PLACEMENTS { return Err(bad(cmd, format!("the assembly is deeper than {MAX_DEPTH} levels or has more than {MAX_PLACEMENTS} placements"))); } - *out.entry(*child).or_default() += 1; - if levels == Levels::Flat && !(fold && ix.numbered.contains(child)) { - stack.push((*child, depth + 1)); + let inside = ix.children.contains_key(child); + match (ix.bom_type(*child), walk) { + (BomType::Reference, _) => {} + (BomType::Phantom, _) => stack.push((*child, depth + 1, walk)), + (BomType::Purchased, _) => *out.entry(*child).or_default() += 1, + (_, Walk::Purchased) => { + if inside { + stack.push((*child, depth + 1, Walk::Purchased)); + } + } + (BomType::Inseparable, Walk::Listed) => { + *out.entry(*child).or_default() += 1; + if levels == Levels::Flat && inside { + stack.push((*child, depth + 1, Walk::Purchased)); + } + } + (BomType::Normal, Walk::Listed) => { + // Parts only: a subassembly is listed through its parts, unless it has bodies + // of its own (or, folding, a part number). + let folded = fold && ix.numbered.contains(child); + if levels == Levels::Top || !inside || folded || ix.owners.contains(child) { + *out.entry(*child).or_default() += 1; + } + if levels == Levels::Flat && inside && !folded { + stack.push((*child, depth + 1, Walk::Listed)); + } + } } } } - if levels == Levels::Flat { - // Parts only: a subassembly is listed through its parts, unless it has bodies of its own - // (or, folding, a part number). - out.retain(|c, _| !ix.children.contains_key(c) || (fold && ix.numbered.contains(c)) || ix.owners.contains(c)); - } Ok(out) } -/// How a kit reads when rows are compared: its parts by part number (else definition) and count. -fn kit_key(s: &Session, kit: &BTreeMap) -> String { +/// How a kit reads when rows are compared: its parts by part number (else definition), bill of +/// materials type and count, so a kit holding a bought bolt isn't the kit holding a made one. +fn kit_key(s: &Session, ix: &Index, kit: &BTreeMap) -> String { let mut parts: Vec = kit .iter() .map(|(id, n)| { let pn = s.doc.components.iter().find(|c| c.id == *id).and_then(|c| c.metadata.part_number()).unwrap_or_default(); - if pn.is_empty() { format!("#{id}×{n}") } else { format!("{pn}×{n}") } + let bom_type = ix.bom_type(*id).as_str(); + if pn.is_empty() { format!("#{id} {bom_type}×{n}") } else { format!("{pn} {bom_type}×{n}") } }) .collect(); parts.sort(); parts.join("\n") } -fn rows(s: &Session, counts: &BTreeMap, group: Group, kits: &BTreeMap>) -> Vec { - let mut grouped: BTreeMap<(String, String, u64), Row> = BTreeMap::new(); +fn rows(s: &Session, ix: &Index, counts: &BTreeMap, group: Group, kits: &BTreeMap>) -> Vec { + let mut grouped: BTreeMap<(String, String, u64, BomType), Row> = BTreeMap::new(); for (id, n) in counts { let Some(c) = s.doc.components.iter().find(|c| c.id == *id) else { continue }; let m = &c.metadata; let part_number = m.part_number().unwrap_or_default().to_string(); let kit = kits.get(id).cloned().unwrap_or_default(); + let bom_type = ix.bom_type(*id); // Without a part number a definition is its own row; parts with different contents - // (a bolt with or without its nut) are different rows. + // (a bolt with or without its nut) or different bill of materials types are different rows. let k = match group { - Group::PartNumber if !part_number.is_empty() => (part_number.clone(), kit_key(s, &kit), 0), - _ => (String::new(), String::new(), *id), + Group::PartNumber if !part_number.is_empty() => (part_number.clone(), kit_key(s, ix, &kit), 0, bom_type), + _ => (String::new(), String::new(), *id, bom_type), }; let row = grouped.entry(k).or_insert_with(|| Row { part_number: part_number.clone(), description: String::new(), revision: String::new(), + bom_type, count: 0, components: Vec::new(), names: Vec::new(), @@ -361,10 +416,11 @@ fn bom(s: &mut Session, p: &Value) -> Result { let o = options(p, cmd)?; let ix = Index::new(s); let counts = counts(&ix, root, o.levels, o.fold, cmd)?; - // Folding: what one of each numbered part holds, as parts at any depth. + // Folding: what one of each numbered part holds, as parts at any depth. A purchased or + // inseparable part already keeps what it holds out of the list. let mut kits: BTreeMap> = BTreeMap::new(); if o.fold { - for id in counts.keys().filter(|id| ix.numbered.contains(id)) { + for id in counts.keys().filter(|id| ix.numbered.contains(id) && ix.bom_type(**id) == BomType::Normal) { let kit = self::counts(&ix, *id, Levels::Flat, false, cmd)?; if !kit.is_empty() { kits.insert(*id, kit); @@ -372,22 +428,24 @@ fn bom(s: &mut Session, p: &Value) -> Result { } } let mut total: u64 = 0; - let rows: Vec = rows(s, &counts, o.group, &kits) + let rows: Vec = rows(s, &ix, &counts, o.group, &kits) .into_iter() .enumerate() .map(|(i, r)| { total += r.count; let mut v = json!({"item": i + 1, "part_number": r.part_number, "description": r.description, "revision": r.revision, - "count": r.count, "definition": r.names.join("; "), "components": r.components, "properties": r.properties}); + "bom_type": r.bom_type.as_str(), "count": r.count, "definition": r.names.join("; "), "components": r.components, + "properties": r.properties}); if !r.kit.is_empty() { - let children: Vec = self::rows(s, &r.kit, Group::PartNumber, &BTreeMap::new()) + let children: Vec = self::rows(s, &ix, &r.kit, Group::PartNumber, &BTreeMap::new()) .into_iter() .enumerate() .map(|(j, c)| { let count = c.count.saturating_mul(r.count); total += count; json!({"item": format!("{}.{}", i + 1, j + 1), "part_number": c.part_number, "description": c.description, - "revision": c.revision, "each": c.count, "count": count, "definition": c.names.join("; ")}) + "revision": c.revision, "bom_type": c.bom_type.as_str(), "each": c.count, "count": count, + "definition": c.names.join("; ")}) }) .collect(); v["children"] = Value::Array(children); diff --git a/crates/engine/src/cmd/bom_tests.rs b/crates/engine/src/cmd/bom_tests.rs index 93e25e9a1f..8dc9f2b150 100644 --- a/crates/engine/src/cmd/bom_tests.rs +++ b/crates/engine/src/cmd/bom_tests.rs @@ -267,7 +267,7 @@ fn a_components_refusals_speak_of_part_properties() { assert!( bogus.ends_with( "`bogus` is not a part property: SolidCraft's are project, part_number, revision, author, organization, description, \ - license; another tool's data goes under a reverse-DNS key such as `com.example.tool`" + license, bom_type; another tool's data goes under a reverse-DNS key such as `com.example.tool`" ), "{bogus}" ); @@ -275,8 +275,8 @@ fn a_components_refusals_speak_of_part_properties() { assert!(refusal(&mut s, json!({"component": "Pin", "values": {"": "x"}})).contains("`` is not a part property")); // Nothing to change: the same list as before the table. assert!(refusal(&mut s, json!({"component": "Pin"})).ends_with( - "give part_number, description, revision, values or properties (project, part_number, revision, author, organization, \ - description, license)" + "give part_number, description, revision, bom_type, values or properties (project, part_number, revision, author, \ + organization, description, license, bom_type)" )); // Only the bill of materials' keys are parameters of their own; the others go in `values`. assert!(refusal(&mut s, json!({"component": "Pin", "author": "Jane"})).contains("give part_number, description, revision")); @@ -400,3 +400,227 @@ fn body_owners_match_each_bodys_component() { assert_eq!(s.doc.model_body_components(&st), in_order); } } + +/// A machine: a plate, and a gearbox (no bodies of its own) that holds a shaft and a bearing. +/// Each is placed once. +fn machine() -> Session { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Plate"})); + run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10})); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "component.create", json!({"name": "Gearbox", "activate": false})); + for (name, diameter, height) in [("Shaft", 10, 80), ("Bearing", 22, 7)] { + run(&mut s, "component.create", json!({"name": name, "parent": "Gearbox"})); + run(&mut s, "solid.cylinder", json!({"diameter": diameter, "height": height})); + run(&mut s, "component.activate", json!({"component": 0})); + } + for (c, pn) in [("Plate", "PL-1"), ("Gearbox", "GB-1"), ("Shaft", "SH-1"), ("Bearing", "BR-1")] { + run(&mut s, "component.metadata.set", json!({"component": c, "part_number": pn})); + } + s +} + +/// Each row's part number, bill of materials type and count. +fn typed(r: &Value) -> Vec<(String, String, u64)> { + r["rows"] + .as_array() + .unwrap() + .iter() + .map(|r| (r["part_number"].as_str().unwrap().to_string(), r["bom_type"].as_str().unwrap().to_string(), r["count"].as_u64().unwrap())) + .collect() +} + +fn row(part_number: &str, bom_type: &str, count: u64) -> (String, String, u64) { + (part_number.to_string(), bom_type.to_string(), count) +} + +fn set_type(s: &mut Session, component: &str, bom_type: &str) { + run(s, "component.metadata.set", json!({"component": component, "bom_type": bom_type})); +} + +#[test] +fn bom_types_decide_what_is_a_row_and_what_counts_inside_it() { + let mut s = machine(); + let top = |s: &mut Session| typed(&run(s, "assembly.bom", json!({"levels": "top"}))); + let flat = |s: &mut Session| typed(&run(s, "assembly.bom", json!({}))); + let own = |s: &mut Session| typed(&run(s, "assembly.bom", json!({"component": "Gearbox"}))); + // Normal: the gearbox is a row at the top and is listed through its parts in parts only. + assert_eq!(top(&mut s), [row("GB-1", "normal", 1), row("PL-1", "normal", 1)]); + assert_eq!(flat(&mut s), [row("BR-1", "normal", 1), row("PL-1", "normal", 1), row("SH-1", "normal", 1)]); + + // Purchased: one row, with nothing inside it listed; its own bill of materials still lists it. + set_type(&mut s, "Gearbox", "purchased"); + assert_eq!(top(&mut s), [row("GB-1", "purchased", 1), row("PL-1", "normal", 1)]); + assert_eq!(flat(&mut s), [row("GB-1", "purchased", 1), row("PL-1", "normal", 1)]); + assert_eq!(own(&mut s), [row("BR-1", "normal", 1), row("SH-1", "normal", 1)]); + + // Inseparable: one row; of what it holds only purchased parts reach the parts-only list. + set_type(&mut s, "Gearbox", "inseparable"); + assert_eq!(flat(&mut s), [row("GB-1", "inseparable", 1), row("PL-1", "normal", 1)]); + set_type(&mut s, "Bearing", "purchased"); + assert_eq!(top(&mut s), [row("GB-1", "inseparable", 1), row("PL-1", "normal", 1)]); + assert_eq!(flat(&mut s), [row("BR-1", "purchased", 1), row("GB-1", "inseparable", 1), row("PL-1", "normal", 1)]); + assert_eq!(own(&mut s), [row("BR-1", "purchased", 1), row("SH-1", "normal", 1)], "in its own context an ordinary assembly"); + + // Reference: counted nowhere, and nothing inside it is counted, purchased parts included. + set_type(&mut s, "Gearbox", "reference"); + assert_eq!(top(&mut s), [row("PL-1", "normal", 1)]); + assert_eq!(flat(&mut s), [row("PL-1", "normal", 1)]); + assert_eq!(run(&mut s, "assembly.bom", json!({}))["total_occurrences"], 1); + + // Phantom: no row; what it holds counts where it is placed, at the top level too. + set_type(&mut s, "Gearbox", "phantom"); + assert_eq!(top(&mut s), [row("BR-1", "purchased", 1), row("PL-1", "normal", 1), row("SH-1", "normal", 1)]); + assert_eq!(flat(&mut s), [row("BR-1", "purchased", 1), row("PL-1", "normal", 1), row("SH-1", "normal", 1)]); + + // Back to normal (empty text removes the key) gives the first lists again. + set_type(&mut s, "Gearbox", ""); + set_type(&mut s, "Bearing", "normal"); + assert_eq!(top(&mut s), [row("GB-1", "normal", 1), row("PL-1", "normal", 1)]); + assert_eq!(flat(&mut s), [row("BR-1", "normal", 1), row("PL-1", "normal", 1), row("SH-1", "normal", 1)]); + assert_eq!(run(&mut s, "component.metadata", json!({"component": "Gearbox"}))["bom_type"], "normal"); +} + +#[test] +fn a_phantom_counts_what_it_holds_in_its_parent_merged_with_the_same_parts() { + // A fastener set (no bodies) of two screws, placed three times, and one more screw placed directly. + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Set", "activate": false})); + run(&mut s, "component.create", json!({"name": "Screw", "parent": "Set"})); + run(&mut s, "solid.cylinder", json!({"diameter": 6, "height": 20})); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "occurrence.copy", json!({"component": "Screw", "parent": "Set", "translate": [10, 0, 0]})); + for y in [50, 100] { + run(&mut s, "occurrence.copy", json!({"component": "Set", "translate": [0, y, 0]})); + } + run(&mut s, "occurrence.copy", json!({"component": "Screw", "parent": 0, "translate": [0, -50, 0]})); + run(&mut s, "component.metadata.set", json!({"component": "Screw", "part_number": "SC-6"})); + run(&mut s, "component.metadata.set", json!({"component": "Set", "part_number": "SET-1"})); + assert_eq!(typed(&run(&mut s, "assembly.bom", json!({"levels": "top"}))), [row("SC-6", "normal", 1), row("SET-1", "normal", 3)]); + assert_eq!(typed(&run(&mut s, "assembly.bom", json!({}))), [row("SC-6", "normal", 7)], "2 x 3 + 1"); + + set_type(&mut s, "Set", "phantom"); + assert_eq!(typed(&run(&mut s, "assembly.bom", json!({"levels": "top"}))), [row("SC-6", "normal", 7)], "promoted and merged"); + let flat = run(&mut s, "assembly.bom", json!({})); + assert_eq!(typed(&flat), [row("SC-6", "normal", 7)]); + assert_eq!(flat["total_occurrences"], 7); + + // A phantom inside a phantom promotes through both. + run(&mut s, "component.create", json!({"name": "Kit", "activate": false})); + run(&mut s, "occurrence.copy", json!({"component": "Set", "parent": "Kit"})); + run(&mut s, "occurrence.copy", json!({"component": "Kit", "translate": [0, 200, 0]})); + set_type(&mut s, "Kit", "phantom"); + // The Kit created at the root holds one Set; its copy holds the same: 2 kits x 2 screws more. + assert_eq!(typed(&run(&mut s, "assembly.bom", json!({"levels": "top"}))), [row("SC-6", "normal", 11)]); +} + +/// Kits with one part number fold into one row only when what they hold is the same, its bill of +/// materials types included: a kit holding a bought bolt isn't the kit holding a made one. +#[test] +fn kits_with_one_part_number_fold_together_only_when_their_contents_types_match() { + let mut s = Session::default(); + for (kit, bolt) in [("KitA", "BoltA"), ("KitB", "BoltB")] { + run(&mut s, "component.create", json!({"name": kit, "activate": false})); + run(&mut s, "component.create", json!({"name": bolt, "parent": kit})); + run(&mut s, "solid.cylinder", json!({"diameter": 6, "height": 20})); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "component.metadata.set", json!({"component": kit, "part_number": "KIT"})); + run(&mut s, "component.metadata.set", json!({"component": bolt, "part_number": "BOLT"})); + } + // Each kit row: its count, and each child's definition, count in one kit and count in all. + let kits = |s: &mut Session| -> Vec<(u64, Vec<(String, u64, u64)>)> { + let r = run(s, "assembly.bom", json!({"group_children": true})); + let child = |c: &Value| (c["definition"].as_str().unwrap().to_string(), c["each"].as_u64().unwrap(), c["count"].as_u64().unwrap()); + r["rows"] + .as_array() + .unwrap() + .iter() + .map(|k| (k["count"].as_u64().unwrap(), k["children"].as_array().unwrap().iter().map(child).collect())) + .collect() + }; + let same = kits(&mut s); + assert_eq!((same.len(), same[0].0, same[0].1.len(), same[0].1[0].1, same[0].1[0].2), (1, 2, 1, 1, 2), "{same:?}"); + set_type(&mut s, "BoltB", "purchased"); + assert_eq!(kits(&mut s), [(1, vec![("BoltA".into(), 1, 1)]), (1, vec![("BoltB".into(), 1, 1)])]); +} + +#[test] +fn a_purchased_part_keeps_what_is_placed_inside_it_out_of_the_list() { + let mut s = bolts(); + // Each inserted bolt is a definition of its own; buy them all. + let bolts = run(&mut s, "assembly.bom", json!({}))["rows"][0]["components"].clone(); + assert_eq!(bolts.as_array().map(Vec::len), Some(3)); + for bolt in bolts.as_array().unwrap() { + run(&mut s, "component.metadata.set", json!({"component": bolt, "bom_type": "purchased"})); + } + // The washers and nuts placed inside two of them are no longer listed. + let plain = run(&mut s, "assembly.bom", json!({})); + assert_eq!(typed(&plain), [row("ISO 4017 M6x30", "purchased", 3)], "{plain}"); + assert_eq!(plain["total_occurrences"], 3); + // Folding has nothing to fold: a purchased part is one row already. + let folded = run(&mut s, "assembly.bom", json!({"group_children": true})); + assert_eq!(typed(&folded), [row("ISO 4017 M6x30", "purchased", 3)]); + assert!(folded["rows"][0].get("children").is_none(), "{folded}"); + // One bolt bought and two made gives two rows for the one part number. + run(&mut s, "component.metadata.set", json!({"component": bolts[0], "bom_type": null})); + run(&mut s, "component.metadata.set", json!({"component": bolts[1], "bom_type": null})); + let mixed = typed(&run(&mut s, "assembly.bom", json!({}))); + assert!(mixed.contains(&row("ISO 4017 M6x30", "normal", 2)) && mixed.contains(&row("ISO 4017 M6x30", "purchased", 1)), "{mixed:?}"); +} + +#[test] +fn a_bom_type_is_one_undo_step_survives_save_and_open_and_evaluates_nothing() { + let dir = scratch("bom-type"); + let mut s = machine(); + let undo = s.undo.len(); + s.evaluations = 0; + let r = run(&mut s, "component.metadata.set", json!({"component": "Gearbox", "bom_type": "inseparable"})); + assert_eq!(r["changed"], json!(["bom_type"])); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.evaluations, 0, "a part property changes no geometry"); + for (file, format) in [("machine.json", "json"), ("machine.solidcraft", "container")] { + let path = dir.join(file); + run(&mut s, "file.save_as", json!({"path": path, "format": format})); + let mut back = Session::default(); + run(&mut back, "doc.open", json!({"path": path})); + assert_eq!(run(&mut back, "component.metadata", json!({"component": "Gearbox"}))["bom_type"], "inseparable", "{format}"); + assert_eq!(typed(&run(&mut back, "assembly.bom", json!({}))), typed(&run(&mut s, "assembly.bom", json!({}))), "{format}"); + } + run(&mut s, "edit.undo", json!({})); + assert_eq!(run(&mut s, "component.metadata", json!({"component": "Gearbox"}))["bom_type"], "normal"); + // Through `values` too, and the same word again changes nothing. + run(&mut s, "component.metadata.set", json!({"component": "Gearbox", "values": {"bom_type": "phantom"}})); + let again = run(&mut s, "component.metadata.set", json!({"component": "Gearbox", "bom_type": "phantom"})); + assert_eq!(again["changed"], json!([])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn hostile_bom_types_are_refused_without_a_change() { + let mut s = machine(); + let undo = s.undo.len(); + for p in [ + json!({"component": "Gearbox", "bom_type": "virtual"}), + json!({"component": "Gearbox", "bom_type": "Phantom"}), + json!({"component": "Gearbox", "bom_type": 3}), + json!({"component": "Gearbox", "bom_type": ["phantom"]}), + json!({"component": "Gearbox", "bom_type": "x".repeat(5000)}), + json!({"component": "Gearbox", "values": {"bom_type": "phantom\u{7}"}}), + json!({"component": 0, "bom_type": "phantom"}), + json!({"component": "Gearbox", "part_number": "GB-2", "bom_type": "nope"}), + ] { + assert!(s.execute("component.metadata.set", &p).is_err(), "{p}"); + } + assert!(s.execute("document.metadata.set", &json!({"key": "bom_type", "value": "phantom"})).is_err(), "not a design property"); + assert!(s.execute("document.metadata.set", &json!({"values": {"bom_type": "purchased"}})).is_err()); + assert_eq!(s.undo.len(), undo, "nothing changed"); + let gb = run(&mut s, "component.metadata", json!({"component": "Gearbox"})); + assert_eq!((gb["part_number"].as_str(), gb["bom_type"].as_str()), (Some("GB-1"), Some("normal")), "all or nothing"); + // A word another build wrote is kept and reads as normal. + let id = s.doc.find_component("Gearbox").unwrap(); + if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == id) { + c.metadata.0.insert("bom_type".into(), json!("kit")); + } + assert_eq!(run(&mut s, "component.metadata", json!({"component": "Gearbox"}))["bom_type"], "normal"); + assert_eq!(typed(&run(&mut s, "assembly.bom", json!({"levels": "top"}))), [row("GB-1", "normal", 1), row("PL-1", "normal", 1)]); +} diff --git a/crates/engine/src/cmd/metadata.rs b/crates/engine/src/cmd/metadata.rs index d510d051d6..96e5d37606 100644 --- a/crates/engine/src/cmd/metadata.rs +++ b/crates/engine/src/cmd/metadata.rs @@ -6,7 +6,7 @@ //! nothing: properties are no geometry. use serde_json::{Map, Value, json}; -use solidcraft_doc::metadata::{self, MAX_KEYS, Owner}; +use solidcraft_doc::metadata::{self, BOM_TYPE, MAX_KEYS, Owner}; use super::CommandSpec; use crate::params::{bad, str_}; @@ -49,6 +49,9 @@ fn set(s: &mut Session, p: &Value) -> Result { let mut next = s.doc.metadata.clone(); let mut changed = Vec::new(); for (key, value) in entries { + if key == BOM_TYPE { + return Err(bad(cmd, "`bom_type` is a component's part property: set it with component.metadata.set")); + } if next.set(Owner::Design, key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? { changed.push(key.to_string()); } diff --git a/crates/geom/src/aabb2.rs b/crates/geom/src/aabb2.rs index 8726d24a98..2cf9a8749b 100644 --- a/crates/geom/src/aabb2.rs +++ b/crates/geom/src/aabb2.rs @@ -57,6 +57,10 @@ impl Aabb2 { pub fn contains(&self, p: Vec2) -> bool { p.x >= self.min.x && p.y >= self.min.y && p.x <= self.max.x && p.y <= self.max.y } + /// The box both hold: empty when they do not meet. + pub fn intersection(&self, o: &Aabb2) -> Aabb2 { + Aabb2 { min: Vec2::new(self.min.x.max(o.min.x), self.min.y.max(o.min.y)), max: Vec2::new(self.max.x.min(o.max.x), self.max.y.min(o.max.y)) } + } /// Whether the two boxes share a point (never when either is empty). pub fn meets(&self, o: &Aabb2) -> bool { self.min.x <= o.max.x && o.min.x <= self.max.x && self.min.y <= o.max.y && o.min.y <= self.max.y diff --git a/crates/geom/src/cell.rs b/crates/geom/src/cell.rs new file mode 100644 index 0000000000..04b8a14488 --- /dev/null +++ b/crates/geom/src/cell.rs @@ -0,0 +1,254 @@ +//! Cell content: what a cell shows, as text and fields, how it should fit, and the cell text rule +//! that places a label and a value in a cell. These are the rules every table of cells shares: +//! title blocks now, parts lists later. Plain data in sheet millimetres, like +//! [`crate::drawing_sheet`]: nothing here reads a field's value, measures text or draws. +//! +//! - [`Content`] is a list of [`Part`]s: text as written, or a field naming a value by its +//! [`Source`] and key, with a [`Format`]. Keys, units and decimals are kept as written; the +//! resolver checks them against each source. [`Content::validate`] bounds its size, and +//! [`check_label`] a label's. +//! - A value is never cut: when it has more than its [`Fit`] allows, it is shown whole with a +//! [`Warning`]. +//! +//! The cell text rule (x to the right, y up), in the cell inside its lines, so the padding is +//! measured from a line's inner edge whatever the line's width: +//! - a label's baseline starts [`PADDING_MM`] in from the cell's left side, and its cap height +//! plus [`PADDING_MM`] down from the cell's top ([`label_at`]); +//! - the value box is the cell inset by [`PADDING_MM`], its top [`PADDING_MM`] below the label's +//! baseline when there is a label ([`value_box`]); +//! - one line of a value that doesn't wrap is centred top to bottom in the value box; otherwise the +//! lines run down from the box's top at the font's line pitch, the first baseline a cap height +//! below it ([`place_lines`]). Each baseline stays in the box, and its descenders +//! ([`DESCENDER_OF_CAP`]) may reach into the padding under it but never past it to a line; +//! - a line's anchor is on its baseline, at the box's left side, middle or right side: a writer +//! aligns the line on it. +//! +//! Breaking a value into lines that fit the box's width, and warning about width, need the text +//! measured: they come with the paper scene's text. + +use serde::{Deserialize, Serialize}; + +use crate::paper::MAX_COORDINATE_MM; +use crate::{Aabb2, EPS, Vec2}; + +/// The most parts a cell's content may have. +pub const MAX_PARTS: usize = 64; +/// The most bytes of text a cell's content may hold in all, and a label. +pub const MAX_TEXT_BYTES: usize = 4096; +/// The space between the inner edge of a cell's lines and its text, in millimetres. +pub const PADDING_MM: f64 = 1.0; +/// How far a line's descenders reach below its baseline, as a share of its cap height: ISO 3098's +/// lettering B, whose lower-case tails are 3/10 of the capitals' height. They may reach into the +/// padding under a value box, never past it. +pub const DESCENDER_OF_CAP: f64 = 0.3; + +/// Where a field's value comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Source { + /// The component the sheet documents. + Component, + /// The drawing. + Drawing, + /// The sheet the cell is on. + Sheet, + /// The drawing's newest revision. + Revision, + /// An answer given when the block was placed. + Prompt, +} + +/// One part of a cell's content. Externally tagged: `{"text": "Sheet "}`, +/// `{"field": {"source": "sheet", "key": "number"}}`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum Part { + /// Text shown as written. + Text(String), + /// The value `source` holds under `key` (such as `component` `mass`), shown by `format`. + Field { + source: Source, + key: String, + #[serde(default)] + format: Format, + }, +} + +/// How a field's number is shown; what isn't given comes from the caller's settings. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Format { + pub unit: Option, + pub decimals: Option, +} + +/// What a cell shows: its parts in order. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Content(pub Vec); + +/// What a value should fit beyond its box. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Fit { + /// The most characters a value should have, line breaks not counted. + pub max_chars: Option, + /// The most for a value with a wide character (Chinese, Japanese, Korean or Yi), when it + /// differs from `max_chars`. + pub wide_max_chars: Option, + /// The value breaks between words to fit the box's width, its lines running down from the top. + #[serde(default)] + pub wrap: bool, +} + +/// Where a line sits across its box. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HAlign { + #[default] + Left, + Centre, + Right, +} + +/// The projection a drawing's views use, first or third angle (ISO 5456-2): the value of +/// `drawing.projection`, which the views and the title block's symbol both read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Projection { + First, + Third, +} + +/// What doesn't fit, when a value is shown whole. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Warning { + /// More characters than the fit allows. + Chars { count: usize, max: u32 }, + /// More lines than the value box holds. + Height, +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("cell: {0}")] +pub struct Invalid(pub &'static str); + +type Result = std::result::Result; + +fn require(condition: bool, message: &'static str) -> Result<()> { + if condition { Ok(()) } else { Err(Invalid(message)) } +} + +/// Checks text by the module's rule: at most [`MAX_TEXT_BYTES`] in all, and no control character +/// but a line break. +fn check_text<'a>(texts: impl IntoIterator) -> Result<()> { + let mut bytes = 0usize; + for text in texts { + bytes = bytes.saturating_add(text.len()); + require(bytes <= MAX_TEXT_BYTES, "a cell's text is at most 4096 bytes")?; + require(!text.chars().any(|c| c.is_control() && c != '\n'), "a cell's text has no control character but a line break")?; + } + Ok(()) +} + +impl Part { + /// Its text, or its key and unit. + fn texts(&self) -> impl Iterator { + let (first, unit) = match self { + Part::Text(text) => (text.as_str(), None), + Part::Field { key, format, .. } => (key.as_str(), format.unit.as_deref()), + }; + std::iter::once(first).chain(unit) + } +} + +impl Content { + /// Checks the content's size: at most [`MAX_PARTS`] parts, counted first, and its text, keys + /// and units by the module's text rule. + pub fn validate(&self) -> Result<()> { + require(self.0.len() <= MAX_PARTS, "a cell has at most 64 parts")?; + check_text(self.0.iter().flat_map(Part::texts)) + } +} + +/// Checks a label's text: one line of at most [`MAX_TEXT_BYTES`], without control characters. +pub fn check_label(text: &str) -> Result<()> { + require(text.len() <= MAX_TEXT_BYTES && !text.chars().any(char::is_control), "a label is one line of at most 4096 bytes") +} + +impl Fit { + /// [`Warning::Chars`] when `text` has more characters than the fit allows. A text with a wide + /// character is held to `wide_max_chars` when it is given. + pub fn warn_chars(&self, text: &str) -> Option { + let wide = text.chars().any(is_wide); + let max = if wide { self.wide_max_chars.or(self.max_chars) } else { self.max_chars }?; + let count = text.chars().filter(|&c| c != '\n').count(); + (count > max as usize).then_some(Warning::Chars { count, max }) + } +} + +/// The Unicode blocks of the scripts written in wide characters (Chinese, Japanese, Korean and Yi), +/// with their full-width and vertical forms. +const WIDE: [(u32, u32); 15] = [ + (0x1100, 0x115F), + (0x2E80, 0x303E), + (0x3041, 0x33FF), + (0x3400, 0x4DBF), + (0x4E00, 0x9FFF), + (0xA000, 0xA4CF), + (0xA960, 0xA97F), + (0xAC00, 0xD7A3), + (0xF900, 0xFAFF), + (0xFE10, 0xFE19), + (0xFE30, 0xFE6F), + (0xFF00, 0xFF60), + (0xFFE0, 0xFFE6), + (0x20000, 0x2FFFD), + (0x30000, 0x3FFFD), +]; + +fn is_wide(c: char) -> bool { + let c = u32::from(c); + WIDE.iter().any(|&(first, last)| (first..=last).contains(&c)) +} + +/// Where a label `cap_mm` high goes in `cell` (the cell inside its lines): its baseline's left end. +pub fn label_at(cell: Aabb2, cap_mm: f64) -> Vec2 { + Vec2::new(cell.min.x + PADDING_MM, cell.max.y - PADDING_MM - cap_mm) +} + +/// The box a cell's value goes in, under a label `label_cap_mm` high when it has one (`cell`: the +/// cell inside its lines). +pub fn value_box(cell: Aabb2, label_cap_mm: Option) -> Aabb2 { + let inset = cell.inflated(-PADDING_MM); + match label_cap_mm { + Some(cap_mm) => Aabb2 { max: Vec2::new(inset.max.x, label_at(cell, cap_mm).y - PADDING_MM), ..inset }, + None => inset, + } +} + +/// Where `lines` lines `cap_mm` high go in `value_box`, `pitch_mm` apart, by the module's rule: +/// the first line's anchor, each next line `pitch_mm` below the one before, and +/// [`Warning::Height`] when the last baseline is below the box or its descenders below the padding +/// under it. The box lies within the paper's coordinates ([`MAX_COORDINATE_MM`]), so the anchor is +/// always finite. +pub fn place_lines(value_box: Aabb2, lines: usize, cap_mm: f64, pitch_mm: f64, align: HAlign, wrap: bool) -> Result<(Vec2, Option)> { + let Aabb2 { min, max } = value_box; + let on_paper = [min.x, min.y, max.x, max.y].into_iter().all(|v| v.abs() <= MAX_COORDINATE_MM); + require(on_paper && min.x <= max.x && min.y <= max.y, "a value box is not inside out and lies within the paper's coordinates")?; + require([cap_mm, pitch_mm].into_iter().all(|v| v.is_finite() && v > 0.0), "a cap height and a line pitch are finite and above 0")?; + let x = match align { + HAlign::Left => min.x, + HAlign::Centre => value_box.center().x, + HAlign::Right => max.x, + }; + let y = if lines == 1 && !wrap { value_box.center().y - cap_mm / 2.0 } else { max.y - cap_mm }; + let last = y - lines.saturating_sub(1) as f64 * pitch_mm; + // How far above the box's bottom the last baseline must be for its descenders to stay in the padding. + let reach = (cap_mm * DESCENDER_OF_CAP - PADDING_MM).max(0.0); + let overflows = lines > 0 && last - reach < min.y - EPS; + Ok((Vec2::new(x, y), overflows.then_some(Warning::Height))) +} + +#[cfg(test)] +#[path = "cell_tests.rs"] +mod tests; diff --git a/crates/geom/src/cell_tests.rs b/crates/geom/src/cell_tests.rs new file mode 100644 index 0000000000..583e3b5549 --- /dev/null +++ b/crates/geom/src/cell_tests.rs @@ -0,0 +1,345 @@ +use serde_json::json; + +use super::*; +use crate::bspline::tests::Lcg; + +fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Aabb2 { + Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) } +} + +fn text(s: &str) -> Part { + Part::Text(s.to_string()) +} + +fn field(source: Source, key: &str) -> Part { + Part::Field { source, key: key.to_string(), format: Format::default() } +} + +/// The title cell's value box on an A3 sheet: the cell (230, 10)-(342, 24) under a 2.5 mm label. +const TITLE_BOX: Aabb2 = Aabb2 { min: Vec2::new(231.0, 11.0), max: Vec2::new(341.0, 19.5) }; + +#[test] +fn the_label_and_the_value_box_inset_the_cell_by_the_padding() { + let cell = rect(230.0, 10.0, 342.0, 24.0); + // The label's baseline 1 + 2.5 mm below the top; the value box's top 1 mm below that. + assert_eq!(label_at(cell, 2.5), Vec2::new(231.0, 20.5)); + assert_eq!(value_box(cell, Some(2.5)), TITLE_BOX); + assert_eq!(value_box(cell, None), rect(231.0, 11.0, 341.0, 23.0)); +} + +#[test] +fn one_line_sits_centred_in_its_value_box_at_the_left_middle_or_right() { + // The box's centre is 15.25 high: the 5 mm line's baseline 2.5 below it. + for (align, x) in [(HAlign::Left, 231.0), (HAlign::Centre, 286.0), (HAlign::Right, 341.0)] { + assert_eq!(place_lines(TITLE_BOX, 1, 5.0, 8.0, align, false), Ok((Vec2::new(x, 12.75), None)), "{align:?}"); + } +} + +#[test] +fn wrapped_lines_and_several_lines_run_down_from_the_top_at_the_pitch() { + let square = rect(0.0, 0.0, 10.0, 10.0); + // A fake font's pitch of 1.6 cap heights: at 2.5 mm the baselines are 2.5 and 6.5 below the top. + let (first, warning) = place_lines(square, 2, 2.5, 4.0, HAlign::Left, true).unwrap(); + assert_eq!((first, warning), (Vec2::new(0.0, 7.5), None)); + assert_eq!(first.y - 4.0, 10.0 - 6.5); + // Two lines without wrapping start at the top too; one line that wraps isn't centred. + assert_eq!(place_lines(square, 2, 2.5, 4.0, HAlign::Left, false), Ok((Vec2::new(0.0, 7.5), None))); + assert_eq!(place_lines(square, 1, 2.5, 4.0, HAlign::Right, true), Ok((Vec2::new(10.0, 7.5), None))); + assert_eq!(place_lines(square, 1, 2.5, 4.0, HAlign::Right, false), Ok((Vec2::new(10.0, 3.75), None))); +} + +#[test] +fn lines_below_the_box_or_with_descenders_past_its_padding_warn_height() { + // 14.5 mm high: four lines at 2.5 and a pitch of 4 end exactly on the bottom; a fifth doesn't fit. + let tall = rect(311.0, 45.0, 379.0, 59.5); + for wrap in [false, true] { + assert_eq!(place_lines(tall, 4, 2.5, 4.0, HAlign::Left, wrap), Ok((Vec2::new(311.0, 57.0), None))); + assert_eq!(place_lines(tall, 5, 2.5, 4.0, HAlign::Left, wrap), Ok((Vec2::new(311.0, 57.0), Some(Warning::Height)))); + } + // Within 1e-9 mm of the bottom is on it. + let raised = |by: f64| Aabb2 { min: Vec2::new(311.0, 45.0 + by), ..tall }; + assert_eq!(place_lines(raised(0.5e-9), 4, 2.5, 4.0, HAlign::Left, true).unwrap().1, None); + assert_eq!(place_lines(raised(2e-9), 4, 2.5, 4.0, HAlign::Left, true).unwrap().1, Some(Warning::Height)); + // One centred line fits while its cap height does and its descenders stay in the 1 mm padding: + // a 2.5 mm line's 0.75 mm do, so it fits a 2.5 mm box (not 2.6 mm of line). A 5 mm line's + // 1.5 mm reach 0.5 mm past the padding from a baseline on the box's bottom, so it needs 6 mm. + let low = |height: f64| rect(0.0, 0.0, 10.0, height); + assert_eq!(place_lines(low(2.5), 1, 2.5, 4.0, HAlign::Left, false), Ok((Vec2::new(0.0, 0.0), None))); + assert_eq!(place_lines(low(2.5), 1, 2.6, 4.0, HAlign::Left, false).unwrap().1, Some(Warning::Height)); + assert_eq!(place_lines(low(6.0), 1, 5.0, 8.0, HAlign::Left, false), Ok((Vec2::new(0.0, 0.5), None))); + for height in [5.0, 5.9] { + assert_eq!(place_lines(low(height), 1, 5.0, 8.0, HAlign::Left, false).unwrap().1, Some(Warning::Height), "{height}"); + } + // The same for the last of several: two 5 mm lines 8 mm apart need 13.5 mm, not 13.4. + assert_eq!(place_lines(low(13.5), 2, 5.0, 8.0, HAlign::Left, true), Ok((Vec2::new(0.0, 8.5), None))); + assert_eq!(place_lines(low(13.4), 2, 5.0, 8.0, HAlign::Left, true).unwrap().1, Some(Warning::Height)); + // A flat box holds no line; no lines never overflow; a vast count still answers. + assert_eq!(place_lines(rect(0.0, 1.0, 10.0, 1.0), 1, 1.0, 1.6, HAlign::Left, false).unwrap().1, Some(Warning::Height)); + assert_eq!(place_lines(rect(0.0, 1.0, 10.0, 1.0), 0, 1.0, 1.6, HAlign::Left, false).unwrap().1, None); + assert_eq!(place_lines(tall, usize::MAX, 2.5, 4.0, HAlign::Left, true).unwrap().1, Some(Warning::Height)); +} + +#[test] +fn a_hostile_value_box_cap_or_pitch_is_refused() { + const BOX: Invalid = Invalid("a value box is not inside out and lies within the paper's coordinates"); + const SIZES: Invalid = Invalid("a cap height and a line pitch are finite and above 0"); + for value_box in [ + Aabb2::EMPTY, + rect(10.0, 0.0, 9.0, 5.0), + rect(0.0, 5.0, 10.0, 4.999), + rect(f64::NAN, 0.0, 10.0, 5.0), + rect(0.0, 0.0, f64::INFINITY, 5.0), + rect(0.0, f64::NEG_INFINITY, 10.0, 5.0), + // Finite and in order, but past the paper's coordinates: centred, they would overflow. + rect(1e308, 0.0, 1.1e308, 10.0), + rect(0.0, 1e308, 10.0, 1.1e308), + rect(0.0, 0.0, 100_000.001, 5.0), + rect(-100_000.001, 0.0, 10.0, 5.0), + ] { + assert_eq!(place_lines(value_box, 1, 2.5, 4.0, HAlign::Left, false), Err(BOX), "{value_box:?}"); + } + for v in [0.0, -0.0, -1.0, f64::NAN, f64::INFINITY] { + assert_eq!(place_lines(TITLE_BOX, 1, v, 4.0, HAlign::Left, false), Err(SIZES), "cap {v}"); + assert_eq!(place_lines(TITLE_BOX, 1, 2.5, v, HAlign::Left, false), Err(SIZES), "pitch {v}"); + } + place_lines(TITLE_BOX, 1, f64::MIN_POSITIVE, f64::MIN_POSITIVE, HAlign::Left, false).unwrap(); + let paper = rect(-MAX_COORDINATE_MM, -MAX_COORDINATE_MM, MAX_COORDINATE_MM, MAX_COORDINATE_MM); + for align in [HAlign::Left, HAlign::Centre, HAlign::Right] { + assert!(place_lines(paper, 1, 2.5, 4.0, align, false).unwrap().0.is_finite(), "{align:?}"); + } +} + +#[test] +fn a_count_warns_above_its_maximum_in_characters_not_bytes() { + let title = Fit { max_chars: Some(25), wide_max_chars: Some(30), wrap: false }; + let chars = |count, max| Some(Warning::Chars { count, max }); + let repeat = |c: &str, n: usize| c.repeat(n); + assert_eq!(title.warn_chars(&repeat("0", 25)), None); + assert_eq!(title.warn_chars(&repeat("0", 26)), chars(26, 25)); + assert_eq!(title.warn_chars(&repeat("0", 40)), chars(40, 25)); + // 'é' is two bytes and one character. + assert_eq!(title.warn_chars(&repeat("é", 25)), None); + assert_eq!(title.warn_chars(&repeat("é", 40)), chars(40, 25)); + // A text with a wide character is held to the wide count, whatever else it holds. + assert_eq!(title.warn_chars(&repeat("字", 30)), None); + assert_eq!(title.warn_chars(&repeat("字", 31)), chars(31, 30)); + assert_eq!(title.warn_chars(&format!("A{}", repeat("字", 29))), None); + assert_eq!(title.warn_chars(&format!("{}字", repeat("A", 30))), chars(31, 30)); + // Line breaks don't count. + assert_eq!(title.warn_chars(&format!("{}\n", repeat("0", 25))), None); + assert_eq!(title.warn_chars(""), None); + // No wide count: the count holds for every text. No count: nothing warns. + let plain = Fit { max_chars: Some(25), ..Fit::default() }; + assert_eq!(plain.warn_chars(&repeat("字", 26)), chars(26, 25)); + let wide_only = Fit { wide_max_chars: Some(30), ..Fit::default() }; + assert_eq!(wide_only.warn_chars(&repeat("0", 1000)), None); + assert_eq!(wide_only.warn_chars(&repeat("字", 31)), chars(31, 30)); + assert_eq!(Fit::default().warn_chars(&repeat("字", 1000)), None); + let none = Fit { max_chars: Some(0), ..Fit::default() }; + assert_eq!(none.warn_chars("a"), chars(1, 0)); + assert_eq!(none.warn_chars("\n"), None); +} + +#[test] +fn wide_characters_are_those_of_the_wide_scripts_blocks() { + for c in ['字', '한', 'カ', 'ひ', 'A', '。', 'ꀀ', '\u{1100}', '\u{115F}', '\u{303E}', '\u{20000}', '\u{3FFFD}', '\u{FFE6}'] { + assert!(is_wide(c), "{c:?} U+{:04X}", u32::from(c)); + } + for c in ['A', 'é', 'Ω', 'Я', 'א', '\u{1160}', '\u{303F}', '\u{3040}', '\u{FF61}', '\u{FFE7}', '\u{3FFFE}', '😀', '\n'] { + assert!(!is_wide(c), "{c:?} U+{:04X}", u32::from(c)); + } +} + +#[test] +fn content_has_at_most_64_parts_and_4096_bytes_of_text() { + Content(vec![text("a"); MAX_PARTS]).validate().unwrap(); + assert_eq!(Content(vec![text(""); MAX_PARTS + 1]).validate(), Err(Invalid("a cell has at most 64 parts"))); + const LONG: Invalid = Invalid("a cell's text is at most 4096 bytes"); + Content(vec![text(&"a".repeat(4096))]).validate().unwrap(); + assert_eq!(Content(vec![text(&"a".repeat(4097))]).validate(), Err(LONG)); + // Bytes, not characters: 2048 'é' are 4096 bytes. + Content(vec![text(&"é".repeat(2048))]).validate().unwrap(); + assert_eq!(Content(vec![text(&"é".repeat(2048)), text("a")]).validate(), Err(LONG)); + // Keys and units count too. + let unit = + |unit: &str| Part::Field { source: Source::Component, key: "k".repeat(2047), format: Format { unit: Some(unit.into()), decimals: None } }; + Content(vec![text(&"a".repeat(2048)), unit("g")]).validate().unwrap(); + assert_eq!(Content(vec![text(&"a".repeat(2048)), unit("kg")]).validate(), Err(LONG)); + assert_eq!(Content(vec![text(&"a".repeat(2048)), field(Source::Drawing, &"k".repeat(2049))]).validate(), Err(LONG)); + // Counted first: 65 parts of bad text report the count. + assert_eq!(Content(vec![text("\t"); MAX_PARTS + 1]).validate(), Err(Invalid("a cell has at most 64 parts"))); + Content::default().validate().unwrap(); +} + +#[test] +fn content_has_no_control_character_but_a_line_break() { + const CONTROL: Invalid = Invalid("a cell's text has no control character but a line break"); + Content(vec![text("Anodised\nblack"), text("\n")]).validate().unwrap(); + for c in ['\t', '\r', '\0', '\u{1b}', '\u{7f}', '\u{85}', '\u{9f}'] { + let s = format!("a{c}b"); + assert_eq!(Content(vec![text(&s)]).validate(), Err(CONTROL), "{c:?}"); + assert_eq!(Content(vec![field(Source::Sheet, &s)]).validate(), Err(CONTROL), "{c:?}"); + let format = Format { unit: Some(s), decimals: Some(1) }; + assert_eq!(Content(vec![Part::Field { source: Source::Component, key: "mass".into(), format }]).validate(), Err(CONTROL), "{c:?}"); + } +} + +#[test] +fn parts_read_and_write_as_externally_tagged_json() { + let content = Content(vec![ + text("Sheet "), + field(Source::Sheet, "number"), + Part::Field { source: Source::Component, key: "mass".into(), format: Format { unit: Some("g".into()), decimals: Some(1) } }, + ]); + let value = serde_json::to_value(&content).unwrap(); + assert_eq!( + value, + json!([ + {"text": "Sheet "}, + {"field": {"source": "sheet", "key": "number", "format": {"unit": null, "decimals": null}}}, + {"field": {"source": "component", "key": "mass", "format": {"unit": "g", "decimals": 1}}}, + ]) + ); + assert_eq!(serde_json::from_value::(value).unwrap(), content); + // Without a format, the caller's settings. + let read = |v: serde_json::Value| serde_json::from_value::(json!([v])); + assert_eq!(read(json!({"field": {"source": "revision", "key": "date"}})).unwrap(), Content(vec![field(Source::Revision, "date")])); + for (name, source) in [ + ("component", Source::Component), + ("drawing", Source::Drawing), + ("sheet", Source::Sheet), + ("revision", Source::Revision), + ("prompt", Source::Prompt), + ] { + assert_eq!(serde_json::to_value(source).unwrap(), json!(name)); + } + for bad in [ + json!({"field": {"source": "customer", "key": "name"}}), + json!({"field": {"source": "Component", "key": "name"}}), + json!({"field": {"source": "sheet", "key": "number", "scale": 1}}), + json!({"field": {"source": "sheet", "key": "number", "format": {"date": "ymd"}}}), + json!({"field": {"source": "component", "key": "mass", "format": {"decimals": 256}}}), + json!({"field": {"source": "component", "key": "mass", "format": {"decimals": -1}}}), + json!({"field": {"source": "component", "key": "mass", "format": {"decimals": 1.5}}}), + json!({"field": {"source": "sheet"}}), + json!({"field": {"source": "sheet", "key": 1}}), + json!({"expr": "a + 1"}), + json!({"text": "a", "field": {"source": "sheet", "key": "number"}}), + json!({"kind": "text", "text": "a"}), + json!("Sheet"), + ] { + assert!(read(bad.clone()).is_err(), "{bad}"); + } +} + +#[test] +fn a_label_is_one_line_of_at_most_4096_bytes() { + const LABEL: Invalid = Invalid("a label is one line of at most 4096 bytes"); + for ok in ["Created by", "", "Masse (kg)", &"é".repeat(2048)] { + check_label(ok).unwrap(); + } + for bad in ["Title\nblock", "\t", "a\u{85}", &"a".repeat(4097), &"é".repeat(2049)] { + assert_eq!(check_label(bad), Err(LABEL), "{bad:?}"); + } +} + +#[test] +fn alignments_projections_and_fits_have_snake_case_names() { + for (align, name) in [(HAlign::Left, "left"), (HAlign::Centre, "centre"), (HAlign::Right, "right")] { + assert_eq!(serde_json::to_value(align).unwrap(), json!(name)); + assert_eq!(serde_json::from_value::(json!(name)).unwrap(), align); + } + assert_eq!(HAlign::default(), HAlign::Left); + for (projection, name) in [(Projection::First, "first"), (Projection::Third, "third")] { + assert_eq!(serde_json::to_value(projection).unwrap(), json!(name)); + assert_eq!(serde_json::from_value::(json!(name)).unwrap(), projection); + } + for bad in ["center", "Left", "middle", ""] { + assert!(serde_json::from_value::(json!(bad)).is_err(), "{bad}"); + } + for bad in ["First", "first_angle", "second", ""] { + assert!(serde_json::from_value::(json!(bad)).is_err(), "{bad}"); + } + let fit = Fit { max_chars: Some(25), wide_max_chars: Some(30), wrap: true }; + let value = serde_json::to_value(fit).unwrap(); + assert_eq!(value, json!({"max_chars": 25, "wide_max_chars": 30, "wrap": true})); + assert_eq!(serde_json::from_value::(value).unwrap(), fit); + assert_eq!(serde_json::from_value::(json!({})).unwrap(), Fit::default()); + assert!(serde_json::from_value::(json!({"padding_mm": 1.0})).is_err()); + assert!(serde_json::from_value::(json!({"max_chars": -1})).is_err()); +} + +/// A whole number below `n` from `rng`. +fn pick(rng: &mut Lcg, n: usize) -> usize { + ((rng.unit() * n as f64) as usize).min(n - 1) +} + +/// Random content, texts and boxes, hostile ones among them: validation agrees with the rule worked +/// out independently, the count agrees with one by hand, and nothing panics. +#[test] +fn fuzzed_content_texts_and_boxes_never_panic_and_follow_the_rules() { + const CHARS: [char; 14] = ['a', 'Z', '0', ' ', '\n', '\t', '\r', '\u{7f}', '\u{85}', 'é', '字', '한', '😀', '\0']; + const NUMBERS: [f64; 13] = [0.0, -1.0, 1e-300, 2.5, 5.0, 14.5, 1e5, 1.1e5, -1e308, 1e308, f64::NAN, f64::INFINITY, f64::NEG_INFINITY]; + let string = |rng: &mut Lcg, len: usize| -> String { (0..len).map(|_| CHARS[pick(rng, CHARS.len())]).collect() }; + let number = |rng: &mut Lcg| if pick(rng, 4) == 0 { NUMBERS[pick(rng, NUMBERS.len())] } else { rng.unit() * 200.0 - 50.0 }; + let mut rng = Lcg(0x5eed); + let mut accepted = 0; + let mut combinations = std::collections::BTreeSet::new(); + for round in 0..3000 { + let lengths = [0, 1, 7, 60, 1100, 4097]; + let mut parts = Vec::new(); + for i in 0..[0, 1, 3, 64, 65][round % 5] { + let len = lengths[(round + i) % lengths.len()]; + parts.push(match i % 3 { + 0 => Part::Text(string(&mut rng, len)), + 1 => Part::Field { source: Source::Drawing, key: string(&mut rng, len % 40), format: Format::default() }, + _ => { + let format = Format { unit: Some(string(&mut rng, len % 5)), decimals: Some(3) }; + Part::Field { source: Source::Component, key: "mass".into(), format } + } + }); + } + let content = Content(parts); + let strings: Vec<&str> = content + .0 + .iter() + .flat_map(|p| match p { + Part::Text(t) => vec![t.as_str()], + Part::Field { key, format, .. } => [Some(key.as_str()), format.unit.as_deref()].into_iter().flatten().collect(), + }) + .collect(); + let bytes: usize = strings.iter().map(|s| s.len()).sum(); + let clean = strings.iter().all(|s| s.chars().all(|c| c == '\n' || !c.is_control())); + let valid = content.0.len() <= MAX_PARTS && bytes <= MAX_TEXT_BYTES && clean; + assert_eq!(content.validate().is_ok(), valid, "round {round}: {} parts, {bytes} bytes, clean {clean}", content.0.len()); + accepted += usize::from(valid); + let back: Content = serde_json::from_value(serde_json::to_value(&content).unwrap()).unwrap(); + assert_eq!(back, content); + let fit = Fit { max_chars: Some(30), wide_max_chars: Some(20), wrap: false }; + for s in &strings { + let count = s.chars().filter(|&c| c != '\n').count(); + let max = if s.chars().any(is_wide) { 20 } else { 30 }; + assert_eq!(fit.warn_chars(s), (count > max as usize).then_some(Warning::Chars { count, max }), "{s:?}"); + } + let (x0, y0, x1, y1, cap, pitch) = + (number(&mut rng), number(&mut rng), number(&mut rng), number(&mut rng), number(&mut rng), number(&mut rng)); + // Each of the lines, the alignment and the wrapping picked on its own, so every combination comes up. + let lines = [0, 1, 2, 5, 1000, usize::MAX][pick(&mut rng, 6)]; + let (align, wrap) = ([HAlign::Left, HAlign::Centre, HAlign::Right][pick(&mut rng, 3)], pick(&mut rng, 2) == 0); + combinations.insert((lines, align as u8, wrap)); + let value_box = rect(x0, y0, x1, y1); + let on_paper = [x0, y0, x1, y1].iter().all(|v| v.abs() <= MAX_COORDINATE_MM); + let sane = on_paper && [cap, pitch].iter().all(|v| v.is_finite() && *v > 0.0) && x0 <= x1 && y0 <= y1; + let placed = place_lines(value_box, lines, cap, pitch, align, wrap); + assert_eq!(placed.is_ok(), sane, "{value_box:?} {cap} {pitch}"); + if let Ok((at, warning)) = placed { + assert!(at.is_finite() && at.x >= x0 && at.x <= x1 && at.y <= y1, "{at:?} in {value_box:?}"); + if lines == 1 && warning.is_none() { + // Its baseline in the box, and its descenders above the line's inner edge, the padding below it. + assert!(at.y >= y0 - EPS && at.y - cap * DESCENDER_OF_CAP >= y0 - PADDING_MM - EPS, "{at:?} {cap} in {value_box:?}"); + } + } + } + assert!(accepted > 500, "{accepted}"); + assert_eq!(combinations.len(), 6 * 3 * 2, "every lines, alignment and wrapping together"); +} diff --git a/crates/geom/src/drawing_sheet.rs b/crates/geom/src/drawing_sheet.rs new file mode 100644 index 0000000000..86ec18ba28 --- /dev/null +++ b/crates/geom/src/drawing_sheet.rs @@ -0,0 +1,521 @@ +//! Drawing sheets: paper sizes, orientation and the drawing border, as data in sheet millimetres. +//! +//! A [`Sheet`] is a paper size and an orientation; [`Sheet::size_mm`] gives its size as it lies, the +//! sheet border running round it. A [`DrawingBorderProfile`] is the rule for the drawing border +//! inside the sheet border: the margins between the two, the drawing border's line width, the grid +//! of reference fields with their labels, and the centring and trimming marks. +//! [`DrawingBorderProfile::validate`] checks a profile on its own, as when one is read, and +//! [`DrawingBorderProfile::layout`] works it out for one sheet, so a new size regrows it. Nothing +//! here is frozen geometry, saved in a design or drawn: a writer draws the [`SheetLayout`] later. +//! +//! Sheet coordinates are millimetres from the sheet's lower-left corner, x to the right and y up. +//! The drawing border's line is centred on [`SheetLayout::drawing_border`]. +//! +//! [`DrawingBorderProfile::iso`] is the built-in profile, after ISO 5457: 10 mm margins all round, +//! a 0.7 mm drawing border, and 50 mm fields counted out from the sheet's centre lines, the corner +//! fields taking what is left. Rows are lettered from the top without I and O and columns numbered +//! from the left, on the sides [`LabelSides::Iso`] gives: the top and right of an A4 sheet, every +//! side of the others. The labels sit between the drawing border and the grid reference border, +//! 5 mm outside it. A centring mark runs across the whole margin, from the trimmed sheet's edge to +//! the drawing border, at each end of the sheet's centre lines, never into the drawing area. +//! +//! The rest of ISO 5457's border is a choice in the profile: the 20 mm filing margin on the left, +//! for drawings that get bound; centring marks that start at the grid reference border and reach +//! inside the drawing border (10 mm in the standard's figure); and a trimming mark at each corner, outside the grid reference border, of +//! two overlapping 10 x 5 mm rectangles. The built-in profile leaves the trimming marks out, as +//! they take a lot of ink. +//! +//! Sizes: ISO 216 A0 to A5, Letter and Legal, ANSI A to F (ASME Y14.1), the architectural A to E +//! and E1, and custom sizes, finite, above 0 and at most [`MAX_SIDE_MM`] a side. Title blocks sit on +//! the drawing border ([`crate::title_block`]). The elongated ISO sizes and the extra centring marks a +//! sheet larger than A0 needs (one in the middle of each section to be filmed) are not here yet. + +use serde::{Deserialize, Serialize}; + +use crate::paper::MAX_LINE_MM; +/// The longest side a custom sheet may have, in millimetres: the paper scene's limit. +pub use crate::paper::MAX_SIDE_MM; +use crate::{Aabb2, Vec2}; + +/// The most fields an equal division may cut each way. +pub const MAX_EQUAL_FIELDS: u32 = 200; +/// ISO 5457's filing margin: the left margin of a drawing that gets bound. +pub const FILING_MARGIN_MM: f64 = 20.0; +const MIN_FIELD_MM: f64 = 1.0; +const MAX_FIELD_MM: f64 = 1000.0; +/// The smallest drawing space the margins may leave, each way. +const MIN_DRAWING_SPACE_MM: f64 = 1.0; +/// A corner field shorter than this joins its neighbour, so rounding never leaves a sliver. +const SLIVER_MM: f64 = 1e-9; +const INCH: f64 = 25.4; + +/// A standard paper size. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaperSize { + A0, + A1, + A2, + A3, + A4, + A5, + Letter, + Legal, + AnsiA, + AnsiB, + AnsiC, + AnsiD, + AnsiE, + AnsiF, + ArchA, + ArchB, + ArchC, + ArchD, + ArchE, + ArchE1, +} + +impl PaperSize { + pub const ALL: [PaperSize; 20] = { + use PaperSize::*; + [A0, A1, A2, A3, A4, A5, Letter, Legal, AnsiA, AnsiB, AnsiC, AnsiD, AnsiE, AnsiF, ArchA, ArchB, ArchC, ArchD, ArchE, ArchE1] + }; + + /// The short and the long side, in millimetres (the US sizes from their inches). + pub fn sides_mm(self) -> (f64, f64) { + use PaperSize::*; + let inches = |short: f64, long: f64| (short * INCH, long * INCH); + match self { + A0 => (841.0, 1189.0), + A1 => (594.0, 841.0), + A2 => (420.0, 594.0), + A3 => (297.0, 420.0), + A4 => (210.0, 297.0), + A5 => (148.0, 210.0), + Letter | AnsiA => inches(8.5, 11.0), + Legal => inches(8.5, 14.0), + AnsiB => inches(11.0, 17.0), + AnsiC => inches(17.0, 22.0), + AnsiD => inches(22.0, 34.0), + AnsiE => inches(34.0, 44.0), + AnsiF => inches(28.0, 40.0), + ArchA => inches(9.0, 12.0), + ArchB => inches(12.0, 18.0), + ArchC => inches(18.0, 24.0), + ArchD => inches(24.0, 36.0), + ArchE => inches(36.0, 48.0), + ArchE1 => inches(30.0, 42.0), + } + } +} + +/// A sheet's size: a standard one or any other. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum SheetSize { + Standard(PaperSize), + /// The two sides in either order; the orientation decides which lies across. + Custom { + sides_mm: [f64; 2], + }, +} + +/// Which way the sheet lies. Landscape puts the long side across. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Orientation { + #[default] + Landscape, + Portrait, +} + +/// One sheet: its size and the way it lies (landscape when not given). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Sheet { + pub size: SheetSize, + #[serde(default)] + pub orientation: Orientation, +} + +/// The distances from the sheet border to the drawing border, in millimetres. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Margins { + pub left_mm: f64, + pub right_mm: f64, + pub top_mm: f64, + pub bottom_mm: f64, +} + +impl Margins { + /// Left, right, top and bottom. + fn sides(self) -> [f64; 4] { + [self.left_mm, self.right_mm, self.top_mm, self.bottom_mm] + } +} + +/// How the drawing border is cut into reference fields. Externally tagged (`"none"`, +/// `{"from_centre": {...}}`), as serde ignores extra fields on an internally tagged unit variant. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum GridRule { + /// No reference fields. + None, + /// Fields of `field_mm` counted out from the sheet's centre lines, the corner fields taking + /// what is left (ISO 5457, with 50 mm). Each way the larger half sets the count `n` (its + /// length over `field_mm`, rounded, at least 1), and each half has `n` fields, or fewer where + /// it is shorter. + FromCentre { field_mm: f64 }, + /// The drawing border cut into equal fields. + Equal { columns: u32, rows: u32 }, +} + +/// Sides of the drawing border. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Sides { + pub top: bool, + pub bottom: bool, + pub left: bool, + pub right: bool, +} + +impl Sides { + pub const ALL: Sides = Sides { top: true, bottom: true, left: true, right: true }; +} + +/// Which sides of the drawing border carry the field labels: a rule for any sheet, so a profile +/// made for one size labels another correctly. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum LabelSides { + /// ISO 5457's: the top and right of an A4 sheet (either way), every side of the others. + Iso, + /// The same sides on every sheet. + Fixed(Sides), +} + +impl LabelSides { + /// The sides labelled on `sheet`. + pub fn on(self, sheet: &Sheet) -> Sides { + match self { + LabelSides::Iso if matches!(sheet.size, SheetSize::Standard(PaperSize::A4)) => { + Sides { top: true, bottom: false, left: false, right: true } + } + LabelSides::Iso => Sides::ALL, + LabelSides::Fixed(sides) => sides, + } + } +} + +/// The rule for a drawing border and the marks around it: worked out for each sheet by +/// [`DrawingBorderProfile::layout`]. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DrawingBorderProfile { + pub margins: Margins, + /// ISO 5457's filing margin: when on, the left margin is [`FILING_MARGIN_MM`] in place of + /// `margins.left_mm`, which is kept for when it's off again. Off when not given. + #[serde(default)] + pub filing_margin: bool, + /// The drawing border's line width; the centring marks are drawn in it too. + pub drawing_border_line_mm: f64, + pub grid: GridRule, + /// Row letters skip I and O (they read as 1 and 0). + pub skip_i_and_o: bool, + pub label_sides: LabelSides, + /// How far outside the drawing border the grid reference border runs; the labels sit between + /// the two. + pub grid_border_mm: f64, + /// How far each centring mark reaches inside the drawing border, 0 to stop it on the drawing + /// border, at most [`MAX_SIDE_MM`] (`None`: no centring marks). + pub centring_mark_mm: Option, + /// Where each centring mark starts outside the drawing border. The trimmed sheet's edge when not + /// given. + #[serde(default)] + pub centring_mark_start: CentringMarkStart, + /// The trimming mark's rectangles: their length along the sheet border, at most + /// [`MAX_SIDE_MM`], and their width into the sheet, which must leave them outside the grid + /// reference border (`None`: no trimming marks). + pub trimming_mark_mm: Option<[f64; 2]>, +} + +/// Where a centring mark starts, outside the drawing border. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CentringMarkStart { + /// The trimmed sheet's edge, so the mark runs across the whole margin (the built-in sheet's). + #[default] + SheetEdge, + /// The grid reference border, as ISO 5457's figure 5 draws it. + GridBorder, +} + +/// One reference field along its axis: `from_mm < to_mm` in sheet coordinates. +#[derive(Clone, Debug, PartialEq)] +pub struct Field { + pub from_mm: f64, + pub to_mm: f64, + pub label: String, +} + +/// A sheet's drawing border and the marks around it, worked out: what a writer draws. +#[derive(Clone, Debug, PartialEq)] +pub struct SheetLayout { + pub sheet_border: Aabb2, + pub drawing_border: Aabb2, + pub drawing_border_line_mm: f64, + /// Left to right, numbered from 1. + pub columns: Vec, + /// Top to bottom, lettered from A. + pub rows: Vec, + /// The sides labelled on this sheet. + pub label_sides: Sides, + pub grid_border: Aabb2, + /// Bottom, top, left and right: each from the grid reference border to the drawing border, or + /// as far inside it as the profile asks. + pub centring_marks: Vec<[Vec2; 2]>, + /// Filled rectangles, two at each corner of the sheet. + pub trimming_marks: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("drawing sheet: {0}")] +pub struct Invalid(pub &'static str); + +type Result = std::result::Result; + +fn require(condition: bool, message: &'static str) -> Result<()> { + if condition { Ok(()) } else { Err(Invalid(message)) } +} + +impl Sheet { + pub fn standard(size: PaperSize, orientation: Orientation) -> Sheet { + Sheet { size: SheetSize::Standard(size), orientation } + } + + /// The sheet's width and height as it lies. + pub fn size_mm(&self) -> Result { + let (short, long) = match self.size { + SheetSize::Standard(size) => size.sides_mm(), + SheetSize::Custom { sides_mm: [a, b] } => { + for side in [a, b] { + require(side.is_finite() && side > 0.0 && side <= MAX_SIDE_MM, "a custom side is finite, above 0 and at most 5000 mm")?; + } + (a.min(b), a.max(b)) + } + }; + Ok(match self.orientation { + Orientation::Landscape => Vec2::new(long, short), + Orientation::Portrait => Vec2::new(short, long), + }) + } +} + +impl DrawingBorderProfile { + /// The built-in drawing border, after ISO 5457, for a sheet of any size (the module's notes + /// say how it follows the standard). + pub fn iso() -> DrawingBorderProfile { + DrawingBorderProfile { + margins: Margins { left_mm: 10.0, right_mm: 10.0, top_mm: 10.0, bottom_mm: 10.0 }, + filing_margin: false, + drawing_border_line_mm: 0.7, + grid: GridRule::FromCentre { field_mm: 50.0 }, + skip_i_and_o: true, + label_sides: LabelSides::Iso, + grid_border_mm: 5.0, + centring_mark_mm: Some(0.0), + centring_mark_start: CentringMarkStart::SheetEdge, + trimming_mark_mm: None, + } + } + + /// The margins as laid out: `margins`, with the filing margin on the left when it's on. + fn laid_out_margins(&self) -> Margins { + let left_mm = if self.filing_margin { FILING_MARGIN_MM } else { self.margins.left_mm }; + Margins { left_mm, ..self.margins } + } + + /// Checks what doesn't depend on the sheet: call it when a profile is read. [`Self::layout`] + /// calls it too. + pub fn validate(&self) -> Result<()> { + // Every margin as given, the left one too while the filing margin stands in for it, so + // turning the filing margin off never brings back a bad value. + for margin in self.margins.sides() { + require(margin.is_finite() && (0.0..=MAX_SIDE_MM).contains(&margin), "a margin is finite and 0 to 5000 mm")?; + } + let line = self.drawing_border_line_mm; + require(line.is_finite() && line > 0.0 && line <= MAX_LINE_MM, "the drawing border's line is above 0 and at most 10 mm wide")?; + match self.grid { + GridRule::None => {} + GridRule::FromCentre { field_mm } => { + require(field_mm.is_finite() && (MIN_FIELD_MM..=MAX_FIELD_MM).contains(&field_mm), "a field is 1 to 1000 mm long")? + } + GridRule::Equal { columns, rows } => { + let counts = 1..=MAX_EQUAL_FIELDS; + require(counts.contains(&columns) && counts.contains(&rows), "an equal division has 1 to 200 fields each way")? + } + } + // The grid reference border lies in the margins, and the trimming marks between it and the + // sheet border (ISO 5457 4.5), so the narrowest margin laid out bounds both. + let narrowest = self.laid_out_margins().sides().into_iter().fold(f64::INFINITY, f64::min); + let g = self.grid_border_mm; + require(g.is_finite() && g >= 0.0 && g <= narrowest, "the grid reference border lies between the sheet border and the drawing border")?; + if let Some(reach) = self.centring_mark_mm { + let fits = reach.is_finite() && (0.0..=MAX_SIDE_MM).contains(&reach); + require(fits, "a centring mark ends on the drawing border or at most 5000 mm inside it")?; + // Else it would start and end on the drawing border. + let outside = match self.centring_mark_start { + CentringMarkStart::SheetEdge => narrowest, + CentringMarkStart::GridBorder => g, + }; + require(outside > 0.0 || reach > 0.0, "a centring mark needs room outside the drawing border to start from, or a reach inside it")?; + } + if let Some([length, width]) = self.trimming_mark_mm { + let fits = length.is_finite() && length <= MAX_SIDE_MM && width > 0.0 && width <= length; + require(fits, "a trimming mark is above 0, at most 5000 mm long and no wider than it is long")?; + require(width <= narrowest - g, "a trimming mark lies between the sheet border and the grid reference border")?; + } + Ok(()) + } + + /// The drawing border and the marks around it on `sheet`. + pub fn layout(&self, sheet: &Sheet) -> Result { + self.validate()?; + let size = sheet.size_mm()?; + let m = self.laid_out_margins(); + let drawing_border = Aabb2 { min: Vec2::new(m.left_mm, m.bottom_mm), max: Vec2::new(size.x - m.right_mm, size.y - m.top_mm) }; + let space = drawing_border.size(); + require(space.x >= MIN_DRAWING_SPACE_MM && space.y >= MIN_DRAWING_SPACE_MM, "the margins leave no drawing space")?; + let (across, down) = match self.grid { + GridRule::None => (vec![], vec![]), + GridRule::FromCentre { field_mm } => ( + from_centre(drawing_border.min.x, drawing_border.max.x, size.x / 2.0, field_mm)?, + from_centre(drawing_border.min.y, drawing_border.max.y, size.y / 2.0, field_mm)?, + ), + GridRule::Equal { columns, rows } => { + (equal(drawing_border.min.x, drawing_border.max.x, columns), equal(drawing_border.min.y, drawing_border.max.y, rows)) + } + }; + let columns = fields(&across, |i| (i + 1).to_string()); + // Rows run top to bottom: the edges are in rising y, so the top row comes last. + let mut rows = fields(&down, |_| String::new()); + rows.reverse(); + for (i, row) in rows.iter_mut().enumerate() { + row.label = letters(i, self.skip_i_and_o); + } + let sheet_border = Aabb2 { min: Vec2::ZERO, max: size }; + let grid_border = drawing_border.inflated(self.grid_border_mm); + let start = match self.centring_mark_start { + CentringMarkStart::SheetEdge => sheet_border, + CentringMarkStart::GridBorder => grid_border, + }; + let centring_marks = match self.centring_mark_mm { + Some(reach) => centring_marks(start, drawing_border, size, reach)?, + None => Vec::new(), + }; + let trimming_marks = match self.trimming_mark_mm { + Some(mark) => trimming_marks(size, mark)?, + None => Vec::new(), + }; + Ok(SheetLayout { + sheet_border, + drawing_border, + drawing_border_line_mm: self.drawing_border_line_mm, + columns, + rows, + label_sides: self.label_sides.on(sheet), + grid_border, + centring_marks, + trimming_marks, + }) + } +} + +/// The four centring marks: on the sheet's centre lines, from the edges of `start` to `reach` +/// inside the drawing border, which must not take them past the centre. +fn centring_marks(start: Aabb2, drawing_border: Aabb2, size: Vec2, reach: f64) -> Result> { + let (cx, cy) = (size.x / 2.0, size.y / 2.0); + let (lo, hi) = (drawing_border.min, drawing_border.max); + let room = (cx - lo.x).min(hi.x - cx).min(cy - lo.y).min(hi.y - cy); + require(reach <= room, "a centring mark stops at the sheet's centre lines")?; + Ok(vec![ + [Vec2::new(cx, start.min.y), Vec2::new(cx, lo.y + reach)], + [Vec2::new(cx, start.max.y), Vec2::new(cx, hi.y - reach)], + [Vec2::new(start.min.x, cy), Vec2::new(lo.x + reach, cy)], + [Vec2::new(start.max.x, cy), Vec2::new(hi.x - reach, cy)], + ]) +} + +/// The trimming marks: at each corner of the sheet, a rectangle `length` along each edge and +/// `width` into the sheet, the two overlapping in the corner. +fn trimming_marks(size: Vec2, [length, width]: [f64; 2]) -> Result> { + // Any longer and the two marks on one edge would meet. + require(length <= size.x.min(size.y) / 2.0, "a trimming mark is at most half the sheet's shorter side long")?; + // The span `extent` long from one end of `0..side`, the far end when `far`. + let span = |far: bool, side: f64, extent: f64| if far { (side - extent, side) } else { (0.0, extent) }; + let mut marks = Vec::with_capacity(8); + for (right, top) in [(false, false), (true, false), (false, true), (true, true)] { + for (across, up) in [(length, width), (width, length)] { + let ((x0, x1), (y0, y1)) = (span(right, size.x, across), span(top, size.y, up)); + marks.push(Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }); + } + } + Ok(marks) +} + +/// The field edges from `lo` to `hi`, in rising order, for fields of `field` out from `centre`. +fn from_centre(lo: f64, hi: f64, centre: f64, field: f64) -> Result> { + let (below, above) = (centre - lo, hi - centre); + require(below > SLIVER_MM && above > SLIVER_MM, "the sheet's centre lines cross the drawing border")?; + // At most MAX_SIDE_MM / MIN_FIELD_MM per half: a bounded count. + let n = (below.max(above) / field).round().max(1.0); + let count = |half: f64| n.min(((half - SLIVER_MM) / field).ceil()) as usize; + let (k_below, k_above) = (count(below), count(above)); + let mut edges = Vec::with_capacity(k_below + k_above + 1); + edges.push(lo); + edges.extend((1..k_below).rev().map(|j| centre - j as f64 * field)); + edges.push(centre); + edges.extend((1..k_above).map(|j| centre + j as f64 * field)); + edges.push(hi); + Ok(edges) +} + +/// The edges of `n` equal fields from `lo` to `hi`, ending exactly at `hi`. +fn equal(lo: f64, hi: f64, n: u32) -> Vec { + let step = (hi - lo) / f64::from(n); + (0..n).map(|i| lo + f64::from(i) * step).chain(std::iter::once(hi)).collect() +} + +fn fields(edges: &[f64], label: impl Fn(usize) -> String) -> Vec { + edges + .windows(2) + .enumerate() + .filter_map(|(i, w)| match *w { + [from_mm, to_mm] => Some(Field { from_mm, to_mm, label: label(i) }), + _ => None, + }) + .collect() +} + +/// The row label for row `i` (0 is A): A to Z, then AA, AB and on (without I and O if asked). +pub fn letters(i: usize, skip_i_and_o: bool) -> String { + const ALL: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const NO_I_O: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ"; + let alphabet = if skip_i_and_o { NO_I_O } else { ALL }; + let base = alphabet.len(); + let mut out = Vec::new(); + // Bijective numbering: after the last letter comes AA, not BA. + let mut n = i.saturating_add(1); + while n > 0 { + n -= 1; + out.extend(alphabet.get(n % base).map(|&b| char::from(b))); + n /= base; + } + out.iter().rev().collect() +} + +#[cfg(test)] +#[path = "drawing_sheet_tests.rs"] +mod tests; diff --git a/crates/geom/src/drawing_sheet_tests.rs b/crates/geom/src/drawing_sheet_tests.rs new file mode 100644 index 0000000000..6b6ee2612c --- /dev/null +++ b/crates/geom/src/drawing_sheet_tests.rs @@ -0,0 +1,611 @@ +use super::*; + +const EPS: f64 = 1e-9; + +fn iso_layout(size: PaperSize, orientation: Orientation) -> SheetLayout { + DrawingBorderProfile::iso().layout(&Sheet::standard(size, orientation)).unwrap() +} + +fn with_filing_margin() -> DrawingBorderProfile { + DrawingBorderProfile { filing_margin: true, ..DrawingBorderProfile::iso() } +} + +/// ISO 5457's border with every choice on: the filing margin, centring marks from the grid +/// reference border to 10 mm inside the drawing border, and trimming marks. +fn full_iso_5457() -> DrawingBorderProfile { + DrawingBorderProfile { + centring_mark_mm: Some(10.0), + centring_mark_start: CentringMarkStart::GridBorder, + trimming_mark_mm: Some([10.0, 5.0]), + ..with_filing_margin() + } +} + +fn lengths(fields: &[Field]) -> Vec { + fields.iter().map(|f| f.to_mm - f.from_mm).collect() +} + +fn close(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < EPS) +} + +#[test] +fn every_size_is_its_published_millimetres() { + use PaperSize::*; + let expected = [ + (A0, 841.0, 1189.0), + (A1, 594.0, 841.0), + (A2, 420.0, 594.0), + (A3, 297.0, 420.0), + (A4, 210.0, 297.0), + (A5, 148.0, 210.0), + (Letter, 215.9, 279.4), + (Legal, 215.9, 355.6), + (AnsiA, 215.9, 279.4), + (AnsiB, 279.4, 431.8), + (AnsiC, 431.8, 558.8), + (AnsiD, 558.8, 863.6), + (AnsiE, 863.6, 1117.6), + (AnsiF, 711.2, 1016.0), + (ArchA, 228.6, 304.8), + (ArchB, 304.8, 457.2), + (ArchC, 457.2, 609.6), + (ArchD, 609.6, 914.4), + (ArchE, 914.4, 1219.2), + (ArchE1, 762.0, 1066.8), + ]; + assert_eq!(expected.len(), PaperSize::ALL.len()); + for (size, short, long) in expected { + let (s, l) = size.sides_mm(); + assert!((s - short).abs() < EPS && (l - long).abs() < EPS, "{size:?}: {s} x {l}"); + } +} + +#[test] +fn landscape_is_the_default_and_either_orientation_turns_every_size() { + let sheet: Sheet = serde_json::from_value(serde_json::json!({"size": {"standard": "a3"}})).unwrap(); + assert_eq!(sheet.orientation, Orientation::Landscape); + assert_eq!(sheet.size_mm().unwrap(), Vec2::new(420.0, 297.0)); + for size in PaperSize::ALL { + let across = Sheet::standard(size, Orientation::Landscape).size_mm().unwrap(); + let upright = Sheet::standard(size, Orientation::Portrait).size_mm().unwrap(); + assert!(across.x > across.y && upright.x < upright.y, "{size:?}"); + assert_eq!(across, Vec2::new(upright.y, upright.x)); + } + // A custom sheet's sides may come in either order; the orientation lays it. + for sides_mm in [[500.0, 700.0], [700.0, 500.0]] { + let sheet = Sheet { size: SheetSize::Custom { sides_mm }, orientation: Orientation::Portrait }; + assert_eq!(sheet.size_mm().unwrap(), Vec2::new(500.0, 700.0)); + } +} + +#[test] +fn a3_landscape_has_a_400_by_277_drawing_border_and_8_by_6_fields() { + let layout = iso_layout(PaperSize::A3, Orientation::Landscape); + assert_eq!(layout.sheet_border, Aabb2 { min: Vec2::ZERO, max: Vec2::new(420.0, 297.0) }); + assert_eq!(layout.drawing_border, Aabb2 { min: Vec2::new(10.0, 10.0), max: Vec2::new(410.0, 287.0) }); + assert_eq!(layout.drawing_border_line_mm, 0.7); + // Columns: 50 mm out from the centre line at 210, which is 200 mm from either side: four fields + // each way, and nothing left over for the corners. + assert!(close(&lengths(&layout.columns), &[50.0; 8])); + assert_eq!(layout.columns.iter().map(|f| f.label.as_str()).collect::>(), ["1", "2", "3", "4", "5", "6", "7", "8"]); + assert!(layout.columns.iter().any(|f| f.to_mm == 210.0)); + // Rows: 148.5 above and below 10 and 287, so two of 50 and a 38.5 mm corner each way. + assert!(close(&lengths(&layout.rows), &[38.5, 50.0, 50.0, 50.0, 50.0, 38.5])); + assert_eq!(layout.rows.iter().map(|f| f.label.as_str()).collect::>(), ["A", "B", "C", "D", "E", "F"]); + let (top, bottom) = (layout.rows.first().unwrap(), layout.rows.last().unwrap()); + assert_eq!((top.from_mm, top.to_mm), (248.5, 287.0)); + assert_eq!((bottom.from_mm, bottom.to_mm), (10.0, 48.5)); + assert_eq!(layout.label_sides, Sides::ALL); +} + +#[test] +fn a3_landscape_has_a_grid_border_and_centring_marks_across_the_margin() { + let layout = iso_layout(PaperSize::A3, Orientation::Landscape); + // 5 mm outside the drawing border (10, 10)-(410, 287). + assert_eq!(layout.grid_border, Aabb2 { min: Vec2::new(5.0, 5.0), max: Vec2::new(415.0, 292.0) }); + // On the centre lines x = 210 and y = 148.5, from the sheet's edge to the drawing border. + let mark = |a: (f64, f64), b: (f64, f64)| [Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)]; + assert_eq!( + layout.centring_marks, + [ + mark((210.0, 0.0), (210.0, 10.0)), + mark((210.0, 297.0), (210.0, 287.0)), + mark((0.0, 148.5), (10.0, 148.5)), + mark((420.0, 148.5), (410.0, 148.5)) + ] + ); + // From the grid reference border instead, when the profile asks. + let iso_start = DrawingBorderProfile { centring_mark_start: CentringMarkStart::GridBorder, ..DrawingBorderProfile::iso() }; + let from_grid = iso_start.layout(&Sheet::standard(PaperSize::A3, Orientation::Landscape)).unwrap(); + assert_eq!(from_grid.centring_marks[0], mark((210.0, 5.0), (210.0, 10.0))); + assert_eq!(from_grid.centring_marks[3], mark((415.0, 148.5), (410.0, 148.5))); + assert!(layout.trimming_marks.is_empty()); + // On the sheet's centre lines, not the drawing border's: a deeper bottom margin leaves them at 148.5. + let sheet = Sheet::standard(PaperSize::A3, Orientation::Landscape); + let iso = DrawingBorderProfile::iso(); + let deep = DrawingBorderProfile { margins: Margins { bottom_mm: 30.0, ..iso.margins }, ..iso }.layout(&sheet).unwrap(); + assert!(deep.centring_marks.iter().skip(2).all(|[a, b]| a.y == 148.5 && b.y == 148.5), "{:?}", deep.centring_marks); + // A profile can leave the centring marks out. + let plain = DrawingBorderProfile { centring_mark_mm: None, ..iso }.layout(&sheet).unwrap(); + assert!(plain.centring_marks.is_empty()); + assert_eq!(plain.grid_border, layout.grid_border); +} + +/// A profile can still ask for all of ISO 5457's border: on A3 the drawing border, fields and marks +/// are the standard's. +#[test] +fn a3_landscape_with_iso_5457s_full_border_has_its_filing_margin_and_every_mark() { + let layout = full_iso_5457().layout(&Sheet::standard(PaperSize::A3, Orientation::Landscape)).unwrap(); + // ISO 5457's 390 x 277 drawing space; the left corner field takes the 40 mm left over. + assert_eq!(layout.drawing_border, Aabb2 { min: Vec2::new(20.0, 10.0), max: Vec2::new(410.0, 287.0) }); + assert!(close(&lengths(&layout.columns), &[40.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0])); + assert!(close(&lengths(&layout.rows), &[38.5, 50.0, 50.0, 50.0, 50.0, 38.5])); + // 5 mm outside the drawing border, the left side too. + assert_eq!(layout.grid_border, Aabb2 { min: Vec2::new(15.0, 5.0), max: Vec2::new(415.0, 292.0) }); + // From the grid reference border to 10 mm inside the drawing border. + let mark = |a: (f64, f64), b: (f64, f64)| [Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)]; + assert_eq!( + layout.centring_marks, + [ + mark((210.0, 5.0), (210.0, 20.0)), + mark((210.0, 292.0), (210.0, 277.0)), + mark((15.0, 148.5), (30.0, 148.5)), + mark((415.0, 148.5), (400.0, 148.5)) + ] + ); + // At each corner, 10 along the bottom or top edge by 5 up or down, and 5 by 10 along the side. + let rect = |x0, y0, x1, y1| Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }; + assert_eq!( + layout.trimming_marks, + [ + rect(0.0, 0.0, 10.0, 5.0), + rect(0.0, 0.0, 5.0, 10.0), + rect(410.0, 0.0, 420.0, 5.0), + rect(415.0, 0.0, 420.0, 10.0), + rect(0.0, 292.0, 10.0, 297.0), + rect(0.0, 287.0, 5.0, 297.0), + rect(410.0, 292.0, 420.0, 297.0), + rect(415.0, 287.0, 420.0, 297.0), + ] + ); +} + +/// On every standard size either way, the marks are where the profile puts them: the built-in +/// centring marks run from the sheet's edge and end exactly on the drawing border, and there are no +/// trimming marks; ISO 5457's full border starts them at the grid reference border, reaches 10 mm +/// inside and has its trimming marks in the corners. +#[test] +fn every_sheet_has_centring_marks_on_its_centre_lines_and_trimming_marks_only_when_asked() { + for (profile, reach, trimming_marks) in [(DrawingBorderProfile::iso(), 0.0, 0), (full_iso_5457(), 10.0, 8)] { + for size in PaperSize::ALL { + for orientation in [Orientation::Landscape, Orientation::Portrait] { + let layout = profile.layout(&Sheet::standard(size, orientation)).unwrap(); + let (size_mm, drawing, grid) = (layout.sheet_border.max, layout.drawing_border, layout.grid_border); + let centre = Vec2::new(size_mm.x / 2.0, size_mm.y / 2.0); + let start = match profile.centring_mark_start { + CentringMarkStart::SheetEdge => layout.sheet_border, + CentringMarkStart::GridBorder => grid, + }; + // Bottom, top, left and right, from where the profile starts them. + let expected = [ + [Vec2::new(centre.x, start.min.y), Vec2::new(centre.x, drawing.min.y + reach)], + [Vec2::new(centre.x, start.max.y), Vec2::new(centre.x, drawing.max.y - reach)], + [Vec2::new(start.min.x, centre.y), Vec2::new(drawing.min.x + reach, centre.y)], + [Vec2::new(start.max.x, centre.y), Vec2::new(drawing.max.x - reach, centre.y)], + ]; + assert_eq!(layout.centring_marks, expected, "{size:?} {orientation:?} {reach}"); + assert_eq!(layout.trimming_marks.len(), trimming_marks, "{size:?} {orientation:?}"); + for (i, r) in layout.trimming_marks.iter().enumerate() { + assert!(r.min.x >= 0.0 && r.min.y >= 0.0 && r.max.x <= size_mm.x && r.max.y <= size_mm.y, "{size:?}: {r:?}"); + let on_corner = (r.min.x == 0.0 || r.max.x == size_mm.x) && (r.min.y == 0.0 || r.max.y == size_mm.y); + let shape = if i % 2 == 0 { Vec2::new(10.0, 5.0) } else { Vec2::new(5.0, 10.0) }; + assert!(on_corner && r.size().dist(shape) < EPS, "{size:?} {orientation:?}: {r:?}"); + // In the margins: up to the grid reference border, never across it. + assert!(!r.inflated(-EPS).meets(&grid), "{size:?} {orientation:?}: {r:?}"); + } + } + } + } +} + +/// The filing margin puts ISO 5457's 20 mm on the left in either orientation and moves nothing +/// else; a 20 mm left margin set by hand lays out the same. +#[test] +fn the_filing_margin_puts_20_mm_on_the_left_of_every_sheet() { + let iso = DrawingBorderProfile::iso(); + let (filing, by_hand) = (with_filing_margin(), DrawingBorderProfile { margins: Margins { left_mm: 20.0, ..iso.margins }, ..iso }); + // ISO 5457's drawing spaces: A3 across 390 x 277, A4 upright 180 x 277. + let a3 = filing.layout(&Sheet::standard(PaperSize::A3, Orientation::Landscape)).unwrap(); + assert_eq!(a3.drawing_border, Aabb2 { min: Vec2::new(20.0, 10.0), max: Vec2::new(410.0, 287.0) }); + assert!(close(&lengths(&a3.columns), &[40.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0])); + let a4 = filing.layout(&Sheet::standard(PaperSize::A4, Orientation::Portrait)).unwrap(); + assert_eq!(a4.drawing_border, Aabb2 { min: Vec2::new(20.0, 10.0), max: Vec2::new(200.0, 287.0) }); + // 85 mm left of the centre line at 105 and 95 right of it: two fields each way. + assert!(close(&lengths(&a4.columns), &[35.0, 50.0, 50.0, 45.0])); + for size in PaperSize::ALL { + for orientation in [Orientation::Landscape, Orientation::Portrait] { + let sheet = Sheet::standard(size, orientation); + let (on, off) = (filing.layout(&sheet).unwrap(), iso.layout(&sheet).unwrap()); + assert_eq!(on, by_hand.layout(&sheet).unwrap(), "{size:?} {orientation:?}"); + assert_eq!((on.drawing_border.min.x, off.drawing_border.min.x), (20.0, 10.0), "{size:?} {orientation:?}"); + assert_eq!((on.drawing_border.min.y, on.drawing_border.max), (off.drawing_border.min.y, off.drawing_border.max)); + assert_eq!(on.rows, off.rows, "{size:?} {orientation:?}"); + } + } + // A left margin set by hand is kept for when the filing margin is off again. + let a3 = Sheet::standard(PaperSize::A3, Orientation::Landscape); + let wide = DrawingBorderProfile { margins: Margins { left_mm: 30.0, ..iso.margins }, ..iso }; + assert_eq!(DrawingBorderProfile { filing_margin: true, ..wide }.layout(&a3).unwrap().drawing_border.min.x, 20.0); + assert_eq!(wide.layout(&a3).unwrap().drawing_border.min.x, 30.0); +} + +#[test] +fn the_iso_rule_gives_the_standard_field_counts_for_a0_to_a4_either_way() { + // ISO 5457's table, long side by short side, with the filing margin or without. + for profile in [DrawingBorderProfile::iso(), with_filing_margin()] { + for (size, long, short) in + [(PaperSize::A0, 24, 16), (PaperSize::A1, 16, 12), (PaperSize::A2, 12, 8), (PaperSize::A3, 8, 6), (PaperSize::A4, 6, 4)] + { + let across = profile.layout(&Sheet::standard(size, Orientation::Landscape)).unwrap(); + assert_eq!((across.columns.len(), across.rows.len()), (long, short), "{size:?} landscape {profile:?}"); + let upright = profile.layout(&Sheet::standard(size, Orientation::Portrait)).unwrap(); + assert_eq!((upright.columns.len(), upright.rows.len()), (short, long), "{size:?} portrait {profile:?}"); + } + } + // A1 across: 410.5 mm each side of the centre line, so eight fields each way and 60.5 mm corners; + // the filing margin leaves 400.5 on the left, a 50.5 mm corner. + let a1 = Sheet::standard(PaperSize::A1, Orientation::Landscape); + for (profile, left) in [(DrawingBorderProfile::iso(), 60.5), (with_filing_margin(), 50.5)] { + let widths = lengths(&profile.layout(&a1).unwrap().columns); + assert!((widths.first().unwrap() - left).abs() < EPS && (widths.last().unwrap() - 60.5).abs() < EPS, "{widths:?}"); + } +} + +/// The label sides are worked out for each sheet, so one profile labels every size as ISO 5457 does. +#[test] +fn one_iso_profile_labels_a4_on_its_top_and_right_and_every_other_size_all_round() { + let iso = DrawingBorderProfile::iso(); + let top_right = Sides { top: true, bottom: false, left: false, right: true }; + let bottom_left = Sides { top: false, bottom: true, left: true, right: false }; + let fixed = DrawingBorderProfile { label_sides: LabelSides::Fixed(bottom_left), ..iso }; + for size in PaperSize::ALL { + for orientation in [Orientation::Portrait, Orientation::Landscape] { + let sheet = Sheet::standard(size, orientation); + let expected = if size == PaperSize::A4 { top_right } else { Sides::ALL }; + assert_eq!(iso.layout(&sheet).unwrap().label_sides, expected, "{size:?} {orientation:?}"); + assert_eq!(fixed.layout(&sheet).unwrap().label_sides, bottom_left, "{size:?} {orientation:?}"); + } + } +} + +#[test] +fn an_equal_division_cuts_the_drawing_border_evenly() { + let sheet = Sheet::standard(PaperSize::A3, Orientation::Landscape); + // With the filing margin, so the 390 mm across isn't a whole number of 50 mm fields. + let profile = DrawingBorderProfile { grid: GridRule::Equal { columns: 8, rows: 6 }, ..with_filing_margin() }; + let layout = profile.layout(&sheet).unwrap(); + assert!(close(&lengths(&layout.columns), &[48.75; 8])); + assert!(close(&lengths(&layout.rows), &[277.0 / 6.0; 6])); + assert!((277.0 / 6.0 - 46.166_666_666_7_f64).abs() < 1e-9); + let none = DrawingBorderProfile { grid: GridRule::None, ..profile }.layout(&sheet).unwrap(); + assert!(none.columns.is_empty() && none.rows.is_empty()); + assert_eq!(none.drawing_border, layout.drawing_border); +} + +#[test] +fn rows_skip_i_and_o_and_letters_go_on_past_z() { + let a0 = iso_layout(PaperSize::A0, Orientation::Landscape); + let rows: String = a0.rows.iter().map(|f| f.label.as_str()).collect(); + assert_eq!(rows, "ABCDEFGHJKLMNPQR"); + assert_eq!(letters(0, true), "A"); + assert_eq!(letters(23, true), "Z"); + assert_eq!(letters(24, true), "AA"); + assert_eq!(letters(25, true), "AB"); + assert_eq!(letters(24 + 24 * 24 - 1, true), "ZZ"); + assert_eq!(letters(24 + 24 * 24, true), "AAA"); + assert_eq!(letters(8, false), "I"); + assert_eq!(letters(25, false), "Z"); + assert_eq!(letters(26, false), "AA"); + assert_eq!(letters(usize::MAX, true).len(), letters(usize::MAX - 1, true).len()); + for i in 0..2000 { + assert!(!letters(i, true).contains(['I', 'O']), "{i}"); + } + let sheet = Sheet::standard(PaperSize::A3, Orientation::Landscape); + let every = DrawingBorderProfile { skip_i_and_o: false, grid: GridRule::Equal { columns: 1, rows: 10 }, ..DrawingBorderProfile::iso() }; + let rows: String = every.layout(&sheet).unwrap().rows.iter().map(|f| f.label.as_str()).collect(); + assert_eq!(rows, "ABCDEFGHIJ"); +} + +#[test] +fn hostile_sizes_and_profiles_are_refused() { + let custom = |a: f64, b: f64| Sheet { size: SheetSize::Custom { sides_mm: [a, b] }, orientation: Orientation::Landscape }; + for (a, b) in [(0.0, 100.0), (-1.0, 100.0), (f64::NAN, 100.0), (100.0, f64::INFINITY), (5_000.000_001, 100.0), (f64::MIN_POSITIVE, f64::MAX)] { + assert!(custom(a, b).size_mm().is_err(), "{a} x {b}"); + } + assert_eq!(custom(5000.0, 5000.0).size_mm().unwrap(), Vec2::new(5000.0, 5000.0)); + let a3 = Sheet::standard(PaperSize::A3, Orientation::Landscape); + let iso = DrawingBorderProfile::iso(); + let with_margins = + |left_mm, right_mm, top_mm, bottom_mm| DrawingBorderProfile { margins: Margins { left_mm, right_mm, top_mm, bottom_mm }, ..iso }; + // Refused on its own, with no sheet at all: validate() refuses it, and so layout() does too. + for profile in [ + with_margins(-1.0, 10.0, 10.0, 10.0), + with_margins(f64::NAN, 10.0, 10.0, 10.0), + with_margins(20.0, f64::INFINITY, 10.0, 10.0), + with_margins(20.0, 10.0, 5000.1, 10.0), + // The left margin is checked while the filing margin stands in for it. + DrawingBorderProfile { filing_margin: true, ..with_margins(f64::NAN, 10.0, 10.0, 10.0) }, + DrawingBorderProfile { filing_margin: true, ..with_margins(-1.0, 10.0, 10.0, 10.0) }, + DrawingBorderProfile { drawing_border_line_mm: 0.0, ..iso }, + DrawingBorderProfile { drawing_border_line_mm: -0.7, ..iso }, + DrawingBorderProfile { drawing_border_line_mm: f64::NAN, ..iso }, + DrawingBorderProfile { drawing_border_line_mm: 10.000_001, ..iso }, + DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: 0.999 }, ..iso }, + DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: 1000.1 }, ..iso }, + DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: f64::NAN }, ..iso }, + DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: f64::INFINITY }, ..iso }, + DrawingBorderProfile { grid: GridRule::Equal { columns: 0, rows: 6 }, ..iso }, + DrawingBorderProfile { grid: GridRule::Equal { columns: 8, rows: 0 }, ..iso }, + DrawingBorderProfile { grid: GridRule::Equal { columns: 201, rows: 1 }, ..iso }, + DrawingBorderProfile { grid: GridRule::Equal { columns: u32::MAX, rows: u32::MAX }, ..iso }, + // The grid reference border lies between the sheet border and the drawing border. + DrawingBorderProfile { grid_border_mm: -0.1, ..iso }, + DrawingBorderProfile { grid_border_mm: f64::NAN, ..iso }, + DrawingBorderProfile { grid_border_mm: 10.000_001, ..iso }, + // On every side: 5 mm is wider than a 4 mm bottom margin, or a 4 mm left one without the + // filing margin. + with_margins(20.0, 10.0, 10.0, 4.0), + with_margins(4.0, 10.0, 10.0, 10.0), + // A centring mark ends on the drawing border or inside it... + DrawingBorderProfile { centring_mark_mm: Some(-0.000_001), ..iso }, + DrawingBorderProfile { centring_mark_mm: Some(-10.0), ..iso }, + DrawingBorderProfile { centring_mark_mm: Some(f64::NAN), ..iso }, + DrawingBorderProfile { centring_mark_mm: Some(f64::INFINITY), ..iso }, + // No mark is longer than the largest sheet's side, so a file's huge number is refused when + // read rather than saved as one the next read refuses. + DrawingBorderProfile { centring_mark_mm: Some(5_000.000_001), ..iso }, + DrawingBorderProfile { centring_mark_mm: Some(u64::MAX as f64), ..iso }, + // ...and is longer than 0: one that stops on the drawing border needs room outside it on + // the side it starts from, a grid reference border or every margin. + DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_start: CentringMarkStart::GridBorder, ..iso }, + DrawingBorderProfile { grid_border_mm: 0.0, ..with_margins(0.0, 10.0, 10.0, 10.0) }, + // A trimming mark is finite, above 0 and no wider than it is long... + DrawingBorderProfile { trimming_mark_mm: Some([4.0, 5.0]), ..iso }, + DrawingBorderProfile { trimming_mark_mm: Some([10.0, 0.0]), ..iso }, + DrawingBorderProfile { trimming_mark_mm: Some([f64::NAN, 5.0]), ..iso }, + DrawingBorderProfile { trimming_mark_mm: Some([10.0, f64::NAN]), ..iso }, + DrawingBorderProfile { trimming_mark_mm: Some([f64::INFINITY, 5.0]), ..iso }, + DrawingBorderProfile { trimming_mark_mm: Some([5_000.000_001, 5.0]), ..iso }, + DrawingBorderProfile { trimming_mark_mm: Some([u64::MAX as f64, 5.0]), ..iso }, + // ...and fits between the sheet border and the grid reference border: ISO 5457's 5 mm fill + // the 10 - 5 mm left exactly. + DrawingBorderProfile { grid_border_mm: 5.000_001, ..full_iso_5457() }, + DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_mm: None, trimming_mark_mm: Some([10.0, 5.0]), ..with_margins(2.0, 2.0, 2.0, 2.0) }, + ] { + assert!(profile.validate().is_err(), "validated {profile:?}"); + assert!(profile.layout(&a3).is_err(), "laid out {profile:?}"); + } + // Fine on its own, but not on an A3 sheet. + for profile in [ + with_margins(210.0, 210.0, 10.0, 10.0), + with_margins(20.0, 10.0, 148.0, 148.5), + // A millimetre of drawing space, but the centre line at 210 lies outside it. + with_margins(200.0, 219.0, 10.0, 10.0), + // A centring mark past the centre line, 138.5 mm inside the drawing border's bottom. + DrawingBorderProfile { centring_mark_mm: Some(138.500_001), ..iso }, + // Or a drawing border its centre lines miss. + DrawingBorderProfile { grid: GridRule::Equal { columns: 2, rows: 2 }, ..with_margins(200.0, 219.0, 10.0, 10.0) }, + // Or one whose left side lies on the centre line at 210, with any reach inside it. + DrawingBorderProfile { + grid: GridRule::Equal { columns: 2, rows: 2 }, + centring_mark_mm: Some(0.000_001), + ..with_margins(210.0, 10.0, 10.0, 10.0) + }, + // A trimming mark longer than half the 297 mm side. + DrawingBorderProfile { trimming_mark_mm: Some([148.500_001, 5.0]), ..iso }, + // The longest marks a profile may have, on the largest sheet only. + DrawingBorderProfile { centring_mark_mm: Some(5000.0), ..iso }, + DrawingBorderProfile { trimming_mark_mm: Some([5000.0, 5.0]), ..iso }, + ] { + assert_eq!(profile.validate(), Ok(()), "{profile:?}"); + assert!(profile.layout(&a3).is_err(), "laid out {profile:?}"); + } + // The same space with an equal division and no centring marks is fine, and so are the limits + // themselves. + let off_centre = + DrawingBorderProfile { grid: GridRule::Equal { columns: 2, rows: 2 }, centring_mark_mm: None, ..with_margins(200.0, 219.0, 10.0, 10.0) }; + assert!((off_centre.layout(&a3).unwrap().drawing_border.max.x - 201.0).abs() < EPS); + for profile in [ + DrawingBorderProfile { drawing_border_line_mm: 10.0, ..iso }, + DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: 1.0 }, ..iso }, + DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: 1000.0 }, ..iso }, + DrawingBorderProfile { grid: GridRule::Equal { columns: 200, rows: 200 }, ..iso }, + DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_mm: None, ..with_margins(0.0, 0.0, 0.0, 0.0) }, + DrawingBorderProfile { grid_border_mm: 10.0, ..iso }, + DrawingBorderProfile { centring_mark_mm: Some(138.5), ..iso }, + // With no grid reference border, a centring mark that reaches inside has a length, and so + // does one from the sheet's edge. + DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_mm: Some(10.0), centring_mark_start: CentringMarkStart::GridBorder, ..iso }, + DrawingBorderProfile { grid_border_mm: 0.0, ..iso }, + // A centre line on the drawing border's left side: a mark that stops on the border fits. + DrawingBorderProfile { grid: GridRule::Equal { columns: 2, rows: 2 }, ..with_margins(210.0, 10.0, 10.0, 10.0) }, + // The filing margin stands in for a left margin too narrow for the grid reference border. + DrawingBorderProfile { filing_margin: true, ..with_margins(4.0, 10.0, 10.0, 10.0) }, + iso, + // ISO 5457's trimming marks fill their strip exactly, and so do these. + full_iso_5457(), + DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_mm: None, trimming_mark_mm: Some([10.0, 2.0]), ..with_margins(2.0, 2.0, 2.0, 2.0) }, + DrawingBorderProfile { trimming_mark_mm: Some([148.5, 5.0]), ..iso }, + ] { + profile.layout(&a3).unwrap(); + } +} + +#[test] +fn names_round_trip_and_unknown_fields_are_refused() { + for (size, name) in + [(PaperSize::A0, "a0"), (PaperSize::A5, "a5"), (PaperSize::Letter, "letter"), (PaperSize::AnsiA, "ansi_a"), (PaperSize::ArchE1, "arch_e1")] + { + assert_eq!(serde_json::to_value(size).unwrap(), serde_json::json!(name)); + } + for size in PaperSize::ALL { + let back: PaperSize = serde_json::from_value(serde_json::to_value(size).unwrap()).unwrap(); + assert_eq!(back, size); + } + let sheet = Sheet { size: SheetSize::Custom { sides_mm: [500.0, 700.0] }, orientation: Orientation::Portrait }; + let value = serde_json::to_value(sheet).unwrap(); + assert_eq!(value, serde_json::json!({"size": {"custom": {"sides_mm": [500.0, 700.0]}}, "orientation": "portrait"})); + assert_eq!(serde_json::from_value::(value).unwrap(), sheet); + let profile = DrawingBorderProfile::iso(); + let value = serde_json::to_value(profile).unwrap(); + assert_eq!(value["margins"], serde_json::json!({"left_mm": 10.0, "right_mm": 10.0, "top_mm": 10.0, "bottom_mm": 10.0})); + assert_eq!(value["filing_margin"], serde_json::json!(false)); + assert_eq!(value["grid"], serde_json::json!({"from_centre": {"field_mm": 50.0}})); + assert_eq!(value["label_sides"], serde_json::json!("iso")); + assert_eq!(value["centring_mark_mm"], serde_json::json!(0.0)); + assert_eq!(value["centring_mark_start"], serde_json::json!("sheet_edge")); + // Not given, it is the sheet's edge; an unknown start is refused. + let mut without = value.clone(); + without.as_object_mut().unwrap().remove("centring_mark_start"); + assert_eq!(serde_json::from_value::(without).unwrap(), profile); + let mut unknown = value.clone(); + unknown["centring_mark_start"] = serde_json::json!("middle"); + assert!(serde_json::from_value::(unknown).is_err()); + assert_eq!(value["trimming_mark_mm"], serde_json::json!(null)); + assert_eq!(serde_json::to_value(GridRule::None).unwrap(), serde_json::json!("none")); + assert_eq!(serde_json::from_value::(value.clone()).unwrap(), profile); + for other in [ + DrawingBorderProfile { grid: GridRule::None, ..profile }, + DrawingBorderProfile { grid: GridRule::Equal { columns: 8, rows: 6 }, ..profile }, + DrawingBorderProfile { label_sides: LabelSides::Fixed(Sides::ALL), ..profile }, + full_iso_5457(), + ] { + assert_eq!(serde_json::from_value::(serde_json::to_value(other).unwrap()).unwrap(), other); + } + // A profile written without the filing margin reads with it off. + let mut without = value.clone(); + without.as_object_mut().unwrap().remove("filing_margin"); + assert_eq!(serde_json::from_value::(without).unwrap(), profile); + for bad in [ + serde_json::json!({"size": {"standard": "a6"}}), + serde_json::json!({"size": {"standard": "a3"}, "scale": 1}), + serde_json::json!({"size": {"custom": {"sides_mm": [1, 2], "units": "in"}}}), + serde_json::json!({"size": {"custom": {"sides_mm": [1, 2, 3]}}}), + serde_json::json!({"size": {"standard": "a3"}, "orientation": "upside_down"}), + ] { + assert!(serde_json::from_value::(bad.clone()).is_err(), "{bad}"); + } + // An unknown field anywhere in a profile is refused, in the grid's `none` form too. + for (key, bad) in [ + ("title_block", serde_json::json!(true)), + ("grid", serde_json::json!({"rule": "none", "columns": 8})), + ("grid", serde_json::json!({"none": {"columns": 8}})), + ("grid", serde_json::json!({"from_centre": {"field_mm": 50.0, "columns": 8}})), + ("grid", serde_json::json!({"from_centre": {"field_mm": 50.0}, "columns": 8})), + ("label_sides", serde_json::json!({"fixed": {"top": true, "bottom": true, "left": true, "right": true, "middle": true}})), + ] { + let mut extra = value.clone(); + extra[key] = bad.clone(); + assert!(serde_json::from_value::(extra).is_err(), "{key}: {bad}"); + } +} + +/// For every accepted sheet and profile, the fields tile the drawing border exactly, top to bottom +/// and left to right, every field is longer than nothing and nothing is NaN. +#[test] +fn fields_tile_every_drawing_border_exactly() { + let mut sheets: Vec = + PaperSize::ALL.into_iter().flat_map(|size| [Orientation::Landscape, Orientation::Portrait].map(|o| Sheet::standard(size, o))).collect(); + for sides_mm in [[1.5, 1.5], [5000.0, 5000.0], [5000.0, 1.0], [17.3, 4999.9], [100.0, 101.0], [123.456, 789.012]] { + for orientation in [Orientation::Landscape, Orientation::Portrait] { + sheets.push(Sheet { size: SheetSize::Custom { sides_mm }, orientation }); + } + } + let margins = [ + Margins { left_mm: 20.0, right_mm: 10.0, top_mm: 10.0, bottom_mm: 10.0 }, + Margins { left_mm: 0.0, right_mm: 0.0, top_mm: 0.0, bottom_mm: 0.0 }, + Margins { left_mm: 5.0, right_mm: 5.0, top_mm: 5.0, bottom_mm: 5.0 }, + Margins { left_mm: 33.3, right_mm: 0.1, top_mm: 7.7, bottom_mm: 19.0 }, + ]; + let grids = [ + GridRule::FromCentre { field_mm: 50.0 }, + GridRule::FromCentre { field_mm: 1.0 }, + GridRule::FromCentre { field_mm: 7.5 }, + GridRule::FromCentre { field_mm: 33.3 }, + GridRule::FromCentre { field_mm: 1000.0 }, + GridRule::Equal { columns: 1, rows: 1 }, + GridRule::Equal { columns: 3, rows: 7 }, + GridRule::Equal { columns: 200, rows: 200 }, + ]; + let mut accepted = 0; + for sheet in &sheets { + for (margins, filing_margin) in margins.into_iter().flat_map(|m| [(m, false), (m, true)]) { + for grid in grids { + let profile = DrawingBorderProfile { + margins, + filing_margin, + drawing_border_line_mm: 0.7, + grid, + skip_i_and_o: true, + label_sides: LabelSides::Fixed(Sides::ALL), + grid_border_mm: 0.0, + centring_mark_mm: None, + centring_mark_start: CentringMarkStart::SheetEdge, + trimming_mark_mm: None, + }; + let Ok(layout) = profile.layout(sheet) else { continue }; + accepted += 1; + let (drawing, size_mm) = (layout.drawing_border, layout.sheet_border.max); + assert!(drawing.min.x >= 0.0 && drawing.min.y >= 0.0 && drawing.max.x <= size_mm.x && drawing.max.y <= size_mm.y); + let check = |fields: &[Field], lo: f64, hi: f64, falling: bool| { + let (first, last) = (fields.first().unwrap(), fields.last().unwrap()); + let (start, end) = if falling { (last.from_mm, first.to_mm) } else { (first.from_mm, last.to_mm) }; + assert_eq!((start, end), (lo, hi), "{sheet:?} {profile:?}"); + for f in fields { + assert!(f.from_mm.is_finite() && f.to_mm.is_finite() && f.to_mm - f.from_mm > EPS, "{f:?} {sheet:?} {profile:?}"); + } + for w in fields.windows(2) { + let (a, b) = (&w[0], &w[1]); + if falling { assert_eq!(a.from_mm, b.to_mm) } else { assert_eq!(a.to_mm, b.from_mm) } + } + let total: f64 = fields.iter().map(|f| f.to_mm - f.from_mm).sum(); + assert!((total - (hi - lo)).abs() < 1e-6, "{total} vs {}", hi - lo); + let mut labels: Vec<&str> = fields.iter().map(|f| f.label.as_str()).collect(); + labels.sort_unstable(); + labels.dedup(); + assert_eq!(labels.len(), fields.len()); + }; + check(&layout.columns, drawing.min.x, drawing.max.x, false); + check(&layout.rows, drawing.min.y, drawing.max.y, true); + if let GridRule::FromCentre { field_mm } = grid { + // Inner fields are exactly the field length, and the centre lines are edges. + let centre = size_mm.x / 2.0; + assert!(layout.columns.iter().any(|f| f.to_mm == centre)); + let inner = layout.columns.len().saturating_sub(2); + for f in layout.columns.iter().skip(1).take(inner) { + assert!((f.to_mm - f.from_mm - field_mm).abs() < 1e-9, "{f:?}"); + } + } + } + } + } + assert!(accepted > 1000, "{accepted}"); +} + +#[test] +fn rounding_past_a_whole_number_of_fields_leaves_no_sliver() { + // 420 mm across with the centre line at 210 and the drawing border to 410: the right half is + // four fields of 50. The left margin puts the left half at 150 mm, three fields, give or take + // rounding noise. + let sheet = Sheet { size: SheetSize::Custom { sides_mm: [420.0, 297.0] }, orientation: Orientation::Landscape }; + for hair in [0.0, 1e-12, 1e-10, -1e-10] { + let margins = Margins { left_mm: 60.0 - hair, right_mm: 10.0, top_mm: 10.0, bottom_mm: 10.0 }; + let layout = DrawingBorderProfile { margins, ..DrawingBorderProfile::iso() }.layout(&sheet).unwrap(); + let widths = lengths(&layout.columns); + // The noise joins the corner field: no field of a hair's width before it. + assert_eq!(widths.len(), 7, "{hair}: {widths:?}"); + assert!(close(&widths, &[50.0; 7]), "{hair}: {widths:?}"); + assert_eq!(layout.columns.first().unwrap().from_mm, 60.0 - hair); + } +} diff --git a/crates/geom/src/drawing_view.rs b/crates/geom/src/drawing_view.rs new file mode 100644 index 0000000000..4459c10db3 --- /dev/null +++ b/crates/geom/src/drawing_view.rs @@ -0,0 +1,291 @@ +//! Drawing views as data (W17a, #4816): which way a view looks, its scale, and the map from the +//! model's millimetres to the paper's. Nothing here draws or measures; the view commands (W07) +//! and the hidden-line and section producers feed it. +//! +//! - A view's **basis** is a [`Plane`]: paper x along `x`, paper y along `y`, and its normal +//! `x × y` toward the viewer. [`NamedView`] gives the principal views and the four upper +//! isometric corners exactly, never from a camera, which tilts a top view by its pitch clamp; +//! [`basis_looking`] gives any other view from its direction and up. +//! - A **[`Scale`]** is a ratio of two numbers from 0.000001 to 1000000, written `"1:2"` (paper : +//! model), from 1:100000 to 1000:1. [`Scale::fit`] picks the largest of ISO 5455's preset +//! scales at which a view fits a space. +//! - A **[`ViewFrame`]** places a view on paper: the point of the view's plane at `centre` lands at +//! `position_mm`, everything scaled by the scale. Its [`ViewFrame::paper_map`] moves the view's +//! curves ([`Seg2::similar`]) and their accuracy ([`CurveAccuracy::scaled`]) onto paper. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::paper::MAX_SIDE_MM; +use crate::section::{CurveAccuracy, MAX_NUMBER, validate_plane}; +use crate::{Aabb2, Plane, Seg2, Similarity2, Vec2, Vec3}; + +/// The smallest scale: one paper millimetre for 100 m of model. +pub const MIN_SCALE: f64 = 1e-5; +/// The largest scale: 1000 paper millimetres for one of model. +pub const MAX_SCALE: f64 = 1000.0; +/// The smallest and largest number either side of a scale may be: `"1:100000"` and `"1000:1"` +/// with room to spare, and never more than about 50 characters written out. +pub const MIN_PART: f64 = 1e-6; +pub const MAX_PART: f64 = 1e6; + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("drawing view: {0}")] +pub struct Invalid(pub &'static str); + +type Result = std::result::Result; + +fn require(condition: bool, message: &'static str) -> Result<()> { + if condition { Ok(()) } else { Err(Invalid(message)) } +} + +/// Two directions closer to parallel than this (the sine of their angle) give no view. +const PARALLEL: f64 = 1e-9; + +/// A view with a name: the six principal views and the isometric views from the four upper +/// corners. Each looks at the model from where its name says; `iso` reads as the front-right one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NamedView { + Front, + Back, + Top, + Bottom, + Left, + Right, + #[serde(alias = "iso")] + IsoFrontRight, + IsoFrontLeft, + IsoBackRight, + IsoBackLeft, +} + +impl NamedView { + pub const ALL: [NamedView; 10] = [ + NamedView::Front, + NamedView::Back, + NamedView::Top, + NamedView::Bottom, + NamedView::Left, + NamedView::Right, + NamedView::IsoFrontRight, + NamedView::IsoFrontLeft, + NamedView::IsoBackRight, + NamedView::IsoBackLeft, + ]; + + /// The view's basis, through the world origin. The principal views' axes are exact unit + /// vectors, with no -0 to write out, each the front view folded about the edge it shares with + /// it: the top and bottom views keep paper x along +X (the bottom's y is -Y), and the back and + /// side views keep paper y along +Z. An isometric view looks from its corner, `(±1, ±1, 1)`, + /// toward the origin, with paper x horizontal (along the view direction × Z) and paper y up. + pub fn basis(self) -> Plane { + let (x, y) = match self { + NamedView::Front => (Vec3::X, Vec3::Z), + NamedView::Back => (Vec3::new(-1.0, 0.0, 0.0), Vec3::Z), + NamedView::Top => (Vec3::X, Vec3::Y), + NamedView::Bottom => (Vec3::X, Vec3::new(0.0, -1.0, 0.0)), + NamedView::Left => (Vec3::new(0.0, -1.0, 0.0), Vec3::Z), + NamedView::Right => (Vec3::Y, Vec3::Z), + NamedView::IsoFrontRight => iso(1.0, -1.0), + NamedView::IsoFrontLeft => iso(-1.0, -1.0), + NamedView::IsoBackRight => iso(1.0, 1.0), + NamedView::IsoBackLeft => iso(-1.0, 1.0), + }; + Plane { origin: Vec3::ZERO, x, y } + } +} + +/// The view through `origin` looking along `direction`, with `up` toward the top of the paper: +/// paper x = direction × up, paper y = x × direction, so the normal points back at the viewer as +/// the named views' do. `up` need not be square to the direction, only not along it. +pub fn basis_looking(direction: Vec3, up: Vec3, origin: Vec3) -> Result { + let d = direction.normalized().ok_or(Invalid("the view direction is zero or not finite"))?; + let up = up.normalized().ok_or(Invalid("`up` is zero or not finite"))?; + let across = d.cross(up); + require(across.len() >= PARALLEL, "`up` lies along the view direction: give another up")?; + require( + origin.is_finite() && [origin.x, origin.y, origin.z].iter().all(|v| v.abs() <= MAX_NUMBER), + "a view's origin is finite and within 1e12 mm of the world's", + )?; + // Square to the direction again: near the threshold the cross product's rounding is a large + // share of its length, which would tilt the normal by up to 1e-8 rad. + let across = across - d * across.dot(d); + let x = across * (1.0 / across.len()); + let basis = Plane { origin, x, y: x.cross(d) }; + validate_plane(&basis).map_err(|_| Invalid("a view's basis is finite and orthonormal"))?; + Ok(basis) +} + +/// Paper x and y for an isometric view from the upper corner `(cx, cy, 1)`: x is horizontal, the +/// view direction × Z, and y = x × direction, so the normal points back at the corner. +fn iso(cx: f64, cy: f64) -> (Vec3, Vec3) { + let toward = Vec3::new(-cx, -cy, -1.0) * (1.0 / 3f64.sqrt()); + let x = Vec3::new(-cy, cx, 0.0) * (1.0 / 2f64.sqrt()); + (x, x.cross(toward)) +} + +/// A drawing scale, paper : model, as two positive numbers so `"1:3"` stays exactly what was +/// written. Read and written as that text. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Scale { + paper: f64, + model: f64, +} + +/// ISO 5455's recommended scales, largest first: enlargements, full size, then reductions. +pub const PRESETS: [Scale; 18] = [ + Scale { paper: 50.0, model: 1.0 }, + Scale { paper: 20.0, model: 1.0 }, + Scale { paper: 10.0, model: 1.0 }, + Scale { paper: 5.0, model: 1.0 }, + Scale { paper: 2.0, model: 1.0 }, + Scale { paper: 1.0, model: 1.0 }, + Scale { paper: 1.0, model: 2.0 }, + Scale { paper: 1.0, model: 5.0 }, + Scale { paper: 1.0, model: 10.0 }, + Scale { paper: 1.0, model: 20.0 }, + Scale { paper: 1.0, model: 50.0 }, + Scale { paper: 1.0, model: 100.0 }, + Scale { paper: 1.0, model: 200.0 }, + Scale { paper: 1.0, model: 500.0 }, + Scale { paper: 1.0, model: 1000.0 }, + Scale { paper: 1.0, model: 2000.0 }, + Scale { paper: 1.0, model: 5000.0 }, + Scale { paper: 1.0, model: 10000.0 }, +]; + +impl Scale { + /// `paper : model`, each from [`MIN_PART`] to [`MAX_PART`] (so the text stays short), their + /// ratio from [`MIN_SCALE`] to [`MAX_SCALE`]. + pub fn new(paper: f64, model: f64) -> Result { + let parts = [paper, model].into_iter().all(|v| (MIN_PART..=MAX_PART).contains(&v)); + let message = "a scale is two numbers from 0.000001 to 1000000, from 1:100000 to 1000:1"; + require(parts && (MIN_SCALE..=MAX_SCALE).contains(&(paper / model)), message)?; + Ok(Scale { paper, model }) + } + + /// Paper millimetres per model millimetre. + pub fn factor(self) -> f64 { + self.paper / self.model + } + + /// The largest preset at which a view `size_mm` wide and high (in model millimetres) fits + /// within `room_mm` on paper, if any does. Compared as `size * paper <= room * model`, so an + /// exact fit at 1:5 isn't lost to 0.2's rounding. A size of 0 sets no limit that way (a view of + /// a line along the other), so a view of a point fits at the largest preset; a size or room + /// that is negative or not finite fits none. + pub fn fit(size_mm: Vec2, room_mm: Vec2) -> Option { + if ![size_mm, room_mm].into_iter().all(|v| v.is_finite() && v.x >= 0.0 && v.y >= 0.0) { + return None; + } + PRESETS.into_iter().find(|s| size_mm.x * s.paper <= room_mm.x * s.model && size_mm.y * s.paper <= room_mm.y * s.model) + } +} + +impl fmt::Display for Scale { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.paper, self.model) + } +} + +/// Plain decimal numbers only (`2`, `2.5`): digits, then at most one point with digits after it; +/// no sign, exponent or space. +fn decimal(text: &str) -> Option { + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + let plain = match text.split_once('.') { + Some((whole, fraction)) => digits(whole) && digits(fraction), + None => digits(text), + }; + plain.then(|| text.parse().ok()).flatten() +} + +impl FromStr for Scale { + type Err = Invalid; + + fn from_str(text: &str) -> Result { + let parts = text.split_once(':').and_then(|(p, m)| Some((decimal(p)?, decimal(m)?))); + let (paper, model) = parts.ok_or(Invalid("a scale is written paper:model, such as 1:2"))?; + Scale::new(paper, model) + } +} + +impl Serialize for Scale { + fn serialize(&self, s: S) -> std::result::Result { + s.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Scale { + fn deserialize>(d: D) -> std::result::Result { + String::deserialize(d)?.parse().map_err(serde::de::Error::custom) + } +} + +/// A view placed on paper: its basis, its scale, and where it sits. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewFrame { + basis: Plane, + scale: Scale, + map: Similarity2, +} + +impl ViewFrame { + /// The view through `basis` at `scale`, with the point `centre` of the view's plane (in its + /// own coordinates, [`Plane::to_local`]) at `position_mm` on paper. + pub fn new(basis: Plane, scale: Scale, centre: Vec2, position_mm: Vec2) -> Result { + validate_plane(&basis).map_err(|_| Invalid("a view's basis is finite and orthonormal"))?; + require(centre.is_finite() && centre.x.abs() <= MAX_NUMBER && centre.y.abs() <= MAX_NUMBER, "a view's centre is finite")?; + let on_paper = position_mm.is_finite() && position_mm.x.abs() <= MAX_SIDE_MM && position_mm.y.abs() <= MAX_SIDE_MM; + require(on_paper, "a view's position is finite and at most 5000 mm from the sheet's origin")?; + let factor = scale.factor(); + let map = Similarity2::new(factor, 0.0, position_mm - centre * factor).ok_or(Invalid("a view's position is finite"))?; + Ok(ViewFrame { basis, scale, map }) + } + + pub fn basis(&self) -> Plane { + self.basis + } + + pub fn scale(&self) -> Scale { + self.scale + } + + /// The map from the view's plane coordinates to paper millimetres, for its curves. Plane + /// coordinates are the plane's own ([`Plane::to_local`]: from the basis's origin along its x + /// and y), as section curves are. Points measured along x and y from the world origin (as the + /// hidden-line output is planned to be) first lose the origin's own `(origin · x, origin · y)`; + /// a named view's origin is the world origin, so for it the two are the same. + pub fn paper_map(&self) -> Similarity2 { + self.map + } + + /// Where a model point appears on paper. + pub fn to_paper(&self, p: Vec3) -> Vec2 { + self.map.apply(self.basis.to_local(p)) + } + + /// A box of the view's plane (in plane coordinates, as [`Self::paper_map`]'s) on paper: the box + /// around its four corners, mapped. A box that is inside out, or not finite on paper, has none. + pub fn boundary(&self, model: &Aabb2) -> Result { + let corners = [model.min, Vec2::new(model.max.x, model.min.y), model.max, Vec2::new(model.min.x, model.max.y)].map(|c| self.map.apply(c)); + require(!model.is_empty() && corners.iter().all(|c| c.is_finite()), "a view's box is not inside out and is finite on paper")?; + Ok(corners.into_iter().collect()) + } + + /// A curve of the view's plane (in plane coordinates, as [`Self::paper_map`]'s), on paper. + pub fn seg_to_paper(&self, seg: &Seg2) -> Seg2 { + seg.similar(&self.map) + } + + /// A curve's accuracy on paper: its deviation scaled as the curve is. + pub fn accuracy_on_paper(&self, accuracy: CurveAccuracy) -> CurveAccuracy { + accuracy.scaled(self.map.scale()) + } +} + +#[cfg(test)] +#[path = "drawing_view_tests.rs"] +mod tests; diff --git a/crates/geom/src/drawing_view_tests.rs b/crates/geom/src/drawing_view_tests.rs new file mode 100644 index 0000000000..75228a89b7 --- /dev/null +++ b/crates/geom/src/drawing_view_tests.rs @@ -0,0 +1,273 @@ +use super::*; +use crate::section::validate_plane; + +const EPS: f64 = 1e-12; + +fn close(a: Vec3, b: Vec3) -> bool { + (a - b).len() < EPS +} + +/// A 50 mm cube's corners, from the origin. +fn cube() -> Vec { + let s = 50.0; + (0..8).map(|i| Vec3::new(f64::from(i & 1) * s, f64::from((i >> 1) & 1) * s, f64::from((i >> 2) & 1) * s)).collect() +} + +/// The paper extents (width, height) of points seen through a frame. +fn extents(frame: &ViewFrame, points: &[Vec3]) -> Vec2 { + let paper: Vec = points.iter().map(|p| frame.to_paper(*p)).collect(); + let span = |f: fn(&Vec2) -> f64| { + let values = paper.iter().map(f); + values.clone().fold(f64::NEG_INFINITY, f64::max) - values.fold(f64::INFINITY, f64::min) + }; + Vec2::new(span(|p| p.x), span(|p| p.y)) +} + +fn full_size(view: NamedView) -> ViewFrame { + ViewFrame::new(view.basis(), Scale::new(1.0, 1.0).unwrap(), Vec2::ZERO, Vec2::ZERO).unwrap() +} + +/// Every named view is an exact, right-handed, orthonormal basis whose normal points back at the +/// viewer: the principal views from where their names say, the isometric views from their +/// upper corners with paper x horizontal. +#[test] +fn each_named_view_looks_from_where_its_name_says() { + let viewer = [ + (NamedView::Front, Vec3::new(0.0, -1.0, 0.0)), + (NamedView::Back, Vec3::Y), + (NamedView::Top, Vec3::Z), + (NamedView::Bottom, Vec3::new(0.0, 0.0, -1.0)), + (NamedView::Left, Vec3::new(-1.0, 0.0, 0.0)), + (NamedView::Right, Vec3::X), + (NamedView::IsoFrontRight, Vec3::new(1.0, -1.0, 1.0) * (1.0 / 3f64.sqrt())), + (NamedView::IsoFrontLeft, Vec3::new(-1.0, -1.0, 1.0) * (1.0 / 3f64.sqrt())), + (NamedView::IsoBackRight, Vec3::new(1.0, 1.0, 1.0) * (1.0 / 3f64.sqrt())), + (NamedView::IsoBackLeft, Vec3::new(-1.0, 1.0, 1.0) * (1.0 / 3f64.sqrt())), + ]; + assert_eq!(viewer.len(), NamedView::ALL.len()); + for (view, toward_viewer) in viewer { + let b = view.basis(); + validate_plane(&b).unwrap(); + assert!(close(b.normal(), toward_viewer), "{view:?}: normal {:?}", b.normal()); + assert_eq!(b.origin, Vec3::ZERO); + assert!(b.y.z >= 0.0, "{view:?}: paper y never points down"); + if matches!(view, NamedView::IsoFrontRight | NamedView::IsoFrontLeft | NamedView::IsoBackRight | NamedView::IsoBackLeft) { + assert!(b.x.z.abs() < EPS, "{view:?}: paper x is horizontal"); + } + } + // The principal views' axes are exact, no rounding from a camera, and each lies on its paper + // as the front view folded about the edge they share: the top and bottom views keep x along + // +X, the back and side views keep y along +Z. + let minus = |v: Vec3| Vec3::ZERO - v; + let principal = [ + (NamedView::Front, Vec3::X, Vec3::Z), + (NamedView::Back, minus(Vec3::X), Vec3::Z), + (NamedView::Top, Vec3::X, Vec3::Y), + (NamedView::Bottom, Vec3::X, minus(Vec3::Y)), + (NamedView::Left, minus(Vec3::Y), Vec3::Z), + (NamedView::Right, Vec3::Y, Vec3::Z), + ]; + for (view, x, y) in principal { + assert_eq!((view.basis().x, view.basis().y), (x, y), "{view:?}"); + } + // The sketch plane XZ is not the front view: its y is -Z, a front view's is +Z. + assert_eq!(Plane::XZ.y, Vec3::new(0.0, 0.0, -1.0)); + assert_eq!(NamedView::Front.basis().y, Vec3::Z); + // No axis has a -0, so a view writes out as `[-1.0, 0.0, 0.0]`, never `[-1.0, -0.0, -0.0]`. + let negative_zero = |v: Vec3| [v.x, v.y, v.z].iter().any(|c| *c == 0.0 && c.is_sign_negative()); + assert!(NamedView::ALL.iter().all(|v| !negative_zero(v.basis().x) && !negative_zero(v.basis().y))); +} + +/// A view from a direction and an up gives the named views' bases (the principal ones exactly), +/// keeps paper y in the plane of the up, and refuses a direction or up it can't make a view from. +#[test] +fn a_view_from_a_direction_and_up_matches_the_named_views() { + for view in NamedView::ALL { + let named = view.basis(); + // Looking down, up is +Y (the back at the top); looking up, -Y (the front at the top). + let up = match view { + NamedView::Top => Vec3::Y, + NamedView::Bottom => Vec3::new(0.0, -1.0, 0.0), + _ => Vec3::Z, + }; + let built = basis_looking(-named.normal(), up, Vec3::ZERO).unwrap(); + assert!(close(built.x, named.x) && close(built.y, named.y), "{view:?}: {built:?}"); + } + assert_eq!(basis_looking(Vec3::Y, Vec3::Z, Vec3::ZERO).unwrap(), NamedView::Front.basis(), "exact"); + let leaning = basis_looking(Vec3::Y, Vec3::new(0.0, 0.1, 1.0), Vec3::new(1.0, 2.0, 3.0)).unwrap(); + assert_eq!((leaning.x, leaning.y, leaning.origin), (Vec3::X, Vec3::Z, Vec3::new(1.0, 2.0, 3.0))); + let refused = [ + (Vec3::ZERO, Vec3::Z, Vec3::ZERO), + (Vec3::new(f64::NAN, 1.0, 0.0), Vec3::Z, Vec3::ZERO), + (Vec3::Y, Vec3::ZERO, Vec3::ZERO), + (Vec3::Y, Vec3::new(0.0, f64::INFINITY, 0.0), Vec3::ZERO), + (Vec3::new(0.0, 0.0, -1.0), Vec3::Z, Vec3::ZERO), + (Vec3::Z, Vec3::new(1e-12, 0.0, 1.0), Vec3::ZERO), + (Vec3::Y, Vec3::Z, Vec3::new(f64::NAN, 0.0, 0.0)), + (Vec3::Y, Vec3::Z, Vec3::new(0.0, 1e13, 0.0)), + ]; + for (direction, up, origin) in refused { + assert!(basis_looking(direction, up, origin).is_err(), "{direction:?} {up:?} {origin:?}"); + } + // A bad origin is named as such, not as a bad basis. + for origin in [Vec3::new(f64::NAN, 0.0, 0.0), Vec3::new(0.0, 1e13, 0.0)] { + assert_eq!(basis_looking(Vec3::Y, Vec3::Z, origin), Err(Invalid("a view's origin is finite and within 1e12 mm of the world's"))); + } + assert_eq!(serde_json::from_value::(serde_json::json!("iso")).unwrap(), NamedView::IsoFrontRight); + assert_eq!(serde_json::to_value(NamedView::IsoFrontRight).unwrap(), serde_json::json!("iso_front_right")); +} + +/// An up just off the view direction still gives a square basis: its normal is the direction back +/// at the viewer to rounding, where the cross product alone would tilt it by up to 9e-9 rad. +#[test] +fn an_up_just_off_the_direction_still_looks_along_it() { + for (direction, off) in [(Vec3::new(0.3, -0.7, 0.2), 1.0001e-9), (Vec3::new(1.0, 2.0, 3.0), 1.2e-9), (Vec3::Y, 1.0001e-9)] { + let d = direction.normalized().unwrap(); + let aside = d.cross(Vec3::Z).normalized().unwrap(); + let basis = basis_looking(direction, d + aside * off, Vec3::ZERO).unwrap(); + let tilt = (basis.normal() + d).len(); + assert!(tilt < 1e-14 && basis.x.dot(d).abs() < 1e-15, "{direction:?}: tilted {tilt:e}"); + } +} + +/// A 50 mm cube is a 50 mm square in every principal view at 1:1, and half that at 1:2. In an +/// orthographic isometric view each edge reads 50·√(2/3), and the outline is 50·√2 wide. +#[test] +fn a_50_mm_cube_measures_as_the_views_say() { + for view in [NamedView::Front, NamedView::Back, NamedView::Top, NamedView::Bottom, NamedView::Left, NamedView::Right] { + let size = extents(&full_size(view), &cube()); + assert!((size - Vec2::new(50.0, 50.0)).len() < EPS, "{view:?}: {size:?}"); + let half = ViewFrame::new(view.basis(), "1:2".parse().unwrap(), Vec2::ZERO, Vec2::ZERO).unwrap(); + assert!((extents(&half, &cube()) - Vec2::new(25.0, 25.0)).len() < EPS, "{view:?} at 1:2"); + } + // Orthographic isometric: a vertical edge reads 50·√(2/3), and the outline is 50·√2 wide. + let iso = full_size(NamedView::IsoFrontRight); + let edge = iso.to_paper(Vec3::new(0.0, 0.0, 50.0)) - iso.to_paper(Vec3::ZERO); + assert!((edge.len() - 50.0 * (2.0f64 / 3.0).sqrt()).abs() < EPS, "{edge:?}"); + assert!(edge.x.abs() < EPS, "a vertical edge stays vertical on paper"); + let size = extents(&iso, &cube()); + assert!((size.x - 50.0 * 2f64.sqrt()).abs() < EPS, "{size:?}"); +} + +/// The view's centre lands on its position, and the map scales about it. +#[test] +fn the_views_centre_lands_on_its_position() { + let front = NamedView::Front.basis(); + let frame = ViewFrame::new(front, "2:1".parse().unwrap(), Vec2::new(50.0, 10.0), Vec2::new(210.0, 148.5)).unwrap(); + assert!((frame.to_paper(Vec3::new(50.0, 0.0, 10.0)) - Vec2::new(210.0, 148.5)).len() < EPS); + assert!((frame.to_paper(Vec3::new(0.0, 7.0, 0.0)) - Vec2::new(110.0, 128.5)).len() < EPS, "depth doesn't move a point"); + // W07's example: a 100 x 20 front view on A3 at 2:1, centred: boundary 110..310 x 128.5..168.5. + assert!((frame.to_paper(Vec3::new(100.0, 0.0, 20.0)) - Vec2::new(310.0, 168.5)).len() < EPS); + let boundary = frame.boundary(&Aabb2 { min: Vec2::ZERO, max: Vec2::new(100.0, 20.0) }).unwrap(); + assert!(boundary.min.dist(Vec2::new(110.0, 128.5)) < EPS && boundary.max.dist(Vec2::new(310.0, 168.5)) < EPS, "{boundary:?}"); + // A box that is inside out, or not finite on paper, has no boundary: never one that drops a + // corner. 1e308 is finite in the plane, but not at 2:1. + let box_of = |x0: f64, y0: f64, x1: f64, y1: f64| Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }; + let refused = [ + Aabb2::EMPTY, + box_of(10.0, 0.0, 0.0, 10.0), + box_of(f64::NAN, 0.0, 10.0, 10.0), + box_of(0.0, 0.0, f64::INFINITY, 10.0), + box_of(0.0, 0.0, 1e308, 10.0), + ]; + for model in refused { + assert_eq!(frame.boundary(&model), Err(Invalid("a view's box is not inside out and is finite on paper")), "{model:?}"); + } + assert!(frame.boundary(&box_of(5.0, 5.0, 5.0, 5.0)).is_ok_and(|b| b.min == b.max), "a point's box is a point"); + let line = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(100.0, 20.0) }; + assert_eq!(frame.seg_to_paper(&line), Seg2::Line { a: Vec2::new(110.0, 128.5), b: Vec2::new(310.0, 168.5) }); + let bounded = CurveAccuracy::Bounded { max_deviation_mm: 0.01 }; + assert_eq!(frame.accuracy_on_paper(bounded), CurveAccuracy::Bounded { max_deviation_mm: 0.02 }); + assert_eq!(frame.accuracy_on_paper(CurveAccuracy::Analytic), CurveAccuracy::Analytic); +} + +/// A scale is read and written as paper:model, kept as written, within 1:100000 to 1000:1. +#[test] +fn a_scale_reads_and_writes_as_paper_to_model() { + for (text, factor) in [("1:1", 1.0), ("1:2", 0.5), ("2:1", 2.0), ("1:2.5", 0.4), ("1:100000", 1e-5), ("1000:1", 1000.0), ("3:7", 3.0 / 7.0)] { + let s: Scale = text.parse().unwrap(); + assert_eq!((s.to_string(), s.factor()), (text.to_string(), factor), "{text}"); + assert_eq!(serde_json::to_value(s).unwrap(), serde_json::json!(text)); + assert_eq!(serde_json::from_value::(serde_json::json!(text)).unwrap(), s); + } + let not_scales = [ + "", "1", "1:", ":2", "1:2:3", "0:1", "1:0", "-1:2", "1:-2", "1:100001", "1001:1", "1/2", " 1:2", "1:2 ", "1e3:1", "NaN:1", "inf:1", "1..5:1", + "1:2.", "١:٢", + ]; + for text in not_scales { + assert!(text.parse::().is_err(), "{text:?}"); + } + assert!(serde_json::from_value::(serde_json::json!(0.5)).is_err(), "a bare number is not a scale"); + for (paper, model) in [(0.0, 1.0), (1.0, 0.0), (f64::NAN, 1.0), (1.0, f64::INFINITY), (1.0, 1e300), (1e-300, 1.0)] { + assert!(Scale::new(paper, model).is_err(), "{paper}:{model}"); + } + // Each side is capped too, or 1e300:1e300 (1:1) would write 603 characters. + for (paper, model) in [(1e300, 1e300), (1e-300, 1e-300), (1.000001e6, 1.000001e6), (0.9e-6, 0.9e-6)] { + assert!(Scale::new(paper, model).is_err(), "{paper}:{model}"); + } + assert!("1000001:1000001".parse::().is_err() && "0.0000009:0.0000009".parse::().is_err()); + // The longest a scale writes: its widest sides, about 50 characters. + for (paper, model) in [(1e6, 1e6), (MIN_PART, MIN_PART), (1.2345678901234567e-6, 1.2345678901234567e-6), (999999.9999999999, 999999.9999999999)] { + let text = Scale::new(paper, model).unwrap().to_string(); + assert!(text.len() <= 50, "{text}"); + assert_eq!(text.parse::().unwrap(), Scale::new(paper, model).unwrap(), "{text}"); + } +} + +/// The fitted scale is the largest ISO 5455 preset at which the view fits: W07's 100 x 20 front +/// view fits an A3 drawing area at 2:1 (5:1 would be 500 mm wide), exactly fitting counts, and a +/// view too large for every preset gets none. +#[test] +fn the_fitted_scale_is_the_largest_preset_that_fits() { + let a3 = Vec2::new(400.0, 277.0); + assert_eq!(Scale::fit(Vec2::new(100.0, 20.0), a3).map(|s| s.to_string()), Some("2:1".into())); + assert_eq!(Scale::fit(Vec2::new(400.0, 10.0), a3).map(|s| s.to_string()), Some("1:1".into()), "an exact fit counts"); + assert_eq!(Scale::fit(Vec2::new(400.1, 10.0), a3).map(|s| s.to_string()), Some("1:2".into())); + assert_eq!(Scale::fit(Vec2::new(1.0, 1.0), a3).map(|s| s.to_string()), Some("50:1".into())); + assert_eq!(Scale::fit(Vec2::new(4e6, 1.0), a3).map(|s| s.to_string()), Some("1:10000".into()), "ISO 5455's smallest"); + assert_eq!(Scale::fit(Vec2::new(5e6, 1.0), a3), None); + // An exact fit at a reduction counts though its factor rounds: 1675.75 * 0.2 is 335.15000000000003. + let room = Vec2::new(335.15, 1000.0); + assert_eq!(Scale::fit(Vec2::new(1675.75, 1.0), room).map(|s| s.to_string()), Some("1:5".into())); + // The presets run largest first, each a 1, 2 or 5 step. + assert!(PRESETS.windows(2).all(|w| w[0].factor() > w[1].factor())); + // A size of 0 sets no limit that way: a line fits by its length, a point at the largest preset. + assert_eq!(Scale::fit(Vec2::new(0.0, 20.0), a3).map(|s| s.to_string()), Some("10:1".into())); + assert_eq!(Scale::fit(Vec2::ZERO, a3).map(|s| s.to_string()), Some("50:1".into())); + // A size or room that is negative or not finite fits nothing. + for (size, room) in [ + (Vec2::new(-1.0, 1.0), a3), + (Vec2::new(1.0, -0.0001), a3), + (Vec2::new(f64::NAN, 1.0), a3), + (Vec2::new(1.0, f64::INFINITY), a3), + (Vec2::new(1.0, 1.0), Vec2::new(-400.0, 277.0)), + (Vec2::new(1.0, 1.0), Vec2::new(f64::INFINITY, 277.0)), + (Vec2::new(1.0, 1.0), Vec2::new(400.0, f64::NAN)), + ] { + assert_eq!(Scale::fit(size, room), None, "{size:?} in {room:?}"); + } +} + +/// What a frame refuses: a basis that isn't orthonormal or finite, a centre that isn't finite, and +/// a position past the paper's limit. +#[test] +fn a_frame_refuses_hostile_input() { + let one = Scale::new(1.0, 1.0).unwrap(); + let front = NamedView::Front.basis(); + let bases = [ + Plane { x: Vec3::new(2.0, 0.0, 0.0), ..front }, + Plane { x: Vec3::Z, ..front }, + Plane { origin: Vec3::new(f64::NAN, 0.0, 0.0), ..front }, + Plane { y: Vec3::new(0.0, 0.0, f64::INFINITY), ..front }, + ]; + for basis in bases { + assert!(ViewFrame::new(basis, one, Vec2::ZERO, Vec2::ZERO).is_err(), "{basis:?}"); + } + for centre in [Vec2::new(f64::NAN, 0.0), Vec2::new(0.0, f64::INFINITY), Vec2::new(1e13, 0.0)] { + assert!(ViewFrame::new(front, one, centre, Vec2::ZERO).is_err(), "{centre:?}"); + } + for position in [Vec2::new(5000.001, 0.0), Vec2::new(0.0, -5000.001), Vec2::new(f64::NAN, 0.0)] { + assert!(ViewFrame::new(front, one, Vec2::ZERO, position).is_err(), "{position:?}"); + } + assert!(ViewFrame::new(front, one, Vec2::ZERO, Vec2::new(5000.0, -5000.0)).is_ok()); +} diff --git a/crates/geom/src/hlr/curve.rs b/crates/geom/src/hlr/curve.rs new file mode 100644 index 0000000000..2da8e2e8fd --- /dev/null +++ b/crates/geom/src/hlr/curve.rs @@ -0,0 +1,371 @@ +//! Evaluation of portable B-rep curves: lines, circle and ellipse arcs, polynomial and rational +//! splines (fitted intersection records are ordinary splines with a stated fitting tolerance). + +use super::HlrError; +use crate::Vec3; +use crate::brep::{Curve, Frame}; +use crate::section::CurveAccuracy; + +#[derive(Clone, Debug)] +pub(crate) enum C3 { + Line { + a: Vec3, + b: Vec3, + }, + /// origin + x*rx*cos(t) + y*ry*sin(t), t in `range`. + Arc { + o: Vec3, + x: Vec3, + y: Vec3, + r: [f64; 2], + range: [f64; 2], + }, + /// Homogeneous control points `[x*w, y*w, z*w, w]` (w = 1 for polynomial splines), and the + /// same curve as Bézier pieces (each piece's parameter span and control points; empty when + /// it could not be split). + Spline { + degree: usize, + knots: Vec, + points: Vec<[f64; 4]>, + pieces: Vec<([f64; 2], Vec<[f64; 4]>)>, + }, + Polyline(Vec), +} + +pub(crate) fn v3(p: [f64; 3]) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +pub(crate) fn frame_axes(f: &Frame) -> (Vec3, Vec3, Vec3) { + let (x, y) = (v3(f.x_axis), v3(f.y_axis)); + (v3(f.origin), x, y) +} + +impl C3 { + pub fn new(c: &Curve) -> Result<(C3, CurveAccuracy), HlrError> { + Ok(match c { + Curve::Line { start, end } => (C3::Line { a: v3(*start), b: v3(*end) }, CurveAccuracy::Analytic), + Curve::CircleArc { frame, radius, angle } => { + let (o, x, y) = frame_axes(frame); + (C3::Arc { o, x, y, r: [*radius, *radius], range: *angle }, CurveAccuracy::Analytic) + } + Curve::EllipseArc { frame, radii, angle } => { + let (o, x, y) = frame_axes(frame); + (C3::Arc { o, x, y, r: *radii, range: *angle }, CurveAccuracy::Analytic) + } + Curve::Polyline { points } => (C3::Polyline(points.iter().copied().map(v3).collect()), CurveAccuracy::Analytic), + Curve::Spline { knots, points } => (spline(knots, points.iter().map(|p| [p[0], p[1], p[2], 1.]).collect())?, CurveAccuracy::Analytic), + Curve::RationalSpline { knots, homogeneous } => (spline(knots, homogeneous.clone())?, CurveAccuracy::Analytic), + Curve::Intersection { knots, points, fitting_tolerance, .. } => ( + spline(knots, points.iter().map(|p| [p[0], p[1], p[2], 1.]).collect())?, + CurveAccuracy::Bounded { max_deviation_mm: *fitting_tolerance }, + ), + // Procedural: the curve is traced on demand from its leader, which carries no bound. + Curve::SurfaceIntersection { .. } => return Err(HlrError::Unsupported("surface intersection curve")), + }) + } + + pub fn domain(&self) -> [f64; 2] { + match self { + C3::Line { .. } => [0., 1.], + C3::Arc { range, .. } => *range, + C3::Spline { degree, knots, points, .. } => [knots.get(*degree).copied().unwrap_or(0.), knots.get(points.len()).copied().unwrap_or(1.)], + C3::Polyline(p) => [0., p.len().saturating_sub(1) as f64], + } + } + + pub fn at(&self, t: f64) -> Vec3 { + match self { + C3::Line { a, b } => *a + (*b - *a) * t, + C3::Arc { o, x, y, r, .. } => *o + *x * (r[0] * t.cos()) + *y * (r[1] * t.sin()), + C3::Spline { degree, knots, points, .. } => de_boor(*degree, knots, points, t), + C3::Polyline(p) => { + let n = p.len(); + if n == 0 { + return Vec3::ZERO; + } + let t = t.clamp(0., (n - 1) as f64); + let i = (t.floor() as usize).min(n.saturating_sub(2)); + match (p.get(i), p.get(i + 1)) { + (Some(a), Some(b)) => a.lerp(*b, t - i as f64), + (Some(a), None) => *a, + _ => Vec3::ZERO, + } + } + } + } + + /// First derivative by a central difference scaled to the domain. + pub fn d1(&self, t: f64) -> Vec3 { + match self { + C3::Line { a, b } => *b - *a, + C3::Arc { x, y, r, .. } => *x * (-r[0] * t.sin()) + *y * (r[1] * t.cos()), + _ => { + let [t0, t1] = self.domain(); + let h = (t1 - t0) * 1e-6; + let (a, b) = ((t - h).max(t0), (t + h).min(t1)); + if b - a <= 0. { Vec3::ZERO } else { (self.at(b) - self.at(a)) / (b - a) } + } + } + } + + /// Parameters that split the curve into pieces each turning at most `max_turn` radians or + /// spanning at most a quarter of the domain, plus all interior knots. + pub fn breaks(&self) -> Vec { + let [t0, t1] = self.domain(); + let mut out = vec![t0, t1]; + match self { + C3::Spline { knots, .. } => out.extend(knots.iter().copied().filter(|k| *k > t0 && *k < t1)), + C3::Polyline(p) => out.extend((1..p.len().saturating_sub(1)).map(|i| i as f64)), + C3::Arc { .. } => { + let n = ((t1 - t0) / (std::f64::consts::PI / 8.)).ceil().max(1.) as usize; + out.extend((1..n).map(|i| t0 + (t1 - t0) * i as f64 / n as f64)); + } + C3::Line { .. } => {} + } + out.sort_by(f64::total_cmp); + // Only equal knots merge: a span narrower than rounding can still carry a whole arc. + out.dedup(); + out + } +} + +fn spline(knots: &[f64], points: Vec<[f64; 4]>) -> Result { + let degree = knots.len().checked_sub(points.len() + 1).ok_or(HlrError::Unsupported("spline knot count"))?; + if degree == 0 || degree > 16 || points.is_empty() { + return Err(HlrError::Unsupported("spline degree")); + } + let pieces = bezier_pieces(degree, knots, &points); + Ok(C3::Spline { degree, knots: knots.to_vec(), points, pieces }) +} + +/// A spline split into Bézier pieces: clamped, then every inner knot raised to the degree +/// (NURBS Book §5.2); empty when that fails. +fn bezier_pieces(p: usize, knots: &[f64], points: &[[f64; 4]]) -> Vec<([f64; 2], Vec<[f64; 4]>)> { + use crate::bspline::{HPoint, clamp_row, insert_knot}; + let row: Vec = points.iter().map(|h| HPoint { x: h[0], y: h[1], z: h[2], w: h[3] }).collect(); + let Ok((mut k, mut c)) = clamp_row(p, knots, &row) else { return Vec::new() }; + let (Some(&lo), Some(&hi)) = (k.get(p), k.get(c.len())) else { return Vec::new() }; + let mut inner: Vec = k.iter().copied().filter(|x| *x > lo && *x < hi).collect(); + inner.dedup(); + for u in &inner { + let mut guard = 0; + while k.iter().filter(|x| *x == u).count() < p { + guard += 1; + let Ok((nk, nc)) = insert_knot(p, &k, &c, *u) else { return Vec::new() }; + if guard > p + 1 { + return Vec::new(); + } + (k, c) = (nk, nc); + } + } + let breaks: Vec = std::iter::once(lo).chain(inner).chain(std::iter::once(hi)).collect(); + let mut out = Vec::with_capacity(breaks.len()); + for (j, w) in breaks.windows(2).enumerate() { + let (Some(&s0), Some(&s1)) = (w.first(), w.get(1)) else { return Vec::new() }; + let Some(ctrl) = c.get(j * p..=j * p + p) else { return Vec::new() }; + out.push(([s0, s1], ctrl.iter().map(|h| [h.x, h.y, h.z, h.w]).collect())); + } + out +} + +/// The Bernstein coefficients of the product of two polynomials given by theirs. +fn bernstein_product(a: &[f64], b: &[f64]) -> Vec { + use crate::bspline::binomial; + let (m, n) = (a.len().saturating_sub(1), b.len().saturating_sub(1)); + (0..=m + n) + .map(|k| { + let terms = (k.saturating_sub(n)..=k.min(m)) + .map(|i| binomial(m, i) * binomial(n, k - i) * a.get(i).copied().unwrap_or(0.) * b.get(k - i).copied().unwrap_or(0.)); + terms.sum::() / binomial(m + n, k) + }) + .collect() +} + +/// The sign a polynomial in Bernstein form keeps over its interval: +1, -1, 0 when it is zero +/// (to rounding) all along, `None` when that cannot be certified. All coefficients of one sign +/// mean the polynomial has it (their hull holds its graph); otherwise the interval is halved. +fn definite_sign(c: &[f64], depth: u32) -> Option { + let scale = c.iter().fold(0f64, |m, x| m.max(x.abs())); + let eps = 1e-12 * scale; + if c.iter().all(|x| x.abs() <= eps) { + return Some(0.); + } + if c.iter().all(|x| *x >= -eps) { + return Some(1.); + } + if c.iter().all(|x| *x <= eps) { + return Some(-1.); + } + if depth >= 16 { + return None; + } + // de Casteljau at the middle. + let (mut left, mut right, mut pts) = (Vec::with_capacity(c.len()), Vec::with_capacity(c.len()), c.to_vec()); + while let (Some(&f), Some(&l)) = (pts.first(), pts.last()) { + left.push(f); + right.push(l); + pts = pts.windows(2).map(|w| 0.5 * (w.first().copied().unwrap_or(0.) + w.get(1).copied().unwrap_or(0.))).collect(); + } + right.reverse(); + match (definite_sign(&left, depth + 1)?, definite_sign(&right, depth + 1)?) { + (a, b) if a == b || b == 0. => Some(a), + (0., b) => Some(b), + _ => None, + } +} + +impl C3 { + /// The way a spline's part `r` turns about the centre `o` in the plane of `u` and `v`: + /// +1 or -1 when its angular speed keeps that sign all along (certified on each Bézier piece + /// from the Bernstein coefficients of `X Y' - Y X'`, which has the speed's sign as the + /// weights are positive), 0 when it does not turn, `None` when it turns back or this cannot + /// be certified. + pub fn turn_sign(&self, r: [f64; 2], o: Vec3, u: Vec3, v: Vec3) -> Option { + use crate::bspline::{HPoint, bezier_segment}; + let C3::Spline { pieces, .. } = self else { return None }; + let mut sign = 0f64; + for (span, ctrl) in pieces { + let (a, b) = (r[0].max(span[0]), r[1].min(span[1])); + let len = span[1] - span[0]; + if !(b > a && len > 0.) { + continue; + } + let (fa, fb) = (((a - span[0]) / len).clamp(0., 1.), ((b - span[0]) / len).clamp(0., 1.)); + if !(fb > fa) { + continue; + } + let h: Vec = ctrl.iter().map(|q| HPoint { x: q[0], y: q[1], z: q[2], w: q[3] }).collect(); + let seg = bezier_segment(&h, fa, fb).ok()?; + if seg.iter().any(|q| !(q.w > 0.)) { + return None; + } + let rel = |q: &HPoint| Vec3::new(q.x, q.y, q.z) - o * q.w; + let x: Vec = seg.iter().map(|q| rel(q).dot(u)).collect(); + let y: Vec = seg.iter().map(|q| rel(q).dot(v)).collect(); + let p = x.len().saturating_sub(1); + if p == 0 { + return None; + } + let d = |c: &[f64]| { + c.windows(2).map(|w| p as f64 * (w.get(1).copied().unwrap_or(0.) - w.first().copied().unwrap_or(0.))).collect::>() + }; + let n: Vec = bernstein_product(&x, &d(&y)).iter().zip(bernstein_product(&y, &d(&x))).map(|(a, b)| a - b).collect(); + let s = definite_sign(&n, 0)?; + if s != 0. { + if sign != 0. && s != sign { + return None; + } + sign = s; + } + } + Some(sign) + } + + /// Points whose hull holds the spline's part `r`: the projected control points of its + /// Bézier pieces cut to `r` (de Casteljau). They close in on the curve as `r` shrinks. + /// `None` for other curves, or when a weight is not positive. + pub fn hull(&self, r: [f64; 2]) -> Option> { + use crate::bspline::{HPoint, bezier_segment}; + let C3::Spline { pieces, .. } = self else { return None }; + let mut out = Vec::new(); + for (span, ctrl) in pieces { + let (a, b) = (r[0].max(span[0]), r[1].min(span[1])); + let len = span[1] - span[0]; + if !(b > a && len > 0.) { + continue; + } + let (fa, fb) = (((a - span[0]) / len).clamp(0., 1.), ((b - span[0]) / len).clamp(0., 1.)); + if !(fb > fa) { + continue; + } + let h: Vec = ctrl.iter().map(|q| HPoint { x: q[0], y: q[1], z: q[2], w: q[3] }).collect(); + for q in bezier_segment(&h, fa, fb).ok()? { + if !(q.w > 0.) { + return None; + } + out.push(q.project()?); + } + } + (!out.is_empty()).then_some(out) + } +} + +/// De Boor evaluation of a (rational) B-spline at `t`, clamped to the active domain. +pub(crate) fn de_boor(p: usize, knots: &[f64], ctrl: &[[f64; 4]], t: f64) -> Vec3 { + let n = ctrl.len(); + let (Some(&lo), Some(&hi)) = (knots.get(p), knots.get(n)) else { return Vec3::ZERO }; + let t = t.clamp(lo, hi); + // Span k with knots[k] <= t < knots[k+1], last nonempty span at the end. + let mut k = p; + while k + 1 < n && knots.get(k + 1).is_some_and(|u| *u <= t) { + k += 1; + } + let mut d: Vec<[f64; 4]> = (0..=p).map(|j| ctrl.get(j + k - p).copied().unwrap_or([0.; 4])).collect(); + for r in 1..=p { + for j in (r..=p).rev() { + let (a, b) = (knots.get(j + k - p).copied().unwrap_or(0.), knots.get(j + 1 + k - r).copied().unwrap_or(0.)); + let alpha = if b - a == 0. { 0. } else { (t - a) / (b - a) }; + let (prev, cur) = (d.get(j - 1).copied().unwrap_or([0.; 4]), d.get(j).copied().unwrap_or([0.; 4])); + if let Some(slot) = d.get_mut(j) { + for c in 0..4 { + slot[c] = (1. - alpha) * prev[c] + alpha * cur[c]; + } + } + } + } + let h = d.get(p).copied().unwrap_or([0.; 4]); + if h[3] == 0. { Vec3::ZERO } else { Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]) } +} + +/// The circle a curve lies on, if it is one: centre, unit normal, radius. Checked at many +/// parameters against `tol` (relative to the radius), so rational circle splines are recognised. +pub(crate) fn as_circle(c: &C3, tol: f64) -> Option<(Vec3, Vec3, f64)> { + match c { + C3::Arc { o, x, y, r, .. } if (r[0] - r[1]).abs() <= tol * r[0] => Some((*o, x.cross(*y).normalized()?, r[0])), + C3::Spline { degree, points, .. } => { + let [t0, t1] = c.domain(); + let at = |f: f64| c.at(t0 + (t1 - t0) * f); + let (a, b, m) = (at(0.), at(1.), at(0.5)); + // Circumcentre of a, m and a third point (b may equal a for a full circle). + let third = if a.dist(b) < 1e-9 * (1. + a.len()) { at(0.25) } else { b }; + let (u, v) = (m - a, third - a); + let n = u.cross(v); + let n2 = n.len2(); + if n2 <= 1e-24 { + return None; + } + let centre = a + (v * u.len2() - u * v.len2()).cross(n) * (1. / (2. * n2)); + let normal = n.normalized()?; + let r = centre.dist(a); + // On one knot span of a (rational) spline of degree p, f = w^2 (|P - c|^2 - r^2) is + // a polynomial of degree 2p. At 2p + 3 Chebyshev points of the span it is at most + // w_max^2 2 r d (d the largest distance from the circle there), so everywhere at most + // 4 times that (their Lebesgue constant is below 4), and as w >= w_min the distance + // anywhere is at most 4 (w_max / w_min)^2 d: weights near zero cannot hide a swerve. + let weights = points.iter().map(|h| h[3]); + let (w_min, w_max) = weights.fold((f64::MAX, f64::MIN), |(lo, hi), w| (lo.min(w), hi.max(w))); + if !(w_min > 0. && w_max.is_finite()) { + return None; + } + let off = |p: Vec3| (p.dist(centre) - r).abs().max((p - centre).dot(normal).abs()); + let n = (2 * degree + 3).max(9); + let mut d = 0f64; + for w in c.breaks().windows(2) { + let (Some(&s0), Some(&s1)) = (w.first(), w.get(1)) else { continue }; + let (mid, half) = (0.5 * (s0 + s1), 0.5 * (s1 - s0)); + d = d.max(off(c.at(s0))).max(off(c.at(s1))); + for k in 0..n { + d = d.max(off(c.at(mid + half * (std::f64::consts::PI * (2 * k + 1) as f64 / (2 * n) as f64).cos()))); + } + } + // Relative to the curve's own size when that is smaller than the radius: a nearly + // straight curve fits a huge circle, whose radius says nothing about the error. + let pts: Vec = points.iter().filter(|h| h[3] != 0.).map(|h| Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])).collect(); + let size = pts.iter().skip(1).fold(pts.first().map(|p| (*p, *p)), |acc, p| acc.map(|(lo, hi)| (lo.min(*p), hi.max(*p)))); + let size = size.map_or(r, |(lo, hi)| (hi - lo).len()); + (4. * (w_max / w_min).powi(2) * d <= tol * r.min(size)).then_some((centre, normal, r)) + } + _ => None, + } +} diff --git a/crates/geom/src/hlr/mod.rs b/crates/geom/src/hlr/mod.rs new file mode 100644 index 0000000000..8023c914fe --- /dev/null +++ b/crates/geom/src/hlr/mod.rs @@ -0,0 +1,1915 @@ +//! Exact orthographic hidden lines for placed portable B-rep bodies (Drawing W06a, #2455; +//! plan #860). Edges and face silhouettes are projected from their exact curves, split where +//! their projections cross, and each piece is classified by rays toward the viewer against the +//! trimmed faces. Every piece is visible, hidden or explicitly uncertain; nothing is guessed. +//! +//! Supported: planes, and cylinders, cones, spheres and tori whether stored as analytic records, +//! revolutions of lines and circles or spline patches (recognised to 1e-9 relative and then +//! trimmed and intersected in their own chart). Other spline and revolution faces are freeform: +//! unless [`Options::freeform`] is set they are listed in [`Drawing::unsupported`], their +//! silhouettes are not drawn and anything they could hide is reported uncertain. +//! +//! Work is done in a frame centred on the scene, so results do not drift for parts far from the +//! origin. Output curves are `Seg2` pieces in screen millimetres with the accuracy vocabulary of +//! [`crate::section`]. See `docs/drawing-hlr.md` for the contract and its measured limits. + +pub(crate) mod curve; +mod silhouette; +pub(crate) mod surface; +mod trim; + +use serde::Serialize; + +use crate::brep::Brep; +use crate::section::CurveAccuracy; +use crate::{Seg2, Vec2, Vec3}; +use curve::{C3, as_circle}; +use surface::{Analytic, Hit, S3}; +use trim::Trim; + +#[derive(Debug, thiserror::Error)] +pub enum HlrError { + #[error("not supported yet: {0}")] + Unsupported(&'static str), + #[error("hidden lines: {0} budget exceeded")] + Budget(&'static str), + #[error("hidden lines: cancelled")] + Cancelled, + #[error("hidden lines: invalid {0}")] + Invalid(&'static str), +} + +/// An orthographic view: `d` is the direction of sight; screen coordinates are `(p.x, p.y)`. +#[derive(Clone, Copy, Debug)] +pub struct View { + pub x: Vec3, + pub y: Vec3, + pub d: Vec3, +} + +impl View { + pub fn new(d: Vec3, up: Vec3) -> Result { + let d = d.normalized().ok_or(HlrError::Invalid("view direction"))?; + let x = d.cross(up).normalized().ok_or(HlrError::Invalid("view up direction"))?; + Ok(View { x, y: x.cross(d), d }) + } + pub fn screen(&self, p: Vec3) -> [f64; 2] { + [p.dot(self.x), p.dot(self.y)] + } +} + +/// One placed body: `world = rotation * local + translation`, rotation columns given. +#[derive(Clone, Copy, Debug)] +pub struct Placement { + pub body: usize, + pub rotation: [Vec3; 3], + pub translation: Vec3, +} + +impl Placement { + fn apply(&self, p: Vec3) -> Vec3 { + let [a, b, c] = self.rotation; + a * p.x + b * p.y + c * p.z + self.translation + } + fn local_dir(&self, v: Vec3) -> Vec3 { + let [a, b, c] = self.rotation; + Vec3::new(a.dot(v), b.dot(v), c.dot(v)) + } + fn local(&self, p: Vec3) -> Vec3 { + self.local_dir(p - self.translation) + } + fn rigid(&self) -> bool { + let [a, b, c] = self.rotation; + [a, b, c].iter().all(|v| v.is_finite() && (v.len() - 1.).abs() < 1e-9) + && a.dot(b).abs() < 1e-9 + && a.dot(c).abs() < 1e-9 + && b.dot(c).abs() < 1e-9 + && (a.cross(b).dot(c) - 1.).abs() < 1e-9 + && self.translation.is_finite() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EdgeKind { + Sharp, + /// Between two faces that meet tangentially (or lie on one surface). + Tangent, + /// Used twice by one face, or between two faces of one analytic surface (a patch boundary). + Seam, +} + +struct EdgeData { + curve: C3, + acc: CurveAccuracy, + faces: Vec, + kind: EdgeKind, +} + +struct FaceData { + surface: S3, + analytic: Analytic, + /// Faces of this body on the same quadric or torus (patches of one surface), itself included. + group: Vec, + trim: Trim, + lo: Vec3, + hi: Vec3, + /// Neither a plane nor a recognised quadric or torus. + freeform: bool, +} + +/// A body prepared once and shared by all of its placements. +pub struct Prepared { + edges: Vec, + faces: Vec, + /// The body's records are kept moved by `-offset` (to its own centre), so its coordinates + /// stay small wherever it was modelled; placements add it back. + offset: Vec3, + lo: Vec3, + hi: Vec3, + pub report: PrepareReport, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct PrepareReport { + pub faces: usize, + pub edges: usize, + /// Face counts by recognised surface: plane, cylinder, cone, sphere, torus, other. + pub surfaces: std::collections::BTreeMap, + /// Edge counts by record kind and recognition, e.g. `rational_spline/circle`. + pub curves: std::collections::BTreeMap, + pub tangent_edges: usize, + pub seam_edges: usize, + /// Faces whose trim could not be classified, with the reason. + pub unsupported_trims: Vec<(usize, String)>, + /// Largest distance between a boundary curve and the chords used for trimming (mm). + pub max_trim_chord: f64, + /// Faces outside the supported subset (see [`Options::freeform`]). + pub freeform_faces: Vec, +} + +/// Whether two recognised surfaces are the same quadric or torus (planes never: coplanar +/// faces of a split are real edges in the record, and are drawn). +fn same_analytic(a: &Analytic, b: &Analytic, tol: f64) -> bool { + let close = |x: Vec3, y: Vec3, scale: f64| x.dist(y) <= tol * (1. + scale); + let on_axis = |p: Vec3, q: Vec3, a: Vec3, scale: f64| (q - p).cross(a).len() <= tol * (1. + scale); + match (a, b) { + (Analytic::Cylinder { p, a, r }, Analytic::Cylinder { p: q, a: b, r: s }) => { + (r - s).abs() <= tol * r && a.cross(*b).len() <= tol && on_axis(*p, *q, *a, *r) + } + (Analytic::Cone { apex, a, half }, Analytic::Cone { apex: b_apex, a: b, half: h }) => { + // The axes by their angle, as for cylinders and tori (a dot product would compare + // its square). + (half - h).abs() <= tol && a.cross(*b).len() <= tol && a.dot(*b) > 0. && close(*apex, *b_apex, apex.len()) + } + (Analytic::Sphere { c, r }, Analytic::Sphere { c: d, r: s }) => (r - s).abs() <= tol * r && close(*c, *d, *r), + (Analytic::Torus { c, a, major, minor }, Analytic::Torus { c: d, a: b, major: m, minor: n }) => { + (major - m).abs() <= tol * major && (minor - n).abs() <= tol * major && a.cross(*b).len() <= tol && close(*c, *d, *major) + } + _ => false, + } +} + +fn analytic_name(a: &Analytic) -> &'static str { + match a { + Analytic::Plane => "plane", + Analytic::Cylinder { .. } => "cylinder", + Analytic::Cone { .. } => "cone", + Analytic::Sphere { .. } => "sphere", + Analytic::Torus { .. } => "torus", + Analytic::Other => "other", + } +} + +pub fn prepare(b: &Brep, opts: &Options) -> Result { + prepare_cancellable(b, opts, &|| false) +} + +/// [`prepare`], polling `cancel` between edges, faces and surface comparisons; when it returns +/// true the call ends with `HlrError::Cancelled`. +pub fn prepare_cancellable(b: &Brep, opts: &Options, cancel: &dyn Fn() -> bool) -> Result { + if !(1..=MAX_TRIM_SAMPLES).contains(&opts.trim_samples) { + return Err(HlrError::Invalid("trim samples")); + } + b.validate().map_err(|_| HlrError::Invalid("portable B-rep"))?; + // Work on the body moved to its own centre: rounding in ray and inversion formulas grows + // with the coordinates, and a part may be modelled far from the origin (W05a finding 7). + let offset = anchor(b); + let moved = shifted(b, offset); + let b = &moved; + let mut edges = Vec::new(); + let mut faces = Vec::new(); + let mut report = PrepareReport::default(); + for shell in &b.shells { + let base_face = faces.len(); + let mut curves = Vec::with_capacity(shell.edges.len()); + for (ei, e) in shell.edges.iter().enumerate() { + if ei % 256 == 0 && cancel() { + return Err(HlrError::Cancelled); + } + let (c, acc) = C3::new(&e.curve)?; + let record = match &e.curve { + crate::brep::Curve::Line { .. } => "line", + crate::brep::Curve::CircleArc { .. } => "circle_arc", + crate::brep::Curve::EllipseArc { .. } => "ellipse_arc", + crate::brep::Curve::Polyline { .. } => "polyline", + crate::brep::Curve::Spline { .. } => "spline", + crate::brep::Curve::RationalSpline { .. } => "rational_spline", + crate::brep::Curve::Intersection { .. } => "intersection", + crate::brep::Curve::SurfaceIntersection { .. } => "surface_intersection", + }; + let shape = if matches!(c, C3::Line { .. }) { + "line" + } else if as_circle(&c, 1e-9).is_some() { + "circle" + } else { + "other" + }; + *report.curves.entry(format!("{record}/{shape}")).or_insert(0) += 1; + curves.push((c, acc)); + } + let local_curves: Vec = curves.iter().map(|(c, _)| c.clone()).collect(); + let mut users: Vec> = vec![Vec::new(); shell.edges.len()]; + for (fi, f) in shell.faces.iter().enumerate() { + if cancel() { + return Err(HlrError::Cancelled); + } + let mut surface = S3::new(&f.surface)?; + // A flat spline patch is a plane: trimmed and intersected as one. + if let Some(plane) = surface.flat(1e-9) { + surface = plane; + } + let analytic = surface.analytic(1e-9); + // Trim and intersect recognised spline surfaces in their own analytic chart. + if matches!(surface, S3::Nurbs(_)) { + let axis = match analytic { + Analytic::Cylinder { p, a, .. } => Some((p, a)), + Analytic::Cone { apex, a, .. } => Some((apex, a)), + _ => None, + }; + let mut h = [0., 0.]; + if let Some((p, a)) = axis { + let along = f.loops.iter().flatten().filter_map(|u| local_curves.get(u.edge as usize)).flat_map(|c| { + let [t0, t1] = c.domain(); + (0..=64).map(move |i| c.at(t0 + (t1 - t0) * i as f64 / 64.)) + }); + let (lo, hi) = along.fold((f64::MAX, f64::MIN), |(lo, hi), q| ((q - p).dot(a).min(lo), (q - p).dot(a).max(hi))); + // Generous: the chart only has to contain the face; the trim bounds it exactly. + let pad = 0.01 * (hi - lo).abs() + 1e-6; + h = [lo - pad, hi + pad]; + } + let toward = match analytic { + Analytic::Sphere { c, .. } => { + let pts: Vec = f + .loops + .iter() + .flatten() + .filter_map(|u| local_curves.get(u.edge as usize)) + .map(|c| c.at(0.5 * (c.domain()[0] + c.domain()[1]))) + .collect(); + let n = pts.len().max(1) as f64; + pts.iter().fold(Vec3::ZERO, |acc, p| acc + *p) / n - c + } + _ => Vec3::ZERO, + }; + if let Some(chart) = S3::from_analytic(&analytic, h, toward) { + surface = chart; + } + } + *report.surfaces.entry(analytic_name(&analytic).to_string()).or_insert(0) += 1; + let trim = trim::build(f, &local_curves, &surface, opts.trim_samples); + if let Some(why) = trim.unsupported { + report.unsupported_trims.push((base_face + fi, why.to_string())); + } + report.max_trim_chord = report.max_trim_chord.max(trim.chord_error); + let (mut lo, mut hi) = (trim.lo, trim.hi); + // Curved faces can bulge past their boundary: bound them by the whole surface's + // guaranteed box (spline hull, turned profile), clipped to nothing tighter. + if !matches!(surface, S3::Plane { .. }) + && let Some((blo, bhi)) = surface.bound() + { + lo = lo.min(blo); + hi = hi.max(bhi); + } + // Every face is padded, planes too: a plane record and its edges may disagree by + // rounding, and a box flat along the normal would cull the plane's own hit. Rounding + // grows with where the body was modelled (its offset), not with the face's size. + let pad = (hi - lo).len() * 1e-6 + 1e-12 * offset.x.abs().max(offset.y.abs()).max(offset.z.abs()); + lo -= Vec3::new(pad, pad, pad); + hi += Vec3::new(pad, pad, pad); + for u in f.loops.iter().flatten() { + if let Some(list) = users.get_mut(u.edge as usize) { + list.push(base_face + fi); + } + } + let freeform = matches!(analytic, Analytic::Other); + if freeform { + report.freeform_faces.push(base_face + fi); + } + faces.push(FaceData { surface, analytic, group: Vec::new(), trim, lo, hi, freeform }); + } + for (ei, (c, acc)) in curves.into_iter().enumerate() { + let list = users.get(ei).cloned().unwrap_or_default(); + let mut distinct = list.clone(); + distinct.sort_unstable(); + distinct.dedup(); + let kind = if list.len() >= 2 && distinct.len() == 1 { + EdgeKind::Seam + } else if let [f1, f2] = distinct.as_slice() { + let [t0, t1] = c.domain(); + let normals_parallel = [0.25, 0.5, 0.75].iter().all(|k| { + let m = c.at(t0 + (t1 - t0) * k); + let n = |f: usize| faces.get(f).and_then(|fd: &FaceData| fd.surface.inverse(m, None).and_then(|uv| fd.surface.normal(uv))); + n(*f1).zip(n(*f2)).is_some_and(|(a, b)| a.cross(b).len() < 1e-6) + }); + let same_surface = match (faces.get(*f1).map(|f: &FaceData| f.analytic), faces.get(*f2).map(|f: &FaceData| f.analytic)) { + (Some(a), Some(b)) => same_analytic(&a, &b, 1e-9), + _ => false, + }; + if same_surface { + EdgeKind::Seam + } else if normals_parallel { + EdgeKind::Tangent + } else { + EdgeKind::Sharp + } + } else { + EdgeKind::Sharp + }; + match kind { + EdgeKind::Tangent => report.tangent_edges += 1, + EdgeKind::Seam => report.seam_edges += 1, + EdgeKind::Sharp => {} + } + edges.push(EdgeData { curve: c, acc, faces: list, kind }); + } + } + // Faces on one quadric or torus. Only surfaces of one kind whose size (radius, half angle, + // major radius) agrees to the tolerance can match, so each face is compared with the faces + // in its window of the sorted sizes, not with every face. + let size = |a: &Analytic| match a { + Analytic::Cylinder { r, .. } => Some((0u8, *r)), + Analytic::Cone { half, .. } => Some((1, *half)), + Analytic::Sphere { r, .. } => Some((2, *r)), + Analytic::Torus { major, .. } => Some((3, *major)), + Analytic::Plane | Analytic::Other => None, + }; + let analytics: Vec = faces.iter().map(|f| f.analytic).collect(); + let mut sorted: Vec<(u8, f64, usize)> = analytics.iter().enumerate().filter_map(|(j, a)| size(a).map(|(k, v)| (k, v, j))).collect(); + sorted.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); + let mut compared = 0usize; + for (i, f) in faces.iter_mut().enumerate() { + if i % 256 == 0 && cancel() { + return Err(HlrError::Cancelled); + } + let mut group = vec![i]; + if let Some((kind, v)) = size(&f.analytic) { + // Wider than any match allows: same_analytic takes sizes within 1e-9 of the size + // (relative for lengths, absolute for the half angle). + let reach = 2e-9 * v.abs().max(1.); + let first = sorted.partition_point(|c| c.0 < kind || (c.0 == kind && c.1 < v - reach)); + for (_, _, j) in sorted.get(first..).unwrap_or_default().iter().take_while(|c| c.0 == kind && c.1 <= v + reach) { + compared += 1; + if compared > 50_000_000 { + return Err(HlrError::Budget("surface group")); + } + if compared.is_multiple_of(65536) && cancel() { + return Err(HlrError::Cancelled); + } + if *j != i && analytics.get(*j).is_some_and(|b| same_analytic(&f.analytic, b, 1e-9)) { + group.push(*j); + } + } + } + group.sort_unstable(); + f.group = group; + } + report.faces = faces.len(); + report.edges = edges.len(); + let (lo, hi) = + faces.iter().fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(a, b), f| (a.min(f.lo), b.max(f.hi))); + if faces.is_empty() || !lo.is_finite() || !hi.is_finite() { + return Err(HlrError::Invalid("empty or unbounded body")); + } + Ok(Prepared { edges, faces, offset, lo, hi, report }) +} + +/// The centre of the body's vertices' box (of its faces' reference points when it has no +/// vertices, a sphere without edges say). +fn anchor(b: &Brep) -> Vec3 { + use crate::brep::Surface; + let v3 = |p: &[f64; 3]| Vec3::new(p[0], p[1], p[2]); + let mut pts: Vec = b.shells.iter().flat_map(|s| s.vertices.iter().map(v3)).collect(); + if pts.is_empty() { + pts = b + .shells + .iter() + .flat_map(|s| s.faces.iter()) + .filter_map(|f| match &f.surface { + Surface::Plane { origin, .. } | Surface::PlaneThrough { origin, .. } | Surface::Revolution { origin, .. } => Some(v3(origin)), + Surface::PlacedRevolution { origin, matrix: [x, y, z, w], .. } => { + let column = |c: &[f64; 4]| Vec3::new(c[0], c[1], c[2]); + Some(column(x) * origin[0] + column(y) * origin[1] + column(z) * origin[2] + column(w)) + } + Surface::Cylinder { frame, .. } | Surface::Cone { frame, .. } | Surface::Sphere { frame, .. } | Surface::Torus { frame, .. } => { + Some(v3(&frame.origin)) + } + Surface::Spline { points, .. } => points.first().and_then(|r| r.first()).map(v3), + Surface::RationalSpline { homogeneous, .. } => { + homogeneous.first().and_then(|r| r.first()).filter(|h| h[3] != 0.).map(|h| Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])) + } + }) + .collect(); + } + let Some(first) = pts.first().copied() else { return Vec3::ZERO }; + let (lo, hi) = pts.iter().fold((first, first), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + let c = (lo + hi) * 0.5; + if c.is_finite() { c } else { Vec3::ZERO } +} + +/// The body's records with every position moved by `-d`. +fn shifted(b: &Brep, d: Vec3) -> Brep { + use crate::brep::{Curve, Surface}; + fn point(p: &mut [f64; 3], d: Vec3) { + p[0] -= d.x; + p[1] -= d.y; + p[2] -= d.z; + } + fn weighted(h: &mut [f64; 4], d: Vec3) { + h[0] -= d.x * h[3]; + h[1] -= d.y * h[3]; + h[2] -= d.z * h[3]; + } + fn curve(c: &mut Curve, d: Vec3) { + match c { + Curve::Line { start, end } => { + point(start, d); + point(end, d); + } + Curve::CircleArc { frame, .. } | Curve::EllipseArc { frame, .. } => point(&mut frame.origin, d), + Curve::Polyline { points } | Curve::Spline { points, .. } | Curve::Intersection { points, .. } => { + points.iter_mut().for_each(|p| point(p, d)) + } + Curve::RationalSpline { homogeneous, .. } => homogeneous.iter_mut().for_each(|h| weighted(h, d)), + Curve::SurfaceIntersection { surfaces, leader, .. } => { + surfaces.iter_mut().for_each(|s| surface(s, d)); + curve(leader, d); + } + } + } + fn surface(s: &mut Surface, d: Vec3) { + match s { + Surface::Plane { origin, .. } => point(origin, d), + Surface::PlaneThrough { origin, u_point, v_point } => [origin, u_point, v_point].into_iter().for_each(|p| point(p, d)), + Surface::Cylinder { frame, .. } | Surface::Cone { frame, .. } | Surface::Sphere { frame, .. } | Surface::Torus { frame, .. } => { + point(&mut frame.origin, d) + } + Surface::Spline { points, .. } => points.iter_mut().flatten().for_each(|p| point(p, d)), + Surface::RationalSpline { homogeneous, .. } => homogeneous.iter_mut().flatten().for_each(|h| weighted(h, d)), + Surface::Revolution { profile, origin, .. } => { + curve(profile, d); + point(origin, d); + } + // The placement's last column carries its translation (and the homogeneous weight). + Surface::PlacedRevolution { matrix: [.., w], .. } => weighted(w, d), + } + } + let mut out = b.clone(); + for s in &mut out.shells { + s.vertices.iter_mut().for_each(|p| point(p, d)); + s.edges.iter_mut().for_each(|e| curve(&mut e.curve, d)); + s.faces.iter_mut().for_each(|f| surface(&mut f.surface, d)); + } + out +} + +#[derive(Clone, Copy, Debug)] +pub struct Options { + /// Draw edges between tangent faces (seams are never drawn). + pub tangent_edges: bool, + /// Also handle freeform faces: contoured silhouettes and seeded ray tests, slow and + /// ill-conditioned where a face is seen nearly edge-on (W05a finding 6). Off by default. + pub freeform: bool, + /// Boundary samples per curve piece when trimming faces, 1 to [`MAX_TRIM_SAMPLES`]. + pub trim_samples: usize, + pub max_tracks: usize, + pub max_rays: usize, +} + +impl Default for Options { + fn default() -> Self { + Options { tangent_edges: true, freeform: false, trim_samples: 256, max_tracks: 200_000, max_rays: 20_000_000 } + } +} + +/// Largest [`Options::trim_samples`]. +pub const MAX_TRIM_SAMPLES: usize = 4096; +/// Largest coordinate of a placed body in the scene, in millimetres: the bound `geom::section` +/// and `geom::brep` put on stored numbers. Beyond it the scene's size, and every tolerance +/// taken from it, would lose its meaning or overflow. +pub const MAX_COORDINATE: f64 = 1e12; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Visibility { + Visible, + Hidden, + Uncertain, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Source { + Edge { + edge: usize, + tangent: bool, + }, + /// The silhouette of the faces on one surface; `face` is the first of them. + Silhouette { + face: usize, + }, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Piece { + /// Parameter range on the source curve. + pub t: [f64; 2], + pub visibility: Visibility, + /// Drawn by another piece: a hidden or uncertain piece lying on a visible one (hidden lines + /// never show through visible ones), or a duplicate of a visible or hidden piece. + pub covered: bool, + /// The piece on screen (millimetres): lines and conic arcs where the projection is exact, + /// chords otherwise. + pub geometry: Vec, + pub accuracy: CurveAccuracy, + /// Exact points along the piece, joined by `geometry`'s chords where it is sampled. + #[serde(skip)] + points: Vec<[f64; 2]>, + /// Largest distance between the projected curve and the chords joining `points`. + #[serde(skip)] + sag: f64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Line { + /// Index into the scene's placements. + pub placement: usize, + pub source: Source, + pub pieces: Vec, +} + +/// A face left out of the supported subset, and why. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Unsupported { + pub placement: usize, + pub face: usize, + pub reason: &'static str, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Stats { + pub tracks: usize, + pub silhouettes: usize, + pub crossings: usize, + pub pieces: usize, + pub uncertain_pieces: usize, + /// Uncertain pieces not covered by a coincident visible piece. + pub uncertain_uncovered: usize, + pub covered_pieces: usize, + /// Visibility changes found inside a piece by sampling, i.e. not at a computed crossing. + pub unexpected_transitions: usize, + pub rays: usize, + pub surface_ray_tests: usize, + /// Milliseconds per phase: curves and silhouettes, crossings, classification, coverage. + /// Zero where the platform has no clock (`wasm32-unknown-unknown`). + pub phase_ms: [f64; 4], +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Drawing { + pub lines: Vec, + /// Faces outside the supported subset: their silhouettes are missing and what they could + /// hide is uncertain. + pub unsupported: Vec, + pub stats: Stats, +} + +enum TrackCurve<'a> { + Edge(&'a C3), + Owned(C3), +} + +struct Track<'a> { + placement: usize, + curve: TrackCurve<'a>, + range: [f64; 2], + source: Source, + acc: CurveAccuracy, + own: Vec, + /// Own-face hits closer than this are the curve's own surface: a sampled silhouette's + /// chord lies up to its deviation inside the surface, and a ray tangent there leaves it + /// again after about sqrt(2 * radius * deviation). + own_eps: f64, + samples: Vec<(f64, [f64; 2])>, + /// Per sample chord: the distance of the exact projected curve from it at its middle. + sags: Vec, + splits: Vec, +} + +impl Track<'_> { + fn c3(&self) -> &C3 { + match &self.curve { + TrackCurve::Edge(c) => c, + TrackCurve::Owned(c) => c, + } + } +} + +struct Ctx<'a> { + defs: &'a [Prepared], + scene: &'a [Placement], + view: View, + eps_own: f64, + eps_other: f64, + scale: f64, + /// World boxes of placements, screen min/max and depth min. + boxes: Vec<([f64; 2], [f64; 2], f64)>, + rays: std::cell::Cell, + surface_tests: std::cell::Cell, + transitions: std::cell::Cell, + max_rays: usize, + freeform: bool, + cancel: &'a dyn Fn() -> bool, +} + +fn context<'a>(defs: &'a [Prepared], scene: &'a [Placement], view: &View, opts: &Options, cancel: &'a dyn Fn() -> bool) -> Result, HlrError> { + let mut boxes = Vec::with_capacity(scene.len()); + let (mut wlo, mut whi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)); + for p in scene { + if !p.rigid() { + return Err(HlrError::Invalid("placement (not a rotation and translation)")); + } + let def = defs.get(p.body).ok_or(HlrError::Invalid("placement body index"))?; + let (mut slo, mut shi, mut dmin) = ([f64::MAX; 2], [f64::MIN; 2], f64::MAX); + for k in 0..8 { + let c = Vec3::new( + if k & 1 == 0 { def.lo.x } else { def.hi.x }, + if k & 2 == 0 { def.lo.y } else { def.hi.y }, + if k & 4 == 0 { def.lo.z } else { def.hi.z }, + ); + let w = p.apply(c); + wlo = wlo.min(w); + whi = whi.max(w); + let s = view.screen(w); + slo = [slo[0].min(s[0]), slo[1].min(s[1])]; + shi = [shi[0].max(s[0]), shi[1].max(s[1])]; + dmin = dmin.min(w.dot(view.d)); + } + boxes.push((slo, shi, dmin)); + } + let scale = if scene.is_empty() { 1. } else { (whi - wlo).len().max(1e-6) }; + Ok(Ctx { + defs, + scene, + view: *view, + eps_own: 1e-6 * scale, + // Hits on other bodies closer than rounding are ignored, the rest up to eps_own are + // uncertain: the work is done in a frame at the scene's centre, so rounding is about + // 1e-16 of the coordinates, far below 1e-12 of the scene. + eps_other: 1e-12 * scale, + scale, + boxes, + rays: std::cell::Cell::new(0), + surface_tests: std::cell::Cell::new(0), + transitions: std::cell::Cell::new(0), + max_rays: opts.max_rays, + freeform: opts.freeform, + cancel, + }) +} + +pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: &Options, cancel: &dyn Fn() -> bool) -> Result { + // Work in a frame centred on the scene: coordinates stay small, so rounding does not grow + // with the distance from the origin (W05a finding 7). Results are shifted back at the end. + let origin = scene_centre(defs, scene)?; + // Each body's own offset (see `Prepared`) joins its placement here. + let local: Vec = + scene.iter().map(|p| Placement { translation: p.apply(defs.get(p.body).map_or(Vec3::ZERO, |d| d.offset)) - origin, ..*p }).collect(); + let scene = local.as_slice(); + let shift = view.screen(origin); + let ctx = context(defs, scene, view, opts, cancel)?; + let scale = ctx.scale; + let mut stats = Stats::default(); + let mut unsupported = Vec::new(); + let clock = Clock::start(); + // 1. Tracks: drawable edges and trimmed silhouettes of every placement. + let mut tracks: Vec = Vec::new(); + for (pi, p) in scene.iter().enumerate() { + if cancel() { + return Err(HlrError::Cancelled); + } + let Some(def) = defs.get(p.body) else { continue }; + // A body's records were rounded where it was modelled: a plane may stand an ulp of + // those coordinates off its edges, which matters for a part far smaller than its + // distance from the origin. + let far = 1e-14 * def.offset.x.abs().max(def.offset.y.abs()).max(def.offset.z.abs()); + for (ei, e) in def.edges.iter().enumerate() { + if e.kind == EdgeKind::Seam || (e.kind == EdgeKind::Tangent && !opts.tangent_edges) { + continue; + } + tracks.push(Track { + placement: pi, + curve: TrackCurve::Edge(&e.curve), + range: e.curve.domain(), + source: Source::Edge { edge: ei, tangent: e.kind == EdgeKind::Tangent }, + acc: e.acc, + own: e.faces.clone(), + // Skip the ray's own start on its faces: rounding grows with the coordinates + // (1e-9 of the scene), the margin with the faces themselves (1e-6 of their + // size), never with the whole scene, so small parts in large scenes keep their + // self-occlusion. + own_eps: { + let size = e.faces.iter().filter_map(|f| def.faces.get(*f)).map(|f| (f.hi - f.lo).len()).fold(0., f64::max); + let base = (1e-9 * ctx.scale).max(1e-6 * size).max(far); + match e.acc { + CurveAccuracy::Bounded { max_deviation_mm: tol } => base.max(10. * tol), + _ => base, + } + }, + samples: Vec::new(), + sags: Vec::new(), + splits: Vec::new(), + }); + } + let dl = p.local_dir(view.d); + let supported = |f: &FaceData| !(f.freeform && !opts.freeform) && f.trim.unsupported.is_none(); + // Faces on one surface (a sphere in two halves, say) share their silhouettes, keyed by + // the set's first face: one curve along their seams, not one per face. + let mut sets: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for (fi, f) in def.faces.iter().enumerate() { + if f.freeform && !opts.freeform { + unsupported.push(Unsupported { placement: pi, face: fi, reason: "freeform face" }); + continue; + } + if let Some(reason) = f.trim.unsupported { + unsupported.push(Unsupported { placement: pi, face: fi, reason }); + continue; + } + if matches!(f.analytic, Analytic::Plane) { + continue; + } + // The set's silhouette is traced on its first face's surface, so a face joins only + // when its recognised surface is that one to rounding (1e-13); recognition groups + // surfaces more loosely (1e-9), and two surfaces that close can still differ inside + // the faces (two spherical caps through one circle). + let first = f.group.iter().copied().filter(|j| def.faces.get(*j).is_some_and(supported)).min().unwrap_or(fi); + let fits = def.faces.get(first).is_some_and(|l| same_analytic(&l.analytic, &f.analytic, 1e-13)); + sets.entry(if fits { first } else { fi }).or_default().push(fi); + } + for (fi, members) in sets { + let Some(f) = def.faces.get(fi) else { continue }; + let parts: Vec<(&S3, &Trim)> = members.iter().filter_map(|j| def.faces.get(*j)).map(|m| (&m.surface, &m.trim)).collect(); + let size = members.iter().filter_map(|j| def.faces.get(*j)).map(|m| (m.hi - m.lo).len()).fold(0., f64::max); + let mut sils = Vec::new(); + silhouette::silhouettes(&parts, f.analytic, dl, &mut sils); + for s in sils { + for r in &s.ranges { + stats.silhouettes += 1; + tracks.push(Track { + placement: pi, + curve: TrackCurve::Owned(s.curve.clone()), + range: *r, + source: Source::Silhouette { face: fi }, + acc: s.acc, + own: f.group.clone(), + own_eps: match s.acc { + CurveAccuracy::Sampled { max_sample_deviation_mm: dev } => { + (1e-9 * ctx.scale).max(1e-6 * size).max(far).max(2. * (2. * size * dev).sqrt()) + } + _ => (1e-9 * ctx.scale).max(1e-6 * size).max(far), + }, + samples: Vec::new(), + sags: Vec::new(), + splits: Vec::new(), + }); + } + } + } + if tracks.len() > opts.max_tracks { + return Err(HlrError::Budget("curve")); + } + } + stats.tracks = tracks.len(); + // 2. Screen samples. + for (ti, t) in tracks.iter_mut().enumerate() { + if ti % 64 == 0 && cancel() { + return Err(HlrError::Cancelled); + } + let Some(p) = scene.get(t.placement) else { continue }; + let c = t.c3(); + let mut ts: Vec = c.breaks().into_iter().filter(|x| *x > t.range[0] && *x < t.range[1]).collect(); + ts.push(t.range[0]); + ts.push(t.range[1]); + ts.sort_by(f64::total_cmp); + let per = match c { + C3::Line { .. } => 1, + C3::Polyline(_) => 1, + _ => 32, + }; + let mut params = Vec::new(); + for w in ts.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + for i in 0..per { + params.push(a + (b - a) * i as f64 / per as f64); + } + } + params.push(t.range[1]); + let screen = |x: f64| view.screen(p.apply(c.at(x))); + let mut samples: Vec<(f64, [f64; 2])> = params.into_iter().map(|x| (x, screen(x))).collect(); + // Halve chords of exact curves until their sag on screen is within the target, so + // chords stand for the curve closely enough for coincidence and crossing tests. + if !matches!(c, C3::Line { .. } | C3::Polyline(_)) { + let target = 1e-7 * scale.max(1.); + for _ in 0..12 { + let mut next = Vec::with_capacity(samples.len() * 2); + let mut split = false; + for w in samples.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + next.push(*a); + let m = 0.5 * (a.0 + b.0); + let q = screen(m); + // A spline's piece is held by its control points' hull: a parameterisation + // racing between samples cannot hide a swerve from the chord. + let hull = + c.hull([a.0, b.0]).map_or(0., |h| h.iter().map(|x| point_segment(view.screen(p.apply(*x)), a.1, b.1)).fold(0., f64::max)); + if point_segment(q, a.1, b.1).max(hull) > target && next.len() < 1 << 16 { + next.push((m, q)); + split = true; + } + } + if let Some(last) = samples.last() { + next.push(*last); + } + samples = next; + if !split { + break; + } + } + } + // Chord sag on screen: exact for lines, measured at the middle for curves; a sampled + // silhouette's chords stand for the true curve to within its measured deviation. + // A fitted source record stands for the true curve to within its bound. + let fitted = match t.acc { + CurveAccuracy::Bounded { max_deviation_mm: tol } => tol, + _ => 0., + }; + let sags = samples + .windows(2) + .map(|w| { + fitted + + match (c, w.first(), w.get(1)) { + (C3::Line { .. }, _, _) => 0., + (C3::Polyline(_), _, _) => match t.acc { + CurveAccuracy::Sampled { max_sample_deviation_mm: dev } => dev, + _ => 0., + }, + (_, Some(a), Some(b)) => { + let hull = c + .hull([a.0, b.0]) + .map_or(0., |h| h.iter().map(|x| point_segment(view.screen(p.apply(*x)), a.1, b.1)).fold(0., f64::max)); + point_segment(view.screen(p.apply(c.at(0.5 * (a.0 + b.0)))), a.1, b.1).max(hull) + } + _ => 0., + } + }) + .collect(); + t.samples = samples; + t.sags = sags; + } + stats.phase_ms[0] = clock.lap(); + // 3. Crossings between projected curves (and each curve with itself). + stats.crossings = crossings(&mut tracks, &ctx)?; + // Faces left out (freeform or with an unsupported trim) draw no silhouettes to cut at, so + // the curves are cut where they cross each such face's screen box instead, and below a + // piece inside a box and behind its nearest point is uncertain: it may be hidden. + let shadows = ShadowIndex::new(shadow_boxes(defs, scene, view, &unsupported))?; + let mut near = Vec::new(); + let mut checks = 0usize; + for (ti, t) in tracks.iter_mut().enumerate() { + if shadows.list.is_empty() { + break; + } + if ti % 64 == 0 && cancel() { + return Err(HlrError::Cancelled); + } + let Some(pl) = scene.get(t.placement) else { continue }; + let mut cuts = Vec::new(); + for w in t.samples.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + // The curve's own screen box between the two samples: it can reach past the chord, + // across it or along it (a view along an arc's plane folds it back on itself). + let (lo, hi) = screen_box(t.c3(), [a.0, b.0], pl, view); + shadows.query(lo, hi, &mut near); + checks = checks.saturating_add(near.len()); + if checks > 500_000_000 { + return Err(HlrError::Budget("shadow")); + } + for sh in near.iter().filter_map(|k| shadows.list.get(*k)) { + for (k, edge) in [(0usize, sh.lo[0]), (0, sh.hi[0]), (1, sh.lo[1]), (1, sh.hi[1])] { + let (u, v) = (a.1[k], b.1[k]); + if (u - edge) * (v - edge) < 0. { + // Where the curve itself crosses the side, not its chord. + let side = |x: f64| view.screen(pl.apply(t.c3().at(x))).get(k).copied().unwrap_or(0.) - edge; + let (mut lo, mut hi) = (a.0, b.0); + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + if (side(mid) < 0.) == (u - edge < 0.) { + lo = mid; + } else { + hi = mid; + } + } + cuts.push(0.5 * (lo + hi)); + } + } + // A curve that enters the box where its chord does not: its stretch becomes a + // piece of its own, judged below. + if overlaps(lo, hi, sh) && box_gap(a.1, b.1, sh.lo, sh.hi) > 0. { + cuts.push(a.0); + cuts.push(b.0); + } + } + } + t.splits.extend(cuts); + } + stats.phase_ms[1] = clock.lap(); + // 4. Classify pieces between splits. + let mut lines = Vec::with_capacity(tracks.len()); + for (ti, t) in tracks.iter().enumerate() { + if ti % 64 == 0 && cancel() { + return Err(HlrError::Cancelled); + } + let c = t.c3(); + let mut cuts = t.splits.clone(); + cuts.push(t.range[0]); + cuts.push(t.range[1]); + cuts.retain(|x| *x >= t.range[0] && *x <= t.range[1]); + cuts.sort_by(f64::total_cmp); + let span = t.range[1] - t.range[0]; + // Cuts merge when their parameters and their points are both close: nearby parameters + // alone do not tell (a knot span narrower than 1e-10 of the range can carry most of a + // circle), nor do equal points (a closed curve's two ends). + cuts.dedup_by(|a, b| *a == *b || ((*a - *b).abs() <= 1e-10 * span && c.at(*a).dist(c.at(*b)) <= 1e-9 * scale.max(1.))); + let mut pieces: Vec = Vec::new(); + for w in cuts.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + for (r, vis) in classify_range(t, a, b, &ctx)? { + let vis = if vis == Visibility::Visible && in_shadow(t, r, scene, view, &shadows) { Visibility::Uncertain } else { vis }; + match pieces.last_mut() { + Some(last) if last.visibility == vis && (last.t[1] - r[0]).abs() <= 1e-12 * span.max(1e-300) => last.t[1] = r[1], + _ => pieces.push(Piece { + t: r, + visibility: vis, + covered: false, + geometry: Vec::new(), + accuracy: t.acc, + points: Vec::new(), + sag: 0., + }), + } + } + } + let p = scene.get(t.placement).ok_or(HlrError::Invalid("placement"))?; + for piece in &mut pieces { + let mut pts = vec![view.screen(p.apply(c.at(piece.t[0])))]; + pts.extend(t.samples.iter().filter(|(x, _)| *x > piece.t[0] && *x < piece.t[1]).map(|(_, s)| *s)); + pts.push(view.screen(p.apply(c.at(piece.t[1])))); + piece.points = pts; + piece.sag = t + .samples + .windows(2) + .zip(&t.sags) + .filter(|(w, _)| w.get(1).is_some_and(|b| b.0 > piece.t[0]) && w.first().is_some_and(|a| a.0 < piece.t[1])) + .map(|(_, sag)| *sag) + .fold(0., f64::max); + } + stats.pieces += pieces.len(); + stats.uncertain_pieces += pieces.iter().filter(|p| p.visibility == Visibility::Uncertain).count(); + lines.push(Line { placement: t.placement, source: t.source, pieces }); + } + stats.phase_ms[2] = clock.lap(); + // Coverage is checked every 1e-4 of the scene (5 um to 0.5 mm): a gap narrower than that + // in a coincident line is not seen. + cover(&mut lines, 1e-7 + 1e-9 * scale, (1e-4 * scale).clamp(0.005, 0.5), cancel)?; + stats.phase_ms[3] = clock.lap(); + for p in lines.iter().flat_map(|l| l.pieces.iter()) { + stats.covered_pieces += usize::from(p.covered); + stats.uncertain_uncovered += usize::from(p.visibility == Visibility::Uncertain && !p.covered); + } + stats.rays = ctx.rays.get(); + stats.surface_ray_tests = ctx.surface_tests.get(); + stats.unexpected_transitions = ctx.transitions.get(); + // Output geometry, back in the caller's frame. + let shifted = |q: [f64; 2]| [q[0] + shift[0], q[1] + shift[1]]; + for (l, t) in lines.iter_mut().zip(&tracks) { + let p = scene.get(t.placement).ok_or(HlrError::Invalid("placement"))?; + for piece in &mut l.pieces { + let (geometry, accuracy) = piece_geometry(t, piece, p, view); + piece.geometry = geometry.into_iter().map(|g| shift_seg(g, shift)).collect(); + piece.accuracy = accuracy; + piece.points = piece.points.iter().map(|q| shifted(*q)).collect(); + } + } + Ok(Drawing { lines, unsupported, stats }) +} + +/// The centre of the placed bodies' boxes, after checking the placements. +/// A left-out face's screen box and the depth of its nearest corner. +struct Shadow { + lo: [f64; 2], + hi: [f64; 2], + near: f64, +} + +fn shadow_boxes(defs: &[Prepared], scene: &[Placement], view: &View, left_out: &[Unsupported]) -> Vec { + let mut out = Vec::new(); + for u in left_out { + let Some(p) = scene.get(u.placement) else { continue }; + let Some(f) = defs.get(p.body).and_then(|d| d.faces.get(u.face)) else { continue }; + let (mut lo, mut hi, mut near) = ([f64::MAX; 2], [f64::MIN; 2], f64::MAX); + for k in 0..8 { + let c = Vec3::new( + if k & 1 == 0 { f.lo.x } else { f.hi.x }, + if k & 2 == 0 { f.lo.y } else { f.hi.y }, + if k & 4 == 0 { f.lo.z } else { f.hi.z }, + ); + let w = p.apply(c); + let s = view.screen(w); + lo = [lo[0].min(s[0]), lo[1].min(s[1])]; + hi = [hi[0].max(s[0]), hi[1].max(s[1])]; + near = near.min(w.dot(view.d)); + } + out.push(Shadow { lo, hi, near }); + } + out +} + +/// Whether the piece `r` of a track may lie behind a left-out face, its own faces included (a +/// curled face can hide its own edge closer than the own-face margin). +fn in_shadow(t: &Track, r: [f64; 2], scene: &[Placement], view: &View, shadows: &ShadowIndex) -> bool { + if shadows.list.is_empty() { + return false; + } + let Some(p) = scene.get(t.placement) else { return false }; + let c = t.c3(); + // The piece is cut at the boxes' sides and where its curve enters a box between samples: + // it is in a box when its own screen box overlaps it and, for a piece holding samples, its + // middle or one of those samples lies in it (its ends may sit on the side it was cut at). + // Then it is in the shadow when its depth can pass the face's nearest point anywhere along + // it: the bound comes from the curve itself (its control points' hull, an arc's reach), + // not from samples, which a dip in depth that the screen does not show could fall between. + let (lo, hi) = screen_box(c, r, p, view); + let mut near = Vec::new(); + shadows.query(lo, hi, &mut near); + if near.is_empty() { + return false; + } + let inner: Vec<[f64; 2]> = t.samples.iter().filter(|q| q.0 > r[0] && q.0 < r[1]).map(|q| q.1).collect(); + let mid = view.screen(p.apply(c.at(0.5 * (r[0] + r[1])))); + let deepest = depth_bound(c, r, p, view.d); + near.iter().filter_map(|k| shadows.list.get(*k)).any(|sh| { + let within = |s: &[f64; 2]| s[0] >= sh.lo[0] && s[0] <= sh.hi[0] && s[1] >= sh.lo[1] && s[1] <= sh.hi[1]; + deepest > sh.near && overlaps(lo, hi, sh) && (inner.is_empty() || within(&mid) || inner.iter().any(within)) + }) +} + +/// Whether the box `lo hi` and a shadow's box share some area. +fn overlaps(lo: [f64; 2], hi: [f64; 2], sh: &Shadow) -> bool { + lo[0] < sh.hi[0] && hi[0] > sh.lo[0] && lo[1] < sh.hi[1] && hi[1] > sh.lo[1] +} + +/// The screen box of the curve's part `r` placed by `p`: a line's ends, an arc's ends and its +/// extremes along each screen axis, the projected control points a spline's part depends on +/// (they hold it), a polyline's vertices. +fn screen_box(c: &C3, r: [f64; 2], p: &Placement, view: &View) -> ([f64; 2], [f64; 2]) { + let s = |q: Vec3| view.screen(p.apply(q)); + let mut pts = vec![s(c.at(r[0])), s(c.at(r[1]))]; + match c { + C3::Line { .. } => {} + C3::Arc { x, y, r: rad, .. } => { + // On screen: centre + U cos t + V sin t; each axis peaks where tan t = V_k / U_k. + let lin = |w: Vec3| view.screen(p.apply(w) - p.translation); + let (u, v) = (lin(*x * rad[0]), lin(*y * rad[1])); + for k in 0..2 { + let (Some(&uk), Some(&vk)) = (u.get(k), v.get(k)) else { continue }; + // The peaks at base + k pi from the first one past the part's start, wherever + // its angles are counted from. + let (base, pi) = (vk.atan2(uk), std::f64::consts::PI); + let first = ((r[0] - base) / pi).ceil(); + for k in 0..8 { + let t = base + (first + k as f64) * pi; + if t >= r[1] { + break; + } + if t > r[0] { + pts.push(s(c.at(t))); + } + } + } + } + C3::Spline { degree, knots, points, .. } => match c.hull(r) { + Some(h) => pts.extend(h.into_iter().map(s)), + None => pts.extend( + points + .iter() + .enumerate() + .filter(|(i, h)| h[3] > 0. && knots.get(*i).zip(knots.get(i + degree + 1)).is_some_and(|(lo, hi)| *lo < r[1] && *hi > r[0])) + .map(|(_, h)| s(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]))), + ), + }, + C3::Polyline(v) => pts.extend(v.iter().enumerate().filter(|(i, _)| (*i as f64) > r[0] && (*i as f64) < r[1]).map(|(_, q)| s(*q))), + } + let lo = pts.iter().fold([f64::MAX; 2], |m, q| [m[0].min(q[0]), m[1].min(q[1])]); + let hi = pts.iter().fold([f64::MIN; 2], |m, q| [m[0].max(q[0]), m[1].max(q[1])]); + (lo, hi) +} + +/// Left-out faces' boxes on a uniform screen grid, so a curve meets only those near it. +struct ShadowIndex { + list: Vec, + lo: [f64; 2], + hi: [f64; 2], + cell: [f64; 2], + n: usize, + buckets: Vec>, +} + +impl ShadowIndex { + fn new(list: Vec) -> Result { + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for sh in &list { + lo = [lo[0].min(sh.lo[0]), lo[1].min(sh.lo[1])]; + hi = [hi[0].max(sh.hi[0]), hi[1].max(sh.hi[1])]; + } + let n = ((list.len() as f64).sqrt().ceil() as usize).clamp(1, 1024); + let cell = [((hi[0] - lo[0]) / n as f64).max(1e-12), ((hi[1] - lo[1]) / n as f64).max(1e-12)]; + let mut index = ShadowIndex { list: Vec::new(), lo, hi, cell, n, buckets: vec![Vec::new(); n * n] }; + let mut entries = 0usize; + for (k, sh) in list.iter().enumerate() { + let (a, b) = (index.cell_of(sh.lo), index.cell_of(sh.hi)); + entries = entries.saturating_add((b[0] - a[0] + 1).saturating_mul(b[1] - a[1] + 1)); + if entries > 50_000_000 { + return Err(HlrError::Budget("shadow grid")); + } + for y in a[1]..=b[1] { + for x in a[0]..=b[0] { + if let Some(bucket) = index.buckets.get_mut(y * n + x) { + bucket.push(k); + } + } + } + } + index.list = list; + Ok(index) + } + + fn cell_of(&self, p: [f64; 2]) -> [usize; 2] { + let f = |k: usize| (((p[k] - self.lo[k]) / self.cell[k]).floor().clamp(0., (self.n - 1) as f64)) as usize; + [f(0), f(1)] + } + + /// The boxes in the cells the box `lo hi` touches, each once. + fn query(&self, lo: [f64; 2], hi: [f64; 2], out: &mut Vec) { + out.clear(); + if self.list.is_empty() || hi[0] < self.lo[0] || hi[1] < self.lo[1] || lo[0] > self.hi[0] || lo[1] > self.hi[1] { + return; + } + let (a, b) = (self.cell_of(lo), self.cell_of(hi)); + for y in a[1]..=b[1] { + for x in a[0]..=b[0] { + if let Some(bucket) = self.buckets.get(y * self.n + x) { + out.extend_from_slice(bucket); + } + } + } + out.sort_unstable(); + out.dedup(); + } +} + +/// An upper bound of the depth along `d` of the curve's part `r` placed by `p`: a line's ends, +/// an arc's centre plus its reach, the projected control points a spline's part depends on +/// (its weights are positive, so the part lies in their hull), a polyline's vertices. +fn depth_bound(c: &C3, r: [f64; 2], p: &Placement, d: Vec3) -> f64 { + let depth = |q: Vec3| p.apply(q).dot(d); + match c { + C3::Line { .. } => depth(c.at(r[0])).max(depth(c.at(r[1]))), + C3::Arc { o, x, y, r: rad, .. } => { + let dl = p.local_dir(d); + depth(*o) + (rad[0] * x.dot(dl)).hypot(rad[1] * y.dot(dl)) + } + C3::Spline { degree, knots, points, .. } => match c.hull(r) { + Some(h) => h.into_iter().map(depth).fold(f64::MIN, f64::max), + None => points + .iter() + .enumerate() + .filter(|(i, h)| h[3] > 0. && knots.get(*i).zip(knots.get(i + degree + 1)).is_some_and(|(lo, hi)| *lo < r[1] && *hi > r[0])) + .map(|(_, h)| depth(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]))) + .fold(f64::MIN, f64::max), + } + .max(depth(c.at(r[0]))) + .max(depth(c.at(r[1]))), + C3::Polyline(pts) => pts + .iter() + .enumerate() + .filter(|(i, _)| (*i as f64) > r[0] && (*i as f64) < r[1]) + .map(|(_, q)| depth(*q)) + .fold(depth(c.at(r[0])).max(depth(c.at(r[1]))), f64::max), + } +} + +/// Distance between the segment `a b` and the box `lo hi` (zero when they meet). +fn box_gap(a: [f64; 2], b: [f64; 2], lo: [f64; 2], hi: [f64; 2]) -> f64 { + let inside = |p: [f64; 2]| p[0] >= lo[0] && p[0] <= hi[0] && p[1] >= lo[1] && p[1] <= hi[1]; + if inside(a) || inside(b) { + return 0.; + } + let corners = [[lo[0], lo[1]], [hi[0], lo[1]], [hi[0], hi[1]], [lo[0], hi[1]]]; + let crosses = (0..4).any(|k| { + let (Some(c), Some(d)) = (corners.get(k), corners.get((k + 1) % 4)) else { return false }; + let side = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]); + side(a, b, *c) * side(a, b, *d) <= 0. && side(*c, *d, a) * side(*c, *d, b) <= 0. + }); + if crosses { + return 0.; + } + let to_box = |p: [f64; 2]| { + let dx = (lo[0] - p[0]).max(p[0] - hi[0]).max(0.); + let dy = (lo[1] - p[1]).max(p[1] - hi[1]).max(0.); + dx.hypot(dy) + }; + let corner_gap = corners.iter().map(|q| point_segment(*q, a, b)).fold(f64::MAX, f64::min); + to_box(a).min(to_box(b)).min(corner_gap) +} + +fn scene_centre(defs: &[Prepared], scene: &[Placement]) -> Result { + let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)); + for p in scene { + if !p.rigid() { + return Err(HlrError::Invalid("placement (not a rotation and translation)")); + } + let def = defs.get(p.body).ok_or(HlrError::Invalid("placement body index"))?; + for k in 0..8 { + let c = Vec3::new( + if k & 1 == 0 { def.lo.x } else { def.hi.x }, + if k & 2 == 0 { def.lo.y } else { def.hi.y }, + if k & 4 == 0 { def.lo.z } else { def.hi.z }, + ); + let w = p.apply(c + def.offset); + if !(w.x.abs() <= MAX_COORDINATE && w.y.abs() <= MAX_COORDINATE && w.z.abs() <= MAX_COORDINATE) { + return Err(HlrError::Invalid("scene extent")); + } + lo = lo.min(w); + hi = hi.max(w); + } + } + let c = if scene.is_empty() { Vec3::ZERO } else { (lo + hi) * 0.5 }; + if c.is_finite() { Ok(c) } else { Err(HlrError::Invalid("scene extent")) } +} + +fn shift_seg(g: Seg2, d: [f64; 2]) -> Seg2 { + let m = |v: Vec2| Vec2::new(v.x + d[0], v.y + d[1]); + match g { + Seg2::Line { a, b } => Seg2::Line { a: m(a), b: m(b) }, + Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: m(center), radius, start, sweep }, + Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: m(p0), p1: m(p1), p2: m(p2), p3: m(p3) }, + Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: m(a), apex: m(apex), b: m(b), w }, + Seg2::Bezier { n, p } => Seg2::Bezier { n, p: p.map(m) }, + } +} + +/// A piece's screen geometry: a line for a straight source, exact conic arcs for a circle or +/// ellipse (an orthographic projection is affine, so it maps them to ellipses exactly), chords +/// between its exact points otherwise. +fn piece_geometry(t: &Track, piece: &Piece, p: &Placement, view: &View) -> (Vec, CurveAccuracy) { + let c = t.c3(); + let s = |q: Vec3| { + let v = view.screen(p.apply(q)); + Vec2::new(v[0], v[1]) + }; + let chords = || { + let segs: Vec = + piece.points.windows(2).filter_map(|w| Some(Seg2::Line { a: Vec2::from(*w.first()?), b: Vec2::from(*w.get(1)?) })).collect(); + (segs, CurveAccuracy::Sampled { max_sample_deviation_mm: piece.sag }) + }; + match c { + C3::Line { .. } if t.acc == CurveAccuracy::Analytic => { + (vec![Seg2::Line { a: s(c.at(piece.t[0])), b: s(c.at(piece.t[1])) }], CurveAccuracy::Analytic) + } + _ if t.acc == CurveAccuracy::Analytic => { + // A circle or ellipse: o + u cos(a) + v sin(a), sampled at the piece's angles. + let frame = match c { + C3::Arc { o, x, y, r, .. } => Some((*o, *x * r[0], *y * r[1], true)), + _ => as_circle(c, 1e-9).and_then(|(centre, normal, r)| { + let e1 = (c.at(t.range[0]) - centre).normalized()?; + Some((centre, e1 * r, normal.cross(e1) * r, false)) + }), + }; + let Some((o, u, v, param_is_angle)) = frame else { return chords() }; + let angle = |x: f64| { + if param_is_angle { + return x; + } + let w = c.at(x) - o; + w.dot(v).atan2(w.dot(u)) + }; + // The angle turned from the piece's start to its end: the way it turns, certified + // all along it (C3::turn_sign; a piece that turns back, or one that cannot be + // certified, is drawn as chords), and its end angles fix the turn, however unevenly + // it is parameterised. A whole closed edge turns a full circle. + let (a0, a1) = if param_is_angle { + (piece.t[0], piece.t[1]) + } else { + let tau = std::f64::consts::TAU; + let wrap = |d: f64| (d + std::f64::consts::PI).rem_euclid(tau) - std::f64::consts::PI; + let (t0, t1) = (piece.t[0], piece.t[1]); + let span = (t.range[1] - t.range[0]).abs(); + let a0 = angle(t0); + let ends = angle(t1) - a0; + let dir = match c.turn_sign([t0, t1], o, u, v) { + Some(d) if d != 0. => d, + // It does not turn at all: a point-like piece, unless its ends differ. + Some(_) if wrap(ends).abs() <= 1e-9 => 1., + _ => return chords(), + }; + let mut turn = (dir * ends).rem_euclid(tau); + let whole = + t0 <= t.range[0] + 1e-12 * span && t1 >= t.range[1] - 1e-12 * span && c.at(t.range[0]).dist(c.at(t.range[1])) <= 1e-9 * u.len(); + if whole && (turn < 1e-9 || turn > tau - 1e-9) { + turn = tau; + } + (a0, a0 + dir * turn) + }; + let pieces = ((a1 - a0).abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.) as usize; + let at = |a: f64| o + u * a.cos() + v * a.sin(); + let mut out = Vec::with_capacity(pieces); + for k in 0..pieces { + let (b0, b1) = (a0 + (a1 - a0) * k as f64 / pieces as f64, a0 + (a1 - a0) * (k + 1) as f64 / pieces as f64); + let (m, h) = (0.5 * (b0 + b1), 0.5 * (b1 - b0)); + let w = h.cos(); + if w <= 1e-12 { + return chords(); + } + out.push(Seg2::Conic { a: s(at(b0)), apex: s(o + (u * m.cos() + v * m.sin()) / w), b: s(at(b1)), w }); + } + (out, CurveAccuracy::Analytic) + } + _ => chords(), + } +} + +/// Finds where projected tracks cross or touch and records the split parameters on both. +fn crossings(tracks: &mut [Track], ctx: &Ctx) -> Result { + struct Seg { + track: usize, + i: usize, + a: [f64; 2], + b: [f64; 2], + sag: f64, + /// The grid cells it was put in: first and last column and row. + cells: [usize; 4], + } + let mut segs = Vec::new(); + // Where each track's segments start in `segs`. + let mut first_seg = Vec::with_capacity(tracks.len()); + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for (ti, t) in tracks.iter().enumerate() { + first_seg.push(segs.len()); + for (i, w) in t.samples.windows(2).enumerate() { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + for p in [a.1, b.1] { + lo = [lo[0].min(p[0]), lo[1].min(p[1])]; + hi = [hi[0].max(p[0]), hi[1].max(p[1])]; + } + segs.push(Seg { track: ti, i, a: a.1, b: b.1, sag: t.sags.get(i).copied().unwrap_or(0.), cells: [0; 4] }); + } + } + if segs.is_empty() { + return Ok(0); + } + let n = ((segs.len() as f64).sqrt().ceil() as usize).clamp(1, 512); + let cell = [((hi[0] - lo[0]) / n as f64).max(1e-12), ((hi[1] - lo[1]) / n as f64).max(1e-12)]; + let idx = |p: [f64; 2]| -> (usize, usize) { + (((p[0] - lo[0]) / cell[0]).floor().clamp(0., (n - 1) as f64) as usize, ((p[1] - lo[1]) / cell[1]).floor().clamp(0., (n - 1) as f64) as usize) + }; + let mut grid: Vec> = vec![Vec::new(); n * n]; + let mut entries = 0usize; + for (si, s) in segs.iter_mut().enumerate() { + if si % 65536 == 0 && (ctx.cancel)() { + return Err(HlrError::Cancelled); + } + let g = s.sag + 1e-9 * ctx.scale; + let (a, b) = (idx([s.a[0].min(s.b[0]) - g, s.a[1].min(s.b[1]) - g]), idx([s.a[0].max(s.b[0]) + g, s.a[1].max(s.b[1]) + g])); + entries = entries.saturating_add((b.0 - a.0 + 1).saturating_mul(b.1 - a.1 + 1)); + if entries > 50_000_000 { + return Err(HlrError::Budget("crossing grid")); + } + s.cells = [a.0, a.1, b.0, b.1]; + for y in a.1..=b.1 { + for x in a.0..=b.0 { + if let Some(c) = grid.get_mut(y * n + x) { + c.push(si); + } + } + } + } + // A pair of segments is visited once per cell they share, so the number of visits is + // known before the search starts, and no set of pairs already met has to be kept. + let pairs = grid.iter().fold(0usize, |n, c| n.saturating_add(c.len().saturating_mul(c.len().saturating_sub(1)) / 2)); + if pairs > 500_000_000 { + return Err(HlrError::Budget("crossing pair")); + } + let tol = 1e-9 * ctx.scale; + let mut found = Vec::new(); + let mut visits = 0usize; + // Two chords that lie on each other end to end, to rounding. + let coincide = |s: &Seg, t: &Seg| { + [point_segment(s.a, t.a, t.b), point_segment(s.b, t.a, t.b), point_segment(t.a, s.a, s.b), point_segment(t.b, s.a, s.b)] + .iter() + .all(|d| *d <= tol) + }; + // The segment of the same track on the other side of a segment's end `f` (0 or 1). + let across = |s: &Seg, f: f64| -> Option<&Seg> { + let i = if f == 1. { s.i + 1 } else { s.i.checked_sub(1)? }; + segs.get(first_seg.get(s.track)? + i).filter(|n| n.track == s.track && n.i == i) + }; + // Two curves that coincide meet at every sample point they share, chord ends touching. Such + // a meeting is no crossing when the curves also coincide on both sides of it; where a + // coincident run starts or ends, one side differs and the meeting is kept. + // The end (0 or 1) of a segment that fraction `f` along it lies at, to rounding. + let end_at = |s: &Seg, f: f64| { + let len = (s.b[0] - s.a[0]).hypot(s.b[1] - s.a[1]); + if f * len <= tol { + Some(0.) + } else if (1. - f) * len <= tol { + Some(1.) + } else { + None + } + }; + let shared_point = |s: &Seg, t: &Seg, (u, v): (f64, f64)| { + end_at(s, u) + .zip(end_at(t, v)) + .and_then(|(fu, fv)| across(s, fu).zip(across(t, fv))) + .is_some_and(|(s2, t2)| coincide(s, t2) && coincide(s2, t)) + }; + // Screen points of a segment's exact curve, `k` pieces (one for lines and polylines). + let sub = |track: usize, i: usize, k: usize| -> Vec<[f64; 2]> { + let Some(tr) = tracks.get(track) else { return Vec::new() }; + let (Some(a), Some(b)) = (tr.samples.get(i), tr.samples.get(i + 1)) else { return Vec::new() }; + let Some(p) = ctx.scene.get(tr.placement) else { return Vec::new() }; + let k = if matches!(tr.c3(), C3::Line { .. } | C3::Polyline(_)) { 1 } else { k }; + (0..=k).map(|j| ctx.view.screen(p.apply(tr.c3().at(a.0 + (b.0 - a.0) * j as f64 / k as f64)))).collect() + }; + for (c, cellv) in grid.iter().enumerate() { + let (x, y) = (c % n, c / n); + for (k, &i) in cellv.iter().enumerate() { + for &j in cellv.iter().skip(k + 1) { + visits += 1; + if visits.is_multiple_of(65536) && (ctx.cancel)() { + return Err(HlrError::Cancelled); + } + let (Some(s), Some(t)) = (segs.get(i), segs.get(j)) else { continue }; + // A pair sharing several cells is intersected in the first of them only. + if s.cells[0].max(t.cells[0]) != x || s.cells[1].max(t.cells[1]) != y { + continue; + } + if s.track == t.track && s.i.abs_diff(t.i) <= 1 { + continue; + } + let straight = |k: usize| tracks.get(k).is_some_and(|tr| matches!(tr.c3(), C3::Line { .. })); + let overlaps = straight(s.track) && straight(t.track); + let hits = seg_hits(s.a, s.b, t.a, t.b, tol, overlaps); + let reach = s.sag + t.sag + tol; + // Chords of two curves that lie on each other end to end, to rounding (one curve + // projected again, aligned holes seen along their axes), cross only within that + // rounding, everywhere and nowhere in particular: visibility changes where such a + // run starts or ends, which chords that only partly overlap find. Intersecting + // their fine pieces is wasted work. Chords merely within each other's sag are not + // skipped: the curves between them can still cross. + let coincident = s.track != t.track && s.sag + t.sag > 0. && coincide(s, t); + if coincident { + continue; + } + if hits.is_empty() && s.sag + t.sag > 0. && seg_gap(s.a, s.b, t.a, t.b) <= reach { + // The chords miss but the curves may not: two crossings close together can + // lie within one chord's sag. Intersect finer pieces of the exact curves. + let (ps, pt) = (sub(s.track, s.i, 16), sub(t.track, t.i, 16)); + let (ns, nt) = (ps.len().saturating_sub(1).max(1) as f64, pt.len().saturating_sub(1).max(1) as f64); + for (x, w) in ps.windows(2).enumerate() { + for (y, z) in pt.windows(2).enumerate() { + let (Some(a), Some(b), Some(c), Some(d)) = (w.first(), w.get(1), z.first(), z.get(1)) else { continue }; + for (u, v) in seg_hits(*a, *b, *c, *d, tol, false) { + found.push((s.track, s.i, (x as f64 + u) / ns, t.track, t.i, (y as f64 + v) / nt)); + } + } + } + } else { + let curved = s.track != t.track && s.sag + t.sag > 0.; + for (u, v) in hits { + if !(curved && shared_point(s, t, (u, v))) { + found.push((s.track, s.i, u, t.track, t.i, v)); + } + } + } + if found.len() > 50_000_000 { + return Err(HlrError::Budget("crossing")); + } + } + } + } + // Curves that nearly coincide in projection (two fitted copies of one intersection, say) + // cross each other again and again within their tolerance; visibility changes only where + // the coincidence starts or ends, so such a pair keeps only its outermost crossings. + let mut by_pair: std::collections::BTreeMap<(usize, usize), Vec<(usize, usize, f64, usize, usize, f64)>> = std::collections::BTreeMap::new(); + for c in found { + by_pair.entry((c.0.min(c.3), c.0.max(c.3))).or_default().push(c); + } + let mut found = Vec::new(); + for (_, mut list) in by_pair { + if list.len() > 8 { + list.sort_by(|a, b| (a.1 as f64 + a.2).total_cmp(&(b.1 as f64 + b.2))); + // Collapse only when the curves stay within their sag of each other between every + // pair of consecutive crossings (one curve seen twice); a curve that weaves across + // another keeps all its crossings. + let together = list.windows(2).all(|w| { + let (Some(x), Some(y)) = (w.first(), w.get(1)) else { return false }; + let (Some(ta), Some(tb)) = (tracks.get(x.0), tracks.get(x.3)) else { return false }; + let mid = 0.5 * (sample_param(tracks, x.0, x.1, x.2) + sample_param(tracks, y.0, y.1, y.2)); + let Some(pa) = ctx.scene.get(ta.placement) else { return false }; + let q = ctx.view.screen(pa.apply(ta.c3().at(mid))); + let sag = |t: &Track| t.sags.iter().fold(0., |m: f64, v| m.max(*v)); + let reach = 4. * (sag(ta) + sag(tb)) + tol; + // Only the other curve's chords between its two crossings (and one either side). + let (j0, j1) = (x.4.min(y.4).saturating_sub(1), x.4.max(y.4) + 2); + tb.samples.get(j0..j1.min(tb.samples.len())).is_some_and(|run| { + run.windows(2).any(|w| match (w.first(), w.get(1)) { + (Some(a), Some(b)) => point_segment(q, a.1, b.1) <= reach, + _ => false, + }) + }) + }); + if together { + let (Some(first), Some(last)) = (list.first().copied(), list.last().copied()) else { continue }; + list = vec![first, last]; + } + } + found.extend(list); + } + let count = found.len(); + for (ta, ia, u, tb, ib, v) in found { + let pa = sample_param(tracks, ta, ia, u); + let pb = sample_param(tracks, tb, ib, v); + let (pa, pb) = refine(tracks, ctx, ta, pa, tb, pb); + if let Some(t) = tracks.get_mut(ta) { + t.splits.push(pa); + } + if let Some(t) = tracks.get_mut(tb) { + t.splits.push(pb); + } + } + Ok(count) +} + +fn sample_param(tracks: &[Track], t: usize, i: usize, f: f64) -> f64 { + let Some(tr) = tracks.get(t) else { return 0. }; + match (tr.samples.get(i), tr.samples.get(i + 1)) { + (Some(a), Some(b)) => a.0 + (b.0 - a.0) * f, + _ => tr.range[0], + } +} + +/// Newton refinement of a crossing on the exact projected curves; keeps the linear estimate +/// when the curves are tangent or the iteration leaves the curves' ranges. +fn refine(tracks: &[Track], ctx: &Ctx, ta: usize, sa: f64, tb: usize, sb: f64) -> (f64, f64) { + let (Some(a), Some(b)) = (tracks.get(ta), tracks.get(tb)) else { return (sa, sb) }; + let (Some(pa), Some(pb)) = (ctx.scene.get(a.placement), ctx.scene.get(b.placement)) else { return (sa, sb) }; + if matches!(a.c3(), C3::Polyline(_)) || matches!(b.c3(), C3::Polyline(_)) { + return (sa, sb); + } + let fa = |s: f64| ctx.view.screen(pa.apply(a.c3().at(s))); + let fb = |s: f64| ctx.view.screen(pb.apply(b.c3().at(s))); + let da = |s: f64| ctx.view.screen(pa.apply(a.c3().d1(s)) - pa.translation); + let db = |s: f64| ctx.view.screen(pb.apply(b.c3().d1(s)) - pb.translation); + let (mut s, mut t) = (sa, sb); + for _ in 0..30 { + let (p, q) = (fa(s), fb(t)); + let f = [p[0] - q[0], p[1] - q[1]]; + let (ja, jb) = (da(s), db(t)); + // [ja, -jb] [ds, dt]^T = -f + let det = ja[0] * (-jb[1]) - (-jb[0]) * ja[1]; + if det.abs() <= 1e-14 * (1. + ja[0].abs() + ja[1].abs()) * (1. + jb[0].abs() + jb[1].abs()) { + return (sa, sb); + } + let ds = (-f[0] * (-jb[1]) - (-jb[0]) * (-f[1])) / det; + let dt = (ja[0] * (-f[1]) - (-f[0]) * ja[1]) / det; + s += ds; + t += dt; + if ds.abs() + dt.abs() <= 1e-15 * (1. + s.abs() + t.abs()) { + break; + } + } + let ok = |x: f64, r: [f64; 2]| x.is_finite() && x >= r[0] - 1e-12 * (r[1] - r[0]) && x <= r[1] + 1e-12 * (r[1] - r[0]); + let close = (s - sa).abs() <= 0.5 * (a.range[1] - a.range[0]) && (t - sb).abs() <= 0.5 * (b.range[1] - b.range[0]); + if ok(s, a.range) && ok(t, b.range) && close { (s.clamp(a.range[0], a.range[1]), t.clamp(b.range[0], b.range[1])) } else { (sa, sb) } +} + +/// Marks hidden and uncertain pieces lying within `tol` of a visible piece of another line. +fn cover(lines: &mut [Line], tol: f64, step: f64, cancel: &dyn Fn() -> bool) -> Result<(), HlrError> { + // Chords of visible and hidden pieces, keyed by (line, piece) so a piece never covers + // itself; another piece of the same line may (a circle seen edge-on folds over itself). + // `tol` is the base; each chord adds its own sag, each query point the sag of its chord. + let mut segs: Vec<((usize, usize), Visibility, [f64; 2], [f64; 2], f64)> = Vec::new(); + for (li, l) in lines.iter().enumerate() { + for (pi, p) in l.pieces.iter().enumerate().filter(|(_, p)| p.visibility != Visibility::Uncertain) { + segs.extend(p.points.windows(2).filter_map(|w| Some(((li, pi), p.visibility, *w.first()?, *w.get(1)?, p.sag)))); + } + } + let reach = segs.iter().map(|s| s.4).fold(0., f64::max) * 2. + tol; + if segs.is_empty() { + return Ok(()); + } + // Bucket the chords on a grid of cells at least `tol` wide. + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for (_, _, a, b, _) in &segs { + for p in [a, b] { + lo = [lo[0].min(p[0]), lo[1].min(p[1])]; + hi = [hi[0].max(p[0]), hi[1].max(p[1])]; + } + } + let n = ((segs.len() as f64).sqrt().ceil() as usize).clamp(1, 1024); + let cell = [((hi[0] - lo[0]) / n as f64).max(reach), ((hi[1] - lo[1]) / n as f64).max(reach)]; + let at = |x: f64, k: usize| (((x - lo[k]) / cell[k]).floor().max(0.) as usize).min(n - 1); + let mut grid: Vec> = vec![Vec::new(); n * n]; + for (i, (_, _, a, b, _)) in segs.iter().enumerate() { + for y in at(a[1].min(b[1]) - reach, 1)..=at(a[1].max(b[1]) + reach, 1) { + for x in at(a[0].min(b[0]) - reach, 0)..=at(a[0].max(b[0]) + reach, 0) { + if let Some(c) = grid.get_mut(y * n + x) { + c.push(i); + } + } + } + } + // Which coincident pieces hide a piece: visible ones hide hidden and uncertain pieces; + // a duplicate of a visible or hidden piece on an earlier line is dropped. + let hides = |key: (usize, usize), own: Visibility, other: (usize, usize), state: Visibility| { + other != key + && match (own, state) { + (Visibility::Hidden | Visibility::Uncertain, Visibility::Visible) => true, + (Visibility::Visible, Visibility::Visible) | (Visibility::Hidden, Visibility::Hidden) => other.0 < key.0, + _ => false, + } + }; + let checks = std::cell::Cell::new(0usize); + let near = |key: (usize, usize), own: Visibility, q: [f64; 2], slack: f64| { + checks.set(checks.get() + 1); + let Some(bucket) = grid.get(at(q[1], 1) * n + at(q[0], 0)) else { return false }; + bucket + .iter() + .filter_map(|i| segs.get(*i)) + .any(|(k, state, a, b, sag)| hides(key, own, *k, *state) && point_segment(q, *a, *b) <= tol + sag + slack) + }; + for (li, l) in lines.iter_mut().enumerate() { + for (pi, p) in l.pieces.iter_mut().enumerate() { + // Points along every chord at most `step` apart (a coincident line may cover a long + // chord's ends and middle yet leave gaps between them). Points between the exact + // ones lie on the chord, up to the piece's sag from its curve. + let mut covered = !p.points.is_empty(); + if checks.get() > 200_000_000 { + return Err(HlrError::Budget("coverage")); + } + if pi % 16 == 0 && cancel() { + return Err(HlrError::Cancelled); + } + for w in p.points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + let k = ((a[0] - b[0]).hypot(a[1] - b[1]) / step).ceil().clamp(1., 1e6) as usize; + covered = covered + && (0..=k).all(|j| { + let f = j as f64 / k as f64; + // The piece's own representation error (chord sag, fitted bound) applies + // to every point it is checked at. + let slack = p.sag; + near((li, pi), p.visibility, [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f], slack) + }); + if !covered { + break; + } + } + if let [q] = p.points.as_slice() { + covered = near((li, pi), p.visibility, *q, p.sag); + } + p.covered = covered; + } + } + Ok(()) +} + +fn point_segment(q: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let l2 = ex * ex + ey * ey; + let t = if l2 > 0. { (((q[0] - a[0]) * ex + (q[1] - a[1]) * ey) / l2).clamp(0., 1.) } else { 0. }; + (a[0] + t * ex - q[0]).hypot(a[1] + t * ey - q[1]) +} + +/// Smallest distance between two screen segments that do not cross. +fn seg_gap(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> f64 { + point_segment(a, c, d).min(point_segment(b, c, d)).min(point_segment(c, a, b)).min(point_segment(d, a, b)) +} + +/// Intersections of two screen segments, touching included: fractions along each. +fn seg_hits(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2], tol: f64, overlaps: bool) -> Vec<(f64, f64)> { + let r = [b[0] - a[0], b[1] - a[1]]; + let s = [d[0] - c[0], d[1] - c[1]]; + let den = r[0] * s[1] - r[1] * s[0]; + let w = [c[0] - a[0], c[1] - a[1]]; + let (rl, sl) = ((r[0] * r[0] + r[1] * r[1]).sqrt(), (s[0] * s[0] + s[1] * s[1]).sqrt()); + if rl <= 0. || sl <= 0. { + return Vec::new(); + } + if den.abs() > 1e-12 * rl * sl { + let u = (w[0] * s[1] - w[1] * s[0]) / den; + let v = (w[0] * r[1] - w[1] * r[0]) / den; + let (tu, tv) = (tol / rl, tol / sl); + if u >= -tu && u <= 1. + tu && v >= -tv && v <= 1. + tv { + return vec![(u.clamp(0., 1.), v.clamp(0., 1.))]; + } + return Vec::new(); + } + // Parallel: overlapping collinear lines split at each other's endpoints. Sampled curves + // that coincide are skipped: their chords overlap everywhere and would split at every sample. + let dist = (w[0] * r[1] - w[1] * r[0]).abs() / rl; + if dist > tol || !overlaps { + return Vec::new(); + } + let along = |p: [f64; 2]| ((p[0] - a[0]) * r[0] + (p[1] - a[1]) * r[1]) / (rl * rl); + let back = |p: [f64; 2]| ((p[0] - c[0]) * s[0] + (p[1] - c[1]) * s[1]) / (sl * sl); + let mut out = Vec::new(); + for (p, on_cd) in [(c, true), (d, true), (a, false), (b, false)] { + let (u, v) = if on_cd { (along(p), if p == c { 0. } else { 1. }) } else { (if p == a { 0. } else { 1. }, back(p)) }; + if (0. ..=1.).contains(&u) && (0. ..=1.).contains(&v) { + out.push((u, v)); + } + } + out +} + +/// States at a few interior points, with transitions located by bisection. +fn classify_range(t: &Track, a: f64, b: f64, ctx: &Ctx) -> Result, HlrError> { + let fr = [0.1, 0.3, 0.5, 0.7, 0.9]; + let xs: Vec = fr.iter().map(|f| a + (b - a) * f).collect(); + let mut states = Vec::with_capacity(xs.len()); + for x in &xs { + states.push(classify(t, *x, ctx)?); + } + let mut out: Vec<([f64; 2], Visibility)> = Vec::new(); + let mut start = a; + for k in 0..xs.len() { + let (Some(&s), Some(&x)) = (states.get(k), xs.get(k)) else { continue }; + if let (Some(&next), Some(&nx)) = (states.get(k + 1), xs.get(k + 1)) { + if next != s { + ctx.transitions.set(ctx.transitions.get() + 1); + let (mut lo, mut hi) = (x, nx); + for _ in 0..40 { + let mid = 0.5 * (lo + hi); + if classify(t, mid, ctx)? == s { + lo = mid; + } else { + hi = mid; + } + } + let cut = 0.5 * (lo + hi); + out.push(([start, cut], s)); + start = cut; + } + } else { + out.push(([start, b], s)); + } + let _ = x; + } + Ok(out) +} + +fn classify(t: &Track, x: f64, ctx: &Ctx) -> Result { + let n = ctx.rays.get() + 1; + ctx.rays.set(n); + if n > ctx.max_rays { + return Err(HlrError::Budget("ray")); + } + if n.is_multiple_of(1024) && (ctx.cancel)() { + return Err(HlrError::Cancelled); + } + let own = ctx.scene.get(t.placement).ok_or(HlrError::Invalid("placement"))?; + Ok(classify_point(ctx, t.placement, &t.own, t.own_eps, own.apply(t.c3().at(x)))) +} + +/// Visibility of a world point on placement `placement` lying on its faces `own`. +fn classify_point(ctx: &Ctx, placement: usize, own: &[usize], own_eps: f64, world: Vec3) -> Visibility { + let screen = ctx.view.screen(world); + let depth = world.dot(ctx.view.d); + let toward = -ctx.view.d; + let mut uncertain = false; + let mut hits: Vec = Vec::new(); + for (qi, q) in ctx.scene.iter().enumerate() { + let Some((slo, shi, dmin)) = ctx.boxes.get(qi) else { continue }; + let pad = ctx.eps_other; + if screen[0] < slo[0] - pad || screen[0] > shi[0] + pad || screen[1] < slo[1] - pad || screen[1] > shi[1] + pad || *dmin > depth { + continue; + } + let Some(def) = ctx.defs.get(q.body) else { continue }; + let (p0, dl) = (q.local(world), q.local_dir(toward)); + for (fi, f) in def.faces.iter().enumerate() { + let is_own = qi == placement && own.contains(&fi); + let Some([l0, l1]) = slab(p0, dl, f.lo, f.hi) else { continue }; + let eps = if is_own { own_eps } else { ctx.eps_other }; + let range = [l0.max(eps), l1]; + if !(range[1] >= range[0]) { + continue; + } + ctx.surface_tests.set(ctx.surface_tests.get() + 1); + hits.clear(); + f.surface.ray_as(&f.analytic, p0, dl, range, &mut hits); + for h in &hits { + let p = f.surface.point(h.uv); + let tol = 2. * f.trim.chord_error + 1e-7 * (f.hi - f.lo).len(); + // A hit on the boundary is uncertain on either side of it, so the parity test is + // left out there: its cost grows with the boundary around the hit, all of a + // circle when bodies in line along the view meet each other on their circles. + if f.trim.near(p, tol) { + uncertain = true; + continue; + } + match f.trim.contains(h.uv) { + None => uncertain = true, + Some(inside) => { + // An unsupported freeform face never hides anything for certain. + let unsure = f.freeform && !ctx.freeform; + if inside && (unsure || h.grazing || (!is_own && h.lam < ctx.eps_own)) { + uncertain = true; + } else if inside { + return Visibility::Hidden; + } + } + } + } + } + } + if uncertain { Visibility::Uncertain } else { Visibility::Visible } +} + +/// Phase timing where a clock exists. +struct Clock { + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + last: std::cell::Cell, +} + +impl Clock { + fn start() -> Clock { + Clock { + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + last: std::cell::Cell::new(std::time::Instant::now()), + } + } + fn lap(&self) -> f64 { + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + let now = std::time::Instant::now(); + let ms = now.duration_since(self.last.get()).as_secs_f64() * 1e3; + self.last.set(now); + ms + } + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + 0. + } +} + +/// Ray parameter range inside an axis-aligned box. +fn slab(p: Vec3, d: Vec3, lo: Vec3, hi: Vec3) -> Option<[f64; 2]> { + let (mut t0, mut t1) = (f64::NEG_INFINITY, f64::INFINITY); + for (o, dd, a, b) in [(p.x, d.x, lo.x, hi.x), (p.y, d.y, lo.y, hi.y), (p.z, d.z, lo.z, hi.z)] { + if dd.abs() < 1e-300 { + if o < a || o > b { + return None; + } + } else { + let (x, y) = ((a - o) / dd, (b - o) / dd); + t0 = t0.max(x.min(y)); + t1 = t1.min(x.max(y)); + } + } + (t1 >= t0.max(0.)).then_some([t0.max(0.), t1]) +} + +#[cfg(test)] +#[path = "../hlr_tests.rs"] +mod tests; diff --git a/crates/geom/src/hlr/silhouette.rs b/crates/geom/src/hlr/silhouette.rs new file mode 100644 index 0000000000..af14e13323 --- /dev/null +++ b/crates/geom/src/hlr/silhouette.rs @@ -0,0 +1,347 @@ +//! Silhouettes of curved faces: the curves where the surface normal is perpendicular to the +//! direction of sight. Recognised cylinders and cones give exact lines and spheres an exact +//! circle; other surfaces are contoured on a chart grid, each point refined onto the curve, +//! and the deviation of the chords from the curve is measured. + +use super::curve::C3; +use super::surface::{Analytic, S3}; +use super::trim::Trim; +use crate::Vec3; +use crate::section::CurveAccuracy; + +const TAU: f64 = std::f64::consts::TAU; + +pub(crate) struct Sil { + pub curve: C3, + pub acc: CurveAccuracy, + /// Parameter ranges of `curve` that lie inside the face. + pub ranges: Vec<[f64; 2]>, +} + +fn perp(a: Vec3) -> Vec3 { + a.any_perp().normalized().unwrap_or(Vec3::new(1., 0., 0.)) +} + +/// Axial extent of the faces' boundaries relative to `p` along `a`. +fn extent(parts: &[(&S3, &Trim)], p: Vec3, a: Vec3) -> (f64, f64) { + parts.iter().flat_map(|(_, trim)| trim.chords.iter()).flat_map(|(x, y)| [*x, *y]).fold((f64::MAX, f64::MIN), |(lo, hi), q| { + let t = (q - p).dot(a); + (lo.min(t), hi.max(t)) + }) +} + +/// Silhouettes of a set of faces on one surface (`parts`: each face's chart and trim, all +/// recognised as `an`): traced once and kept where they lie on any of the faces, so a +/// silhouette along a seam between them is one curve. +pub(crate) fn silhouettes(parts: &[(&S3, &Trim)], an: Analytic, d: Vec3, out: &mut Vec) { + let Some(&(s, trim)) = parts.first() else { return }; + let mut push = |curve: C3, acc: CurveAccuracy| { + // A traced curve's chords stand up to their deviation off the surface. + let slack = match acc { + CurveAccuracy::Sampled { max_sample_deviation_mm: dev } => dev, + _ => 0., + }; + let mut ranges: Vec<[f64; 2]> = parts.iter().flat_map(|(s, trim)| inside_ranges(&curve, s, trim, slack)).collect(); + ranges.sort_by(|a, b| a[0].total_cmp(&b[0])); + let [t0, t1] = curve.domain(); + let mut merged: Vec<[f64; 2]> = Vec::with_capacity(ranges.len()); + for r in ranges { + match merged.last_mut() { + Some(last) if r[0] <= last[1] + 1e-12 * (t1 - t0) => last[1] = last[1].max(r[1]), + _ => merged.push(r), + } + } + if !merged.is_empty() { + out.push(Sil { curve, acc, ranges: merged }); + } + }; + match an { + Analytic::Plane => {} + Analytic::Cylinder { p, a, r } => { + let Some(m) = a.cross(d).normalized() else { return }; + let (t0, t1) = extent(parts, p, a); + if !(t1 > t0) { + return; + } + for side in [1., -1.] { + let base = p + m * (r * side); + push(C3::Line { a: base + a * t0, b: base + a * t1 }, CurveAccuracy::Analytic); + } + } + Analytic::Cone { apex, a, half } => { + let e1 = perp(a); + let e2 = a.cross(e1); + let (da, d1, d2) = (d.dot(a), d.dot(e1), d.dot(e2)); + let rho = (d1 * d1 + d2 * d2).sqrt(); + if rho <= 1e-12 { + return; + } + let c = half.tan() * da / rho; + if c.abs() > 1. { + return; + } + let phid = d2.atan2(d1); + let (h0, h1) = extent(parts, apex, a); + let (t0, t1) = (h0.max(0.) / half.cos(), h1.max(0.) / half.cos()); + if !(t1 > t0) { + return; + } + for sign in [1., -1.] { + let phi = phid + sign * c.acos(); + let g = a * half.cos() + (e1 * phi.cos() + e2 * phi.sin()) * half.sin(); + push(C3::Line { a: apex + g * t0, b: apex + g * t1 }, CurveAccuracy::Analytic); + } + } + Analytic::Sphere { c, r } => { + let x = perp(d); + let y = d.cross(x); + push(C3::Arc { o: c, x, y, r: [r, r], range: [0., TAU] }, CurveAccuracy::Analytic); + } + Analytic::Torus { .. } | Analytic::Other => { + // Several faces of one torus may each hold part of its silhouette: trace it on the + // whole torus then. One face keeps its own chart (finer along a narrow strip). + let whole = if parts.len() > 1 { S3::from_analytic(&an, [0., 0.], Vec3::ZERO) } else { None }; + let s = whole.as_ref().unwrap_or(s); + let traced = if matches!(s, S3::Rev(_)) { s.rev_silhouette(d) } else { contour(s, trim, d) }; + for (curve, dev) in traced { + let acc = if dev == 0. && matches!(curve, C3::Arc { .. }) { + CurveAccuracy::Analytic + } else { + CurveAccuracy::Sampled { max_sample_deviation_mm: dev } + }; + push(curve, acc); + } + } + } +} + +/// How far a face's surface may lie from the surface its silhouette set is traced on: rounding, +/// 1e-13 of the face's boundary size plus its coordinates (faces join a set when their +/// recognised surfaces agree to 1e-13). Only that close does a ray along the set's silhouette +/// leave the face within the own-face margin (1e-6 of the size): a surface `e` away is crossed +/// about `sqrt(2 R e)` along the ray. +pub(crate) fn join_tol(trim: &Trim) -> f64 { + 1e-13 * (1. + (trim.hi - trim.lo).len()) + 1e-15 * trim.lo.len().max(trim.hi.len()) +} + +/// Parameter ranges of `c` whose points are inside the face, transitions found by bisection. +/// `slack` is how far the curve itself may stand off the surface (a traced curve's chords). +pub(crate) fn inside_ranges(c: &C3, s: &S3, trim: &Trim, slack: f64) -> Vec<[f64; 2]> { + let [t0, t1] = c.domain(); + let n = 256; + let mut hint = None; + // The face is closed: a silhouette along its boundary (a seam between two faces of one + // surface, which is not drawn as an edge) is inside. Off the patch is outside: the inversion + // clamps to the chart's domain. + let tol = 2. * trim.chord_error + 1e-9 * (1. + (trim.hi - trim.lo).len()); + let on_patch = tol + 2. * join_tol(trim) + 2. * slack; + let state = |t: f64, hint: &mut Option<[f64; 2]>| -> bool { + let p = c.at(t); + let uv = s.inverse(p, *hint); + *hint = uv; + let Some(uv) = uv else { return false }; + match trim.contains(uv) { + _ if s.point(uv).dist(p) > on_patch => false, + Some(true) => true, + Some(false) => trim.near(p, tol), + None => false, + } + }; + // Uniform samples, plus the parameters nearest to the face's boundary points: an in-face + // interval is bounded by boundary crossings, so a narrow one between two uniform samples + // still gets samples on both of its sides. + let mut ts: Vec = (0..=n).map(|i| t0 + (t1 - t0) * i as f64 / n as f64).collect(); + let near = 0.02 * (trim.hi - trim.lo).len(); + ts.extend(trim.chords.iter().step_by(4).filter_map(|(q, _)| closest_param(c, *q).filter(|t| c.at(*t).dist(*q) <= near))); + ts.retain(|t| *t >= t0 && *t <= t1); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-12 * (t1 - t0)); + let mut hints = Vec::with_capacity(ts.len()); + let states: Vec = ts + .iter() + .map(|t| { + let st = state(*t, &mut hint); + hints.push(hint); + st + }) + .collect(); + let mut out = Vec::new(); + let mut start: Option = None; + for i in 0..ts.len() { + let (Some(&t), Some(&inside)) = (ts.get(i), states.get(i)) else { continue }; + let prev = i.checked_sub(1).and_then(|j| states.get(j).copied()); + if prev.is_some_and(|p| p != inside) { + let (mut lo, mut hi) = (ts.get(i - 1).copied().unwrap_or(t), t); + let near = hints.get(i).copied().flatten(); + for _ in 0..50 { + let mid = 0.5 * (lo + hi); + let mut h = near; + if state(mid, &mut h) == inside { + hi = mid; + } else { + lo = mid; + } + } + let edge = 0.5 * (lo + hi); + if inside { + start = Some(edge); + } else if let Some(s0) = start.take() { + out.push([s0, edge]); + } + } else if i == 0 && inside { + start = Some(t); + } + } + if let Some(s0) = start { + out.push([s0, t1]); + } + out.retain(|r| r[1] - r[0] > 1e-12 * (t1 - t0)); + out +} + +/// The parameter of the point of `c` nearest to `p` (lines and arcs exactly, polylines at a +/// vertex), when `p` projects inside the curve's domain. +fn closest_param(c: &C3, p: Vec3) -> Option { + match c { + C3::Line { a, b } => { + let e = *b - *a; + let l2 = e.len2(); + (l2 > 0.).then(|| ((p - *a).dot(e) / l2).clamp(0., 1.)) + } + C3::Arc { o, x, y, r, range } => { + let w = p - *o; + let t = (w.dot(*y) / r[1]).atan2(w.dot(*x) / r[0]); + // The turn of t that falls in the range, if any. + let k = ((range[0] - t) / TAU).ceil(); + let t = t + k * TAU; + (t <= range[1]).then_some(t) + } + // The nearest point on any chord, not just the nearest vertex: a face narrower than + // the chords still gets parameters inside it. + C3::Polyline(pts) => pts + .windows(2) + .enumerate() + .filter_map(|(i, w)| { + let (a, b) = (*w.first()?, *w.get(1)?); + let e = b - a; + let f = if e.len2() > 0. { ((p - a).dot(e) / e.len2()).clamp(0., 1.) } else { 0. }; + Some((i as f64 + f, (a + e * f).dist(p))) + }) + .min_by(|x, y| x.1.total_cmp(&y.1)) + .map(|(t, _)| t), + C3::Spline { .. } => None, + } +} + +/// Zero set of `normal . d` on a chart grid over the face's chart box. +fn contour(s: &S3, trim: &Trim, d: Vec3) -> Vec<(C3, f64)> { + let Some((lo, hi)) = trim.uv_bounds() else { return Vec::new() }; + let dom = s.domain(); + let lo = [lo[0].max(dom[0][0]), lo[1].max(dom[1][0])]; + let hi = [hi[0].min(dom[0][1]), hi[1].min(dom[1][1])]; + if !(hi[0] > lo[0] && hi[1] > lo[1]) { + return Vec::new(); + } + let g = 128usize; + let uv = |i: usize, j: usize| [lo[0] + (hi[0] - lo[0]) * i as f64 / g as f64, lo[1] + (hi[1] - lo[1]) * j as f64 / g as f64]; + let f = |p: [f64; 2]| s.normal(p).map_or(f64::NAN, |n| n.dot(d)); + let vals: Vec = (0..=g).flat_map(|j| (0..=g).map(move |i| (i, j))).map(|(i, j)| f(uv(i, j))).collect(); + let val = |i: usize, j: usize| vals.get(j * (g + 1) + i).copied().unwrap_or(f64::NAN); + let zero = |a: [f64; 2], b: [f64; 2], fa: f64| -> [f64; 2] { + let (mut lo, mut hi, mut flo) = (a, b, fa); + for _ in 0..50 { + let m = [0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1])]; + let fm = f(m); + if fm.is_nan() { + break; + } + if (fm > 0.) == (flo > 0.) { + lo = m; + flo = fm; + } else { + hi = m; + } + } + [0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1])] + }; + // Edge ids: horizontal (i,j)-(i+1,j) = 2k, vertical (i,j)-(i,j+1) = 2k+1, k = j*(g+1)+i. + let mut points = std::collections::BTreeMap::new(); + let mut crossing = |id: usize, a: (usize, usize), b: (usize, usize)| -> Option { + let (fa, fb) = (val(a.0, a.1), val(b.0, b.1)); + if fa.is_nan() || fb.is_nan() || (fa > 0.) == (fb > 0.) { + return None; + } + points.entry(id).or_insert_with(|| zero(uv(a.0, a.1), uv(b.0, b.1), fa)); + Some(id) + }; + let mut links: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for j in 0..g { + for i in 0..g { + let k = |i: usize, j: usize| j * (g + 1) + i; + let e = [ + crossing(2 * k(i, j), (i, j), (i + 1, j)), + crossing(2 * k(i + 1, j) + 1, (i + 1, j), (i + 1, j + 1)), + crossing(2 * k(i, j + 1), (i, j + 1), (i + 1, j + 1)), + crossing(2 * k(i, j) + 1, (i, j), (i, j + 1)), + ]; + let found: Vec = e.iter().flatten().copied().collect(); + let pairs: Vec<(usize, usize)> = match found.as_slice() { + [a, b] => vec![(*a, *b)], + [a, b, c, dd] => { + let centre = f([0.5 * (uv(i, j)[0] + uv(i + 1, j)[0]), 0.5 * (uv(i, j)[1] + uv(i, j + 1)[1])]); + if (centre > 0.) == (val(i, j) > 0.) { vec![(*a, *b), (*c, *dd)] } else { vec![(*a, *dd), (*b, *c)] } + } + _ => Vec::new(), + }; + for (a, b) in pairs { + links.entry(a).or_default().push(b); + links.entry(b).or_default().push(a); + } + } + } + // Chain the links into polylines. + let mut used = std::collections::BTreeSet::new(); + let mut chains = Vec::new(); + let starts: Vec = links.iter().filter(|(_, v)| v.len() == 1).map(|(k, _)| *k).chain(links.keys().copied()).collect(); + for s0 in starts { + if used.contains(&s0) { + continue; + } + let mut chain = vec![s0]; + used.insert(s0); + let mut cur = s0; + while let Some(next) = links.get(&cur).and_then(|v| v.iter().find(|n| !used.contains(*n)).copied()) { + used.insert(next); + chain.push(next); + cur = next; + } + // Close loops back to their start. + if chain.len() > 2 && links.get(&cur).is_some_and(|v| v.contains(&s0)) { + chain.push(s0); + } + if chain.len() >= 2 { + chains.push(chain); + } + } + let mut out = Vec::new(); + for chain in chains { + let uvs: Vec<[f64; 2]> = chain.iter().filter_map(|id| points.get(id).copied()).collect(); + let pts: Vec = uvs.iter().map(|p| s.point(*p)).collect(); + // Deviation: refine each chord's midpoint onto the curve across the chord. + let mut dev = 0f64; + for (w, q) in uvs.windows(2).zip(pts.windows(2)) { + let (Some(a), Some(b), Some(pa), Some(pb)) = (w.first(), w.get(1), q.first(), q.get(1)) else { continue }; + let m = [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])]; + let n = [-(b[1] - a[1]), b[0] - a[0]]; + let (x, y) = ([m[0] - n[0], m[1] - n[1]], [m[0] + n[0], m[1] + n[1]]); + let (fx, fy) = (f(x), f(y)); + if fx.is_nan() || fy.is_nan() || (fx > 0.) == (fy > 0.) { + continue; + } + let exact = s.point(zero(x, y, fx)); + dev = dev.max(exact.dist_to_segment(*pa, *pb)); + } + out.push((C3::Polyline(pts), dev)); + } + out +} diff --git a/crates/geom/src/hlr/surface.rs b/crates/geom/src/hlr/surface.rs new file mode 100644 index 0000000000..d8c3dfc766 --- /dev/null +++ b/crates/geom/src/hlr/surface.rs @@ -0,0 +1,1298 @@ +//! Surfaces of portable B-rep faces with a parameter chart, inversion, ray intersection and +//! analytic recognition. Planes are closed form; revolutions are reduced to their 2D profile; +//! spline surfaces use Newton iterations seeded from a sample grid. + +use super::HlrError; +use super::curve::{C3, as_circle, frame_axes, v3}; +use crate::Vec3; +use crate::brep::{Frame, Surface}; +use crate::bspline::{HPoint, NurbsSurface}; + +const TAU: f64 = std::f64::consts::TAU; + +#[derive(Clone, Debug)] +pub(crate) struct Rev { + o: Vec3, + pub a: Vec3, + e1: Vec3, + e2: Vec3, + profile: C3, + dom: [f64; 2], + theta: [f64; 2], + closed: bool, + /// Profile samples `[s, r, h]`. + samples: Vec<[f64; 3]>, + pub rmax: f64, + hrange: [f64; 2], +} + +#[derive(Clone, Debug)] +pub(crate) struct Nurbs { + s: NurbsSurface, + dom: [[f64; 2]; 2], + g: usize, + /// `(g+1)²` samples, u fastest. + grid: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) enum S3 { + Plane { o: Vec3, u: Vec3, v: Vec3, n: Vec3 }, + Rev(Box), + Nurbs(Box), +} + +/// What a surface is, when it is a quadric or torus regardless of its record. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Analytic { + Plane, + /// A point on the axis, unit axis, radius. + Cylinder { + p: Vec3, + a: Vec3, + r: f64, + }, + /// Apex, unit axis (opening direction), half angle. + Cone { + apex: Vec3, + a: Vec3, + half: f64, + }, + Sphere { + c: Vec3, + r: f64, + }, + Torus { + c: Vec3, + a: Vec3, + major: f64, + minor: f64, + }, + Other, +} + +impl Analytic { + /// Distance from `q` to the whole surface (a cone's nappe near `q`); infinite for planes + /// and other surfaces, which are not checked this way. + fn distance(&self, q: Vec3) -> f64 { + let rh = |o: Vec3, a: Vec3| { + let w = q - o; + let h = w.dot(a); + ((w - a * h).len(), h) + }; + match *self { + Analytic::Cylinder { p, a, r } => (rh(p, a).0 - r).abs(), + Analytic::Cone { apex, a, half } => { + let (rho, h) = rh(apex, a); + (rho * half.cos() - h * half.sin()).abs() + } + Analytic::Sphere { c, r } => (q.dist(c) - r).abs(), + Analytic::Torus { c, a, major, minor } => { + let (rho, h) = rh(c, a); + ((rho - major).hypot(h) - minor).abs() + } + Analytic::Plane | Analytic::Other => f64::INFINITY, + } + } + + /// The degree of the surface's implicit equation: 2 for quadrics, 4 for a torus. + fn implicit_degree(&self) -> usize { + match self { + Analytic::Torus { .. } => 4, + _ => 2, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Hit { + pub lam: f64, + pub uv: [f64; 2], + pub grazing: bool, +} + +fn perp(a: Vec3) -> Vec3 { + a.any_perp().normalized().unwrap_or(Vec3::new(1., 0., 0.)) +} + +impl S3 { + pub fn new(s: &Surface) -> Result { + Ok(match s { + Surface::Plane { origin, u_axis, v_axis } => { + let (u, v) = (v3(*u_axis), v3(*v_axis)); + let n = u.cross(v).normalized().ok_or(HlrError::Unsupported("degenerate plane"))?; + S3::Plane { o: v3(*origin), u, v, n } + } + // The plane through three points: parameter (1, 0) at `u_point`, (0, 1) at `v_point`. + Surface::PlaneThrough { origin, u_point, v_point } => { + let o = v3(*origin); + let (u, v) = (v3(*u_point) - o, v3(*v_point) - o); + let n = u.cross(v).normalized().ok_or(HlrError::Unsupported("degenerate plane"))?; + S3::Plane { o, u, v, n } + } + // A revolution mapped by an affine placement, which may scale it unevenly. + Surface::PlacedRevolution { .. } => return Err(HlrError::Unsupported("placed revolution surface")), + Surface::Spline { u_knots, v_knots, points } => { + let h = points.iter().map(|r| r.iter().map(|p| [p[0], p[1], p[2], 1.]).collect()).collect::>>(); + S3::Nurbs(Box::new(Nurbs::new(u_knots, v_knots, &h)?)) + } + Surface::RationalSpline { u_knots, v_knots, homogeneous } => S3::Nurbs(Box::new(Nurbs::new(u_knots, v_knots, homogeneous)?)), + Surface::Revolution { profile, origin, axis, angle } => { + let (c, _) = C3::new(profile)?; + S3::Rev(Box::new(Rev::new(v3(*origin), v3(*axis), None, c, *angle)?)) + } + Surface::Cylinder { frame, radius, angle, height } => { + let (o, x, _, z) = axes(frame); + let line = C3::Line { a: o + x * *radius + z * height[0], b: o + x * *radius + z * height[1] }; + S3::Rev(Box::new(Rev::new(o, z, Some(x), line, *angle)?)) + } + Surface::Cone { frame, radius_at_origin, half_angle, angle, height } => { + let (o, x, _, z) = axes(frame); + let r = |h: f64| radius_at_origin + h * half_angle.tan(); + let line = C3::Line { a: o + x * r(height[0]) + z * height[0], b: o + x * r(height[1]) + z * height[1] }; + S3::Rev(Box::new(Rev::new(o, z, Some(x), line, *angle)?)) + } + Surface::Sphere { frame, radius, longitude, latitude } => { + let (o, x, _, z) = axes(frame); + let arc = C3::Arc { o, x, y: z, r: [*radius, *radius], range: *latitude }; + S3::Rev(Box::new(Rev::new(o, z, Some(x), arc, *longitude)?)) + } + Surface::Torus { frame, major_radius, minor_radius, longitude, meridian } => { + let (o, x, _, z) = axes(frame); + let arc = C3::Arc { o: o + x * *major_radius, x, y: z, r: [*minor_radius, *minor_radius], range: *meridian }; + S3::Rev(Box::new(Rev::new(o, z, Some(x), arc, *longitude)?)) + } + }) + } + + /// The recognised quadric or torus as a revolution chart: closed-form inversion and exact + /// points for trimming, whatever record (spline, rational spline) the face was stored as. + /// `h` bounds the axial extent of cylinders and cones (the face's boundary along the axis); + /// `toward` points from a sphere's centre to the face. + pub fn from_analytic(an: &Analytic, h: [f64; 2], toward: Vec3) -> Option { + let rev = match *an { + Analytic::Cylinder { p, a, r } => { + let e1 = perp(a); + Rev::new(p, a, Some(e1), C3::Line { a: p + e1 * r + a * h[0], b: p + e1 * r + a * h[1] }, [0., TAU]) + } + Analytic::Cone { apex, a, half } => { + let e1 = perp(a); + let at = |x: f64| apex + a * x + e1 * (x * half.tan()); + Rev::new(apex, a, Some(e1), C3::Line { a: at(h[0].max(0.)), b: at(h[1].max(0.)) }, [0., TAU]) + } + Analytic::Sphere { c, r } => { + // An axis square to the patch's direction keeps the poles off the patch. + let a = perp(toward); + let e1 = toward.normalized().unwrap_or_else(|| perp(a)); + let half = std::f64::consts::FRAC_PI_2; + Rev::new(c, a, Some(e1), C3::Arc { o: c, x: e1, y: a, r: [r, r], range: [-half, half] }, [0., TAU]) + } + Analytic::Torus { c, a, major, minor } => { + let e1 = perp(a); + Rev::new(c, a, Some(e1), C3::Arc { o: c + e1 * major, x: e1, y: a, r: [minor, minor], range: [0., TAU] }, [0., TAU]) + } + Analytic::Plane | Analytic::Other => return None, + }; + rev.ok().map(|r| S3::Rev(Box::new(r))) + } + + /// A box that contains the whole surface for certain: the control-point hull of a spline + /// (weights are positive), the turned profile's hull for a revolution; `None` for planes. + pub fn bound(&self) -> Option<(Vec3, Vec3)> { + let hull = |pts: &mut dyn Iterator| { + pts.fold(None, |acc: Option<(Vec3, Vec3)>, p| Some(acc.map_or((p, p), |(lo, hi)| (lo.min(p), hi.max(p))))) + }; + match self { + S3::Plane { .. } => None, + S3::Nurbs(n) => hull(&mut n.s.homogeneous().iter().filter_map(|h| h.project())), + S3::Rev(r) => { + // Radial distance is convex, so its maximum over the profile's hull is at a vertex. + let pts: Vec = match &r.profile { + C3::Line { a, b } => vec![*a, *b], + C3::Polyline(p) => p.clone(), + C3::Spline { points, .. } => { + points.iter().filter(|h| h[3] > 0.).map(|h| Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])).collect() + } + C3::Arc { o, x, y, r: rad, .. } => { + // The whole circle's bounding square in its plane. + let m = rad[0].max(rad[1]); + vec![*o + *x * m + *y * m, *o + *x * m - *y * m, *o - *x * m + *y * m, *o - *x * m - *y * m] + } + }; + let (mut rmax, mut h0, mut h1) = (0f64, f64::MAX, f64::MIN); + for p in &pts { + let w = *p - r.o; + let h = w.dot(r.a); + rmax = rmax.max((w - r.a * h).len()); + h0 = h0.min(h); + h1 = h1.max(h); + } + if !(h1 >= h0) { + return None; + } + let (c0, c1) = (r.o + r.a * h0, r.o + r.a * h1); + let ext = |k: f64| rmax * (1. - k * k).max(0.).sqrt(); + let e = Vec3::new(ext(r.a.x), ext(r.a.y), ext(r.a.z)); + Some((c0.min(c1) - e, c0.max(c1) + e)) + } + } + } + + /// Silhouette points of a revolution for direction `d`, traced along the profile: at each + /// profile parameter the normal is perpendicular to `d` at up to two angles. Returns + /// polylines of exact points with their chord deviation, and exact circles when `d` is the axis. + pub fn rev_silhouette(&self, d: Vec3) -> Vec<(C3, f64)> { + let S3::Rev(r) = self else { return Vec::new() }; + r.silhouette(d) + } + + /// A spline patch whose control points all lie in one plane (to `tol` of its size) as that + /// plane; the convex hull property puts the whole patch in it. + pub fn flat(&self, tol: f64) -> Option { + let S3::Nurbs(n) = self else { return None }; + let pts: Vec = n.s.homogeneous().iter().filter_map(|h| h.project()).collect(); + let o = *pts.first()?; + let far = |key: &dyn Fn(Vec3) -> f64| pts.iter().copied().max_by(|a, b| key(*a).total_cmp(&key(*b))); + let p1 = far(&|p| p.dist(o))?; + let u = (p1 - o).normalized()?; + let p2 = far(&|p| (p - o).cross(u).len())?; + let nrm = u.cross(p2 - o).normalized()?; + let size = (p1 - o).len(); + pts.iter().all(|p| (*p - o).dot(nrm).abs() <= tol * size).then(|| S3::Plane { o, u, v: nrm.cross(u), n: nrm }) + } + + pub fn domain(&self) -> [[f64; 2]; 2] { + match self { + S3::Plane { .. } => [[f64::NEG_INFINITY, f64::INFINITY]; 2], + S3::Rev(r) => [r.theta, r.dom], + S3::Nurbs(n) => n.dom, + } + } + + pub fn periods(&self) -> [Option; 2] { + match self { + S3::Rev(r) => [((r.theta[1] - r.theta[0]) >= TAU - 1e-9).then_some(TAU), r.closed.then_some(r.dom[1] - r.dom[0])], + _ => [None, None], + } + } + + pub fn point(&self, uv: [f64; 2]) -> Vec3 { + match self { + S3::Plane { o, u, v, .. } => *o + *u * uv[0] + *v * uv[1], + S3::Rev(r) => r.point(uv), + S3::Nurbs(n) => n.point(uv), + } + } + + pub fn partials(&self, uv: [f64; 2]) -> (Vec3, Vec3) { + match self { + S3::Plane { u, v, .. } => (*u, *v), + _ => { + let d = self.domain(); + let fd = |k: usize| { + let span = d[k][1] - d[k][0]; + let h = span * 1e-6; + let lo = if self.periods()[k].is_some() { uv[k] - h } else { (uv[k] - h).max(d[k][0]) }; + let hi = if self.periods()[k].is_some() { uv[k] + h } else { (uv[k] + h).min(d[k][1]) }; + let (mut a, mut b) = (uv, uv); + a[k] = lo; + b[k] = hi; + if hi - lo <= 0. { Vec3::ZERO } else { (self.point(b) - self.point(a)) / (hi - lo) } + }; + (fd(0), fd(1)) + } + } + } + + /// The chart normal `Su x Sv`, unit, or `None` at a singular point. + pub fn normal(&self, uv: [f64; 2]) -> Option { + match self { + S3::Plane { n, .. } => Some(*n), + _ => { + let (su, sv) = self.partials(uv); + su.cross(sv).normalized() + } + } + } + + /// Chart coordinates of a point on (or very near) the surface. `hint` guides spline + /// Newton iterations and the choice of periodic branch. + pub fn inverse(&self, p: Vec3, hint: Option<[f64; 2]>) -> Option<[f64; 2]> { + match self { + S3::Plane { o, u, v, .. } => { + let w = p - *o; + let (uu, uv, vv) = (u.dot(*u), u.dot(*v), v.dot(*v)); + let det = uu * vv - uv * uv; + if det.abs() <= 1e-300 { + return None; + } + let (bu, bv) = (w.dot(*u), w.dot(*v)); + Some([(bu * vv - bv * uv) / det, (bv * uu - bu * uv) / det]) + } + S3::Rev(r) => r.inverse(p, hint), + S3::Nurbs(n) => n.inverse(p, hint), + } + } + + /// Ray hits along unit `d` from `p0` with `lam` in `range` (the face's box along the ray). + pub fn ray(&self, p0: Vec3, d: Vec3, range: [f64; 2], out: &mut Vec) { + match self { + S3::Plane { n, .. } => { + let nd = n.dot(d); + if nd.abs() < 1e-12 { + return; + } + let lam = n.dot(self.point([0., 0.]) - p0) / nd; + if lam >= range[0] + && lam <= range[1] + && let Some(uv) = self.inverse(p0 + d * lam, None) + { + out.push(Hit { lam, uv, grazing: nd.abs() < 1e-6 }); + } + } + S3::Rev(r) => r.ray(p0, d, range, out), + S3::Nurbs(n) => n.ray(p0, d, range, out), + } + } + + /// Ray hits using the recognised analytic form when there is one (closed form for + /// quadrics, a sampled quartic for tori), else the record's own intersection. + pub fn ray_as(&self, an: &Analytic, p0: Vec3, d: Vec3, range: [f64; 2], out: &mut Vec) { + let lams = match *an { + Analytic::Cylinder { p, a, r } => { + let (w, v) = (p0 - p, d - a * d.dot(a)); + let w = w - a * w.dot(a); + quadratic(v.dot(v), 2. * v.dot(w), w.dot(w) - r * r) + } + Analytic::Cone { apex, a, half } => { + let w = p0 - apex; + let c2 = half.cos().powi(2); + let (da, wa) = (d.dot(a), w.dot(a)); + let roots = quadratic(da * da - c2, 2. * (da * wa - c2 * d.dot(w)), wa * wa - c2 * w.dot(w)); + roots.into_iter().filter(|(l, _)| (w + d * *l).dot(a) >= 0.).collect() + } + Analytic::Sphere { c, r } => { + let w = p0 - c; + quadratic(1., 2. * d.dot(w), w.dot(w) - r * r) + } + Analytic::Torus { c, a, major, minor } => torus_roots(p0 - c, d, a, major, minor, range), + Analytic::Plane | Analytic::Other => return self.ray(p0, d, range, out), + }; + for (lam, grazing) in lams { + if lam >= range[0] && lam <= range[1] { + // The analytic surface extends past this record's domain: keep hits on it only. + let p = p0 + d * lam; + if let Some(uv) = self.inverse(p, None).filter(|uv| self.point(*uv).dist(p) <= 1e-7 * (1. + p.len())) { + out.push(Hit { lam, uv, grazing }); + } + } + } + } + + /// Which analytic surface this is, checked at sample points to `tol` relative to its size. + pub fn analytic(&self, tol: f64) -> Analytic { + match self { + S3::Plane { .. } => Analytic::Plane, + S3::Rev(r) => r.analytic(tol), + S3::Nurbs(n) => n.analytic(tol), + } + } +} + +/// Real roots of `a x^2 + b x + c`, a double root flagged as grazing. +pub(crate) fn quadratic(a: f64, b: f64, c: f64) -> Vec<(f64, bool)> { + let scale = b * b + (a * c).abs(); + if a.abs() <= 1e-14 * (1. + b.abs()) { + return if b.abs() > 1e-300 { vec![(-c / b, false)] } else { Vec::new() }; + } + let disc = b * b - 4. * a * c; + if disc < -1e-12 * scale { + return Vec::new(); + } + if disc <= 1e-12 * scale { + return vec![(-b / (2. * a), true)]; + } + // Numerically stable pair. + let q = -0.5 * (b + b.signum() * disc.sqrt()); + vec![(q / a, false), (c / q, false)] +} + +/// Roots of the torus implicit function along a ray, by sampling and bisection; dips that +/// touch zero without a sign change are grazing. +pub(crate) fn torus_roots(w: Vec3, d: Vec3, a: Vec3, big: f64, small: f64, range: [f64; 2]) -> Vec<(f64, bool)> { + // Signed distance from the tube: distance to the centre circle less the tube radius, + // computed directly (no quartic), so a thin tube on a large ring keeps its precision. + let g = |l: f64| { + let p = w + d * l; + let z = p.dot(a); + let rho = (p - a * z).len(); + (rho - big).hypot(z) - small + }; + // Sample only where the ray is inside the shell |z| <= r, R - r <= rho <= R + r, in steps + // of a quarter of the tube radius: the shell is short along any ray, so the steps are few. + // The shell is padded: a crossing exactly on its surface (rho = R + r at z = 0, say) must + // fall strictly inside a sampled piece. + let m = 0.5 * small + 1e-9 * big; + let (z0, dz) = (w.dot(a), d.dot(a)); + let mut span = range; + if dz.abs() > 1e-300 { + let (l0, l1) = ((-small - m - z0) / dz, (small + m - z0) / dz); + span = [span[0].max(l0.min(l1)), span[1].min(l0.max(l1))]; + } else if z0.abs() > small + m { + return Vec::new(); + } + // rho^2 along the ray: |w_perp + l d_perp|^2. + let (wp, dp) = (w - a * z0, d - a * dz); + let (qa, qb, qc) = (dp.dot(dp), 2. * wp.dot(dp), wp.dot(wp)); + let within = |r2: f64| -> Option<[f64; 2]> { + // Where rho^2 <= r2. + if qa <= 1e-300 { + return (qc <= r2).then_some([f64::NEG_INFINITY, f64::INFINITY]); + } + let disc = qb * qb - 4. * qa * (qc - r2); + (disc >= 0.).then(|| [(-qb - disc.sqrt()) / (2. * qa), (-qb + disc.sqrt()) / (2. * qa)]) + }; + let Some(outer) = within((big + small + m).powi(2)) else { return Vec::new() }; + span = [span[0].max(outer[0]), span[1].min(outer[1])]; + if !(span[1] > span[0]) { + return Vec::new(); + } + // Remove the inside of the inner radius, leaving up to two pieces. + let mut pieces = vec![span]; + if big > small + m + && let Some(inner) = within((big - small - m).powi(2)) + { + pieces = vec![[span[0], span[1].min(inner[0])], [span[0].max(inner[1]), span[1]]]; + } + // |g| is 1-Lipschitz along the ray: two samples whose values share a sign and add up to + // more than their distance cannot have a crossing between them. Other pairs are halved down + // to a thousandth of the tube radius, so a shallow cut through the tube (a chord shorter than + // the sample step) is still found. + let fine = 1e-3 * small; + let mut out = Vec::new(); + for [s0, s1] in pieces.into_iter().filter(|p| p[1] > p[0]) { + let n = ((s1 - s0) / (0.25 * small)).ceil().clamp(8., 1_000_000.) as usize; + let at = |i: usize| s0 + (s1 - s0) * i as f64 / n as f64; + let mut stack: Vec<(f64, f64, f64, f64)> = Vec::new(); + let vals: Vec = (0..=n).map(|i| g(at(i))).collect(); + for i in 0..n { + if let (Some(&ga), Some(&gb)) = (vals.get(i), vals.get(i + 1)) { + stack.push((at(i), at(i + 1), ga, gb)); + } + } + stack.reverse(); + let mut budget = 1_000_000usize; + while let Some((lo0, hi0, ga, gb)) = stack.pop() { + budget = budget.saturating_sub(1); + if budget == 0 { + break; + } + if ga != 0. && gb != 0. && ga.signum() != gb.signum() { + let (mut lo, mut hi, mut glo) = (lo0, hi0, ga); + for _ in 0..80 { + let mid = 0.5 * (lo + hi); + let gm = g(mid); + if gm.signum() == glo.signum() { + lo = mid; + glo = gm; + } else { + hi = mid; + } + } + out.push((0.5 * (lo + hi), false)); + continue; + } + if ga.abs() + gb.abs() > hi0 - lo0 { + continue; + } + if hi0 - lo0 <= fine { + // Close to the tube without crossing it: a touch when it is a dip inside the shell. + let m = 0.5 * (lo0 + hi0); + let gm = g(m); + if gm > 0. && gm <= ga && gm <= gb && lo0 > s0 && hi0 < s1 && gm < 1e-4 * small.max(1e-9 * big) { + out.push((m, true)); + } + continue; + } + let m = 0.5 * (lo0 + hi0); + let gm = g(m); + stack.push((m, hi0, gm, gb)); + stack.push((lo0, m, ga, gm)); + } + } + out.sort_by(|x, y| x.0.total_cmp(&y.0)); + out.dedup_by(|x, y| (x.0 - y.0).abs() <= fine && x.1 == y.1); + out +} + +fn axes(f: &Frame) -> (Vec3, Vec3, Vec3, Vec3) { + let (o, x, y) = frame_axes(f); + (o, x, y, x.cross(y)) +} + +impl Rev { + fn new(o: Vec3, axis: Vec3, e1: Option, profile: C3, angle: [f64; 2]) -> Result { + let a = axis.normalized().ok_or(HlrError::Unsupported("zero revolution axis"))?; + let dom = profile.domain(); + let e1 = e1.unwrap_or_else(|| { + let w = profile.at(dom[0]) - o; + (w - a * w.dot(a)).normalized().unwrap_or_else(|| perp(a)) + }); + let e2 = a.cross(e1); + let closed = profile.at(dom[0]).dist(profile.at(dom[1])) <= 1e-9 * (1. + profile.at(dom[0]).dist(o)); + let n = 512; + let mut samples = Vec::with_capacity(n + 1); + let (mut rmax, mut hlo, mut hhi) = (0f64, f64::MAX, f64::MIN); + for i in 0..=n { + let s = dom[0] + (dom[1] - dom[0]) * i as f64 / n as f64; + let w = profile.at(s) - o; + let h = w.dot(a); + let r = (w - a * h).len(); + rmax = rmax.max(r); + hlo = hlo.min(h); + hhi = hhi.max(h); + samples.push([s, r, h]); + } + if !(rmax.is_finite() && hlo.is_finite() && hhi.is_finite()) { + return Err(HlrError::Unsupported("non-finite revolution profile")); + } + // Chart theta is measured from e1; a full turn is [0, 2pi). + let theta = if angle[1] - angle[0] >= TAU - 1e-9 { [0., TAU] } else { angle }; + Ok(Rev { o, a, e1, e2, profile, dom, theta, closed, samples, rmax, hrange: [hlo, hhi] }) + } + + fn rh(&self, s: f64) -> [f64; 2] { + let w = self.profile.at(s) - self.o; + let h = w.dot(self.a); + [(w - self.a * h).len(), h] + } + + fn rh_d(&self, s: f64) -> [f64; 2] { + // Exact for line and arc profiles: a one-sided difference at the domain's ends misses + // zeros that sit there (a half torus's equators). A difference for the rest. + let dw = match &self.profile { + C3::Line { a, b } => Some(*b - *a), + C3::Arc { x, y, r, .. } => Some(*x * (-r[0] * s.sin()) + *y * (r[1] * s.cos())), + _ => None, + }; + if let Some(dw) = dw { + let w = self.profile.at(s) - self.o; + let radial = w - self.a * w.dot(self.a); + let r = radial.len(); + if r > 1e-12 * (1. + self.rmax) { + return [radial.dot(dw) / r, dw.dot(self.a)]; + } + } + let h = (self.dom[1] - self.dom[0]) * 1e-7; + let (a, b) = ((s - h).max(self.dom[0]), (s + h).min(self.dom[1])); + if b - a <= 0. { + return [0., 0.]; + } + let (p, q) = (self.rh(a), self.rh(b)); + [(q[0] - p[0]) / (b - a), (q[1] - p[1]) / (b - a)] + } + + fn point(&self, uv: [f64; 2]) -> Vec3 { + let [r, h] = self.rh(uv[1]); + self.o + self.a * h + (self.e1 * uv[0].cos() + self.e2 * uv[0].sin()) * r + } + + /// Profile parameters where component `k` of the profile's (r, h) tangent vanishes: sign + /// changes between samples, refined by bisection, exact zeros at samples, and for a closed + /// profile the change across the seam where its ends meet. + fn roots(&self, ss: &[f64], k: usize) -> Vec { + let f = |s: f64| self.rh_d(s).get(k).copied().unwrap_or(0.); + let vals: Vec = ss.iter().map(|s| f(*s)).collect(); + let scale = vals.iter().fold(0f64, |m, v| m.max(v.abs())).max(1e-300); + let zero = |v: f64| v.abs() <= 1e-12 * scale; + let mut out: Vec = Vec::new(); + let mut pairs: Vec<(usize, usize)> = (0..ss.len().saturating_sub(1)).map(|i| (i, i + 1)).collect(); + if self.closed && ss.len() > 2 { + // The last sample repeats the first; the pair across the seam is (n-1, 1). + pairs.push((ss.len() - 2, 1)); + } + for (i, sv) in vals.iter().enumerate() { + if zero(*sv) + && !(self.closed && i + 1 == vals.len()) + && let Some(s0) = ss.get(i) + { + out.push(*s0); + } + } + for (i, j) in pairs { + let (Some(&a), Some(&b), Some(&sa), Some(&sb)) = (vals.get(i), vals.get(j), ss.get(i), ss.get(j)) else { continue }; + if zero(a) || zero(b) || a.signum() == b.signum() { + continue; + } + // Across the seam, walk sb forward a period so the interval is increasing. + let sb = if sb < sa { sb + (self.dom[1] - self.dom[0]) } else { sb }; + let wrap = |x: f64| if x > self.dom[1] { x - (self.dom[1] - self.dom[0]) } else { x }; + let (mut lo, mut hi, mut flo) = (sa, sb, a); + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + let fm = f(wrap(mid)); + if fm.signum() == flo.signum() && !zero(fm) { + lo = mid; + flo = fm; + } else { + hi = mid; + } + } + out.push(wrap(0.5 * (lo + hi))); + } + out.sort_by(f64::total_cmp); + let span = self.dom[1] - self.dom[0]; + out.dedup_by(|x, y| (*x - *y).abs() <= 1e-9 * span); + // A closed profile's start and end are one point: one root there, not two. + if self.closed + && let (Some(&first), Some(&last)) = (out.first(), out.last()) + && out.len() > 1 + && (first + span - last).abs() <= 1e-9 * span + { + out.pop(); + } + out + } + + /// cos(theta - phi) = dr (a.d) / (dh rho) along the profile; see `S3::rev_silhouette`. + fn silhouette(&self, d: Vec3) -> Vec<(C3, f64)> { + let (d1, d2, da) = (d.dot(self.e1), d.dot(self.e2), d.dot(self.a)); + let rho = (d1 * d1 + d2 * d2).sqrt(); + let phi = d2.atan2(d1); + let n = 720usize; + let mut ss: Vec = (0..=n).map(|i| self.dom[0] + (self.dom[1] - self.dom[0]) * i as f64 / n as f64).collect(); + let mut out = Vec::new(); + if rho > 1e-12 { + // The silhouette exists where |dr (a.d)| <= |dh| rho; near the axis direction those + // intervals shrink around the zeros of dr, which are sampled so none is stepped over. + let extra = self.roots(&ss, 0); + ss.extend(extra); + ss.sort_by(f64::total_cmp); + ss.dedup_by(|x, y| (*x - *y).abs() <= 1e-15 * (self.dom[1] - self.dom[0])); + } + if rho <= 1e-12 { + // Along the axis: circles where the profile runs parallel to the axis (dr = 0). + for s0 in self.roots(&ss, 0) { + let [r, h] = self.rh(s0); + if r > 1e-12 { + out.push((C3::Arc { o: self.o + self.a * h, x: self.e1, y: self.e2, r: [r, r], range: [0., TAU] }, 0.)); + } + } + return out; + } + if da.abs() <= 1e-12 { + // Square to the axis: where the profile runs radially (dh = 0) the normal is the + // axis, square to d for every angle, so the whole parallel circle is a silhouette. + for s0 in self.roots(&ss, 1) { + let [r, h] = self.rh(s0); + if r > 1e-12 { + out.push((C3::Arc { o: self.o + self.a * h, x: self.e1, y: self.e2, r: [r, r], range: [0., TAU] }, 0.)); + } + } + } + let c = |s: f64| { + let t = self.rh_d(s); + if t[1].abs() <= 1e-300 { f64::INFINITY } else { t[0] * da / (t[1] * rho) } + }; + let at = |sign: f64, s: f64| self.point([phi + sign * c(s).clamp(-1., 1.).acos(), s]); + // The parameter between a (|c| <= 1) and b (|c| > 1) where |c| = 1. + let edge = |a: f64, b: f64| { + let (mut lo, mut hi) = (a, b); + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + if c(mid).abs() <= 1. { + lo = mid; + } else { + hi = mid; + } + } + lo + }; + // Chords are halved where their middle is further than this from the curve; the angle + // moves fastest where a branch starts or ends, so uniform samples alone are coarse there. + let target = 1e-7 * (1. + self.rmax + (self.hrange[1] - self.hrange[0])); + // One polyline per branch while |c| <= 1. + for sign in [1., -1.] { + let mut run: Vec = Vec::new(); + let flush = |run: &mut Vec, out: &mut Vec<(C3, f64)>| { + if run.len() >= 2 { + for _ in 0..40 { + let mut next = Vec::with_capacity(run.len() * 2); + let mut split = false; + for w in run.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + next.push(a); + let m = 0.5 * (a + b); + if next.len() < 1 << 16 && at(sign, m).dist_to_segment(at(sign, a), at(sign, b)) > target { + next.push(m); + split = true; + } + } + if let Some(&last) = run.last() { + next.push(last); + } + *run = next; + if !split { + break; + } + } + let pts: Vec = run.iter().map(|s| at(sign, *s)).collect(); + let mut dev = 0f64; + for (w, q) in run.windows(2).zip(pts.windows(2)) { + let (Some(a), Some(b), Some(pa), Some(pb)) = (w.first(), w.get(1), q.first(), q.get(1)) else { continue }; + dev = dev.max(at(sign, 0.5 * (a + b)).dist_to_segment(*pa, *pb)); + } + out.push((C3::Polyline(pts), dev)); + } + run.clear(); + }; + for i in 0..ss.len() { + let Some(&s) = ss.get(i) else { continue }; + let inside = c(s).abs() <= 1.; + let prev = i.checked_sub(1).and_then(|j| ss.get(j).copied()); + match (inside, prev.map(|p| c(p).abs() <= 1.)) { + (true, Some(false)) => { + if let Some(p) = prev { + run.push(edge(s, p)); + } + run.push(s); + } + (true, _) => run.push(s), + (false, Some(true)) => { + if let Some(p) = prev { + run.push(edge(p, s)); + } + flush(&mut run, &mut out); + } + (false, _) => {} + } + } + flush(&mut run, &mut out); + } + out + } + + /// Closest profile parameter to `(r, h)`, with the squared distance. + fn closest(&self, q: [f64; 2]) -> (f64, f64) { + if let C3::Line { .. } = self.profile { + let (p0, p1) = (self.rh(self.dom[0]), self.rh(self.dom[1])); + let e = [p1[0] - p0[0], p1[1] - p0[1]]; + let l2 = e[0] * e[0] + e[1] * e[1]; + let t = if l2 > 0. { (((q[0] - p0[0]) * e[0] + (q[1] - p0[1]) * e[1]) / l2).clamp(0., 1.) } else { 0. }; + let s = self.dom[0] + (self.dom[1] - self.dom[0]) * t; + let p = self.rh(s); + return (s, (p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)); + } + // Coarse scan, then the finer samples around the best coarse one. + let mut best = (0usize, f64::MAX); + for (i, smp) in self.samples.iter().enumerate().step_by(8) { + let d2 = (smp[1] - q[0]).powi(2) + (smp[2] - q[1]).powi(2); + if d2 < best.1 { + best = (i, d2); + } + } + let mut best = (self.samples.get(best.0).map_or(self.dom[0], |s| s[0]), best.1, best.0); + for i in best.2.saturating_sub(8)..(best.2 + 9).min(self.samples.len()) { + if let Some(smp) = self.samples.get(i) { + let d2 = (smp[1] - q[0]).powi(2) + (smp[2] - q[1]).powi(2); + if d2 < best.1 { + best = (smp[0], d2, i); + } + } + } + let best = (best.0, best.1); + let mut s = best.0; + for _ in 0..30 { + let p = self.rh(s); + let d = self.rh_d(s); + let f = (p[0] - q[0]) * d[0] + (p[1] - q[1]) * d[1]; + let fp = d[0] * d[0] + d[1] * d[1]; + if fp <= 1e-300 { + break; + } + let next = (s - f / fp).clamp(self.dom[0], self.dom[1]); + if (next - s).abs() <= 1e-15 * (1. + s.abs()) { + s = next; + break; + } + s = next; + } + let p = self.rh(s); + (s, (p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)) + } + + fn inverse(&self, p: Vec3, hint: Option<[f64; 2]>) -> Option<[f64; 2]> { + let w = p - self.o; + let h = w.dot(self.a); + let radial = w - self.a * h; + let r = radial.len(); + let theta = if r > 1e-12 * (1. + self.rmax) { radial.dot(self.e2).atan2(radial.dot(self.e1)) } else { hint.map_or(0., |u| u[0]) }; + let (s, _) = self.closest([r, h]); + let mut t = theta; + if self.theta[1] - self.theta[0] >= TAU - 1e-9 { + t = t.rem_euclid(TAU); + } else if t < self.theta[0] { + t += TAU; + } + Some([t, s]) + } + + /// Signed distance of `(r, h)` from the profile, or `None` beyond an open profile's ends. + fn signed(&self, q: [f64; 2]) -> Option { + let (s, d2) = self.closest(q); + let at_end = !self.closed && ((s - self.dom[0]).abs() <= 1e-12 * (1. + s.abs()) || (s - self.dom[1]).abs() <= 1e-12 * (1. + s.abs())); + let p = self.rh(s); + let t = self.rh_d(s); + if at_end { + // Beyond the end the normal distance is undefined unless q projects onto it. + let along = (q[0] - p[0]) * t[0] + (q[1] - p[1]) * t[1]; + let outward = if (s - self.dom[0]).abs() < (s - self.dom[1]).abs() { -along } else { along }; + if outward > 1e-12 * (1. + self.rmax) { + return None; + } + } + let cross = t[0] * (q[1] - p[1]) - t[1] * (q[0] - p[0]); + Some(cross.signum() * d2.sqrt()) + } + + fn ray(&self, p0: Vec3, d: Vec3, range: [f64; 2], out: &mut Vec) { + let rh = |lam: f64| { + let w = p0 + d * lam - self.o; + let h = w.dot(self.a); + [(w - self.a * h).len(), h] + }; + // Clip the ray to the bounding cylinder, coarsely, by sampling. + let pad = 1e-6 * (1. + self.rmax + (self.hrange[1] - self.hrange[0])); + let n = 400usize; + let f = |lam: f64| { + let q = rh(lam); + if q[0] > self.rmax + pad || q[1] < self.hrange[0] - pad || q[1] > self.hrange[1] + pad { None } else { self.signed(q) } + }; + // Values within the noise floor carry no sign (a ray tangent at its start, say). + let floor = 1e-9 * (1. + self.rmax + (self.hrange[1] - self.hrange[0])); + let mut prev: Option<(f64, f64)> = None; + let mut minima: Vec<(f64, f64)> = Vec::new(); + let mut last: Option = None; + let mut falling = false; + let at = |i: usize| range[0] + (range[1] - range[0]) * i as f64 / n as f64; + for i in 0..=n { + let lam = at(i); + let v = f(lam); + match (prev, v) { + (Some((la, fa)), Some(fb)) if fa.abs() > floor && fb.abs() > floor && fa.signum() != fb.signum() => { + let (mut lo, mut hi, mut flo) = (la, lam, fa); + for _ in 0..80 { + let mid = 0.5 * (lo + hi); + match f(mid) { + Some(fm) if fm.signum() == flo.signum() => { + lo = mid; + flo = fm; + } + Some(_) => hi = mid, + None => break, + } + } + let lam = 0.5 * (lo + hi); + if lam >= range[0] + && let Some(uv) = self.inverse(p0 + d * lam, None) + { + out.push(Hit { lam, uv, grazing: false }); + } + } + _ => {} + } + // Interior dips only: the range's first sample is never a minimum. + if let Some(fb) = v { + let a = fb.abs(); + if let Some(l) = last { + if falling && a > l { + minima.push((at(i - 1), l)); + } + falling = a < l; + } + last = Some(a); + } else { + falling = false; + last = None; + } + match v { + Some(fb) if fb.abs() > floor => prev = Some((lam, fb)), + Some(_) => {} + None => prev = None, + } + } + // Near-tangent rays: a dip close to zero without a sign change. + let tol = 1e-4 * (1. + self.rmax); + for (lam, a) in minima { + if a < tol + && lam >= range[0] + && !out.iter().any(|h| (h.lam - lam).abs() < 2. * (range[1] - range[0]) / n as f64) + && let Some(uv) = self.inverse(p0 + d * lam, None) + { + out.push(Hit { lam, uv, grazing: true }); + } + } + } + + fn analytic(&self, tol: f64) -> Analytic { + let size = self.rmax.max(self.hrange[1] - self.hrange[0]).max(1e-9); + let pts: Vec<[f64; 2]> = self.samples.iter().map(|s| [s[1], s[2]]).collect(); + let (Some(first), Some(last)) = (pts.first(), pts.last()) else { return Analytic::Other }; + // A straight profile: cylinder (constant r), cone, or a plane square to the axis. + let e = [last[0] - first[0], last[1] - first[1]]; + let el = (e[0] * e[0] + e[1] * e[1]).sqrt(); + let straight = el > 0. && pts.iter().all(|p| ((p[0] - first[0]) * e[1] - (p[1] - first[1]) * e[0]).abs() / el <= tol * size); + if straight { + if e[0].abs() <= tol * el { + return Analytic::Cylinder { p: self.o, a: self.a, r: first[0] }; + } + if e[1].abs() <= tol * el { + return Analytic::Plane; + } + // Apex where r = 0 on the line. + let t = -first[0] / e[0]; + let h_apex = first[1] + t * e[1]; + let dir = if e[1] * e[0] > 0. { self.a } else { -self.a }; + let half = (e[0].abs() / el).asin(); + return Analytic::Cone { apex: self.o + self.a * h_apex, a: dir, half }; + } + // A circular profile: sphere when its centre is on the axis, torus otherwise. + if let Some((c, _, r)) = as_circle(&self.profile, tol) { + let w = c - self.o; + let h = w.dot(self.a); + let rc = (w - self.a * h).len(); + if rc <= tol * size { + return Analytic::Sphere { c: self.o + self.a * h, r }; + } + return Analytic::Torus { c: self.o + self.a * h, a: self.a, major: rc, minor: r }; + } + Analytic::Other + } +} + +impl Nurbs { + fn new(u: &[f64], v: &[f64], rows: &[Vec<[f64; 4]>]) -> Result { + let nu = rows.len(); + let nv = rows.first().map_or(0, Vec::len); + let pu = u.len().checked_sub(nu + 1).ok_or(HlrError::Unsupported("surface knots"))?; + let pv = v.len().checked_sub(nv + 1).ok_or(HlrError::Unsupported("surface knots"))?; + let mut ctrl = Vec::with_capacity(nu * nv); + for j in 0..nv { + for row in rows { + let p = row.get(j).ok_or(HlrError::Unsupported("ragged surface"))?; + ctrl.push(HPoint { x: p[0], y: p[1], z: p[2], w: p[3] }); + } + } + let s = NurbsSurface::from_homogeneous([pu, pv], [u.to_vec(), v.to_vec()], ctrl).map_err(|_| HlrError::Unsupported("spline surface"))?; + let dom = s.domain(); + let g = (4 * nu.max(nv)).clamp(16, 64); + let mut n = Nurbs { s, dom, g, grid: Vec::new() }; + let mut grid = Vec::with_capacity((g + 1) * (g + 1)); + for j in 0..=g { + for i in 0..=g { + grid.push(n.point(n.grid_uv(i, j))); + } + } + if grid.iter().any(|p| !p.is_finite()) { + return Err(HlrError::Unsupported("spline surface evaluation")); + } + n.grid = grid; + Ok(n) + } + + pub fn grid_uv(&self, i: usize, j: usize) -> [f64; 2] { + let f = |k: usize, x: usize| self.dom[k][0] + (self.dom[k][1] - self.dom[k][0]) * x as f64 / self.g as f64; + [f(0, i), f(1, j)] + } + + fn point(&self, uv: [f64; 2]) -> Vec3 { + let u = uv[0].clamp(self.dom[0][0], self.dom[0][1]); + let v = uv[1].clamp(self.dom[1][0], self.dom[1][1]); + self.s.eval(u, v).unwrap_or(Vec3::new(f64::NAN, f64::NAN, f64::NAN)) + } + + fn partials(&self, uv: [f64; 2]) -> (Vec3, Vec3) { + let fd = |k: usize| { + let h = (self.dom[k][1] - self.dom[k][0]) * 1e-6; + let (mut a, mut b) = (uv, uv); + a[k] = (uv[k] - h).max(self.dom[k][0]); + b[k] = (uv[k] + h).min(self.dom[k][1]); + if b[k] - a[k] <= 0. { Vec3::ZERO } else { (self.point(b) - self.point(a)) / (b[k] - a[k]) } + }; + (fd(0), fd(1)) + } + + fn clamp(&self, uv: [f64; 2]) -> [f64; 2] { + [uv[0].clamp(self.dom[0][0], self.dom[0][1]), uv[1].clamp(self.dom[1][0], self.dom[1][1])] + } + + fn inverse(&self, p: Vec3, hint: Option<[f64; 2]>) -> Option<[f64; 2]> { + let mut uv = match hint { + Some(h) => self.clamp(h), + None => { + let mut best = (0usize, f64::MAX); + for (k, q) in self.grid.iter().enumerate() { + let d = q.dist(p); + if d < best.1 { + best = (k, d); + } + } + self.grid_uv(best.0 % (self.g + 1), best.0 / (self.g + 1)) + } + }; + for _ in 0..40 { + let (su, sv) = self.partials(uv); + let r = self.point(uv) - p; + let (a, b, c) = (su.dot(su), su.dot(sv), sv.dot(sv)); + let det = a * c - b * b; + if det.abs() <= 1e-300 { + break; + } + let (gu, gv) = (su.dot(r), sv.dot(r)); + let du = (gu * c - gv * b) / det; + let dv = (gv * a - gu * b) / det; + let next = self.clamp([uv[0] - du, uv[1] - dv]); + let moved = (next[0] - uv[0]).abs() + (next[1] - uv[1]).abs(); + uv = next; + if moved <= 1e-15 * (1. + uv[0].abs() + uv[1].abs()) { + break; + } + } + uv[0].is_finite().then_some(uv) + } + + fn ray(&self, p0: Vec3, d: Vec3, range: [f64; 2], out: &mut Vec) { + let ep = perp(d); + let eq = d.cross(ep); + let q2 = |p: Vec3| [(p - p0).dot(ep), (p - p0).dot(eq)]; + let g = self.g; + let start = out.len(); + let proj: Vec<[f64; 2]> = self.grid.iter().map(|p| q2(*p)).collect(); + for j in 0..g { + for i in 0..g { + let corners = [(i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)]; + let pts: Vec<[f64; 2]> = corners.iter().filter_map(|(a, b)| proj.get(b * (g + 1) + a).copied()).collect(); + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for p in &pts { + lo = [lo[0].min(p[0]), lo[1].min(p[1])]; + hi = [hi[0].max(p[0]), hi[1].max(p[1])]; + } + let pad = 0.25 * ((hi[0] - lo[0]).max(hi[1] - lo[1])); + if lo[0] - pad > 0. || hi[0] + pad < 0. || lo[1] - pad > 0. || hi[1] + pad < 0. { + continue; + } + let (a, b) = (self.grid_uv(i, j), self.grid_uv(i + 1, j + 1)); + let mut uv = [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])]; + let mut ok = false; + for _ in 0..40 { + let p = self.point(uv); + let f = q2(p); + let (su, sv) = self.partials(uv); + let (j11, j12, j21, j22) = (su.dot(ep), sv.dot(ep), su.dot(eq), sv.dot(eq)); + let det = j11 * j22 - j12 * j21; + if det.abs() <= 1e-300 { + break; + } + let du = (f[0] * j22 - f[1] * j12) / det; + let dv = (f[1] * j11 - f[0] * j21) / det; + uv = self.clamp([uv[0] - du, uv[1] - dv]); + if du.abs() + dv.abs() <= 1e-14 * (1. + uv[0].abs() + uv[1].abs()) { + let r = q2(self.point(uv)); + ok = r[0].abs() + r[1].abs() <= 1e-9 * (1. + self.size_hint()); + break; + } + } + // Keep roots inside this cell (with slack) so neighbouring cells don't repeat them. + let slack = [(b[0] - a[0]) * 1e-6, (b[1] - a[1]) * 1e-6]; + if !ok || uv[0] < a[0] - slack[0] || uv[0] > b[0] + slack[0] || uv[1] < a[1] - slack[1] || uv[1] > b[1] + slack[1] { + continue; + } + let p = self.point(uv); + let lam = (p - p0).dot(d); + if lam >= range[0] && lam <= range[1] && !out.iter().skip(start).any(|h| (h.lam - lam).abs() <= 1e-9 * (1. + lam)) { + let (su, sv) = self.partials(uv); + let grazing = su.cross(sv).normalized().is_none_or(|n| n.dot(d).abs() < 1e-6); + out.push(Hit { lam, uv, grazing }); + } + } + } + } + + fn size_hint(&self) -> f64 { + self.grid.first().zip(self.grid.last()).map_or(1., |(a, b)| a.dist(*b)) + } + + /// The recognised form, checked over the whole patch: on each knot span the implicit + /// equation of a surface of degree d, multiplied out by the weight, is a polynomial of + /// degree d p by d q in the span's parameters, so it is zero everywhere when zero at + /// d p + 1 by d q + 1 points. It is checked at d p + 3 by d q + 3 Chebyshev points of + /// every span and the span's corners: nothing between samples can hide a bump. + fn analytic(&self, tol: f64) -> Analytic { + let an = self.candidate(tol); + if matches!(an, Analytic::Other | Analytic::Plane) { + return an; + } + let pts: Vec = self.s.homogeneous().iter().filter_map(|h| h.project()).collect(); + let hull = pts.iter().skip(1).fold(pts.first().map(|p| (*p, *p)), |acc, p| acc.map(|(lo, hi)| (lo.min(*p), hi.max(*p)))); + let size = hull.map_or(1., |(lo, hi)| (hi - lo).len()).max(1e-9); + let [pu, pv] = self.s.degree(); + let d = an.implicit_degree(); + let spans = |k: usize| -> Vec<[f64; 2]> { + let (Some(ks), Some(dom)) = (self.s.knots().get(k), self.dom.get(k)) else { return Vec::new() }; + let mut cuts: Vec = ks.iter().copied().filter(|x| *x > dom[0] && *x < dom[1]).collect(); + cuts.push(dom[0]); + cuts.push(dom[1]); + cuts.sort_by(f64::total_cmp); + cuts.dedup(); + cuts.windows(2).filter_map(|w| Some([*w.first()?, *w.get(1)?])).collect() + }; + let nodes = |deg: usize, s: [f64; 2]| -> Vec { + let n = d * deg + 3; + let (mid, half) = (0.5 * (s[0] + s[1]), 0.5 * (s[1] - s[0])); + let inner = (0..n).map(move |k| mid + half * (std::f64::consts::PI * (2 * k + 1) as f64 / (2 * n) as f64).cos()); + [s[0], s[1]].into_iter().chain(inner).collect() + }; + // As for circles (curve::as_circle): the largest distance at the points, times the + // tensor Lebesgue constant (below 16) and the weights' spread to the power d, bounds the + // distance anywhere on the patch. + let (w_min, w_max) = self.s.homogeneous().iter().fold((f64::MAX, f64::MIN), |(lo, hi), h| (lo.min(h.w), hi.max(h.w))); + if !(w_min > 0. && w_max.is_finite()) { + return Analytic::Other; + } + let (su, sv) = (spans(0), spans(1)); + let mut far = 0f64; + for a in &su { + let us = nodes(pu, *a); + for b in &sv { + for v in nodes(pv, *b) { + for u in &us { + far = far.max(an.distance(self.point([*u, v]))); + } + } + } + } + if 16. * (w_max / w_min).powi(d as i32) * far <= tol * size { an } else { Analytic::Other } + } + + /// Cylinder/cone/sphere/torus candidates from iso-curves and grid samples. + fn candidate(&self, tol: f64) -> Analytic { + let size = self.size_hint().max(1e-9); + let iso = |k: usize, x: f64| -> C3 { + let n = 24; + let pts = (0..=n) + .map(|i| { + let t = self.dom[k][0] + (self.dom[k][1] - self.dom[k][0]) * i as f64 / n as f64; + if k == 0 { self.point([t, x]) } else { self.point([x, t]) } + }) + .collect(); + C3::Polyline(pts) + }; + let straight = |c: &C3| { + let C3::Polyline(p) = c else { return false }; + let (Some(a), Some(b)) = (p.first(), p.last()) else { return false }; + let Some(e) = (*b - *a).normalized() else { return false }; + p.iter().all(|q| ((*q - *a) - e * (*q - *a).dot(e)).len() <= tol * size) + }; + let circle_of = |k: usize, x: f64| -> Option<(Vec3, Vec3, f64)> { + // Fit through exact surface points, then verify densely. + let at = |f: f64| { + let t = self.dom[k][0] + (self.dom[k][1] - self.dom[k][0]) * f; + if k == 0 { self.point([t, x]) } else { self.point([x, t]) } + }; + let pts: Vec = (0..=32).map(|i| at(i as f64 / 32.)).collect(); + let sp = C3::Polyline(pts.clone()); + let (a, m, b) = (pts.first().copied()?, pts.get(16).copied()?, pts.get(8).copied()?); + let (u, v) = (m - a, b - a); + let n = u.cross(v); + let n2 = n.len2(); + // Nearly collinear relative to the points' own spacing. + if n2 <= 1e-24 * u.len2() * v.len2() { + return None; + } + let c = a + (v * u.len2() - u * v.len2()).cross(n) * (1. / (2. * n2)); + let nn = n.normalized()?; + let r = c.dist(a); + let ok = match &sp { + C3::Polyline(p) => p.iter().all(|q| (q.dist(c) - r).abs() <= tol * r && (*q - c).dot(nn).abs() <= tol * r), + _ => false, + }; + ok.then_some((c, nn, r)) + }; + for (line_dir, circ_dir) in [(1usize, 0usize), (0, 1)] { + let mids = [0., 0.5, 1.].map(|f| self.dom[circ_dir][0] + (self.dom[circ_dir][1] - self.dom[circ_dir][0]) * f); + if !mids.iter().all(|x| straight(&iso(line_dir, *x))) { + continue; + } + let ends = [0., 0.5, 1.].map(|f| self.dom[line_dir][0] + (self.dom[line_dir][1] - self.dom[line_dir][0]) * f); + let circles: Option> = ends.iter().map(|x| circle_of(circ_dir, *x)).collect(); + let Some(circles) = circles else { continue }; + let (Some(c0), Some(c2)) = (circles.first(), circles.last()) else { continue }; + let Some(axis) = (c2.0 - c0.0).normalized() else { continue }; + if c0.1.cross(axis).len() > tol { + continue; + } + if (c0.2 - c2.2).abs() <= tol * c0.2 { + return Analytic::Cylinder { p: c0.0, a: axis, r: c0.2 }; + } + let dr = (c2.2 - c0.2) / c0.0.dist(c2.0); + let half = dr.abs().atan(); + let dir = if dr > 0. { axis } else { -axis }; + let apex = c0.0 - dir * (c0.2 / dr.abs()); + return Analytic::Cone { apex, a: dir, half }; + } + // Sphere: every grid point equidistant from the centre of four spread points. Several + // spreads are tried: on a patch closed around (a full band) the corners meet at its seam. + let g = self.g; + let pick = |i: usize, j: usize| self.grid.get(j * (g + 1) + i).copied(); + let spreads = [ + [(0, 0), (g, 0), (g / 2, g / 2), (0, g)], + [(0, 0), (g / 2, 0), (g / 4, g), (3 * g / 4, g / 2)], + [(g / 3, 0), (0, g / 2), (2 * g / 3, g), (g, g / 3)], + ]; + for q in spreads { + let (Some(a), Some(b), Some(c), Some(d)) = (pick(q[0].0, q[0].1), pick(q[1].0, q[1].1), pick(q[2].0, q[2].1), pick(q[3].0, q[3].1)) + else { + continue; + }; + let Some(centre) = sphere_centre(a, b, c, d) else { continue }; + let r = centre.dist(a); + if r > 0. && self.grid.iter().all(|p| (p.dist(centre) - r).abs() <= tol * r) { + return Analytic::Sphere { c: centre, r }; + } + } + // Torus: circles both ways, a meridian giving the tube's centre and radius and a + // parallel the axis; then every grid point on that torus. + let mid = |k: usize| self.dom.get(k).map_or(0., |d| 0.5 * (d[0] + d[1])); + for (meridian, parallel) in [(0usize, 1usize), (1, 0)] { + let (Some(m), Some(p)) = (circle_of(meridian, mid(parallel)), circle_of(parallel, mid(meridian))) else { continue }; + let (a, minor) = (p.1, m.2); + let c = p.0 + a * (m.0 - p.0).dot(a); + let major = m.0.dist(c); + if !(major > 0. && minor > 0.) { + continue; + } + let on = self.grid.iter().all(|q| { + let w = *q - c; + let h = w.dot(a); + (((w - a * h).len() - major).hypot(h) - minor).abs() <= tol * (major + minor) + }); + if on { + return Analytic::Torus { c, a, major, minor }; + } + } + Analytic::Other + } +} + +fn sphere_centre(a: Vec3, b: Vec3, c: Vec3, d: Vec3) -> Option { + // Solve 2(p_i - a).x = |p_i|^2 - |a|^2 for i = b, c, d. + let rows = [b - a, c - a, d - a]; + let rhs = [b.len2() - a.len2(), c.len2() - a.len2(), d.len2() - a.len2()].map(|v| v * 0.5); + let [r0, r1, r2] = rows; + let det = r0.dot(r1.cross(r2)); + if det.abs() <= 1e-18 * (1. + r0.len() * r1.len() * r2.len()) { + return None; + } + let x = (r1.cross(r2) * rhs[0] + r2.cross(r0) * rhs[1] + r0.cross(r1) * rhs[2]) / det; + x.is_finite().then_some(x) +} diff --git a/crates/geom/src/hlr/trim.rs b/crates/geom/src/hlr/trim.rs new file mode 100644 index 0000000000..c416d33190 --- /dev/null +++ b/crates/geom/src/hlr/trim.rs @@ -0,0 +1,382 @@ +//! Face trimming in the surface chart. Boundary loops are sampled from the exact edge curves, +//! unwrapped across periodic directions, and a point is classified by the parity of boundary +//! crossings on a path to a point known to be inside. Seams (an edge used twice by one face) +//! are crossed twice, so they cancel without special cases. + +use super::curve::C3; +use super::surface::S3; +use crate::Vec3; +use crate::brep::Face; + +#[derive(Clone, Debug)] +pub(crate) struct Trim { + /// Unwrapped chart segments of all loops. + segs: Vec<([f64; 2], [f64; 2])>, + period: [Option; 2], + /// A point inside the face; `None` when the face covers its whole chart. + inside: Option<[f64; 2]>, + /// 3D boundary chords, for distance-to-boundary margins. + pub chords: Vec<(Vec3, Vec3)>, + /// Largest distance between an exact boundary curve and its chords. + pub chord_error: f64, + pub lo: Vec3, + pub hi: Vec3, + /// Why classification is unavailable, if it is. + pub unsupported: Option<&'static str>, + /// Index for the two-leg parity path q -> (r.u, q.v) -> r. + index: Option, + /// `chords` on a grid, for boundaries with many of them. + grid: Option, +} + +/// Boundary chords bucketed on a uniform grid over the boundary's box, so a point is checked +/// against the chords around it, not all of them (a circle alone has `trim_samples` per break). +/// Cells are at least as long as the longest chord, so a chord lies in at most two per axis. +#[derive(Clone, Debug)] +struct ChordGrid { + lo: Vec3, + cell: f64, + n: [usize; 3], + /// (cell, chord) pairs sorted by cell. + entries: Vec<(u32, u32)>, +} + +impl ChordGrid { + fn new(chords: &[(Vec3, Vec3)], lo: Vec3, hi: Vec3) -> Option { + // A few chords are checked faster one by one than through a grid. + if chords.len() < 64 || u32::try_from(chords.len()).is_err() { + return None; + } + let ext = hi - lo; + let longest = chords.iter().map(|(a, b)| a.dist(*b)).fold(0., f64::max); + let cell = longest.max(ext.len() / 256.).max(ext.x.max(ext.y).max(ext.z) / 512.); + if !(cell > 0. && cell.is_finite()) { + return None; + } + let n = [ext.x, ext.y, ext.z].map(|e| ((e / cell).floor() as usize).saturating_add(1).min(513)); + let mut grid = ChordGrid { lo, cell, n, entries: Vec::with_capacity(chords.len() * 2) }; + for (i, (a, b)) in (0u32..).zip(chords) { + let (c0, c1) = (grid.cell_of(a.min(*b)), grid.cell_of(a.max(*b))); + for x in c0[0]..=c1[0] { + for y in c0[1]..=c1[1] { + for z in c0[2]..=c1[2] { + grid.entries.push((grid.key([x, y, z]), i)); + } + } + } + } + grid.entries.sort_unstable(); + Some(grid) + } + + fn cell_of(&self, p: Vec3) -> [usize; 3] { + let at = |v: f64, l: f64, n: usize| (((v - l) / self.cell).floor().max(0.) as usize).min(n.saturating_sub(1)); + [at(p.x, self.lo.x, self.n[0]), at(p.y, self.lo.y, self.n[1]), at(p.z, self.lo.z, self.n[2])] + } + + fn key(&self, c: [usize; 3]) -> u32 { + // At most 513 cells per axis: the key fits in 28 bits. + ((c[0] * self.n[1] + c[1]) * self.n[2] + c[2]) as u32 + } + + /// Whether a chord touching a cell of the box `p` +- `tol` is within `tol` of `p`; `None` + /// when that box covers too many cells to be worth it. + fn near(&self, chords: &[(Vec3, Vec3)], p: Vec3, tol: f64) -> Option { + // A little wider than `tol`, so rounding in the distance cannot pick a chord whose + // nearest point lies in a cell left out. + let w = tol * (1. + 1e-6); + let t = Vec3::new(w, w, w); + let (c0, c1) = (self.cell_of(p - t), self.cell_of(p + t)); + let span = |k: usize| c1[k].saturating_sub(c0[k]).saturating_add(1); + if span(0).saturating_mul(span(1)).saturating_mul(span(2)) > 64 { + return None; + } + for x in c0[0]..=c1[0] { + for y in c0[1]..=c1[1] { + for z in c0[2]..=c1[2] { + let k = self.key([x, y, z]); + let first = self.entries.partition_point(|e| e.0 < k); + let close = self + .entries + .get(first..) + .unwrap_or_default() + .iter() + .take_while(|e| e.0 == k) + .any(|(_, i)| chords.get(*i as usize).is_some_and(|(a, b)| p.dist_to_segment(*a, *b) <= tol)); + if close { + return Some(true); + } + } + } + } + Some(false) + } +} + +/// Segment copies shifted across periods near the inside point, the sorted crossings of the +/// vertical line u = r.u, and v-buckets of the copies for the horizontal leg. +#[derive(Clone, Debug)] +struct Index { + copies: Vec<([f64; 2], [f64; 2])>, + vcross: Vec, + buckets: Vec>, + v0: f64, + dv: f64, +} + +fn spans(x: f64, a: f64, b: f64) -> bool { + (a <= x && x < b) || (b <= x && x < a) +} + +fn index(segs: &[([f64; 2], [f64; 2])], period: [Option; 2], r: [f64; 2]) -> Index { + let shifts = |k: usize| -> Vec { + let Some(p) = period[k] else { return vec![0.] }; + let (lo, hi) = segs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (a, b)| (lo.min(a[k]).min(b[k]), hi.max(a[k]).max(b[k]))); + let k0 = ((r[k] - p - hi) / p).floor() as i64; + let k1 = ((r[k] + p - lo) / p).ceil() as i64; + (k0.max(-8)..=k1.min(8)).map(|i| i as f64 * p).collect() + }; + let mut copies = Vec::new(); + for dx in shifts(0) { + for dy in shifts(1) { + copies.extend(segs.iter().map(|(a, b)| ([a[0] + dx, a[1] + dy], [b[0] + dx, b[1] + dy]))); + } + } + let mut vcross: Vec = + copies.iter().filter(|(a, b)| spans(r[0], a[0], b[0])).map(|(a, b)| a[1] + (b[1] - a[1]) * (r[0] - a[0]) / (b[0] - a[0])).collect(); + vcross.sort_by(f64::total_cmp); + let (v0, v1) = copies.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (a, b)| (lo.min(a[1]).min(b[1]), hi.max(a[1]).max(b[1]))); + let n = ((copies.len() as f64).sqrt().ceil() as usize).clamp(1, 4096); + let dv = ((v1 - v0) / n as f64).max(1e-300); + let mut buckets = vec![Vec::new(); n]; + for (i, (a, b)) in copies.iter().enumerate() { + let k0 = (((a[1].min(b[1]) - v0) / dv).floor().max(0.) as usize).min(n - 1); + let k1 = (((a[1].max(b[1]) - v0) / dv).floor().max(0.) as usize).min(n - 1); + for bucket in buckets.iter_mut().take(k1 + 1).skip(k0) { + bucket.push(i as u32); + } + } + Index { copies, vcross, buckets, v0, dv } +} + +fn lift(x: f64, near: f64, p: Option) -> f64 { + match p { + Some(p) if p > 0. => x + p * ((near - x) / p).round(), + _ => x, + } +} + +pub(crate) fn build(face: &Face, edges: &[C3], surface: &S3, per_break: usize) -> Trim { + let period = surface.periods(); + let mut segs = Vec::new(); + let mut seam = Vec::new(); + let mut chords = Vec::new(); + let mut chord_error = 0f64; + let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)); + let mut uses = std::collections::BTreeMap::new(); + for u in face.loops.iter().flatten() { + *uses.entry(u.edge).or_insert(0usize) += 1; + } + let mut area_u = 0.; // -loop integral of v du + let mut area_v = 0.; // loop integral of u dv + let mut wraps = [false; 2]; + let mut unsupported = None; + for lp in &face.loops { + let mut pts: Vec<([f64; 2], bool)> = Vec::new(); + let mut hint: Option<[f64; 2]> = None; + for u in lp { + let Some(c) = edges.get(u.edge as usize) else { + unsupported = Some("edge reference"); + continue; + }; + let is_seam = uses.get(&u.edge).copied().unwrap_or(0) > 1; + let mut ts = Vec::new(); + let b = c.breaks(); + for w in b.windows(2) { + let (Some(t0), Some(t1)) = (w.first(), w.get(1)) else { continue }; + for i in 0..per_break { + ts.push(t0 + (t1 - t0) * i as f64 / per_break as f64); + } + } + if let Some(last) = b.last() { + ts.push(*last); + } + if !u.forward { + ts.reverse(); + } + let mut prev3: Option<(f64, Vec3)> = None; + for t in ts { + let p = c.at(t); + lo = lo.min(p); + hi = hi.max(p); + if let Some((tp, q)) = prev3 { + chords.push((q, p)); + let m = c.at(0.5 * (t + tp)); + chord_error = chord_error.max(m.dist_to_segment(q, p)); + } + prev3 = Some((t, p)); + let Some(mut uv) = surface.inverse(p, hint) else { + unsupported = Some("boundary point off its surface"); + continue; + }; + if let Some(h) = hint { + uv = [lift(uv[0], h[0], period[0]), lift(uv[1], h[1], period[1])]; + } + hint = Some(uv); + pts.push((uv, is_seam)); + } + } + let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else { continue }; + // Close the loop at the lifted copy of its start (a wrapping loop ends a period away). + let close = [lift(first.0[0], last.0[0], period[0]), lift(first.0[1], last.0[1], period[1])]; + for k in 0..2 { + if (close[k] - first.0[k]).abs() > 1e-9 { + wraps[k] = true; + } + } + pts.push((close, last.1)); + for w in pts.windows(2) { + let (Some((a, _)), Some((b, s))) = (w.first(), w.get(1)) else { continue }; + area_u -= 0.5 * (a[1] + b[1]) * (b[0] - a[0]); + area_v += 0.5 * (a[0] + b[0]) * (b[1] - a[1]); + segs.push((*a, *b)); + seam.push(*s); + } + } + let area = match wraps { + [true, true] => { + unsupported = unsupported.or(Some("loops wrap in both chart directions")); + 0. + } + [false, true] => area_v, + _ => area_u, + }; + // Loops that wind once around a periodic direction without coming back (a hemisphere's + // equator) close against the chart's own edge, so their area depends on where the chart + // starts: there the face is taken on the loops' left (the B-rep convention), and below the + // side within the surface's domain wins either way. + let net = segs.iter().fold([0f64; 2], |n, (a, b)| [n[0] + b[0] - a[0], n[1] + b[1] - a[1]]); + let open = (0..2).any(|k| period[k].is_some_and(|p| net[k].abs() > 0.5 * p)); + let sigma = if open { 1. } else { area.signum() }; + let dom = surface.domain(); + // Inside point: beside the longest non-seam segment, on the face's side. + let mut inside = None; + let real: Vec = (0..segs.len()).filter(|i| !seam.get(*i).copied().unwrap_or(true)).collect(); + if !real.is_empty() && (open || area != 0.) { + let len = |i: usize| segs.get(i).map_or(0., |(a, b)| ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt()); + let best = real.iter().copied().max_by(|a, b| len(*a).total_cmp(&len(*b))); + if let Some((a, b)) = best.and_then(|i| segs.get(i)) { + let m = [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])]; + let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + let n = [-(b[1] - a[1]) / l * sigma, (b[0] - a[0]) / l * sigma]; + // Stay well inside: a quarter of the distance to any other boundary piece. + let mut room = l; + for (j, (c, e)) in segs.iter().enumerate() { + if Some(j) == best { + continue; + } + for (dx, dy) in shifts(period) { + let (c2, e2) = ([c[0] + dx, c[1] + dy], [e[0] + dx, e[1] + dy]); + room = room.min(point_segment(m, c2, e2)); + } + } + let delta = 0.25 * room; + // At most half way to the chart's domain edge, so a face hugging that edge (a cone's + // tip) keeps its point; a loop on the edge itself has no room on the outer side. + let step = |dir: [f64; 2]| { + let mut s = delta; + for (((d, &c), &mk), p) in dom.iter().zip(&dir).zip(&m).zip(&period) { + if p.is_some() { + continue; + } + if c < 0. { + s = s.min(0.5 * (mk - d[0]) / -c); + } else if c > 0. { + s = s.min(0.5 * (d[1] - mk) / c); + } + } + s + }; + let flip = [-n[0], -n[1]]; + let pick = [(n, step(n)), (flip, step(flip))].into_iter().find(|(_, s)| *s > 0. && s.is_finite()); + if let Some((dir, s)) = pick { + inside = Some([m[0] + dir[0] * s, m[1] + dir[1] * s]); + } + } + if inside.is_none() { + unsupported = unsupported.or(Some("no room for an inside reference point")); + } + } + let index = inside.map(|r| index(&segs, period, r)); + let grid = ChordGrid::new(&chords, lo, hi); + Trim { segs, period, inside, chords, chord_error, lo, hi, unsupported, index, grid } +} + +fn shifts(period: [Option; 2]) -> Vec<(f64, f64)> { + let ks = |p: Option| p.map_or(vec![0.], |p| vec![-p, 0., p]); + let mut out = Vec::new(); + for dx in ks(period[0]) { + for dy in ks(period[1]) { + out.push((dx, dy)); + } + } + out +} + +fn point_segment(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let l2 = ex * ex + ey * ey; + let t = if l2 > 0. { (((p[0] - a[0]) * ex + (p[1] - a[1]) * ey) / l2).clamp(0., 1.) } else { 0. }; + ((a[0] + t * ex - p[0]).powi(2) + (a[1] + t * ey - p[1]).powi(2)).sqrt() +} + +impl Trim { + /// Whether chart point `uv` lies inside the face; `None` when the trim is unsupported. + /// Parity of boundary crossings on the path q -> (r.u, q.v) -> r to the inside point r. + pub fn contains(&self, uv: [f64; 2]) -> Option { + if self.unsupported.is_some() { + return None; + } + let (Some(r), Some(ix)) = (self.inside, self.index.as_ref()) else { return Some(true) }; + let q = [lift(uv[0], r[0], self.period[0]), lift(uv[1], r[1], self.period[1])]; + let mut crossings = 0usize; + // Horizontal leg at v = q.v between q.u and r.u. + let (ulo, uhi) = (q[0].min(r[0]), q[0].max(r[0])); + let k = (((q[1] - ix.v0) / ix.dv).floor().max(0.) as usize).min(ix.buckets.len().saturating_sub(1)); + for i in ix.buckets.get(k).map(Vec::as_slice).unwrap_or(&[]) { + let Some((a, b)) = ix.copies.get(*i as usize) else { continue }; + if spans(q[1], a[1], b[1]) { + let u = a[0] + (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]); + if u > ulo && u <= uhi { + crossings += 1; + } + } + } + // Vertical leg at u = r.u between q.v and r.v. + let (vlo, vhi) = (q[1].min(r[1]), q[1].max(r[1])); + let first = ix.vcross.partition_point(|v| *v <= vlo); + let last = ix.vcross.partition_point(|v| *v <= vhi); + crossings += last.saturating_sub(first); + Some(crossings.is_multiple_of(2)) + } + + /// The chart box of the unwrapped boundary. + pub fn uv_bounds(&self) -> Option<([f64; 2], [f64; 2])> { + let mut it = self.segs.iter().flat_map(|(a, b)| [a, b]); + let first = *it.next()?; + Some(it.fold((first, first), |(lo, hi), p| ([lo[0].min(p[0]), lo[1].min(p[1])], [hi[0].max(p[0]), hi[1].max(p[1])]))) + } + + /// Whether a 3D point is within `tol` of the sampled boundary: the boundary's box first, + /// then the chords around the point (all of them for a short boundary or a wide `tol`). + pub fn near(&self, p: Vec3, tol: f64) -> bool { + let out = (self.lo - p).max(p - self.hi).max(Vec3::new(0., 0., 0.)); + if !(out.len() <= tol) { + return false; + } + match self.grid.as_ref().and_then(|g| g.near(&self.chords, p, tol)) { + Some(close) => close, + None => self.chords.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol), + } + } +} diff --git a/crates/geom/src/hlr_tests.rs b/crates/geom/src/hlr_tests.rs new file mode 100644 index 0000000000..d56729e3d9 --- /dev/null +++ b/crates/geom/src/hlr_tests.rs @@ -0,0 +1,2213 @@ +use super::*; +use crate::brep::{Brep, Curve, Edge, EdgeUse, Face, Shell, Surface}; + +/// An axis-aligned box with line edges and plane faces. +fn cuboid(lo: [f64; 3], hi: [f64; 3]) -> Brep { + let corner = |i: usize| [if i & 1 == 0 { lo[0] } else { hi[0] }, if i & 2 == 0 { lo[1] } else { hi[1] }, if i & 4 == 0 { lo[2] } else { hi[2] }]; + let vertices: Vec<[f64; 3]> = (0..8).map(corner).collect(); + let pairs = [(0, 1), (2, 3), (4, 5), (6, 7), (0, 2), (1, 3), (4, 6), (5, 7), (0, 4), (1, 5), (2, 6), (3, 7)]; + let edges: Vec = + pairs.iter().map(|&(a, b)| Edge { vertices: [a as u32, b as u32], curve: Curve::Line { start: corner(a), end: corner(b) } }).collect(); + let cycle = |vs: [usize; 4]| -> Vec { + (0..4) + .map(|k| { + let (a, b) = (vs[k], vs[(k + 1) % 4]); + let (i, fwd) = pairs + .iter() + .enumerate() + .find_map(|(i, &(x, y))| (x == a && y == b).then_some((i, true)).or((x == b && y == a).then_some((i, false)))) + .unwrap(); + EdgeUse { edge: i as u32, forward: fwd } + }) + .collect() + }; + let plane = |o: usize, u: usize, v: usize| { + let (o, u, v) = (corner(o), corner(u), corner(v)); + Surface::Plane { origin: o, u_axis: [u[0] - o[0], u[1] - o[1], u[2] - o[2]], v_axis: [v[0] - o[0], v[1] - o[1], v[2] - o[2]] } + }; + let faces = vec![ + Face { loops: vec![cycle([0, 2, 3, 1])], surface: plane(0, 2, 1), forward: true }, + Face { loops: vec![cycle([4, 5, 7, 6])], surface: plane(4, 5, 6), forward: true }, + Face { loops: vec![cycle([0, 1, 5, 4])], surface: plane(0, 1, 4), forward: true }, + Face { loops: vec![cycle([2, 6, 7, 3])], surface: plane(2, 6, 3), forward: true }, + Face { loops: vec![cycle([0, 4, 6, 2])], surface: plane(0, 4, 2), forward: true }, + Face { loops: vec![cycle([1, 3, 7, 5])], surface: plane(1, 3, 5), forward: true }, + ]; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices, edges, faces }] } +} + +fn identity(body: usize, t: [f64; 3]) -> Placement { + Placement { body, rotation: [Vec3::new(1., 0., 0.), Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)], translation: Vec3::new(t[0], t[1], t[2]) } +} + +fn never() -> bool { + false +} + +#[test] +fn iso_cube_hides_the_three_back_edges() { + let def = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -1., -1.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let mut hidden = Vec::new(); + for l in &d.lines { + let Source::Edge { edge, .. } = l.source else { panic!("a box has no silhouettes") }; + let states: Vec<_> = l.pieces.iter().map(|p| p.visibility).collect(); + assert!(states.windows(2).all(|w| w[0] == w[1]), "edge {edge} is split: {states:?}"); + if states[0] == Visibility::Hidden { + hidden.push(edge); + } + } + hidden.sort_unstable(); + // The edges at the corner nearest the origin: (0,1), (0,2), (0,4). + assert_eq!(hidden, vec![0, 4, 8], "{d:#?}"); +} + +#[test] +fn a_box_in_front_hides_the_middle_of_an_edge() { + let back = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap(); + let front = prepare(&cuboid([0.5, -2., 0.25], [1.5, -1., 0.75]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[back, front], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edge 9 joins corners 1 (1,0,0) and 5 (1,0,1): t is z. + let line = d.lines.iter().find(|l| l.placement == 0 && l.source == Source::Edge { edge: 9, tangent: false }).unwrap(); + let got: Vec<([f64; 2], Visibility)> = line.pieces.iter().map(|p| (p.t, p.visibility)).collect(); + assert_eq!(got.len(), 3, "{got:?}"); + assert_eq!(got[1].1, Visibility::Hidden); + assert!((got[1].0[0] - 0.25).abs() < 1e-12 && (got[1].0[1] - 0.75).abs() < 1e-12, "{got:?}"); + assert_eq!((got[0].1, got[2].1), (Visibility::Visible, Visibility::Visible)); +} + +#[test] +fn quadratic_roots_with_no_linear_term() { + // A ray from inside a sphere, square to the radius at its start (b = 0): both roots. + let mut roots = surface::quadratic(1., 0., -100.); + roots.sort_by(|a, b| a.0.total_cmp(&b.0)); + assert_eq!(roots.len(), 2, "{roots:?}"); + assert!((roots[0].0 + 10.).abs() < 1e-12 && (roots[1].0 - 10.).abs() < 1e-12, "{roots:?}"); + let mut roots = surface::quadratic(1., -0., -100.); + roots.sort_by(|a, b| a.0.total_cmp(&b.0)); + assert!((roots[0].0 + 10.).abs() < 1e-12 && (roots[1].0 - 10.).abs() < 1e-12, "{roots:?}"); +} + +#[test] +fn a_torus_seen_along_its_axis_has_both_equator_silhouettes() { + // A whole-meridian torus record: the outer equator sits where the closed profile's ends meet. + let frame = crate::brep::Frame { origin: [0.; 3], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }; + let tau = std::f64::consts::TAU; + let s = surface::S3::new(&Surface::Torus { frame, major_radius: 20., minor_radius: 5., longitude: [0., tau], meridian: [0., tau] }).unwrap(); + let mut radii: Vec = s + .rev_silhouette(Vec3::new(0., 0., -1.)) + .iter() + .map(|(c, _)| match c { + curve::C3::Arc { r, .. } => r[0], + _ => panic!("circles expected"), + }) + .collect(); + radii.sort_by(f64::total_cmp); + assert_eq!(radii.len(), 2, "{radii:?}"); + assert!((radii[0] - 15.).abs() < 1e-9 && (radii[1] - 25.).abs() < 1e-9, "{radii:?}"); +} + +#[test] +fn far_apart_bodies_do_not_merge_nearby_visible_edges() { + // A 5 mm cube and a box 100 m away: coincidence is judged at the chords' own accuracy, + // not at a fraction of the scene's size, so none of the cube's edges is taken as a duplicate. + let near = prepare(&cuboid([0.; 3], [5.; 3]), &Options::default()).unwrap(); + let far = prepare(&cuboid([100_000.; 3], [100_001.; 3]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.7, -0.4), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[near, far], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let covered_visible = d + .lines + .iter() + .filter(|l| l.placement == 0) + .flat_map(|l| l.pieces.iter()) + .filter(|p| p.visibility == Visibility::Visible && p.covered) + .count(); + assert_eq!(covered_visible, 0); +} + +#[test] +fn a_thin_torus_tube_stops_a_ray() { + // R 100, r 0.1: a ray across the tube meets it twice within 0.2 mm. + let roots = surface::torus_roots(Vec3::new(100., -50., 0.), Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.), 100., 0.1, [0., 200.]); + assert!(roots.iter().filter(|(_, grazing)| !grazing).count() >= 2, "{roots:?}"); +} + +#[test] +fn a_thin_tube_on_a_huge_torus_stops_a_ray() { + // R 100 m, r 1 mm: inside the tube the implicit function stays far below a size^4 floor. + // A radial ray from 5 mm inside the centre circle crosses the tube at 4 and 6 mm. + let roots = surface::torus_roots(Vec3::new(100_000. - 5., 0., 0.), Vec3::new(1., 0., 0.), Vec3::new(0., 0., 1.), 100_000., 1., [0., 10.]); + let hits: Vec = roots.iter().filter(|(_, grazing)| !grazing).map(|(l, _)| *l).collect(); + // The implicit form cancels terms of 4e20 here: the roots are good to about 1e-6 mm. + assert!(hits.len() == 2 && (hits[0] - 4.).abs() < 1e-5 && (hits[1] - 6.).abs() < 1e-5, "{roots:?}"); +} + +#[test] +fn a_hair_thin_tube_on_a_kilometre_torus_stops_a_ray() { + // R 1 km, r 0.1 mm (R/r = 1e7): roots at 0.9 and 1.1 mm, good to about 1e-4 mm here. + let roots = surface::torus_roots(Vec3::new(1e6 - 1., 0., 0.), Vec3::new(1., 0., 0.), Vec3::new(0., 0., 1.), 1e6, 0.1, [0., 2.]); + let hits: Vec = roots.iter().filter(|(_, grazing)| !grazing).map(|(l, _)| *l).collect(); + assert!(hits.len() == 2 && (hits[0] - 0.9).abs() < 1e-3 && (hits[1] - 1.1).abs() < 1e-3, "{roots:?}"); +} + +#[test] +fn a_ray_from_the_hole_crosses_a_thin_large_torus() { + // R 4 m, r 0.01 mm, from the centre outward over 8 m: only the shell around the tube is sampled. + let roots = surface::torus_roots(Vec3::new(0., 0., 0.), Vec3::new(1., 0., 0.), Vec3::new(0., 0., 1.), 4000., 0.01, [0., 8000.]); + let hits: Vec = roots.iter().filter(|(_, grazing)| !grazing).map(|(l, _)| *l).collect(); + assert!(hits.len() == 2 && (hits[0] - 3999.99).abs() < 1e-6 && (hits[1] - 4000.01).abs() < 1e-6, "{roots:?}"); +} + +#[test] +fn a_torus_seen_nearly_along_its_axis_keeps_its_silhouettes() { + // Meridian starting off the equator and a view 1e-4 rad from the axis: the silhouette lives + // in narrow profile intervals around the equators, which must not fall between samples. + let frame = crate::brep::Frame { origin: [0.; 3], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }; + let tau = std::f64::consts::TAU; + let shift = std::f64::consts::PI / 720.; + let s = + surface::S3::new(&Surface::Torus { frame, major_radius: 1000., minor_radius: 500., longitude: [0., tau], meridian: [shift, shift + tau] }) + .unwrap(); + let d = Vec3::new(1e-4, 0., -1.).normalized().unwrap(); + let curves = s.rev_silhouette(d); + // Both the outer (radius about 1500) and the inner (about 500) silhouettes are there. + let radii: Vec = curves + .iter() + .flat_map(|(c, _)| { + let [t0, t1] = c.domain(); + (0..=8).map(move |i| c.at(t0 + (t1 - t0) * i as f64 / 8.)) + }) + .map(|p| p.x.hypot(p.y)) + .collect(); + assert!(radii.iter().any(|r| *r > 1400.) && radii.iter().any(|r| *r < 600.), "{radii:?}"); +} + +#[test] +fn a_shallow_cut_through_a_torus_tube_is_found() { + // R 20, r 5, a ray along the axis 0.01 mm inside the outer equator: a 0.63 mm chord. + let roots = surface::torus_roots(Vec3::new(24.99, 0., -10.), Vec3::new(0., 0., 1.), Vec3::new(0., 0., 1.), 20., 5., [0., 20.]); + let hits: Vec = roots.iter().filter(|(_, grazing)| !grazing).map(|(l, _)| *l).collect(); + let half = (25f64 - 4.99f64.powi(2)).sqrt(); + assert!(hits.len() == 2 && (hits[0] - (10. - half)).abs() < 1e-6 && (hits[1] - (10. + half)).abs() < 1e-6, "{roots:?}"); +} + +/// A frame at `o` with x along +X and y along +Y (z along +Z). +fn frame_at(o: [f64; 3]) -> crate::brep::Frame { + crate::brep::Frame { origin: o, x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] } +} + +/// A full circle about Z through vertex `v`. +fn circle(o: [f64; 3], r: f64, v: u32) -> Edge { + Edge { vertices: [v, v], curve: Curve::CircleArc { frame: frame_at(o), radius: r, angle: [0., std::f64::consts::TAU] } } +} + +fn uses(edge: u32, forward: bool) -> EdgeUse { + EdgeUse { edge, forward } +} + +/// A cylinder of radius `r` and height `h` standing on the XY plane, as analytic records. +fn cylinder(r: f64, h: f64) -> Brep { + turned(r, r, h) +} + +/// A cylinder (`r0 == r1`) or a cone frustum from radius `r0` at z = 0 to `r1` at z = `h`, +/// its seam at angle 0. +fn turned(r0: f64, r1: f64, h: f64) -> Brep { + let tau = std::f64::consts::TAU; + let seam = Edge { vertices: [0, 1], curve: Curve::Line { start: [r0, 0., 0.], end: [r1, 0., h] } }; + let edges = vec![circle([0.; 3], r0, 0), circle([0., 0., h], r1, 1), seam]; + let side = if r0 == r1 { + Surface::Cylinder { frame: frame_at([0.; 3]), radius: r0, angle: [0., tau], height: [0., h] } + } else { + Surface::Cone { frame: frame_at([0.; 3]), radius_at_origin: r0, half_angle: ((r1 - r0) / h).atan(), angle: [0., tau], height: [0., h] } + }; + let faces = vec![ + Face { loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], surface: side, forward: true }, + Face { + loops: vec![vec![uses(0, false)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., -1., 0.] }, + forward: true, + }, + Face { + loops: vec![vec![uses(1, true)]], + surface: Surface::Plane { origin: [0., 0., h], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }, + ]; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: vec![[r0, 0., 0.], [r1, 0., h]], edges, faces }] } +} + +/// A sphere of radius `r` at the origin: two hemispheres meeting at the equator. +fn sphere(r: f64) -> Brep { + let (tau, half) = (std::f64::consts::TAU, std::f64::consts::FRAC_PI_2); + let hemisphere = |latitude: [f64; 2], forward| Face { + loops: vec![vec![uses(0, forward)]], + surface: Surface::Sphere { frame: frame_at([0.; 3]), radius: r, longitude: [0., tau], latitude }, + forward: true, + }; + let faces = vec![hemisphere([0., half], true), hemisphere([-half, 0.], false)]; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: vec![[r, 0., 0.]], edges: vec![circle([0.; 3], r, 0)], faces }] } +} + +/// A torus about Z (major radius `big`, minor `small`): top and bottom halves meeting at the +/// outer and inner equators. +fn torus(big: f64, small: f64) -> Brep { + let (tau, pi) = (std::f64::consts::TAU, std::f64::consts::PI); + let half = |meridian: [f64; 2], forward: bool| Face { + loops: vec![vec![uses(0, forward)], vec![uses(1, !forward)]], + surface: Surface::Torus { frame: frame_at([0.; 3]), major_radius: big, minor_radius: small, longitude: [0., tau], meridian }, + forward: true, + }; + let edges = vec![circle([0.; 3], big + small, 0), circle([0.; 3], big - small, 1)]; + let faces = vec![half([0., pi], true), half([pi, tau], false)]; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: vec![[big + small, 0., 0.], [big - small, 0., 0.]], edges, faces }] } +} + +/// A bilinear (twisted) spline patch over x, y in [-5, 5] with z between 0 and 1. +fn twisted_patch() -> Brep { + let p = [[-5., -5., 0.], [5., -5., 1.], [5., 5., 0.], [-5., 5., 1.]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: p[i], end: p[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Spline { u_knots: vec![0., 0., 1., 1.], v_knots: vec![0., 0., 1., 1.], points: vec![vec![p[0], p[3]], vec![p[1], p[2]]] }, + forward: true, + }; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: p.to_vec(), edges, faces: vec![face] }] } +} + +fn pieces(d: &Drawing) -> impl Iterator { + d.lines.iter().flat_map(|l| l.pieces.iter()) +} + +/// The turn of a circle edge's pieces drawn as exact conic arcs, or `None` when they are all +/// drawn as chords (weights too far apart to certify the circle): never a wrong exact arc. +fn turn_or_chords<'a>(pieces: impl Iterator) -> Option { + let pieces: Vec<&Piece> = pieces.collect(); + if pieces.iter().all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })) { + return None; + } + let mut sweep = 0.; + for p in pieces { + assert_eq!(p.accuracy, CurveAccuracy::Analytic, "{p:?}"); + for g in &p.geometry { + let Seg2::Conic { w, .. } = *g else { panic!("a circle is drawn as conic arcs: {g:?}") }; + sweep += 2. * w.acos(); + } + } + Some(sweep) +} + +fn silhouette_pieces(d: &Drawing) -> Vec<&Piece> { + d.lines.iter().filter(|l| matches!(l.source, Source::Silhouette { .. })).flat_map(|l| l.pieces.iter()).collect() +} + +fn edge_line(d: &Drawing, placement: usize, edge: usize) -> &Line { + let found = d.lines.iter().find(|l| l.placement == placement && matches!(l.source, Source::Edge { edge: e, .. } if e == edge)); + found.unwrap_or_else(|| panic!("no line for edge {edge} of placement {placement}")) +} + +#[test] +fn a_cylinder_seen_from_the_side_has_exact_silhouette_lines() { + let def = prepare(&cylinder(10., 30.), &Options::default()).unwrap(); + // Slightly from above, along +X (rulings at +-90 degrees) and along +Y (rulings at 0 and + // 180 degrees: one runs along the seam, which is not drawn as an edge). + for dir in [Vec3::new(1., 0., -0.3), Vec3::new(0., 1., -0.3)] { + let view = View::new(dir, Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(std::slice::from_ref(&def), &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + check_cylinder_sides(&d, &view); + } +} + +fn check_cylinder_sides(d: &Drawing, view: &View) { + let sil = silhouette_pieces(d); + assert!(!sil.is_empty()); + let rise = 30. * view.y.z; + let mut sides = Vec::new(); + for p in sil { + assert_eq!(p.accuracy, CurveAccuracy::Analytic); + assert_eq!(p.visibility, Visibility::Visible); + for g in &p.geometry { + let Seg2::Line { a, b } = *g else { panic!("a silhouette ruling is a line: {g:?}") }; + assert!((a.x.abs() - 10.).abs() < 1e-12 && (a.x - b.x).abs() < 1e-12, "{g:?}"); + sides.push((a.x.signum(), (b.y - a.y).abs())); + } + } + for side in [-1., 1.] { + let len: f64 = sides.iter().filter(|s| s.0 == side).map(|s| s.1).sum(); + assert!((len - rise).abs() < 1e-9, "side {side}: {len} vs {rise}"); + } + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); + assert!(d.unsupported.is_empty()); +} + +#[test] +fn a_sphere_outline_is_exact_conic_arcs_in_any_view() { + let def = prepare(&sphere(10.), &Options::default()).unwrap(); + let views = [ + (Vec3::new(1., 2., 3.), Vec3::new(0., 0., 1.)), + (Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)), + (Vec3::new(-0.3, 1., 0.1), Vec3::new(0., 0., 1.)), + ]; + for (dir, up) in views { + let view = View::new(dir, up).unwrap(); + let d = hidden_lines(std::slice::from_ref(&def), &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // The outline: every uncovered visible piece lies on the circle of radius 10. + let mut drawn = 0.; + for p in + pieces(&d).filter(|p| p.visibility == Visibility::Visible && !p.covered && p.geometry.iter().all(|g| matches!(g, Seg2::Conic { .. }))) + { + assert_eq!(p.accuracy, CurveAccuracy::Analytic); + for g in &p.geometry { + let ends = (g.point_at(0.), g.point_at(1.)); + if (ends.0.len() - 10.).abs() > 1e-9 { + continue; + } + for k in 0..=16 { + let q = g.point_at(k as f64 / 16.); + assert!((q.len() - 10.).abs() < 1e-9, "{dir:?}: {q:?} off the outline"); + } + drawn += ends.0.dist(ends.1); + } + } + assert!(drawn > 40., "{dir:?}: the outline is drawn ({drawn} mm of chords)"); + for p in silhouette_pieces(&d) { + assert!(p.geometry.iter().all(|g| matches!(g, Seg2::Conic { .. })), "{dir:?}: {p:?}"); + } + assert_eq!(d.stats.uncertain_uncovered, 0, "{dir:?}: {:?}", d.stats); + } +} + +#[test] +fn a_torus_seen_face_on_is_drawn_at_both_equators() { + let def = prepare(&torus(20., 5.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let (mut outer, mut inner) = (false, false); + for p in pieces(&d).filter(|p| p.visibility == Visibility::Visible && !p.covered) { + for g in &p.geometry { + for k in 0..=8 { + let r = g.point_at(k as f64 / 8.).len(); + assert!((r - 25.).abs() < 1e-9 || (r - 15.).abs() < 1e-9, "a point at radius {r}"); + outer |= (r - 25.).abs() < 1e-9; + inner |= (r - 15.).abs() < 1e-9; + } + } + } + assert!(outer && inner); + // Both equators are silhouettes along the seams between the halves: one curve each. + assert_eq!(d.stats.silhouettes, 2, "{:?}", d.stats); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); +} + +#[test] +fn an_edge_under_a_torus_is_hidden_where_the_tube_covers_it() { + // Looking down on a torus (R 20, r 5) over a long bar: the bar's top edge along y = 1 is + // hidden where sqrt(x^2 + 1) is between 15 and 25. + let t = prepare(&torus(20., 5.), &Options::default()).unwrap(); + let bar = prepare(&cuboid([-40., -1., -12.], [40., 1., -10.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[t, bar], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edge 3 joins corners 6 (-40, 1, -10) and 7 (40, 1, -10): x = 80 t - 40. + let hidden: Vec<[f64; 2]> = + edge_line(&d, 1, 3).pieces.iter().filter(|p| p.visibility == Visibility::Hidden).map(|p| [p.t[0] * 80. - 40., p.t[1] * 80. - 40.]).collect(); + let (a, b) = (224f64.sqrt(), 624f64.sqrt()); + assert_eq!(hidden.len(), 2, "{hidden:?}"); + for (h, want) in hidden.iter().zip([[-b, -a], [a, b]]) { + assert!((h[0] - want[0]).abs() < 1e-9 && (h[1] - want[1]).abs() < 1e-9, "{hidden:?}"); + } + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); +} + +#[test] +fn a_part_far_from_the_origin_draws_the_same_as_at_the_origin() { + let def = prepare(&cylinder(10., 30.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.7, -0.4), Vec3::new(0., 0., 1.)).unwrap(); + let defs = [def]; + let states = |d: &Drawing| -> Vec<(Visibility, bool, usize)> { pieces(d).map(|p| (p.visibility, p.covered, p.geometry.len())).collect() }; + let base = hidden_lines(&defs, &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for t in [[1e5, 2e5, -3e5], [1e7, 2e7, -3e7]] { + let d = hidden_lines(&defs, &[identity(0, t)], &view, &Options::default(), &never).unwrap(); + assert_eq!(states(&base), states(&d), "classification moved at {t:?}"); + let shift = view.screen(Vec3::new(t[0], t[1], t[2])); + // Only the final shift back to the caller's frame rounds: about 1e-16 of |t|. + let tol = 1e-15 * (t[0].abs() + t[1].abs() + t[2].abs()) + 1e-9; + for (a, b) in pieces(&base).zip(pieces(&d)) { + for (g, h) in a.geometry.iter().zip(&b.geometry) { + for s in [0., 0.5, 1.] { + let (p, q) = (g.point_at(s), h.point_at(s)); + assert!((q.x - shift[0] - p.x).abs() < tol && (q.y - shift[1] - p.y).abs() < tol, "{p:?} vs {q:?} at {t:?}"); + } + } + } + } +} + +#[test] +fn freeform_faces_are_reported_and_never_hide_for_certain() { + let patch = prepare(&twisted_patch(), &Options::default()).unwrap(); + assert_eq!(patch.report.freeform_faces, vec![0]); + // A bar under the patch, seen from above: its top edges (2, 3, 6, 7) are behind the patch only. + let bar = prepare(&cuboid([-2., -1., -5.], [2., 1., -3.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.1, 0.05, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let defs = [patch, bar]; + let scene = [identity(0, [0.; 3]), identity(1, [0.; 3])]; + let top = |d: &Drawing| -> Vec { + [2, 3, 6, 7].iter().flat_map(|&e| edge_line(d, 1, e).pieces.iter().map(|p| p.visibility)).collect::>() + }; + let d = hidden_lines(&defs, &scene, &view, &Options::default(), &never).unwrap(); + assert_eq!(d.unsupported, vec![Unsupported { placement: 0, face: 0, reason: "freeform face" }]); + assert!(top(&d).iter().all(|v| *v == Visibility::Uncertain), "{:?}", top(&d)); + // With freeform faces handled the patch hides them. + let d = hidden_lines(&defs, &scene, &view, &Options { freeform: true, ..Options::default() }, &never).unwrap(); + assert!(d.unsupported.is_empty()); + assert!(top(&d).iter().all(|v| *v == Visibility::Hidden), "{:?}", top(&d)); +} + +#[test] +fn a_flat_spline_patch_is_a_plane_that_hides_for_certain() { + // A bilinear patch with all four corners at z = 0 over a bar whose top is at z = -1. + let p = [[-5., -5., 0.], [5., -5., 0.], [5., 5., 0.], [-5., 5., 0.]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: p[i], end: p[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Spline { u_knots: vec![0., 0., 1., 1.], v_knots: vec![0., 0., 1., 1.], points: vec![vec![p[0], p[3]], vec![p[1], p[2]]] }, + forward: true, + }; + let patch = + prepare(&Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: p.to_vec(), edges, faces: vec![face] }] }, &Options::default()) + .unwrap(); + assert!(patch.report.freeform_faces.is_empty()); + let bar = prepare(&cuboid([-2., -1., -3.], [2., 1., -1.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.1, 0.05, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[patch, bar], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(d.unsupported.is_empty()); + let top: Vec = [2, 3, 6, 7].iter().flat_map(|&e| edge_line(&d, 1, e).pieces.iter().map(|p| p.visibility)).collect(); + assert!(top.iter().all(|v| *v == Visibility::Hidden), "{top:?}"); +} + +/// The Bernstein coefficients of the product of two polynomials given by theirs. +fn bernstein_product(a: &[f64], b: &[f64]) -> Vec { + let binom = |n: usize, k: usize| (0..k).fold(1f64, |acc, i| acc * (n - i) as f64 / (i + 1) as f64); + let (m, n) = (a.len() - 1, b.len() - 1); + (0..=m + n) + .map(|k| { + let terms = (k.saturating_sub(n)..=k.min(m)).map(|i| binom(m, i) * binom(n, k - i) * a[i] * b[k - i]); + terms.sum::() / binom(m + n, k) + }) + .collect() +} + +#[test] +fn a_circle_racing_through_its_first_turn_is_drawn_whole_edge_on() { + // A closed rational sextic on the circle of radius 10: 10 (A + iB)^6 / |A + iB|^6 with + // A = 0.05 + 0.95 t, B = sqrt 3 t. It turns 3.6 rad in its first screen sample step, and + // seen along its plane that step looks straight; the arcs must still turn 360 degrees. + // A + iB = 0.05 (1 - t) + (1 + i sqrt 3) t, so (A + iB)^6 has the Bernstein coefficients + // 0.05^(6-k) (1 + i sqrt 3)^k, and A^2 + B^2 the coefficients 0.0025, 0.05, 4. + let c1 = (1f64, 3f64.sqrt()); + let mut num = Vec::new(); + let mut pw = (1f64, 0f64); + for k in 0..=6 { + num.push((0.05f64.powi(6 - k) * pw.0, 0.05f64.powi(6 - k) * pw.1)); + pw = (pw.0 * c1.0 - pw.1 * c1.1, pw.0 * c1.1 + pw.1 * c1.0); + } + let q = [0.0025, 0.05, 4.]; + let w = bernstein_product(&bernstein_product(&q, &q), &q); + let homogeneous: Vec<[f64; 4]> = num.iter().zip(&w).map(|((re, im), w)| [10. * re, 10. * im, 0., *w]).collect(); + let start = [10., 0., 0.]; + let disc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![start], + edges: vec![Edge { vertices: [0, 0], curve: Curve::RationalSpline { knots: [[0.; 7], [1.; 7]].concat(), homogeneous } }], + faces: vec![Face { + loops: vec![vec![uses(0, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&disc, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + if let Some(sweep) = turn_or_chords(pieces(&d)) { + assert!((sweep - std::f64::consts::TAU).abs() < 1e-9, "the arcs turn {sweep} rad"); + } +} + +#[test] +fn an_arc_racing_through_almost_a_full_turn_is_drawn_whole() { + // 10 z^4 / |z|^4 with z = (1 - t) + 400 e^(i (pi/2 - 0.01)) t: an exact arc of radius 10 that + // turns 2 pi - 0.04 almost all within t < 0.01, closed by a short line. Seen along its plane + // at -0.45 rad, the first samples look straight and turn little between them; the arcs + // must still turn the whole 2 pi - 0.04. + let phi = std::f64::consts::FRAC_PI_2 - 0.01; + let c1 = (400. * phi.cos(), 400. * phi.sin()); + let mut num = Vec::new(); + let mut pw = (1f64, 0f64); + for _ in 0..=4 { + num.push(pw); + pw = (pw.0 * c1.0 - pw.1 * c1.1, pw.0 * c1.1 + pw.1 * c1.0); + } + let q = [1., c1.0, 160_000.]; + let w = bernstein_product(&q, &q); + let homogeneous: Vec<[f64; 4]> = num.iter().zip(&w).map(|((re, im), w)| [10. * re, 10. * im, 0., *w]).collect(); + let sweep_want = 4. * phi; + let end = [10. * sweep_want.cos(), 10. * sweep_want.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::RationalSpline { knots: [[0.; 5], [1.; 5]].concat(), homogeneous } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.45f64.sin(), 0.45f64.cos(), 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + if let Some(sweep) = turn_or_chords(edge_line(&d, 0, 0).pieces.iter()) { + assert!((sweep - sweep_want).abs() < 1e-9, "the arcs turn {sweep} rad, want {sweep_want}"); + } +} + +#[test] +fn a_short_piece_of_a_fast_arc_turns_its_own_way() { + // An exact arc of radius 10 as a quartic rational spline of two spans: 0 to 5 rad over the + // parameters 0 to 1e-6, then on to 5.8 rad; a chord closes the loop. A box in front, seen + // along -Z, cuts the arc into pieces a fraction of 1e-6 long that turn several radians. + let span = |c0: (f64, f64), c1: (f64, f64)| -> Vec<[f64; 4]> { + let mul = |a: (f64, f64), b: (f64, f64)| (a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0); + let pw = |c: (f64, f64), k: usize| (0..k).fold((1., 0.), |acc, _| mul(acc, c)); + let q = [c0.0 * c0.0 + c0.1 * c0.1, c0.0 * c1.0 + c0.1 * c1.1, c1.0 * c1.0 + c1.1 * c1.1]; + let w = bernstein_product(&q, &q); + (0..=4) + .map(|k| { + let z = mul(pw(c0, 4 - k), pw(c1, k)); + [10. * z.0, 10. * z.1, 0., w[k]] + }) + .collect() + }; + let unit = |a: f64| (a.cos(), a.sin()); + // Weights far apart in the first span (|z| from 1 to 40) crowd its turn near its start. + let fast = (40. * 1.25f64.cos(), 40. * 1.25f64.sin()); + let first = span(unit(0.), fast); + let second = span(fast, unit(1.45)); + let homogeneous: Vec<[f64; 4]> = first.iter().chain(second.iter().skip(1)).copied().collect(); + let end = [10. * 5.8f64.cos(), 10. * 5.8f64.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::RationalSpline { knots: [vec![0.; 5], vec![1e-6; 4], vec![1.; 5]].concat(), homogeneous } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let arc = prepare(&shape, &Options::default()).unwrap(); + let block = prepare(&cuboid([-8.2, -11., 1.], [11., -0.001, 2.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[arc, block], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let line = edge_line(&d, 0, 0); + assert!(line.pieces.len() > 1, "the block splits the arc"); + if let Some(sweep) = turn_or_chords(line.pieces.iter()) { + assert!((sweep - 5.8).abs() < 1e-9, "the pieces turn {sweep} rad in all, want 5.8"); + } +} + +/// The homogeneous control points of an exact quartic arc of radius 10 about the origin from +/// angle `a0` to `a1` (z^4 / |z|^4 with z running straight between the quarter angles). +fn quartic_arc(a0: f64, a1: f64) -> Vec<[f64; 4]> { + let (c0, c1) = ((a0 / 4.).cos(), (a0 / 4.).sin()); + let (d0, d1) = ((a1 / 4.).cos(), (a1 / 4.).sin()); + let mul = |a: (f64, f64), b: (f64, f64)| (a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0); + let pw = |c: (f64, f64), k: usize| (0..k).fold((1., 0.), |acc, _| mul(acc, c)); + let q = [1., c0 * d0 + c1 * d1, 1.]; + let w = bernstein_product(&q, &q); + (0..=4) + .map(|k| { + let z = mul(pw((c0, c1), 4 - k), pw((d0, d1), k)); + [10. * z.0, 10. * z.1, 0., w[k]] + }) + .collect() +} + +#[test] +fn an_arc_whose_whole_domain_is_8_roundings_wide_is_not_guessed() { + // An exact arc of radius 10 turning +3.75 rad over the parameters 0.25 to 0.25 + 2^-51 + // (8 roundings), closed by a chord: drawn as the right arc or as chords, never as the short + // arc the other way round, through (0, -10). + let k = 0.25 + 2f64.powi(-51); + let end = [10. * 3.75f64.cos(), 10. * 3.75f64.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { + vertices: [0, 1], + curve: Curve::RationalSpline { knots: [vec![0.25; 5], vec![k; 5]].concat(), homogeneous: quartic_arc(0., 3.75) }, + }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + if let Some(sweep) = turn_or_chords(edge_line(&d, 0, 0).pieces.iter()) { + assert!((sweep - 3.75).abs() < 1e-9, "the arcs turn {sweep} rad"); + } + for p in &edge_line(&d, 0, 0).pieces { + for g in &p.geometry { + for s in [0., 0.25, 0.5, 0.75, 1.] { + assert!(g.point_at(s).dist(Vec2::new(0., -10.)) > 1., "{g:?} passes the wrong side"); + } + } + } +} + +#[test] +fn a_piece_in_a_knot_span_of_1e_11_turns_its_own_way() { + // An exact arc of radius 10 in three quartic spans: 0 to 0.1 rad, then 0.1 to 3.85 rad over + // the parameters 0.25 to 0.25 + 1e-11, then on to 4.5 rad. Two blocks in front hide the + // outer spans, so the middle one is a visible piece of its own turning 3.75 rad. + let span = quartic_arc; + let (s1, s2, s3) = (span(0., 0.1), span(0.1, 3.85), span(3.85, 4.5)); + let homogeneous: Vec<[f64; 4]> = s1.iter().chain(s2.iter().skip(1)).chain(s3.iter().skip(1)).copied().collect(); + let k = 0.25 + 1e-11; + let end = [10. * 4.5f64.cos(), 10. * 4.5f64.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { + vertices: [0, 1], + curve: Curve::RationalSpline { knots: [vec![0.; 5], vec![0.25; 4], vec![k; 4], vec![1.; 5]].concat(), homogeneous }, + }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let arc = prepare(&shape, &Options::default()).unwrap(); + let low = prepare(&cuboid([9., -1., 1.], [11., 10. * 0.1f64.sin(), 2.]), &Options::default()).unwrap(); + let high = prepare(&cuboid([-11., -11., 1.], [0., 10. * 3.85f64.sin(), 2.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let scene = [identity(0, [0.; 3]), identity(1, [0.; 3]), identity(2, [0.; 3])]; + let d = hidden_lines(&[arc, low, high], &scene, &view, &Options::default(), &never).unwrap(); + let line = edge_line(&d, 0, 0); + let mut sweep = 0.; + for p in &line.pieces { + for g in &p.geometry { + match *g { + Seg2::Conic { w, .. } => sweep += 2. * w.acos(), + _ => panic!("an arc is drawn as conic arcs: {g:?}"), + } + } + } + assert!(line.pieces.len() >= 3, "{:?}", line.pieces.iter().map(|p| p.t).collect::>()); + assert!((sweep - 4.5).abs() < 1e-9, "the pieces turn {sweep} rad in all, want 4.5"); +} + +#[test] +fn a_curve_that_swerves_where_its_weight_is_near_zero_is_not_taken_for_a_circle() { + // 1e-12 (1 - t)^3 (0, -10) + t Q(t), Q the exact quarter circle of radius 10 from (10, 0) + // to (0, 10): on the circle at every check point to 1.3e-9 mm, yet near t = 1e-12, where + // its weight is tiny, it passes (5, -5), 2.9 mm inside. Drawn as chords, not an arc. + let s = std::f64::consts::FRAC_1_SQRT_2; + let q = [[10., 0., 0., 1.], [10. * s, 10. * s, 0., s], [0., 10., 0., 1.]]; + let homogeneous: Vec<[f64; 4]> = (0..4) + .map(|k| { + let tq: Vec = (0..4).map(|c| bernstein_product(&[0., 1.], &q.map(|h| h[c]))[k]).collect(); + let a = if k == 0 { [0., -10e-12, 0., 1e-12] } else { [0.; 4] }; + [tq[0] + a[0], tq[1] + a[1], tq[2] + a[2], tq[3] + a[3]] + }) + .collect(); + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[0., -10., 0.], [0., 10., 0.]], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::RationalSpline { knots: [[0.; 4], [1.; 4]].concat(), homogeneous } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: [0., 10., 0.], end: [0., -10., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(edge_line(&d, 0, 0).pieces.iter().all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{d:?}"); +} + +#[test] +fn an_edge_leaving_a_left_out_faces_box_in_front_stays_visible() { + // Over the twisted patch (x, y in [-5, 5], z in [0, 1]), seen from above, an edge from + // (-10, 0, -1) up to (0, 0, 5): below the patch's level only outside its box, inside the + // box above the patch. Nothing can hide it. + let patch = prepare(&twisted_patch(), &Options::default()).unwrap(); + let c = [[-10., 0., -1.], [0., 0., 5.], [0., 0.2, 5.], [-10., 0.2, -1.]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: c[i], end: c[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Plane { origin: c[0], u_axis: [10., 0., 6.], v_axis: [0., 0.2, 0.] }, + forward: true, + }; + let strip = + prepare(&Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: c.to_vec(), edges, faces: vec![face] }] }, &Options::default()) + .unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[patch, strip], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let states: Vec<([f64; 2], Visibility)> = edge_line(&d, 1, 0).pieces.iter().map(|p| (p.t, p.visibility)).collect(); + assert!(states.iter().all(|(_, v)| *v == Visibility::Visible), "{states:?}"); +} + +#[test] +fn a_nearly_straight_cubic_is_not_taken_for_a_huge_circle() { + // x = 1000 t, y = 0.071 t - 0.113 t^2 + 0.042 t^3: within 0.002 mm of a circle of radius + // about 1e7, which is 2e-10 of that radius but 2e-6 of the curve. Drawn as chords. + let pts = [[0., 0., 0.], [1000. / 3., 0.071 / 3., 0.], [2000. / 3., 0.071 * 2. / 3. - 0.113 / 3., 0.], [1000., 0., 0.]]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[0.; 3], [1000., 0., 0.]], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::Spline { knots: [[0.; 4], [1.; 4]].concat(), points: pts.to_vec() } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: [1000., 0., 0.], end: [0.; 3] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(edge_line(&d, 0, 0).pieces.iter().all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{d:?}"); +} + +#[test] +fn an_edge_dipping_behind_a_left_out_face_between_samples_is_uncertain() { + // Under a freeform patch over x in [0, 10], y in [0, 1] (z within 1e-4 of zero, zero along + // y = 0.5), a quadratic edge x = 10 t, y = 0.5, z = 20 (t - 0.0075)(t - 0.02375) dips + // below the patch for t from 0.0075 to 0.02375, between its samples; its projection is + // straight, so the screen never asks for more. + let e = 1e-4; + let p = [[0., 0., e], [10., 0., -e], [10., 1., e], [0., 1., -e]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: p[i], end: p[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Spline { u_knots: vec![0., 0., 1., 1.], v_knots: vec![0., 0., 1., 1.], points: vec![vec![p[0], p[3]], vec![p[1], p[2]]] }, + forward: true, + }; + let patch = + prepare(&Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: p.to_vec(), edges, faces: vec![face] }] }, &Options::default()) + .unwrap(); + assert_eq!(patch.report.freeform_faces, vec![0]); + // z = 20 t^2 - 0.625 t + 0.0035625 as a quadratic Bezier. + let z = [0.0035625, 0.0035625 - 0.3125, 20. - 0.625 + 0.0035625]; + let c = [[0., 0.5, z[0]], [5., 0.5, z[1]], [10., 0.5, z[2]]]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![c[0], c[2]], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::Spline { knots: vec![0., 0., 0., 1., 1., 1.], points: c.to_vec() } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: c[2], end: c[0] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: c[0], u_axis: [1., 0., 0.], v_axis: [0., 0., 1.] }, + forward: true, + }], + }], + }; + let curve = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[patch, curve], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let dip: Vec<&Piece> = edge_line(&d, 1, 0).pieces.iter().filter(|p| p.t[0] < 0.02 && p.t[1] > 0.01).collect(); + assert!(!dip.is_empty()); + assert!(dip.iter().all(|p| p.visibility != Visibility::Visible), "{dip:?}"); +} + +#[test] +fn an_arc_that_turns_back_is_drawn_as_chords() { + // (10 (1 - u^2), 20 u) / (1 + u^2) with u = 10 (t^3 / 3 - t^2 / 4 + 0.04 t): on the circle + // of radius 10, but u falls between t = 0.1 and 0.4, so the edge goes back down to + // y = -0.533 before it turns on to 1.78 rad. No single arc draws that: chords, which reach + // down there. + let u3 = [0., 0.4 / 3., 0.8 / 3. - 2.5 / 3., 0.4 - 2.5 + 10. / 3.]; + let u = bernstein_product(&u3, &[1.; 4]); + let uu = bernstein_product(&u3, &u3); + let homogeneous: Vec<[f64; 4]> = (0..7).map(|k| [10. * (1. - uu[k]), 20. * u[k], 0., 1. + uu[k]]).collect(); + let th = 2. * u3[3].atan(); + let end = [10. * th.cos(), 10. * th.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::RationalSpline { knots: [[0.; 7], [1.; 7]].concat(), homogeneous } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let line = edge_line(&d, 0, 0); + assert!(line.pieces.iter().all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{:?}", line.pieces); + let low = line.pieces.iter().flat_map(|p| p.geometry.iter()).map(|g| g.point_at(0.).y.min(g.point_at(1.).y)).fold(f64::MAX, f64::min); + assert!(low < -0.5, "the chords reach down to y = {low}"); +} + +#[test] +fn a_cone_tip_trimmed_near_the_apex_is_only_its_tip() { + // A cone record of slope 0.25 from its apex up to 10 m, trimmed by a circle at z = 1 to the + // tip below it: a box edge at x = 40, z = 50 to 150, seen along +X, is in front of nothing. + let tau = std::f64::consts::TAU; + let tip = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[0.25, 0., 1.]], + edges: vec![circle([0., 0., 1.], 0.25, 0)], + faces: vec![Face { + loops: vec![vec![uses(0, false)]], + surface: Surface::Cone { + frame: frame_at([0.; 3]), + radius_at_origin: 0., + half_angle: 0.25f64.atan(), + angle: [0., tau], + height: [0., 10_000.], + }, + forward: true, + }], + }], + }; + let tip = prepare(&tip, &Options::default()).unwrap(); + let bar = prepare(&cuboid([40., -1., 50.], [41., 1., 150.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(1., 0., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[tip, bar], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edges 8 and 10 run along z at x = 40, the side facing the viewer. + for e in [8, 10] { + let states: Vec = edge_line(&d, 1, e).pieces.iter().map(|p| p.visibility).collect(); + assert!(states.iter().all(|v| *v == Visibility::Visible), "edge {e}: {states:?}"); + } +} + +#[test] +fn an_edge_behind_a_left_out_freeform_face_is_uncertain_there() { + // A freeform tube about the vertical line x = 10 (radius 2.5 at its ends, 2.75 halfway up, + // a quadratic profile) in front of a long box edge at z = 5. Its silhouettes are left out, + // so nothing cuts the edge where the tube covers it (x from 7.25 to 12.75); that part may + // be hidden and must not come out visible. + let tau = std::f64::consts::TAU; + let profile = Curve::Spline { knots: vec![0., 0., 0., 1., 1., 1.], points: vec![[12.5, 0., 0.], [13., 0., 5.], [12.5, 0., 10.]] }; + let tube = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[12.5, 0., 0.], [12.5, 0., 10.]], + edges: vec![circle([10., 0., 0.], 2.5, 0), circle([10., 0., 10.], 2.5, 1), Edge { vertices: [0, 1], curve: profile.clone() }], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Revolution { profile: Box::new(profile), origin: [10., 0., 0.], axis: [0., 0., 1.], angle: [0., tau] }, + forward: true, + }], + }], + }; + let tube = prepare(&tube, &Options::default()).unwrap(); + assert_eq!(tube.report.freeform_faces, vec![0]); + let bar = prepare(&cuboid([-50., 4., 4.], [50., 6., 5.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[tube, bar], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edge 2 joins corners 4 (-50, 4, 5) and 5 (50, 4, 5): x = 100 t - 50. + for p in &edge_line(&d, 1, 2).pieces { + let (x0, x1) = (100. * p.t[0] - 50., 100. * p.t[1] - 50.); + if x1 > 7.25 && x0 < 12.75 { + assert_ne!(p.visibility, Visibility::Visible, "x {x0}..{x1}"); + } + if x1 < 6. || x0 > 14. { + assert_eq!(p.visibility, Visibility::Visible, "x {x0}..{x1}"); + } + } +} + +#[test] +fn a_sloping_edge_that_runs_behind_a_left_out_face_is_uncertain() { + // Over the twisted patch (z = 0.5 along y = 0), seen from above, a sloping strip's edge runs + // from (-4, 0, 2) down to (4, 0, 0.4): in front of the patch at its middle, behind it past + // x = 3.5. All of it lies in the patch's screen box, so nothing cuts it there. + let patch = prepare(&twisted_patch(), &Options::default()).unwrap(); + let c = [[-4., 0., 2.], [4., 0., 0.4], [4., 0.2, 0.4], [-4., 0.2, 2.]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: c[i], end: c[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Plane { origin: c[0], u_axis: [8., 0., -1.6], v_axis: [0., 0.2, 0.] }, + forward: true, + }; + let strip = + prepare(&Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: c.to_vec(), edges, faces: vec![face] }] }, &Options::default()) + .unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[patch, strip], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for p in &edge_line(&d, 1, 0).pieces { + if 8. * p.t[1] - 4. > 3.6 { + assert_ne!(p.visibility, Visibility::Visible, "{p:?}"); + } + } +} + +#[test] +fn a_torus_stored_as_a_rational_spline_patch_is_recognised() { + // The patch of a torus R 20, r 5 with longitude and meridian both from -pi/4 to pi/4, as a + // degree (2, 2) rational spline, seen along the axis: a torus, not a freeform face, and the + // outer equator inside it is an exact silhouette arc of radius 25. + let (big, small) = (20., 5.); + let q = std::f64::consts::FRAC_PI_4; + let s2 = std::f64::consts::SQRT_2; + // The unit arc from -45 to 45 degrees: its control points and weights. + let arc = [([q.cos(), -q.sin()], 1.), ([s2, 0.], q.cos()), ([q.cos(), q.sin()], 1.)]; + let homogeneous: Vec> = arc + .iter() + .map(|(ci, wi)| { + arc.iter() + .map(|(mj, wj)| { + let (rho, h, w) = (big + small * mj[0], small * mj[1], wi * wj); + [rho * ci[0] * w, rho * ci[1] * w, h * w, w] + }) + .collect() + }) + .collect(); + let at = |t: f64, f: f64| [(big + small * f.cos()) * t.cos(), (big + small * f.cos()) * t.sin(), small * f.sin()]; + let parallel = |f: f64| crate::brep::Frame { origin: [0., 0., small * f.sin()], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }; + let meridian = |t: f64| crate::brep::Frame { origin: [big * t.cos(), big * t.sin(), 0.], x_axis: [t.cos(), t.sin(), 0.], y_axis: [0., 0., 1.] }; + let edges = vec![ + Edge { vertices: [0, 1], curve: Curve::CircleArc { frame: parallel(-q), radius: big + small * q.cos(), angle: [-q, q] } }, + Edge { vertices: [1, 2], curve: Curve::CircleArc { frame: meridian(q), radius: small, angle: [-q, q] } }, + Edge { vertices: [3, 2], curve: Curve::CircleArc { frame: parallel(q), radius: big + small * q.cos(), angle: [-q, q] } }, + Edge { vertices: [0, 3], curve: Curve::CircleArc { frame: meridian(-q), radius: small, angle: [-q, q] } }, + ]; + let face = Face { + loops: vec![vec![uses(0, true), uses(1, true), uses(2, false), uses(3, false)]], + surface: Surface::RationalSpline { u_knots: vec![0., 0., 0., 1., 1., 1.], v_knots: vec![0., 0., 0., 1., 1., 1.], homogeneous }, + forward: true, + }; + let patch = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { vertices: vec![at(-q, -q), at(q, -q), at(q, q), at(-q, q)], edges, faces: vec![face] }], + }; + let def = prepare(&patch, &Options::default()).unwrap(); + assert!(def.report.freeform_faces.is_empty(), "{:?}", def.report); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(d.unsupported.is_empty()); + let mut length = 0.; + for p in silhouette_pieces(&d) { + assert_eq!(p.accuracy, CurveAccuracy::Analytic); + for g in &p.geometry { + for k in 0..=8 { + assert!((g.point_at(k as f64 / 8.).len() - 25.).abs() < 1e-6, "{g:?}"); + } + length += (0..16).map(|k| g.point_at(k as f64 / 16.).dist(g.point_at((k + 1) as f64 / 16.))).sum::(); + } + } + assert!((length - 25. * std::f64::consts::FRAC_PI_2).abs() < 0.01, "{length} mm of outline"); +} + +#[test] +fn a_torus_patch_with_a_bump_in_a_narrow_knot_span_is_not_taken_for_a_torus() { + // The torus patch R 20, r 5 again, its longitude in three spans, the middle one 0.002 of + // the parameters wide; one control point there pushed out by 0.2 %. Between the sample + // grid's points, but not between the checks on every knot span. + let (big, small) = (20., 5.); + let q = std::f64::consts::FRAC_PI_4; + let s2 = std::f64::consts::SQRT_2; + let ts = [-q, -q + 0.3, -q + 0.5, q]; + let mut lon: Vec<([f64; 2], f64)> = vec![([ts[0].cos(), ts[0].sin()], 1.)]; + for w in ts.windows(2) { + let (m, h) = (0.5 * (w[0] + w[1]), 0.5 * (w[1] - w[0])); + lon.push(([m.cos() / h.cos(), m.sin() / h.cos()], h.cos())); + lon.push(([w[1].cos(), w[1].sin()], 1.)); + } + let mer = [([q.cos(), -q.sin()], 1.), ([s2, 0.], q.cos()), ([q.cos(), q.sin()], 1.)]; + let homogeneous: Vec> = lon + .iter() + .enumerate() + .map(|(i, (ci, wi))| { + mer.iter() + .enumerate() + .map(|(j, (mj, wj))| { + let bump = if (i, j) == (3, 1) { 1.002 } else { 1. }; + let (rho, h, w) = ((big + small * mj[0]) * bump, small * mj[1], wi * wj); + [rho * ci[0] * w, rho * ci[1] * w, h * w, w] + }) + .collect() + }) + .collect(); + let at = |t: f64, f: f64| [(big + small * f.cos()) * t.cos(), (big + small * f.cos()) * t.sin(), small * f.sin()]; + let parallel = |f: f64| crate::brep::Frame { origin: [0., 0., small * f.sin()], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }; + let meridian = |t: f64| crate::brep::Frame { origin: [big * t.cos(), big * t.sin(), 0.], x_axis: [t.cos(), t.sin(), 0.], y_axis: [0., 0., 1.] }; + let edges = vec![ + Edge { vertices: [0, 1], curve: Curve::CircleArc { frame: parallel(-q), radius: big + small * q.cos(), angle: [-q, q] } }, + Edge { vertices: [1, 2], curve: Curve::CircleArc { frame: meridian(q), radius: small, angle: [-q, q] } }, + Edge { vertices: [3, 2], curve: Curve::CircleArc { frame: parallel(q), radius: big + small * q.cos(), angle: [-q, q] } }, + Edge { vertices: [0, 3], curve: Curve::CircleArc { frame: meridian(-q), radius: small, angle: [-q, q] } }, + ]; + let face = Face { + loops: vec![vec![uses(0, true), uses(1, true), uses(2, false), uses(3, false)]], + surface: Surface::RationalSpline { + u_knots: vec![0., 0., 0., 0.1, 0.1, 0.102, 0.102, 1., 1., 1.], + v_knots: vec![0., 0., 0., 1., 1., 1.], + homogeneous, + }, + forward: true, + }; + let patch = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { vertices: vec![at(-q, -q), at(q, -q), at(q, q), at(-q, q)], edges, faces: vec![face] }], + }; + let def = prepare(&patch, &Options::default()).unwrap(); + assert_eq!(def.report.freeform_faces, vec![0], "{:?}", def.report); +} + +#[test] +fn a_big_circle_passing_behind_a_thin_left_out_face_is_uncertain_there() { + // A disc of radius 5000 whose edge passes under a freeform tube (radius 0.01 to about + // 0.02, a cubic profile) lying along Y over (5000, 0), with a cube 9 km away making the scene coarse. The edge is turned so + // that its samples on either side of the tube are 30 mm away at x = 4999.905: the chord + // between them misses the tube's box by less than its sag, crossing none of its sides. + let tau = std::f64::consts::TAU; + let turn = 8.5 * std::f64::consts::PI / 256.; + let (c, sn) = (turn.cos(), turn.sin()); + let frame = crate::brep::Frame { origin: [0.; 3], x_axis: [-c, -sn, 0.], y_axis: [sn, -c, 0.] }; + let disc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[-5000. * c, -5000. * sn, 0.]], + edges: vec![Edge { vertices: [0, 0], curve: Curve::CircleArc { frame, radius: 5000., angle: [0., tau] } }], + faces: vec![Face { + loops: vec![vec![uses(0, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let profile = Curve::Spline { + knots: vec![0., 0., 0., 0., 1., 1., 1., 1.], + points: vec![[5000.01, -100., 1.], [5000.03, -33., 1.], [5000.01, 33., 1.], [5000.01, 100., 1.]], + }; + let end = |y: f64, v: u32| Edge { + vertices: [v, v], + curve: Curve::CircleArc { + frame: crate::brep::Frame { origin: [5000., y, 1.], x_axis: [1., 0., 0.], y_axis: [0., 0., 1.] }, + radius: 0.01, + angle: [0., tau], + }, + }; + let tube = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[5000.01, -100., 1.], [5000.01, 100., 1.]], + edges: vec![end(-100., 0), end(100., 1), Edge { vertices: [0, 1], curve: profile.clone() }], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Revolution { profile: Box::new(profile), origin: [5000., 0., 1.], axis: [0., 1., 0.], angle: [0., tau] }, + forward: true, + }], + }], + }; + let defs = [ + prepare(&tube, &Options::default()).unwrap(), + prepare(&disc, &Options::default()).unwrap(), + prepare(&cuboid([9e6, 0., 0.], [9e6 + 1., 1., 1.]), &Options::default()).unwrap(), + ]; + assert_eq!(defs[0].report.freeform_faces, vec![0]); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3]), identity(2, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // The disc's edge reaches (5000, 0) at the parameter pi - turn. + let at = std::f64::consts::PI - turn; + let under: Vec<&Piece> = edge_line(&d, 1, 0).pieces.iter().filter(|p| p.t[0] <= at && p.t[1] >= at).collect(); + assert!(!under.is_empty()); + assert!(under.iter().all(|p| p.visibility != Visibility::Visible), "{under:?}"); +} + +#[test] +fn a_torus_in_two_halves_keeps_whole_outlines_with_fine_trims() { + // Seen along (0, 1, -1) the traced outline's chords stand up to their deviation off the + // torus; with a finer trim (768 samples a span) the boundary tolerance is smaller than + // that, and the outline must still not break into gaps. + let def = |n: usize| prepare(&torus(20., 5.), &Options { trim_samples: n, ..Options::default() }).unwrap(); + let view = View::new(Vec3::new(0., 1., -1.), Vec3::new(0., 0., 1.)).unwrap(); + let count = |n: usize| { + hidden_lines(&[def(n)], &[identity(0, [0.; 3])], &view, &Options { trim_samples: n, ..Options::default() }, &never).unwrap().stats.silhouettes + }; + assert_eq!(count(768), count(256)); +} + +#[test] +fn an_arc_folding_back_under_a_left_out_face_seen_edge_on_is_uncertain_there() { + // An arc of radius 10000 in the XZ plane, seen from above: its projection lies on the X axis + // and turns back at x = 10000 a quarter of the way into its first sample step, so the chord + // between its first samples (x 9999.953 and 9999.577) has no sag and stops short of the + // turn. A tiny freeform patch above x = 10000 covers the turn. The arc's angles are counted + // from 0 and from 8 pi. + for turns in [0., 4.] { + folding_arc_under_a_patch(turns * std::f64::consts::TAU); + } +} + +fn folding_arc_under_a_patch(offset: f64) { + let step = std::f64::consts::PI / 256.; + let ta = -step / 4. + offset; + let frame = crate::brep::Frame { origin: [0.; 3], x_axis: [1., 0., 0.], y_axis: [0., 0., 1.] }; + let at = |t: f64| [10000. * t.cos(), 0., 10000. * t.sin()]; + let (start, end) = (at(ta), at(ta + std::f64::consts::PI / 8.)); + let arc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![start, end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::CircleArc { frame, radius: 10000., angle: [ta, ta + std::f64::consts::PI / 8.] } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: start } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 0., 1.] }, + forward: true, + }], + }], + }; + let (x0, x1, y0, y1) = (9999.98, 10000.02, -0.02, 0.02); + let p = [[x0, y0, 1.], [x1, y0, 1.0004], [x1, y1, 1.], [x0, y1, 1.0004]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: p[i], end: p[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Spline { u_knots: vec![0., 0., 1., 1.], v_knots: vec![0., 0., 1., 1.], points: vec![vec![p[0], p[3]], vec![p[1], p[2]]] }, + forward: true, + }; + let patch = Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: p.to_vec(), edges, faces: vec![face] }] }; + let defs = [prepare(&patch, &Options::default()).unwrap(), prepare(&arc, &Options::default()).unwrap()]; + assert_eq!(defs[0].report.freeform_faces, vec![0]); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let turn: Vec<&Piece> = edge_line(&d, 1, 0).pieces.iter().filter(|p| p.t[0] <= offset && p.t[1] >= offset).collect(); + assert!(!turn.is_empty()); + assert!(turn.iter().all(|p| p.visibility != Visibility::Visible), "{turn:?}"); +} + +#[test] +fn a_left_out_face_curling_over_its_own_edge_makes_it_uncertain() { + // A freeform patch X = 1000 u (u - 0.5), Y = 8000 v, Z = 0.01 u seen from above: at u = 0.5 + // it passes back over its own edge u = 0, 0.005 mm above it, closer than the own-face + // margin of the ray test. The edge may be hidden. + let (a, b, c) = ([0., 0.], [-250., 0.005], [500., 0.01]); + let at = |q: [f64; 2], y: f64| [q[0], y, q[1]]; + let edges = vec![ + Edge { vertices: [0, 1], curve: Curve::Spline { knots: vec![0., 0., 0., 1., 1., 1.], points: vec![at(a, 0.), at(b, 0.), at(c, 0.)] } }, + Edge { vertices: [1, 2], curve: Curve::Line { start: at(c, 0.), end: at(c, 8000.) } }, + Edge { + vertices: [3, 2], + curve: Curve::Spline { knots: vec![0., 0., 0., 1., 1., 1.], points: vec![at(a, 8000.), at(b, 8000.), at(c, 8000.)] }, + }, + Edge { vertices: [0, 3], curve: Curve::Line { start: at(a, 0.), end: at(a, 8000.) } }, + ]; + let face = Face { + loops: vec![vec![uses(0, true), uses(1, true), uses(2, false), uses(3, false)]], + surface: Surface::Spline { + u_knots: vec![0., 0., 0., 1., 1., 1.], + v_knots: vec![0., 0., 1., 1.], + points: vec![vec![at(a, 0.), at(a, 8000.)], vec![at(b, 0.), at(b, 8000.)], vec![at(c, 0.), at(c, 8000.)]], + }, + forward: true, + }; + let sheet = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { vertices: vec![at(a, 0.), at(c, 0.), at(c, 8000.), at(a, 8000.)], edges, faces: vec![face] }], + }; + let def = prepare(&sheet, &Options::default()).unwrap(); + assert_eq!(def.report.freeform_faces, vec![0]); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let states: Vec = edge_line(&d, 0, 3).pieces.iter().map(|p| p.visibility).collect(); + assert!(states.iter().all(|v| *v != Visibility::Visible), "{states:?}"); +} + +#[test] +fn a_plate_just_above_a_box_in_a_large_scene_is_not_looked_through() { + // A box with its top at z = 0, a larger plate 0.005 mm above it and a cube 1e7 mm away: + // the box's top edges are under the plate and must not come out visible. + let defs = [ + prepare(&cuboid([0., 0., -10.], [10., 10., 0.]), &Options::default()).unwrap(), + prepare(&cuboid([-5., -5., 0.005], [15., 15., 0.007]), &Options::default()).unwrap(), + prepare(&cuboid([1e7, 0., 0.], [1e7 + 1., 1., 1.]), &Options::default()).unwrap(), + ]; + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3]), identity(2, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edges 2, 3, 6 and 7 of the box run along its top face. + for e in [2, 3, 6, 7] { + let states: Vec = edge_line(&d, 0, e).pieces.iter().map(|p| p.visibility).collect(); + assert!(states.iter().all(|v| *v != Visibility::Visible), "edge {e}: {states:?}"); + } +} + +#[test] +fn a_sphere_with_a_spline_band_between_record_caps_is_one_sphere() { + // A sphere of radius 10: a rational spline band all the way round between the latitudes + // -45 and 45 degrees, and two sphere records as caps, meeting at circles of radius 7.07. + // The band is recognised as the same sphere, so those circles are seams: seen along the + // axis only the outline of radius 10 is drawn. + let (r, q, s) = (10., std::f64::consts::FRAC_PI_4, std::f64::consts::FRAC_1_SQRT_2); + let ring = [ + ([1., 0.], 1.), + ([1., 1.], s), + ([0., 1.], 1.), + ([-1., 1.], s), + ([-1., 0.], 1.), + ([-1., -1.], s), + ([0., -1.], 1.), + ([1., -1.], s), + ([1., 0.], 1.), + ]; + let lat = [([r * q.cos(), -r * q.sin()], 1.), ([r / q.cos(), 0.], q.cos()), ([r * q.cos(), r * q.sin()], 1.)]; + let homogeneous: Vec> = ring + .iter() + .map(|(ci, wi)| lat.iter().map(|(pj, wj)| [pj[0] * ci[0] * wi * wj, pj[0] * ci[1] * wi * wj, pj[1] * wi * wj, wi * wj]).collect()) + .collect(); + let z = r * q.sin(); + let rho = r * q.cos(); + let tau = std::f64::consts::TAU; + let half = std::f64::consts::FRAC_PI_2; + let cap = |latitude: [f64; 2], e: u32, forward: bool| Face { + loops: vec![vec![uses(e, forward)]], + surface: Surface::Sphere { frame: frame_at([0.; 3]), radius: r, longitude: [0., tau], latitude }, + forward: true, + }; + let band = Face { + loops: vec![vec![uses(0, true)], vec![uses(1, false)]], + surface: Surface::RationalSpline { + u_knots: vec![0., 0., 0., 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1., 1., 1.], + v_knots: vec![0., 0., 0., 1., 1., 1.], + homogeneous, + }, + forward: true, + }; + let body = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[rho, 0., -z], [rho, 0., z]], + edges: vec![circle([0., 0., -z], rho, 0), circle([0., 0., z], rho, 1)], + faces: vec![band, cap([q, half], 1, true), cap([-half, -q], 0, false)], + }], + }; + let def = prepare(&body, &Options::default()).unwrap(); + assert_eq!(def.report.surfaces.get("sphere"), Some(&3), "{:?}", def.report); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for p in pieces(&d).filter(|p| p.visibility == Visibility::Visible && !p.covered) { + for g in &p.geometry { + let radius = g.point_at(0.5).len(); + assert!((radius - 10.).abs() < 1e-6, "a line at radius {radius}"); + } + } +} + +#[test] +fn chords_of_a_racing_circle_stay_within_their_stated_deviation() { + // z^6 / |z|^6 with z = 0.01 (1 - t) + (1 + i sqrt 3) t: the unit circle, its weights + // 1e-12 to 64 apart, so drawn as chords; a cube 9 km away makes the scene coarse. Every + // point of the circle must lie within the chords' stated deviation of them. + let c1 = (1f64, 3f64.sqrt()); + let mut num = Vec::new(); + let mut pw = (1f64, 0f64); + for k in 0..=6 { + num.push((0.01f64.powi(6 - k) * pw.0, 0.01f64.powi(6 - k) * pw.1)); + pw = (pw.0 * c1.0 - pw.1 * c1.1, pw.0 * c1.1 + pw.1 * c1.0); + } + let q = [1e-4, 0.01, 4.]; + let w = bernstein_product(&bernstein_product(&q, &q), &q); + let homogeneous: Vec<[f64; 4]> = num.iter().zip(&w).map(|((re, im), w)| [*re, *im, 0., *w]).collect(); + let disc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[1., 0., 0.]], + edges: vec![Edge { vertices: [0, 0], curve: Curve::RationalSpline { knots: [[0.; 7], [1.; 7]].concat(), homogeneous } }], + faces: vec![Face { + loops: vec![vec![uses(0, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let defs = [prepare(&disc, &Options::default()).unwrap(), prepare(&cuboid([9e6, 0., 0.], [9e6 + 1., 1., 1.]), &Options::default()).unwrap()]; + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let line = edge_line(&d, 0, 0); + if turn_or_chords(line.pieces.iter()).is_some() { + return; + } + let dev = line + .pieces + .iter() + .filter_map(|p| if let CurveAccuracy::Sampled { max_sample_deviation_mm: v } = p.accuracy { Some(v) } else { None }) + .fold(0., f64::max); + let chords: Vec<(Vec2, Vec2)> = + line.pieces.iter().flat_map(|p| p.geometry.iter()).filter_map(|g| if let Seg2::Line { a, b } = *g { Some((a, b)) } else { None }).collect(); + for k in 0..720 { + let t = std::f64::consts::TAU * k as f64 / 720.; + let q = Vec2::new(t.cos(), t.sin()); + let gap = chords.iter().map(|(a, b)| point_segment([q.x, q.y], [a.x, a.y], [b.x, b.y])).fold(f64::MAX, f64::min); + assert!(gap <= dev + 1e-6, "({}, {}) is {gap} mm from the chords, stated {dev}", q.x, q.y); + } +} + +#[test] +fn a_big_arc_is_cut_where_it_meets_a_left_out_faces_box() { + // An arc of radius 10000 (angles -pi/8 to 0) under a freeform tube lying along Y at + // x = 9990 (radius about 2.5, a cubic profile), a cube 9 km away making the scene coarse: + // no visible piece of the arc may reach under the tube. + let tau = std::f64::consts::TAU; + let a0 = -std::f64::consts::PI / 8.; + let (start, end) = ([10000. * a0.cos(), 10000. * a0.sin(), 0.], [10000., 0., 0.]); + let arc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![start, end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::CircleArc { frame: frame_at([0.; 3]), radius: 10000., angle: [a0, 0.] } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: start } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let x = 9990.; + let profile = Curve::Spline { + knots: vec![0., 0., 0., 0., 1., 1., 1., 1.], + points: vec![[x + 2.5, -4000., 1.], [x + 2.504, -4000. / 3., 1.], [x + 2.5, 2000. / 3., 1.], [x + 2.5, 3000., 1.]], + }; + let end_circle = |y: f64, v: u32| Edge { + vertices: [v, v], + curve: Curve::CircleArc { + frame: crate::brep::Frame { origin: [x, y, 1.], x_axis: [1., 0., 0.], y_axis: [0., 0., 1.] }, + radius: 2.5, + angle: [0., tau], + }, + }; + let tube = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[x + 2.5, -4000., 1.], [x + 2.5, 3000., 1.]], + edges: vec![end_circle(-4000., 0), end_circle(3000., 1), Edge { vertices: [0, 1], curve: profile.clone() }], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Revolution { profile: Box::new(profile), origin: [x, 0., 1.], axis: [0., 1., 0.], angle: [0., tau] }, + forward: true, + }], + }], + }; + let defs = [ + prepare(&tube, &Options::default()).unwrap(), + prepare(&arc, &Options::default()).unwrap(), + prepare(&cuboid([9e6, 0., 0.], [9e6 + 1., 1., 1.]), &Options::default()).unwrap(), + ]; + assert_eq!(defs[0].report.freeform_faces, vec![0]); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3]), identity(2, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for p in edge_line(&d, 1, 0).pieces.iter().filter(|p| p.visibility == Visibility::Visible) { + for k in 0..=400 { + let t = p.t[0] + (p.t[1] - p.t[0]) * k as f64 / 400.; + let (px, py) = (10000. * t.cos(), 10000. * t.sin()); + let under = px > x - 2.5 && px < x + 2.5 && py > -4000. && py < 3000.; + assert!(!under, "visible at ({px}, {py}), t {t}, piece {:?}", p.t); + } + } +} + +#[test] +fn narrow_torus_strips_keep_their_outline() { + // Two strips of a torus R 20, r 10 between the meridian angles 0.3002, 0.3005 and 0.3008, + // seen from the side: each side of the outline is a 0.006 mm piece of the circle traced on + // the whole torus, whose chords are far longer than the strips are wide. + let tau = std::f64::consts::TAU; + let ring = |phi: f64, v: u32| circle([0., 0., 10. * phi.sin()], 20. + 10. * phi.cos(), v); + let strip = |m: [f64; 2], e: [u32; 2]| Face { + loops: vec![vec![uses(e[0], true)], vec![uses(e[1], false)]], + surface: Surface::Torus { frame: frame_at([0.; 3]), major_radius: 20., minor_radius: 10., longitude: [0., tau], meridian: m }, + forward: true, + }; + let phis: [f64; 3] = [0.3002, 0.3005, 0.3008]; + let body = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: phis.iter().map(|p| [20. + 10. * p.cos(), 0., 10. * p.sin()]).collect(), + edges: vec![ring(phis[0], 0), ring(phis[1], 1), ring(phis[2], 2)], + faces: vec![strip([phis[0], phis[1]], [0, 1]), strip([phis[1], phis[2]], [1, 2])], + }], + }; + let def = prepare(&body, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for side in [-1., 1.] { + let drawn: f64 = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .filter(|g| g.point_at(0.5).x * side > 0.) + .map(|g| g.point_at(0.).dist(g.point_at(1.))) + .sum(); + assert!((drawn - 0.006).abs() < 1e-4, "side {side}: {drawn} mm of outline"); + } +} + +#[test] +fn mirrored_placements_bad_indices_and_bad_views_are_refused() { + let def = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -1., -1.), Vec3::new(0., 0., 1.)).unwrap(); + let mirror = + Placement { body: 0, rotation: [Vec3::new(-1., 0., 0.), Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)], translation: Vec3::new(0., 0., 0.) }; + let defs = [def]; + assert!(matches!(hidden_lines(&defs, &[mirror], &view, &Options::default(), &never), Err(HlrError::Invalid(_)))); + assert!(matches!(hidden_lines(&defs, &[identity(3, [0.; 3])], &view, &Options::default(), &never), Err(HlrError::Invalid(_)))); + assert!(matches!(hidden_lines(&defs, &[identity(0, [f64::NAN, 0., 0.])], &view, &Options::default(), &never), Err(HlrError::Invalid(_)))); + assert!(View::new(Vec3::new(0., 0., 0.), Vec3::new(0., 0., 1.)).is_err()); + assert!(View::new(Vec3::new(0., 0., 1.), Vec3::new(0., 0., 2.)).is_err()); +} + +#[test] +fn cancellation_and_budgets_return_errors() { + let def = prepare(&cylinder(10., 30.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.7, -0.4), Vec3::new(0., 0., 1.)).unwrap(); + let defs = [def]; + let scene = [identity(0, [0.; 3]), identity(0, [5., 0., 0.])]; + assert!(matches!(hidden_lines(&defs, &scene, &view, &Options::default(), &|| true), Err(HlrError::Cancelled))); + assert!(matches!(hidden_lines(&defs, &scene, &view, &Options { max_rays: 3, ..Options::default() }, &never), Err(HlrError::Budget(_)))); + assert!(matches!(hidden_lines(&defs, &scene, &view, &Options { max_tracks: 1, ..Options::default() }, &never), Err(HlrError::Budget(_)))); +} + +#[test] +fn two_touching_boxes_draw_each_shared_edge_once() { + let a = prepare(&cuboid([0.; 3], [10.; 3]), &Options::default()).unwrap(); + let b = prepare(&cuboid([10., 0., 0.], [20., 10., 10.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.7, -0.5), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[a, b], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); + // The edges at x = 10: 5, 7, 9, 11 of the first box are 4, 6, 8, 10 of the second. + let uncovered = |l: &Line| -> f64 { l.pieces.iter().filter(|p| !p.covered).map(|p| p.t[1] - p.t[0]).sum() }; + for (ea, eb) in [(5, 4), (7, 6), (9, 8), (11, 10)] { + let once = uncovered(edge_line(&d, 0, ea)) + uncovered(edge_line(&d, 1, eb)); + assert!((once - 1.).abs() < 1e-9, "edges {ea}/{eb} drawn {once} times"); + } +} + +#[test] +fn a_cone_in_a_front_view_has_exact_outline_rulings_and_edge_on_circles() { + // A frustum from radius 5 at z = 0 to 10 at z = 20, seen exactly from the front: the + // rulings run from (+-5, 0) to (+-10, 20) (one along the seam) and each circle is drawn + // once as the segment it is seen edge-on as. + let def = prepare(&turned(5., 10., 20.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); + let mut rulings: Vec<[f64; 4]> = Vec::new(); + for p in silhouette_pieces(&d) { + assert_eq!((p.visibility, p.accuracy), (Visibility::Visible, CurveAccuracy::Analytic)); + for g in &p.geometry { + let Seg2::Line { a, b } = *g else { panic!("a ruling is a line: {g:?}") }; + let (a, b) = if a.y <= b.y { (a, b) } else { (b, a) }; + rulings.push([a.x, a.y, b.x, b.y]); + } + } + rulings.sort_by(|a, b| a[0].total_cmp(&b[0])); + assert_eq!(rulings.len(), 2, "{rulings:?}"); + for (r, want) in rulings.iter().zip([[-5., 0., -10., 20.], [5., 0., 10., 20.]]) { + assert!(r.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-9), "{rulings:?}"); + } + for (edge, (y, half)) in [(0, (0., 5.)), (1, (20., 10.))] { + let drawn: f64 = edge_line(&d, 0, edge) + .pieces + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .map(|g| { + let (a, b) = (g.point_at(0.), g.point_at(1.)); + assert!((a.y - y).abs() < 1e-9 && (b.y - y).abs() < 1e-9 && a.x.abs() <= half + 1e-9, "{g:?}"); + (b.x - a.x).abs() + }) + .sum(); + assert!((drawn - 2. * half).abs() < 1e-6, "circle {edge}: {drawn} mm drawn"); + } +} + +#[test] +fn a_torus_seen_obliquely_spans_its_outer_diameter() { + // Across the view (screen x is square to the axis here) the outline of a torus reaches + // R + r on both sides, whatever the tilt; the traced silhouette reports its deviation. + let def = prepare(&torus(20., 5.), &Options::default()).unwrap(); + for tilt in [0.3, 1., 3.] { + let view = View::new(Vec3::new(0., 1., -tilt), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(std::slice::from_ref(&def), &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert_eq!(d.stats.uncertain_uncovered, 0, "tilt {tilt}: {:?}", d.stats); + let mut dev = 0f64; + let (mut lo, mut hi) = (f64::MAX, f64::MIN); + for p in pieces(&d).filter(|p| p.visibility == Visibility::Visible && !p.covered) { + if let CurveAccuracy::Sampled { max_sample_deviation_mm } = p.accuracy { + dev = dev.max(max_sample_deviation_mm); + } + for g in &p.geometry { + for s in [0., 0.5, 1.] { + let q = g.point_at(s); + (lo, hi) = (lo.min(q.x), hi.max(q.x)); + } + } + } + assert!(dev < 1e-3, "tilt {tilt}: deviation {dev}"); + assert!((hi - 25.).abs() < 1e-3 && (lo + 25.).abs() < 1e-3, "tilt {tilt}: x from {lo} to {hi}"); + } +} + +/// A disc bounded by a rational quadratic circle of radius `r` (control points `pts` with +/// weights 1 and 1/sqrt 2 alternating) whose first three quarters are squeezed into the +/// parameters 0 to 0.009. +fn squeezed_disc(r: f64, pts: [[f64; 2]; 9]) -> Brep { + disc(r, pts, vec![0., 0., 0., 0.003, 0.003, 0.006, 0.006, 0.009, 0.009, 1., 1., 1.]) +} + +/// A disc bounded by a rational quadratic spline with control points `pts` (weights 1 and +/// 1/sqrt 2 alternating) and `knots`, starting at (`r`, 0). +fn disc(r: f64, pts: [[f64; 2]; 9], knots: Vec) -> Brep { + let s = std::f64::consts::FRAC_1_SQRT_2; + let homogeneous = pts.iter().enumerate().map(|(i, p)| if i % 2 == 1 { [p[0] * s, p[1] * s, 0., s] } else { [p[0], p[1], 0., 1.] }).collect(); + Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[r, 0., 0.]], + edges: vec![Edge { vertices: [0, 0], curve: Curve::RationalSpline { knots, homogeneous } }], + faces: vec![Face { + loops: vec![vec![uses(0, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + } +} + +fn circle_points(r: f64) -> [[f64; 2]; 9] { + [[r, 0.], [r, r], [0., r], [-r, r], [-r, 0.], [-r, -r], [0., -r], [r, -r], [r, 0.]] +} + +#[test] +fn a_bent_quarter_in_a_knot_span_narrower_than_rounding_is_drawn() { + // The second quarter lives in the knot span 0.25 to 0.25 + 2^-50 and bulges to a radius of + // about 10.3: the edge is not taken for a circle and the bulge is drawn. + let mut pts = circle_points(10.); + if let Some(p) = pts.get_mut(3) { + *p = [-11., 10.]; + } + let k = 0.25 + 2f64.powi(-50); + let def = prepare(&disc(10., pts, vec![0., 0., 0., 0.25, 0.25, k, k, 0.5, 0.5, 1., 1., 1.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(pieces(&d).all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{d:?}"); + let far = pieces(&d).flat_map(|p| p.geometry.iter()).flat_map(|g| [g.point_at(0.), g.point_at(1.)]).map(|q| q.len()).fold(0., f64::max); + assert!(far > 10.25, "the bulge reaches {far}"); +} + +#[test] +fn an_unevenly_parameterised_circle_is_drawn_whole() { + // The conic arcs must still turn the full 360 degrees. + let def = prepare(&squeezed_disc(10., circle_points(10.)), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.2, 0.1, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let mut sweep = 0.; + for p in pieces(&d) { + assert_eq!((p.visibility, p.accuracy), (Visibility::Visible, CurveAccuracy::Analytic)); + for g in &p.geometry { + let Seg2::Conic { w, .. } = *g else { panic!("a circle is drawn as conic arcs: {g:?}") }; + sweep += 2. * w.acos(); + } + } + assert!((sweep - std::f64::consts::TAU).abs() < 1e-9, "the arcs turn {sweep} rad"); +} + +#[test] +fn a_spline_off_its_circle_in_a_narrow_knot_span_is_not_taken_for_one() { + // Moving one control point of the squeezed circle bends the span 0.003 to 0.006 off the + // circle by about 0.3 mm: the edge is drawn as sampled chords, not exact arcs. + let mut pts = circle_points(10.); + if let Some(p) = pts.get_mut(3) { + *p = [-11., 10.]; + } + let def = prepare(&squeezed_disc(10., pts), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.2, 0.1, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(pieces(&d).all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{d:?}"); +} + +/// A cone frustum about a vertical axis through `o`: radius `r0` at the frame's origin and +/// slope `k`, between the heights `h`. +fn frustum(o: [f64; 3], r0: f64, k: f64, h: [f64; 2]) -> Shell { + let tau = std::f64::consts::TAU; + let at = |z: f64| [o[0] + r0 + k * z, o[1], o[2] + z]; + let ring = |z: f64, v: u32| circle([o[0], o[1], o[2] + z], r0 + k * z, v); + let edges = vec![ring(h[0], 0), ring(h[1], 1), Edge { vertices: [0, 1], curve: Curve::Line { start: at(h[0]), end: at(h[1]) } }]; + let plane = |z: f64, flip: f64| Surface::Plane { origin: [o[0], o[1], o[2] + z], u_axis: [1., 0., 0.], v_axis: [0., flip, 0.] }; + let faces = vec![ + Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Cone { frame: frame_at(o), radius_at_origin: r0, half_angle: k.atan(), angle: [0., tau], height: h }, + forward: true, + }, + Face { loops: vec![vec![uses(0, false)]], surface: plane(h[0], -1.), forward: true }, + Face { loops: vec![vec![uses(1, true)]], surface: plane(h[1], 1.), forward: true }, + ]; + Shell { vertices: vec![at(h[0]), at(h[1])], edges, faces } +} + +#[test] +fn nearly_coaxial_cones_far_out_keep_their_own_silhouettes() { + // Two frusta of slope 0.25 whose axes are slightly apart, far out: recognition may take + // them for one cone, but each is drawn with its own outline. Small ones 0.005 mm apart + // 1e7 mm out, and 1 km ones 0.4 mm apart 1e9 mm out. + for (x, r, gap) in [(1e7, 5., 0.005), (1e9, 1e6, 0.4)] { + let shells = vec![frustum([x, 0., 0.], r, 0.25, [0., 10.]), frustum([x + gap, 0., 0.], r, 0.25, [20., 30.])]; + check_two_cone_outlines(&Brep { version: crate::brep::VERSION, shells }); + } +} + +/// A sector `a` (radians) of a cone about a vertical axis through `o`, radius `r0` at the +/// frame's origin and slope `k`, between the heights `h`: one face, no caps. +fn cone_sector(o: [f64; 3], r0: f64, k: f64, h: [f64; 2], a: [f64; 2]) -> Shell { + let at = |z: f64, t: f64| [o[0] + (r0 + k * z) * t.cos(), o[1] + (r0 + k * z) * t.sin(), o[2] + z]; + let arc = |z: f64, v: u32, w: u32| Edge { + vertices: [v, w], + curve: Curve::CircleArc { frame: frame_at([o[0], o[1], o[2] + z]), radius: r0 + k * z, angle: a }, + }; + let ruling = |t: f64, v: u32, w: u32| Edge { vertices: [v, w], curve: Curve::Line { start: at(h[0], t), end: at(h[1], t) } }; + let edges = vec![arc(h[0], 0, 1), ruling(a[1], 1, 2), arc(h[1], 3, 2), ruling(a[0], 0, 3)]; + let face = Face { + loops: vec![vec![uses(0, true), uses(1, true), uses(2, false), uses(3, false)]], + surface: Surface::Cone { frame: frame_at(o), radius_at_origin: r0, half_angle: k.atan(), angle: a, height: h }, + forward: true, + }; + Shell { vertices: vec![at(h[0], a[0]), at(h[0], a[1]), at(h[1], a[1]), at(h[1], a[0])], edges, faces: vec![face] } +} + +#[test] +fn nearly_coincident_cone_sectors_both_draw_their_outline() { + // Two sectors (0 to 0.02 rad) of 1 m cones of slope 0.25, 1e9 mm out, axes 3e-6 mm apart, + // seen along the tangent at 0.01 rad: recognition takes them for one cone, but a ray along + // one's ruling would cross the other, so each keeps its own outline, visible over its + // whole height. + let x = 1e9; + let shells = vec![ + cone_sector([x, 0., 0.], 1000., 0.25, [0., 1000.], [0., 0.02]), + cone_sector([x + 3e-6, 0., 0.], 1000., 0.25, [2000., 3000.], [0., 0.02]), + ]; + let def = prepare(&Brep { version: crate::brep::VERSION, shells }, &Options::default()).unwrap(); + let view = View::new(Vec3::new(-(0.01f64.sin()), 0.01f64.cos(), 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for (z0, z1) in [(0., 1000.), (2000., 3000.)] { + let drawn: f64 = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .map(|g| { + let (a, b) = (g.point_at(0.).y.clamp(z0, z1), g.point_at(1.).y.clamp(z0, z1)); + (b - a).abs() + }) + .sum(); + assert!((drawn - (z1 - z0)).abs() < 1e-3, "z {z0}..{z1}: {drawn} mm of ruling drawn"); + } +} + +/// A spherical cap of radius `r` about `c`: the latitudes from `lat` up to the pole, bounded by +/// one circle through the vertex `v` at longitude 0. +fn cap(c: [f64; 3], r: f64, lat: f64, edge: Curve, v: [f64; 3]) -> Shell { + let (tau, half) = (std::f64::consts::TAU, std::f64::consts::FRAC_PI_2); + let face = Face { + loops: vec![vec![uses(0, true)]], + surface: Surface::Sphere { frame: frame_at(c), radius: r, longitude: [0., tau], latitude: [lat, half] }, + forward: true, + }; + Shell { vertices: vec![v], edges: vec![Edge { vertices: [0, 0], curve: edge }], faces: vec![face] } +} + +#[test] +fn spherical_caps_through_one_circle_keep_their_own_outlines() { + // Two caps of radius 1000 whose centres are 5e-7 mm apart along the axis, both bounded by + // the same circle: recognition takes them for one sphere, the boundary lies on both, but + // inside they differ, so each draws its own outline. + let (r, z): (f64, f64) = (1000., 600.); + let ring = (r * r - z * z).sqrt(); + let edge = || Curve::CircleArc { frame: frame_at([0., 0., z]), radius: ring, angle: [0., std::f64::consts::TAU] }; + let dz = 5e-7; + let lat = |zc: f64| ((z - zc) / r).asin(); + let shells = vec![ + cap([0.; 3], r, lat(0.), edge(), [ring, 0., z]), + cap([0., 0., dz], (ring * ring + (z - dz).powi(2)).sqrt(), lat(dz), edge(), [ring, 0., z]), + ]; + let def = prepare(&Brep { version: crate::brep::VERSION, shells }, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Above the ring the outline is a half circle of about pi * 1000 * 0.41 mm on each side. + let drawn: f64 = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .map(|g| (0..32).map(|k| g.point_at(k as f64 / 32.).dist(g.point_at((k + 1) as f64 / 32.))).sum::()) + .sum(); + let arc = 2. * r * (std::f64::consts::FRAC_PI_2 - (z / r).asin()); + assert!(drawn > 0.99 * arc, "{drawn} mm of outline drawn of about {arc}"); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); +} + +/// The lateral face of a cone with its apex at the origin, slope 0.25, between the heights +/// `h`, its axis turned by `tilt` about Y from +Z. +fn tilted_cone(tilt: f64, h: [f64; 2]) -> Shell { + let (x, y, z) = (Vec3::new(tilt.cos(), 0., -tilt.sin()), Vec3::new(0., 1., 0.), Vec3::new(tilt.sin(), 0., tilt.cos())); + let arr = |v: Vec3| [v.x, v.y, v.z]; + let frame = |o: Vec3| crate::brep::Frame { origin: arr(o), x_axis: arr(x), y_axis: arr(y) }; + let at = |hh: f64| x * (0.25 * hh) + z * hh; + let ring = |hh: f64, v: u32| Edge { + vertices: [v, v], + curve: Curve::CircleArc { frame: frame(z * hh), radius: 0.25 * hh, angle: [0., std::f64::consts::TAU] }, + }; + let seam = Edge { vertices: [0, 1], curve: Curve::Line { start: arr(at(h[0])), end: arr(at(h[1])) } }; + let face = Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Cone { + frame: frame(Vec3::new(0., 0., 0.)), + radius_at_origin: 0., + half_angle: 0.25f64.atan(), + angle: [0., std::f64::consts::TAU], + height: h, + }, + forward: true, + }; + Shell { vertices: vec![arr(at(h[0])), arr(at(h[1]))], edges: vec![ring(h[0], 0), ring(h[1], 1), seam], faces: vec![face] } +} + +#[test] +fn cones_with_slightly_turned_axes_keep_their_own_outlines() { + // One apex, axes 3e-7 rad apart, heights 1000 to 3000 and 6000 to 8000: each frustum draws + // both rulings of its own outline. + let shells = vec![tilted_cone(0., [1000., 3000.]), tilted_cone(3e-7, [6000., 8000.])]; + let def = prepare(&Brep { version: crate::brep::VERSION, shells }, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for (z0, z1) in [(1000., 3000.), (6000., 8000.)] { + let drawn: f64 = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .filter(|g| g.point_at(0.5).y > z0 && g.point_at(0.5).y < z1) + .map(|g| g.point_at(0.).dist(g.point_at(1.))) + .sum(); + let ruling = (z1 - z0) * (1f64 + 0.0625).sqrt(); + assert!((drawn - 2. * ruling).abs() < 1e-3 * ruling, "z {z0}..{z1}: {drawn} mm of rulings drawn, want {}", 2. * ruling); + } +} + +#[test] +fn a_high_degree_spline_through_points_of_a_circle_is_not_taken_for_one() { + // A closed degree-16 Bezier through 17 points of a circle of radius 10 at t = j / 16, at + // unevenly spaced angles: on the circle at every t = j / 16 (where a check with evenly + // spaced samples would look), off it between them, so it is drawn as chords. + let n = 16usize; + let ts: Vec = (0..=n).map(|j| j as f64 / n as f64).collect(); + let binom = |k: usize| (0..k).fold(1f64, |acc, i| acc * (n - i) as f64 / (i + 1) as f64); + // Solve the Bernstein collocation system for x and y by Gaussian elimination. + let mut rows: Vec> = ts + .iter() + .enumerate() + .map(|(j, t)| { + let a = std::f64::consts::TAU * j as f64 / n as f64 + 0.2 * (std::f64::consts::TAU * 3. * j as f64 / n as f64).sin(); + let mut row: Vec = (0..=n).map(|i| binom(i) * t.powi(i as i32) * (1. - t).powi((n - i) as i32)).collect(); + row.extend([10. * a.cos(), 10. * a.sin()]); + row + }) + .collect(); + for col in 0..=n { + let pivot = (col..=n).max_by(|a, b| rows[*a][col].abs().total_cmp(&rows[*b][col].abs())).unwrap(); + rows.swap(col, pivot); + for r in 0..=n { + if r != col { + let f = rows[r][col] / rows[col][col]; + let src = rows[col].clone(); + for (x, s) in rows[r].iter_mut().zip(src) { + *x -= f * s; + } + } + } + } + let points: Vec<[f64; 3]> = (0..=n).map(|i| [rows[i][n + 1] / rows[i][i], rows[i][n + 2] / rows[i][i], 0.]).collect(); + let knots: Vec = std::iter::repeat_n(0., n + 1).chain(std::iter::repeat_n(1., n + 1)).collect(); + let start = points[0]; + let disc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![start], + edges: vec![Edge { vertices: [0, 0], curve: Curve::Spline { knots, points } }], + faces: vec![Face { + loops: vec![vec![uses(0, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&disc, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.2, 0.1, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(pieces(&d).all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{:?}", pieces(&d).map(|p| p.accuracy).collect::>()); +} + +fn check_two_cone_outlines(body: &Brep) { + let def = prepare(body, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for (z0, z1) in [(0., 10.), (20., 30.)] { + let rulings = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .filter(|g| { + let (a, b) = (g.point_at(0.), g.point_at(1.)); + (a.y.min(b.y) - z0).abs() < 1e-6 && (a.y.max(b.y) - z1).abs() < 1e-6 + }) + .count(); + assert_eq!(rulings, 2, "z {z0}..{z1}: {:?}", silhouette_pieces(&d)); + } +} + +#[test] +fn the_shadow_index_finds_each_nearby_box_once() { + // 40 000 boxes 1 mm wide every 20 mm: a query near one finds it and no far one. + let mut list = Vec::new(); + for i in 0..200 { + for j in 0..200 { + let (x, y) = (20. * i as f64, 20. * j as f64); + list.push(Shadow { lo: [x, y], hi: [x + 1., y + 1.], near: 0. }); + } + } + let index = ShadowIndex::new(list).unwrap(); + let mut near = Vec::new(); + index.query([400.5, 600.5], [400.6, 600.6], &mut near); + let faces: Vec = near + .iter() + .copied() + .filter(|k| index.list.get(*k).is_some_and(|sh| sh.lo[0] <= 400.6 && sh.hi[0] >= 400.5 && sh.lo[1] <= 600.6 && sh.hi[1] >= 600.5)) + .collect(); + assert_eq!(faces, vec![20 * 200 + 30]); + assert!(near.len() <= 9, "{} candidates", near.len()); + index.query([-50., -50.], [-40., -40.], &mut near); + assert!(near.is_empty()); +} + +#[test] +fn a_plane_through_three_points_draws_as_a_plane() { + // The box with every face stored as the plane through three of its points draws the same + // bits as the box with axis-stored planes. + let o = Options::default(); + let mut through = cuboid([0.; 3], [1.; 3]); + for f in &mut through.shells[0].faces { + let Surface::Plane { origin, u_axis, v_axis } = f.surface else { panic!("a box face is a plane") }; + let at = |a: [f64; 3]| [origin[0] + a[0], origin[1] + a[1], origin[2] + a[2]]; + f.surface = Surface::PlaneThrough { origin, u_point: at(u_axis), v_point: at(v_axis) }; + } + let view = View::new(Vec3::new(-1., -0.8, -0.6), Vec3::new(0., 0., 1.)).unwrap(); + let draw = |b: &Brep| { + let mut d = hidden_lines(&[prepare(b, &o).unwrap()], &[identity(0, [0.; 3])], &view, &o, &never).unwrap(); + d.stats.phase_ms = [0.; 4]; + format!("{d:?}") + }; + assert!(draw(&through) == draw(&cuboid([0.; 3], [1.; 3]))); +} + +#[test] +fn procedural_and_placed_records_are_not_supported_yet() { + let o = Options::default(); + // A cylinder side stored as a placed revolution of its seam line. + let mut placed = cylinder(4., 10.); + let profile = Box::new(Curve::Line { start: [4., 0., 0.], end: [4., 0., 10.] }); + let matrix = [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]]; + placed.shells[0].faces[0].surface = Surface::PlacedRevolution { profile, origin: [0.; 3], axis: [0., 0., 1.], matrix, forward: true }; + assert!(matches!(prepare(&placed, &o), Err(HlrError::Unsupported("placed revolution surface")))); + // A box edge stored as the meeting of the planes y = 0 and z = 0, traced from a leader. + let mut traced = cuboid([0.; 3], [1.; 3]); + let y0 = Box::new(Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 0., 1.] }); + let z0 = Box::new(Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }); + let leader = Box::new(Curve::Polyline { points: vec![[0.; 3], [1., 0., 0.]] }); + traced.shells[0].edges[0].curve = Curve::SurfaceIntersection { surfaces: [y0, z0], leader, tolerance: 1e-6 }; + assert!(matches!(prepare(&traced, &o), Err(HlrError::Unsupported("surface intersection curve")))); +} + +/// The edges whose every piece is hidden, in a box drawn from the isometric view. +fn hidden_box_edges(b: &Brep) -> Vec { + let def = prepare(b, &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -1., -1.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let mut hidden = Vec::new(); + for l in &d.lines { + let Source::Edge { edge, .. } = l.source else { panic!("a box has no silhouettes") }; + let states: Vec<_> = l.pieces.iter().map(|p| p.visibility).collect(); + assert!(states.windows(2).all(|w| w[0] == w[1]), "edge {edge} is split: {states:?}"); + if states[0] == Visibility::Hidden { + hidden.push(edge); + } + } + hidden.sort_unstable(); + hidden +} + +#[test] +fn a_plane_record_off_its_edges_by_rounding_still_hides() { + // The top face's plane stands one rounding (a 1 mm cube) or 1e-11 (a 100 mm cube) above + // its edges. Its box must still let the rays from the back edges reach it. + for (size, off) in [(1., 2.2e-16), (100., 1e-11), (1., -2.2e-16)] { + let mut b = cuboid([0.; 3], [size; 3]); + let Surface::Plane { origin, .. } = &mut b.shells[0].faces[1].surface else { panic!("the top face is a plane") }; + assert_eq!(origin[2], size); + origin[2] += off; + assert_ne!(origin[2], size, "the offset must change the record"); + assert_eq!(hidden_box_edges(&b), vec![0, 4, 8], "size {size}, offset {off}"); + } + // A 0.001 mm cube modelled 1e7 mm out, its top plane one step (1.9e-9 mm) above its edges: + // more than 1e-6 of the face, so the padding must also grow with the coordinates. + let (c, size) = (1e7, 0.001); + let mut b = cuboid([c; 3], [c + size; 3]); + let Surface::Plane { origin, .. } = &mut b.shells[0].faces[1].surface else { panic!("the top face is a plane") }; + origin[2] = f64::from_bits(origin[2].to_bits() + 1); + assert!(origin[2] - (c + size) > 1e-9); + assert_eq!(hidden_box_edges(&b), vec![0, 4, 8], "a tiny cube far out"); +} + +/// A plate of `n` by `n` holes of radius `r` at `pitch`, `h` thick: the faces of `cuboid`, a +/// circle per hole in the top and bottom faces, and a cylinder face (seam at angle 0) per hole. +fn holed_plate(n: usize, pitch: f64, r: f64, h: f64) -> Brep { + let tau = std::f64::consts::TAU; + let size = n as f64 * pitch; + let mut b = cuboid([0.; 3], [size, size, h]); + let shell = &mut b.shells[0]; + for i in 0..n { + for j in 0..n { + let (cx, cy) = ((i as f64 + 0.5) * pitch, (j as f64 + 0.5) * pitch); + let v = shell.vertices.len() as u32; + shell.vertices.push([cx + r, cy, 0.]); + shell.vertices.push([cx + r, cy, h]); + let e = shell.edges.len() as u32; + shell.edges.push(circle([cx, cy, 0.], r, v)); + shell.edges.push(circle([cx, cy, h], r, v + 1)); + shell.edges.push(Edge { vertices: [v, v + 1], curve: Curve::Line { start: [cx + r, cy, 0.], end: [cx + r, cy, h] } }); + // Holes run clockwise seen from outside: the bottom face looks down, the top up. + shell.faces[0].loops.push(vec![uses(e, true)]); + shell.faces[1].loops.push(vec![uses(e + 1, false)]); + shell.faces.push(Face { + loops: vec![vec![uses(e, true), uses(e + 2, true), uses(e + 1, false), uses(e + 2, false)]], + surface: Surface::Cylinder { frame: frame_at([cx, cy, 0.]), radius: r, angle: [0., tau], height: [0., h] }, + forward: false, + }); + } + } + b +} + +#[test] +fn trim_samples_and_scene_extents_beyond_their_limits_are_refused() { + for trim_samples in [0, MAX_TRIM_SAMPLES + 1] { + assert!(matches!(prepare(&cuboid([0.; 3], [1.; 3]), &Options { trim_samples, ..Options::default() }), Err(HlrError::Invalid(_)))); + } + // Placements far apart once made the scene's size infinite and every piece visible. + let def = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -1., -1.), Vec3::new(0., 0., 1.)).unwrap(); + let defs = [def]; + for far in [1e200, 2e12, -2e12] { + let scene = [identity(0, [0.; 3]), identity(0, [far, 0., 0.])]; + assert!(matches!(hidden_lines(&defs, &scene, &view, &Options::default(), &never), Err(HlrError::Invalid("scene extent"))), "{far}"); + } + let scene = [identity(0, [0.; 3]), identity(0, [1e11, 0., 0.])]; + assert!(hidden_lines(&defs, &scene, &view, &Options::default(), &never).is_ok()); +} + +#[test] +fn surface_groups_match_comparing_every_pair_and_preparing_can_be_cancelled() { + // Many holes of one radius, one a hair larger, a torus and a sphere in halves. + let mut b = holed_plate(4, 10., 3., 2.); + let wider = holed_plate(1, 10., 3. * (1. + 5e-10), 2.); + for s in [wider.shells[0].clone(), torus(5., 1.5).shells[0].clone(), sphere(4.).shells[0].clone()] { + b.shells.push(s); + } + let def = prepare(&b, &Options::default()).unwrap(); + for (i, f) in def.faces.iter().enumerate() { + let every: Vec = + def.faces.iter().enumerate().filter(|(j, g)| *j == i || same_analytic(&f.analytic, &g.analytic, 1e-9)).map(|(j, _)| j).collect(); + assert_eq!(f.group, every, "face {i}"); + } + // The wider hole joins the first hole (same axis); the torus and sphere halves pair up. + assert_eq!(def.faces.iter().filter(|f| f.group.len() == 2).count(), 6); + assert!(matches!(prepare_cancellable(&b, &Options::default(), &|| true), Err(HlrError::Cancelled))); +} + +#[test] +fn the_chord_grid_finds_what_a_scan_of_every_chord_finds() { + // The plate's top face: 16 circles and a square, thousands of chords. + let def = prepare(&holed_plate(4, 10., 3., 2.), &Options::default()).unwrap(); + let trim = &def.faces[1].trim; + assert!(trim.chords.len() > 1000); + let (lo, hi) = (trim.lo, trim.hi); + let mut checked = [0usize; 2]; + for i in 0..=60 { + for j in 0..=60 { + let p = Vec3::new(lo.x + (hi.x - lo.x) * i as f64 / 60., lo.y + (hi.y - lo.y) * j as f64 / 60., lo.z + 1e-9 * (i % 3) as f64); + for tol in [1e-12, 1e-6, 0.01, 0.3, 2., 50.] { + let scan = trim.chords.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol); + assert_eq!(trim.near(p, tol), scan, "point {p:?}, tol {tol}"); + checked[usize::from(scan)] += 1; + } + } + } + assert!(checked[0] > 1000 && checked[1] > 1000, "{checked:?}"); +} + +#[test] +fn every_hole_of_a_plate_hides_the_same_part_of_its_bottom_circle() { + let def = prepare(&holed_plate(4, 10., 3., 2.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.8, -0.6), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let hidden = + |e: usize| -> f64 { edge_line(&d, 0, e).pieces.iter().filter(|p| p.visibility == Visibility::Hidden).map(|p| p.t[1] - p.t[0]).sum() }; + // Edges 12, 15, 18, ...: each hole's bottom circle (12 box edges first). + let first = hidden(12); + assert!(first > 0.1 && first < std::f64::consts::TAU - 0.1, "part of a bottom circle is hidden by the wall: {first}"); + for k in 1..16 { + let e = 12 + 3 * k; + assert!((hidden(e) - first).abs() < 1e-6, "hole {k}: {} against {first}", hidden(e)); + let top = edge_line(&d, 0, e + 1); + assert!(top.pieces.iter().all(|p| p.visibility == Visibility::Visible), "hole {k}'s top circle is in front: {:?}", top.pieces); + } +} + +#[test] +fn coaxial_cylinders_seen_along_their_axis_draw_one_circle() { + // Eight cylinders stacked along Z, seen from above: all sixteen circles fall on one; only + // the top one shows, the others are hidden or lie under it. + let def = prepare(&cylinder(4., 10.), &Options::default()).unwrap(); + let scene: Vec = (0..8).map(|k| identity(0, [0., 0., 15. * k as f64])).collect(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &scene, &view, &Options::default(), &never).unwrap(); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); + // The circles meet at every sample point they share; none of that is a crossing (only the + // seam's ends, where each curve stops, may count): 120 pairs once gave over 100 000. + assert!(d.stats.crossings <= 4 * 120, "{:?}", d.stats); + for l in &d.lines { + let top = l.placement == 7 && l.source == Source::Edge { edge: 1, tangent: false }; + let shown: f64 = l.pieces.iter().filter(|p| p.visibility == Visibility::Visible && !p.covered).map(|p| p.t[1] - p.t[0]).sum(); + if top { + assert!((shown - std::f64::consts::TAU).abs() < 1e-9, "the top circle shows whole: {:?}", l.pieces); + } else { + assert_eq!(shown, 0., "placement {} {:?}: {:?}", l.placement, l.source, l.pieces); + } + } +} + +#[test] +fn a_few_hundred_placements_draw_as_one() { + // 300 boxes apart on screen: each is drawn exactly as the first one is. + let def = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap(); + let scene: Vec = (0..300).map(|k| identity(0, [4. * (k % 20) as f64, 4. * (k / 20) as f64, 0.])).collect(); + let view = View::new(Vec3::new(0.3, 0.2, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &scene, &view, &Options::default(), &never).unwrap(); + assert_eq!(d.lines.len(), 300 * 12); + let first: Vec<&Line> = d.lines.iter().filter(|l| l.placement == 0).collect(); + assert!(first.iter().any(|l| l.pieces.iter().any(|p| p.visibility == Visibility::Hidden)), "a box hides some of its own edges"); + for (k, chunk) in d.lines.chunks(12).enumerate() { + for (l, f) in chunk.iter().zip(&first) { + assert_eq!((l.placement, l.source), (k, f.source)); + assert_eq!(l.pieces.len(), f.pieces.len(), "placement {k} {:?}", l.source); + for (p, q) in l.pieces.iter().zip(&f.pieces) { + assert_eq!((p.visibility, p.covered), (q.visibility, q.covered), "placement {k} {:?}", l.source); + assert!((p.t[0] - q.t[0]).abs() < 1e-9 && (p.t[1] - q.t[1]).abs() < 1e-9, "placement {k} {:?}", l.source); + } + } + } +} + +#[test] +fn chords_only_near_each_other_still_meet_their_crossings() { + // An arc of radius 1000 sampled as one chord (sag about 5 mm) and a line 1 mm off that chord + // toward the arc's bulge: all four ends lie within the sag of the other chord, yet the arc + // crosses the line twice. The crossings must be found, not skipped as a coincidence. + let def = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap(); + let scene = [identity(0, [0.; 3])]; + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let defs = [def]; + let ctx = context(&defs, &scene, &view, &Options::default(), &never).unwrap(); + let arc = C3::Arc { o: Vec3::ZERO, x: Vec3::new(1., 0., 0.), y: Vec3::new(0., 1., 0.), r: [1000., 1000.], range: [0., 0.2] }; + let (a, b) = (arc.at(0.), arc.at(0.2)); + let sag = 1000. * (1. - 0.1f64.cos()); + let out = Vec3::new(0.1f64.cos(), 0.1f64.sin(), 0.); + let line = C3::Line { a: a + out, b: b + out }; + let track = |curve: &C3, range: [f64; 2], sag: f64| { + let screen = |t: f64| view.screen(curve.at(t)); + Track { + placement: 0, + curve: TrackCurve::Owned(curve.clone()), + range, + source: Source::Edge { edge: 0, tangent: false }, + acc: CurveAccuracy::Analytic, + own: Vec::new(), + own_eps: 1e-6, + samples: vec![(range[0], screen(range[0])), (range[1], screen(range[1]))], + sags: vec![sag], + splits: Vec::new(), + } + }; + let mut tracks = vec![track(&arc, [0., 0.2], sag), track(&line, [0., 1.], 0.)]; + assert!(sag > 4.9 && ((a + out).dist(a) - 1.).abs() < 1e-12); + assert_eq!(crossings(&mut tracks, &ctx).unwrap(), 2); + assert!(tracks.iter().all(|t| t.splits.len() == 2), "{:?}", tracks.iter().map(|t| &t.splits).collect::>()); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 19a8f77a0c..0c60fbe722 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -15,21 +15,27 @@ pub mod blend; pub mod brep; pub mod bspline; mod bvh; +pub mod cell; mod chain; pub mod compare; +pub mod drawing_sheet; +pub mod drawing_view; pub mod env; pub mod fill; pub mod fill_general; mod grid; +pub mod hlr; mod mesh; pub mod mesh_distance; pub mod network; +pub mod paper; mod plane; mod profile; pub mod section; pub mod simplify; pub mod spline_space; pub mod tess; +pub mod title_block; pub mod tspline; mod unit_vec3; mod vec; @@ -42,7 +48,7 @@ pub use chain::{Chain, Closed, Ends, Open, Path, Planar, Ring}; pub use grid::Grid; pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair}; pub use plane::Plane; -pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, regions_apart}; +pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, Similarity2, bezier, regions_apart}; pub use unit_vec3::UnitVec3; pub use vec::{Vec2, Vec3}; diff --git a/crates/geom/src/paper.rs b/crates/geom/src/paper.rs new file mode 100644 index 0000000000..bcafed7796 --- /dev/null +++ b/crates/geom/src/paper.rs @@ -0,0 +1,222 @@ +//! The paper scene: what a sheet shows, as strokes and fills in sheet millimetres, for the writers +//! that put it in a file (DXF, SVG, PNG, PDF). Plain data with one check, [`Scene::validate`]: +//! nothing here is saved, drawn on screen or made by a command yet. Text, clips, line patterns and +//! styles are later additions. +//! +//! - Coordinates are millimetres from the sheet's lower-left corner, x to the right and y up; +//! angles are radians, anticlockwise. A line's width is its width on paper, centred on its path. +//! - Paint goes on in order: the groups in turn, then the items of each group in turn, each over +//! what is already there. +//! - A stroke has butt caps and miter joins with a miter limit of 4 (a join sharper than about 29° +//! is bevelled). A closed stroke joins at its start instead of having two caps. A fill is +//! painted even-odd, so a hole is a hole whichever way its loop runs. +//! - Each group says which object on the sheet drew it, by an id, and what kind of object that is. +//! The id is opaque here: it names one object within the sheet, and several groups may come from +//! the same object. DXF and PDF put a group on the layer its kind names ([`ObjectKind::layer`]). +//! +//! [`Scene::validate`] checks a scene before any writer reads it: +//! - at most [`MAX_RECORDS`] groups, items, loops and curve pieces in all, counted before any +//! geometry is read; +//! - each side of the sheet above 0 and at most [`MAX_SIDE_MM`]; +//! - each piece's numbers through the checks section geometry uses, with every stored coordinate +//! and radius finite and at most 1e5 mm in size, so every point drawn has both coordinates +//! within ±2e5 mm; +//! - a stroke's pieces meeting end to start within [`EPS`], and its ends too when it is closed; +//! every loop of a fill closed the same way; +//! - a line width above 0 and at most [`MAX_LINE_MM`]. +//! +//! It does not certify simple loops, holes inside their outer loop or a path that never doubles +//! back. Validation after parsing cannot bound parsing allocations: an encoded-input boundary must +//! also limit bytes and nesting. +//! +//! Writers read a [`Prepared`] scene, which only [`Scene::prepare`] makes, so none reads one that +//! wasn't checked. Clipping will happen there too, once, before any writer (A8). + +use serde::{Deserialize, Serialize}; + +use std::f64::consts::FRAC_PI_2; + +use crate::{EPS, Region2, Seg2, Vec2}; + +/// The widest line a scene may draw, in millimetres. +pub const MAX_LINE_MM: f64 = 10.0; +/// The longest side a sheet may have, in millimetres. +pub const MAX_SIDE_MM: f64 = 5000.0; +/// Groups, items, loops and curve pieces a scene may hold in all. +pub const MAX_RECORDS: usize = 100_000; +/// The largest coordinate and radius either way, in millimetres: twenty times the longest side. +pub const MAX_COORDINATE_MM: f64 = 1e5; + +/// One sheet's marks, in paint order. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Scene { + /// The sheet's width and height. + pub size_mm: Vec2, + pub groups: Vec, +} + +/// Items one object on the sheet drew, painted in order. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Group { + /// The id of the object that drew these items, opaque here. + pub object: u64, + /// What kind of object it is. + pub kind: ObjectKind, + pub items: Vec, +} + +/// What kind of object drew a group. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ObjectKind { + /// The physical edge of the sheet's paper. + SheetBorder, + /// The outline inside the sheet border that bounds the views and annotations, with the grid + /// and the centring and trimming marks of its profile. + DrawingBorder, + /// The block of fields that identifies the drawing and what its sheet documents. + TitleBlock, + /// The sheet table that presents an assembly's bill of materials. + PartsList, + /// The sheet table that lists the drawing's revisions. + RevisionTable, +} + +impl ObjectKind { + /// The layer DXF and PDF put this kind's groups on: its name in capitals, as DXF layers are + /// named. + pub fn layer(self) -> &'static str { + match self { + ObjectKind::SheetBorder => "SHEET_BORDER", + ObjectKind::DrawingBorder => "DRAWING_BORDER", + ObjectKind::TitleBlock => "TITLE_BLOCK", + ObjectKind::PartsList => "PARTS_LIST", + ObjectKind::RevisionTable => "REVISION_TABLE", + } + } +} + +/// One mark on the paper. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Item { + /// A line `width_mm` wide along `path`, each piece starting where the one before ends. A + /// closed stroke also ends where it starts. + Stroke { path: Vec, closed: bool, width_mm: f64 }, + /// A region painted solid. + Fill { region: Region2 }, +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("paper scene: {0}")] +pub struct Invalid(pub &'static str); + +type Result = std::result::Result; + +fn require(condition: bool, message: &'static str) -> Result<()> { + if condition { Ok(()) } else { Err(Invalid(message)) } +} + +struct Budget(usize); +impl Budget { + fn add(&mut self, n: usize) -> Result<()> { + const MESSAGE: &str = "more than 100,000 groups, items, loops and pieces"; + self.0 = self.0.checked_add(n).ok_or(Invalid(MESSAGE))?; + require(self.0 <= MAX_RECORDS, MESSAGE) + } +} + +fn pieces(segs: &[Seg2]) -> Result<()> { + segs.iter().try_for_each(|s| s.check(MAX_COORDINATE_MM).map_err(Invalid)) +} + +/// Whether `b` starts where `a` ends. +fn meet(a: &Seg2, b: &Seg2) -> bool { + a.end().dist(b.start()) <= EPS +} + +/// A scene that passed [`Scene::validate`]: what every writer reads. +#[derive(Clone, Copy, Debug)] +pub struct Prepared<'a> { + scene: &'a Scene, +} + +impl<'a> Prepared<'a> { + pub fn size_mm(&self) -> Vec2 { + self.scene.size_mm + } + + /// The groups, in paint order. + pub fn groups(&self) -> &'a [Group] { + &self.scene.groups + } +} + +/// The ends of the pieces of at most a quarter turn the arc of `sweep` radians from `start` +/// round `center` is drawn in, in order, the last its own end. For writers that draw an arc from +/// its ends (SVG's `A`): there a half turn's centre is ill-conditioned, and moves by up to 1e-2 mm +/// once its ends are rounded to the nanometre (six decimals of a millimetre), where a quarter +/// turn's stays under 1e-6 mm. +pub fn arc_pieces(center: Vec2, radius: f64, start: f64, sweep: f64) -> impl Iterator { + // A scene's sweeps are at most a whole turn and a little: one to four pieces. + let n = ((sweep.abs() / FRAC_PI_2) - 1e-9).ceil().clamp(1.0, 4.0) as usize; + (1..=n).map(move |k| { + let angle = if k == n { start + sweep } else { start + sweep * (k as f64 / n as f64) }; + center + Vec2::from_angle(angle) * radius + }) +} + +impl Scene { + /// The scene, checked ([`Scene::validate`]), for the writers. + pub fn prepare(&self) -> Result> { + self.validate()?; + Ok(Prepared { scene: self }) + } + + /// Checks the scene against the limits in the module's notes, counting its records first. + pub fn validate(&self) -> Result<()> { + let mut budget = Budget(0); + budget.add(self.groups.len())?; + for group in &self.groups { + budget.add(group.items.len())?; + for item in &group.items { + match item { + Item::Stroke { path, .. } => budget.add(path.len())?, + Item::Fill { region } => { + for l in region.loops() { + budget.add(1 + l.segs.len())?; + } + } + } + } + } + let Vec2 { x, y } = self.size_mm; + require([x, y].into_iter().all(|side| side > 0.0 && side <= MAX_SIDE_MM), "each side of the sheet is above 0 and at most 5000 mm")?; + for item in self.groups.iter().flat_map(|g| &g.items) { + match item { + Item::Stroke { path, closed, width_mm } => { + require(*width_mm > 0.0 && *width_mm <= MAX_LINE_MM, "a line is above 0 and at most 10 mm wide")?; + require(!path.is_empty(), "a stroke has at least one piece")?; + pieces(path)?; + require(path.iter().zip(path.iter().skip(1)).all(|(a, b)| meet(a, b)), "a stroke's pieces meet end to start")?; + if *closed { + require(path.last().zip(path.first()).is_some_and(|(a, b)| meet(a, b)), "a closed stroke ends where it starts")?; + } + } + Item::Fill { region } => { + for l in region.loops() { + pieces(&l.segs)?; + require(l.is_closed(EPS), "each loop of a fill is closed")?; + } + } + } + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "paper_tests.rs"] +mod tests; diff --git a/crates/geom/src/paper_tests.rs b/crates/geom/src/paper_tests.rs new file mode 100644 index 0000000000..8cc36a6e55 --- /dev/null +++ b/crates/geom/src/paper_tests.rs @@ -0,0 +1,305 @@ +use std::f64::consts::{PI, TAU}; + +use serde_json::json; + +use super::*; +use crate::{Loop2, MAX_BEZIER_CTRL}; + +fn p(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) +} + +fn line(a: Vec2, b: Vec2) -> Seg2 { + Seg2::Line { a, b } +} + +fn stroke(path: Vec, closed: bool, width_mm: f64) -> Item { + Item::Stroke { path, closed, width_mm } +} + +fn fill(outer: Loop2, holes: Vec) -> Item { + Item::Fill { region: Region2 { outer, holes } } +} + +/// Anticlockwise from `min`. +fn rectangle(min: Vec2, max: Vec2) -> Loop2 { + Loop2::polygon(&[min, p(max.x, min.y), max, p(min.x, max.y)]) +} + +/// An A3 landscape sheet with one object's items. +fn sheet(items: Vec) -> Scene { + Scene { size_mm: p(420.0, 297.0), groups: vec![Group { object: 1, kind: ObjectKind::DrawingBorder, items }] } +} + +fn open(path: Vec) -> Scene { + sheet(vec![stroke(path, false, 0.35)]) +} + +fn closed(path: Vec) -> Scene { + sheet(vec![stroke(path, true, 0.35)]) +} + +/// ISO 5457's drawing border on an A3 landscape sheet, and the two trimming rectangles at its +/// lower-left corner, by hand. +fn a3() -> Scene { + sheet(vec![ + stroke(rectangle(p(20.0, 10.0), p(410.0, 287.0)).segs, true, 0.7), + fill(rectangle(p(0.0, 0.0), p(10.0, 5.0)), vec![]), + fill(rectangle(p(0.0, 0.0), p(5.0, 10.0)), vec![]), + ]) +} + +#[test] +fn an_a3_drawing_border_and_its_trimming_marks_validate_and_round_trip() { + let scene = a3(); + scene.validate().unwrap(); + let value = serde_json::to_value(&scene).unwrap(); + assert_eq!(value["size_mm"], json!([420.0, 297.0])); + let group = &value["groups"][0]; + assert_eq!(group["object"], json!(1)); + assert_eq!(group["kind"], "drawing_border"); + let border = &group["items"][0]; + assert_eq!(border["kind"], "stroke"); + assert_eq!(border["closed"], true); + assert_eq!(border["width_mm"], 0.7); + assert_eq!(border["path"][0], json!({"type": "line", "a": [20.0, 10.0], "b": [410.0, 10.0]})); + assert_eq!(border["path"][2], json!({"type": "line", "a": [410.0, 287.0], "b": [20.0, 287.0]})); + assert_eq!(border["path"][3]["b"], json!([20.0, 10.0])); + let trim = &group["items"][2]; + assert_eq!(trim["kind"], "fill"); + assert_eq!(trim["region"]["outer"]["segs"][1], json!({"type": "line", "a": [5.0, 0.0], "b": [5.0, 10.0]})); + assert_eq!(trim["region"]["holes"], json!([])); + let restored: Scene = serde_json::from_value(value).unwrap(); + restored.validate().unwrap(); + assert_eq!(restored, scene); +} + +#[test] +fn each_kind_names_its_layer_in_the_capitals_of_its_id() { + use ObjectKind::*; + for (kind, layer) in [ + (SheetBorder, "SHEET_BORDER"), + (DrawingBorder, "DRAWING_BORDER"), + (TitleBlock, "TITLE_BLOCK"), + (PartsList, "PARTS_LIST"), + (RevisionTable, "REVISION_TABLE"), + ] { + assert_eq!(kind.layer(), layer); + let id = layer.to_ascii_lowercase(); + assert_eq!(serde_json::to_value(kind).unwrap(), json!(id)); + assert_eq!(serde_json::from_value::(json!(id)).unwrap(), kind); + } +} + +#[test] +fn each_side_of_the_sheet_is_above_zero_and_at_most_5000_mm() { + const REFUSED: Invalid = Invalid("each side of the sheet is above 0 and at most 5000 mm"); + // Marks may lie off a small sheet: the scene doesn't clip them. + for side in [5000.0, 1e-9, f64::MIN_POSITIVE] { + for size_mm in [p(side, 297.0), p(420.0, side)] { + Scene { size_mm, ..a3() }.validate().unwrap(); + } + } + for side in [0.0, -0.0, -1.0, 5000.001, 5000f64.next_up(), f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for size_mm in [p(side, 297.0), p(420.0, side)] { + assert_eq!(Scene { size_mm, ..a3() }.validate(), Err(REFUSED), "{size_mm:?}"); + } + } +} + +#[test] +fn a_line_is_above_zero_and_at_most_10_mm_wide() { + let border = |width| sheet(vec![stroke(rectangle(p(20.0, 10.0), p(410.0, 287.0)).segs, true, width)]); + for width in [1e-9, 0.7, 10.0] { + border(width).validate().unwrap(); + } + for width in [0.0, -0.0, -0.7, 10.000001, 10f64.next_up(), f64::NAN, f64::INFINITY] { + assert_eq!(border(width).validate(), Err(Invalid("a line is above 0 and at most 10 mm wide")), "{width}"); + } +} + +#[test] +fn coordinates_and_radii_are_finite_and_at_most_1e5_mm() { + const REFUSED: Invalid = Invalid("non-finite or excessive coordinate"); + for v in [1e5, -1e5] { + open(vec![line(p(0.0, 0.0), p(v, 0.0))]).validate().unwrap(); + open(vec![line(p(0.0, v), p(1.0, 0.0))]).validate().unwrap(); + } + for v in [1e5 + 1e-6, -1e5 - 1e-6, 1e5f64.next_up(), f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(open(vec![line(p(0.0, 0.0), p(v, 0.0))]).validate(), Err(REFUSED), "{v}"); + assert_eq!(open(vec![line(p(0.0, v), p(1.0, 0.0))]).validate(), Err(REFUSED), "{v}"); + assert_eq!(sheet(vec![fill(rectangle(p(0.0, 0.0), p(v, 1.0)), vec![])]).validate(), Err(REFUSED), "{v}"); + } + // A radius has the same bound, so a circle's points stay within ±2e5 mm each way. + let circle = |center, radius| vec![Seg2::Arc { center, radius, start: 0.0, sweep: TAU }]; + closed(circle(p(1e5, -1e5), 1e5)).validate().unwrap(); + for radius in [1e5 + 1e-6, 0.0, -1.0, 1e-13, f64::NAN, f64::INFINITY] { + assert_eq!(closed(circle(p(0.0, 0.0), radius)).validate(), Err(Invalid("arc radius")), "{radius}"); + } + assert_eq!(closed(circle(p(1e5 + 1e-6, 0.0), 1.0)).validate(), Err(REFUSED)); +} + +#[test] +fn every_piece_goes_through_the_section_checks_at_the_papers_bound() { + let arc = |radius, start, sweep| Seg2::Arc { center: p(50.0, 50.0), radius, start, sweep }; + let conic = |w| Seg2::Conic { a: p(1.0, 0.0), apex: p(1.0, 1.0), b: p(0.0, 1.0), w }; + let one = p(1.0, 1.0); + for (piece, message) in [ + (arc(0.0, 0.0, PI), "arc radius"), + (arc(10.0, 1e6 + 1.0, PI), "arc start angle"), + (arc(10.0, 0.0, 0.0), "arc sweep"), + (arc(10.0, 0.0, -TAU - 1e-8), "arc sweep"), + (conic(0.0), "conic weight"), + (conic(1e12 + 1.0), "conic weight"), + (line(one, one), "collapsed curve"), + (Seg2::Cubic { p0: one, p1: one, p2: one, p3: one }, "collapsed curve"), + ] { + assert_eq!(open(vec![piece]).validate(), Err(Invalid(message)), "{piece:?}"); + } + // A conic's weight is a ratio, not a length: the paper's bound doesn't apply to it. + open(vec![conic(1e12)]).validate().unwrap(); + // A fill's loops go through the same checks: a zero-radius arc in a hole. + let hole = Loop2 { segs: vec![arc(0.0, 0.0, TAU)] }; + assert_eq!(sheet(vec![fill(rectangle(p(0.0, 0.0), p(100.0, 100.0)), vec![hole])]).validate(), Err(Invalid("arc radius"))); + // A Bézier's unused slots are checked at the paper's bound too, as they are serialized. + let mut controls = [Vec2::ZERO; MAX_BEZIER_CTRL]; + for (i, q) in controls.iter_mut().enumerate().take(5) { + *q = p(i as f64, (i % 2) as f64); + } + open(vec![Seg2::Bezier { n: 4, p: controls }]).validate().unwrap(); + for value in [f64::NAN, f64::INFINITY, 1e5 + 1e-6] { + let mut hidden = controls; + hidden[MAX_BEZIER_CTRL - 1] = p(value, 0.0); + assert_eq!(open(vec![Seg2::Bezier { n: 4, p: hidden }]).validate(), Err(Invalid("non-finite or excessive coordinate")), "{value}"); + } + for n in [3, 10] { + assert_eq!(open(vec![Seg2::Bezier { n, p: controls }]).validate(), Err(Invalid("Bezier degree"))); + } +} + +#[test] +fn a_strokes_pieces_meet_within_1e_9_mm_and_a_closed_one_ends_where_it_starts() { + const APART: Invalid = Invalid("a stroke's pieces meet end to start"); + const OPEN_ENDS: Invalid = Invalid("a closed stroke ends where it starts"); + // The joins lie at the origin, where a gap of 1e-9 mm is exact. + let corner = |gap| vec![line(p(-10.0, 0.0), p(0.0, 0.0)), line(p(gap, 0.0), p(0.0, 10.0))]; + open(corner(1e-9)).validate().unwrap(); + for gap in [2e-9, 1e-9f64.next_up(), -2e-9] { + assert_eq!(open(corner(gap)).validate(), Err(APART), "{gap}"); + } + let square = |gap| vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(10.0, 10.0)), line(p(10.0, 10.0), p(0.0, gap))]; + closed(square(1e-9)).validate().unwrap(); + for gap in [2e-9, 1e-9f64.next_up(), 10.0] { + assert_eq!(closed(square(gap)).validate(), Err(OPEN_ENDS), "{gap}"); + open(square(gap)).validate().unwrap(); + } +} + +#[test] +fn stroke_shapes_one_piece_reversed_duplicated_and_doubled_back() { + let a = line(p(0.0, 0.0), p(10.0, 0.0)); + let border = rectangle(p(20.0, 10.0), p(410.0, 287.0)); + open(vec![a]).validate().unwrap(); + assert_eq!(closed(vec![a]).validate(), Err(Invalid("a closed stroke ends where it starts"))); + assert_eq!(open(vec![]).validate(), Err(Invalid("a stroke has at least one piece"))); + assert_eq!(closed(vec![]).validate(), Err(Invalid("a stroke has at least one piece"))); + // A whole circle as one piece, or as two halves. + closed(vec![Seg2::Arc { center: p(100.0, 100.0), radius: 5.0, start: 0.3, sweep: -TAU }]).validate().unwrap(); + closed(Loop2::circle(p(100.0, 100.0), 5.0).segs).validate().unwrap(); + closed(border.reversed().segs).validate().unwrap(); + // A piece twice over doesn't join; out of order neither. + assert_eq!(open(vec![a, a]).validate(), Err(Invalid("a stroke's pieces meet end to start"))); + let mut swapped = border.segs.clone(); + swapped.swap(1, 2); + assert_eq!(closed(swapped).validate(), Err(Invalid("a stroke's pieces meet end to start"))); + // Doubling back joins, so it is accepted: the scene doesn't certify a path. + open(vec![a, a.reversed()]).validate().unwrap(); + closed(vec![a, a.reversed()]).validate().unwrap(); + // Nothing to draw is a valid scene, and so is an object that drew nothing. + Scene { groups: vec![], ..a3() }.validate().unwrap(); + sheet(vec![]).validate().unwrap(); +} + +#[test] +fn every_loop_of_a_fill_is_closed_within_1e_9_mm() { + const OPEN: Invalid = Invalid("each loop of a fill is closed"); + let outer = rectangle(p(0.0, 0.0), p(100.0, 100.0)); + // A triangle whose last piece ends `gap` from its start, at the origin. + let triangle = |gap| Loop2 { segs: vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(0.0, 10.0)), line(p(0.0, 10.0), p(0.0, gap))] }; + sheet(vec![fill(triangle(1e-9), vec![])]).validate().unwrap(); + let hole = |gap: f64| triangle(gap).reversed(); + sheet(vec![fill(outer.clone(), vec![hole(1e-9)])]).validate().unwrap(); + for gap in [2e-9, 1e-9f64.next_up(), 5.0] { + assert_eq!(sheet(vec![fill(triangle(gap), vec![])]).validate(), Err(OPEN), "{gap}"); + assert_eq!(sheet(vec![fill(outer.clone(), vec![hole(gap)])]).validate(), Err(OPEN), "{gap}"); + } + assert_eq!(sheet(vec![fill(Loop2::default(), vec![])]).validate(), Err(OPEN)); + assert_eq!(sheet(vec![fill(outer.clone(), vec![Loop2::default()])]).validate(), Err(OPEN)); + // Even-odd: a hole that runs the same way as its outer loop, and a round hole, are accepted. + sheet(vec![fill(outer.clone(), vec![rectangle(p(10.0, 10.0), p(20.0, 20.0))])]).validate().unwrap(); + sheet(vec![fill(outer, vec![Loop2::circle(p(50.0, 50.0), 5.0).reversed()])]).validate().unwrap(); +} + +#[test] +fn at_most_100_000_records_are_counted_before_any_geometry() { + const REFUSED: Invalid = Invalid("more than 100,000 groups, items, loops and pieces"); + let group = |items| Group { object: 7, kind: ObjectKind::PartsList, items }; + let scene = |groups| Scene { size_mm: p(420.0, 297.0), groups }; + // Groups alone. + scene(vec![group(vec![]); MAX_RECORDS]).validate().unwrap(); + assert_eq!(scene(vec![group(vec![]); MAX_RECORDS + 1]).validate(), Err(REFUSED)); + // One group and one stroke, then its pieces. + let steps = |n: usize| -> Vec { (0..n).map(|i| line(p(i as f64 * 1e-3, 0.0), p((i + 1) as f64 * 1e-3, 0.0))).collect() }; + scene(vec![group(vec![stroke(steps(MAX_RECORDS - 2), false, 0.35)])]).validate().unwrap(); + assert_eq!(scene(vec![group(vec![stroke(steps(MAX_RECORDS - 1), false, 0.35)])]).validate(), Err(REFUSED)); + // A fill counts each loop as well as its pieces: 2 + (1 + 5) + 49,996 × (1 + 1) = 100,000. + let holes = vec![Loop2 { segs: vec![Seg2::Arc { center: p(200.0, 150.0), radius: 1.0, start: 0.0, sweep: TAU }] }; 49_996]; + let polygon = |n: usize| { + let corners: Vec = (0..n).map(|i| p(200.0, 150.0) + Vec2::from_angle(TAU * i as f64 / n as f64) * 100.0).collect(); + Loop2::polygon(&corners) + }; + scene(vec![group(vec![fill(polygon(5), holes.clone())])]).validate().unwrap(); + assert_eq!(scene(vec![group(vec![fill(polygon(6), holes)])]).validate(), Err(REFUSED)); + // Counted first: an over-budget scene with a non-finite piece reports the count. + let mut bad = steps(MAX_RECORDS - 1); + bad[0] = line(p(f64::NAN, 0.0), p(1.0, 0.0)); + assert_eq!(Scene { size_mm: p(f64::NAN, 0.0), ..scene(vec![group(vec![stroke(bad, false, f64::NAN)])]) }.validate(), Err(REFUSED)); + let mut count = Budget(MAX_RECORDS); + assert_eq!(count.add(usize::MAX), Err(REFUSED)); +} + +#[test] +fn unknown_fields_kinds_and_ids_are_refused_when_read() { + let value = serde_json::to_value(a3()).unwrap(); + let read = |v: serde_json::Value| serde_json::from_value::(v); + read(value.clone()).unwrap(); + for at in ["", "/groups/0", "/groups/0/items/0", "/groups/0/items/1"] { + let mut v = value.clone(); + v.pointer_mut(at).unwrap()["layer"] = json!("A"); + assert!(read(v).is_err(), "an unknown field at {at:?}"); + } + let mut v = value.clone(); + v["groups"][0]["items"][0]["kind"] = json!("text"); + assert!(read(v).is_err()); + for kind in ["frame", "Title Block", "TITLE_BLOCK", "titleblock", ""] { + let mut v = value.clone(); + v["groups"][0]["kind"] = json!(kind); + assert!(read(v).is_err(), "{kind:?}"); + } + // The id is any u64 and nothing else. + for object in [json!(-1), json!(1.5), json!("1"), json!(null)] { + let mut v = value.clone(); + v["groups"][0]["object"] = object; + assert!(read(v).is_err()); + } + let mut v = value.clone(); + v["groups"][0]["object"] = json!("ID"); + let with = |id: &str| serde_json::from_str::(&serde_json::to_string(&v).unwrap().replace("\"ID\"", id)); + with("1").unwrap(); + assert!(with("18446744073709551616").is_err()); + v["groups"][0].as_object_mut().unwrap().remove("object"); + assert!(read(v).is_err()); + let mut v = value; + v["groups"][0]["object"] = json!(u64::MAX); + read(v).unwrap().validate().unwrap(); +} diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 7edc5985f1..21437c5ff4 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -58,6 +58,42 @@ pub const MAX_BEZIER_CTRL: usize = MAX_BEZIER_DEGREE + 1; /// continuity order a blend curve can be built for (4 = G4). pub const MAX_DERIVATIVE_ORDER: usize = 4; +/// A similarity of the plane: a point is scaled by `scale` (above 0) about the origin, turned +/// `angle` radians counter-clockwise, then moved by `shift`. It keeps shapes and the direction +/// curves turn in, so an arc stays an arc ([`Seg2::similar`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Similarity2 { + scale: f64, + angle: f64, + shift: Vec2, + /// The angle's cosine and sine times the scale, worked out once rather than per point. + cos: f64, + sin: f64, +} + +impl Similarity2 { + /// `None` unless `scale` is finite and above 0 and `angle` and `shift` are finite. + pub fn new(scale: f64, angle: f64, shift: Vec2) -> Option { + if !(scale.is_finite() && scale > 0.0 && angle.is_finite() && shift.is_finite()) { + return None; + } + let (sin, cos) = angle.sin_cos(); + Some(Similarity2 { scale, angle, shift, cos: cos * scale, sin: sin * scale }) + } + + pub fn scale(&self) -> f64 { + self.scale + } + + pub fn angle(&self) -> f64 { + self.angle + } + + pub fn apply(&self, p: Vec2) -> Vec2 { + Vec2::new(self.cos * p.x - self.sin * p.y, self.sin * p.x + self.cos * p.y) + self.shift + } +} + /// A Bézier segment over `pts` (2…[`MAX_BEZIER_CTRL`] points): a line, a cubic or a higher-degree /// piece. A quadratic becomes a conic of weight 1. pub fn bezier(pts: &[Vec2]) -> Option { @@ -76,6 +112,62 @@ pub fn bezier(pts: &[Vec2]) -> Option { }) } +/// Smallest radius, conic weight and absolute arc sweep [`Seg2::check`] accepts. +const MIN_POSITIVE: f64 = 1e-12; +/// Largest conic weight [`Seg2::check`] accepts. +const MAX_WEIGHT: f64 = 1e12; +/// Largest arc start angle either way, in radians, [`Seg2::check`] accepts. +const MAX_START_ANGLE: f64 = 1e6; + +fn require(condition: bool, message: &'static str) -> Result<(), &'static str> { + if condition { Ok(()) } else { Err(message) } +} + +/// Every value finite and at most `max_abs` in size: [`Seg2::check`]'s rule for coordinates, which +/// `section` applies to its planes and contacts too. +pub(crate) fn coordinates(values: impl IntoIterator, max_abs: f64) -> Result<(), &'static str> { + require(values.into_iter().all(|v| v.is_finite() && v.abs() <= max_abs), "non-finite or excessive coordinate") +} + +fn controls(points: &[Vec2], max_abs: f64) -> Result<(), &'static str> { + coordinates(points.iter().flat_map(|p| [p.x, p.y]), max_abs)?; + let Some((first, rest)) = points.split_first() else { return Err("empty controls") }; + require(rest.iter().any(|p| p != first), "collapsed curve") +} + +impl Seg2 { + /// Checks a segment read from outside before anything evaluates it: every stored coordinate + /// finite and at most `max_abs` in size (a Bézier's unused slots too, as they are serialized), + /// a radius in 1e-12..=`max_abs`, an arc's start within ±1e6 radians and its absolute sweep + /// 1e-12 to one turn (with 1e-12 radians of allowance), a conic weight in 1e-12..=1e12, a + /// Bézier degree of 4 to [`MAX_BEZIER_DEGREE`], controls not all at one point, and finite + /// ends. The limits keep evaluation finite; they promise no accuracy. The caller wraps the + /// message in its own error. + pub(crate) fn check(&self, max_abs: f64) -> Result<(), &'static str> { + match *self { + Seg2::Line { a, b } => controls(&[a, b], max_abs)?, + Seg2::Arc { center, radius, start, sweep } => { + coordinates([center.x, center.y], max_abs)?; + require(radius.is_finite() && (MIN_POSITIVE..=max_abs).contains(&radius), "arc radius")?; + require(start.is_finite() && start.abs() <= MAX_START_ANGLE, "arc start angle")?; + require(sweep.is_finite() && (MIN_POSITIVE..=TAU + 1e-12).contains(&sweep.abs()), "arc sweep")?; + } + Seg2::Cubic { p0, p1, p2, p3 } => controls(&[p0, p1, p2, p3], max_abs)?, + Seg2::Conic { a, apex, b, w } => { + controls(&[a, apex, b], max_abs)?; + require(w.is_finite() && (MIN_POSITIVE..=MAX_WEIGHT).contains(&w), "conic weight")?; + } + Seg2::Bezier { n, ref p } => { + require((4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)), "Bezier degree")?; + // The unused slots are serialized too: reject hidden non-finite/excessive values. + coordinates(p.iter().flat_map(|q| [q.x, q.y]), max_abs)?; + controls(p.get(..=usize::from(n)).ok_or("Bezier degree")?, max_abs)?; + } + } + require(self.start().is_finite() && self.end().is_finite(), "non-finite curve endpoint") + } +} + /// de Casteljau: the point at `t` and the two halves' control points. fn casteljau(c: &[Vec2], t: f64) -> (Vec2, Vec, Vec) { let mut w = c.to_vec(); @@ -318,6 +410,31 @@ impl Seg2 { } } } + /// The segment with each control point mapped by `f`: exact for any affine map of lines, + /// Béziers and conics. An arc is returned as it is: only a similarity keeps it an arc + /// ([`Seg2::similar`]), and callers that mirror one handle it themselves. + pub fn map_controls(&self, f: impl Fn(Vec2) -> Vec2) -> Seg2 { + match *self { + Seg2::Line { a, b } => Seg2::Line { a: f(a), b: f(b) }, + Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: f(p0), p1: f(p1), p2: f(p2), p3: f(p3) }, + Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: f(a), apex: f(apex), b: f(b), w }, + Seg2::Bezier { n, mut p } => { + p.iter_mut().take(usize::from(n) + 1).for_each(|q| *q = f(*q)); + Seg2::Bezier { n, p } + } + arc @ Seg2::Arc { .. } => arc, + } + } + /// The segment moved by the similarity `m`, exactly: an arc's centre maps, its radius scales + /// and its start turns; every other kind maps its control points. + pub fn similar(&self, m: &Similarity2) -> Seg2 { + match *self { + Seg2::Arc { center, radius, start, sweep } => { + Seg2::Arc { center: m.apply(center), radius: radius * m.scale(), start: start + m.angle(), sweep } + } + _ => self.map_controls(|p| m.apply(p)), + } + } pub fn length(&self) -> f64 { match *self { Seg2::Line { a, b } => a.dist(b), @@ -586,6 +703,10 @@ impl Region2 { pub fn area(&self) -> f64 { self.outer.signed_area().abs() - self.holes.iter().map(|h| h.signed_area().abs()).sum::() } + /// The outer loop, then the holes. + pub fn loops(&self) -> impl Iterator + Clone { + std::iter::once(&self.outer).chain(&self.holes) + } /// A point strictly inside the region (for picking and labels). pub fn interior_point(&self) -> Vec2 { let c = self.centroid(); diff --git a/crates/geom/src/section.rs b/crates/geom/src/section.rs index ee2d99238e..ba3dcf6656 100644 --- a/crates/geom/src/section.rs +++ b/crates/geom/src/section.rs @@ -21,18 +21,15 @@ //! exact or certified measures for conics and freeform curves. Profile values contain geometry //! only: callers retain the original result's accuracy declarations and local identities. -use std::f64::consts::TAU; - use serde::{Deserialize, Serialize}; -use crate::{Loop2, MAX_BEZIER_DEGREE, Plane, Region2, Seg2, Vec2, Vec3}; +use crate::profile::coordinates; +use crate::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; /// Aggregate input records/references, and the selected conversion's output items, at most. /// Input counts curves, wires, regions, contacts, curve uses and hole references together. pub const MAX_ITEMS: usize = 100_000; -const MAX_NUMBER: f64 = 1e12; -const MIN_POSITIVE: f64 = 1e-12; -const MAX_START_ANGLE: f64 = 1e6; +pub(crate) const MAX_NUMBER: f64 = 1e12; const FRAME_TOLERANCE: f64 = 1e-8; /// A section's geometric result, without source identity, freshness or persistence semantics. @@ -68,6 +65,20 @@ pub enum CurveAccuracy { Sampled { max_sample_deviation_mm: f64 }, } +impl CurveAccuracy { + /// The same accuracy for the curve scaled by `factor` (above 0): a deviation scales with it, + /// and an analytic curve stays analytic. + pub fn scaled(self, factor: f64) -> CurveAccuracy { + match self { + CurveAccuracy::Analytic => CurveAccuracy::Analytic, + CurveAccuracy::Bounded { max_deviation_mm } => CurveAccuracy::Bounded { max_deviation_mm: max_deviation_mm * factor }, + CurveAccuracy::Sampled { max_sample_deviation_mm } => { + CurveAccuracy::Sampled { max_sample_deviation_mm: max_sample_deviation_mm * factor } + } + } + } +} + /// An ordered connected chain. `closed` is explicit, never inferred or repaired. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -111,16 +122,12 @@ impl Budget { } } -fn numbers(values: impl IntoIterator) -> Result<()> { - require(values.into_iter().all(|v| v.is_finite() && v.abs() <= MAX_NUMBER), "non-finite or excessive coordinate") -} - fn point(p: Vec2) -> Result<()> { - numbers([p.x, p.y]) + coordinates([p.x, p.y], MAX_NUMBER).map_err(Invalid) } fn vector(p: Vec3) -> Result<()> { - numbers([p.x, p.y, p.z]) + coordinates([p.x, p.y, p.z], MAX_NUMBER).map_err(Invalid) } /// Strictly check an existing plane without re-orthonormalizing it or changing its serde format. @@ -137,38 +144,8 @@ pub fn validate_plane(plane: &Plane) -> Result<()> { ) } -fn controls(points: &[Vec2]) -> Result<()> { - for p in points { - point(*p)?; - } - let Some((first, rest)) = points.split_first() else { return Err(Invalid("empty controls")) }; - require(rest.iter().any(|p| p != first), "collapsed curve") -} - fn curve(curve: &SectionCurve) -> Result<()> { - match curve.geometry { - Seg2::Line { a, b } => controls(&[a, b])?, - Seg2::Arc { center, radius, start, sweep } => { - point(center)?; - require(radius.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&radius), "arc radius")?; - require(start.is_finite() && start.abs() <= MAX_START_ANGLE, "arc start angle")?; - require(sweep.is_finite() && (MIN_POSITIVE..=TAU + 1e-12).contains(&sweep.abs()), "arc sweep")?; - } - Seg2::Cubic { p0, p1, p2, p3 } => controls(&[p0, p1, p2, p3])?, - Seg2::Conic { a, apex, b, w } => { - controls(&[a, apex, b])?; - require(w.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&w), "conic weight")?; - } - Seg2::Bezier { n, ref p } => { - require((4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)), "Bezier degree")?; - // The unused slots are serialized too: reject hidden non-finite/excessive values. - for q in p { - point(*q)?; - } - controls(p.get(..=usize::from(n)).ok_or(Invalid("Bezier degree"))?)?; - } - } - require(curve.geometry.start().is_finite() && curve.geometry.end().is_finite(), "non-finite curve endpoint")?; + curve.geometry.check(MAX_NUMBER).map_err(Invalid)?; match curve.accuracy { CurveAccuracy::Analytic => Ok(()), CurveAccuracy::Bounded { max_deviation_mm } => deviation(max_deviation_mm), diff --git a/crates/geom/src/section_tests.rs b/crates/geom/src/section_tests.rs index 961409ac81..7feb71c72f 100644 --- a/crates/geom/src/section_tests.rs +++ b/crates/geom/src/section_tests.rs @@ -1,4 +1,4 @@ -use std::f64::consts::PI; +use std::f64::consts::{PI, TAU}; use super::*; use crate::{MAX_BEZIER_CTRL, bezier}; @@ -407,3 +407,17 @@ fn connected_bow_tie_and_wrong_winding_are_not_certified_or_silently_repaired() let converted = reversed.to_region(0, TOL).unwrap(); assert!((converted.outer.signed_area() + 6000.0).abs() < 1e-10); } + +/// A curve's accuracy scales with it: a bound or a sampled deviation by the factor, and an analytic +/// curve stays analytic (W17a: paper error = scale × model error). +#[test] +fn an_accuracy_scales_with_its_curve() { + assert_eq!(CurveAccuracy::Analytic.scaled(0.5), CurveAccuracy::Analytic); + assert_eq!(CurveAccuracy::Sampled { max_sample_deviation_mm: 0.01 }.scaled(3.0), CurveAccuracy::Sampled { max_sample_deviation_mm: 0.03 }); + assert_eq!(CurveAccuracy::Bounded { max_deviation_mm: 0.01 }.scaled(2.0), CurveAccuracy::Bounded { max_deviation_mm: 0.02 }); + assert_eq!( + CurveAccuracy::Sampled { max_sample_deviation_mm: 0.0 }.scaled(5.0), + CurveAccuracy::Sampled { max_sample_deviation_mm: 0.0 }, + "a sampled zero stays sampled" + ); +} diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index ab614f0e4e..6ff94beefd 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -847,3 +847,48 @@ fn a_box_from_points_holds_them_and_grows() { assert_eq!((both.min, both.max), (Vec3::new(0.0, 1.0, 4.0), Vec3::new(1.0, 2.0, 5.0))); assert!(b.intersection(&far).is_empty()); } + +/// A similarity moves every kind of segment exactly: the moved segment's points are the original's +/// points moved, its length scales, and an arc stays an arc turning the same way. +#[test] +fn a_similarity_moves_each_kind_of_segment_exactly() { + let m = Similarity2::new(2.5, 0.7, Vec2::new(-3.0, 11.0)).unwrap(); + let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL]; + for (i, q) in p.iter_mut().take(6).enumerate() { + *q = Vec2::new(i as f64, (i * i) as f64 * 0.3); + } + let segments = [ + Seg2::Line { a: Vec2::new(1.0, 2.0), b: Vec2::new(4.0, -1.0) }, + Seg2::Arc { center: Vec2::new(2.0, 1.0), radius: 3.0, start: 0.4, sweep: -2.1 }, + Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(1.0, 2.0), p2: Vec2::new(3.0, 2.0), p3: Vec2::new(4.0, 0.0) }, + Seg2::Conic { a: Vec2::new(10.0, 0.0), apex: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0), w: std::f64::consts::FRAC_1_SQRT_2 }, + Seg2::Bezier { n: 5, p }, + ]; + for seg in segments { + let moved = seg.similar(&m); + for i in 0..=8 { + let t = f64::from(i) / 8.0; + assert!((moved.point_at(t) - m.apply(seg.point_at(t))).len() < 1e-9, "{seg:?} at {t}"); + } + assert!((moved.length() - seg.length() * m.scale()).abs() < 1e-9 * seg.length().max(1.0), "{seg:?}"); + assert_eq!(std::mem::discriminant(&moved), std::mem::discriminant(&seg)); + } + // An arc keeps its sweep: the turning direction survives a similarity. + let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: -1.0 }; + assert!(matches!(arc.similar(&m), Seg2::Arc { sweep, .. } if sweep == -1.0)); + // The identity changes nothing. + let identity = Similarity2::new(1.0, 0.0, Vec2::ZERO).unwrap(); + for seg in segments { + assert_eq!(seg.similar(&identity), seg); + } + // A similarity scales by a finite number above 0, and turns and moves by finite ones. + for (scale, angle, shift) in [ + (0.0, 0.0, Vec2::ZERO), + (-1.0, 0.0, Vec2::ZERO), + (f64::NAN, 0.0, Vec2::ZERO), + (1.0, f64::INFINITY, Vec2::ZERO), + (1.0, 0.0, Vec2::new(f64::NAN, 0.0)), + ] { + assert!(Similarity2::new(scale, angle, shift).is_none(), "{scale} {angle} {shift:?}"); + } +} diff --git a/crates/geom/src/title_block.rs b/crates/geom/src/title_block.rs new file mode 100644 index 0000000000..8efc6bc72c --- /dev/null +++ b/crates/geom/src/title_block.rs @@ -0,0 +1,230 @@ +//! Title blocks: the block of cells that identifies a drawing and what its sheet documents, as +//! data in sheet millimetres. +//! +//! A [`TitleBlock`] is a definition: its size, its two line widths and its cells. Each [`Cell`] is +//! a rectangle measured from the block's lower-right corner (x at most 0, y at least 0), with a +//! label and a value: text from cell content ([`crate::cell`]), or the projection symbol. The block +//! sits on the drawing border's lower-right corner, so one definition serves every sheet it fits. +//! [`TitleBlock::validate`] checks a definition on its own, as when one is read, and +//! [`TitleBlock::layout`] places it on one sheet by the cell text rule: each cell's rectangle, where +//! its label goes and the box its value goes in. Values are set in their boxes once the paper scene +//! has text; nothing here is saved or drawn yet. +//! +//! The outline is drawn `outline_line_mm` wide and the lines between cells `cell_line_mm`, each +//! centred on its edge. The block's right and bottom edges lie on the drawing border, whose line is +//! centred there too. A cell's text is padded from the inner edge of its lines and of the border's +//! line, wherever that reaches, and its descenders never reach a line (the cell text rule), so no +//! line covers it. + +use serde::{Deserialize, Serialize}; + +use crate::cell::{self, Content, Fit, HAlign}; +use crate::drawing_sheet::SheetLayout; +use crate::paper::MAX_LINE_MM; +use crate::{Aabb2, Vec2}; + +/// The most cells a block may have. +pub const MAX_CELLS: usize = 64; +const MIN_BLOCK_SIDE_MM: f64 = 1.0; +const MAX_BLOCK_SIDE_MM: f64 = 1000.0; +const MAX_NAME_BYTES: usize = 64; + +/// A title block's definition. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TitleBlock { + /// The block's width and height. + pub size_mm: Vec2, + pub outline_line_mm: f64, + pub cell_line_mm: f64, + pub cells: Vec, +} + +/// One cell of a block. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Cell { + /// Snake case, and no other cell of the block has it. + pub name: String, + /// `[x0, y0, x1, y1]` from the block's lower-right corner: `-width <= x0 < x1 <= 0` and + /// `0 <= y0 < y1 <= height`. + pub rect_mm: [f64; 4], + /// None: the value has the whole cell. + pub label: Option