mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5741c5d205 | |||
| b68fa4f868 | |||
| 0c4d882ba9 | |||
| 9e6a6137d9 | |||
| 009fe45bdc | |||
| 3dc7804c28 | |||
| 509c4ac7f2 | |||
| ebafe07487 | |||
| 3575c99750 | |||
| de7bf4fc4d | |||
| f18616e19c | |||
| 657c733166 | |||
| 4c4f25115b | |||
| 672ef13eb8 | |||
| 4c63a88fc3 | |||
| 5e23f58f62 | |||
| 01a542d6e8 | |||
| f0cc73eb83 |
@@ -174,7 +174,8 @@ A change that saves something new in a design adds ONE file, `crates/doc/src/for
|
||||
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
|
||||
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
|
||||
works out the number (the next one while anything is pending), and the release folds pending
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md. Drawings work the same
|
||||
way with their own numbers: one file in `crates/doc/src/drawing/format/pending/`.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
|
||||
+3
-1
@@ -255,7 +255,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<N>/` and fixes that number for good (see
|
||||
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
|
||||
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` 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
|
||||
|
||||
|
||||
+43
-17
@@ -1,15 +1,40 @@
|
||||
//! Lists the design-format changes in `src/format/`: one file per change, in `v<N>/` 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<N>/` 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<N>/`).\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.
|
||||
|
||||
@@ -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> {
|
||||
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;
|
||||
|
||||
@@ -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<String>,
|
||||
pub name: String,
|
||||
/// The designs the drawing draws.
|
||||
pub sources: Vec<Source>,
|
||||
/// 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<Arc<Sheet>>,
|
||||
}
|
||||
|
||||
/// 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<DrawingBorderProfile>,
|
||||
/// 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<SheetComponent>,
|
||||
#[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<DrawingBorderProfile>,
|
||||
id_source: &mut IdSource,
|
||||
) -> Result<Drawing> {
|
||||
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<Drawing> {
|
||||
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<String> {
|
||||
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<u64> {
|
||||
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<Item = u64> {
|
||||
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;
|
||||
@@ -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<N>/` 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<N>/`, as it does the
|
||||
design format's (`crates/doc/src/format/pending/README.md`).
|
||||
@@ -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.solidcraft", "../parts/bolt.solidcraft", "bracket.solidcraft"] {
|
||||
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("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> {
|
||||
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.solidcraft"}, {"id": bolt_id, "path": "../parts/bolt.solidcraft"}, {"id": bracket_id, "path": "bracket.solidcraft"}])
|
||||
);
|
||||
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}.solidcraft")})).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.solidcraft".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.solidcraft".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::<usize>(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -469,7 +469,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()
|
||||
|
||||
+332
-73
@@ -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<N>/`.
|
||||
//!
|
||||
//! 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<N>/` 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<N>/` or `src/format/pending/`.
|
||||
/// One change to a family's file format: a file in its `v<N>/` 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<JsonStep>,
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<u32>,
|
||||
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<String> {
|
||||
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<u32> {
|
||||
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<Vec<String>>)> {
|
||||
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<String> {
|
||||
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])],
|
||||
@@ -285,23 +379,35 @@ 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. A pending change this build doesn't have is a
|
||||
/// newer build's, and the design is refused.
|
||||
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 pending change `applied` (its `format_pending`) names that this build doesn't have,
|
||||
/// which only a newer build's file of the pending number can. A list beside a format that
|
||||
/// isn't pending 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 pending = version == current && !self.pending.is_empty();
|
||||
match applied.unwrap_or_default().iter().find(|name| pending && !self.pending.iter().any(|p| p.name == name.as_str())) {
|
||||
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: the steps below need a format from 1 to the current one.
|
||||
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.
|
||||
if let (Some(applied), Some(parts)) = (applied, self.parts(version)) {
|
||||
let pending = version == self.current() && !self.pending.is_empty();
|
||||
if let Some(unknown) = applied.iter().find(|name| pending && !parts.iter().any(|p| p.name == name.as_str())) {
|
||||
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"
|
||||
)));
|
||||
}
|
||||
for part in parts.iter().filter(|p| !applied.iter().any(|name| name == p.name)) {
|
||||
part.step.map_or(Ok(()), |step| step(v))?;
|
||||
}
|
||||
@@ -320,25 +426,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<Option<Vec<String>>> {
|
||||
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<Option<Vec<String>>> {
|
||||
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::<Result<_>>().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<u32> {
|
||||
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`.
|
||||
@@ -548,24 +655,8 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
|
||||
/// Read a design file's JSON, upgrading older formats.
|
||||
pub fn read(s: &str) -> Result<Document> {
|
||||
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"
|
||||
)));
|
||||
}
|
||||
check_design_numbers(&v)?;
|
||||
let (version, applied) = DESIGN.take_version(&mut v)?;
|
||||
DESIGN.check_numbers(&v, "metadata")?;
|
||||
crate::config::validate_rules_json(&v)?;
|
||||
crate::config::validate_styles_json(&v)?;
|
||||
crate::face_anchor::validate_face_anchors_json(&v)?;
|
||||
@@ -576,7 +667,7 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
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}")))?;
|
||||
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
|
||||
for (feature, wire) in legacy_wires {
|
||||
@@ -1036,31 +1127,21 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
|
||||
/// outside what the kernel's tolerances serve.
|
||||
const MAX_REAL: f64 = 1e9;
|
||||
|
||||
/// Every number the design itself holds is in range ([`check_numbers`]). `metadata` is left
|
||||
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
|
||||
/// already bounds its nesting.
|
||||
fn check_design_numbers(v: &Value) -> Result<()> {
|
||||
match v {
|
||||
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != "metadata").try_for_each(|(_, x)| check_numbers(x, 1)),
|
||||
v => check_numbers(v, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every non-integer number in the file 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 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(())
|
||||
@@ -1167,25 +1248,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"]);
|
||||
}
|
||||
|
||||
@@ -1200,16 +1281,194 @@ 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());
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
let mut names: Vec<String> = 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::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Vec<String>> {
|
||||
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::<String>::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]
|
||||
|
||||
@@ -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<u64>) -> Result<u64> {
|
||||
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<u64> {
|
||||
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<S: Serializer>(id: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&encode(*id))
|
||||
}
|
||||
|
||||
/// See [`serialize`].
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<u64, D::Error> {
|
||||
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<u64> {
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(run(7), run(7));
|
||||
assert_ne!(run(7), run(8));
|
||||
assert_eq!(run(7).into_iter().collect::<BTreeSet<_>>().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<u64> = (0..101).map(|_| seven.draw()).collect();
|
||||
let in_use: BTreeSet<u64> = 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());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,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;
|
||||
@@ -28,6 +29,7 @@ pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
|
||||
pub mod format;
|
||||
mod format_offsets;
|
||||
mod history_names;
|
||||
pub mod ids;
|
||||
pub use format_offsets::whole_profile_selection_from_value;
|
||||
pub mod joints;
|
||||
mod lineage;
|
||||
@@ -53,8 +55,8 @@ pub use project3d::iso_grid;
|
||||
pub mod sheet;
|
||||
|
||||
pub use assembly::{
|
||||
IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul,
|
||||
rigid,
|
||||
BomType, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse,
|
||||
mat_mul, rigid,
|
||||
};
|
||||
pub use document::*;
|
||||
pub use eval::KeptResults;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! SolidCraft reads a few well-known, unprefixed text keys from it (project, part number,
|
||||
//! revision, author, organization, description, licence) and 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.
|
||||
//! key each (`com.example.tool`), holding an object. A component's part properties follow the
|
||||
//! same rules, with 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
|
||||
@@ -17,7 +18,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";
|
||||
@@ -31,6 +32,11 @@ pub const LICENSE: &str = "license";
|
||||
/// unprefixed key is reserved for SolidCraft too.
|
||||
pub const WELL_KNOWN: [&str; 7] = [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE];
|
||||
|
||||
/// A component's [`BomType`] in the bill of materials (one of [`BomType::as_str`]'s words). It is
|
||||
/// a part property of a component, not of the design, so the Design Properties dialog (which
|
||||
/// shows [`WELL_KNOWN`]) doesn't list 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.
|
||||
@@ -48,9 +54,9 @@ pub const MAX_TOTAL_BYTES: usize = 256 * 1024;
|
||||
#[serde(transparent)]
|
||||
pub struct Metadata(pub Map<String, Value>);
|
||||
|
||||
/// A key SolidCraft reads as a design property.
|
||||
/// A key SolidCraft reads as a design or part property.
|
||||
pub fn is_well_known(key: &str) -> bool {
|
||||
WELL_KNOWN.contains(&key)
|
||||
WELL_KNOWN.contains(&key) || key == BOM_TYPE
|
||||
}
|
||||
|
||||
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
|
||||
@@ -122,6 +128,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.
|
||||
@@ -170,6 +181,11 @@ fn check(key: &str, value: Value) -> Result<Option<Value>> {
|
||||
if s.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
|
||||
return Err(invalid(format!("`{key}` has a control character")));
|
||||
}
|
||||
if key == BOM_TYPE && 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(Value::String(s)))
|
||||
}
|
||||
_ => Err(invalid(format!("`{key}` takes text"))),
|
||||
@@ -243,6 +259,29 @@ mod tests {
|
||||
assert_eq!(m.0.keys().collect::<Vec<_>>(), ["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(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(BOM_TYPE, bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(m.bom_type(), BomType::Inseparable, "a refused value leaves the old one");
|
||||
assert!(m.set(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!(!WELL_KNOWN.contains(&BOM_TYPE), "the Design Properties dialog doesn't list it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
|
||||
let mut m = Metadata::default();
|
||||
|
||||
+3
-18
@@ -114,21 +114,6 @@ fn chorded_region(region: &Region2) -> Result<Region2> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Region2> {
|
||||
if region.holes.len() > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
@@ -4017,7 +4002,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() };
|
||||
@@ -4378,7 +4363,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()
|
||||
@@ -5022,7 +5007,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
|
||||
|
||||
@@ -4,11 +4,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::{DESCRIPTION, MAX_KEYS, MAX_TEXT_BYTES, PART_NUMBER, REVISION, WELL_KNOWN};
|
||||
use solidcraft_doc::BomType;
|
||||
use solidcraft_doc::metadata::{BOM_TYPE, DESCRIPTION, MAX_KEYS, MAX_TEXT_BYTES, PART_NUMBER, REVISION, WELL_KNOWN};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
@@ -17,13 +23,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 project, part_number, revision, author, organization, description, \
|
||||
license, 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 project, part_number, revision, author, organization, description, license, 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 \
|
||||
@@ -31,8 +38,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)",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -66,7 +75,7 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
|
||||
let m = &c.metadata;
|
||||
Ok(json!({"component": id, "name": c.name, "part_number": m.part_number(), "description": m.description(),
|
||||
"revision": m.revision(), "metadata": m, "properties": c.properties}))
|
||||
"revision": m.revision(), "bom_type": m.bom_type().as_str(), "metadata": m, "properties": c.properties}))
|
||||
}
|
||||
|
||||
/// A free property as it may be saved (`None`: remove it), or why not.
|
||||
@@ -107,7 +116,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 [PART_NUMBER, DESCRIPTION, REVISION] {
|
||||
for k in [PART_NUMBER, DESCRIPTION, REVISION, BOM_TYPE] {
|
||||
if let Some(v) = p.get(k) {
|
||||
entries.push((k.to_string(), v.clone()));
|
||||
}
|
||||
@@ -119,7 +128,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 part_number, description, revision, values or properties ({})", WELL_KNOWN.join(", "))));
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("give part_number, description, revision, bom_type, values or properties ({}, {BOM_TYPE})", WELL_KNOWN.join(", ")),
|
||||
));
|
||||
}
|
||||
let mut changed: Vec<String> = Vec::new();
|
||||
for (k, v) in entries {
|
||||
@@ -172,6 +184,7 @@ struct Row {
|
||||
part_number: String,
|
||||
description: String,
|
||||
revision: String,
|
||||
bom_type: BomType,
|
||||
count: u64,
|
||||
components: Vec<u64>,
|
||||
names: Vec<String>,
|
||||
@@ -221,6 +234,8 @@ struct Index {
|
||||
owners: HashSet<u64>,
|
||||
/// Components with a part number: with `group_children`, one row however much is inside.
|
||||
numbered: HashSet<u64>,
|
||||
/// Components whose bill of materials type isn't normal.
|
||||
types: BTreeMap<u64, BomType>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
@@ -230,8 +245,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<Group> {
|
||||
@@ -242,63 +271,88 @@ fn group_key(g: &str, cmd: &str) -> Result<Group> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<BTreeMap<u64, u64>> {
|
||||
let mut out: BTreeMap<u64, u64> = 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<u64, u64>) -> 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<u64, u64>) -> String {
|
||||
let mut parts: Vec<String> = 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<u64, u64>, group: Group, kits: &BTreeMap<u64, BTreeMap<u64, u64>>) -> Vec<Row> {
|
||||
let mut grouped: BTreeMap<(String, String, u64), Row> = BTreeMap::new();
|
||||
fn rows(s: &Session, ix: &Index, counts: &BTreeMap<u64, u64>, group: Group, kits: &BTreeMap<u64, BTreeMap<u64, u64>>) -> Vec<Row> {
|
||||
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(),
|
||||
@@ -357,10 +411,11 @@ fn bom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<u64, BTreeMap<u64, u64>> = 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);
|
||||
@@ -368,22 +423,24 @@ fn bom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let mut total: u64 = 0;
|
||||
let rows: Vec<Value> = rows(s, &counts, o.group, &kits)
|
||||
let rows: Vec<Value> = 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<Value> = self::rows(s, &r.kit, Group::PartNumber, &BTreeMap::new())
|
||||
let children: Vec<Value> = 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);
|
||||
|
||||
@@ -275,3 +275,227 @@ fn body_owners_match_each_bodys_component() {
|
||||
assert!(each.len() > 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! agents all go through these commands.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::metadata::{MAX_KEYS, WELL_KNOWN};
|
||||
use solidcraft_doc::metadata::{BOM_TYPE, MAX_KEYS, WELL_KNOWN};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
@@ -48,6 +48,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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(key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
changed.push(key.to_string());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
pub decimals: Option<u8>,
|
||||
}
|
||||
|
||||
/// What a cell shows: its parts in order.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Content(pub Vec<Part>);
|
||||
|
||||
/// 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<u32>,
|
||||
/// 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<u32>,
|
||||
/// 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<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
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<Item = &'a str>) -> 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<Item = &str> {
|
||||
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<Warning> {
|
||||
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<f64>) -> 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<Warning>)> {
|
||||
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;
|
||||
@@ -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::<Content>(value).unwrap(), content);
|
||||
// Without a format, the caller's settings.
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<Content>(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::<HAlign>(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::<Projection>(json!(name)).unwrap(), projection);
|
||||
}
|
||||
for bad in ["center", "Left", "middle", ""] {
|
||||
assert!(serde_json::from_value::<HAlign>(json!(bad)).is_err(), "{bad}");
|
||||
}
|
||||
for bad in ["First", "first_angle", "second", ""] {
|
||||
assert!(serde_json::from_value::<Projection>(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::<Fit>(value).unwrap(), fit);
|
||||
assert_eq!(serde_json::from_value::<Fit>(json!({})).unwrap(), Fit::default());
|
||||
assert!(serde_json::from_value::<Fit>(json!({"padding_mm": 1.0})).is_err());
|
||||
assert!(serde_json::from_value::<Fit>(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");
|
||||
}
|
||||
@@ -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<f64>,
|
||||
/// 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<Field>,
|
||||
/// Top to bottom, lettered from A.
|
||||
pub rows: Vec<Field>,
|
||||
/// 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<Aabb2>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("drawing sheet: {0}")]
|
||||
pub struct Invalid(pub &'static str);
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
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<Vec2> {
|
||||
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<SheetLayout> {
|
||||
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<Vec<[Vec2; 2]>> {
|
||||
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<Vec<Aabb2>> {
|
||||
// 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<Vec<f64>> {
|
||||
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<f64> {
|
||||
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<Field> {
|
||||
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;
|
||||
@@ -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<f64> {
|
||||
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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Sheet>(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::<DrawingBorderProfile>(without).unwrap(), profile);
|
||||
let mut unknown = value.clone();
|
||||
unknown["centring_mark_start"] = serde_json::json!("middle");
|
||||
assert!(serde_json::from_value::<DrawingBorderProfile>(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::<DrawingBorderProfile>(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::<DrawingBorderProfile>(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::<DrawingBorderProfile>(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::<Sheet>(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::<DrawingBorderProfile>(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<Sheet> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
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<Plane> {
|
||||
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<Scale> {
|
||||
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<Scale> {
|
||||
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<f64> {
|
||||
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<Scale> {
|
||||
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<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Scale {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Scale, D::Error> {
|
||||
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<ViewFrame> {
|
||||
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<Aabb2> {
|
||||
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;
|
||||
@@ -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<Vec3> {
|
||||
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<Vec2> = 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::<NamedView>(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::<Scale>(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::<Scale>().is_err(), "{text:?}");
|
||||
}
|
||||
assert!(serde_json::from_value::<Scale>(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::<Scale>().is_err() && "0.0000009:0.0000009".parse::<Scale>().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::<Scale>().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());
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Plain hatch patterns in paper millimetres at scale 1, with angles in radians.
|
||||
//!
|
||||
//! A family's origin is in the pattern's XY frame. With tangent `u` at its angle and
|
||||
//! left normal `n`, member `k` passes through `origin + k * (delta_x * u + delta_y * n)`.
|
||||
//! The signed deltas preserve both the spacing and the dash phase between members.
|
||||
//! Pattern lengths do not inherit a drawing view's scale.
|
||||
//!
|
||||
//! Empty dashes mean a continuous line. Otherwise positive lengths draw, negative
|
||||
//! lengths leave gaps, and zero places a dot without advancing. The sequence repeats
|
||||
//! after the sum of its absolute lengths. A negative-only sequence is valid and draws
|
||||
//! nothing; an all-zero sequence is invalid because it cannot advance.
|
||||
//!
|
||||
//! Validate after deserialization and before use: these records are mutable, and serde
|
||||
//! does not call the validators. The limits bound the stored values, not numerical
|
||||
//! accuracy or the number of marks a region needs. Clipping, its work/output budgets,
|
||||
//! drawing records and paper-scene construction are separate from this model. Readers
|
||||
//! must also bound encoded bytes and allocations before constructing the records.
|
||||
|
||||
mod budget;
|
||||
mod clip;
|
||||
mod generate;
|
||||
mod phase;
|
||||
|
||||
use std::f64::consts::FRAC_PI_4;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Vec2;
|
||||
|
||||
pub use generate::{Mark, generate};
|
||||
|
||||
/// The most parallel-line families in one pattern.
|
||||
pub const MAX_FAMILIES: usize = 64;
|
||||
/// The most entries in one family's repeating dash sequence.
|
||||
pub const MAX_DASHES: usize = 6;
|
||||
/// The longest pattern name, in ASCII bytes.
|
||||
pub const MAX_NAME_BYTES: usize = 31;
|
||||
/// The longest description, in Unicode scalar values.
|
||||
pub const MAX_DESCRIPTION_CHARS: usize = 80;
|
||||
/// The largest stored coordinate, delta or dash magnitude, in paper millimetres.
|
||||
pub const MAX_LENGTH_MM: f64 = 1e5;
|
||||
/// The largest stored angle magnitude, in radians.
|
||||
pub const MAX_ANGLE_RAD: f64 = 1e6;
|
||||
|
||||
/// A named pattern whose definition can travel with a drawing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Pattern {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub families: Vec<Family>,
|
||||
}
|
||||
|
||||
/// One family of parallel lines and its repeating dash sequence.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Family {
|
||||
/// Counterclockwise direction of each line, in radians.
|
||||
pub angle: f64,
|
||||
/// A point on member zero, in the global pattern XY frame, in paper millimetres.
|
||||
pub origin: Vec2,
|
||||
/// Signed shift along the line from one member to the next, in paper millimetres.
|
||||
pub delta_x: f64,
|
||||
/// Signed perpendicular spacing from one member to the next, in paper millimetres.
|
||||
pub delta_y: f64,
|
||||
/// Signed draw/gap lengths and zero dots; empty means continuous.
|
||||
pub dashes: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("hatch pattern: {0}")]
|
||||
pub struct Invalid(pub &'static str);
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
fn require(condition: bool, message: &'static str) -> Result<()> {
|
||||
if condition { Ok(()) } else { Err(Invalid(message)) }
|
||||
}
|
||||
|
||||
fn length(value: f64) -> bool {
|
||||
value.is_finite() && value.abs() <= MAX_LENGTH_MM
|
||||
}
|
||||
|
||||
impl Family {
|
||||
/// Check stored values without normalizing angles, changing signs or discarding dots.
|
||||
/// Nonzero spacing has no lower cutoff here; a generator must bound its resulting work.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require(self.dashes.len() <= MAX_DASHES, "more than 6 dash entries")?;
|
||||
require(self.angle.is_finite() && self.angle.abs() <= MAX_ANGLE_RAD, "angle must be finite and within +/-1000000 radians")?;
|
||||
require(length(self.origin.x) && length(self.origin.y), "origin must be finite and within +/-100000 mm")?;
|
||||
require(length(self.delta_x) && length(self.delta_y), "deltas must be finite and within +/-100000 mm")?;
|
||||
require(self.delta_y != 0.0, "perpendicular spacing must be nonzero")?;
|
||||
require(self.dashes.iter().copied().all(length), "dash lengths must be finite and within +/-100000 mm")?;
|
||||
if !self.dashes.is_empty() {
|
||||
let period: f64 = self.dashes.iter().map(|d| d.abs()).sum();
|
||||
require(period.is_finite() && period > 0.0, "a nonempty dash sequence must have a positive finite period")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn continuous(angle: f64) -> Self {
|
||||
Self { angle, origin: Vec2::ZERO, delta_x: 0.0, delta_y: 1.0, dashes: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
/// Check metadata and every family. A solid fill is a separate paint, not an empty pattern.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require(!self.families.is_empty() && self.families.len() <= MAX_FAMILIES, "a pattern needs 1 to 64 families")?;
|
||||
require(!self.name.is_empty() && self.name.len() <= MAX_NAME_BYTES, "name must have 1 to 31 ASCII characters")?;
|
||||
let valid_name = self.name.as_bytes().split_first().is_some_and(|(first, rest)| {
|
||||
first.is_ascii_alphanumeric() && rest.iter().all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'-' | b'$'))
|
||||
});
|
||||
require(valid_name, "name must start with an ASCII letter or digit and contain only ASCII letters, digits, _, - or $")?;
|
||||
// A Unicode scalar takes at most four UTF-8 bytes. Bound traversal before counting it.
|
||||
require(self.description.len() <= MAX_DESCRIPTION_CHARS * 4, "description exceeds 80 characters")?;
|
||||
require(self.description.chars().count() <= MAX_DESCRIPTION_CHARS, "description exceeds 80 characters")?;
|
||||
require(!self.description.chars().any(char::is_control), "description contains a control character")?;
|
||||
self.families.iter().try_for_each(Family::validate)
|
||||
}
|
||||
|
||||
/// Original continuous 45-degree lines spaced 1 mm apart in the pattern's paper frame.
|
||||
pub fn lines_45() -> Self {
|
||||
Self {
|
||||
name: "LINES_45".into(),
|
||||
description: "Parallel lines at 45 degrees, 1 mm apart".into(),
|
||||
families: vec![Family::continuous(FRAC_PI_4)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Original crossed 45- and 135-degree lines, each family spaced 1 mm apart.
|
||||
pub fn cross_hatch() -> Self {
|
||||
Self {
|
||||
name: "CROSS_HATCH".into(),
|
||||
description: "Crossed lines at 45 and 135 degrees, 1 mm apart".into(),
|
||||
families: vec![Family::continuous(FRAC_PI_4), Family::continuous(3.0 * FRAC_PI_4)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clip_tests;
|
||||
#[cfg(test)]
|
||||
mod generate_tests;
|
||||
#[cfg(test)]
|
||||
mod phase_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "hatch_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,76 @@
|
||||
//! One cumulative budget for preparation, clipping and pattern expansion.
|
||||
|
||||
use super::Invalid;
|
||||
|
||||
/// Internal limits can tighten, but never enlarge, the production ceilings.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Limits {
|
||||
pub input_pieces: usize,
|
||||
pub candidate_lines: usize,
|
||||
pub work: usize,
|
||||
pub events: usize,
|
||||
pub dash_steps: usize,
|
||||
pub marks: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { input_pieces: 8192, candidate_lines: 10000, work: 2000000, events: 65536, dash_steps: 200000, marks: 40000 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Budget {
|
||||
limits: Limits,
|
||||
used: Limits,
|
||||
}
|
||||
|
||||
impl Default for Budget {
|
||||
fn default() -> Self {
|
||||
Self::new(Limits::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
pub fn new(limits: Limits) -> Self {
|
||||
Self { limits, used: Limits { input_pieces: 0, candidate_lines: 0, work: 0, events: 0, dash_steps: 0, marks: 0 } }
|
||||
}
|
||||
|
||||
pub fn input_pieces(&mut self, n: usize) -> Result<(), Invalid> {
|
||||
charge(&mut self.used.input_pieces, n, self.limits.input_pieces.min(Limits::default().input_pieces), "input piece budget exhausted")
|
||||
}
|
||||
|
||||
pub fn candidate_lines(&mut self, n: usize) -> Result<(), Invalid> {
|
||||
charge(
|
||||
&mut self.used.candidate_lines,
|
||||
n,
|
||||
self.limits.candidate_lines.min(Limits::default().candidate_lines),
|
||||
"candidate line budget exhausted",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn work(&mut self, n: usize) -> Result<(), Invalid> {
|
||||
charge(&mut self.used.work, n, self.limits.work.min(Limits::default().work), "geometry work budget exhausted")
|
||||
}
|
||||
|
||||
pub fn events(&mut self, n: usize) -> Result<(), Invalid> {
|
||||
charge(&mut self.used.events, n, self.limits.events.min(Limits::default().events), "boundary event budget exhausted")
|
||||
}
|
||||
|
||||
pub fn dash_steps(&mut self, n: usize) -> Result<(), Invalid> {
|
||||
charge(&mut self.used.dash_steps, n, self.limits.dash_steps.min(Limits::default().dash_steps), "dash step budget exhausted")
|
||||
}
|
||||
|
||||
pub fn marks(&mut self, n: usize) -> Result<(), Invalid> {
|
||||
charge(&mut self.used.marks, n, self.limits.marks.min(Limits::default().marks), "output mark budget exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
fn charge(used: &mut usize, n: usize, limit: usize, message: &'static str) -> Result<(), Invalid> {
|
||||
let next = used.checked_add(n).ok_or(Invalid(message))?;
|
||||
if next > limit {
|
||||
return Err(Invalid(message));
|
||||
}
|
||||
*used = next;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
//! Analytic polygon and canonical-circle clipping. Uncertain predicates refuse
|
||||
//! the whole operation.
|
||||
//!
|
||||
//! Preparation proves simple, exactly closed loops and disjoint interior holes.
|
||||
//! Consequently even-odd crossings equal outer minus the union of holes. Boundary
|
||||
//! intervals are removed separately; isolated contacts split, never join, spans.
|
||||
|
||||
use crate::{Region2, Seg2, Vec2};
|
||||
|
||||
use super::budget::{Budget, Limits};
|
||||
use super::phase::Param;
|
||||
use super::{Invalid, MAX_LENGTH_MM};
|
||||
|
||||
mod circle;
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
const UNCERTAIN: Invalid = Invalid("unresolved clipping arithmetic");
|
||||
|
||||
/// A closed enclosure of a real arithmetic result on represented f64 inputs.
|
||||
/// This is deliberately a filter, not an arbitrary-precision fallback.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Bound {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
}
|
||||
|
||||
impl Bound {
|
||||
fn point(x: f64) -> Result<Self> {
|
||||
Self::new(x, x)
|
||||
}
|
||||
|
||||
fn new(lo: f64, hi: f64) -> Result<Self> {
|
||||
if lo.is_finite() && hi.is_finite() && lo <= hi { Ok(Self { lo, hi }) } else { Err(UNCERTAIN) }
|
||||
}
|
||||
|
||||
fn parameter(value: Param) -> Result<Self> {
|
||||
let bound = Self::new(value.lo(), value.hi())?;
|
||||
let nearest = value.nearest();
|
||||
if nearest.is_finite() && nearest >= bound.lo && nearest <= bound.hi { Ok(bound) } else { Err(UNCERTAIN) }
|
||||
}
|
||||
|
||||
fn add(self, other: Self) -> Result<Self> {
|
||||
let low = sum(self.lo, other.lo)?;
|
||||
let high = sum(self.hi, other.hi)?;
|
||||
Self::new(low.lo, high.hi)
|
||||
}
|
||||
|
||||
fn neg(self) -> Self {
|
||||
Self { lo: -self.hi, hi: -self.lo }
|
||||
}
|
||||
|
||||
fn sub(self, other: Self) -> Result<Self> {
|
||||
self.add(other.neg())
|
||||
}
|
||||
|
||||
fn mul(self, other: Self) -> Result<Self> {
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
for (a, b) in [(self.lo, other.lo), (self.lo, other.hi), (self.hi, other.lo), (self.hi, other.hi)] {
|
||||
let v = product(a, b)?;
|
||||
lo = lo.min(v.lo);
|
||||
hi = hi.max(v.hi);
|
||||
}
|
||||
Self::new(lo, hi)
|
||||
}
|
||||
|
||||
fn div(self, other: Self) -> Result<Self> {
|
||||
if other.lo <= 0.0 && other.hi >= 0.0 {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
for (a, b) in [(self.lo, other.lo), (self.lo, other.hi), (self.hi, other.lo), (self.hi, other.hi)] {
|
||||
let q = a / b;
|
||||
let v = if a == 0.0 || b.abs() == 1.0 || a == b || a == -b { Self::point(q)? } else { Self::new(q.next_down(), q.next_up())? };
|
||||
lo = lo.min(v.lo);
|
||||
hi = hi.max(v.hi);
|
||||
}
|
||||
Self::new(lo, hi)
|
||||
}
|
||||
|
||||
fn sign(self) -> Result<i8> {
|
||||
if self.lo > 0.0 {
|
||||
Ok(1)
|
||||
} else if self.hi < 0.0 {
|
||||
Ok(-1)
|
||||
} else if self.lo == 0.0 && self.hi == 0.0 {
|
||||
Ok(0)
|
||||
} else {
|
||||
Err(UNCERTAIN)
|
||||
}
|
||||
}
|
||||
|
||||
fn middle(self) -> Result<f64> {
|
||||
let value = self.lo + (self.hi - self.lo) * 0.5;
|
||||
if value.is_finite() && value >= self.lo && value <= self.hi { Ok(value) } else { Err(UNCERTAIN) }
|
||||
}
|
||||
}
|
||||
|
||||
// Error-free addition (with gradual underflow). Reject overflow before using
|
||||
// the residual. Exact operations retain exact zero, rather than an EPS band.
|
||||
fn sum(a: f64, b: f64) -> Result<Bound> {
|
||||
let value = a + b;
|
||||
if !value.is_finite() {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
let part = value - a;
|
||||
let error = (a - (value - part)) + (b - part);
|
||||
if !error.is_finite() {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
Bound::new(if error < 0.0 { value.next_down() } else { value }, if error > 0.0 { value.next_up() } else { value })
|
||||
}
|
||||
|
||||
fn product(a: f64, b: f64) -> Result<Bound> {
|
||||
let value = a * b;
|
||||
if !value.is_finite() {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
if a == 0.0 || b == 0.0 || a.abs() == 1.0 || b.abs() == 1.0 {
|
||||
return Bound::point(value);
|
||||
}
|
||||
// Bound the exact product's quantum from BOTH operands, not the exponent
|
||||
// of its rounded result (which can carry across a power-of-two boundary).
|
||||
// If this quantum is smaller than the least subnormal, even a normal
|
||||
// product can have a nonzero FMA residual that rounds to zero.
|
||||
if quantum_exponent(a) + quantum_exponent(b) < -1074 {
|
||||
return Bound::new(value.next_down(), value.next_up());
|
||||
}
|
||||
// Here the exact product and the rounded value are both multiples of
|
||||
// 2^-1074 or larger. A nonzero residual therefore cannot round to zero.
|
||||
let error = a.mul_add(b, -value);
|
||||
if !error.is_finite() {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
Bound::new(if error < 0.0 { value.next_down() } else { value }, if error > 0.0 { value.next_up() } else { value })
|
||||
}
|
||||
|
||||
fn quantum_exponent(value: f64) -> i32 {
|
||||
let exponent = ((value.to_bits() >> 52) & 0x7ff) as i32;
|
||||
if exponent == 0 { -1074 } else { exponent - 1075 }
|
||||
}
|
||||
|
||||
fn difference(a: f64, b: f64) -> Result<Bound> {
|
||||
sum(a, -b)
|
||||
}
|
||||
|
||||
fn orient(a: Vec2, b: Vec2, p: Vec2) -> Result<i8> {
|
||||
let ax = difference(b.x, a.x)?;
|
||||
let ay = difference(b.y, a.y)?;
|
||||
let px = difference(p.x, a.x)?;
|
||||
let py = difference(p.y, a.y)?;
|
||||
ax.mul(py)?.sub(ay.mul(px)?)?.sign()
|
||||
}
|
||||
|
||||
fn in_box(p: Vec2, a: Vec2, b: Vec2) -> bool {
|
||||
p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y)
|
||||
}
|
||||
|
||||
fn intersect(a: Vec2, b: Vec2, c: Vec2, d: Vec2) -> Result<bool> {
|
||||
if a.x.max(b.x) < c.x.min(d.x) || c.x.max(d.x) < a.x.min(b.x) || a.y.max(b.y) < c.y.min(d.y) || c.y.max(d.y) < a.y.min(b.y) {
|
||||
return Ok(false);
|
||||
}
|
||||
let ab_c = orient(a, b, c)?;
|
||||
let ab_d = orient(a, b, d)?;
|
||||
let cd_a = orient(c, d, a)?;
|
||||
let cd_b = orient(c, d, b)?;
|
||||
Ok((ab_c == 0 && in_box(c, a, b))
|
||||
|| (ab_d == 0 && in_box(d, a, b))
|
||||
|| (cd_a == 0 && in_box(a, c, d))
|
||||
|| (cd_b == 0 && in_box(b, c, d))
|
||||
|| (ab_c * ab_d < 0 && cd_a * cd_b < 0))
|
||||
}
|
||||
|
||||
fn push<T>(items: &mut Vec<T>, item: T) -> Result<()> {
|
||||
items.try_reserve(1).map_err(|_| Invalid("clipping allocation failed"))?;
|
||||
items.push(item);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edge(points: &[Vec2], i: usize) -> Result<(Vec2, Vec2)> {
|
||||
let a = points.get(i).copied().ok_or(Invalid("invalid prepared edge"))?;
|
||||
let next = if i + 1 == points.len() { 0 } else { i + 1 };
|
||||
let b = points.get(next).copied().ok_or(Invalid("invalid prepared edge"))?;
|
||||
Ok((a, b))
|
||||
}
|
||||
|
||||
fn simple(points: &[Vec2], budget: &mut Budget) -> Result<()> {
|
||||
for i in 0..points.len() {
|
||||
let (a, b) = edge(points, i)?;
|
||||
for j in i + 1..points.len() {
|
||||
budget.work(1)?;
|
||||
let (c, d) = edge(points, j)?;
|
||||
if j == i + 1 || (i == 0 && j + 1 == points.len()) {
|
||||
let (p, q, r) = if j == i + 1 { (a, b, d) } else { (c, d, b) };
|
||||
if orient(p, q, r)? == 0 && !in_box(q, p, r) {
|
||||
return Err(Invalid("overlapping adjacent boundary edges"));
|
||||
}
|
||||
} else if intersect(a, b, c, d)? {
|
||||
return Err(Invalid("boundary is not a simple loop"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn separate(a: &[Vec2], b: &[Vec2], budget: &mut Budget) -> Result<()> {
|
||||
for i in 0..a.len() {
|
||||
let (p, q) = edge(a, i)?;
|
||||
for j in 0..b.len() {
|
||||
budget.work(1)?;
|
||||
let (r, s) = edge(b, j)?;
|
||||
if intersect(p, q, r, s)? {
|
||||
return Err(Invalid("region boundaries touch or intersect"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inside(points: &[Vec2], p: Vec2, budget: &mut Budget) -> Result<bool> {
|
||||
let mut parity = false;
|
||||
for i in 0..points.len() {
|
||||
budget.work(1)?;
|
||||
let (a, b) = edge(points, i)?;
|
||||
let crosses = (a.y > p.y) != (b.y > p.y);
|
||||
if crosses || in_box(p, a, b) {
|
||||
let side = orient(a, b, p)?;
|
||||
if side == 0 && in_box(p, a, b) {
|
||||
return Err(Invalid("region boundaries touch"));
|
||||
}
|
||||
if crosses && (side > 0) == (b.y > a.y) {
|
||||
parity = !parity;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(parity)
|
||||
}
|
||||
|
||||
/// Owned and immutable after complete, bounded topology validation.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct PreparedRegion {
|
||||
shape: PreparedShape,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PreparedShape {
|
||||
Polygon(Vec<Vec<Vec2>>),
|
||||
Circular(circle::CircularRegion),
|
||||
}
|
||||
|
||||
impl PreparedRegion {
|
||||
pub fn new(region: &Region2, budget: &mut Budget) -> Result<Self> {
|
||||
if matches!(region.outer.segs.first(), Some(Seg2::Arc { .. })) {
|
||||
return Ok(Self { shape: PreparedShape::Circular(circle::CircularRegion::new(region, budget)?) });
|
||||
}
|
||||
if region.holes.len() > Limits::default().input_pieces / 3 {
|
||||
return Err(Invalid("too many region holes"));
|
||||
}
|
||||
let mut loops = Vec::new();
|
||||
for boundary in region.loops() {
|
||||
let n = boundary.segs.len();
|
||||
budget.input_pieces(n)?;
|
||||
budget.work(n)?;
|
||||
if n < 3 {
|
||||
return Err(Invalid("a polygon loop needs at least three edges"));
|
||||
}
|
||||
let mut points = Vec::new();
|
||||
for (i, seg) in boundary.segs.iter().enumerate() {
|
||||
seg.check(MAX_LENGTH_MM).map_err(Invalid)?;
|
||||
let Seg2::Line { a, b } = *seg else { return Err(Invalid("not supported yet: non-Line hatch boundary")) };
|
||||
let next = boundary.segs.get(if i + 1 == n { 0 } else { i + 1 }).ok_or(Invalid("invalid boundary edge"))?;
|
||||
let Seg2::Line { a: next_start, .. } = *next else { return Err(Invalid("not supported yet: non-Line hatch boundary")) };
|
||||
if b != next_start {
|
||||
return Err(Invalid("hatch boundary must be exactly closed"));
|
||||
}
|
||||
push(&mut points, a)?;
|
||||
}
|
||||
simple(&points, budget)?;
|
||||
push(&mut loops, points)?;
|
||||
}
|
||||
let (outer, holes) = loops.split_first().ok_or(Invalid("missing outer boundary"))?;
|
||||
for (i, hole) in holes.iter().enumerate() {
|
||||
separate(outer, hole, budget)?;
|
||||
let p = hole.first().copied().ok_or(Invalid("empty hole"))?;
|
||||
if !inside(outer, p, budget)? {
|
||||
return Err(Invalid("hole is not strictly inside outer boundary"));
|
||||
}
|
||||
for other in holes.iter().skip(i + 1) {
|
||||
separate(hole, other, budget)?;
|
||||
let q = other.first().copied().ok_or(Invalid("empty hole"))?;
|
||||
if inside(hole, q, budget)? || inside(other, p, budget)? {
|
||||
return Err(Invalid("holes must not overlap or nest"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self { shape: PreparedShape::Polygon(loops) })
|
||||
}
|
||||
|
||||
/// Encloses the complete boundary's normal range, including extremal candidates.
|
||||
pub fn normal_bounds(&self, frame: &Frame, budget: &mut Budget) -> Result<(f64, f64)> {
|
||||
let loops = match &self.shape {
|
||||
PreparedShape::Polygon(loops) => loops,
|
||||
PreparedShape::Circular(region) => return region.normal_bounds(frame, budget),
|
||||
};
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
for points in loops {
|
||||
for &p in points {
|
||||
budget.work(1)?;
|
||||
let (_, h) = frame.project(p)?;
|
||||
lo = lo.min(h.lo);
|
||||
hi = hi.max(h.hi);
|
||||
}
|
||||
}
|
||||
let bounds = Bound::new(lo, hi)?;
|
||||
Ok((bounds.lo, bounds.hi))
|
||||
}
|
||||
|
||||
/// Exact scalar offsets retained for the existing clipping fixtures.
|
||||
#[cfg(test)]
|
||||
pub fn clip(&self, frame: &Frame, offset: f64, budget: &mut Budget) -> Result<ClippedLine> {
|
||||
self.clip_bound(frame, Bound::point(offset)?, budget)
|
||||
}
|
||||
|
||||
/// Classify the exact represented-input normal offset through its enclosure.
|
||||
/// nearest() is used only later, when constructing rounded output points.
|
||||
pub fn clip_parameter(&self, frame: &Frame, offset: Param, budget: &mut Budget) -> Result<ClippedLine> {
|
||||
budget.work(1)?;
|
||||
self.clip_bound(frame, Bound::parameter(offset)?, budget)
|
||||
}
|
||||
|
||||
fn clip_bound(&self, frame: &Frame, offset: Bound, budget: &mut Budget) -> Result<ClippedLine> {
|
||||
let loops = match &self.shape {
|
||||
PreparedShape::Polygon(loops) => loops,
|
||||
PreparedShape::Circular(region) => return region.clip(frame, offset, budget),
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
let mut base = 0usize;
|
||||
for points in loops {
|
||||
let mut projected = Vec::new();
|
||||
for (i, &p) in points.iter().enumerate() {
|
||||
budget.work(1)?;
|
||||
let (t, height) = frame.project(p)?;
|
||||
let h = height.sub(offset)?;
|
||||
let sign = h.sign()?;
|
||||
let key = base.checked_add(i).ok_or(UNCERTAIN)?;
|
||||
let vertex = Projected { t, h, sign, key };
|
||||
if sign == 0 {
|
||||
event(&mut events, Event { at: t, key: Key::Vertex(key), toggle: false, boundary: 0 }, budget)?;
|
||||
}
|
||||
push(&mut projected, vertex)?;
|
||||
}
|
||||
for (i, a) in projected.iter().copied().enumerate() {
|
||||
budget.work(1)?;
|
||||
let b = projected.get(if i + 1 == projected.len() { 0 } else { i + 1 }).copied().ok_or(UNCERTAIN)?;
|
||||
if a.sign == 0 && b.sign == 0 {
|
||||
let (first, last) = if a.t.hi < b.t.lo {
|
||||
(a, b)
|
||||
} else if b.t.hi < a.t.lo {
|
||||
(b, a)
|
||||
} else {
|
||||
return Err(UNCERTAIN);
|
||||
};
|
||||
event(&mut events, Event { at: first.t, key: Key::Vertex(first.key), toggle: false, boundary: 1 }, budget)?;
|
||||
event(&mut events, Event { at: last.t, key: Key::Vertex(last.key), toggle: false, boundary: -1 }, budget)?;
|
||||
} else if (a.sign > 0) != (b.sign > 0) {
|
||||
let (at, key) = if a.sign == 0 {
|
||||
(a.t, Key::Vertex(a.key))
|
||||
} else if b.sign == 0 {
|
||||
(b.t, Key::Vertex(b.key))
|
||||
} else {
|
||||
let fraction = a.h.neg().div(b.h.sub(a.h)?)?;
|
||||
(a.t.add(b.t.sub(a.t)?.mul(fraction)?)?, Key::Edge(a.key))
|
||||
};
|
||||
event(&mut events, Event { at, key, toggle: true, boundary: 0 }, budget)?;
|
||||
}
|
||||
}
|
||||
base = base.checked_add(points.len()).ok_or(UNCERTAIN)?;
|
||||
}
|
||||
sweep(events, budget)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Frame {
|
||||
origin: Vec2,
|
||||
tangent: Vec2,
|
||||
norm_squared: Bound,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(origin: Vec2, tangent: Vec2) -> Result<Self> {
|
||||
if !origin.is_finite() || origin.x.abs() > MAX_LENGTH_MM || origin.y.abs() > MAX_LENGTH_MM || !tangent.is_finite() {
|
||||
return Err(Invalid("invalid hatch frame"));
|
||||
}
|
||||
let scale = tangent.x.abs().max(tangent.y.abs());
|
||||
if scale == 0.0 {
|
||||
return Err(Invalid("invalid hatch tangent"));
|
||||
}
|
||||
// Scaling before hypot also handles subnormal and very large directions.
|
||||
// Refuse if normalization would erase a nonzero direction component.
|
||||
let scaled = tangent / scale;
|
||||
let normalized = scaled / scaled.len();
|
||||
if !normalized.is_finite() || (tangent.x != 0.0 && normalized.x == 0.0) || (tangent.y != 0.0 && normalized.y == 0.0) {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
let tangent = normalized;
|
||||
let norm_squared = product(tangent.x, tangent.x)?.add(product(tangent.y, tangent.y)?)?;
|
||||
if norm_squared.sign()? != 1 {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
Ok(Self { origin, tangent, norm_squared })
|
||||
}
|
||||
|
||||
pub fn tangent(&self) -> Vec2 {
|
||||
self.tangent
|
||||
}
|
||||
|
||||
pub fn normal(&self) -> Vec2 {
|
||||
self.tangent().perp()
|
||||
}
|
||||
|
||||
/// Rounded endpoint on the represented frame; never an accuracy certificate.
|
||||
pub fn point(&self, offset: f64, t: f64) -> Result<Vec2> {
|
||||
if !offset.is_finite() || !t.is_finite() {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
let p = self.origin + self.normal() * offset + self.tangent * t;
|
||||
if !p.is_finite() || p.x.abs() > MAX_LENGTH_MM || p.y.abs() > MAX_LENGTH_MM {
|
||||
return Err(Invalid("non-finite or excessive hatch output point"));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn project(&self, p: Vec2) -> Result<(Bound, Bound)> {
|
||||
let x = difference(p.x, self.origin.x)?;
|
||||
let y = difference(p.y, self.origin.y)?;
|
||||
let u = self.tangent;
|
||||
let t = x.mul(Bound::point(u.x)?)?.add(y.mul(Bound::point(u.y)?)?)?.div(self.norm_squared)?;
|
||||
let h = x.mul(Bound::point(-u.y)?)?.add(y.mul(Bound::point(u.x)?)?)?.div(self.norm_squared)?;
|
||||
Ok((t, h))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Span {
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ClippedLine {
|
||||
spans: Vec<Span>,
|
||||
ends: Vec<(Bound, Bound)>,
|
||||
}
|
||||
|
||||
impl ClippedLine {
|
||||
pub fn spans(&self) -> &[Span] {
|
||||
&self.spans
|
||||
}
|
||||
|
||||
// Each endpoint visit and the ordering classification consume work. The
|
||||
// query's entry charge covers checked access and bounded value validation.
|
||||
fn span_ends(&self, i: usize, budget: &mut Budget) -> Result<(Bound, Bound)> {
|
||||
let &(start, end) = self.ends.get(i).ok_or(Invalid("invalid clipped span index"))?;
|
||||
budget.work(1)?;
|
||||
let start = Bound::new(start.lo, start.hi)?;
|
||||
budget.work(1)?;
|
||||
let end = Bound::new(end.lo, end.hi)?;
|
||||
budget.work(1)?;
|
||||
if start.hi < end.lo { Ok((start, end)) } else { Err(UNCERTAIN) }
|
||||
}
|
||||
|
||||
/// Conservative cycle-search window from the retained contact enclosures.
|
||||
/// Rounded output endpoints must not determine which cycles are visited.
|
||||
pub fn span_window(&self, i: usize, budget: &mut Budget) -> Result<(f64, f64)> {
|
||||
budget.work(1)?;
|
||||
let (start, end) = self.span_ends(i, budget)?;
|
||||
Ok((start.lo, end.hi))
|
||||
}
|
||||
|
||||
/// Only a certified strict interior owns a dot. Distinct zero tokens still
|
||||
/// produce distinct marks; querying another span cannot own this token too.
|
||||
pub fn dot_in_span(&self, i: usize, t: Param, budget: &mut Budget) -> Result<Option<f64>> {
|
||||
budget.work(1)?;
|
||||
let t_bound = Bound::parameter(t)?;
|
||||
let (start, end) = self.span_ends(i, budget)?;
|
||||
budget.work(1)?;
|
||||
let after_start = t_bound.lo > start.hi;
|
||||
budget.work(1)?;
|
||||
let before_end = t_bound.hi < end.lo;
|
||||
if after_start && before_end {
|
||||
return Ok(Some(t.nearest()));
|
||||
}
|
||||
budget.work(1)?;
|
||||
let before_start = t_bound.hi <= start.lo;
|
||||
budget.work(1)?;
|
||||
let after_end = t_bound.lo >= end.hi;
|
||||
if before_start || after_end { Ok(None) } else { Err(UNCERTAIN) }
|
||||
}
|
||||
|
||||
/// Clip an exact positive dash using enclosure dominance, retaining the
|
||||
/// selected endpoints' bounds until positive length has been certified.
|
||||
pub fn dash_in_span(&self, i: usize, a: Param, b: Param, budget: &mut Budget) -> Result<Option<Span>> {
|
||||
budget.work(1)?;
|
||||
let a_bound = Bound::parameter(a)?;
|
||||
let b_bound = Bound::parameter(b)?;
|
||||
budget.work(1)?;
|
||||
if a_bound.hi >= b_bound.lo {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
let (start, end) = self.span_ends(i, budget)?;
|
||||
budget.work(1)?;
|
||||
if b_bound.hi <= start.lo {
|
||||
return Ok(None);
|
||||
}
|
||||
budget.work(1)?;
|
||||
if a_bound.lo >= end.hi {
|
||||
return Ok(None);
|
||||
}
|
||||
let span = self.spans.get(i).ok_or(Invalid("invalid clipped span index"))?;
|
||||
// Keep the existing geometric output value beside its actual bound;
|
||||
// never promote that rounded value to a singleton certificate.
|
||||
budget.work(1)?;
|
||||
let (left, output_start) = if a_bound.hi <= start.lo {
|
||||
(start, span.start)
|
||||
} else {
|
||||
budget.work(1)?;
|
||||
if a_bound.lo >= start.hi { (a_bound, a.nearest()) } else { return Err(UNCERTAIN) }
|
||||
};
|
||||
budget.work(1)?;
|
||||
let (right, output_end) = if end.hi <= b_bound.lo {
|
||||
(end, span.end)
|
||||
} else {
|
||||
budget.work(1)?;
|
||||
if b_bound.hi <= end.lo { (b_bound, b.nearest()) } else { return Err(UNCERTAIN) }
|
||||
};
|
||||
budget.work(1)?;
|
||||
if left.hi >= right.lo {
|
||||
budget.work(1)?;
|
||||
if left.lo == left.hi && right.lo == right.hi && left.lo == right.lo {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
budget.work(1)?;
|
||||
if !output_start.is_finite() || !output_end.is_finite() || output_start >= output_end {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
Ok(Some(Span { start: output_start, end: output_end }))
|
||||
}
|
||||
|
||||
/// Boundary contacts are excluded, including a zero-parity reflex vertex.
|
||||
/// A parameter inside an uncertain contact enclosure is refused, not guessed.
|
||||
#[cfg(test)]
|
||||
pub fn contains_strict(&self, t: f64, budget: &mut Budget) -> Result<bool> {
|
||||
// A query costs work even when there are no spans or its input is invalid.
|
||||
budget.work(1)?;
|
||||
if !t.is_finite() {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
for &(start, end) in &self.ends {
|
||||
budget.work(1)?;
|
||||
if t > start.hi && t < end.lo {
|
||||
return Ok(true);
|
||||
}
|
||||
for contact in [start, end] {
|
||||
if t >= contact.lo && t <= contact.hi {
|
||||
if contact.lo == contact.hi {
|
||||
return Ok(false);
|
||||
}
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Projected {
|
||||
t: Bound,
|
||||
h: Bound,
|
||||
sign: i8,
|
||||
key: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Key {
|
||||
Vertex(usize),
|
||||
Edge(usize),
|
||||
Circle(usize, u8),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Event {
|
||||
at: Bound,
|
||||
key: Key,
|
||||
toggle: bool,
|
||||
boundary: i32,
|
||||
}
|
||||
|
||||
fn event(events: &mut Vec<Event>, e: Event, budget: &mut Budget) -> Result<()> {
|
||||
budget.events(1)?;
|
||||
budget.work(1)?;
|
||||
push(events, e)
|
||||
}
|
||||
|
||||
// Fallible insertion sort charges every comparison/move and can stop immediately
|
||||
// at the work limit. Ordinary polygon scanlines have few events; large cases may
|
||||
// honestly exhaust this budget rather than run an unaccounted sorting phase.
|
||||
fn sort(events: &mut [Event], budget: &mut Budget) -> Result<()> {
|
||||
for i in 1..events.len() {
|
||||
let current = events.get(i).copied().ok_or(UNCERTAIN)?;
|
||||
let mut j = i;
|
||||
while j > 0 {
|
||||
budget.work(1)?;
|
||||
let previous = events.get(j - 1).copied().ok_or(UNCERTAIN)?;
|
||||
if previous.at.lo.total_cmp(¤t.at.lo).then(previous.key.cmp(¤t.key)).is_le() {
|
||||
break;
|
||||
}
|
||||
*events.get_mut(j).ok_or(UNCERTAIN)? = previous;
|
||||
j -= 1;
|
||||
}
|
||||
*events.get_mut(j).ok_or(UNCERTAIN)? = current;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sweep(mut events: Vec<Event>, budget: &mut Budget) -> Result<ClippedLine> {
|
||||
sort(&mut events, budget)?;
|
||||
let mut spans = Vec::new();
|
||||
let mut ends = Vec::new();
|
||||
let mut events = events.into_iter().peekable();
|
||||
let mut previous: Option<Bound> = None;
|
||||
let mut parity = false;
|
||||
let mut boundary = 0i32;
|
||||
while let Some(first) = events.next() {
|
||||
budget.work(1)?;
|
||||
if let Some(start) = previous {
|
||||
if start.hi >= first.at.lo {
|
||||
return Err(Invalid("unresolved boundary contact ordering"));
|
||||
}
|
||||
if parity && boundary == 0 {
|
||||
push(&mut spans, Span { start: start.middle()?, end: first.at.middle()? })?;
|
||||
push(&mut ends, (start, first.at))?;
|
||||
}
|
||||
}
|
||||
parity ^= first.toggle;
|
||||
boundary = boundary.checked_add(first.boundary).ok_or(UNCERTAIN)?;
|
||||
while events.peek().is_some_and(|next| next.key == first.key) {
|
||||
budget.work(1)?;
|
||||
let next = events.next().ok_or(UNCERTAIN)?;
|
||||
if next.at != first.at {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
parity ^= next.toggle;
|
||||
boundary = boundary.checked_add(next.boundary).ok_or(UNCERTAIN)?;
|
||||
}
|
||||
if boundary < 0 {
|
||||
return Err(Invalid("inconsistent boundary intervals"));
|
||||
}
|
||||
previous = Some(first.at);
|
||||
}
|
||||
if parity || boundary != 0 {
|
||||
return Err(Invalid("inconsistent boundary crossings"));
|
||||
}
|
||||
Ok(ClippedLine { spans, ends })
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Complete circles recognized from the documented two-semicircle encoding.
|
||||
//! This is structural semantics, not a tolerance for closing arbitrary arc joins.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use crate::Loop2;
|
||||
|
||||
use super::{Bound, Budget, ClippedLine, Event, Frame, Invalid, Key, MAX_LENGTH_MM, Region2, Result, Seg2, UNCERTAIN, Vec2, event, product, sweep};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Circle {
|
||||
center: Vec2,
|
||||
radius: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct CircularRegion {
|
||||
outer: Circle,
|
||||
inner: Option<Circle>,
|
||||
}
|
||||
|
||||
impl CircularRegion {
|
||||
pub(super) fn new(region: &Region2, budget: &mut Budget) -> Result<Self> {
|
||||
budget.work(1)?;
|
||||
if region.holes.len() > 1 {
|
||||
return Err(Invalid("not supported yet: multiple circular hatch holes"));
|
||||
}
|
||||
// Charge the original Arc records, before reducing a boundary to one
|
||||
// circle. No unchecked traversal or allocation precedes these charges.
|
||||
for boundary in region.loops() {
|
||||
budget.input_pieces(boundary.segs.len())?;
|
||||
budget.work(boundary.segs.len())?;
|
||||
}
|
||||
let outer = Circle::new(®ion.outer, budget)?;
|
||||
let inner = region.holes.first().map(|boundary| Circle::new(boundary, budget)).transpose()?;
|
||||
if let Some(inner) = inner {
|
||||
budget.work(1)?;
|
||||
if inner.center != outer.center {
|
||||
return Err(Invalid("not supported yet: eccentric circular hatch hole"));
|
||||
}
|
||||
if inner.radius >= outer.radius {
|
||||
return Err(Invalid("circular hatch hole must be strictly inside outer circle"));
|
||||
}
|
||||
}
|
||||
Ok(Self { outer, inner })
|
||||
}
|
||||
|
||||
pub(super) fn normal_bounds(&self, frame: &Frame, budget: &mut Budget) -> Result<(f64, f64)> {
|
||||
budget.work(1)?;
|
||||
let (_, height) = frame.project(self.outer.center)?;
|
||||
budget.work(1)?;
|
||||
let radius = square_root(self.outer.scaled_radius_squared(frame)?, budget)?;
|
||||
budget.work(1)?;
|
||||
Ok((height.sub(radius)?.lo, height.add(radius)?.hi))
|
||||
}
|
||||
|
||||
pub(super) fn clip(&self, frame: &Frame, offset: Bound, budget: &mut Budget) -> Result<ClippedLine> {
|
||||
budget.work(1)?;
|
||||
let mut events = Vec::new();
|
||||
self.outer.events(frame, offset, 0, &mut events, budget)?;
|
||||
if let Some(inner) = self.inner {
|
||||
inner.events(frame, offset, 1, &mut events, budget)?;
|
||||
}
|
||||
sweep(events, budget)
|
||||
}
|
||||
}
|
||||
|
||||
impl Circle {
|
||||
fn new(boundary: &Loop2, budget: &mut Budget) -> Result<Self> {
|
||||
budget.work(1)?;
|
||||
let [first, second] = boundary.segs.as_slice() else {
|
||||
return Err(Invalid("not supported yet: noncanonical circular hatch boundary"));
|
||||
};
|
||||
// These two checks are covered by the original-piece work charge.
|
||||
first.check(MAX_LENGTH_MM).map_err(Invalid)?;
|
||||
second.check(MAX_LENGTH_MM).map_err(Invalid)?;
|
||||
let (Seg2::Arc { center, radius, start: a, sweep: sa }, Seg2::Arc { center: other_center, radius: other_radius, start: b, sweep: sb }) =
|
||||
(*first, *second)
|
||||
else {
|
||||
return Err(Invalid("not supported yet: mixed circular hatch boundary"));
|
||||
};
|
||||
let forward = sa == PI && sb == PI && ((a == 0.0 && b == PI) || (b == 0.0 && a == PI));
|
||||
let reversed = sa == -PI && sb == -PI && ((a == TAU && b == PI) || (b == TAU && a == PI));
|
||||
if center != other_center || radius != other_radius || !(forward || reversed) {
|
||||
return Err(Invalid("not supported yet: noncanonical circular hatch boundary"));
|
||||
}
|
||||
// Evaluated sin/cos endpoints do not certify a closed circle. Only the
|
||||
// exact signatures above mean a full circle here, including signed zero
|
||||
// equivalence, reversal and cyclic order. No other seam is repaired.
|
||||
let radius_bound = Bound::point(radius)?;
|
||||
for component in [center.x, center.y] {
|
||||
budget.work(1)?;
|
||||
let center_bound = Bound::point(component)?;
|
||||
if center_bound.sub(radius_bound)?.lo < -MAX_LENGTH_MM || center_bound.add(radius_bound)?.hi > MAX_LENGTH_MM {
|
||||
return Err(Invalid("circular hatch boundary exceeds coordinate limit"));
|
||||
}
|
||||
}
|
||||
Ok(Self { center, radius })
|
||||
}
|
||||
|
||||
fn scaled_radius_squared(self, frame: &Frame) -> Result<Bound> {
|
||||
// The represented u and u.perp are orthogonal but need not have unit
|
||||
// length. In this frame (t-tc)^2 + (h-hc)^2 = radius^2 / (u dot u).
|
||||
product(self.radius, self.radius)?.div(frame.norm_squared)
|
||||
}
|
||||
|
||||
fn events(self, frame: &Frame, offset: Bound, boundary: usize, events: &mut Vec<Event>, budget: &mut Budget) -> Result<()> {
|
||||
budget.work(1)?;
|
||||
let (center, height) = frame.project(self.center)?;
|
||||
budget.work(1)?;
|
||||
let distance = offset.sub(height)?;
|
||||
let discriminant = self.scaled_radius_squared(frame)?.sub(distance.mul(distance)?)?;
|
||||
// Charge even a miss or a tangent that paints nothing. A discriminant
|
||||
// straddling zero is unresolved, not a reason to clamp it to zero.
|
||||
budget.work(1)?;
|
||||
match discriminant.sign()? {
|
||||
-1 => Ok(()),
|
||||
0 => event(events, Event { at: center, key: Key::Circle(boundary, 0), toggle: false, boundary: 0 }, budget),
|
||||
1 => {
|
||||
let reach = square_root(discriminant, budget)?;
|
||||
budget.work(2)?;
|
||||
let left = center.sub(reach)?;
|
||||
let right = center.add(reach)?;
|
||||
// Keep distinct root identities and enclosures. The shared
|
||||
// sweep certifies their order and splits at tangent contacts.
|
||||
event(events, Event { at: left, key: Key::Circle(boundary, 0), toggle: true, boundary: 0 }, budget)?;
|
||||
event(events, Event { at: right, key: Key::Circle(boundary, 1), toggle: true, boundary: 0 }, budget)
|
||||
}
|
||||
_ => Err(UNCERTAIN),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn square_root(value: Bound, budget: &mut Budget) -> Result<Bound> {
|
||||
budget.work(1)?;
|
||||
if value.lo < 0.0 {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
Bound::new(root_endpoint(value.lo, false, budget)?, root_endpoint(value.hi, true, budget)?)
|
||||
}
|
||||
|
||||
fn root_endpoint(value: f64, upper: bool, budget: &mut Budget) -> Result<f64> {
|
||||
budget.work(1)?;
|
||||
let mut root = value.sqrt();
|
||||
// sqrt supplies only a starting candidate. Each returned endpoint proves
|
||||
// its square inequality using conservative products; four attempts bound
|
||||
// the cost, not an assumed accuracy guarantee for the platform's sqrt.
|
||||
for _ in 0..4 {
|
||||
budget.work(1)?;
|
||||
if !root.is_finite() || root < 0.0 {
|
||||
return Err(UNCERTAIN);
|
||||
}
|
||||
let squared = product(root, root)?;
|
||||
let certified = if upper { squared.lo >= value } else { squared.hi <= value };
|
||||
if certified {
|
||||
return Ok(root);
|
||||
}
|
||||
root = if upper { root.next_up() } else { root.next_down() };
|
||||
}
|
||||
Err(UNCERTAIN)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
//! Bounded hatch expansion over polygons and canonical circular regions.
|
||||
|
||||
use crate::{Region2, Vec2};
|
||||
|
||||
use super::budget::{Budget, Limits};
|
||||
use super::clip::{ClippedLine, Frame, PreparedRegion};
|
||||
use super::phase::{Dyadic, Param};
|
||||
use super::{Invalid, Pattern};
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
// Leave representable integer neighbours for conservative range padding and cycle ends.
|
||||
const MAX_INDEX: f64 = 4_503_599_627_370_496.0;
|
||||
const MAX_EXACT_INDEX: i128 = 1i128 << 52;
|
||||
|
||||
/// One generated mark in paper millimetres. Dots remain points, not degenerate lines.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Mark {
|
||||
Line { a: Vec2, b: Vec2 },
|
||||
Dot { at: Vec2 },
|
||||
}
|
||||
|
||||
/// Expand a validated pattern over a supported polygon, disk or concentric annulus.
|
||||
/// Circular regions use the canonical two-semicircle encoding, with at most one
|
||||
/// concentric circular hole; general arc boundaries remain unsupported.
|
||||
/// The result is complete or an error; inputs are never changed. Stored lengths already
|
||||
/// use paper millimetres, so this function applies no unit conversion or view scale.
|
||||
///
|
||||
/// Output follows family order, ascending signed line index and ascending dash cycles
|
||||
/// within each clipped span. A hole or boundary contact does not restart dash phase.
|
||||
/// Strokes retain their clipped endpoints; dots require strict interior membership.
|
||||
/// Every explicit zero retains its own dot, including coincident zeros across cycles.
|
||||
///
|
||||
/// Preparation, candidate lines, clipping, dash visits and marks share cumulative limits.
|
||||
/// Unsupported boundaries, exhausted limits, nonfinite arithmetic, unsafe indices or
|
||||
/// lost numeric progress return an error rather than truncated or repaired geometry.
|
||||
/// Normal offsets, dash phase, prefixes and periods use exact stored-binary64 values
|
||||
/// within a checked i128 envelope; clipping receives their enclosing neighbours.
|
||||
/// No rounded normal offset is promoted to an exact line for contact classification.
|
||||
/// Excessive exponent alignment or arithmetic overflow is refused.
|
||||
/// Parameters round once to nearest-even after the complete exact phase expression;
|
||||
/// clipping uses their enclosures. Ambiguous contacts, collapsed rounded steps and
|
||||
/// uncertain normal contacts can still refuse valid patterns. Final frame coordinates are
|
||||
/// rounded f64 values, not an exact geometry or universal accuracy certificate.
|
||||
pub fn generate(pattern: &Pattern, region: &Region2) -> Result<Vec<Mark>> {
|
||||
generate_with_limits(pattern, region, Limits::default())
|
||||
}
|
||||
|
||||
pub(super) fn generate_with_limits(pattern: &Pattern, region: &Region2, limits: Limits) -> Result<Vec<Mark>> {
|
||||
pattern.validate()?;
|
||||
let mut budget = Budget::new(limits);
|
||||
let region = PreparedRegion::new(region, &mut budget)?;
|
||||
let mut marks = Vec::new();
|
||||
for family in &pattern.families {
|
||||
budget.work(1)?;
|
||||
let frame = Frame::new(family.origin, Vec2::from_angle(family.angle))?;
|
||||
let (low, high) = region.normal_bounds(&frame, &mut budget)?;
|
||||
let a = finite(low / family.delta_y)?;
|
||||
let b = finite(high / family.delta_y)?;
|
||||
// Include neighbouring indices when projection/division rounds onto an integer.
|
||||
// These are candidates, not output promises; exterior candidates also cost budget.
|
||||
let lines = IndexRange::new(a.min(b).floor() - 1.0, a.max(b).ceil() + 1.0)?;
|
||||
budget.candidate_lines(lines.count()?)?;
|
||||
let cycle = Cycle::new(&family.dashes, &mut budget)?;
|
||||
let spacing = Dyadic::from_f64(family.delta_y, &mut budget)?;
|
||||
let mut previous_offset = None;
|
||||
for k in lines.start..=lines.end {
|
||||
budget.work(1)?;
|
||||
let offset = spacing.mul_index(k, &mut budget)?.param(&mut budget)?;
|
||||
let rounded_offset = offset.nearest();
|
||||
if let Some(previous) = previous_offset {
|
||||
let advances = if family.delta_y > 0.0 { rounded_offset > previous } else { rounded_offset < previous };
|
||||
if !advances {
|
||||
return Err(Invalid("line spacing loses numeric progress"));
|
||||
}
|
||||
}
|
||||
previous_offset = Some(rounded_offset);
|
||||
let clipped = region.clip_parameter(&frame, offset, &mut budget)?;
|
||||
if clipped.spans().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let phase =
|
||||
if cycle.entries.is_empty() { Dyadic::ZERO } else { Dyadic::from_f64(family.delta_x, &mut budget)?.mul_index(k, &mut budget)? };
|
||||
let member = Member { clipped: &clipped, frame: &frame, offset: rounded_offset, phase };
|
||||
for (i, span) in clipped.spans().iter().enumerate() {
|
||||
budget.work(1)?;
|
||||
if !span.start.is_finite() || !span.end.is_finite() || span.start >= span.end {
|
||||
return Err(Invalid("invalid clipped span"));
|
||||
}
|
||||
if cycle.entries.is_empty() {
|
||||
push_line(&frame, rounded_offset, span.start, span.end, &mut budget, &mut marks)?;
|
||||
} else {
|
||||
cycle.expand(&member, i, &mut budget, &mut marks)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(marks)
|
||||
}
|
||||
|
||||
struct IndexRange {
|
||||
start: i64,
|
||||
end: i64,
|
||||
}
|
||||
|
||||
impl IndexRange {
|
||||
fn new(start: f64, end: f64) -> Result<Self> {
|
||||
if !start.is_finite() || !end.is_finite() || start.abs() > MAX_INDEX || end.abs() > MAX_INDEX || start > end {
|
||||
return Err(Invalid("hatch line or cycle index is not safely representable"));
|
||||
}
|
||||
// Callers supply floor/ceil values, and the checked range lies inside exact i64/f64 integers.
|
||||
Ok(Self { start: start as i64, end: end as i64 })
|
||||
}
|
||||
|
||||
fn count(&self) -> Result<usize> {
|
||||
let count = self.end.checked_sub(self.start).and_then(|n| n.checked_add(1)).ok_or(Invalid("hatch index count overflow"))?;
|
||||
usize::try_from(count).map_err(|_| Invalid("hatch index count exceeds the platform limit"))
|
||||
}
|
||||
|
||||
fn from_exact(start: i128, end: i128) -> Result<Self> {
|
||||
let error = Invalid("hatch line or cycle index is not safely representable");
|
||||
if !(-MAX_EXACT_INDEX..=MAX_EXACT_INDEX).contains(&start) || !(-MAX_EXACT_INDEX..=MAX_EXACT_INDEX).contains(&end) || start > end {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Self { start: i64::try_from(start).map_err(|_| error.clone())?, end: i64::try_from(end).map_err(|_| error)? })
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
start: Dyadic,
|
||||
end: Dyadic,
|
||||
value: f64,
|
||||
}
|
||||
|
||||
struct Cycle {
|
||||
entries: Vec<Entry>,
|
||||
period: Dyadic,
|
||||
}
|
||||
|
||||
struct Member<'a> {
|
||||
clipped: &'a ClippedLine,
|
||||
frame: &'a Frame,
|
||||
offset: f64,
|
||||
phase: Dyadic,
|
||||
}
|
||||
|
||||
impl Cycle {
|
||||
fn new(dashes: &[f64], budget: &mut Budget) -> Result<Self> {
|
||||
let mut entries = Vec::new();
|
||||
entries.try_reserve_exact(dashes.len()).map_err(|_| Invalid("cannot allocate hatch dash entries"))?;
|
||||
let mut period = Dyadic::from_parts(0, 0, budget)?;
|
||||
// Pattern::validate has already bounded this list to six entries.
|
||||
for &value in dashes {
|
||||
budget.work(1)?;
|
||||
let end = period.add(Dyadic::from_f64(value.abs(), budget)?, budget)?;
|
||||
if value != 0.0 && end == period {
|
||||
return Err(Invalid("dash length loses numeric progress within its cycle"));
|
||||
}
|
||||
entries.push(Entry { start: period, end, value });
|
||||
period = end;
|
||||
}
|
||||
if !entries.is_empty() && !period.is_positive() {
|
||||
return Err(Invalid("dash cycle must have a positive period"));
|
||||
}
|
||||
Ok(Self { entries, period })
|
||||
}
|
||||
|
||||
fn expand(&self, member: &Member<'_>, span_index: usize, budget: &mut Budget, marks: &mut Vec<Mark>) -> Result<()> {
|
||||
let (low, high) = member.clipped.span_window(span_index, budget)?;
|
||||
let first = Dyadic::from_f64(low, budget)?.sub(member.phase, budget)?.floor_ratio(self.period, budget)?;
|
||||
let last = Dyadic::from_f64(high, budget)?.sub(member.phase, budget)?.floor_ratio(self.period, budget)?;
|
||||
// Floor acts on exact dyadics, before separately checked padding and index
|
||||
// admission. The window uses retained contact bounds, never rounded spans.
|
||||
let first = first.checked_sub(1).ok_or(Invalid("hatch cycle index padding overflow"))?;
|
||||
let last = last.checked_add(1).ok_or(Invalid("hatch cycle index padding overflow"))?;
|
||||
let cycles = IndexRange::from_exact(first, last)?;
|
||||
let visits = cycles.count()?.checked_mul(self.entries.len()).ok_or(Invalid("dash visit count overflow"))?;
|
||||
// Reserve all visits before iteration, including gaps and cycles outside the span.
|
||||
// The previous cycle also owns any trailing zero coincident with this cycle's start.
|
||||
budget.dash_steps(visits)?;
|
||||
for j in cycles.start..=cycles.end {
|
||||
budget.work(1)?;
|
||||
let base = member.phase.add(self.period.mul_index(j, budget)?, budget)?;
|
||||
let next_j = j.checked_add(1).ok_or(Invalid("dash cycle index overflow"))?;
|
||||
// This checked successor is an endpoint, not another enumerated cycle.
|
||||
let next_base = member.phase.add(self.period.mul_index(next_j, budget)?, budget)?;
|
||||
if next_base.param(budget)?.nearest() <= base.param(budget)?.nearest() {
|
||||
return Err(Invalid("dash cycle loses numeric progress"));
|
||||
}
|
||||
for entry in &self.entries {
|
||||
budget.work(1)?;
|
||||
let start = self.position(entry.start, base, next_base, budget)?;
|
||||
if entry.value == 0.0 {
|
||||
// The full enclosure establishes unique strict span membership
|
||||
// before its one rounded parameter becomes an output coordinate.
|
||||
if let Some(t) = member.clipped.dot_in_span(span_index, start, budget)? {
|
||||
let at = member.frame.point(member.offset, t)?;
|
||||
if !at.is_finite() {
|
||||
return Err(Invalid("dot position is not finite"));
|
||||
}
|
||||
budget.marks(1)?;
|
||||
marks.try_reserve(1).map_err(|_| Invalid("cannot allocate hatch output marks"))?;
|
||||
marks.push(Mark::Dot { at });
|
||||
}
|
||||
} else {
|
||||
let end = self.position(entry.end, base, next_base, budget)?;
|
||||
if end.nearest() <= start.nearest() {
|
||||
return Err(Invalid("dash or gap loses numeric progress on its line"));
|
||||
}
|
||||
if entry.value > 0.0
|
||||
&& let Some(span) = member.clipped.dash_in_span(span_index, start, end, budget)?
|
||||
{
|
||||
push_line(member.frame, member.offset, span.start, span.end, budget, marks)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn position(&self, prefix: Dyadic, base: Dyadic, next_base: Dyadic, budget: &mut Budget) -> Result<Param> {
|
||||
// Canonical cycle endpoints keep final and initial zeros exactly coincident.
|
||||
let t = if prefix == self.period { next_base } else { base.add(prefix, budget)? };
|
||||
t.param(budget)
|
||||
}
|
||||
}
|
||||
|
||||
fn push_line(frame: &Frame, offset: f64, start: f64, end: f64, budget: &mut Budget, marks: &mut Vec<Mark>) -> Result<()> {
|
||||
let a = frame.point(offset, start)?;
|
||||
let b = frame.point(offset, end)?;
|
||||
let progress = (b - a).dot(frame.tangent());
|
||||
if !a.is_finite() || !b.is_finite() || a == b || !progress.is_finite() || progress <= 0.0 {
|
||||
return Err(Invalid("positive hatch span loses finite directional progress"));
|
||||
}
|
||||
budget.marks(1)?;
|
||||
marks.try_reserve(1).map_err(|_| Invalid("cannot allocate hatch output marks"))?;
|
||||
marks.push(Mark::Line { a, b });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finite(value: f64) -> Result<f64> {
|
||||
if value.is_finite() { Ok(value) } else { Err(Invalid("nonfinite hatch arithmetic")) }
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
//! Original analytic hatch-output cases; no external pattern library or CAD output.
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
|
||||
use super::budget::Limits;
|
||||
use super::generate::generate_with_limits;
|
||||
use super::{Family, Mark, Pattern, generate};
|
||||
use crate::{Loop2, Region2, Seg2, Vec2};
|
||||
|
||||
// Comparison only, never an admission/snap tolerance. The rotated and decimal
|
||||
// fixtures stay within 66 mm: this is under 128 binary64 epsilons at that scale.
|
||||
// Axis-aligned dyadic fixtures below instead require exact coordinates.
|
||||
const ROUNDOFF_MM: f64 = 1e-12;
|
||||
|
||||
fn family(origin: [f64; 2], dx: f64, dy: f64, dashes: &[f64]) -> Family {
|
||||
Family { angle: 0.0, origin: Vec2::new(origin[0], origin[1]), delta_x: dx, delta_y: dy, dashes: dashes.to_vec() }
|
||||
}
|
||||
|
||||
fn pattern(families: Vec<Family>) -> Pattern {
|
||||
Pattern { name: "ORIGINAL_PHASE".into(), description: "Independent analytic output cases".into(), families }
|
||||
}
|
||||
|
||||
fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> Loop2 {
|
||||
Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)])
|
||||
}
|
||||
|
||||
fn region(x0: f64, y0: f64, x1: f64, y1: f64) -> Region2 {
|
||||
Region2 { outer: rectangle(x0, y0, x1, y1), holes: vec![] }
|
||||
}
|
||||
|
||||
fn near_point(actual: Vec2, expected: [f64; 2], tolerance: f64) -> bool {
|
||||
actual.is_finite() && (actual.x - expected[0]).abs() <= tolerance && (actual.y - expected[1]).abs() <= tolerance
|
||||
}
|
||||
|
||||
fn assert_marks(marks: &[Mark], lines: &[[f64; 4]], dots: &[[f64; 2]], tolerance: f64) {
|
||||
// Match the complete multiset, allowing traversal and endpoint direction to
|
||||
// differ. Consuming each match separately preserves coincident dot instances.
|
||||
assert_eq!(marks.len(), lines.len() + dots.len(), "unexpected marks: {marks:?}");
|
||||
let mut unmatched = marks.to_vec();
|
||||
for &[ax, ay, bx, by] in lines {
|
||||
let found = unmatched.iter().position(|mark| match *mark {
|
||||
Mark::Line { a, b } => {
|
||||
assert!(a.is_finite() && b.is_finite() && a != b, "invalid stroke: {mark:?}");
|
||||
(near_point(a, [ax, ay], tolerance) && near_point(b, [bx, by], tolerance))
|
||||
|| (near_point(a, [bx, by], tolerance) && near_point(b, [ax, ay], tolerance))
|
||||
}
|
||||
Mark::Dot { .. } => false,
|
||||
});
|
||||
let Some(index) = found else { panic!("missing line {ax},{ay} -> {bx},{by}; remaining: {unmatched:?}") };
|
||||
unmatched.swap_remove(index);
|
||||
}
|
||||
for &at in dots {
|
||||
let found = unmatched.iter().position(|mark| match *mark {
|
||||
Mark::Dot { at: actual } => near_point(actual, at, tolerance),
|
||||
Mark::Line { .. } => false,
|
||||
});
|
||||
let Some(index) = found else { panic!("missing dot {at:?}; remaining: {unmatched:?}") };
|
||||
unmatched.swap_remove(index);
|
||||
}
|
||||
assert!(unmatched.is_empty(), "unexpected marks: {unmatched:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_first_stroke_keeps_the_origin_and_negative_cycles() {
|
||||
// Strokes are [2 + 5*j, 5 + 5*j]. A reset at -5.5 also gives total
|
||||
// length 9, so only the individual endpoints distinguish the wrong phase.
|
||||
let p = pattern(vec![family([2.0, 0.0], 0.0, 10.0, &[3.0, -2.0])]);
|
||||
let r = region(-5.5, -0.5, 9.5, 0.5);
|
||||
let marks = generate(&p, &r).unwrap();
|
||||
assert_marks(&marks, &[[-5.5, 0.0, -5.0, 0.0], [-3.0, 0.0, 0.0, 0.0], [2.0, 0.0, 5.0, 0.0], [7.0, 0.0, 9.5, 0.0]], &[], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangential_delta_changes_phase_once_for_positive_and_negative_members() {
|
||||
// k=-1,0,1 have y=-2,0,2 and x origins -1.5,0,1.5.
|
||||
let p = pattern(vec![family([0.0, 0.0], 1.5, 2.0, &[2.0, -3.0])]);
|
||||
let r = region(0.25, -2.5, 8.25, 2.5);
|
||||
let marks = generate(&p, &r).unwrap();
|
||||
assert_marks(
|
||||
&marks,
|
||||
&[[0.25, -2.0, 0.5, -2.0], [3.5, -2.0, 5.5, -2.0], [0.25, 0.0, 2.0, 0.0], [5.0, 0.0, 7.0, 0.0], [1.5, 2.0, 3.5, 2.0], [6.5, 2.0, 8.25, 2.0]],
|
||||
&[],
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_negative_deltas_keep_global_origin_and_signed_member_phase() {
|
||||
// u=(0,1), n=(-1,0), hence B_k=(4+3*k,1-k), k=-1,0,1,2.
|
||||
let mut f = family([4.0, 1.0], -1.0, -3.0, &[1.0, -2.0]);
|
||||
f.angle = FRAC_PI_2;
|
||||
let marks = generate(&pattern(vec![f]), ®ion(0.5, -0.5, 10.5, 5.5)).unwrap();
|
||||
assert_marks(
|
||||
&marks,
|
||||
&[
|
||||
[1.0, -0.5, 1.0, 0.0],
|
||||
[1.0, 2.0, 1.0, 3.0],
|
||||
[1.0, 5.0, 1.0, 5.5],
|
||||
[4.0, 1.0, 4.0, 2.0],
|
||||
[4.0, 4.0, 4.0, 5.0],
|
||||
[7.0, 0.0, 7.0, 1.0],
|
||||
[7.0, 3.0, 7.0, 4.0],
|
||||
[10.0, -0.5, 10.0, 0.0],
|
||||
[10.0, 2.0, 10.0, 3.0],
|
||||
[10.0, 5.0, 10.0, 5.5],
|
||||
],
|
||||
&[],
|
||||
ROUNDOFF_MM,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_splits_a_dash_without_pausing_or_restarting_its_cycle() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[3.0, -2.0])]);
|
||||
let mut r = region(-0.5, -0.75, 14.5, 0.75);
|
||||
r.holes.push(rectangle(2.5, -0.25, 8.5, 0.25).reversed());
|
||||
let marks = generate(&p, &r).unwrap();
|
||||
assert_marks(&marks, &[[0.0, 0.0, 2.5, 0.0], [10.0, 0.0, 13.0, 0.0]], &[], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dots_keep_negative_cycles_and_phase_across_a_hole() {
|
||||
// Every cycle has stroke [4*j,4*j+1] and dot 4*j+2.
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[1.0, -1.0, 0.0, -2.0])]);
|
||||
let mut r = region(-2.5, -0.75, 10.5, 0.75);
|
||||
r.holes.push(rectangle(1.5, -0.25, 5.5, 0.25).reversed());
|
||||
let marks = generate(&p, &r).unwrap();
|
||||
assert_marks(&marks, &[[0.0, 0.0, 1.0, 0.0], [8.0, 0.0, 9.0, 0.0]], &[[-2.0, 0.0], [6.0, 0.0], [10.0, 0.0]], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_paper_millimetres_and_common_translation_are_applied_once() {
|
||||
// Values are already converted by 12.7 mm per authored length. There is
|
||||
// no PAT reader or units argument in this generator-level output oracle.
|
||||
let p = pattern(vec![family([3.175, 1.5875], 6.35, 12.7, &[3.175, -3.175, 0.0, -6.35])]);
|
||||
let marks = generate(&p, ®ion(0.0, 0.0, 25.4, 3.175)).unwrap();
|
||||
assert_marks(&marks, &[[3.175, 1.5875, 6.35, 1.5875], [15.875, 1.5875, 19.05, 1.5875]], &[[9.525, 1.5875], [22.225, 1.5875]], ROUNDOFF_MM);
|
||||
|
||||
let translated = pattern(vec![family([43.175, -5.4125], 6.35, 12.7, &[3.175, -3.175, 0.0, -6.35])]);
|
||||
let marks = generate(&translated, ®ion(40.0, -7.0, 65.4, -3.825)).unwrap();
|
||||
assert_marks(
|
||||
&marks,
|
||||
&[[43.175, -5.4125, 46.35, -5.4125], [55.875, -5.4125, 59.05, -5.4125]],
|
||||
&[[49.525, -5.4125], [62.225, -5.4125]],
|
||||
ROUNDOFF_MM,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversing_signed_member_parameterization_preserves_the_same_geometry() {
|
||||
let r = region(0.25, -2.5, 8.25, 2.5);
|
||||
let p = pattern(vec![family([0.0, 0.0], -1.5, -2.0, &[2.0, -3.0])]);
|
||||
let marks = generate(&p, &r).unwrap();
|
||||
assert_marks(
|
||||
&marks,
|
||||
&[[0.25, -2.0, 0.5, -2.0], [3.5, -2.0, 5.5, -2.0], [0.25, 0.0, 2.0, 0.0], [5.0, 0.0, 7.0, 0.0], [1.5, 2.0, 3.5, 2.0], [6.5, 2.0, 8.25, 2.0]],
|
||||
&[],
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_loop_start_and_orientation_does_not_restart_the_pattern() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[3.0, -2.0])]);
|
||||
let outer = rectangle(-0.5, -0.75, 14.5, 0.75);
|
||||
let hole = rectangle(2.5, -0.25, 8.5, 0.25);
|
||||
for reverse_outer in [false, true] {
|
||||
for reverse_hole in [false, true] {
|
||||
for start in 0..4 {
|
||||
let mut r = Region2 {
|
||||
outer: if reverse_outer { outer.reversed() } else { outer.clone() },
|
||||
holes: vec![if reverse_hole { hole.reversed() } else { hole.clone() }],
|
||||
};
|
||||
r.outer.segs.rotate_left(start);
|
||||
r.holes[0].segs.rotate_left((start + 1) % 4);
|
||||
let marks = generate(&p, &r).unwrap();
|
||||
assert_marks(&marks, &[[0.0, 0.0, 2.5, 0.0], [10.0, 0.0, 13.0, 0.0]], &[], 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_lines_ignore_tangential_phase_and_keep_holes() {
|
||||
let p = pattern(vec![family([7.0, 0.0], 3.5, 2.0, &[])]);
|
||||
let mut r = region(-1.5, -2.5, 4.5, 2.5);
|
||||
r.holes.push(rectangle(0.5, -0.25, 1.5, 0.25).reversed());
|
||||
let marks = generate(&p, &r).unwrap();
|
||||
assert_marks(&marks, &[[-1.5, -2.0, 4.5, -2.0], [-1.5, 0.0, 0.5, 0.0], [1.5, 0.0, 4.5, 0.0], [-1.5, 2.0, 4.5, 2.0]], &[], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_zero_tokens_are_distinct_even_at_neighboring_cycle_boundaries() {
|
||||
let r = region(-0.25, -0.5, 2.25, 0.5);
|
||||
let expected = [[0.0, 0.0], [0.0, 0.0], [1.0, 0.0], [1.0, 0.0], [2.0, 0.0], [2.0, 0.0]];
|
||||
for dashes in [&[0.0, 0.0, -1.0][..], &[0.0, -1.0, 0.0][..]] {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, dashes)]);
|
||||
let marks = generate_with_limits(&p, &r, Limits { marks: 6, ..Limits::default() }).unwrap();
|
||||
assert_marks(&marks, &[], &expected, 0.0);
|
||||
assert!(generate_with_limits(&p, &r, Limits { marks: 5, ..Limits::default() }).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_on_outer_and_hole_boundaries_are_omitted_but_interior_dots_remain() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[0.0, -1.0])]);
|
||||
let mut r = region(0.0, -0.75, 6.0, 0.75);
|
||||
r.holes.push(rectangle(2.0, -0.25, 4.0, 0.25).reversed());
|
||||
let marks = generate(&p, &r).unwrap();
|
||||
assert_marks(&marks, &[], &[[1.0, 0.0], [5.0, 0.0]], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_retains_boundary_endpoints_without_becoming_a_dot() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[1.0, -1.0])]);
|
||||
let marks = generate(&p, ®ion(0.0, -0.5, 1.0, 0.5)).unwrap();
|
||||
assert_marks(&marks, &[[0.0, 0.0, 1.0, 0.0]], &[], 0.0);
|
||||
// At x=1 and x=2 the adjacent positive strokes have only point contact.
|
||||
let marks = generate(&p, ®ion(1.0, -0.5, 2.0, 0.5)).unwrap();
|
||||
assert_marks(&marks, &[], &[], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_families_keep_their_own_origins_and_phase() {
|
||||
let p = pattern(vec![family([0.0, -0.25], 0.0, 10.0, &[1.0, -1.0]), family([0.5, 0.25], 0.0, 10.0, &[0.0, -2.0])]);
|
||||
let marks = generate(&p, ®ion(-0.25, -0.5, 3.25, 0.5)).unwrap();
|
||||
assert_marks(&marks, &[[0.0, -0.25, 1.0, -0.25], [2.0, -0.25, 3.0, -0.25]], &[[0.5, 0.25], [2.5, 0.25]], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_negative_cycles_paint_nothing_instead_of_continuous_lines() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 1.5, -2.0, &[-1.0, -2.0])]);
|
||||
let marks = generate(&p, ®ion(-4.5, -2.5, 4.5, 2.5)).unwrap();
|
||||
assert_marks(&marks, &[], &[], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painting_nothing_does_not_bypass_pattern_or_region_validation() {
|
||||
let gap = family([0.0, 0.0], 0.0, 10.0, &[-1.0]);
|
||||
let p = pattern(vec![gap.clone()]);
|
||||
// Canonical full circles are supported; a different seam remains outside
|
||||
// the admitted two-semicircle encoding, even for a gap-only pattern.
|
||||
let curved = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 1.0, FRAC_PI_2), holes: vec![] };
|
||||
assert!(generate(&p, &curved).is_err());
|
||||
assert!(generate(&p, &Region2::default()).is_err());
|
||||
let mut open = region(-1.0, -0.5, 1.0, 0.5);
|
||||
open.outer.segs.truncate(3);
|
||||
assert!(generate(&p, &open).is_err());
|
||||
|
||||
let r = region(-1.0, -0.5, 1.0, 0.5);
|
||||
let invalid = Family { delta_y: 0.0, ..gap.clone() };
|
||||
assert!(generate(&pattern(vec![gap, invalid]), &r).is_err());
|
||||
let mut invalid_name = p;
|
||||
invalid_name.name.clear();
|
||||
assert!(generate(&invalid_name, &r).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_invalid_family_refuses_the_entire_result() {
|
||||
let good = family([0.0, 0.0], 0.0, 10.0, &[]);
|
||||
let r = region(-1.0, -0.5, 1.0, 0.5);
|
||||
let mut bad = good.clone();
|
||||
bad.angle = f64::NAN;
|
||||
assert!(generate(&pattern(vec![good, bad]), &r).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_line_input_does_not_silently_accept_an_unsupported_hole() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[])]);
|
||||
let mut r = region(-2.0, -2.0, 2.0, 2.0);
|
||||
r.holes.push(Loop2::circle(Vec2::ZERO, 0.5).reversed());
|
||||
assert!(generate(&p, &r).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_runs_are_omitted_without_shifting_the_interior_members() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 1.0, &[])]);
|
||||
let marks = generate(&p, ®ion(-1.0, 0.0, 1.0, 2.0)).unwrap();
|
||||
assert_marks(&marks, &[[-1.0, 1.0, 1.0, 1.0]], &[], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_work_budget_can_refuse_a_small_otherwise_valid_pattern() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[1.0, -1.0])]);
|
||||
let r = region(-0.25, -0.5, 1.25, 0.5);
|
||||
assert_marks(&generate(&p, &r).unwrap(), &[[0.0, 0.0, 1.0, 0.0]], &[], 0.0);
|
||||
for limits in [
|
||||
Limits { input_pieces: 0, ..Limits::default() },
|
||||
Limits { candidate_lines: 0, ..Limits::default() },
|
||||
Limits { work: 0, ..Limits::default() },
|
||||
Limits { events: 0, ..Limits::default() },
|
||||
Limits { dash_steps: 0, ..Limits::default() },
|
||||
Limits { marks: 0, ..Limits::default() },
|
||||
] {
|
||||
assert!(generate_with_limits(&p, &r, limits).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_are_counted_across_families_and_split_strokes_without_partial_success() {
|
||||
let first = family([0.0, -0.25], 0.0, 10.0, &[]);
|
||||
let second = family([0.0, 0.25], 0.0, 10.0, &[]);
|
||||
let mut r = region(-1.5, -0.5, 2.5, 0.5);
|
||||
r.holes.push(rectangle(0.0, -0.375, 1.0, 0.375).reversed());
|
||||
let p = pattern(vec![first, second]);
|
||||
let marks = generate_with_limits(&p, &r, Limits { marks: 4, ..Limits::default() }).unwrap();
|
||||
assert_marks(&marks, &[[-1.5, -0.25, 0.0, -0.25], [1.0, -0.25, 2.5, -0.25], [-1.5, 0.25, 0.0, 0.25], [1.0, 0.25, 2.5, 0.25]], &[], 0.0);
|
||||
assert!(generate_with_limits(&p, &r, Limits { marks: 3, ..Limits::default() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_lines_are_counted_even_without_marks() {
|
||||
let r = region(-0.25, -0.5, 0.25, 0.5);
|
||||
let f = family([0.0, 0.0], 0.0, 10.0, &[-1.0]);
|
||||
let p = pattern(vec![f.clone(), f]);
|
||||
assert!(generate(&p, &r).unwrap().is_empty());
|
||||
// Each family has member k=0 in the region; a one-line budget cannot
|
||||
// serve both. Conservative exterior candidates also consume this budget.
|
||||
assert!(generate_with_limits(&p, &r, Limits { candidate_lines: 1, ..Limits::default() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_lines_and_dash_steps_are_charged_for_a_visible_gap() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[1.0, -3.0])]);
|
||||
let r = region(1.25, -0.5, 3.75, 0.5);
|
||||
assert!(generate(&p, &r).unwrap().is_empty());
|
||||
assert!(generate_with_limits(&p, &r, Limits { candidate_lines: 0, ..Limits::default() }).is_err());
|
||||
assert!(generate_with_limits(&p, &r, Limits { dash_steps: 0, ..Limits::default() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subnormal_spacing_is_valid_data_but_cannot_expand_without_a_bound() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, f64::from_bits(1), &[])]);
|
||||
assert!(p.validate().is_ok());
|
||||
assert!(generate(&p, ®ion(-0.5, -0.5, 0.5, 0.5)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonzero_dash_step_that_cannot_advance_is_refused() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[1.0, -f64::from_bits(1)])]);
|
||||
assert!(p.validate().is_ok());
|
||||
assert!(generate(&p, ®ion(-0.25, -0.5, 2.25, 0.5)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_far_phase_indices_are_not_saturated_into_a_success() {
|
||||
let p = pattern(vec![family([100_000.0, 0.0], 0.0, 10.0, &[2.0_f64.powi(-40), -2.0_f64.powi(-40)])]);
|
||||
let r = region(-2.0_f64.powi(-50), -0.5, 2.0_f64.powi(-50), 0.5);
|
||||
assert!(p.validate().is_ok());
|
||||
assert!(generate(&p, &r).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_or_nonfinite_line_boundaries_return_an_error() {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[])]);
|
||||
for point in [Vec2::ZERO, Vec2::new(f64::NAN, 0.0), Vec2::new(f64::INFINITY, 0.0)] {
|
||||
let r = Region2 { outer: Loop2 { segs: vec![Seg2::Line { a: point, b: point }] }, holes: vec![] };
|
||||
assert!(generate(&p, &r).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_member_phase_cancels_before_its_single_output_rounding() {
|
||||
// Independent integer oracle: k*dx = 99_999_000_000_099_999.
|
||||
// With period 100 and cycle -999_990_000_001_000, the visible stroke
|
||||
// is exactly [-1,0]. Rounding k*dx first instead shifts it to [0,1].
|
||||
let k: i64 = 1_000_000_000_001;
|
||||
let dx: i64 = 99_999;
|
||||
let cycle: i128 = -999_990_000_001_000;
|
||||
let exact_start = i128::from(k) * i128::from(dx) + cycle * 100;
|
||||
assert_eq!(exact_start, -1);
|
||||
assert_eq!(exact_start + 1, 0);
|
||||
|
||||
// These dyadic bounds contain only member k and preserve their width
|
||||
// exactly. Stored origin, deltas and dashes all satisfy model admission.
|
||||
let dy = 2.0_f64.powi(-40);
|
||||
let p = pattern(vec![family([0.0, 0.0], dx as f64, dy, &[1.0, -99.0])]);
|
||||
let r = region(-2.0, (k as f64 - 0.25) * dy, 2.0, (k as f64 + 0.25) * dy);
|
||||
assert!(p.validate().is_ok());
|
||||
let y = k as f64 * dy;
|
||||
assert_marks(&generate(&p, &r).unwrap(), &[[-1.0, y, 0.0, y]], &[], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_dash_cycles_round_complete_endpoints_once() {
|
||||
// The exact sum of the stored binary64 values 0.1 and 0.2 is
|
||||
// 10_808_639_105_689_191 / 2^55: it lies between representable f64 values.
|
||||
// With n=3_602_879_701_896_397, the exact endpoints for j=-2..2 are
|
||||
// [-6n,-5n], [-3n,-2n], [0,n], [3n,4n], [6n,7n], all divided by 2^55.
|
||||
// In particular -5n/2^55 rounds to -0.5; adding a rounded cycle base
|
||||
// to the first dash would instead round that endpoint twice.
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 10.0, &[0.1, -0.2])]);
|
||||
let r = region(-0.75, -0.5, 0.75, 0.5);
|
||||
assert!(p.validate().is_ok());
|
||||
assert_marks(
|
||||
&generate(&p, &r).unwrap(),
|
||||
&[
|
||||
[-0.6000000000000001, 0.0, -0.5, 0.0],
|
||||
[-0.30000000000000004, 0.0, -0.2, 0.0],
|
||||
[0.0, 0.0, 0.1, 0.0],
|
||||
[0.30000000000000004, 0.0, 0.4, 0.0],
|
||||
[0.6000000000000001, 0.0, 0.7000000000000001, 0.0],
|
||||
],
|
||||
&[],
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_decimal_cycle_preserves_the_stored_period_residual() {
|
||||
// Let n=3_602_879_701_896_397, D=2^55 and K=J=2^50.
|
||||
// The stored dx is -(3n+1)/D, whereas the exact dash period is 3n/D.
|
||||
// K*dx + J*period = -1/32; adding the first dash gives
|
||||
// (n-2^50)/D = 2_476_979_795_053_773/D, exactly the stored 0.06875.
|
||||
// Repeating the rounded period would wrongly yield [0,0.1].
|
||||
let dy = 2.0_f64.powi(-50);
|
||||
let p = pattern(vec![family([0.0, 0.0], -0.30000000000000004, dy, &[0.1, -0.2])]);
|
||||
let half_height = 2.0_f64.powi(-52);
|
||||
let r = region(-0.0625, 1.0 - half_height, 0.1875, 1.0 + half_height);
|
||||
assert!(p.validate().is_ok());
|
||||
assert_marks(&generate(&p, &r).unwrap(), &[[-0.03125, 1.0, 0.06875, 1.0]], &[], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonrepresentable_dot_phase_rounds_only_after_interior_classification() {
|
||||
// Stored dx=3_002_399_751_580_331/2^53; member k=3 gives the exact
|
||||
// phase 1+2^-53, enclosed by [1,1+2^-52] and rounded nearest-even to 1.
|
||||
// That enclosure is strictly inside (0,2), so its single dot is retained.
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.33333333333333337, 1.0, &[0.0, -4.0])]);
|
||||
let r = region(0.0, 2.5, 2.0, 3.5);
|
||||
assert_marks(&generate(&p, &r).unwrap(), &[], &[[1.0, 3.0]], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dot_enclosure_overlapping_a_boundary_refuses_the_entire_result() {
|
||||
// The same exact phase lies above 1, but its f64 enclosure touches the
|
||||
// boundary at 1. Neither dropping its rounded point nor accepting its
|
||||
// midpoint certifies membership. A preceding family must not leak output.
|
||||
let first = family([0.0, 3.0], 0.0, 10.0, &[]);
|
||||
let ambiguous = family([0.0, 0.0], 0.33333333333333337, 1.0, &[0.0, -4.0]);
|
||||
let r = region(1.0, 2.5, 2.0, 3.5);
|
||||
assert_marks(&generate(&pattern(vec![first.clone()]), &r).unwrap(), &[[1.0, 3.0, 2.0, 3.0]], &[], 0.0);
|
||||
assert!(generate(&pattern(vec![ambiguous.clone()]), &r).is_err());
|
||||
assert!(generate(&pattern(vec![first, ambiguous]), &r).is_err());
|
||||
}
|
||||
|
||||
fn circular_region(with_hole: bool) -> Region2 {
|
||||
let center = Vec2::new(2.0, -1.0);
|
||||
Region2 { outer: Loop2::circle(center, 5.0), holes: if with_hole { vec![Loop2::circle(center, 3.0)] } else { vec![] } }
|
||||
}
|
||||
|
||||
fn circle_lines(intervals: &[[f64; 3]]) -> Vec<Mark> {
|
||||
// Independent world map of the analytic (left t, right t, relative y)
|
||||
// tables for center (2,-1), horizontal tangent and vertical normal.
|
||||
intervals.iter().map(|&[left, right, q]| Mark::Line { a: Vec2::new(2.0 + left, -1.0 + q), b: Vec2::new(2.0 + right, -1.0 + q) }).collect()
|
||||
}
|
||||
|
||||
fn circle_dots(q: f64, positions: &[f64]) -> Vec<Mark> {
|
||||
positions.iter().map(|&t| Mark::Dot { at: Vec2::new(2.0 + t, -1.0 + q) }).collect()
|
||||
}
|
||||
|
||||
fn circle_output(p: &Pattern, r: &Region2, limits: Option<Limits>) -> Result<Vec<Mark>, super::Invalid> {
|
||||
let pattern_before = p.clone();
|
||||
let region_before = r.clone();
|
||||
let result = match limits {
|
||||
Some(limits) => generate_with_limits(p, r, limits),
|
||||
None => generate(p, r),
|
||||
};
|
||||
assert_eq!(p, &pattern_before);
|
||||
assert_eq!(r, ®ion_before);
|
||||
result
|
||||
}
|
||||
|
||||
fn assert_exact_circle_output(p: &Pattern, r: &Region2, expected: &[Mark], total_length: f64) {
|
||||
let marks = circle_output(p, r, None).unwrap();
|
||||
// Full equality checks member/span/cycle order and each repeated zero token.
|
||||
assert_eq!(marks, expected);
|
||||
let length: f64 = marks
|
||||
.iter()
|
||||
.map(|mark| match *mark {
|
||||
Mark::Line { a, b } => {
|
||||
assert!(a.is_finite() && b.is_finite() && a.x < b.x);
|
||||
assert_eq!(a.y, b.y);
|
||||
b.x - a.x
|
||||
}
|
||||
Mark::Dot { at } => {
|
||||
assert!(at.is_finite());
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
assert_eq!(length, total_length);
|
||||
let exact = Limits { marks: expected.len(), ..Limits::default() };
|
||||
assert_eq!(circle_output(p, r, Some(exact)).unwrap(), expected);
|
||||
if !expected.is_empty() {
|
||||
let short = Limits { marks: expected.len() - 1, ..Limits::default() };
|
||||
assert!(circle_output(p, r, Some(short)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disk_generates_three_ordered_secants_with_total_length_twenty_six() {
|
||||
let r = circular_region(false);
|
||||
let p = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[])]);
|
||||
// x^2 + q^2 = 25 in centered coordinates: q=-3,0,3 gives A=4,5,4.
|
||||
let expected = circle_lines(&[[-4.0, 4.0, -3.0], [-5.0, 5.0, 0.0], [-4.0, 4.0, 3.0]]);
|
||||
assert_exact_circle_output(&p, &r, &expected, 26.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disk_keeps_twenty_three_strict_integer_dots_and_excludes_its_boundary() {
|
||||
let r = circular_region(false);
|
||||
let p = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[0.0, -1.0])]);
|
||||
let mut expected = circle_dots(-3.0, &[-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0]);
|
||||
expected.extend(circle_dots(0.0, &[-4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0]));
|
||||
expected.extend(circle_dots(3.0, &[-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0]));
|
||||
assert_eq!(expected.len(), 23);
|
||||
assert_exact_circle_output(&p, &r, &expected, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_circle_tangencies_and_exterior_members_emit_neither_lines_nor_dots() {
|
||||
for with_hole in [false, true] {
|
||||
let r = circular_region(with_hole);
|
||||
for q in [-6.0, -5.0, 5.0, 6.0] {
|
||||
for dashes in [&[][..], &[0.0, -1.0][..]] {
|
||||
// Only k=0 can meet the circle; q=+/-5 is an isolated contact,
|
||||
// whereas q=+/-6 has no roots. Both must return empty success.
|
||||
let p = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, dashes)]);
|
||||
assert_exact_circle_output(&p, &r, &[], 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_annulus_keeps_six_ordered_spans_and_splits_both_inner_tangencies() {
|
||||
let r = circular_region(true);
|
||||
let p = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[])]);
|
||||
let expected = circle_lines(&[[-4.0, 0.0, -3.0], [0.0, 4.0, -3.0], [-5.0, -3.0, 0.0], [3.0, 5.0, 0.0], [-4.0, 0.0, 3.0], [0.0, 4.0, 3.0]]);
|
||||
assert_exact_circle_output(&p, &r, &expected, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_annulus_keeps_fourteen_strict_dots_without_a_dot_at_inner_tangency() {
|
||||
let r = circular_region(true);
|
||||
let p = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[0.0, -1.0])]);
|
||||
let mut expected = circle_dots(-3.0, &[-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]);
|
||||
expected.extend(circle_dots(0.0, &[-4.0, 4.0]));
|
||||
expected.extend(circle_dots(3.0, &[-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]));
|
||||
assert_eq!(expected.len(), 14);
|
||||
assert_exact_circle_output(&p, &r, &expected, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annular_secants_beyond_the_hole_keep_the_middle_line_and_dot() {
|
||||
let r = circular_region(true);
|
||||
for q in [-4.0, 4.0] {
|
||||
let continuous = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, &[])]);
|
||||
assert_exact_circle_output(&continuous, &r, &circle_lines(&[[-3.0, 3.0, q]]), 6.0);
|
||||
let dotted = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, &[0.0, -1.0])]);
|
||||
assert_exact_circle_output(&dotted, &r, &circle_dots(q, &[-2.0, -1.0, 0.0, 1.0, 2.0]), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn phased_annulus_lines() -> Vec<Mark> {
|
||||
// Exact period 3, phase k/2: clipping neither restarts a cycle nor joins
|
||||
// the two pieces of the k=-1, j=0 dash across its tangent contact.
|
||||
circle_lines(&[
|
||||
[-3.5, -1.5, -3.0],
|
||||
[-0.5, 0.0, -3.0],
|
||||
[0.0, 1.5, -3.0],
|
||||
[2.5, 4.0, -3.0],
|
||||
[-5.0, -4.0, 0.0],
|
||||
[3.0, 5.0, 0.0],
|
||||
[-4.0, -3.5, 3.0],
|
||||
[-2.5, -0.5, 3.0],
|
||||
[0.5, 2.5, 3.0],
|
||||
[3.5, 4.0, 3.0],
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_annular_dash_phase_preserves_ten_pieces_and_thirteen_and_a_half_length() {
|
||||
let p = pattern(vec![family([2.0, -1.0], 0.5, 3.0, &[2.0, -1.0])]);
|
||||
assert_exact_circle_output(&p, &circular_region(true), &phased_annulus_lines(), 13.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annular_zero_tokens_keep_thirty_ordered_instances_across_span_windows() {
|
||||
let p = pattern(vec![family([2.0, -1.0], 0.5, 3.0, &[0.0, 0.0, -2.0, 0.0])]);
|
||||
let mut positions = circle_dots(-3.0, &[-2.5, -0.5, 1.5, 3.5]);
|
||||
positions.extend(circle_dots(0.0, &[-4.0, 4.0]));
|
||||
positions.extend(circle_dots(3.0, &[-3.5, -1.5, 0.5, 2.5]));
|
||||
// At H+2*j: previous cycle's trailing zero, then this cycle's two zeros.
|
||||
let expected: Vec<_> = positions.into_iter().flat_map(|mark| [mark; 3]).collect();
|
||||
assert_eq!(expected.len(), 30);
|
||||
assert_exact_circle_output(&p, &circular_region(true), &expected, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_circle_reversals_cyclic_starts_and_translation_preserve_ordered_phase() {
|
||||
for reversed in [false, true] {
|
||||
for start in 0..2 {
|
||||
for shift in [Vec2::ZERO, Vec2::new(11.0, -7.0)] {
|
||||
let center = Vec2::new(2.0, -1.0) + shift;
|
||||
let mut outer = Loop2::circle(center, 5.0);
|
||||
let mut hole = Loop2::circle(center, 3.0);
|
||||
if reversed {
|
||||
outer = outer.reversed();
|
||||
hole = hole.reversed();
|
||||
}
|
||||
outer.segs.rotate_left(start);
|
||||
hole.segs.rotate_left(1 - start);
|
||||
let r = Region2 { outer, holes: vec![hole] };
|
||||
let p = pattern(vec![family([center.x, center.y], 0.5, 3.0, &[2.0, -1.0])]);
|
||||
let expected: Vec<_> = phased_annulus_lines()
|
||||
.into_iter()
|
||||
.map(|mark| match mark {
|
||||
Mark::Line { a, b } => Mark::Line { a: a + shift, b: b + shift },
|
||||
Mark::Dot { .. } => panic!("the analytic phased fixture has only lines"),
|
||||
})
|
||||
.collect();
|
||||
assert_exact_circle_output(&p, &r, &expected, 13.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn near_tangent_root_bracket(numerator: u128) -> [f64; 2] {
|
||||
// The analytic squared root is numerator/2^60. Integer square root of
|
||||
// numerator*2^40 brackets the root on a 2^-50 lattice, independently of
|
||||
// the clipper's floating products, sqrt candidate and contact midpoint.
|
||||
assert!(numerator < (17_u128 << 60));
|
||||
let square = numerator << 40;
|
||||
let lower = square.isqrt();
|
||||
let upper = if lower * lower == square { lower } else { lower + 1 };
|
||||
assert!(lower * lower <= square && upper * upper >= square);
|
||||
assert!(upper <= (1_u128 << 53));
|
||||
let unit = 2.0_f64.powi(-50);
|
||||
[lower as f64 * unit, upper as f64 * unit]
|
||||
}
|
||||
|
||||
// Reuse the clipping acceptance's 1e-9 mm output comparison for this fixture.
|
||||
// A geometric contact is the midpoint of a certified Bound, not a correctly
|
||||
// rounded analytic root. Near q=3-2^-30, the square enclosure near 9 can have
|
||||
// width 2^-49; propagating it through the small inner root gives about 1e-11
|
||||
// mm of contact uncertainty. This comparison includes that effect and final
|
||||
// coordinate rounding; it never chooses topology or relaxes the older tests.
|
||||
const CIRCLE_CONTACT_COMPARISON_MM: f64 = 1e-9;
|
||||
|
||||
fn assert_circle_line_enclosures(marks: &[Mark], y: f64, intervals: &[([f64; 2], [f64; 2])], length: [f64; 2]) {
|
||||
assert_eq!(marks.len(), intervals.len());
|
||||
let mut total = 0.0;
|
||||
for (mark, &(left, right)) in marks.iter().zip(intervals) {
|
||||
let Mark::Line { a, b } = *mark else { panic!("continuous circle pattern produced a dot") };
|
||||
assert!(a.is_finite() && b.is_finite() && a.x < b.x);
|
||||
assert_eq!(a.y, y);
|
||||
assert_eq!(b.y, y);
|
||||
for (actual, bounds) in [(a.x, left), (b.x, right)] {
|
||||
// These reference lattice endpoints and their translation by 2
|
||||
// are exactly representable. Only the public contact output gets
|
||||
// the documented comparison allowance around the exact-root bracket.
|
||||
let low = 2.0 + bounds[0];
|
||||
let high = 2.0 + bounds[1];
|
||||
assert!(actual >= low - CIRCLE_CONTACT_COMPARISON_MM && actual <= high + CIRCLE_CONTACT_COMPARISON_MM);
|
||||
}
|
||||
total += b.x - a.x;
|
||||
}
|
||||
// Each positive horizontal span contributes two endpoint uncertainties.
|
||||
let allowance = 2.0 * intervals.len() as f64 * CIRCLE_CONTACT_COMPARISON_MM;
|
||||
assert!(total >= length[0] - allowance && total <= length[1] + allowance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_inner_tangency_keeps_distinct_gaps_contacts_and_strict_dot_counts() {
|
||||
let r = circular_region(true);
|
||||
let delta = 2.0_f64.powi(-30);
|
||||
let denominator = 1_u128 << 60;
|
||||
let six_delta = 6_u128 << 30;
|
||||
// Analytic radicands over 2^60 avoid the cancellation of delta^2 in a
|
||||
// floating computation of 25-q*q or 9-q*q. Counts are exact: 2/2/1
|
||||
// spans and 8/6/7 dots; no success-or-error alternative is accepted.
|
||||
for (side, outer_square, inner_square, dot_positions) in [
|
||||
(-1, 16 * denominator + six_delta - 1, Some(six_delta - 1), &[-4.0, -3.0, -2.0, -1.0, 1.0, 2.0, 3.0, 4.0][..]),
|
||||
(0, 16 * denominator, Some(0), &[-3.0, -2.0, -1.0, 1.0, 2.0, 3.0][..]),
|
||||
(1, 16 * denominator - six_delta - 1, None, &[-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0][..]),
|
||||
] {
|
||||
let a = near_tangent_root_bracket(outer_square);
|
||||
let (intervals, length) = match inner_square {
|
||||
Some(square) => {
|
||||
let b = near_tangent_root_bracket(square);
|
||||
(vec![([-a[1], -a[0]], [-b[1], -b[0]]), (b, a)], [2.0 * (a[0] - b[1]), 2.0 * (a[1] - b[0])])
|
||||
}
|
||||
None => (vec![([-a[1], -a[0]], a)], [2.0 * a[0], 2.0 * a[1]]),
|
||||
};
|
||||
for sign in [-1.0, 1.0] {
|
||||
let q = sign * (3.0 + f64::from(side) * delta);
|
||||
let continuous = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, &[])]);
|
||||
let marks = circle_output(&continuous, &r, None).unwrap();
|
||||
assert_circle_line_enclosures(&marks, -1.0 + q, &intervals, length);
|
||||
let Mark::Line { a: first, .. } = marks[0] else { panic!("missing first line") };
|
||||
let Mark::Line { b: last, .. } = marks[marks.len() - 1] else { panic!("missing last line") };
|
||||
if side < 0 {
|
||||
// A>4 and B>0: the comparison allowance cannot conceal an
|
||||
// outer endpoint snapped to 4 or a collapsed positive hole gap.
|
||||
assert!(first.x < -2.0 && last.x > 6.0);
|
||||
let Mark::Line { b: left, .. } = marks[0] else { panic!("missing left span") };
|
||||
let Mark::Line { a: right, .. } = marks[1] else { panic!("missing right span") };
|
||||
assert!(left.x < 2.0 && right.x > 2.0);
|
||||
} else if side == 0 {
|
||||
assert_eq!(marks, circle_lines(&[[-4.0, 0.0, q], [0.0, 4.0, q]]));
|
||||
} else {
|
||||
// A<4 and no hole contact: one span crosses the center.
|
||||
assert!(first.x > -2.0 && last.x < 6.0 && first.x < 2.0 && last.x > 2.0);
|
||||
}
|
||||
assert_eq!(circle_output(&continuous, &r, Some(Limits { marks: intervals.len(), ..Limits::default() })).unwrap(), marks);
|
||||
assert!(circle_output(&continuous, &r, Some(Limits { marks: intervals.len() - 1, ..Limits::default() })).is_err());
|
||||
let dotted = pattern(vec![family([2.0, -1.0 + q], 0.0, 32.0, &[0.0, -1.0])]);
|
||||
assert_exact_circle_output(&dotted, &r, &circle_dots(q, dot_positions), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_generation_charges_original_pieces_candidates_roots_and_tokens() {
|
||||
let continuous = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[])]);
|
||||
for (r, pieces) in [(circular_region(false), 1), (circular_region(true), 3)] {
|
||||
let expected = circle_output(&continuous, &r, None).unwrap();
|
||||
assert!(!expected.is_empty());
|
||||
assert_eq!(circle_output(&continuous, &r, Some(Limits { input_pieces: pieces + 1, ..Limits::default() })).unwrap(), expected);
|
||||
for limits in [
|
||||
Limits { input_pieces: pieces, ..Limits::default() },
|
||||
Limits { candidate_lines: 0, ..Limits::default() },
|
||||
// At least k=-3..3 are charged, including exterior candidates.
|
||||
// Wider certified bounds may legitimately visit more than seven.
|
||||
Limits { candidate_lines: 6, ..Limits::default() },
|
||||
Limits { work: 0, ..Limits::default() },
|
||||
Limits { events: 0, ..Limits::default() },
|
||||
] {
|
||||
assert!(circle_output(&continuous, &r, Some(limits)).is_err());
|
||||
}
|
||||
let dotted = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[0.0, -1.0])]);
|
||||
assert!(circle_output(&dotted, &r, Some(Limits { dash_steps: 0, ..Limits::default() })).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_circular_output_still_charges_geometric_and_gap_work() {
|
||||
let r = circular_region(true);
|
||||
let gap = pattern(vec![family([2.0, -1.0], 0.0, 3.0, &[-1.0])]);
|
||||
assert_exact_circle_output(&gap, &r, &[], 0.0);
|
||||
for limits in [
|
||||
Limits { input_pieces: 3, ..Limits::default() },
|
||||
Limits { candidate_lines: 0, ..Limits::default() },
|
||||
Limits { candidate_lines: 6, ..Limits::default() },
|
||||
Limits { work: 0, ..Limits::default() },
|
||||
Limits { events: 0, ..Limits::default() },
|
||||
Limits { dash_steps: 0, ..Limits::default() },
|
||||
] {
|
||||
assert!(circle_output(&gap, &r, Some(limits)).is_err());
|
||||
}
|
||||
let exterior = pattern(vec![family([2.0, 5.0], 0.0, 32.0, &[0.0, -1.0])]);
|
||||
assert_exact_circle_output(&exterior, &r, &[], 0.0);
|
||||
assert!(circle_output(&exterior, &r, Some(Limits { candidate_lines: 0, ..Limits::default() })).is_err());
|
||||
assert!(circle_output(&exterior, &r, Some(Limits { work: 0, ..Limits::default() })).is_err());
|
||||
}
|
||||
|
||||
// Decimal-spacing fixtures use the exact stored 0.1 = N / 2^55. This integer
|
||||
// oracle is independent of the generator's dyadic and enclosure implementation.
|
||||
fn tenth_member(k: i64) -> f64 {
|
||||
let numerator = i128::from(k) * 3_602_879_701_896_397;
|
||||
numerator as f64 / 36_028_797_018_963_968.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_spacing_keeps_every_signed_member_in_a_polygon() {
|
||||
let r = region(-1.0, -0.95, 1.0, 0.95);
|
||||
let before = r.clone();
|
||||
let lines: Vec<_> = (-9..=9).map(|k| [-1.0, tenth_member(k), 1.0, tenth_member(k)]).collect();
|
||||
assert_eq!(lines.len(), 19);
|
||||
for dy in [0.1, -0.1] {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, dy, &[])]);
|
||||
let saved = p.clone();
|
||||
assert_marks(&generate(&p, &r).unwrap(), &lines, &[], 0.0);
|
||||
assert_eq!(p, saved);
|
||||
assert_eq!(r, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_spacing_keeps_dot_phase_and_holes_without_partial_output() {
|
||||
let mut r = region(-0.25, -0.35, 2.25, 0.35);
|
||||
r.holes.push(rectangle(0.75, -0.15, 1.25, 0.15).reversed());
|
||||
let mut dots = Vec::new();
|
||||
for k in -3i64..=3 {
|
||||
let xs: &[f64] = if k == 0 {
|
||||
&[0.0, 2.0]
|
||||
} else if k % 2 == 0 {
|
||||
&[0.0, 1.0, 2.0]
|
||||
} else {
|
||||
&[0.5, 1.5]
|
||||
};
|
||||
dots.extend(xs.iter().map(|&x| [x, tenth_member(k)]));
|
||||
}
|
||||
assert_eq!(dots.len(), 16);
|
||||
for (dx, dy) in [(0.5, 0.1), (-0.5, -0.1)] {
|
||||
let p = pattern(vec![family([0.0, 0.0], dx, dy, &[0.0, -1.0])]);
|
||||
assert_marks(&generate_with_limits(&p, &r, Limits { marks: 16, ..Limits::default() }).unwrap(), &[], &dots, 0.0);
|
||||
assert!(generate_with_limits(&p, &r, Limits { marks: 15, ..Limits::default() }).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_spacing_clips_disks_and_annuli_with_the_same_offset_enclosure() {
|
||||
for hole in [false, true] {
|
||||
let r =
|
||||
Region2 { outer: Loop2::circle(Vec2::ZERO, 1.125), holes: if hole { vec![Loop2::circle(Vec2::ZERO, 0.25).reversed()] } else { vec![] } };
|
||||
let mut lines = Vec::new();
|
||||
for k in -11..=11 {
|
||||
let y = tenth_member(k);
|
||||
let outer = (1.265625 - y * y).sqrt();
|
||||
if hole && (-2..=2).contains(&k) {
|
||||
let inner = (0.0625 - y * y).sqrt();
|
||||
lines.extend([[-outer, y, -inner, y], [inner, y, outer, y]]);
|
||||
} else {
|
||||
lines.push([-outer, y, outer, y]);
|
||||
}
|
||||
}
|
||||
assert_eq!(lines.len(), if hole { 28 } else { 23 });
|
||||
for dy in [0.1, -0.1] {
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, dy, &[])]);
|
||||
assert_marks(&generate(&p, &r).unwrap(), &lines, &[], ROUNDOFF_MM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_spacing_contact_uncertainty_is_not_replaced_by_a_rounded_line() {
|
||||
// 3 * stored(0.1) is strictly between stored(0.3) and its successor.
|
||||
// Its enclosure touches the lower edge: guessing from nearest() would
|
||||
// silently draw a stroke instead of reporting that unresolved contact.
|
||||
let r = region(-1.0, 0.3, 1.0, 0.4);
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 0.1, &[])]);
|
||||
assert!(generate(&p, &r).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_spacing_preserves_cumulative_limits_and_gap_only_semantics() {
|
||||
let r = region(-1.0, -0.95, 1.0, 0.95);
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 0.1, &[])]);
|
||||
assert_eq!(generate_with_limits(&p, &r, Limits { marks: 19, ..Limits::default() }).unwrap().len(), 19);
|
||||
for limits in [
|
||||
Limits { marks: 18, ..Limits::default() },
|
||||
Limits { candidate_lines: 18, ..Limits::default() },
|
||||
Limits { input_pieces: 3, ..Limits::default() },
|
||||
Limits { work: 0, ..Limits::default() },
|
||||
Limits { events: 0, ..Limits::default() },
|
||||
] {
|
||||
assert!(generate_with_limits(&p, &r, limits).is_err());
|
||||
}
|
||||
let gap = pattern(vec![family([0.0, 0.0], 0.5, 0.1, &[-1.0])]);
|
||||
assert!(generate(&gap, &r).unwrap().is_empty());
|
||||
assert!(generate_with_limits(&gap, &r, Limits { dash_steps: 0, ..Limits::default() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_spacing_strokes_keep_their_phase_across_hole_fragments() {
|
||||
let mut r = region(-0.25, -0.35, 2.25, 0.35);
|
||||
r.holes.push(rectangle(0.75, -0.15, 1.25, 0.15).reversed());
|
||||
// Member k starts at k/2 and repeats [1,-1]. The k=0 and k=1
|
||||
// strokes meet the hole; clipping must not restart their two-mm cycle.
|
||||
let endpoints: &[(i64, &[(f64, f64)])] = &[
|
||||
(-3, &[(0.5, 1.5)]),
|
||||
(-2, &[(-0.25, 0.0), (1.0, 2.0)]),
|
||||
(-1, &[(-0.25, 0.5), (1.5, 2.25)]),
|
||||
(0, &[(0.0, 0.75), (2.0, 2.25)]),
|
||||
(1, &[(0.5, 0.75), (1.25, 1.5)]),
|
||||
(2, &[(-0.25, 0.0), (1.0, 2.0)]),
|
||||
(3, &[(-0.25, 0.5), (1.5, 2.25)]),
|
||||
];
|
||||
let lines: Vec<_> = endpoints.iter().flat_map(|&(k, spans)| spans.iter().map(move |&(a, b)| [a, tenth_member(k), b, tenth_member(k)])).collect();
|
||||
assert_eq!(lines.len(), 13);
|
||||
for (dx, dy) in [(0.5, 0.1), (-0.5, -0.1)] {
|
||||
let p = pattern(vec![family([0.0, 0.0], dx, dy, &[1.0, -1.0])]);
|
||||
assert_marks(&generate(&p, &r).unwrap(), &lines, &[], 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_spacing_circle_contact_is_not_invented_by_nearest_rounding() {
|
||||
// The third exact offset is between adjacent floats. Treating nearest()
|
||||
// as the exact offset could guess a miss or tangency at these radii.
|
||||
for radius in [0.3, tenth_member(3)] {
|
||||
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] };
|
||||
let p = pattern(vec![family([0.0, 0.0], 0.0, 0.1, &[])]);
|
||||
assert!(generate(&p, &r).is_err(), "uncertain contact at radius {radius}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Exact stored-binary64 phase arithmetic within a fixed, checked integer envelope.
|
||||
|
||||
use super::Invalid;
|
||||
use super::budget::Budget;
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
const MIN_EXPONENT: i32 = -1074;
|
||||
const MAX_EXPONENT: i32 = 1023;
|
||||
const ENVELOPE: Invalid = Invalid("hatch phase exceeds exact arithmetic envelope");
|
||||
const PARAMETER: Invalid = Invalid("hatch phase cannot form a finite parameter enclosure");
|
||||
|
||||
/// Exact coefficient * 2^exponent. Nonzero coefficients are odd and never MIN.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct Dyadic {
|
||||
coefficient: i128,
|
||||
exponent: i32,
|
||||
}
|
||||
|
||||
/// Tight finite neighbours and one nearest-even conversion of an exact parameter.
|
||||
/// Only Dyadic::param constructs this certificate; callers cannot supply a triple.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Param {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
nearest: f64,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
pub fn lo(self) -> f64 {
|
||||
self.lo
|
||||
}
|
||||
|
||||
pub fn hi(self) -> f64 {
|
||||
self.hi
|
||||
}
|
||||
|
||||
pub fn nearest(self) -> f64 {
|
||||
self.nearest
|
||||
}
|
||||
}
|
||||
|
||||
impl Dyadic {
|
||||
pub const ZERO: Self = Self { coefficient: 0, exponent: 0 };
|
||||
|
||||
pub fn from_parts(coefficient: i128, exponent: i32, budget: &mut Budget) -> Result<Self> {
|
||||
budget.work(2)?;
|
||||
normalize(coefficient, exponent)
|
||||
}
|
||||
|
||||
pub fn from_f64(value: f64, budget: &mut Budget) -> Result<Self> {
|
||||
budget.work(2)?;
|
||||
if !value.is_finite() {
|
||||
return Err(Invalid("nonfinite hatch phase input"));
|
||||
}
|
||||
let bits = value.to_bits();
|
||||
let field = i32::try_from((bits >> 52) & 0x7ff).map_err(|_| ENVELOPE)?;
|
||||
let fraction = bits & ((1u64 << 52) - 1);
|
||||
let (significand, exponent) = if field == 0 { (fraction, MIN_EXPONENT) } else { (fraction | (1u64 << 52), field - 1075) };
|
||||
let magnitude = i128::from(significand);
|
||||
let coefficient = if bits >> 63 == 0 { magnitude } else { magnitude.checked_neg().ok_or(ENVELOPE)? };
|
||||
normalize(coefficient, exponent)
|
||||
}
|
||||
|
||||
pub fn add(self, rhs: Self, budget: &mut Budget) -> Result<Self> {
|
||||
budget.work(4)?;
|
||||
if self.coefficient == 0 {
|
||||
return Ok(rhs);
|
||||
}
|
||||
if rhs.coefficient == 0 {
|
||||
return Ok(self);
|
||||
}
|
||||
let (a, b, exponent) = align(self, rhs)?;
|
||||
normalize(a.checked_add(b).ok_or(ENVELOPE)?, exponent)
|
||||
}
|
||||
|
||||
pub fn sub(self, rhs: Self, budget: &mut Budget) -> Result<Self> {
|
||||
budget.work(4)?;
|
||||
if rhs.coefficient == 0 {
|
||||
return Ok(self);
|
||||
}
|
||||
if self == rhs {
|
||||
return Ok(Self::ZERO);
|
||||
}
|
||||
if self.coefficient == 0 {
|
||||
return normalize(rhs.coefficient.checked_neg().ok_or(ENVELOPE)?, rhs.exponent);
|
||||
}
|
||||
let (a, b, exponent) = align(self, rhs)?;
|
||||
normalize(a.checked_sub(b).ok_or(ENVELOPE)?, exponent)
|
||||
}
|
||||
|
||||
pub fn mul_index(self, index: i64, budget: &mut Budget) -> Result<Self> {
|
||||
budget.work(4)?;
|
||||
if index == 0 || self.coefficient == 0 {
|
||||
return Ok(Self::ZERO);
|
||||
}
|
||||
// Widen before manipulating the sign, including i64::MIN. Extract powers
|
||||
// of two before multiplying so an exact scale need not grow the coefficient.
|
||||
let factor = i128::from(index);
|
||||
let shift = factor.trailing_zeros();
|
||||
let odd = factor.checked_shr(shift).ok_or(ENVELOPE)?;
|
||||
let exponent = self.exponent.checked_add(i32::try_from(shift).map_err(|_| ENVELOPE)?).ok_or(ENVELOPE)?;
|
||||
normalize(self.coefficient.checked_mul(odd).ok_or(ENVELOPE)?, exponent)
|
||||
}
|
||||
|
||||
pub fn floor_ratio(self, positive: Self, budget: &mut Budget) -> Result<i128> {
|
||||
budget.work(4)?;
|
||||
if !positive.is_positive() {
|
||||
return Err(Invalid("hatch phase divisor must be positive"));
|
||||
}
|
||||
if self.coefficient == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let (numerator, denominator, _) = align(self, positive)?;
|
||||
// Positive denominator makes Euclidean division floor toward -infinity,
|
||||
// with 0 <= remainder < denominator, also for negative phase indices.
|
||||
numerator.checked_div_euclid(denominator).ok_or(ENVELOPE)
|
||||
}
|
||||
|
||||
pub fn is_positive(self) -> bool {
|
||||
self.coefficient > 0
|
||||
}
|
||||
|
||||
pub fn param(self, budget: &mut Budget) -> Result<Param> {
|
||||
budget.work(4)?;
|
||||
if self.coefficient == 0 {
|
||||
return Ok(Param { lo: 0.0, hi: 0.0, nearest: 0.0 });
|
||||
}
|
||||
let magnitude = u128::try_from(self.coefficient.checked_abs().ok_or(PARAMETER)?).map_err(|_| PARAMETER)?;
|
||||
let width = u128::BITS - magnitude.leading_zeros();
|
||||
let leading = self.exponent.checked_add(i32::try_from(width).map_err(|_| PARAMETER)?).and_then(|n| n.checked_sub(1)).ok_or(PARAMETER)?;
|
||||
let (lower, upper, rounded) = if leading < -1022 {
|
||||
// Every admitted dyadic is a multiple of the least subnormal. Thus a
|
||||
// subnormal result is exact, never rounded down from a smaller quantum.
|
||||
let shift = u32::try_from(self.exponent.checked_add(1074).ok_or(PARAMETER)?).map_err(|_| PARAMETER)?;
|
||||
let limit = ((1u128 << 52) - 1).checked_shr(shift).ok_or(PARAMETER)?;
|
||||
if magnitude > limit {
|
||||
return Err(PARAMETER);
|
||||
}
|
||||
let fraction = magnitude.checked_shl(shift).ok_or(PARAMETER)?;
|
||||
let value = f64::from_bits(u64::try_from(fraction).map_err(|_| PARAMETER)?);
|
||||
(value, value, value)
|
||||
} else {
|
||||
let (significand, remainder, round_up) = if width > 53 {
|
||||
let shift = width - 53;
|
||||
let unit = 1u128.checked_shl(shift).ok_or(PARAMETER)?;
|
||||
let significand = magnitude.checked_shr(shift).ok_or(PARAMETER)?;
|
||||
let remainder = magnitude & (unit - 1);
|
||||
let half = unit >> 1;
|
||||
(significand, remainder, remainder > half || (remainder == half && significand & 1 != 0))
|
||||
} else {
|
||||
// width + shift is exactly 53, so this shift cannot discard bits.
|
||||
(magnitude.checked_shl(53 - width).ok_or(PARAMETER)?, 0, false)
|
||||
};
|
||||
let lower = normal_value(significand, leading)?;
|
||||
let upper = if remainder == 0 { lower } else { lower.next_up() };
|
||||
// Selecting the adjacent value also handles carry into the next binade.
|
||||
// No intermediate conversion to f64 or floating scale loses the tail.
|
||||
(lower, upper, if round_up { upper } else { lower })
|
||||
};
|
||||
let param =
|
||||
if self.coefficient < 0 { Param { lo: -upper, hi: -lower, nearest: -rounded } } else { Param { lo: lower, hi: upper, nearest: rounded } };
|
||||
if !param.lo.is_finite() || !param.hi.is_finite() || !param.nearest.is_finite() || param.lo > param.nearest || param.nearest > param.hi {
|
||||
return Err(PARAMETER);
|
||||
}
|
||||
Ok(param)
|
||||
}
|
||||
}
|
||||
|
||||
// Public operations charge fixed bounded work up front, including failure. These
|
||||
// helpers have no allocation or iteration and do not charge that work a second time.
|
||||
fn normalize(coefficient: i128, exponent: i32) -> Result<Dyadic> {
|
||||
if coefficient == i128::MIN || !(MIN_EXPONENT..=MAX_EXPONENT).contains(&exponent) {
|
||||
return Err(ENVELOPE);
|
||||
}
|
||||
if coefficient == 0 {
|
||||
return Ok(Dyadic::ZERO);
|
||||
}
|
||||
let shift = coefficient.trailing_zeros();
|
||||
let exponent = exponent.checked_add(i32::try_from(shift).map_err(|_| ENVELOPE)?).ok_or(ENVELOPE)?;
|
||||
if !(MIN_EXPONENT..=MAX_EXPONENT).contains(&exponent) {
|
||||
return Err(ENVELOPE);
|
||||
}
|
||||
Ok(Dyadic { coefficient: coefficient.checked_shr(shift).ok_or(ENVELOPE)?, exponent })
|
||||
}
|
||||
|
||||
fn align(a: Dyadic, b: Dyadic) -> Result<(i128, i128, i32)> {
|
||||
let exponent = a.exponent.min(b.exponent);
|
||||
Ok((aligned_coefficient(a, exponent)?, aligned_coefficient(b, exponent)?, exponent))
|
||||
}
|
||||
|
||||
fn aligned_coefficient(value: Dyadic, exponent: i32) -> Result<i128> {
|
||||
let shift = u32::try_from(value.exponent.checked_sub(exponent).ok_or(ENVELOPE)?).map_err(|_| ENVELOPE)?;
|
||||
let limit = i128::MAX.checked_shr(shift).ok_or(ENVELOPE)?;
|
||||
if value.coefficient.checked_abs().ok_or(ENVELOPE)? > limit {
|
||||
return Err(ENVELOPE);
|
||||
}
|
||||
// checked_shl checks only the count. The magnitude comparison above also
|
||||
// proves that neither signed overflow nor discarded value bits can occur.
|
||||
value.coefficient.checked_shl(shift).ok_or(ENVELOPE)
|
||||
}
|
||||
|
||||
fn normal_value(significand: u128, leading: i32) -> Result<f64> {
|
||||
if !(-1022..=MAX_EXPONENT).contains(&leading) || !((1u128 << 52)..(1u128 << 53)).contains(&significand) {
|
||||
return Err(PARAMETER);
|
||||
}
|
||||
let exponent = u64::try_from(leading.checked_add(1023).ok_or(PARAMETER)?).map_err(|_| PARAMETER)?;
|
||||
let fraction = u64::try_from(significand - (1u128 << 52)).map_err(|_| PARAMETER)?;
|
||||
// The checked exponent uses at most 11 bits; fraction uses at most 52.
|
||||
Ok(f64::from_bits((exponent << 52) | fraction))
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Original integer and binary64 oracles for exact hatch phase arithmetic.
|
||||
|
||||
use super::Invalid;
|
||||
use super::budget::{Budget, Limits};
|
||||
use super::phase::Dyadic;
|
||||
|
||||
fn dyadic(coefficient: i128, exponent: i32) -> Dyadic {
|
||||
Dyadic::from_parts(coefficient, exponent, &mut Budget::default()).unwrap()
|
||||
}
|
||||
|
||||
fn parameter_bits(value: Dyadic) -> (u64, u64, u64) {
|
||||
let parameter = value.param(&mut Budget::default()).unwrap();
|
||||
assert!(parameter.lo().is_finite() && parameter.nearest().is_finite() && parameter.hi().is_finite());
|
||||
assert!(parameter.lo() <= parameter.nearest() && parameter.nearest() <= parameter.hi());
|
||||
(parameter.nearest().to_bits(), parameter.lo().to_bits(), parameter.hi().to_bits())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_preserves_value_and_canonicalizes_signed_zero() {
|
||||
assert_eq!(dyadic(12, -4), dyadic(3, -2));
|
||||
assert_eq!(dyadic(-12, -4), dyadic(-3, -2));
|
||||
for exponent in [-1074, 0, 1023] {
|
||||
assert_eq!(dyadic(0, exponent), Dyadic::ZERO);
|
||||
}
|
||||
for value in [0.0, -0.0] {
|
||||
let zero = Dyadic::from_f64(value, &mut Budget::default()).unwrap();
|
||||
assert_eq!(zero, Dyadic::ZERO);
|
||||
assert_eq!(parameter_bits(zero), (0, 0, 0));
|
||||
}
|
||||
assert!(!Dyadic::ZERO.is_positive());
|
||||
assert!(!dyadic(-1, -1074).is_positive());
|
||||
assert!(dyadic(1, -1074).is_positive());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coefficient_and_exponent_domains_are_checked_before_normalization() {
|
||||
for (coefficient, exponent) in
|
||||
[(i128::MIN, 0), (i128::MIN, -1074), (0, -1075), (2, -1075), (0, 1024), (0, i32::MIN), (0, i32::MAX), (2, 1023), (-2, 1023)]
|
||||
{
|
||||
assert!(Dyadic::from_parts(coefficient, exponent, &mut Budget::default()).is_err());
|
||||
}
|
||||
assert_eq!(dyadic(2, -1074), dyadic(1, -1073));
|
||||
assert!(dyadic(i128::MAX, -1074).is_positive());
|
||||
assert!(!dyadic(-i128::MAX, -1074).is_positive());
|
||||
assert!(dyadic(1, 1023).is_positive());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_binary64_inputs_decode_to_their_exact_stored_values() {
|
||||
// Decimal expectations are the stored binary rationals, not 1/10 or 1/5.
|
||||
for (value, coefficient, exponent) in [
|
||||
(0.1, 3_602_879_701_896_397, -55),
|
||||
(0.2, 3_602_879_701_896_397, -54),
|
||||
(0.30000000000000004, 1_351_079_888_211_149, -52),
|
||||
(f64::from_bits(1), 1, -1074),
|
||||
(f64::MIN_POSITIVE, 1, -1022),
|
||||
(f64::MAX, (1_i128 << 53) - 1, 971),
|
||||
] {
|
||||
assert_eq!(Dyadic::from_f64(value, &mut Budget::default()).unwrap(), dyadic(coefficient, exponent));
|
||||
assert_eq!(Dyadic::from_f64(-value, &mut Budget::default()).unwrap(), dyadic(-coefficient, exponent));
|
||||
}
|
||||
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert!(Dyadic::from_f64(value, &mut Budget::default()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_addition_and_subtraction_keep_the_unrounded_period() {
|
||||
let a = Dyadic::from_f64(0.1, &mut Budget::default()).unwrap();
|
||||
let b = Dyadic::from_f64(0.2, &mut Budget::default()).unwrap();
|
||||
let mut budget = Budget::default();
|
||||
let sum = a.add(b, &mut budget).unwrap();
|
||||
assert_eq!(sum, dyadic(10_808_639_105_689_191, -55));
|
||||
assert_eq!(sum.sub(a, &mut budget).unwrap(), b);
|
||||
assert_eq!(sum.sub(b, &mut budget).unwrap(), a);
|
||||
assert_eq!(sum.sub(sum, &mut budget).unwrap(), Dyadic::ZERO);
|
||||
let rounded = Dyadic::from_f64(0.30000000000000004, &mut budget).unwrap();
|
||||
assert_eq!(rounded.sub(sum, &mut budget).unwrap(), dyadic(1, -55));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_member_and_cycle_products_cancel_as_integers() {
|
||||
let mut budget = Budget::default();
|
||||
let phase = dyadic(99_999, 0).mul_index(1_000_000_000_001, &mut budget).unwrap();
|
||||
assert_eq!(phase, dyadic(99_999_000_000_099_999, 0));
|
||||
let cycle = dyadic(100, 0).mul_index(-999_990_000_001_000, &mut budget).unwrap();
|
||||
let start = phase.add(cycle, &mut budget).unwrap();
|
||||
assert_eq!(start, dyadic(-1, 0));
|
||||
assert_eq!(start.add(dyadic(1, 0), &mut budget).unwrap(), Dyadic::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_multiplication_keeps_full_signed_index_precision() {
|
||||
let mut budget = Budget::default();
|
||||
assert_eq!(dyadic(3, -3).mul_index(-4, &mut budget).unwrap(), dyadic(-3, -1));
|
||||
assert_eq!(dyadic(1, -63).mul_index(i64::MIN, &mut budget).unwrap(), dyadic(-1, 0));
|
||||
assert_eq!(dyadic(1, 0).mul_index(i64::MAX, &mut budget).unwrap(), dyadic(i128::from(i64::MAX), 0));
|
||||
assert_eq!(Dyadic::ZERO.mul_index(i64::MIN, &mut budget).unwrap(), Dyadic::ZERO);
|
||||
assert_eq!(dyadic(i128::MAX, 1023).mul_index(0, &mut budget).unwrap(), Dyadic::ZERO);
|
||||
assert!(dyadic(1, 1023).mul_index(2, &mut budget).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_floor_returns_the_mathematical_quotient_and_remainder() {
|
||||
for (n, e, p, pe, quotient, remainder, re) in
|
||||
[(-9, -3, 3, -3, -3, 0, 0), (-5, -2, 3, -3, -4, 1, -2), (-1, 0, 3, 0, -1, 2, 0), (5, -2, 3, -3, 3, 1, -3), (0, 0, 3, -3, 0, 0, 0)]
|
||||
{
|
||||
let mut budget = Budget::default();
|
||||
let numerator = dyadic(n, e);
|
||||
let denominator = dyadic(p, pe);
|
||||
assert_eq!(numerator.floor_ratio(denominator, &mut budget).unwrap(), i128::from(quotient));
|
||||
let multiple = denominator.mul_index(quotient, &mut budget).unwrap();
|
||||
assert_eq!(numerator.sub(multiple, &mut budget).unwrap(), dyadic(remainder, re));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_floor_distinguishes_values_that_all_round_to_minus_three() {
|
||||
let scale = 1_i128 << 54;
|
||||
for (coefficient, quotient, remainder) in [(-(3 * scale + 1), -4, scale - 1), (-3 * scale, -3, 0), (-(3 * scale - 1), -3, 1)] {
|
||||
let mut budget = Budget::default();
|
||||
let numerator = dyadic(coefficient, -54);
|
||||
assert_eq!(parameter_bits(numerator).0, 0xc008_0000_0000_0000);
|
||||
assert_eq!(numerator.floor_ratio(dyadic(1, 0), &mut budget).unwrap(), i128::from(quotient));
|
||||
assert_eq!(numerator.sub(dyadic(i128::from(quotient), 0), &mut budget).unwrap(), dyadic(remainder, -54));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_floor_is_separate_from_generator_cycle_index_admission() {
|
||||
let bound = 1_i128 << 52;
|
||||
let mut budget = Budget::default();
|
||||
assert_eq!(dyadic(-(3 * bound - 2), 0).floor_ratio(dyadic(3, 0), &mut budget).unwrap(), -bound);
|
||||
assert_eq!(dyadic(-(3 * bound + 1), 0).floor_ratio(dyadic(3, 0), &mut budget).unwrap(), -bound - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_rejects_nonpositive_divisors_even_for_a_zero_numerator() {
|
||||
for numerator in [Dyadic::ZERO, dyadic(-1, 0), dyadic(1, 0)] {
|
||||
for divisor in [Dyadic::ZERO, dyadic(-1, 0), dyadic(-1, -1074)] {
|
||||
assert!(numerator.floor_ratio(divisor, &mut Budget::default()).is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(Dyadic::ZERO.floor_ratio(dyadic(1, -1074), &mut Budget::default()).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_even_ties_and_binade_carries_have_tight_signed_enclosures() {
|
||||
let two53 = 1_i128 << 53;
|
||||
let two54 = 1_i128 << 54;
|
||||
// Expected columns: nearest, lower, upper. The negative rows reverse
|
||||
// neighbour order; carrying to 2 crosses a binary exponent boundary.
|
||||
for (n, expected) in [
|
||||
(two53 + 1, (0x3ff0_0000_0000_0000, 0x3ff0_0000_0000_0000, 0x3ff0_0000_0000_0001)),
|
||||
(two53 + 3, (0x3ff0_0000_0000_0002, 0x3ff0_0000_0000_0001, 0x3ff0_0000_0000_0002)),
|
||||
(-(two53 + 1), (0xbff0_0000_0000_0000, 0xbff0_0000_0000_0001, 0xbff0_0000_0000_0000)),
|
||||
(-(two53 + 3), (0xbff0_0000_0000_0002, 0xbff0_0000_0000_0002, 0xbff0_0000_0000_0001)),
|
||||
(two54 - 1, (0x4000_0000_0000_0000, 0x3fff_ffff_ffff_ffff, 0x4000_0000_0000_0000)),
|
||||
(-(two54 - 1), (0xc000_0000_0000_0000, 0xc000_0000_0000_0000, 0xbfff_ffff_ffff_ffff)),
|
||||
] {
|
||||
assert_eq!(parameter_bits(dyadic(n, -53)), expected, "coefficient {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_on_both_sides_of_a_tie_choose_the_nearest_neighbour() {
|
||||
let two54 = 1_i128 << 54;
|
||||
for (n, expected) in [
|
||||
(two54 + 1, (0x3ff0_0000_0000_0000, 0x3ff0_0000_0000_0000, 0x3ff0_0000_0000_0001)),
|
||||
(two54 + 3, (0x3ff0_0000_0000_0001, 0x3ff0_0000_0000_0000, 0x3ff0_0000_0000_0001)),
|
||||
(-(two54 + 1), (0xbff0_0000_0000_0000, 0xbff0_0000_0000_0001, 0xbff0_0000_0000_0000)),
|
||||
(-(two54 + 3), (0xbff0_0000_0000_0001, 0xbff0_0000_0000_0001, 0xbff0_0000_0000_0000)),
|
||||
] {
|
||||
assert_eq!(parameter_bits(dyadic(n, -54)), expected, "coefficient {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subnormal_and_normal_boundary_values_are_exact_points() {
|
||||
for (n, e, positive, negative) in [
|
||||
(1, -1074, 0x0000_0000_0000_0001, 0x8000_0000_0000_0001),
|
||||
((1_i128 << 52) - 1, -1074, 0x000f_ffff_ffff_ffff, 0x800f_ffff_ffff_ffff),
|
||||
(1, -1022, 0x0010_0000_0000_0000, 0x8010_0000_0000_0000),
|
||||
((1_i128 << 52) + 1, -1074, 0x0010_0000_0000_0001, 0x8010_0000_0000_0001),
|
||||
((1_i128 << 53) - 1, 971, 0x7fef_ffff_ffff_ffff, 0xffef_ffff_ffff_ffff),
|
||||
] {
|
||||
assert_eq!(parameter_bits(dyadic(n, e)), (positive, positive, positive));
|
||||
assert_eq!(parameter_bits(dyadic(-n, e)), (negative, negative, negative));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_output_requires_both_bounds_even_when_nearest_would_be_finite() {
|
||||
// MAX=(2^55-4)*2^969. The first value is one quarter of an ulp above
|
||||
// MAX and rounds to MAX, but its required upper neighbour is infinity.
|
||||
// The second is the overflow tie; the third exceeds the finite binade.
|
||||
for (n, e) in [((1_i128 << 55) - 3, 969), ((1_i128 << 54) - 1, 970), (3, 1023)] {
|
||||
assert!(dyadic(n, e).param(&mut Budget::default()).is_err());
|
||||
assert!(dyadic(-n, e).param(&mut Budget::default()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_capacity_accepts_126_bits_and_rejects_127_bits() {
|
||||
let mut budget = Budget::default();
|
||||
let one = dyadic(1, 0);
|
||||
let small = dyadic(1, -126);
|
||||
let sum = one.add(small, &mut budget).unwrap();
|
||||
assert_eq!(sum, dyadic((1_i128 << 126) + 1, -126));
|
||||
assert_eq!(sum.sub(one, &mut budget).unwrap(), small);
|
||||
assert!(one.add(dyadic(1, -127), &mut budget).is_err());
|
||||
assert!(one.add(dyadic(1, -1074), &mut budget).is_err());
|
||||
// The arithmetic envelope is bounded even if the final floor is small.
|
||||
assert!(dyadic(1, -1074).floor_ratio(one, &mut budget).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_shift_counts_do_not_allow_value_bits_to_be_discarded() {
|
||||
let mut budget = Budget::default();
|
||||
// Shifting by 126 is a legal i128 shift count, but 3*2^126 exceeds MAX.
|
||||
assert!(dyadic(3, 0).add(dyadic(1, -126), &mut budget).is_err());
|
||||
assert!(dyadic(-3, 0).sub(dyadic(1, -126), &mut budget).is_err());
|
||||
assert!(dyadic(3, 0).floor_ratio(dyadic(1, -126), &mut budget).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_sums_and_raw_minimum_are_not_rescued_by_normalization() {
|
||||
let mut budget = Budget::default();
|
||||
assert!(dyadic(i128::MAX, -126).add(dyadic(1, -125), &mut budget).is_err());
|
||||
assert!(dyadic(-i128::MAX, -126).sub(dyadic(1, -125), &mut budget).is_err());
|
||||
assert!(dyadic(-i128::MAX, 0).sub(dyadic(1, 0), &mut budget).is_err());
|
||||
assert!(dyadic(i128::MAX, 0).add(dyadic(1, 0), &mut budget).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiplication_refuses_overflow_but_accepts_a_fitting_product() {
|
||||
let mut budget = Budget::default();
|
||||
assert!(dyadic((1_i128 << 126) + 1, -126).mul_index(3, &mut budget).is_err());
|
||||
assert!(dyadic(-((1_i128 << 126) + 1), -126).mul_index(3, &mut budget).is_err());
|
||||
let coefficient = (1_i128 << 125) - 1;
|
||||
assert_eq!(dyadic(coefficient, -125).mul_index(3, &mut budget).unwrap(), dyadic(3 * coefficient, -125));
|
||||
}
|
||||
|
||||
fn assert_work_cost(cost: usize, operation: impl Fn(&mut Budget) -> Result<(), Invalid>) {
|
||||
let mut short = Budget::new(Limits { work: cost - 1, ..Limits::default() });
|
||||
assert_eq!(operation(&mut short), Err(Invalid("geometry work budget exhausted")));
|
||||
let mut exact = Budget::new(Limits { work: cost, ..Limits::default() });
|
||||
assert_eq!(operation(&mut exact), Ok(()));
|
||||
assert_eq!(exact.work(1), Err(Invalid("geometry work budget exhausted")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_numeric_operation_obeys_its_fixed_work_charge() {
|
||||
let one = dyadic(1, 0);
|
||||
assert_work_cost(2, |budget| Dyadic::from_parts(1, 0, budget).map(|_| ()));
|
||||
assert_work_cost(2, |budget| Dyadic::from_f64(0.1, budget).map(|_| ()));
|
||||
assert_work_cost(4, |budget| one.add(one, budget).map(|_| ()));
|
||||
assert_work_cost(4, |budget| one.sub(one, budget).map(|_| ()));
|
||||
assert_work_cost(4, |budget| one.mul_index(0, budget).map(|_| ()));
|
||||
assert_work_cost(4, |budget| Dyadic::ZERO.floor_ratio(one, budget).map(|_| ()));
|
||||
assert_work_cost(4, |budget| Dyadic::ZERO.param(budget).map(|_| ()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_numeric_operations_still_consume_the_shared_work_budget() {
|
||||
let mut budget = Budget::new(Limits { work: 16, ..Limits::default() });
|
||||
let envelope = Invalid("hatch phase exceeds exact arithmetic envelope");
|
||||
assert_eq!(Dyadic::from_parts(i128::MIN, 0, &mut budget), Err(envelope.clone()));
|
||||
assert_eq!(Dyadic::from_f64(f64::INFINITY, &mut budget), Err(Invalid("nonfinite hatch phase input")));
|
||||
assert_eq!(dyadic(1, 0).add(dyadic(1, -127), &mut budget), Err(envelope));
|
||||
assert_eq!(Dyadic::ZERO.floor_ratio(Dyadic::ZERO, &mut budget), Err(Invalid("hatch phase divisor must be positive")));
|
||||
assert_eq!(dyadic(3, 1023).param(&mut budget).map(|_| ()), Err(Invalid("hatch phase cannot form a finite parameter enclosure")));
|
||||
assert_eq!(budget.work(1), Err(Invalid("geometry work budget exhausted")));
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use std::f64::consts::{FRAC_PI_4, TAU};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Family, Pattern};
|
||||
use crate::Vec2;
|
||||
|
||||
fn continuous() -> Family {
|
||||
Family { angle: 0.0, origin: Vec2::ZERO, delta_x: 0.0, delta_y: 5.0, dashes: vec![] }
|
||||
}
|
||||
|
||||
fn pattern(family: Family) -> Pattern {
|
||||
Pattern { name: "R45-a_$9".into(), description: " Original pattern ".into(), families: vec![family] }
|
||||
}
|
||||
|
||||
type SetLength = fn(&mut Family, f64);
|
||||
|
||||
fn length_fields() -> [(&'static str, SetLength); 5] {
|
||||
[
|
||||
("origin.x", |f, v| f.origin.x = v),
|
||||
("origin.y", |f, v| f.origin.y = v),
|
||||
("delta_x", |f, v| f.delta_x = v),
|
||||
("delta_y", |f, v| f.delta_y = v),
|
||||
("dash", |f, v| f.dashes = vec![2.0, v]),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_builtins_have_unit_paper_spacing_and_perpendicular_cross_families() {
|
||||
let diagonal = Family { angle: FRAC_PI_4, delta_y: 1.0, ..continuous() };
|
||||
let cross = Family { angle: 3.0 * FRAC_PI_4, delta_y: 1.0, ..continuous() };
|
||||
let lines = Pattern::lines_45();
|
||||
let crossed = Pattern::cross_hatch();
|
||||
assert_eq!(lines.name, "LINES_45");
|
||||
assert_eq!(crossed.name, "CROSS_HATCH");
|
||||
assert_eq!(lines.families, vec![diagonal.clone()]);
|
||||
assert_eq!(crossed.families, vec![diagonal, cross]);
|
||||
assert!(lines.validate().is_ok());
|
||||
assert!(crossed.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_family_has_an_explicit_json_shape_without_normalization() {
|
||||
// An original record, independent of the serializer: origin is in the global
|
||||
// pattern frame, while both deltas retain their signed family-frame meaning.
|
||||
let value = json!({
|
||||
"name": "R45-a_$9",
|
||||
"description": " Original pattern ",
|
||||
"families": [{
|
||||
"angle": 0.25,
|
||||
"origin": [12.5, -7.25],
|
||||
"delta_x": -2.0,
|
||||
"delta_y": -5.0,
|
||||
"dashes": [-3.0, 0.0, 1.5]
|
||||
}]
|
||||
});
|
||||
let expected = pattern(Family { angle: 0.25, origin: Vec2::new(12.5, -7.25), delta_x: -2.0, delta_y: -5.0, dashes: vec![-3.0, 0.0, 1.5] });
|
||||
let restored: Pattern = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(restored, expected);
|
||||
assert!(restored.validate().is_ok());
|
||||
assert_eq!(restored, expected);
|
||||
assert_eq!(serde_json::to_value(&restored).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_keeps_unwrapped_angles_signed_deltas_and_dot_entries() {
|
||||
let family =
|
||||
Family { angle: 2.0 * TAU + FRAC_PI_4, origin: Vec2::new(4.0, -9.0), delta_x: -2.0, delta_y: -5.0, dashes: vec![0.0, -3.0, -0.0, 2.0] };
|
||||
let before = family.clone();
|
||||
assert!(family.validate().is_ok());
|
||||
assert_eq!(family, before);
|
||||
let bits: Vec<u64> = family.dashes.iter().map(|v| v.to_bits()).collect();
|
||||
assert_eq!(bits, vec![0.0_f64.to_bits(), (-3.0_f64).to_bits(), (-0.0_f64).to_bits(), 2.0_f64.to_bits()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_only_sequences_are_valid_records_even_though_they_draw_no_ink() {
|
||||
// D1 admits the record; no generator or clipping result is asserted here.
|
||||
for dashes in [vec![-3.0], vec![-1.0, -2.0, -3.0], vec![-100_000.0; 6]] {
|
||||
let family = Family { dashes, ..continuous() };
|
||||
let before = family.clone();
|
||||
assert!(family.validate().is_ok());
|
||||
assert!(pattern(family.clone()).validate().is_ok());
|
||||
assert_eq!(family, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dashes_are_continuous_but_nonempty_zero_periods_are_invalid() {
|
||||
assert!(continuous().validate().is_ok());
|
||||
for dashes in [vec![0.0], vec![-0.0], vec![0.0, -0.0], vec![0.0; 6]] {
|
||||
let family = Family { dashes, ..continuous() };
|
||||
assert!(family.validate().is_err());
|
||||
}
|
||||
// A signed sum of zero is not a zero period: 2 mm ink, then 2 mm gap.
|
||||
assert!(Family { dashes: vec![2.0, -2.0], ..continuous() }.validate().is_ok());
|
||||
assert!(Family { dashes: vec![0.0, -2.0], ..continuous() }.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_perpendicular_spacing_is_invalid_but_subnormal_spacing_has_no_model_cutoff() {
|
||||
for delta_y in [0.0, -0.0] {
|
||||
assert!(Family { delta_y, ..continuous() }.validate().is_err());
|
||||
}
|
||||
let smallest = f64::from_bits(1);
|
||||
for delta_y in [smallest, -smallest] {
|
||||
let family = Family { delta_y, dashes: vec![0.0, -smallest], ..continuous() };
|
||||
assert!(family.validate().is_ok());
|
||||
assert_eq!(family.delta_y.to_bits(), delta_y.to_bits());
|
||||
}
|
||||
// Eventual generation must cap the work independently of record admission.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_stored_length_accepts_both_inclusive_limits_and_refuses_the_next_float() {
|
||||
// Literal policy bounds keep this oracle independent of implementation constants.
|
||||
let outside = f64::from_bits(100_000.0_f64.to_bits() + 1);
|
||||
for (field, set) in length_fields() {
|
||||
for value in [-100_000.0, 100_000.0] {
|
||||
let mut family = continuous();
|
||||
set(&mut family, value);
|
||||
let before = family.clone();
|
||||
assert!(family.validate().is_ok(), "{field}: {value}");
|
||||
assert_eq!(family, before);
|
||||
}
|
||||
for value in [-outside, outside] {
|
||||
let mut family = continuous();
|
||||
set(&mut family, value);
|
||||
let before = family.clone();
|
||||
assert!(family.validate().is_err(), "{field}: {value}");
|
||||
assert_eq!(family, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angle_bounds_are_inclusive_and_do_not_wrap_an_out_of_range_angle() {
|
||||
for angle in [-1_000_000.0, 1_000_000.0] {
|
||||
let family = Family { angle, ..continuous() };
|
||||
assert!(family.validate().is_ok());
|
||||
assert_eq!(family.angle, angle);
|
||||
}
|
||||
let outside = f64::from_bits(1_000_000.0_f64.to_bits() + 1);
|
||||
for angle in [-outside, outside] {
|
||||
let family = Family { angle, ..continuous() };
|
||||
assert!(family.validate().is_err());
|
||||
assert_eq!(family.angle, angle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_numeric_field_refuses_nan_and_both_infinities() {
|
||||
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert!(Family { angle: value, ..continuous() }.validate().is_err());
|
||||
for (field, set) in length_fields() {
|
||||
let mut family = continuous();
|
||||
set(&mut family, value);
|
||||
assert!(family.validate().is_err(), "{field}: {value}");
|
||||
assert!(pattern(family).validate().is_err(), "nested {field}: {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn family_and_dash_counts_are_bounded_without_silent_truncation() {
|
||||
let mut p = pattern(continuous());
|
||||
p.families.clear();
|
||||
assert!(p.validate().is_err());
|
||||
p.families = vec![continuous(); 64];
|
||||
assert!(p.validate().is_ok());
|
||||
p.families.push(continuous());
|
||||
let before = p.clone();
|
||||
assert!(p.validate().is_err());
|
||||
assert_eq!(p, before);
|
||||
|
||||
let mut family = Family { dashes: vec![1.0, -2.0, 0.0, 3.0, -4.0, 0.0], ..continuous() };
|
||||
assert!(family.validate().is_ok());
|
||||
family.dashes.push(1.0);
|
||||
let before = family.clone();
|
||||
assert!(family.validate().is_err());
|
||||
assert_eq!(family, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_checks_the_last_family_and_the_last_dash() {
|
||||
let mut p = pattern(continuous());
|
||||
p.families = vec![continuous(); 64];
|
||||
p.families.last_mut().unwrap().delta_y = 0.0;
|
||||
let before = p.clone();
|
||||
assert!(p.validate().is_err());
|
||||
assert_eq!(p, before);
|
||||
|
||||
let family = Family { dashes: vec![1.0, -2.0, 0.0, 3.0, -4.0, f64::INFINITY], ..continuous() };
|
||||
assert!(family.validate().is_err());
|
||||
assert!(pattern(family).validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_use_the_bounded_ascii_alphanumeric_first_identifier_grammar() {
|
||||
for name in ["A".to_owned(), "9_a-Z$0".to_owned(), "x".repeat(31)] {
|
||||
let p = Pattern { name: name.clone(), ..pattern(continuous()) };
|
||||
assert!(p.validate().is_ok(), "{name}");
|
||||
assert_eq!(p.name, name);
|
||||
}
|
||||
for name in ["", "_A", "-A", "$A", " A", "A ", "A B", "A,B", "A/B", "A.B", "Aé", "A", "A\n", "A\0"] {
|
||||
assert!(Pattern { name: name.into(), ..pattern(continuous()) }.validate().is_err(), "{name:?}");
|
||||
}
|
||||
let p = Pattern { name: "x".repeat(32), ..pattern(continuous()) };
|
||||
assert!(p.validate().is_err());
|
||||
assert_eq!(p.name.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_limit_counts_unicode_scalars_not_utf8_bytes_and_keeps_spaces() {
|
||||
for description in [String::new(), " Trama α ".into(), "a".repeat(80), "é".repeat(80), "🟧".repeat(80), "e\u{301}".repeat(40)] {
|
||||
let p = Pattern { description: description.clone(), ..pattern(continuous()) };
|
||||
assert!(p.validate().is_ok());
|
||||
assert_eq!(p.description, description);
|
||||
}
|
||||
for description in ["a".repeat(81), "é".repeat(81), "🟧".repeat(81), "e\u{301}".repeat(41)] {
|
||||
let p = Pattern { description: description.clone(), ..pattern(continuous()) };
|
||||
assert!(p.validate().is_err());
|
||||
assert_eq!(p.description, description);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptions_refuse_ascii_and_unicode_control_characters() {
|
||||
for control in ['\0', '\t', '\n', '\r', '\u{7f}', '\u{85}', '\u{9f}'] {
|
||||
let p = Pattern { description: format!("before{control}after"), ..pattern(continuous()) };
|
||||
let before = p.clone();
|
||||
assert!(p.validate().is_err(), "{control:?}");
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_refused_at_both_pattern_and_family_boundaries() {
|
||||
let family = json!({"angle": 0.0, "origin": [0.0, 0.0], "delta_x": 0.0, "delta_y": 5.0, "dashes": []});
|
||||
let p = json!({"name": "A", "description": "", "families": [family.clone()]});
|
||||
assert!(serde_json::from_value::<Pattern>(p.clone()).is_ok());
|
||||
let mut extra_pattern = p.clone();
|
||||
extra_pattern["view_scale"] = json!(2.0);
|
||||
assert!(serde_json::from_value::<Pattern>(extra_pattern).is_err());
|
||||
let mut extra_family = family;
|
||||
extra_family["delta_z"] = json!(1.0);
|
||||
assert!(serde_json::from_value::<Family>(extra_family.clone()).is_err());
|
||||
let mut nested = p;
|
||||
nested["families"] = json!([extra_family]);
|
||||
assert!(serde_json::from_value::<Pattern>(nested).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoding_is_not_numeric_validation_and_failed_validation_preserves_the_record() {
|
||||
let value = json!({
|
||||
"name": "A", "description": "Unchanged", "families": [{
|
||||
"angle": 0.0, "origin": [0.0, 0.0], "delta_x": 2.0, "delta_y": 0.0, "dashes": [1.0, -1.0]
|
||||
}]
|
||||
});
|
||||
let restored: Pattern = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(restored.validate().is_err());
|
||||
assert_eq!(serde_json::to_value(&restored).unwrap(), value);
|
||||
|
||||
let mut edited = pattern(continuous());
|
||||
assert!(edited.validate().is_ok());
|
||||
edited.families.last_mut().unwrap().delta_y = 0.0;
|
||||
assert!(edited.validate().is_err());
|
||||
}
|
||||
@@ -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<f64>,
|
||||
points: Vec<[f64; 4]>,
|
||||
pieces: Vec<([f64; 2], Vec<[f64; 4]>)>,
|
||||
},
|
||||
Polyline(Vec<Vec3>),
|
||||
}
|
||||
|
||||
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<f64> {
|
||||
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<C3, HlrError> {
|
||||
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<HPoint> = 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<f64> = 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<f64> = 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<f64> {
|
||||
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::<f64>() / 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<f64> {
|
||||
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<f64> {
|
||||
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<HPoint> = 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<f64> = seg.iter().map(|q| rel(q).dot(u)).collect();
|
||||
let y: Vec<f64> = 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::<Vec<f64>>()
|
||||
};
|
||||
let n: Vec<f64> = 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<Vec<Vec3>> {
|
||||
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<HPoint> = 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<Vec3> = 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,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Sil>) {
|
||||
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<f64> = (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<bool> = ts
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let st = state(*t, &mut hint);
|
||||
hints.push(hint);
|
||||
st
|
||||
})
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<f64> = 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<f64> {
|
||||
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<f64> = (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<usize> {
|
||||
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<usize, Vec<usize>> = 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<usize> = 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<usize> = 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<Vec3> = 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<f64>; 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<Index>,
|
||||
/// `chords` on a grid, for boundaries with many of them.
|
||||
grid: Option<ChordGrid>,
|
||||
}
|
||||
|
||||
/// 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<ChordGrid> {
|
||||
// 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<bool> {
|
||||
// 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<f64>,
|
||||
buckets: Vec<Vec<u32>>,
|
||||
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<f64>; 2], r: [f64; 2]) -> Index {
|
||||
let shifts = |k: usize| -> Vec<f64> {
|
||||
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<f64> =
|
||||
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>) -> 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<usize> = (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<f64>; 2]) -> Vec<(f64, f64)> {
|
||||
let ks = |p: Option<f64>| 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<bool> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,21 +15,28 @@ 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 hatch;
|
||||
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 +49,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};
|
||||
|
||||
|
||||
@@ -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<Group>,
|
||||
}
|
||||
|
||||
/// 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<Item>,
|
||||
}
|
||||
|
||||
/// 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<Seg2>, 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<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
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<Item = Vec2> {
|
||||
// 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<Prepared<'_>> {
|
||||
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;
|
||||
@@ -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<Seg2>, closed: bool, width_mm: f64) -> Item {
|
||||
Item::Stroke { path, closed, width_mm }
|
||||
}
|
||||
|
||||
fn fill(outer: Loop2, holes: Vec<Loop2>) -> 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<Item>) -> Scene {
|
||||
Scene { size_mm: p(420.0, 297.0), groups: vec![Group { object: 1, kind: ObjectKind::DrawingBorder, items }] }
|
||||
}
|
||||
|
||||
fn open(path: Vec<Seg2>) -> Scene {
|
||||
sheet(vec![stroke(path, false, 0.35)])
|
||||
}
|
||||
|
||||
fn closed(path: Vec<Seg2>) -> 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::<ObjectKind>(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<Seg2> { (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<Vec2> = (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::<Scene>(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::<Scene>(&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();
|
||||
}
|
||||
@@ -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<Similarity2> {
|
||||
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<Seg2> {
|
||||
@@ -76,6 +112,62 @@ pub fn bezier(pts: &[Vec2]) -> Option<Seg2> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Item = f64>, 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<Vec2>, Vec<Vec2>) {
|
||||
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::<f64>()
|
||||
}
|
||||
/// The outer loop, then the holes.
|
||||
pub fn loops(&self) -> impl Iterator<Item = &Loop2> + 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();
|
||||
|
||||
+20
-43
@@ -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<Item = f64>) -> 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),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -783,3 +783,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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Cell>,
|
||||
}
|
||||
|
||||
/// 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<Label>,
|
||||
pub kind: CellKind,
|
||||
}
|
||||
|
||||
/// A cell's label, such as "Created by": one line at its top left.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Label {
|
||||
pub text: String,
|
||||
pub cap_mm: f64,
|
||||
}
|
||||
|
||||
/// What a cell's value is. Externally tagged (`{"text": {...}}`, `"projection_symbol"`), as serde
|
||||
/// ignores extra fields on an internally tagged unit variant.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum CellKind {
|
||||
Text(TextCell),
|
||||
/// The projection symbol, drawn from the drawing's projection (`drawing.projection`), which the
|
||||
/// views read too: never fixed in the block.
|
||||
ProjectionSymbol,
|
||||
}
|
||||
|
||||
/// A text value: its content, its cap height, where its lines sit across the value box (left when
|
||||
/// not given) and what it should fit (nothing when not given).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TextCell {
|
||||
pub content: Content,
|
||||
pub cap_mm: f64,
|
||||
#[serde(default)]
|
||||
pub align: HAlign,
|
||||
#[serde(default)]
|
||||
pub fit: Fit,
|
||||
}
|
||||
|
||||
/// A block placed on one sheet: what a writer draws, before the values are set.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TitleBlockLayout {
|
||||
pub outline: Aabb2,
|
||||
pub outline_line_mm: f64,
|
||||
pub cell_line_mm: f64,
|
||||
/// One for each of the definition's cells, in its order.
|
||||
pub cells: Vec<CellLayout>,
|
||||
}
|
||||
|
||||
/// One cell placed on a sheet.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CellLayout {
|
||||
pub rect: Aabb2,
|
||||
/// Where the label's baseline starts, when the cell has one.
|
||||
pub label_at: Option<Vec2>,
|
||||
pub value_box: Aabb2,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("title block: {0}")]
|
||||
pub struct Invalid(pub &'static str);
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
fn require(condition: bool, message: &'static str) -> Result<()> {
|
||||
if condition { Ok(()) } else { Err(Invalid(message)) }
|
||||
}
|
||||
|
||||
/// A lowercase letter, then lowercase letters, digits and underscores: at most 64 in all.
|
||||
fn is_snake_case(name: &str) -> bool {
|
||||
let mut bytes = name.bytes();
|
||||
name.len() <= MAX_NAME_BYTES
|
||||
&& bytes.next().is_some_and(|b| b.is_ascii_lowercase())
|
||||
&& bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
}
|
||||
|
||||
/// Whether two rectangles share more than an edge.
|
||||
fn overlap(a: Aabb2, b: Aabb2) -> bool {
|
||||
a.min.x < b.max.x && b.min.x < a.max.x && a.min.y < b.max.y && b.min.y < a.max.y
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
/// The cell's rectangle with the block's lower-right corner at `corner`.
|
||||
fn rect(&self, corner: Vec2) -> Aabb2 {
|
||||
let [x0, y0, x1, y1] = self.rect_mm;
|
||||
Aabb2 { min: corner + Vec2::new(x0, y0), max: corner + Vec2::new(x1, y1) }
|
||||
}
|
||||
|
||||
/// The box its value goes in, in `inside`, the cell inside its lines.
|
||||
fn value_box(&self, inside: Aabb2) -> Aabb2 {
|
||||
cell::value_box(inside, self.label.as_ref().map(|label| label.cap_mm))
|
||||
}
|
||||
}
|
||||
|
||||
impl TitleBlock {
|
||||
/// `cell`'s rectangle `rect` inside its lines: each side moved in by half the line drawn on it,
|
||||
/// the outline's on the block's edges and a cell line's inside the block.
|
||||
fn inside_lines(&self, cell: &Cell, rect: Aabb2) -> Aabb2 {
|
||||
let [x0, y0, x1, y1] = cell.rect_mm;
|
||||
let half = |on_outline: bool| if on_outline { self.outline_line_mm } else { self.cell_line_mm } / 2.0;
|
||||
let lower = Vec2::new(half(x0 == -self.size_mm.x), half(y0 == 0.0));
|
||||
let upper = Vec2::new(half(x1 == 0.0), half(y1 == self.size_mm.y));
|
||||
Aabb2 { min: rect.min + lower, max: rect.max - upper }
|
||||
}
|
||||
|
||||
/// Each cell with the block's lower-right corner at `corner`, inside its lines and inside
|
||||
/// `clear` too when given (the drawing border's line's inner edge), checked by the cell text
|
||||
/// rule: room for a value below its label, and for one line of a text value.
|
||||
fn place_cells(&self, corner: Vec2, clear: Option<Aabb2>) -> Result<Vec<CellLayout>> {
|
||||
let place = |cell: &Cell| {
|
||||
let rect = cell.rect(corner);
|
||||
let inside = self.inside_lines(cell, rect);
|
||||
let inside = clear.map_or(inside, |clear| inside.intersection(&clear));
|
||||
let value_box = cell.value_box(inside);
|
||||
let room = value_box.size();
|
||||
require(room.x > 0.0 && room.y > 0.0, "a cell has room for its value below its label")?;
|
||||
if let CellKind::Text(text) = &cell.kind {
|
||||
let one_line = cell::place_lines(value_box, 1, text.cap_mm, text.cap_mm, text.align, false);
|
||||
require(matches!(one_line, Ok((_, None))), "a value's cap height is above 0 and one line of it fits its value box")?;
|
||||
}
|
||||
let label_at = cell.label.as_ref().map(|label| cell::label_at(inside, label.cap_mm));
|
||||
Ok(CellLayout { rect, label_at, value_box })
|
||||
};
|
||||
self.cells.iter().map(place).collect()
|
||||
}
|
||||
|
||||
/// Checks a definition on its own, counting its cells first: call it when one is read. Its
|
||||
/// cells' room is checked with the corner at the origin and only the outline on its edges;
|
||||
/// [`Self::layout`] calls it too, and checks their room again where the block sits.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require(self.cells.len() <= MAX_CELLS, "a title block has at most 64 cells")?;
|
||||
let Vec2 { x: width, y: height } = self.size_mm;
|
||||
let sides = MIN_BLOCK_SIDE_MM..=MAX_BLOCK_SIDE_MM;
|
||||
require(sides.contains(&width) && sides.contains(&height), "each side of a title block is 1 to 1000 mm")?;
|
||||
let lines = [self.outline_line_mm, self.cell_line_mm];
|
||||
require(lines.into_iter().all(|line| line > 0.0 && line <= MAX_LINE_MM), "a title block's lines are above 0 and at most 10 mm wide")?;
|
||||
for (i, cell) in self.cells.iter().enumerate() {
|
||||
let earlier = || self.cells.iter().take(i);
|
||||
require(is_snake_case(&cell.name), "a cell's name is snake case and at most 64 characters")?;
|
||||
require(earlier().all(|other| other.name != cell.name), "no two cells have the same name")?;
|
||||
let [x0, y0, x1, y1] = cell.rect_mm;
|
||||
require(-width <= x0 && x0 < x1 && x1 <= 0.0 && 0.0 <= y0 && y0 < y1 && y1 <= height, "a cell lies inside its block")?;
|
||||
let rect = cell.rect(Vec2::ZERO);
|
||||
require(earlier().all(|other| !overlap(other.rect(Vec2::ZERO), rect)), "no two cells overlap")?;
|
||||
if let Some(label) = &cell.label {
|
||||
require(label.cap_mm.is_finite() && label.cap_mm > 0.0, "a label's cap height is finite and above 0")?;
|
||||
cell::check_label(&label.text).map_err(|cell::Invalid(message)| Invalid(message))?;
|
||||
}
|
||||
if let CellKind::Text(text) = &cell.kind {
|
||||
text.content.validate().map_err(|cell::Invalid(message)| Invalid(message))?;
|
||||
}
|
||||
}
|
||||
self.place_cells(Vec2::ZERO, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The block on the lower-right corner of `sheet`'s drawing border, which must hold it, each
|
||||
/// cell with room inside the border's line too.
|
||||
pub fn layout(&self, sheet: &SheetLayout) -> Result<TitleBlockLayout> {
|
||||
self.validate()?;
|
||||
let border = sheet.drawing_border;
|
||||
let (size, room) = (self.size_mm, border.size());
|
||||
let fits = border.min.is_finite() && border.max.is_finite() && size.x <= room.x && size.y <= room.y;
|
||||
require(fits, "a title block fits inside the drawing border")?;
|
||||
let corner = Vec2::new(border.max.x, border.min.y);
|
||||
// Checked again here: the border's line, centred on it, runs along the block's right and
|
||||
// bottom edges (and any other it reaches), and rounding at the real corner may take the
|
||||
// last of a cell's room.
|
||||
let cells = self.place_cells(corner, Some(border.inflated(-sheet.drawing_border_line_mm / 2.0)))?;
|
||||
Ok(TitleBlockLayout {
|
||||
outline: Aabb2 { min: corner - Vec2::new(size.x, 0.0), max: corner + Vec2::new(0.0, size.y) },
|
||||
outline_line_mm: self.outline_line_mm,
|
||||
cell_line_mm: self.cell_line_mm,
|
||||
cells,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "title_block_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,545 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::bspline::tests::Lcg;
|
||||
use crate::cell::{DESCENDER_OF_CAP, Format, PADDING_MM, Part, Source, Warning, place_lines};
|
||||
use crate::drawing_sheet::{DrawingBorderProfile, Orientation, PaperSize, Sheet, SheetSize};
|
||||
|
||||
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Aabb2 {
|
||||
Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }
|
||||
}
|
||||
|
||||
fn field(source: Source, key: &str) -> Part {
|
||||
Part::Field { source, key: key.to_string(), format: Format::default() }
|
||||
}
|
||||
|
||||
fn text(cap_mm: f64, align: HAlign, fit: Fit, parts: Vec<Part>) -> CellKind {
|
||||
CellKind::Text(TextCell { content: Content(parts), cap_mm, align, fit })
|
||||
}
|
||||
|
||||
/// The S2 plan's 180 x 54 mm default, as test data: twelve cells under 2.5 mm labels.
|
||||
fn block() -> TitleBlock {
|
||||
use HAlign::{Centre, Left};
|
||||
use Source::{Component, Drawing, Revision};
|
||||
let count = |n| Fit { max_chars: Some(n), ..Fit::default() };
|
||||
let (whole, wrap) = (Fit::default(), Fit { wrap: true, ..Fit::default() });
|
||||
let title = Fit { max_chars: Some(25), wide_max_chars: Some(30), wrap: false };
|
||||
let sheet = vec![field(Source::Sheet, "number"), Part::Text("/".into()), field(Source::Sheet, "count")];
|
||||
let cells = [
|
||||
("title", [-180.0, 0.0, -68.0, 14.0], "Title", text(5.0, Left, title, vec![field(Component, "name")])),
|
||||
("identification_number", [-68.0, 0.0, 0.0, 14.0], "Identification number", text(5.0, Left, count(16), vec![field(Drawing, "number")])),
|
||||
("created_by", [-180.0, 14.0, -121.0, 24.0], "Created by", text(3.5, Left, count(20), vec![field(Revision, "designer")])),
|
||||
("approved_by", [-121.0, 14.0, -62.0, 24.0], "Approved by", text(3.5, Left, count(20), vec![field(Revision, "checker")])),
|
||||
("date_of_issue", [-62.0, 14.0, -31.0, 24.0], "Date of issue", text(3.5, Left, count(10), vec![field(Revision, "date")])),
|
||||
("sheet", [-31.0, 14.0, 0.0, 24.0], "Sheet", text(3.5, Centre, count(9), sheet)),
|
||||
("material", [-180.0, 24.0, -100.0, 34.0], "Material", text(3.5, Left, whole, vec![field(Component, "material")])),
|
||||
("finish", [-100.0, 24.0, -30.0, 34.0], "Finish", text(3.5, Left, whole, vec![field(Component, "properties.finish")])),
|
||||
("mass", [-30.0, 24.0, 0.0, 34.0], "Mass", text(3.5, Centre, whole, vec![field(Component, "mass")])),
|
||||
("legal_owner", [-180.0, 34.0, -100.0, 54.0], "Legal owner", text(3.5, Left, wrap, vec![field(Drawing, "properties.legal_owner")])),
|
||||
(
|
||||
"general_tolerances",
|
||||
[-100.0, 34.0, -30.0, 54.0],
|
||||
"General tolerances",
|
||||
text(2.5, Left, wrap, vec![field(Drawing, "properties.general_tolerances")]),
|
||||
),
|
||||
("projection", [-30.0, 34.0, 0.0, 54.0], "Projection", CellKind::ProjectionSymbol),
|
||||
];
|
||||
let cells = cells
|
||||
.into_iter()
|
||||
.map(|(name, rect_mm, label, kind)| Cell { name: name.into(), rect_mm, label: Some(Label { text: label.into(), cap_mm: 2.5 }), kind })
|
||||
.collect();
|
||||
TitleBlock { size_mm: Vec2::new(180.0, 54.0), outline_line_mm: 0.7, cell_line_mm: 0.35, cells }
|
||||
}
|
||||
|
||||
fn sheet_layout(profile: &DrawingBorderProfile, size: PaperSize, orientation: Orientation) -> SheetLayout {
|
||||
profile.layout(&Sheet::standard(size, orientation)).unwrap()
|
||||
}
|
||||
|
||||
fn iso(size: PaperSize, orientation: Orientation) -> SheetLayout {
|
||||
sheet_layout(&DrawingBorderProfile::iso(), size, orientation)
|
||||
}
|
||||
|
||||
fn filing(size: PaperSize, orientation: Orientation) -> SheetLayout {
|
||||
sheet_layout(&DrawingBorderProfile { filing_margin: true, ..DrawingBorderProfile::iso() }, size, orientation)
|
||||
}
|
||||
|
||||
fn cell_named<'a>(block: &TitleBlock, layout: &'a TitleBlockLayout, name: &str) -> &'a CellLayout {
|
||||
let i = block.cells.iter().position(|c| c.name == name).unwrap();
|
||||
&layout.cells[i]
|
||||
}
|
||||
|
||||
/// `a` and `b` within 1e-9 mm at both corners.
|
||||
fn near(a: Aabb2, b: Aabb2) -> bool {
|
||||
a.min.dist(b.min) < 1e-9 && a.max.dist(b.max) < 1e-9
|
||||
}
|
||||
|
||||
/// `cell`'s rectangle placed at `corner`, inside its lines by the module's rule worked out by hand:
|
||||
/// half the 0.7 mm outline on the block's edges, half the 0.35 mm cell line inside.
|
||||
fn inside(cell: &Cell, corner: Vec2) -> Aabb2 {
|
||||
let [x0, y0, x1, y1] = cell.rect_mm;
|
||||
let half = |outline: bool| if outline { 0.35 } else { 0.175 };
|
||||
Aabb2 {
|
||||
min: corner + Vec2::new(x0 + half(x0 == -180.0), y0 + half(y0 == 0.0)),
|
||||
max: corner + Vec2::new(x1 - half(x1 == 0.0), y1 - half(y1 == 54.0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(cell: &Cell) -> &TextCell {
|
||||
match &cell.kind {
|
||||
CellKind::Text(text) => text,
|
||||
CellKind::ProjectionSymbol => panic!("{} has no text", cell.name),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_geometry_validates_and_its_cells_tile_the_block() {
|
||||
let block = block();
|
||||
block.validate().unwrap();
|
||||
assert_eq!(block.cells.len(), 12);
|
||||
let area: f64 = block.cells.iter().map(|c| c.rect(Vec2::ZERO).size()).map(|s| s.x * s.y).sum();
|
||||
assert_eq!((area, 180.0 * 54.0), (9720.0, 9720.0));
|
||||
// Each text cell's value box holds one line of its cap height, centred, its descenders above the
|
||||
// lines' inner edge.
|
||||
for cell in &block.cells {
|
||||
let room = cell.value_box(block.inside_lines(cell, cell.rect(Vec2::ZERO))).size();
|
||||
if let CellKind::Text(text) = &cell.kind {
|
||||
let below = (room.y - text.cap_mm) / 2.0 + PADDING_MM;
|
||||
assert!(text.cap_mm <= room.y && text.cap_mm * DESCENDER_OF_CAP <= below, "{}: {} in {room:?}", cell.name, text.cap_mm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_a3_landscape_the_block_sits_on_the_drawing_borders_lower_right_corner() {
|
||||
let (block, sheet) = (block(), iso(PaperSize::A3, Orientation::Landscape));
|
||||
let layout = block.layout(&sheet).unwrap();
|
||||
assert_eq!(layout.outline, rect(230.0, 10.0, 410.0, 64.0));
|
||||
assert_eq!(layout.outline.size().x, 180.0);
|
||||
assert_eq!((layout.outline_line_mm, layout.cell_line_mm), (0.7, 0.35));
|
||||
assert_eq!(layout.cells.len(), 12);
|
||||
// The title cell, on the outline at its left and bottom (0.35 mm in), a cell line at its right
|
||||
// and top (0.175 in): its label's baseline 1 + 2.5 mm below its top's inner edge, its value box
|
||||
// 1 mm in from the lines' inner edges and 1 mm under the label.
|
||||
let title = cell_named(&block, &layout, "title");
|
||||
assert_eq!(title.rect, rect(230.0, 10.0, 342.0, 24.0));
|
||||
assert!(title.label_at.is_some_and(|at| at.dist(Vec2::new(231.35, 20.325)) < 1e-9), "{:?}", title.label_at);
|
||||
assert!(near(title.value_box, rect(231.35, 11.35, 340.825, 19.325)), "{:?}", title.value_box);
|
||||
let (at, warning) = place_lines(title.value_box, 1, 5.0, 8.0, HAlign::Left, false).unwrap();
|
||||
assert!(at.dist(Vec2::new(231.35, 12.8375)) < 1e-9 && warning.is_none(), "{at:?}");
|
||||
// The projection cell, on the outline at its right and top.
|
||||
let projection = cell_named(&block, &layout, "projection");
|
||||
assert_eq!(projection.rect, rect(380.0, 44.0, 410.0, 64.0));
|
||||
assert!(projection.label_at.is_some_and(|at| at.dist(Vec2::new(381.175, 60.15)) < 1e-9), "{:?}", projection.label_at);
|
||||
assert!(near(projection.value_box, rect(381.175, 45.175, 408.65, 59.15)), "{:?}", projection.value_box);
|
||||
// A cell without a label gives its value the whole cell inside its lines, padded.
|
||||
let mut bare = block.clone();
|
||||
bare.cells[0].label = None;
|
||||
let title = bare.layout(&sheet).unwrap().cells[0];
|
||||
assert!(title.label_at.is_none() && near(title.value_box, rect(231.35, 11.35, 340.825, 22.825)), "{:?}", title.value_box);
|
||||
}
|
||||
|
||||
/// Padding is measured from a line's inner edge: a 3 mm outline moves the title's text 1.5 mm in
|
||||
/// from where a hairline puts it, so the line never covers it. The drawing border's line is a
|
||||
/// hairline too, so only the outline moves the text.
|
||||
#[test]
|
||||
fn the_padding_starts_at_the_lines_inner_edge() {
|
||||
let sheet = SheetLayout { drawing_border_line_mm: 1e-9, ..iso(PaperSize::A3, Orientation::Landscape) };
|
||||
let hairline = TitleBlock { outline_line_mm: 1e-9, cell_line_mm: 1e-9, ..block() }.layout(&sheet).unwrap();
|
||||
let wide = TitleBlock { outline_line_mm: 3.0, ..block() }.layout(&sheet).unwrap();
|
||||
let (thin, thick) = (hairline.cells[0].value_box, wide.cells[0].value_box);
|
||||
assert!((thick.min.x - thin.min.x - 1.5).abs() < 1e-6 && (thick.min.y - thin.min.y - 1.5).abs() < 1e-6, "{thin:?} {thick:?}");
|
||||
// The outline's inner edge is 1.5 mm inside the cell's left side; the text starts 1 mm past it.
|
||||
assert!((thick.min.x - (230.0 + 1.5 + 1.0)).abs() < 1e-9, "{thick:?}");
|
||||
}
|
||||
|
||||
/// The block's right and bottom edges lie on the drawing border, its line centred on them too: where
|
||||
/// that line is wider than the outline, the cells there are padded from its inner edge instead.
|
||||
#[test]
|
||||
fn a_drawing_border_line_wider_than_the_outline_pads_the_right_and_bottom_cells() {
|
||||
let a3 = |line_mm: f64| SheetLayout { drawing_border_line_mm: line_mm, ..iso(PaperSize::A3, Orientation::Landscape) };
|
||||
let block = block();
|
||||
let boxes = |block: &TitleBlock, line_mm: f64| {
|
||||
let layout = block.layout(&a3(line_mm)).unwrap();
|
||||
["title", "identification_number", "projection"].map(|name| cell_named(block, &layout, name).value_box)
|
||||
};
|
||||
// ISO's 0.7 mm, the outline's width, and a narrower line leave the outline's inner edge.
|
||||
assert_eq!(boxes(&block, 0.35), boxes(&block, 0.7));
|
||||
// A 2 mm line's inner edge is at y = 11 and x = 409: the value boxes start 1 mm above it and end
|
||||
// 1 mm left of it; the block's left and top edges keep the outline's.
|
||||
let [title, number, projection] = boxes(&block, 2.0);
|
||||
assert!(near(title, rect(231.35, 12.0, 340.825, 19.325)), "{title:?}");
|
||||
assert!(near(number, rect(343.175, 12.0, 408.0, 19.325)), "{number:?}");
|
||||
assert!(near(projection, rect(381.175, 45.175, 408.0, 59.15)), "{projection:?}");
|
||||
// A 5 mm line's inner edge is at y = 12.5 and x = 407.5: the title's 5 mm line no longer fits
|
||||
// above it, so the block that validates on its own is refused on this sheet. With 3.5 mm values,
|
||||
// the boxes keep 1 mm from it.
|
||||
block.validate().unwrap();
|
||||
assert_eq!(block.layout(&a3(5.0)), Err(Invalid("a value's cap height is above 0 and one line of it fits its value box")));
|
||||
let mut smaller = block.clone();
|
||||
for cell in &mut smaller.cells[..2] {
|
||||
if let CellKind::Text(text) = &mut cell.kind {
|
||||
text.cap_mm = 3.5;
|
||||
}
|
||||
}
|
||||
let [title, number, _] = boxes(&smaller, 5.0);
|
||||
assert!(near(title, rect(231.35, 13.5, 340.825, 19.325)) && near(number, rect(343.175, 13.5, 406.5, 19.325)), "{title:?} {number:?}");
|
||||
// A block that fills the drawing border has its left and top edges on the border's line too:
|
||||
// on a 200 x 74 sheet the border is (10, 10)-(190, 64), and a 2 mm line's inner edge is at x = 11
|
||||
// and y = 63.
|
||||
let sheet = |sides_mm| Sheet { size: SheetSize::Custom { sides_mm }, orientation: Orientation::Landscape };
|
||||
let filled = SheetLayout { drawing_border_line_mm: 2.0, ..DrawingBorderProfile::iso().layout(&sheet([200.0, 74.0])).unwrap() };
|
||||
let layout = block.layout(&filled).unwrap();
|
||||
let (title, projection) = (cell_named(&block, &layout, "title"), cell_named(&block, &layout, "projection"));
|
||||
assert!(near(title.value_box, rect(12.0, 12.0, 120.825, 19.325)), "{:?}", title.value_box);
|
||||
assert!(projection.label_at.is_some_and(|at| at.dist(Vec2::new(161.175, 59.5)) < 1e-9), "{:?}", projection.label_at);
|
||||
assert!(near(projection.value_box, rect(161.175, 45.175, 188.0, 58.5)), "{:?}", projection.value_box);
|
||||
}
|
||||
|
||||
/// A cell with a few ulps of room at the origin, where a definition is checked on its own, has none
|
||||
/// where A3's corner (x = 410) rounds it: the block is refused there, not laid out inside out, and
|
||||
/// placed where rounding leaves the room (A2's corner, x = 584).
|
||||
#[test]
|
||||
fn a_cells_room_is_checked_again_where_the_block_sits() {
|
||||
let sliver = Cell { rect_mm: [-70.34999999999971, 0.0, -67.9999999999997, 14.0], ..block().cells[0].clone() };
|
||||
let block = TitleBlock { cells: vec![sliver], ..block() };
|
||||
block.validate().unwrap();
|
||||
const NO_ROOM: Invalid = Invalid("a cell has room for its value below its label");
|
||||
assert_eq!(block.layout(&iso(PaperSize::A3, Orientation::Landscape)), Err(NO_ROOM));
|
||||
let value_box = block.layout(&iso(PaperSize::A2, Orientation::Landscape)).unwrap().cells[0].value_box;
|
||||
assert!(value_box.min.x < value_box.max.x, "{value_box:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_block_follows_the_drawing_border_on_a2_and_a4() {
|
||||
let block = block();
|
||||
assert_eq!(block.layout(&iso(PaperSize::A2, Orientation::Landscape)).unwrap().outline, rect(404.0, 10.0, 584.0, 64.0));
|
||||
// A4 upright: 10 mm to spare on the left of the 190 mm drawing border, none with the filing margin.
|
||||
let a4 = iso(PaperSize::A4, Orientation::Portrait);
|
||||
assert_eq!(a4.drawing_border, rect(10.0, 10.0, 200.0, 287.0));
|
||||
assert_eq!(block.layout(&a4).unwrap().outline, rect(20.0, 10.0, 200.0, 64.0));
|
||||
let a4 = filing(PaperSize::A4, Orientation::Portrait);
|
||||
let outline = block.layout(&a4).unwrap().outline;
|
||||
assert_eq!((outline.min.x, outline), (a4.drawing_border.min.x, rect(20.0, 10.0, 200.0, 64.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a5_landscape_holds_the_block_and_a5_portrait_is_too_narrow() {
|
||||
const TOO_BIG: Invalid = Invalid("a title block fits inside the drawing border");
|
||||
let block = block();
|
||||
assert_eq!(block.layout(&iso(PaperSize::A5, Orientation::Landscape)).unwrap().outline, rect(20.0, 10.0, 200.0, 64.0));
|
||||
// With the filing margin, the drawing border is exactly the block's width.
|
||||
let a5 = filing(PaperSize::A5, Orientation::Landscape);
|
||||
assert_eq!(a5.drawing_border.size().x, 180.0);
|
||||
assert_eq!(block.layout(&a5).unwrap().outline, rect(20.0, 10.0, 200.0, 64.0));
|
||||
// Upright, the drawing border is 128 mm wide, or 118 with the filing margin.
|
||||
for sheet in [iso(PaperSize::A5, Orientation::Portrait), filing(PaperSize::A5, Orientation::Portrait)] {
|
||||
assert!(sheet.drawing_border.size().x < 180.0);
|
||||
assert_eq!(block.layout(&sheet), Err(TOO_BIG));
|
||||
}
|
||||
// A drawing border of exactly 180 x 54 holds it; a hair less either way doesn't.
|
||||
let custom = |a: f64, b: f64| {
|
||||
DrawingBorderProfile::iso().layout(&Sheet { size: SheetSize::Custom { sides_mm: [a, b] }, orientation: Orientation::Landscape })
|
||||
};
|
||||
let exact = custom(200.0, 74.0).unwrap();
|
||||
assert_eq!(block.layout(&exact).unwrap().outline, exact.drawing_border);
|
||||
for sheet in [custom(199.999, 74.0), custom(200.0, 73.999)] {
|
||||
assert_eq!(block.layout(&sheet.unwrap()), Err(TOO_BIG));
|
||||
}
|
||||
}
|
||||
|
||||
/// On every standard sheet either way, with the filing margin or without, the block sits on the
|
||||
/// drawing border's lower-right corner and each cell keeps its place from it; only A5 upright is
|
||||
/// too narrow.
|
||||
#[test]
|
||||
fn on_every_sheet_each_cell_keeps_its_place_from_the_corner() {
|
||||
let block = block();
|
||||
let mut refused = Vec::new();
|
||||
for size in PaperSize::ALL {
|
||||
for orientation in [Orientation::Landscape, Orientation::Portrait] {
|
||||
for sheet in [iso(size, orientation), filing(size, orientation)] {
|
||||
let border = sheet.drawing_border;
|
||||
let Ok(layout) = block.layout(&sheet) else {
|
||||
assert!(border.size().x < 180.0 || border.size().y < 54.0, "{size:?} {orientation:?}");
|
||||
refused.push((size, orientation));
|
||||
continue;
|
||||
};
|
||||
let corner = Vec2::new(border.max.x, border.min.y);
|
||||
assert_eq!(layout.outline, Aabb2 { min: corner - Vec2::new(180.0, 0.0), max: corner + Vec2::new(0.0, 54.0) });
|
||||
for (cell, placed) in block.cells.iter().zip(&layout.cells) {
|
||||
let [x0, y0, x1, y1] = cell.rect_mm;
|
||||
let expected = Aabb2 { min: corner + Vec2::new(x0, y0), max: corner + Vec2::new(x1, y1) };
|
||||
assert_eq!(placed.rect, expected, "{size:?} {orientation:?} {}", cell.name);
|
||||
let lines = inside(cell, corner);
|
||||
let label_at = Vec2::new(lines.min.x + 1.0, lines.max.y - 3.5);
|
||||
assert!(placed.label_at.is_some_and(|at| at.dist(label_at) < 1e-9), "{}", cell.name);
|
||||
let value_box = Aabb2 { min: lines.min + Vec2::new(1.0, 1.0), max: Vec2::new(lines.max.x - 1.0, lines.max.y - 4.5) };
|
||||
assert!(near(placed.value_box, value_box), "{}", cell.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(refused, [(PaperSize::A5, Orientation::Portrait); 2]);
|
||||
}
|
||||
|
||||
/// The plan's line counts for the two wrapped cells at Liberation Sans' pitch (2355/1409 of the cap
|
||||
/// height): two lines of 3.5 mm for the legal owner, three of 2.5 mm for the general tolerances.
|
||||
#[test]
|
||||
fn the_wrapped_cells_hold_the_lines_the_plan_gives_them() {
|
||||
let block = block();
|
||||
let layout = block.layout(&iso(PaperSize::A3, Orientation::Landscape)).unwrap();
|
||||
for (name, lines) in [("legal_owner", 2), ("general_tolerances", 3)] {
|
||||
let i = block.cells.iter().position(|c| c.name == name).unwrap();
|
||||
let text = text_of(&block.cells[i]);
|
||||
let pitch = text.cap_mm * 2355.0 / 1409.0;
|
||||
let place = |n| place_lines(layout.cells[i].value_box, n, text.cap_mm, pitch, text.align, text.fit.wrap).unwrap();
|
||||
// From the value box's top, 59.15 mm up the sheet: under the 0.7 mm outline's inner edge.
|
||||
let (at, warning) = place(lines);
|
||||
assert!(at.dist(Vec2::new(layout.cells[i].value_box.min.x, 59.15 - text.cap_mm)) < 1e-9 && warning.is_none(), "{name}: {at:?}");
|
||||
assert_eq!(place(lines + 1).1, Some(Warning::Height), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_definitions_are_refused() {
|
||||
let a3 = iso(PaperSize::A3, Orientation::Landscape);
|
||||
let with = |change: &dyn Fn(&mut TitleBlock)| {
|
||||
let mut block = block();
|
||||
change(&mut block);
|
||||
block
|
||||
};
|
||||
let named = |name: &'static str| move |b: &mut TitleBlock| b.cells[0].name = name.into();
|
||||
let at = |rect_mm: [f64; 4]| move |b: &mut TitleBlock| b.cells[0].rect_mm = rect_mm;
|
||||
let label_cap = |cap_mm: f64| move |b: &mut TitleBlock| b.cells[0].label = Some(Label { text: "Title".into(), cap_mm });
|
||||
let label_text = |text: String| move |b: &mut TitleBlock| b.cells[0].label = Some(Label { text: text.clone(), cap_mm: 2.5 });
|
||||
let value_cap = |cap_mm: f64| move |b: &mut TitleBlock| b.cells[0].kind = text(cap_mm, HAlign::Left, Fit::default(), vec![]);
|
||||
let mut cases: Vec<(TitleBlock, &str)> = Vec::new();
|
||||
for side in [0.999, 1000.001, 0.0, -180.0, f64::NAN, f64::INFINITY] {
|
||||
cases.push((with(&|b| b.size_mm.x = side), "each side of a title block is 1 to 1000 mm"));
|
||||
cases.push((with(&|b| b.size_mm.y = side), "each side of a title block is 1 to 1000 mm"));
|
||||
}
|
||||
for line in [0.0, -0.7, 10.000_001, f64::NAN, f64::INFINITY] {
|
||||
cases.push((with(&|b| b.outline_line_mm = line), "a title block's lines are above 0 and at most 10 mm wide"));
|
||||
cases.push((with(&|b| b.cell_line_mm = line), "a title block's lines are above 0 and at most 10 mm wide"));
|
||||
}
|
||||
let (longest, too_long): (&'static str, &'static str) = ("a".repeat(64).leak(), "a".repeat(65).leak());
|
||||
for name in ["", "Title", "title_Block", "1st", "title-block", "title block", "_title", "titlé", too_long] {
|
||||
cases.push((with(&named(name)), "a cell's name is snake case and at most 64 characters"));
|
||||
}
|
||||
cases.push((with(&|b| b.cells[1].name = "title".into()), "no two cells have the same name"));
|
||||
for rect_mm in [
|
||||
[-180.0, 0.0, 0.001, 14.0],
|
||||
[-180.0, -0.001, -68.0, 14.0],
|
||||
[-180.001, 0.0, -68.0, 14.0],
|
||||
[-180.0, 40.0, -68.0, 54.001],
|
||||
[-68.0, 0.0, -68.0, 14.0],
|
||||
[-180.0, 14.0, -68.0, 0.0],
|
||||
[f64::NAN, 0.0, -68.0, 14.0],
|
||||
[-180.0, 0.0, -68.0, f64::INFINITY],
|
||||
] {
|
||||
cases.push((with(&at(rect_mm)), "a cell lies inside its block"));
|
||||
}
|
||||
// The identification number reaching 1 mm into the title, a copy of the title, a cell inside the
|
||||
// material cell.
|
||||
cases.push((with(&|b| b.cells[1].rect_mm = [-69.0, 0.0, 0.0, 14.0]), "no two cells overlap"));
|
||||
cases.push((with(&|b| b.cells.push(Cell { name: "copy".into(), ..b.cells[0].clone() })), "no two cells overlap"));
|
||||
cases.push((
|
||||
with(&|b| b.cells.push(Cell { name: "inner".into(), rect_mm: [-170.0, 26.0, -150.0, 32.0], ..b.cells[0].clone() })),
|
||||
"no two cells overlap",
|
||||
));
|
||||
for cap in [0.0, -2.5, f64::NAN, f64::INFINITY] {
|
||||
cases.push((with(&label_cap(cap)), "a label's cap height is finite and above 0"));
|
||||
}
|
||||
for text in ["Title\nblock".to_string(), "\t".into(), "a".repeat(4097)] {
|
||||
cases.push((with(&label_text(text)), "a label is one line of at most 4096 bytes"));
|
||||
}
|
||||
// A 14 mm cell under a 12 mm label, a 2 mm wide cell, a symbol under a label filling its cell:
|
||||
// the projection cell is 19.475 mm high inside its lines, so a label of 16.475 leaves no room.
|
||||
cases.push((with(&label_cap(12.0)), "a cell has room for its value below its label"));
|
||||
cases.push((
|
||||
with(&|b| b.cells[11].label = Some(Label { text: "Projection".into(), cap_mm: 16.475 + 1e-9 })),
|
||||
"a cell has room for its value below its label",
|
||||
));
|
||||
cases.push((with(&at([-180.0, 0.0, -178.0, 14.0])), "a cell has room for its value below its label"));
|
||||
cases.push((
|
||||
with(&|b| b.cells[11].label = Some(Label { text: "Projection".into(), cap_mm: 17.0 })),
|
||||
"a cell has room for its value below its label",
|
||||
));
|
||||
// The title's value box is 7.975 mm high inside its lines. A line filling it has its descenders
|
||||
// 2.39 mm below the box, past the 1 mm padding; the most it holds centred is 9.975 / 1.6 mm, its
|
||||
// descenders (3/10 of it) then on the outline's inner edge.
|
||||
let title_most = 9.975 / 1.6;
|
||||
for cap in [0.0, -5.0, 7.975, title_most + 1e-6, f64::NAN, f64::INFINITY] {
|
||||
cases.push((with(&value_cap(cap)), "a value's cap height is above 0 and one line of it fits its value box"));
|
||||
}
|
||||
// The cell content's own rule, under this module's name.
|
||||
cases.push((
|
||||
with(&|b| b.cells[0].kind = text(5.0, HAlign::Left, Fit::default(), vec![Part::Text(String::new()); 65])),
|
||||
"a cell has at most 64 parts",
|
||||
));
|
||||
let control = vec![Part::Text("a\u{0}".into())];
|
||||
cases.push((
|
||||
with(&|b| b.cells[0].kind = text(5.0, HAlign::Left, Fit::default(), control.clone())),
|
||||
"a cell's text has no control character but a line break",
|
||||
));
|
||||
let many = |n: usize| {
|
||||
let cell = |i: usize| Cell {
|
||||
name: format!("c{i}"),
|
||||
rect_mm: [-10.0 * (i + 1) as f64, 0.0, -10.0 * i as f64, 10.0],
|
||||
label: None,
|
||||
kind: text(1.0, HAlign::Left, Fit::default(), vec![]),
|
||||
};
|
||||
TitleBlock { size_mm: Vec2::new(1000.0, 10.0), outline_line_mm: 0.7, cell_line_mm: 0.35, cells: (0..n).map(cell).collect() }
|
||||
};
|
||||
cases.push((many(MAX_CELLS + 1), "a title block has at most 64 cells"));
|
||||
for (block, message) in cases {
|
||||
assert_eq!(block.validate(), Err(Invalid(message)), "{block:?}");
|
||||
assert_eq!(block.layout(&a3), Err(Invalid(message)), "{block:?}");
|
||||
}
|
||||
// And their limits are accepted.
|
||||
let accepted = [
|
||||
many(MAX_CELLS),
|
||||
TitleBlock { size_mm: Vec2::new(1.0, 1.0), cells: vec![], ..block() },
|
||||
TitleBlock { size_mm: Vec2::new(1000.0, 1000.0), cells: vec![], ..block() },
|
||||
// The widest outline on its own: the default's text doesn't fit inside a 10 mm line.
|
||||
TitleBlock { outline_line_mm: 10.0, cells: vec![], ..block() },
|
||||
with(&|b| b.cell_line_mm = 1e-9),
|
||||
with(&named("a")),
|
||||
with(&named("title_2")),
|
||||
with(&named(longest)),
|
||||
with(&value_cap(title_most - 1e-6)),
|
||||
with(&|b| b.cells[11].label = Some(Label { text: "Projection".into(), cap_mm: 16.475 - 1e-9 })),
|
||||
with(&label_text("a".repeat(4096))),
|
||||
with(&|b| b.cells[0].label = None),
|
||||
];
|
||||
for block in accepted {
|
||||
block.validate().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_round_trips_through_json_and_unknown_fields_are_refused() {
|
||||
let block = block();
|
||||
let value = serde_json::to_value(&block).unwrap();
|
||||
assert_eq!(value["size_mm"], json!([180.0, 54.0]));
|
||||
assert_eq!((value["outline_line_mm"].clone(), value["cell_line_mm"].clone()), (json!(0.7), json!(0.35)));
|
||||
assert_eq!(
|
||||
value["cells"][5],
|
||||
json!({
|
||||
"name": "sheet",
|
||||
"rect_mm": [-31.0, 14.0, 0.0, 24.0],
|
||||
"label": {"text": "Sheet", "cap_mm": 2.5},
|
||||
"kind": {"text": {
|
||||
"content": [
|
||||
{"field": {"source": "sheet", "key": "number", "format": {"unit": null, "decimals": null}}},
|
||||
{"text": "/"},
|
||||
{"field": {"source": "sheet", "key": "count", "format": {"unit": null, "decimals": null}}}
|
||||
],
|
||||
"cap_mm": 3.5,
|
||||
"align": "centre",
|
||||
"fit": {"max_chars": 9, "wide_max_chars": null, "wrap": false}
|
||||
}}
|
||||
})
|
||||
);
|
||||
assert_eq!(value["cells"][11]["kind"], json!("projection_symbol"));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<TitleBlock>(v);
|
||||
assert_eq!(read(value.clone()).unwrap(), block);
|
||||
// Not given: no label, left-aligned, nothing to fit.
|
||||
let mut plain = value.clone();
|
||||
for key in ["align", "fit"] {
|
||||
plain["cells"][0]["kind"]["text"].as_object_mut().unwrap().remove(key);
|
||||
}
|
||||
plain["cells"][0].as_object_mut().unwrap().remove("label");
|
||||
let plain = read(plain).unwrap();
|
||||
assert_eq!((plain.cells[0].label.clone(), text_of(&plain.cells[0]).align, text_of(&plain.cells[0]).fit), (None, HAlign::Left, Fit::default()));
|
||||
for at in ["", "/cells/0", "/cells/0/label", "/cells/0/kind/text", "/cells/0/kind/text/fit"] {
|
||||
let mut v = value.clone();
|
||||
v.pointer_mut(at).unwrap()["scale"] = json!(1);
|
||||
assert!(read(v).is_err(), "an unknown field at {at:?}");
|
||||
}
|
||||
for (at, bad) in [
|
||||
("/cells/11/kind", json!("logo")),
|
||||
("/cells/11/kind", json!("projection")),
|
||||
("/cells/11/kind", json!({"projection_symbol": {"angle": "third"}})),
|
||||
("/cells/11/kind", json!({"kind": "projection_symbol"})),
|
||||
("/cells/0/rect_mm", json!([-180.0, 0.0, -68.0])),
|
||||
("/cells/0/rect_mm", json!({"x0": -180.0})),
|
||||
("/cells/0/name", json!(1)),
|
||||
("/size_mm", json!([180.0])),
|
||||
] {
|
||||
let mut v = value.clone();
|
||||
*v.pointer_mut(at).unwrap() = bad.clone();
|
||||
assert!(read(v).is_err(), "{at}: {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawing_border_that_isnt_finite_holds_no_block() {
|
||||
let block = block();
|
||||
let a3 = iso(PaperSize::A3, Orientation::Landscape);
|
||||
for drawing_border in [Aabb2::EMPTY, rect(410.0, 10.0, 10.0, 287.0), rect(f64::NAN, 10.0, 410.0, 287.0), rect(10.0, 10.0, f64::INFINITY, 287.0)] {
|
||||
let sheet = SheetLayout { drawing_border, ..a3.clone() };
|
||||
assert_eq!(block.layout(&sheet), Err(Invalid("a title block fits inside the drawing border")), "{drawing_border:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Random changes to the default's numbers, hostile ones among them: a definition is accepted or
|
||||
/// refused without panicking, and an accepted one placed on a sheet it fits (any standard size,
|
||||
/// either way, with or without the filing margin, any drawing border line) keeps every cell inside
|
||||
/// the outline, apart from the others, with its label and value box inside it, and every value box
|
||||
/// padded from the drawing border's line.
|
||||
#[test]
|
||||
fn fuzzed_definitions_never_panic_and_an_accepted_one_lays_out_inside_its_outline() {
|
||||
const NUMBERS: [f64; 9] = [0.0, -0.0, 1e-9, 1e5, -1e5, 1000.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
|
||||
let mut rng = Lcg(0x7b1c);
|
||||
let (mut accepted, mut laid_out) = (0, 0);
|
||||
for _ in 0..4000 {
|
||||
let mut block = block();
|
||||
for _ in 0..1 + (rng.unit() * 3.0) as usize {
|
||||
let i = (rng.unit() * 12.0) as usize % 12;
|
||||
let n = if rng.unit() < 0.3 { NUMBERS[(rng.unit() * 9.0) as usize % 9] } else { (rng.unit() - 0.5) * 20.0 };
|
||||
let cell = &mut block.cells[i];
|
||||
match (rng.unit() * 8.0) as usize {
|
||||
0 => block.size_mm.x += n,
|
||||
1 => block.size_mm.y = n,
|
||||
2 => block.outline_line_mm = n,
|
||||
3 => {
|
||||
if let CellKind::Text(text) = &mut cell.kind {
|
||||
text.cap_mm += n;
|
||||
}
|
||||
}
|
||||
4 => cell.label = Some(Label { text: "x".into(), cap_mm: n }),
|
||||
k => cell.rect_mm[(k + i) % 4] += n,
|
||||
}
|
||||
}
|
||||
if block.validate().is_err() {
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
let size = PaperSize::ALL[(rng.unit() * PaperSize::ALL.len() as f64) as usize % PaperSize::ALL.len()];
|
||||
let orientation = if rng.unit() < 0.5 { Orientation::Landscape } else { Orientation::Portrait };
|
||||
let sheet = if rng.unit() < 0.5 { iso(size, orientation) } else { filing(size, orientation) };
|
||||
let line_mm = [0.25, 0.7, 2.0, 5.0, 10.0][(rng.unit() * 5.0) as usize % 5];
|
||||
let sheet = SheetLayout { drawing_border_line_mm: line_mm, ..sheet };
|
||||
let Ok(layout) = block.layout(&sheet) else { continue };
|
||||
laid_out += 1;
|
||||
let outline = layout.outline.inflated(1e-9);
|
||||
let clear = sheet.drawing_border.inflated(-line_mm / 2.0 - PADDING_MM + 1e-9);
|
||||
for (i, (cell, placed)) in block.cells.iter().zip(&layout.cells).enumerate() {
|
||||
assert!(outline.contains(placed.rect.min) && outline.contains(placed.rect.max), "{} {:?}", cell.name, placed.rect);
|
||||
assert!(layout.cells.iter().take(i).all(|other| !overlap(other.rect, placed.rect)), "{}", cell.name);
|
||||
let inside = placed.rect.inflated(1e-9);
|
||||
assert!(inside.contains(placed.value_box.min) && inside.contains(placed.value_box.max), "{}", cell.name);
|
||||
let room = placed.value_box.size();
|
||||
assert!(room.x > 0.0 && room.y > 0.0 && clear.contains(placed.value_box.min) && clear.contains(placed.value_box.max), "{}", cell.name);
|
||||
if let Some(at) = placed.label_at {
|
||||
assert!(inside.contains(at), "{}", cell.name);
|
||||
}
|
||||
if let CellKind::Text(text) = &cell.kind {
|
||||
let (_, warning) = place_lines(placed.value_box, 1, text.cap_mm, text.cap_mm * 1.6, text.align, false).unwrap();
|
||||
assert_eq!(warning, None, "{}", cell.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fewer lay out than accepted: about two in five meet a 5 or 10 mm border line, which leaves most blocks no room.
|
||||
assert!(accepted > 400 && laid_out > 200, "{accepted} accepted, {laid_out} laid out");
|
||||
}
|
||||
+50
-21
@@ -5,8 +5,10 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod drawing;
|
||||
mod native_cache;
|
||||
mod thumbnail;
|
||||
pub use drawing::{FileKind, file_kind, read_drawing, read_drawing_file, write_drawing_container};
|
||||
pub use native_cache::read_design_geometry;
|
||||
pub use thumbnail::{THUMBNAIL_SIZE, read_design_thumbnail, read_design_thumbnail_file};
|
||||
|
||||
@@ -72,6 +74,12 @@ struct PackageWriter {
|
||||
}
|
||||
|
||||
impl PackageWriter {
|
||||
fn new() -> PackageWriter {
|
||||
PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 }
|
||||
}
|
||||
fn finish(self) -> Result<Vec<u8>> {
|
||||
Ok(self.zip.finish().map_err(invalid)?.into_inner())
|
||||
}
|
||||
fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<()> {
|
||||
if bytes.len() > if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART } {
|
||||
return Err(invalid("part too large"));
|
||||
@@ -220,7 +228,7 @@ fn write_container(
|
||||
thumbnail: Option<&[u8]>,
|
||||
budget: usize,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 };
|
||||
let mut writer = PackageWriter::new();
|
||||
let mut features = Vec::with_capacity(doc.features.len());
|
||||
for feature in &doc.features {
|
||||
let replacements = match &feature.kind {
|
||||
@@ -294,7 +302,7 @@ fn write_container(
|
||||
if let Some(png) = thumbnail {
|
||||
thumbnail::write(&mut writer, png, &metadata)?;
|
||||
}
|
||||
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
|
||||
let bytes = writer.finish()?;
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
return Err(invalid(format!("the design would be {} MB, over the {} MB a design file may hold", bytes.len() >> 20, MAX_DESIGN_BYTES >> 20)));
|
||||
}
|
||||
@@ -428,6 +436,9 @@ impl<R: Read + Seek> PackageReader<R> {
|
||||
}
|
||||
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
|
||||
}
|
||||
fn has_part(&self, name: &str) -> bool {
|
||||
self.zip.index_for_name(name).is_some()
|
||||
}
|
||||
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
|
||||
self.limited_part(name, if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART })
|
||||
}
|
||||
@@ -612,6 +623,33 @@ pub(super) fn read(bytes: &[u8]) -> Result<Document> {
|
||||
/// keeps one seekable file handle instead of buffering the complete archive. JSON continues
|
||||
/// through the normal migrations.
|
||||
pub fn read_design_file(path: &str, notify: Option<impl FnOnce(DesignPreview)>) -> Result<(Document, Option<solidcraft_doc::SavedModel>)> {
|
||||
let mut source = open_regular_file(path)?;
|
||||
if is_container_source(&mut source)? {
|
||||
let mut reader = DesignContainerReader::new(source)?;
|
||||
// A design with its model opens without a picture first: the model is the session.
|
||||
let model = reader.geometry();
|
||||
if let Some(notify) = notify
|
||||
&& model.is_none()
|
||||
&& let Ok(preview) = read_preview(&mut reader.reader)
|
||||
{
|
||||
notify(preview);
|
||||
}
|
||||
Ok((reader.read_document()?, model))
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
source.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
Ok((crate::read_design(&bytes)?, None))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type FileSource = std::fs::File;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
type FileSource = Cursor<Vec<u8>>;
|
||||
|
||||
/// A file to read: inside the folders this process may use, a regular file, and at most
|
||||
/// [`MAX_DESIGN_BYTES`], read from its start.
|
||||
fn open_regular_file(path: &str) -> Result<FileSource> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let mut source = {
|
||||
crate::vfs::check(path).map_err(invalid)?;
|
||||
@@ -635,33 +673,24 @@ pub fn read_design_file(path: &str, notify: Option<impl FnOnce(DesignPreview)>)
|
||||
return Err(invalid("file too large"));
|
||||
}
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
/// Whether a source starts as a container does, leaving it at its start.
|
||||
fn is_container_source(source: &mut FileSource) -> Result<bool> {
|
||||
let mut prefix = [0u8; 4];
|
||||
let size = source.seek(SeekFrom::End(0)).map_err(invalid)?;
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
if size >= 4 {
|
||||
source.read_exact(&mut prefix).map_err(invalid)?;
|
||||
}
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
if is_design_container(&prefix) {
|
||||
let mut reader = DesignContainerReader::new(source)?;
|
||||
// A design with its model opens without a picture first: the model is the session.
|
||||
let model = reader.geometry();
|
||||
if let Some(notify) = notify
|
||||
&& model.is_none()
|
||||
&& let Ok(preview) = read_preview(&mut reader.reader)
|
||||
{
|
||||
notify(preview);
|
||||
}
|
||||
Ok((reader.read_document()?, model))
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
source.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
Ok((crate::read_design(&bytes)?, None))
|
||||
}
|
||||
Ok(is_design_container(&prefix))
|
||||
}
|
||||
|
||||
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
|
||||
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
|
||||
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
if drawing::container_kind(reader)? == FileKind::Drawing {
|
||||
return Err(IoError::Invalid("this file is a SolidCraft drawing, not a design: open it as a drawing".into()));
|
||||
}
|
||||
let bytes = reader.document(MAX_DESIGN_BYTES)?;
|
||||
let metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Drawing files (#3890): a [`Drawing`] saved as a container or as plain JSON, like a design.
|
||||
//!
|
||||
//! A drawing container is a design container holding `drawing.json` (Deflate, as
|
||||
//! `document.json`) in place of `document.json`, and saying so in `container.json`:
|
||||
//! `{"content": "drawing", "format": "solidcraft-container/1"}`. A design's `container.json` has no
|
||||
//! `content`, as before. The container's own checks (budgets, entries, the ZIP directory) are the
|
||||
//! design container's. The plain JSON form is [`Drawing::to_json`]'s text, the same bytes each
|
||||
//! save, so a drawing can live in version control as text.
|
||||
//!
|
||||
//! A design is never read as a drawing nor a drawing as a design: [`file_kind`] tells them apart,
|
||||
//! and each reader refuses the other's file by name.
|
||||
|
||||
use std::io::{Cursor, Read, Seek};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::drawing::{Drawing, MAX_DRAWING_BYTES};
|
||||
use solidcraft_doc::format::DRAWING;
|
||||
|
||||
use super::{PackageReader, PackageWriter, VERSION, invalid, is_design_container};
|
||||
use crate::{IoError, MAX_DESIGN_BYTES, Result};
|
||||
|
||||
/// The part a drawing container keeps its drawing in.
|
||||
const DRAWING_PART: &str = "drawing.json";
|
||||
/// What a drawing's `container.json` says it holds.
|
||||
const DRAWING_CONTENT: &str = "drawing";
|
||||
|
||||
/// What a SolidCraft file holds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FileKind {
|
||||
Design,
|
||||
Drawing,
|
||||
}
|
||||
|
||||
/// Which kind of file `bytes` are, from the container's `container.json` or the JSON's `format`.
|
||||
/// JSON that isn't a drawing's counts as a design, which the design reader then checks.
|
||||
pub fn file_kind(bytes: &[u8]) -> Result<FileKind> {
|
||||
if is_design_container(bytes) {
|
||||
return container_kind(&mut PackageReader::new(Cursor::new(bytes))?);
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Format {
|
||||
format: Option<String>,
|
||||
}
|
||||
let format = serde_json::from_slice::<Format>(bytes).ok().and_then(|f| f.format);
|
||||
Ok(if format.is_some_and(|f| DRAWING.version_of(&f).is_some()) { FileKind::Drawing } else { FileKind::Design })
|
||||
}
|
||||
|
||||
/// What a container holds, by its `container.json`: no `content` is a design.
|
||||
pub(super) fn container_kind<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<FileKind> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if !matches!(container.get("format").and_then(Value::as_str), Some(super::VERSION | super::LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
}
|
||||
match container.get("content") {
|
||||
None => Ok(FileKind::Design),
|
||||
Some(Value::String(content)) if content == DRAWING_CONTENT => Ok(FileKind::Drawing),
|
||||
Some(other) => Err(invalid(format!("this build doesn't know container content {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The drawing as a container: the same bytes for the same drawing.
|
||||
pub fn write_drawing_container(drawing: &Drawing) -> Result<Vec<u8>> {
|
||||
let text = drawing.to_json()?;
|
||||
let mut writer = PackageWriter::new();
|
||||
writer.part(DRAWING_PART, text.as_bytes(), true)?;
|
||||
let container = json!({"format": VERSION, "content": DRAWING_CONTENT});
|
||||
writer.part("container.json", &serde_json::to_vec(&container).map_err(invalid)?, false)?;
|
||||
writer.finish()
|
||||
}
|
||||
|
||||
/// Read a drawing file's bytes: a container or plain JSON.
|
||||
pub fn read_drawing(bytes: &[u8]) -> Result<Drawing> {
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
return Err(IoError::Invalid("drawing file too large".into()));
|
||||
}
|
||||
if is_design_container(bytes) {
|
||||
return read_container(Cursor::new(bytes));
|
||||
}
|
||||
if file_kind(bytes)? == FileKind::Design {
|
||||
return Err(IoError::Invalid("this file is a SolidCraft design, not a drawing: open it as a design".into()));
|
||||
}
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("drawing file is not UTF-8 JSON".into()))?;
|
||||
Ok(Drawing::from_json(text)?)
|
||||
}
|
||||
|
||||
/// Read a drawing from disk (or the browser's files), a container through one seekable handle.
|
||||
pub fn read_drawing_file(path: &str) -> Result<Drawing> {
|
||||
let mut source = super::open_regular_file(path)?;
|
||||
if super::is_container_source(&mut source)? {
|
||||
return read_container(source);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
source.take(MAX_DRAWING_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
read_drawing(&bytes)
|
||||
}
|
||||
|
||||
fn read_container<R: Read + Seek>(source: R) -> Result<Drawing> {
|
||||
let mut reader = PackageReader::new(source)?;
|
||||
if container_kind(&mut reader)? == FileKind::Design {
|
||||
return Err(IoError::Invalid("this file is a SolidCraft design, not a drawing: open it as a design".into()));
|
||||
}
|
||||
if reader.has_part("document.json") {
|
||||
return Err(invalid("a drawing container also holds a design's document.json"));
|
||||
}
|
||||
let bytes = reader.limited_part(DRAWING_PART, MAX_DRAWING_BYTES)?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(invalid)?;
|
||||
Ok(Drawing::from_json(text)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawing_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,155 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_doc::drawing::Source;
|
||||
use solidcraft_doc::ids::IdSource;
|
||||
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile, Orientation, PaperSize};
|
||||
|
||||
use super::*;
|
||||
use crate::{is_drawing_path, read_design, write_design, write_design_container};
|
||||
|
||||
/// A drawing of two designs on one A3 sheet.
|
||||
fn drawing() -> Drawing {
|
||||
let mut ids = IdSource::seeded(3890);
|
||||
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 ids).unwrap();
|
||||
for path in ["plate.solidcraft", "../parts/bolt.solidcraft"] {
|
||||
let id = d.allocate_id(&mut ids).unwrap();
|
||||
d.sources.push(Source { id, path: path.into() });
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// A container's parts by name.
|
||||
fn parts(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
(0..zip.len())
|
||||
.map(|i| {
|
||||
let mut entry = zip.by_index(i).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
entry.read_to_end(&mut bytes).unwrap();
|
||||
(entry.name().unwrap().into_owned(), bytes)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A container written part by part, as a hostile or older tool might.
|
||||
fn container(parts: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut writer = PackageWriter::new();
|
||||
for (name, bytes) in parts {
|
||||
writer.part(name, bytes, true).unwrap();
|
||||
}
|
||||
writer.finish().unwrap()
|
||||
}
|
||||
|
||||
fn error(result: Result<impl std::fmt::Debug>) -> String {
|
||||
result.err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted"))
|
||||
}
|
||||
|
||||
/// A drawing round-trips through both forms, each giving the same bytes every save; the
|
||||
/// container holds `container.json` and `drawing.json`, and no `document.json`.
|
||||
#[test]
|
||||
fn a_drawing_round_trips_through_both_forms_byte_for_byte() {
|
||||
let d = drawing();
|
||||
let bytes = write_drawing_container(&d).unwrap();
|
||||
assert_eq!(read_drawing(&bytes).unwrap(), d);
|
||||
assert_eq!(write_drawing_container(&read_drawing(&bytes).unwrap()).unwrap(), bytes, "save, open, save: the same bytes");
|
||||
let inside = parts(&bytes);
|
||||
assert_eq!(inside.keys().map(String::as_str).collect::<Vec<_>>(), ["container.json", "drawing.json"]);
|
||||
assert_eq!(inside["container.json"], br#"{"content":"drawing","format":"solidcraft-container/1"}"#);
|
||||
let json = d.to_json().unwrap();
|
||||
assert_eq!(inside["drawing.json"], json.as_bytes(), "the container holds the plain form's text");
|
||||
let again = read_drawing(json.as_bytes()).unwrap();
|
||||
assert_eq!((again.to_json().unwrap(), &again), (json, &d));
|
||||
}
|
||||
|
||||
/// Each file is the kind it says: a design container's `container.json` has no `content`, a
|
||||
/// drawing's says `drawing`, and plain JSON is a drawing when its format is one.
|
||||
#[test]
|
||||
fn file_kind_tells_designs_and_drawings_apart() {
|
||||
let doc = Document::new("Plate");
|
||||
let design = write_design_container(&doc).unwrap();
|
||||
assert_eq!(parts(&design)["container.json"], br#"{"format":"solidcraft-container/1"}"#, "a design's container is as it was");
|
||||
let d = drawing();
|
||||
let cases = [
|
||||
(design, FileKind::Design),
|
||||
(write_design(&doc), FileKind::Design),
|
||||
(write_drawing_container(&d).unwrap(), FileKind::Drawing),
|
||||
(d.to_json().unwrap().into_bytes(), FileKind::Drawing),
|
||||
(b"{\"format\": \"solidcraft-drawing/1\"}".to_vec(), FileKind::Drawing),
|
||||
(b"{\"format\": \"solidcraft/1\"}".to_vec(), FileKind::Design),
|
||||
(b"not json".to_vec(), FileKind::Design),
|
||||
];
|
||||
for (bytes, kind) in cases {
|
||||
assert_eq!(file_kind(&bytes).unwrap(), kind, "{}", String::from_utf8_lossy(&bytes[..bytes.len().min(40)]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Neither reader takes the other's file, and each says what the file is.
|
||||
#[test]
|
||||
fn a_design_is_never_read_as_a_drawing_nor_a_drawing_as_a_design() {
|
||||
let d = drawing();
|
||||
for bytes in [write_drawing_container(&d).unwrap(), d.to_json().unwrap().into_bytes()] {
|
||||
assert!(error(read_design(&bytes)).contains("is a SolidCraft drawing, not a design"));
|
||||
}
|
||||
let doc = Document::new("Plate");
|
||||
for bytes in [write_design_container(&doc).unwrap(), write_design(&doc)] {
|
||||
assert!(error(read_drawing(&bytes)).contains("is a SolidCraft design, not a drawing"));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a drawing container refuses: content this build doesn't know, a design's document beside
|
||||
/// the drawing, a missing drawing, an unknown container version, and a damaged drawing.
|
||||
#[test]
|
||||
fn a_drawing_container_refuses_what_it_cant_take() {
|
||||
let text = drawing().to_json().unwrap();
|
||||
let drawing_json = |content: &str| format!(r#"{{"content":{content},"format":"solidcraft-container/1"}}"#);
|
||||
let cases: [(Vec<u8>, &str); 6] = [
|
||||
(container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "container content \"sketch\""),
|
||||
(container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "container content 7"),
|
||||
(
|
||||
container(&[
|
||||
("container.json", drawing_json("\"drawing\"").as_bytes()),
|
||||
("drawing.json", text.as_bytes()),
|
||||
("document.json", &b"{}"[..]),
|
||||
]),
|
||||
"also holds a design's document.json",
|
||||
),
|
||||
(container(&[("container.json", drawing_json("\"drawing\"").as_bytes())]), "required part `drawing.json`"),
|
||||
(
|
||||
container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]),
|
||||
"version",
|
||||
),
|
||||
(
|
||||
container(&[("container.json", drawing_json("\"drawing\"").as_bytes()), ("drawing.json", text.replace("en-GB", "e").as_bytes())]),
|
||||
"damaged drawing",
|
||||
),
|
||||
];
|
||||
for (bytes, says) in cases {
|
||||
let e = error(read_drawing(&bytes));
|
||||
assert!(e.contains(says), "{says}: {e}");
|
||||
}
|
||||
assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("container content"));
|
||||
}
|
||||
|
||||
/// Drawing files: the extension, and reading one from disk in either form.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn drawing_files_open_from_disk_in_either_form() {
|
||||
// The browsers' file pickers take only ASCII letters, digits, `+` and `.`, up to 16 long.
|
||||
assert!(crate::DRAWING_EXT.len() < 16 && crate::DRAWING_EXT.bytes().all(|b| b.is_ascii_alphanumeric()), "{}", crate::DRAWING_EXT);
|
||||
for (path, drawing) in [("a.scdwg", true), ("A.SCDWG", true), ("a.solidcraft", false), ("scdwg", false), ("a.dwg", false)] {
|
||||
assert_eq!(is_drawing_path(path), drawing, "{path}");
|
||||
}
|
||||
let d = drawing();
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-drawing-files-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name, bytes) in [("container.scdwg", write_drawing_container(&d).unwrap()), ("plain.scdwg", d.to_json().unwrap().into_bytes())] {
|
||||
let path = dir.join(name);
|
||||
std::fs::File::create(&path).unwrap().write_all(&bytes).unwrap();
|
||||
assert_eq!(read_drawing_file(path.to_str().unwrap()).unwrap(), d, "{name}");
|
||||
}
|
||||
assert!(error(read_drawing_file(dir.to_str().unwrap())).contains("not a regular file"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
+20
-4
@@ -12,15 +12,18 @@ mod design_metadata;
|
||||
mod dxf;
|
||||
mod dxf_out;
|
||||
pub use design::{
|
||||
DesignCompression, DesignContainerReader, DesignPreview, THUMBNAIL_SIZE, is_design_container, read_design_file, read_design_geometry,
|
||||
read_design_preview, read_design_thumbnail, read_design_thumbnail_file, write_design_container, write_design_container_with_cache,
|
||||
write_design_container_with_compression, write_design_container_with_preview, write_design_container_with_thumbnail,
|
||||
DesignCompression, DesignContainerReader, DesignPreview, FileKind, THUMBNAIL_SIZE, file_kind, is_design_container, read_design_file,
|
||||
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, read_drawing, read_drawing_file,
|
||||
write_design_container, write_design_container_with_cache, write_design_container_with_compression, write_design_container_with_preview,
|
||||
write_design_container_with_thumbnail, write_drawing_container,
|
||||
};
|
||||
mod legacy_dir;
|
||||
#[cfg(test)]
|
||||
mod mutate;
|
||||
mod native_cad;
|
||||
pub use native_cad::{NATIVE_CAD_EXTS, is_native_cad_path, native_cad_to_step};
|
||||
pub mod paper;
|
||||
mod pat;
|
||||
pub mod safe_write;
|
||||
mod sketch2d;
|
||||
mod svg;
|
||||
@@ -31,6 +34,7 @@ pub mod vfs;
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
|
||||
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_path};
|
||||
pub use pat::{PatImportOptions, PatUnits, read_pat};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
pub use svg::read_svg;
|
||||
@@ -59,6 +63,14 @@ pub fn is_design_path(path: &str) -> bool {
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| DESIGN_EXTS.iter().chain(["json"].iter()).any(|x| e.eq_ignore_ascii_case(x)))
|
||||
}
|
||||
/// Drawing file extension: a drawing is a file of its own beside the designs it draws. Short and
|
||||
/// plain, as the browsers' file pickers take only ASCII letters, digits, `+` and `.`, up to 16 long.
|
||||
pub const DRAWING_EXT: &str = "scdwg";
|
||||
|
||||
/// Is this a drawing file name (`.scdwg`, any case)?
|
||||
pub fn is_drawing_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case(DRAWING_EXT))
|
||||
}
|
||||
/// Largest design file we read.
|
||||
pub const MAX_DESIGN_BYTES: usize = 256 << 20;
|
||||
|
||||
@@ -576,7 +588,11 @@ pub fn read_design(bytes: &[u8]) -> Result<Document> {
|
||||
return design::read(bytes);
|
||||
}
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("design file is not UTF-8 JSON".into()))?;
|
||||
Ok(Document::from_json(s)?)
|
||||
// Only a file that isn't a design pays for asking whether it is a drawing.
|
||||
Document::from_json(s).map_err(|e| match file_kind(bytes) {
|
||||
Ok(FileKind::Drawing) => IoError::Invalid("this file is a SolidCraft drawing, not a design: open it as a drawing".into()),
|
||||
_ => e.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Design file bytes.
|
||||
|
||||
@@ -525,6 +525,55 @@ outer
|
||||
survives("design JSON", &mutations(&json, true, 0xde6), usize::MAX, |b| crate::read_design(b).ok().map(|d| d.features.len()));
|
||||
}
|
||||
|
||||
/// A drawing, as a container and as plain JSON: every mutation gives an error or a drawing,
|
||||
/// never a panic, and `file_kind` always answers.
|
||||
#[test]
|
||||
fn drawings_survive_mutation() {
|
||||
use solidcraft_geom::drawing_sheet::{DrawingBorderProfile, Orientation, PaperSize, Sheet};
|
||||
let mut ids = solidcraft_doc::ids::IdSource::seeded(0xd4a);
|
||||
let a3 = Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut drawing = solidcraft_doc::drawing::Drawing::new("Fuzz", "en", "Sheet 1", a3, Some(DrawingBorderProfile::iso()), &mut ids).unwrap();
|
||||
let id = drawing.allocate_id(&mut ids).unwrap();
|
||||
drawing.sources.push(solidcraft_doc::drawing::Source { id, path: "plate.solidcraft".into() });
|
||||
let container = crate::write_drawing_container(&drawing).unwrap();
|
||||
assert!(crate::read_drawing(&container).is_ok());
|
||||
survives("drawing container", &mutations(&container, false, 0xd4b), usize::MAX, |b| {
|
||||
let _ = crate::file_kind(b);
|
||||
let _ = crate::read_design(b);
|
||||
crate::read_drawing(b).ok().map(|d| d.sheets.len())
|
||||
});
|
||||
let json = drawing.to_json().unwrap().into_bytes();
|
||||
assert!(crate::read_drawing(&json).is_ok());
|
||||
survives("drawing JSON", &mutations(&json, true, 0xd4c), usize::MAX, |b| {
|
||||
let _ = crate::file_kind(b);
|
||||
crate::read_drawing(b).ok().map(|d| d.sheets.len())
|
||||
});
|
||||
}
|
||||
|
||||
/// A paper scene's JSON, mutated, read, prepared and written as SVG: an error or a document,
|
||||
/// never a panic.
|
||||
#[test]
|
||||
fn paper_scenes_survive_mutation_into_svg() {
|
||||
use solidcraft_geom::paper::{Group, Item, ObjectKind, Scene};
|
||||
use solidcraft_geom::{Loop2, Region2, Seg2, Vec2};
|
||||
let p = Vec2::new;
|
||||
let border = Loop2::polygon(&[p(20.0, 10.0), p(410.0, 10.0), p(410.0, 287.0), p(20.0, 287.0)]);
|
||||
let items = vec![
|
||||
Item::Stroke { path: border.segs.clone(), closed: true, width_mm: 0.7 },
|
||||
Item::Stroke { path: vec![Seg2::Arc { center: p(50.0, 50.0), radius: 10.0, start: 0.0, sweep: 4.0 }], closed: false, width_mm: 0.35 },
|
||||
Item::Fill { region: Region2 { outer: border, holes: vec![] } },
|
||||
];
|
||||
let scene = Scene { size_mm: p(420.0, 297.0), groups: vec![Group { object: 7, kind: ObjectKind::DrawingBorder, items }] };
|
||||
let json = serde_json::to_vec(&scene).unwrap();
|
||||
let write = |b: &[u8]| -> Option<usize> {
|
||||
let scene: Scene = serde_json::from_slice(b).ok()?;
|
||||
let prepared = scene.prepare().ok()?;
|
||||
crate::paper::paper_svg(&prepared).ok().map(|(bytes, _)| bytes.len())
|
||||
};
|
||||
assert!(write(&json).is_some());
|
||||
survives("paper scene to SVG", &mutations(&json, true, 0x5a3), usize::MAX, write);
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648) of `bytes`, for a data URI.
|
||||
fn base64(bytes: &[u8]) -> String {
|
||||
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A sheet's paper scene (`geom::paper`) in a file: SVG first (A3, #4890); DXF, PNG and PDF follow.
|
||||
|
||||
mod svg;
|
||||
|
||||
pub use svg::{SvgReport, paper_svg};
|
||||
@@ -0,0 +1,242 @@
|
||||
//! A paper scene as SVG (#4890, A3): one user unit a millimetre, the paper's size in mm, and the
|
||||
//! scene's own curves where SVG has them.
|
||||
//!
|
||||
//! - **Numbers** are whole nanometres, written as millimetres with up to six decimals: no exponent, no "-0",
|
||||
//! nothing that depends on the locale. They are also the writer's state, so a joint is written
|
||||
//! once and a closed path ends exactly on its first point.
|
||||
//! - **y is flipped** by writing the page height less y, so the file's numbers are page
|
||||
//! coordinates, with no transform. After the flip an anticlockwise arc turns the negative way,
|
||||
//! so its sweep flag is 0.
|
||||
//! - **Curves:** lines `L`, circular arcs `A` in pieces of at most a quarter turn (a half turn's
|
||||
//! centre is ill-conditioned once its ends are rounded), cubics `C`, parabolas `Q`. Ellipses,
|
||||
//! hyperbolas and Béziers above degree 3 aren't written yet (A3b): a scene with one is refused.
|
||||
//! - **Paint:** strokes `stroke-width`, with butt caps, miter joins and a miter limit of 4 on the
|
||||
//! root; fills black, even-odd. One `<path>` per item, so each stays one object in an editor.
|
||||
//! - **Groups** are `<g data-object="…" data-kind="…">`: object ids repeat and start with a digit,
|
||||
//! so they can't be XML ids. No background: the page stays transparent, as cutters want.
|
||||
//!
|
||||
//! What the grid can't express is counted, never an error: a width under half a nanometre is
|
||||
//! written as one, and a piece ending within 10 nanometres of where the path is goes straight on
|
||||
//! to the next.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use solidcraft_geom::paper::{Item, Prepared, arc_pieces};
|
||||
use solidcraft_geom::{Region2, Seg2, Vec2};
|
||||
|
||||
use crate::{IoError, MAX_DRAWING_BYTES, Result};
|
||||
|
||||
/// The largest number written, in millimetres either way: far past any scene's 2e5.
|
||||
const MAX_NUMBER_MM: f64 = 9e9;
|
||||
/// Pieces ending this many grid steps or fewer from where the path is are merged (1e-5 mm).
|
||||
const SLIVER_STEPS: i64 = 10;
|
||||
|
||||
/// What writing a scene did that the scene didn't say.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SvgReport {
|
||||
/// Widths under half a nanometre, written as one nanometre.
|
||||
pub widths_snapped: usize,
|
||||
/// Pieces too short for the nanometre grid, merged into the next.
|
||||
pub pieces_merged: usize,
|
||||
}
|
||||
|
||||
/// A scene's SVG document, and what writing it did.
|
||||
pub fn paper_svg(scene: &Prepared) -> Result<(Vec<u8>, SvgReport)> {
|
||||
let mut w = Writer { out: String::new(), height: nano(scene.size_mm().y)?, report: SvgReport::default(), at: (0, 0), first: (0, 0) };
|
||||
let (width, height) = (number(nano(scene.size_mm().x)?), number(w.height));
|
||||
w.out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
let _ = writeln!(
|
||||
w.out,
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}mm\" height=\"{height}mm\" viewBox=\"0 0 {width} {height}\" \
|
||||
fill=\"none\" stroke=\"#000\" stroke-linecap=\"butt\" stroke-linejoin=\"miter\" stroke-miterlimit=\"4\">"
|
||||
);
|
||||
for group in scene.groups() {
|
||||
let kind = serde_json::to_value(group.kind).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
let _ = writeln!(w.out, "<g data-object=\"{}\" data-kind=\"{kind}\">", group.object);
|
||||
for item in &group.items {
|
||||
match item {
|
||||
Item::Stroke { path, closed, width_mm } => w.stroke(path, *closed, *width_mm)?,
|
||||
Item::Fill { region } => w.fill(region)?,
|
||||
}
|
||||
if w.out.len() > MAX_DRAWING_BYTES {
|
||||
return Err(IoError::Invalid(format!("the SVG would be over {} MiB", MAX_DRAWING_BYTES >> 20)));
|
||||
}
|
||||
}
|
||||
w.out.push_str("</g>\n");
|
||||
}
|
||||
w.out.push_str("</svg>\n");
|
||||
Ok((w.out.into_bytes(), w.report))
|
||||
}
|
||||
|
||||
/// A value in millimetres as whole nanometres.
|
||||
fn nano(v: f64) -> Result<i64> {
|
||||
let n = (v * 1e6).round();
|
||||
if n.abs() <= MAX_NUMBER_MM * 1e6 { Ok(n as i64) } else { Err(IoError::Invalid("a number too large (or not a number) for the SVG".into())) }
|
||||
}
|
||||
|
||||
/// Nanometres written as millimetres onto `out`: the integer part, then up to six decimals
|
||||
/// without trailing zeros.
|
||||
fn push_number(out: &mut String, n: i64) {
|
||||
let (sign, n) = if n < 0 { ("-", n.unsigned_abs()) } else { ("", n.unsigned_abs()) };
|
||||
let (whole, mut part, mut digits) = (n / 1_000_000, n % 1_000_000, 6);
|
||||
let _ = write!(out, "{sign}{whole}");
|
||||
if part != 0 {
|
||||
while part % 10 == 0 {
|
||||
(part, digits) = (part / 10, digits - 1);
|
||||
}
|
||||
let _ = write!(out, ".{part:0digits$}");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`push_number`] as its own text, for the numbers written once per document or arc.
|
||||
fn number(n: i64) -> String {
|
||||
let mut text = String::new();
|
||||
push_number(&mut text, n);
|
||||
text
|
||||
}
|
||||
|
||||
struct Writer {
|
||||
out: String,
|
||||
/// The page height, in nanometres: y is written as this less y.
|
||||
height: i64,
|
||||
report: SvgReport,
|
||||
/// Where the path is, and where its subpath started, on the grid.
|
||||
at: (i64, i64),
|
||||
first: (i64, i64),
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// A scene point on the page's grid.
|
||||
fn grid(&self, p: Vec2) -> Result<(i64, i64)> {
|
||||
Ok((nano(p.x)?, self.height - nano(p.y)?))
|
||||
}
|
||||
|
||||
fn point(&mut self, (x, y): (i64, i64)) {
|
||||
push_number(&mut self.out, x);
|
||||
self.out.push(' ');
|
||||
push_number(&mut self.out, y);
|
||||
}
|
||||
|
||||
/// Starts a subpath at `p`.
|
||||
fn move_to(&mut self, p: Vec2) -> Result<()> {
|
||||
let at = self.grid(p)?;
|
||||
self.out.push('M');
|
||||
self.point(at);
|
||||
(self.at, self.first) = (at, at);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a piece ending at `end` is too short to write: it is merged into the next. A path's
|
||||
/// last piece (`keep`) has no next, so it is written unless it has no length: an open path
|
||||
/// ends where it ends, a closed one on its first point exactly.
|
||||
fn merged(&mut self, end: (i64, i64), keep: bool) -> bool {
|
||||
if keep {
|
||||
return end == self.at;
|
||||
}
|
||||
let (dx, dy) = (end.0 - self.at.0, end.1 - self.at.1);
|
||||
let short = dx.abs() <= SLIVER_STEPS && dy.abs() <= SLIVER_STEPS && dx * dx + dy * dy <= SLIVER_STEPS * SLIVER_STEPS;
|
||||
if short {
|
||||
self.report.pieces_merged += 1;
|
||||
}
|
||||
short
|
||||
}
|
||||
|
||||
/// One piece, from where the path is: `closing` makes its end the subpath's first point, and
|
||||
/// `keep` marks the path's last piece ([`Writer::merged`]).
|
||||
fn piece(&mut self, seg: &Seg2, closing: bool, keep: bool) -> Result<()> {
|
||||
let end = if closing { self.first } else { self.grid(seg.end())? };
|
||||
match *seg {
|
||||
Seg2::Line { .. } => {
|
||||
if !self.merged(end, keep) {
|
||||
self.out.push('L');
|
||||
self.point(end);
|
||||
self.at = end;
|
||||
}
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
let r = number(nano(radius)?);
|
||||
let flag = u8::from(sweep < 0.0);
|
||||
let mut ends = arc_pieces(center, radius, start, sweep).peekable();
|
||||
while let Some(p) = ends.next() {
|
||||
let final_piece = ends.peek().is_none();
|
||||
let to = if final_piece { end } else { self.grid(p)? };
|
||||
if !self.merged(to, keep && final_piece) {
|
||||
let _ = write!(self.out, "A{r} {r} 0 0 {flag} ");
|
||||
self.point(to);
|
||||
self.at = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
Seg2::Cubic { p1, p2, .. } => {
|
||||
let (c1, c2) = (self.grid(p1)?, self.grid(p2)?);
|
||||
self.out.push('C');
|
||||
self.point(c1);
|
||||
self.out.push(' ');
|
||||
self.point(c2);
|
||||
self.out.push(' ');
|
||||
self.point(end);
|
||||
self.at = end;
|
||||
}
|
||||
Seg2::Conic { apex, w, .. } if (w - 1.0).abs() <= 1e-12 => {
|
||||
let c = self.grid(apex)?;
|
||||
self.out.push('Q');
|
||||
self.point(c);
|
||||
self.out.push(' ');
|
||||
self.point(end);
|
||||
self.at = end;
|
||||
}
|
||||
Seg2::Conic { .. } | Seg2::Bezier { .. } => {
|
||||
return Err(IoError::Invalid("not supported yet: SVG of ellipses, hyperbolas and Béziers above degree 3 (#4890, A3b)".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A path's pieces after its `M`: a closed one ends exactly on its first point, a line back to
|
||||
/// it as `Z`.
|
||||
fn pieces(&mut self, path: &[Seg2], closed: bool) -> Result<()> {
|
||||
let Some((last, body)) = path.split_last() else { return Ok(()) };
|
||||
for seg in body {
|
||||
self.piece(seg, false, false)?;
|
||||
}
|
||||
if !closed {
|
||||
return self.piece(last, false, true);
|
||||
}
|
||||
if !matches!(last, Seg2::Line { .. }) {
|
||||
self.piece(last, true, true)?;
|
||||
}
|
||||
self.out.push('Z');
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stroke(&mut self, path: &[Seg2], closed: bool, width_mm: f64) -> Result<()> {
|
||||
let Some(first) = path.first() else { return Ok(()) };
|
||||
let mut width = nano(width_mm)?;
|
||||
if width == 0 {
|
||||
width = 1;
|
||||
self.report.widths_snapped += 1;
|
||||
}
|
||||
self.out.push_str("<path stroke-width=\"");
|
||||
push_number(&mut self.out, width);
|
||||
self.out.push_str("\" d=\"");
|
||||
self.move_to(first.start())?;
|
||||
self.pieces(path, closed)?;
|
||||
self.out.push_str("\"/>\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fill(&mut self, region: &Region2) -> Result<()> {
|
||||
self.out.push_str("<path fill=\"#000\" stroke=\"none\" fill-rule=\"evenodd\" d=\"");
|
||||
for l in region.loops() {
|
||||
let Some(first) = l.segs.first() else { continue };
|
||||
self.move_to(first.start())?;
|
||||
self.pieces(&l.segs, true)?;
|
||||
}
|
||||
self.out.push_str("\"/>\n");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "svg_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,165 @@
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
use solidcraft_geom::paper::{Group, ObjectKind, Scene};
|
||||
use solidcraft_geom::{Loop2, Region2};
|
||||
|
||||
use super::*;
|
||||
use crate::{Geom2, read_svg};
|
||||
|
||||
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<Seg2>, closed: bool, width_mm: f64) -> Item {
|
||||
Item::Stroke { path, closed, width_mm }
|
||||
}
|
||||
|
||||
/// 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<Item>) -> Scene {
|
||||
Scene { size_mm: p(420.0, 297.0), groups: vec![Group { object: 1, kind: ObjectKind::DrawingBorder, items }] }
|
||||
}
|
||||
|
||||
fn svg(scene: &Scene) -> (String, SvgReport) {
|
||||
let (bytes, report) = paper_svg(&scene.prepare().unwrap()).unwrap();
|
||||
(String::from_utf8(bytes).unwrap(), report)
|
||||
}
|
||||
|
||||
/// The `d` of the document's first path.
|
||||
fn first_d(scene: &Scene) -> String {
|
||||
let (text, _) = svg(scene);
|
||||
let start = text.find(" d=\"").unwrap() + 4;
|
||||
text[start..].split('"').next().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn arc(center: Vec2, radius: f64, start: f64, sweep: f64) -> Seg2 {
|
||||
Seg2::Arc { center, radius, start, sweep }
|
||||
}
|
||||
|
||||
/// The plan's A3 sheet, the whole document: a drawing border closed by `Z`, two trimming fills.
|
||||
#[test]
|
||||
fn an_a3_drawing_border_is_this_document() {
|
||||
let scene = sheet(vec![
|
||||
stroke(rectangle(p(20.0, 10.0), p(410.0, 287.0)).segs, true, 0.7),
|
||||
Item::Fill { region: Region2 { outer: rectangle(p(0.0, 0.0), p(10.0, 5.0)), holes: vec![] } },
|
||||
Item::Fill { region: Region2 { outer: rectangle(p(0.0, 0.0), p(5.0, 10.0)), holes: vec![] } },
|
||||
]);
|
||||
let (text, report) = svg(&scene);
|
||||
let expected = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
|
||||
<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"420mm\" height=\"297mm\" viewBox=\"0 0 420 297\" fill=\"none\" stroke=\"#000\" \
|
||||
stroke-linecap=\"butt\" stroke-linejoin=\"miter\" stroke-miterlimit=\"4\">\n\
|
||||
<g data-object=\"1\" data-kind=\"drawing_border\">\n\
|
||||
<path stroke-width=\"0.7\" d=\"M20 287L410 287L410 10L20 10Z\"/>\n\
|
||||
<path fill=\"#000\" stroke=\"none\" fill-rule=\"evenodd\" d=\"M0 297L10 297L10 292L0 292Z\"/>\n\
|
||||
<path fill=\"#000\" stroke=\"none\" fill-rule=\"evenodd\" d=\"M0 297L5 297L5 287L0 287Z\"/>\n\
|
||||
</g>\n\
|
||||
</svg>\n";
|
||||
assert_eq!(text, expected);
|
||||
assert_eq!(report, SvgReport::default());
|
||||
assert_eq!(svg(&scene).0, text, "the same scene gives the same bytes");
|
||||
}
|
||||
|
||||
/// Arcs: anticlockwise on paper is sweep flag 0 after the flip, clockwise 1; at most a quarter turn
|
||||
/// a piece, so a 270° arc is three and a whole turn four, large-arc never 1.
|
||||
#[test]
|
||||
fn arcs_are_written_in_quarter_turns_with_the_flipped_sweep() {
|
||||
let c = p(50.0, 50.0);
|
||||
let open = |seg: Seg2| sheet(vec![stroke(vec![seg], false, 0.35)]);
|
||||
assert_eq!(first_d(&open(arc(c, 10.0, 0.0, FRAC_PI_2))), "M60 247A10 10 0 0 0 50 237");
|
||||
assert_eq!(first_d(&open(arc(c, 10.0, 0.0, 1.5 * PI))), "M60 247A10 10 0 0 0 50 237A10 10 0 0 0 40 247A10 10 0 0 0 50 257");
|
||||
assert_eq!(first_d(&open(arc(c, 10.0, FRAC_PI_2, -FRAC_PI_2))), "M50 237A10 10 0 0 1 60 247", "clockwise: sweep flag 1");
|
||||
let circle = first_d(&sheet(vec![stroke(vec![arc(c, 10.0, 0.0, TAU)], true, 0.35)]));
|
||||
assert_eq!(circle, "M60 247A10 10 0 0 0 50 237A10 10 0 0 0 40 247A10 10 0 0 0 50 257A10 10 0 0 0 60 247Z");
|
||||
let halves = first_d(&sheet(vec![stroke(Loop2::circle(c, 10.0).segs, true, 0.35)]));
|
||||
assert_eq!(halves, circle, "a circle of two halves is the same four quarters");
|
||||
// Just over a quarter is two pieces; a quarter and a hair (the 1e-9 margin) is one.
|
||||
assert_eq!(first_d(&open(arc(c, 10.0, 0.0, FRAC_PI_2 + 1e-12))).matches('A').count(), 1);
|
||||
assert_eq!(first_d(&open(arc(c, 10.0, 0.0, FRAC_PI_2 + 1e-6))).matches('A').count(), 2);
|
||||
}
|
||||
|
||||
/// Cubics as `C`, parabolas as `Q`; ellipses, hyperbolas and higher Béziers refused until A3b.
|
||||
#[test]
|
||||
fn cubics_and_parabolas_are_written_and_other_conics_refused() {
|
||||
let cubic = Seg2::Cubic { p0: p(0.0, 0.0), p1: p(10.0, 20.0), p2: p(30.0, 20.0), p3: p(40.0, 0.0) };
|
||||
assert_eq!(first_d(&sheet(vec![stroke(vec![cubic], false, 0.25)])), "M0 297C10 277 30 277 40 297");
|
||||
let parabola = Seg2::Conic { a: p(0.0, 0.0), apex: p(10.0, 10.0), b: p(20.0, 0.0), w: 1.0 };
|
||||
assert_eq!(first_d(&sheet(vec![stroke(vec![parabola], false, 0.25)])), "M0 297Q10 287 20 297");
|
||||
for w in [0.5, 2.0] {
|
||||
let conic = Seg2::Conic { a: p(0.0, 0.0), apex: p(10.0, 10.0), b: p(20.0, 0.0), w };
|
||||
let scene = sheet(vec![stroke(vec![conic], false, 0.25)]);
|
||||
let e = paper_svg(&scene.prepare().unwrap()).err().map(|e| e.to_string()).unwrap();
|
||||
assert!(e.contains("not supported yet"), "{w}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Numbers are whole nanometres: no exponent, no "-0", trailing zeros trimmed; a width under half
|
||||
/// a nanometre is written as one and reported.
|
||||
#[test]
|
||||
fn numbers_are_whole_nanometres() {
|
||||
for (n, text) in [(0, "0"), (1, "0.000001"), (-1, "-0.000001"), (1_500_000, "1.5"), (-420_000_000, "-420"), (123_456_789, "123.456789")] {
|
||||
assert_eq!(number(n), text);
|
||||
}
|
||||
assert_eq!((nano(-0.0).unwrap(), nano(4e-7).unwrap(), nano(6e-7).unwrap()), (0, 0, 1));
|
||||
assert!(nano(f64::NAN).is_err() && nano(1e10).is_err());
|
||||
let thin = sheet(vec![stroke(vec![line(p(0.0, 0.0), p(1.0, 0.0))], false, 1e-9)]);
|
||||
let (text, report) = svg(&thin);
|
||||
assert!(text.contains("stroke-width=\"0.000001\""), "{text}");
|
||||
assert_eq!(report.widths_snapped, 1);
|
||||
}
|
||||
|
||||
/// A closed path ends exactly on its first point, even when its end and start round apart; a
|
||||
/// sliver of a piece is merged into the next and reported; an open path's last piece is kept
|
||||
/// however short, since nothing follows it.
|
||||
#[test]
|
||||
fn joints_and_closure_are_exact_on_the_grid() {
|
||||
// The start rounds to 1 nanometre, the end (2e-10 away) to 0: the last curve ends on the start.
|
||||
let (s, e) = (p(5e-7 + 1e-10, 0.0), p(5e-7 - 1e-10, 0.0));
|
||||
let path =
|
||||
vec![line(s, p(10.0, 0.0)), line(p(10.0, 0.0), p(10.0, 10.0)), Seg2::Cubic { p0: p(10.0, 10.0), p1: p(5.0, 10.0), p2: p(0.0, 5.0), p3: e }];
|
||||
let d = first_d(&sheet(vec![stroke(path, true, 0.35)]));
|
||||
assert_eq!(d, "M0.000001 297L10 297L10 287C5 287 0 292 0.000001 297Z");
|
||||
// A 1e-9 mm line between two lines: merged, the path straight on to the next point.
|
||||
let path = vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(10.0, 1e-9)), line(p(10.0, 1e-9), p(15.0, 8.66))];
|
||||
let (text, report) = svg(&sheet(vec![stroke(path, false, 0.35)]));
|
||||
assert!(text.contains("d=\"M0 297L10 297L15 288.34\""), "{text}");
|
||||
assert_eq!(report.pieces_merged, 1);
|
||||
// A 5 nanometre last piece of an open path stays; one that rounds to no length doesn't.
|
||||
let path = vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(10.000005, 0.0))];
|
||||
let (text, report) = svg(&sheet(vec![stroke(path, false, 0.35)]));
|
||||
assert!(text.contains("d=\"M0 297L10 297L10.000005 297\""), "{text}");
|
||||
assert_eq!(report.pieces_merged, 0);
|
||||
let path = vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(10.0000001, 0.0))];
|
||||
assert_eq!(first_d(&sheet(vec![stroke(path, false, 0.35)])), "M0 297L10 297");
|
||||
// The same for an arc's last quarter: a 5 nanometre arc ending an open path is written.
|
||||
let path = vec![line(p(0.0, 0.0), p(10.0, 0.0)), arc(p(10.0, 1.0), 1.0, -FRAC_PI_2, 5e-6)];
|
||||
assert!(first_d(&sheet(vec![stroke(path, false, 0.35)])).starts_with("M0 297L10 297A1 1 0 0 0 10.000005 297"));
|
||||
}
|
||||
|
||||
/// Our own SVG reader takes the file back: in millimetres, lines exactly (these have at most six
|
||||
/// decimals), circular arcs as arcs. The reader's root transform puts the page's top at 0, so its
|
||||
/// y is ours less the height.
|
||||
#[test]
|
||||
fn the_svg_reader_reads_it_back() {
|
||||
let scene = sheet(vec![
|
||||
stroke(vec![line(p(20.0, 10.0), p(410.0, 10.0)), line(p(410.0, 10.0), p(410.0, 287.0))], false, 0.7),
|
||||
stroke(vec![arc(p(50.0, 50.0), 10.0, 0.0, FRAC_PI_2)], false, 0.35),
|
||||
]);
|
||||
let (text, _) = svg(&scene);
|
||||
let read = read_svg(&text).unwrap();
|
||||
let up = |v: Vec2| p(v.x, v.y + 297.0);
|
||||
let lines: Vec<(Vec2, Vec2)> = read.iter().filter_map(|g| if let Geom2::Line(a, b) = g { Some((up(*a), up(*b))) } else { None }).collect();
|
||||
assert_eq!(lines, [(p(20.0, 10.0), p(410.0, 10.0)), (p(410.0, 10.0), p(410.0, 287.0))]);
|
||||
let arcs: Vec<(Vec2, Vec2, Vec2)> =
|
||||
read.iter().filter_map(|g| if let Geom2::Arc { c, a, b } = g { Some((up(*c), up(*a), up(*b))) } else { None }).collect();
|
||||
assert_eq!(arcs.len(), 1, "{read:?}");
|
||||
let (c, a, b) = arcs[0];
|
||||
assert!(c.dist(p(50.0, 50.0)) < 3e-6 && a.dist(p(60.0, 50.0)) < 3e-6 && b.dist(p(50.0, 60.0)) < 3e-6, "{:?}", arcs[0]);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Bounded PAT import into kernel-independent hatch records.
|
||||
//!
|
||||
//! The public PAT syntax uses degrees and unitless lengths. Import options give those
|
||||
//! lengths a paper-millimetre meaning; no drawing view scale is applied here. Names,
|
||||
//! family order, signed angles and dash/gap/dot values are preserved, including repeated
|
||||
//! names. This reader neither installs a pattern library nor generates hatch geometry.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_geom::hatch::{Family, MAX_DASHES, MAX_FAMILIES, Pattern};
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
const MAX_PAT_BYTES: usize = 1 << 20;
|
||||
const MAX_PAT_PATTERNS: usize = 1_000;
|
||||
const MAX_PAT_LINE_CHARS: usize = 80;
|
||||
|
||||
/// Length unit of the PAT source, which has no embedded unit declaration.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum PatUnits {
|
||||
#[default]
|
||||
Millimetres,
|
||||
Inches,
|
||||
}
|
||||
|
||||
/// Convert source lengths to paper millimetres, independently of a drawing view's scale.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PatImportOptions {
|
||||
pub units: PatUnits,
|
||||
/// Positive finite multiplier, applied once to every origin, delta and dash length.
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl Default for PatImportOptions {
|
||||
fn default() -> Self {
|
||||
Self { units: PatUnits::Millimetres, scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Read complete PAT definitions in their input order, or report an error without a
|
||||
/// partial result. Duplicate names, including case variants, remain separate definitions.
|
||||
///
|
||||
/// Input must be UTF-8; one initial BOM is accepted. Descriptions and comments may be
|
||||
/// Unicode, while names and numbers use ASCII syntax. Each physical line is limited to
|
||||
/// 80 Unicode scalar values before removing comments, excluding CRLF/LF and the initial
|
||||
/// BOM. The 1 MiB byte limit includes both. Bare CR and embedded BOMs are rejected.
|
||||
/// At most 1,000 patterns, 64 families per pattern and six dashes per family are admitted.
|
||||
///
|
||||
/// A real blank physical line (empty or containing only ASCII spaces/tabs) must follow
|
||||
/// the final family. A single terminal newline or a comment-only line is insufficient.
|
||||
/// Errors identify the physical one-based line; invalid options and the total byte cap
|
||||
/// are import errors without a line. Converted values also pass the hatch validators.
|
||||
pub fn read_pat(bytes: &[u8], options: PatImportOptions) -> Result<Vec<Pattern>> {
|
||||
if bytes.len() > MAX_PAT_BYTES {
|
||||
return Err(IoError::Invalid("PAT import: input exceeds 1 MiB".into()));
|
||||
}
|
||||
let factor = length_factor(options)?;
|
||||
let text = std::str::from_utf8(bytes).map_err(|error| {
|
||||
let line = 1 + bytes.iter().take(error.valid_up_to()).filter(|byte| **byte == b'\n').count();
|
||||
invalid(line, "input is not valid UTF-8")
|
||||
})?;
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let mut patterns = Vec::new();
|
||||
let mut current: Option<(Pattern, usize)> = None;
|
||||
let mut final_blank = false;
|
||||
let mut physical_lines = 0;
|
||||
|
||||
// split_inclusive does not manufacture a blank record for the last newline.
|
||||
for (index, raw) in text.split_inclusive('\n').enumerate() {
|
||||
let line_number = index + 1;
|
||||
physical_lines = line_number;
|
||||
let line = match raw.strip_suffix('\n') {
|
||||
Some(line) => line.strip_suffix('\r').unwrap_or(line),
|
||||
None => raw,
|
||||
};
|
||||
check_physical_line(line, line_number)?;
|
||||
if trim_horizontal(line).is_empty() {
|
||||
final_blank = true;
|
||||
continue;
|
||||
}
|
||||
let content = trim_horizontal(line.split_once(';').map_or(line, |(content, _)| content));
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
final_blank = false;
|
||||
if let Some(header) = content.strip_prefix('*') {
|
||||
finish_pattern(&mut current, &mut patterns)?;
|
||||
if patterns.len() >= MAX_PAT_PATTERNS {
|
||||
return Err(invalid(line_number, "more than 1000 patterns"));
|
||||
}
|
||||
let (name, description) = header.split_once(',').unwrap_or((header, ""));
|
||||
current = Some((
|
||||
Pattern { name: trim_horizontal(name).into(), description: trim_horizontal(description).into(), families: Vec::new() },
|
||||
line_number,
|
||||
));
|
||||
} else {
|
||||
let (pattern, _) = current.as_mut().ok_or_else(|| invalid(line_number, "family appears before a pattern header"))?;
|
||||
if pattern.families.len() >= MAX_FAMILIES {
|
||||
return Err(invalid(line_number, "more than 64 families in one pattern"));
|
||||
}
|
||||
pattern.families.push(read_family(content, line_number, factor)?);
|
||||
}
|
||||
}
|
||||
finish_pattern(&mut current, &mut patterns)?;
|
||||
if patterns.is_empty() {
|
||||
return Err(invalid(1, "no pattern definitions"));
|
||||
}
|
||||
if !final_blank {
|
||||
return Err(invalid(physical_lines + 1, "missing final blank line after the last family"));
|
||||
}
|
||||
Ok(patterns)
|
||||
}
|
||||
|
||||
fn length_factor(options: PatImportOptions) -> Result<f64> {
|
||||
if !options.scale.is_finite() || options.scale <= 0.0 {
|
||||
return Err(IoError::Invalid("PAT import: scale must be positive and finite".into()));
|
||||
}
|
||||
let unit = match options.units {
|
||||
PatUnits::Millimetres => 1.0,
|
||||
PatUnits::Inches => 25.4,
|
||||
};
|
||||
let factor = unit * options.scale;
|
||||
if !factor.is_finite() || factor <= 0.0 {
|
||||
return Err(IoError::Invalid("PAT import: units and scale produce an invalid length factor".into()));
|
||||
}
|
||||
Ok(factor)
|
||||
}
|
||||
|
||||
fn trim_horizontal(text: &str) -> &str {
|
||||
text.trim_matches([' ', '\t'])
|
||||
}
|
||||
|
||||
fn invalid(line: usize, reason: impl std::fmt::Display) -> IoError {
|
||||
IoError::Invalid(format!("PAT line {line}: {reason}"))
|
||||
}
|
||||
|
||||
fn check_physical_line(line: &str, number: usize) -> Result<()> {
|
||||
if line.chars().take(MAX_PAT_LINE_CHARS + 1).count() > MAX_PAT_LINE_CHARS {
|
||||
return Err(invalid(number, "physical line exceeds 80 characters"));
|
||||
}
|
||||
if line.contains('\u{feff}') {
|
||||
return Err(invalid(number, "a BOM is only allowed once at the start of the file"));
|
||||
}
|
||||
if line.chars().any(|c| c.is_control() && c != '\t') {
|
||||
return Err(invalid(number, "control character or bare CR in physical line"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_pattern(current: &mut Option<(Pattern, usize)>, patterns: &mut Vec<Pattern>) -> Result<()> {
|
||||
if let Some((pattern, header_line)) = current.take() {
|
||||
pattern.validate().map_err(|error| invalid(header_line, error))?;
|
||||
patterns.push(pattern);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_family(line: &str, number: usize, factor: f64) -> Result<Family> {
|
||||
let mut fields = line.split(',');
|
||||
let mut next = |name| -> Result<f64> {
|
||||
let field = fields.next().ok_or_else(|| invalid(number, format!("missing {name} field")))?;
|
||||
read_number(field, number, name)
|
||||
};
|
||||
let degrees = next("angle")?;
|
||||
let angle = degrees.to_radians();
|
||||
if degrees != 0.0 && angle == 0.0 {
|
||||
return Err(invalid(number, "angle underflows to zero radians"));
|
||||
}
|
||||
let origin_x = scaled(next("origin x")?, factor, number, "origin x")?;
|
||||
let origin_y = scaled(next("origin y")?, factor, number, "origin y")?;
|
||||
let delta_x = scaled(next("delta x")?, factor, number, "delta x")?;
|
||||
let delta_y = scaled(next("delta y")?, factor, number, "delta y")?;
|
||||
let mut dashes = Vec::new();
|
||||
for field in fields {
|
||||
if dashes.len() >= MAX_DASHES {
|
||||
return Err(invalid(number, "more than 6 dash entries"));
|
||||
}
|
||||
dashes.push(scaled(read_number(field, number, "dash")?, factor, number, "dash")?);
|
||||
}
|
||||
let family = Family { angle, origin: Vec2::new(origin_x, origin_y), delta_x, delta_y, dashes };
|
||||
family.validate().map_err(|error| invalid(number, error))?;
|
||||
Ok(family)
|
||||
}
|
||||
|
||||
fn read_number(field: &str, line: usize, name: &str) -> Result<f64> {
|
||||
let token = trim_horizontal(field);
|
||||
if !token.is_ascii() || token.is_empty() {
|
||||
return Err(invalid(line, format!("{name} needs an ASCII number")));
|
||||
}
|
||||
let value: f64 = token.parse().map_err(|_| invalid(line, format!("invalid {name} number")))?;
|
||||
if !value.is_finite() {
|
||||
return Err(invalid(line, format!("{name} must be finite")));
|
||||
}
|
||||
// A nonzero decimal must not become a dot, zero origin or zero spacing during parsing.
|
||||
let mantissa = token.split(['e', 'E']).next().unwrap_or(token);
|
||||
if value == 0.0 && mantissa.bytes().any(|b| matches!(b, b'1'..=b'9')) {
|
||||
return Err(invalid(line, format!("{name} number underflows to zero")));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn scaled(value: f64, factor: f64, line: usize, name: &str) -> Result<f64> {
|
||||
let converted = value * factor;
|
||||
if !converted.is_finite() {
|
||||
return Err(invalid(line, format!("{name} is not finite after unit/scale conversion")));
|
||||
}
|
||||
if value != 0.0 && converted == 0.0 {
|
||||
return Err(invalid(line, format!("{name} underflows to zero after unit/scale conversion")));
|
||||
}
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pat_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,598 @@
|
||||
//! Original PAT inputs: no pattern library or output from another CAD product.
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
|
||||
use crate::{IoError, PatImportOptions, PatUnits, read_pat};
|
||||
use solidcraft_geom::hatch::{self, Family, Mark, Pattern};
|
||||
use solidcraft_geom::{Loop2, Region2, Vec2};
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, SQRT_2};
|
||||
|
||||
const UNIT: &str = "*UNIT,Original unit probe\n90,1,-2,0.5,4,2,-1,0\n\n";
|
||||
const CONTINUOUS: &str = "*LINES\n0,0,0,0,1\n\n";
|
||||
|
||||
fn read(text: &str) -> Vec<Pattern> {
|
||||
read_pat(text.as_bytes(), PatImportOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
fn family(text: &str, options: PatImportOptions) -> Family {
|
||||
let mut patterns = read_pat(text.as_bytes(), options).unwrap();
|
||||
assert_eq!(patterns.len(), 1);
|
||||
let mut pattern = patterns.remove(0);
|
||||
assert_eq!(pattern.families.len(), 1);
|
||||
pattern.families.remove(0)
|
||||
}
|
||||
|
||||
fn invalid(bytes: &[u8], options: PatImportOptions) -> String {
|
||||
match read_pat(bytes, options) {
|
||||
Err(IoError::Invalid(message)) => message,
|
||||
other => panic!("expected a PAT input error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn line_error(text: &str, line: usize) -> String {
|
||||
let message = invalid(text.as_bytes(), PatImportOptions::default());
|
||||
assert!(message.starts_with(&format!("PAT line {line}:")), "{message}");
|
||||
message
|
||||
}
|
||||
|
||||
fn descriptor(fields: &[&str]) -> String {
|
||||
format!("*PROBE\n{}\n\n", fields.join(","))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_and_scale_convert_every_length_but_not_the_angle() {
|
||||
let defaults = PatImportOptions::default();
|
||||
assert!(matches!(defaults.units, PatUnits::Millimetres));
|
||||
assert_eq!(defaults.scale, 1.0);
|
||||
// Powers of two preserve the binary 25.4 conversion factor exactly. Expected
|
||||
// values are independent literals, not calls to the importer's conversion.
|
||||
for (units, scale, origin, delta, dashes) in [
|
||||
(PatUnits::Millimetres, 1.0, [1.0, -2.0], [0.5, 4.0], [2.0, -1.0, 0.0]),
|
||||
(PatUnits::Millimetres, 2.0, [2.0, -4.0], [1.0, 8.0], [4.0, -2.0, 0.0]),
|
||||
(PatUnits::Inches, 1.0, [25.4, -50.8], [12.7, 101.6], [50.8, -25.4, 0.0]),
|
||||
(PatUnits::Inches, 2.0, [50.8, -101.6], [25.4, 203.2], [101.6, -50.8, 0.0]),
|
||||
] {
|
||||
let patterns = read_pat(UNIT.as_bytes(), PatImportOptions { units, scale }).unwrap();
|
||||
assert_eq!(patterns.len(), 1);
|
||||
assert_eq!(patterns[0].name, "UNIT");
|
||||
assert_eq!(patterns[0].description, "Original unit probe");
|
||||
assert_eq!(patterns[0].families.len(), 1);
|
||||
let f = &patterns[0].families[0];
|
||||
assert_eq!(f.angle, FRAC_PI_2);
|
||||
assert_eq!([f.origin.x, f.origin.y], origin);
|
||||
assert_eq!([f.delta_x, f.delta_y], delta);
|
||||
assert_eq!(f.dashes, dashes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_deltas_signed_angles_and_scientific_notation_are_preserved() {
|
||||
let text = "*LOCAL\n -9e1 , 1e0 , -2e0 , 5e-1 , -4e0 , 2e0 , -1e0 , 0 \n\n";
|
||||
let f = family(text, PatImportOptions::default());
|
||||
assert_eq!(f.angle, -FRAC_PI_2);
|
||||
assert_eq!([f.origin.x, f.origin.y], [1.0, -2.0]);
|
||||
assert_eq!([f.delta_x, f.delta_y], [0.5, -4.0]);
|
||||
assert_eq!(f.dashes, [2.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_dots_and_gap_only_families_keep_distinct_meanings() {
|
||||
assert!(family(CONTINUOUS, PatImportOptions::default()).dashes.is_empty());
|
||||
assert_eq!(read(CONTINUOUS)[0].description, "");
|
||||
for dashes in ["0,-1", "2,-1,0", "-2,-1"] {
|
||||
let text = format!("*DASHES\n0,0,0,0,1,{dashes}\n\n");
|
||||
let f = family(&text, PatImportOptions::default());
|
||||
let expected: &[f64] = match dashes {
|
||||
"0,-1" => &[0.0, -1.0],
|
||||
"2,-1,0" => &[2.0, -1.0, 0.0],
|
||||
_ => &[-2.0, -1.0],
|
||||
};
|
||||
assert_eq!(f.dashes, expected);
|
||||
}
|
||||
for dashes in ["0", "0,-0", "0,0,0,0,0,0"] {
|
||||
line_error(&format!("*ZERO\n0,0,0,0,1,{dashes}\n\n"), 2);
|
||||
}
|
||||
for spacing in ["0", "-0", "0.0", "-0e12"] {
|
||||
line_error(&format!("*ZERO\n0,0,0,0,{spacing}\n\n"), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_nonzero_spacing_and_dashes_are_not_snapped_to_zero() {
|
||||
let text = "*SMALL\n0,0,0,0,0.00000095367431640625,0.00000095367431640625,-1\n\n";
|
||||
let f = family(text, PatImportOptions::default());
|
||||
assert_eq!(f.delta_y, 0.00000095367431640625);
|
||||
assert_eq!(f.dashes, [0.00000095367431640625, -1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_whitespace_crlf_and_family_order_are_supported() {
|
||||
let text = "; original test data\n\n*ORDER, First, second ; ignored\n\t0,0,0,0,1 ; family one\n\
|
||||
90,2,3,4,-5,2,-1\n \t\n; trailing comment\n";
|
||||
for input in [text.to_owned(), text.replace('\n', "\r\n")] {
|
||||
let patterns = read(&input);
|
||||
assert_eq!(patterns.len(), 1);
|
||||
assert_eq!(patterns[0].description, "First, second");
|
||||
assert_eq!(patterns[0].families.len(), 2);
|
||||
assert_eq!(patterns[0].families[0].angle, 0.0);
|
||||
assert_eq!(patterns[0].families[0].delta_y, 1.0);
|
||||
assert_eq!(patterns[0].families[1].angle, FRAC_PI_2);
|
||||
assert_eq!(patterns[0].families[1].delta_x, 4.0);
|
||||
assert_eq!(patterns[0].families[1].delta_y, -5.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_preserve_each_definition_case_and_order() {
|
||||
let text = "*Same,First\n0,0,0,0,1\n*Same,Second\n0,0,0,0,2\n*same,Third\n0,0,0,0,3\n\n";
|
||||
let patterns = read(text);
|
||||
assert_eq!(patterns.len(), 3);
|
||||
for (p, (name, description, spacing)) in patterns.iter().zip([("Same", "First", 1.0), ("Same", "Second", 2.0), ("same", "Third", 3.0)]) {
|
||||
assert_eq!(p.name, name);
|
||||
assert_eq!(p.description, description);
|
||||
assert_eq!(p.families.len(), 1);
|
||||
assert_eq!(p.families[0].delta_y, spacing);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_follow_the_ascii_grammar_and_thirty_one_character_limit() {
|
||||
for name in ["A_1-$".to_owned(), "9x".to_owned(), "A".repeat(31)] {
|
||||
assert_eq!(read(&format!("*{name}\n0,0,0,0,1\n\n"))[0].name, name);
|
||||
}
|
||||
for name in ["", "has space", "bad/name", "bad.name", "_first", "-first", "$first", "é", "A\tB", "A\0B"] {
|
||||
line_error(&format!("*{name}\n0,0,0,0,1\n\n"), 1);
|
||||
}
|
||||
line_error(&format!("*{}\n0,0,0,0,1\n\n", "A".repeat(32)), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_descriptions_comments_and_one_initial_bom_are_supported() {
|
||||
let text = "\u{feff}*UTF8,Rayado café, 漢字\n0,0,0,0,1 ; comentario válido\n\n";
|
||||
let patterns = read(text);
|
||||
assert_eq!(patterns[0].name, "UTF8");
|
||||
assert_eq!(patterns[0].description, "Rayado café, 漢字");
|
||||
assert_eq!(patterns[0].families[0].delta_y, 1.0);
|
||||
line_error(&format!("\u{feff}\u{feff}{CONTINUOUS}"), 1);
|
||||
line_error("*A\n0,0,0,0,1\n\u{feff}*B\n0,0,0,0,2\n\n", 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_line_limit_counts_characters_before_removing_comments() {
|
||||
for character in ['x', 'é', '界'] {
|
||||
let description = character.to_string().repeat(77);
|
||||
let header = format!("*T,{description}");
|
||||
assert_eq!(header.chars().count(), 80);
|
||||
for ending in ["\n", "\r\n"] {
|
||||
let text = format!("{header}{ending}0,0,0,0,1{ending}{ending}");
|
||||
assert_eq!(read(&text)[0].description, description);
|
||||
}
|
||||
for count in [78, 79] {
|
||||
let header = format!("*T,{}", character.to_string().repeat(count));
|
||||
assert!(matches!(header.chars().count(), 81 | 82));
|
||||
line_error(&format!("{header}\n0,0,0,0,1\n\n"), 1);
|
||||
}
|
||||
let comment = format!(";{}", character.to_string().repeat(79));
|
||||
assert_eq!(comment.chars().count(), 80);
|
||||
assert_eq!(read(&format!("{comment}\n{CONTINUOUS}")).len(), 1);
|
||||
line_error(&format!("{comment}{character}\n{CONTINUOUS}"), 1);
|
||||
}
|
||||
let line = format!("0,0,0,0,1;{}", "x".repeat(70));
|
||||
assert_eq!(line.len(), 80);
|
||||
assert_eq!(read(&format!("*T\n{line}\n\n")).len(), 1);
|
||||
line_error(&format!("*T\n{line}x\n\n"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_and_bare_carriage_returns_report_physical_lines() {
|
||||
let bytes = b"; original\n\n*TEXT,broken \xff\n0,0,0,0,1\n\n";
|
||||
let message = invalid(bytes, PatImportOptions::default());
|
||||
assert!(message.starts_with("PAT line 3:"), "{message}");
|
||||
line_error("*A\n0,0,0,0,1\r\n\r\n*B\r0,0,0,0,2\n\n", 4);
|
||||
line_error("*A\n0,0,0,0,1\0\n\n", 2);
|
||||
line_error("*A,embedded\tcontrol\n0,0,0,0,1\n\n", 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_blank_line_after_the_final_family_is_required() {
|
||||
for text in ["*A\n0,0,0,0,1\n\n", "*A\n0,0,0,0,1\n \t\n", "*A\n0,0,0,0,1\n \t", "*A\n0,0,0,0,1\n\n; after blank"] {
|
||||
assert_eq!(read(text).len(), 1);
|
||||
assert_eq!(read(&text.replace('\n', "\r\n")).len(), 1);
|
||||
}
|
||||
for text in ["*A\n0,0,0,0,1", "*A\n0,0,0,0,1\n", "*A\r\n0,0,0,0,1\r\n"] {
|
||||
line_error(text, 3);
|
||||
}
|
||||
line_error("*A\n0,0,0,0,1\n; only a comment\n", 4);
|
||||
line_error("*A\n0,0,0,0,1\n\n90,0,0,0,2\n", 5);
|
||||
line_error("*A\n0,0,0,0,1\n\n*B\n0,0,0,0,2\n", 6);
|
||||
assert_eq!(read("*A\n0,0,0,0,1\n; comment\n\n").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_and_patterns_without_families_are_errors() {
|
||||
for text in ["", "\n", " \t\r\n", "; comments only\n\n"] {
|
||||
let message = invalid(text.as_bytes(), PatImportOptions::default());
|
||||
assert!(message.starts_with("PAT "), "{message}");
|
||||
}
|
||||
line_error("0,0,0,0,1\n\n", 1);
|
||||
line_error("*EMPTY\n\n", 1);
|
||||
line_error("; comment\n*EMPTY\n*FULL\n0,0,0,0,1\n\n", 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_descriptor_errors_keep_physical_line_numbers_and_no_partial_result() {
|
||||
for line in ["0,0,0,1", "0,0,0,0,1,", "0,0,,0,1", "0,0,0,0,1x", "0,0,0,0,2mm", "0,0,0,0,1+2", "0,0,0,0,١"] {
|
||||
let text = format!("; original\n\n*BAD\n{line}\n\n");
|
||||
line_error(&text, 4);
|
||||
line_error(&text.replace('\n', "\r\n"), 4);
|
||||
line_error(&format!("*GOOD\n0,0,0,0,1\n*BAD\n{line}\n\n"), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_dashes_are_accepted_and_seven_are_refused() {
|
||||
let f = family("*SIX\n0,0,0,0,1,1,-1,0,2,-2,0\n\n", PatImportOptions::default());
|
||||
assert_eq!(f.dashes, [1.0, -1.0, 0.0, 2.0, -2.0, 0.0]);
|
||||
line_error("*SEVEN\n0,0,0,0,1,1,-1,0,2,-2,0,1\n\n", 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_numeric_field_rejects_nonfinite_and_overflowed_input() {
|
||||
for index in 0..8 {
|
||||
for bad in ["NaN", "inf", "-inf", "1e309", "-1e309"] {
|
||||
let mut fields = ["90", "1", "-2", "0.5", "4", "2", "-1", "0"];
|
||||
fields[index] = bad;
|
||||
line_error(&descriptor(&fields), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_length_is_bounded_after_scale_and_unit_conversion() {
|
||||
for index in 1..8 {
|
||||
for sign in [1.0, -1.0] {
|
||||
let limit = (sign * 50000.0).to_string();
|
||||
let beyond = (sign * 50001.0).to_string();
|
||||
let mut fields = ["0", "1", "-2", "0.5", "4", "2", "-1", "0"];
|
||||
fields[index] = &limit;
|
||||
let f = family(&descriptor(&fields), PatImportOptions { units: PatUnits::Millimetres, scale: 2.0 });
|
||||
let lengths = [f.origin.x, f.origin.y, f.delta_x, f.delta_y, f.dashes[0], f.dashes[1], f.dashes[2]];
|
||||
assert_eq!(lengths[index - 1], sign * 100000.0);
|
||||
fields[index] = &beyond;
|
||||
let message = invalid(descriptor(&fields).as_bytes(), PatImportOptions { units: PatUnits::Millimetres, scale: 2.0 });
|
||||
assert!(message.starts_with("PAT line 2:"), "{message}");
|
||||
}
|
||||
let mut fields = ["0", "1", "-2", "0.5", "4", "2", "-1", "0"];
|
||||
fields[index] = "2048";
|
||||
let f = family(&descriptor(&fields), PatImportOptions { units: PatUnits::Inches, scale: 1.0 });
|
||||
let lengths = [f.origin.x, f.origin.y, f.delta_x, f.delta_y, f.dashes[0], f.dashes[1], f.dashes[2]];
|
||||
assert_eq!(lengths[index - 1], 52019.2);
|
||||
fields[index] = "4096";
|
||||
let message = invalid(descriptor(&fields).as_bytes(), PatImportOptions { units: PatUnits::Inches, scale: 1.0 });
|
||||
assert!(message.starts_with("PAT line 2:"), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_angle_bound_is_in_radians_and_is_independent_of_length_units() {
|
||||
for units in [PatUnits::Millimetres, PatUnits::Inches] {
|
||||
for angle in ["57000000", "-57000000"] {
|
||||
let f = family(&format!("*ANGLE\n{angle},0,0,0,1\n\n"), PatImportOptions { units, scale: 2.0 });
|
||||
assert!(f.angle.abs() > 900000.0 && f.angle.abs() < 1000000.0);
|
||||
assert_eq!(f.angle.is_sign_negative(), angle.starts_with('-'));
|
||||
}
|
||||
for angle in ["58000000", "-58000000"] {
|
||||
let message = invalid(format!("*ANGLE\n{angle},0,0,0,1\n\n").as_bytes(), PatImportOptions { units, scale: 2.0 });
|
||||
assert!(message.starts_with("PAT line 2:"), "{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_scales_and_overflowed_conversion_are_refused() {
|
||||
for scale in [0.0, -0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let message = invalid(UNIT.as_bytes(), PatImportOptions { units: PatUnits::Millimetres, scale });
|
||||
assert!(message.starts_with("PAT import:"), "{message}");
|
||||
}
|
||||
let message = invalid(UNIT.as_bytes(), PatImportOptions { units: PatUnits::Inches, scale: f64::MAX });
|
||||
assert!(message.starts_with("PAT import:"), "{message}");
|
||||
let message = invalid(UNIT.as_bytes(), PatImportOptions { units: PatUnits::Millimetres, scale: f64::MAX });
|
||||
assert!(message.starts_with("PAT line 2:"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_must_not_turn_nonzero_input_into_zero() {
|
||||
for index in 1..7 {
|
||||
let mut fields = ["0", "1", "1", "1", "1", "1", "-1"];
|
||||
fields[index] = "0.5";
|
||||
let message = invalid(descriptor(&fields).as_bytes(), PatImportOptions { units: PatUnits::Millimetres, scale: f64::from_bits(1) });
|
||||
assert!(message.starts_with("PAT line 2:"), "{message}");
|
||||
}
|
||||
line_error("*ANGLE\n5e-324,0,0,0,1\n\n", 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_underflow_is_not_mistaken_for_an_authored_zero() {
|
||||
for index in 0..7 {
|
||||
for underflow in ["1e-999", "-1e-999"] {
|
||||
let mut fields = ["0", "1", "1", "1", "1", "1", "-1"];
|
||||
fields[index] = underflow;
|
||||
line_error(&descriptor(&fields), 2);
|
||||
}
|
||||
}
|
||||
let text = "*ZEROS\n0e999,0e999,-0e999,0e999,1,0e999,-1\n\n";
|
||||
let f = family(text, PatImportOptions::default());
|
||||
assert_eq!(f.angle, 0.0);
|
||||
assert_eq!([f.origin.x, f.origin.y, f.delta_x, f.delta_y], [0.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(f.dashes, [0.0, -1.0]);
|
||||
}
|
||||
|
||||
fn padded_file(bytes: usize) -> Vec<u8> {
|
||||
let padding = bytes - CONTINUOUS.len();
|
||||
let mut text = format!(";{}\n", "p".repeat(79)).repeat(padding / 81);
|
||||
match padding % 81 {
|
||||
0 => {}
|
||||
1 => text.push('\n'),
|
||||
remainder => text.push_str(&format!(";{}\n", "p".repeat(remainder - 2))),
|
||||
}
|
||||
text.push_str(CONTINUOUS);
|
||||
assert_eq!(text.len(), bytes);
|
||||
assert!(text.lines().all(|line| line.len() <= 80));
|
||||
text.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_mebibyte_is_accepted_and_one_more_byte_is_refused_before_decoding() {
|
||||
let patterns = read_pat(&padded_file(1_048_576), PatImportOptions::default()).unwrap();
|
||||
assert_eq!(patterns.len(), 1);
|
||||
assert_eq!(patterns[0].name, "LINES");
|
||||
for bytes in [padded_file(1_048_577), vec![0xff; 1_048_577]] {
|
||||
let message = invalid(&bytes, PatImportOptions::default());
|
||||
assert!(message.starts_with("PAT import:"), "{message}");
|
||||
}
|
||||
let mut with_bom = vec![0xef, 0xbb, 0xbf];
|
||||
with_bom.extend(padded_file(1_048_573));
|
||||
assert_eq!(with_bom.len(), 1_048_576);
|
||||
assert_eq!(read_pat(&with_bom, PatImportOptions::default()).unwrap().len(), 1);
|
||||
with_bom.push(b'\n');
|
||||
let message = invalid(&with_bom, PatImportOptions::default());
|
||||
assert!(message.starts_with("PAT import:"), "{message}");
|
||||
|
||||
let mut unicode = format!(";{}\n", "é".repeat(39)).repeat(13_108);
|
||||
unicode.push_str(CONTINUOUS);
|
||||
assert!(unicode.len() > 1_048_576 && unicode.chars().count() < 1_048_576);
|
||||
assert!(unicode.lines().all(|line| line.chars().count() <= 80));
|
||||
let message = invalid(unicode.as_bytes(), PatImportOptions::default());
|
||||
assert!(message.starts_with("PAT import:"), "{message}");
|
||||
}
|
||||
|
||||
fn patterns_file(count: usize) -> String {
|
||||
let mut text = String::new();
|
||||
for i in 0..count {
|
||||
text.push_str(&format!("*P{i:04}\n0,0,0,0,1\n"));
|
||||
}
|
||||
text.push('\n');
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_thousand_patterns_is_inclusive_and_excess_is_not_truncated() {
|
||||
let patterns = read(&patterns_file(1000));
|
||||
assert_eq!(patterns.len(), 1000);
|
||||
assert_eq!(patterns[0].name, "P0000");
|
||||
assert_eq!(patterns[999].name, "P0999");
|
||||
line_error(&patterns_file(1001), 2001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixty_four_families_is_a_per_pattern_limit() {
|
||||
let mut text = String::new();
|
||||
for name in ["FIRST", "SECOND"] {
|
||||
text.push_str(&format!("*{name}\n"));
|
||||
for spacing in 1..=64 {
|
||||
text.push_str(&format!("0,0,0,0,{spacing}\n"));
|
||||
}
|
||||
}
|
||||
text.push('\n');
|
||||
let patterns = read(&text);
|
||||
assert_eq!(patterns.len(), 2);
|
||||
for pattern in patterns {
|
||||
assert_eq!(pattern.families.len(), 64);
|
||||
for (family, expected) in pattern.families.iter().zip(1..=64) {
|
||||
assert_eq!(family.delta_y, f64::from(expected));
|
||||
}
|
||||
}
|
||||
line_error(&format!("*TOO_MANY\n{}\n", "0,0,0,0,1\n".repeat(65)), 66);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_byte_mutations_return_errors_or_complete_valid_patterns() {
|
||||
let input = UNIT.as_bytes();
|
||||
for end in 0..=input.len() {
|
||||
if let Ok(patterns) = read_pat(&input[..end], PatImportOptions::default()) {
|
||||
assert!(!patterns.is_empty());
|
||||
for pattern in patterns {
|
||||
pattern.validate().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
for index in 0..input.len() {
|
||||
for byte in [0, 0xff, b',', b';', b'\n'] {
|
||||
let mut changed = input.to_vec();
|
||||
changed[index] = byte;
|
||||
if let Ok(patterns) = read_pat(&changed, PatImportOptions::default()) {
|
||||
assert!(!patterns.is_empty());
|
||||
for pattern in patterns {
|
||||
pattern.validate().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DIAGONAL_PAT: &str = "*R45,Original 45 degree lines\n45,0,0,0,5\n\n";
|
||||
// Same analytic fixture and comparison tolerance as geom's hatch/clip_tests.rs;
|
||||
// this is not a clipping, snapping or import-admission tolerance.
|
||||
const DIAGONAL_TOL_MM: f64 = 1e-9;
|
||||
|
||||
fn paper_rectangle(width: f64, height: f64) -> Region2 {
|
||||
let outer = Loop2::polygon(&[Vec2::ZERO, Vec2::new(width, 0.0), Vec2::new(width, height), Vec2::new(0.0, height)]);
|
||||
Region2 { outer, holes: vec![] }
|
||||
}
|
||||
|
||||
fn imported_diagonal_output(region: &Region2, options: PatImportOptions, spacing: f64) -> Vec<Mark> {
|
||||
let direct = Pattern {
|
||||
name: "R45".into(),
|
||||
description: "Original 45 degree lines".into(),
|
||||
families: vec![Family { angle: FRAC_PI_4, origin: Vec2::ZERO, delta_x: 0.0, delta_y: spacing, dashes: vec![] }],
|
||||
};
|
||||
let imported = read_pat(DIAGONAL_PAT.as_bytes(), options).unwrap();
|
||||
assert_eq!(imported, vec![direct.clone()]);
|
||||
let patterns_before = imported.clone();
|
||||
let region_before = region.clone();
|
||||
let marks = hatch::generate(&imported[0], region).unwrap();
|
||||
assert_eq!(marks, hatch::generate(&direct, region).unwrap());
|
||||
assert_eq!(imported, patterns_before);
|
||||
assert_eq!(region, ®ion_before);
|
||||
marks
|
||||
}
|
||||
|
||||
fn diagonal_oracle(with_hole: bool) -> Vec<[f64; 4]> {
|
||||
let r = SQRT_2;
|
||||
// Original 40x20 mm oracle: y-x=5*sqrt(2)*k, k=-5..2.
|
||||
// The hole [10,20] x [5,15] splits only members -2, -1 and 0.
|
||||
if with_hole {
|
||||
vec![
|
||||
[25.0 * r, 0.0, 40.0, 40.0 - 25.0 * r],
|
||||
[20.0 * r, 0.0, 40.0, 40.0 - 20.0 * r],
|
||||
[15.0 * r, 0.0, 40.0, 40.0 - 15.0 * r],
|
||||
[10.0 * r, 0.0, 5.0 + 10.0 * r, 5.0],
|
||||
[20.0, 20.0 - 10.0 * r, 20.0 + 10.0 * r, 20.0],
|
||||
[5.0 * r, 0.0, 5.0 + 5.0 * r, 5.0],
|
||||
[20.0, 20.0 - 5.0 * r, 20.0 + 5.0 * r, 20.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[15.0, 15.0, 20.0, 20.0],
|
||||
[0.0, 5.0 * r, 20.0 - 5.0 * r, 20.0],
|
||||
[0.0, 10.0 * r, 20.0 - 10.0 * r, 20.0],
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
[25.0 * r, 0.0, 40.0, 40.0 - 25.0 * r],
|
||||
[20.0 * r, 0.0, 40.0, 40.0 - 20.0 * r],
|
||||
[15.0 * r, 0.0, 40.0, 40.0 - 15.0 * r],
|
||||
[10.0 * r, 0.0, 20.0 + 10.0 * r, 20.0],
|
||||
[5.0 * r, 0.0, 20.0 + 5.0 * r, 20.0],
|
||||
[0.0, 0.0, 20.0, 20.0],
|
||||
[0.0, 5.0 * r, 20.0 - 5.0 * r, 20.0],
|
||||
[0.0, 10.0 * r, 20.0 - 10.0 * r, 20.0],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_diagonal_marks(marks: &[Mark], expected: &[[f64; 4]], total_length: f64) {
|
||||
assert_eq!(marks.len(), expected.len());
|
||||
let mut total = 0.0;
|
||||
for mark in marks {
|
||||
let Mark::Line { a, b } = *mark else { panic!("continuous PAT produced a dot") };
|
||||
assert!(a.is_finite() && b.is_finite() && a != b);
|
||||
total += a.dist(b);
|
||||
}
|
||||
// Consume the full multiset; order and endpoint direction are immaterial.
|
||||
let mut unmatched = marks.to_vec();
|
||||
for &[ax, ay, bx, by] in expected {
|
||||
let c = Vec2::new(ax, ay);
|
||||
let d = Vec2::new(bx, by);
|
||||
let found = unmatched.iter().position(|mark| match *mark {
|
||||
Mark::Line { a, b } => {
|
||||
(a.dist(c) <= DIAGONAL_TOL_MM && b.dist(d) <= DIAGONAL_TOL_MM) || (a.dist(d) <= DIAGONAL_TOL_MM && b.dist(c) <= DIAGONAL_TOL_MM)
|
||||
}
|
||||
Mark::Dot { .. } => false,
|
||||
});
|
||||
let Some(index) = found else { panic!("missing segment {c:?} -> {d:?}; remaining {unmatched:?}") };
|
||||
unmatched.swap_remove(index);
|
||||
}
|
||||
assert!(unmatched.is_empty());
|
||||
assert!((total - total_length).abs() <= DIAGONAL_TOL_MM, "actual length {total}, expected {total_length}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_diagonal_generates_all_eight_analytic_rectangle_segments() {
|
||||
let region = paper_rectangle(40.0, 20.0);
|
||||
let marks = imported_diagonal_output(®ion, PatImportOptions::default(), 5.0);
|
||||
assert_diagonal_marks(&marks, &diagonal_oracle(false), 220.0 * SQRT_2 - 150.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_diagonal_keeps_all_eleven_segments_and_leaves_the_hole_unchanged() {
|
||||
let mut region = paper_rectangle(40.0, 20.0);
|
||||
region.holes.push(Loop2::polygon(&[Vec2::new(10.0, 5.0), Vec2::new(10.0, 15.0), Vec2::new(20.0, 15.0), Vec2::new(20.0, 5.0)]));
|
||||
let marks = imported_diagonal_output(®ion, PatImportOptions::default(), 5.0);
|
||||
assert_diagonal_marks(&marks, &diagonal_oracle(true), 185.0 * SQRT_2 - 120.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_units_and_scale_reach_generation_once_in_paper_millimetres() {
|
||||
// Similarity of the same original rectangle oracle, not expected geometry
|
||||
// calculated from the imported Pattern or generated marks. Fixed paper
|
||||
// regions expose missing/repeated conversion. There is no view-scale API.
|
||||
for (options, width, height, spacing, factor) in [
|
||||
(PatImportOptions { units: PatUnits::Millimetres, scale: 2.0 }, 80.0, 40.0, 10.0, 2.0),
|
||||
(PatImportOptions { units: PatUnits::Inches, scale: 0.5 }, 508.0, 254.0, 63.5, 12.7),
|
||||
] {
|
||||
let region = paper_rectangle(width, height);
|
||||
let marks = imported_diagonal_output(®ion, options, spacing);
|
||||
let expected: Vec<_> = diagonal_oracle(false).into_iter().map(|line| line.map(|coordinate| coordinate * factor)).collect();
|
||||
assert_diagonal_marks(&marks, &expected, (220.0 * SQRT_2 - 150.0) * factor);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_decimal_spacing_reaches_generation_after_one_unit_conversion() {
|
||||
// Original PAT records. Independent binary64 ratios after conversion:
|
||||
// 0.1 = 3602879701896397 / 2^55; 1.5875 = 3574732204225331 / 2^51.
|
||||
for (text, options, spacing, numerator, denominator, half_height, last) in [
|
||||
(
|
||||
"*SC_DECIMAL,Original decimal spacing\n0,0,0,0,0.2\n\n",
|
||||
PatImportOptions { units: PatUnits::Millimetres, scale: 0.5 },
|
||||
0.1,
|
||||
3_602_879_701_896_397i128,
|
||||
36_028_797_018_963_968.0,
|
||||
0.95,
|
||||
9i64,
|
||||
),
|
||||
(
|
||||
"*SC_DECIMAL,Original decimal spacing\n0,0,0,0,0.125\n\n",
|
||||
PatImportOptions { units: PatUnits::Inches, scale: 0.5 },
|
||||
1.5875,
|
||||
3_574_732_204_225_331i128,
|
||||
2_251_799_813_685_248.0,
|
||||
5.0,
|
||||
3i64,
|
||||
),
|
||||
] {
|
||||
let imported = read_pat(text.as_bytes(), options).unwrap();
|
||||
assert_eq!(imported.len(), 1);
|
||||
assert_eq!(imported[0].families[0].delta_y, spacing);
|
||||
let before = imported.clone();
|
||||
let region = Region2 {
|
||||
outer: Loop2::polygon(&[
|
||||
Vec2::new(-1.0, -half_height),
|
||||
Vec2::new(1.0, -half_height),
|
||||
Vec2::new(1.0, half_height),
|
||||
Vec2::new(-1.0, half_height),
|
||||
]),
|
||||
holes: vec![],
|
||||
};
|
||||
let saved_region = region.clone();
|
||||
let marks = hatch::generate(&imported[0], ®ion).unwrap();
|
||||
let expected: Vec<_> = (-last..=last)
|
||||
.map(|k| {
|
||||
let y = (i128::from(k) * numerator) as f64 / denominator;
|
||||
Mark::Line { a: Vec2::new(-1.0, y), b: Vec2::new(1.0, y) }
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(marks, expected);
|
||||
assert_eq!(marks.len(), if last == 9 { 19 } else { 7 });
|
||||
assert_eq!(imported, before);
|
||||
assert_eq!(region, saved_region);
|
||||
}
|
||||
}
|
||||
@@ -329,7 +329,7 @@ revision, other tools' data under a reverse-DNS key (the same rules as the desig
|
||||
properties), and free text properties such as a material grade or a finish. Every occurrence of
|
||||
the component shares them, Paste New copies them, and they are saved with the component.
|
||||
`component.metadata {component}` reads them and
|
||||
`component.metadata.set {component, part_number?, description?, revision?, values?, properties?}`
|
||||
`component.metadata.set {component, part_number?, description?, revision?, bom_type?, values?, properties?}`
|
||||
changes them in one undo step (an empty value removes a field). The root component is the
|
||||
design itself: its properties are `document.metadata`'s. Standard parts (`parts.insert`) get a
|
||||
part number from their standard and size (`ISO 4762 M6x20`, or a bearing's designation) and a
|
||||
@@ -351,8 +351,23 @@ description; **Change Part Size** renumbers them, keeping a field you changed yo
|
||||
rows. `total_occurrences` counts the children too, so it is the same either way.
|
||||
- `component` lists one subassembly's parts instead of the design's.
|
||||
|
||||
A component's **bill of materials type** (`bom_type`, a part property set with
|
||||
`component.metadata.set {component, bom_type}`) decides how it counts in the assemblies it is
|
||||
placed in. Without one it is `normal`.
|
||||
|
||||
| Type | For | In the assembly it is placed in |
|
||||
|---|---|---|
|
||||
| `normal` | Something you make | A row of its own; every occurrence adds to its quantity |
|
||||
| `phantom` | Something just to group (a fastener set) | No row: what it holds counts as if it were placed there directly, in the same rows as the same parts placed there |
|
||||
| `reference` | Something to use as a reference (a fixture shown for context) | Counted nowhere, and nothing inside it is counted either |
|
||||
| `purchased` | Something you buy (a gearbox) | One row; nothing inside it is counted or listed |
|
||||
| `inseparable` | Something made as one piece: welded, glued, riveted and so on (a welded frame) | One row; in parts only, the purchased parts inside it are listed too, and the rest of what it holds is listed in its own bill of materials |
|
||||
|
||||
Two components with the same part number share a row only when their types agree. A
|
||||
component's own bill of materials (`component`) lists what it holds whatever its type.
|
||||
|
||||
Rows are sorted by part number (rows without one come last) and hold `item`, `part_number`,
|
||||
`description`, `revision`, `count`, `definition` (the component names), `components` (their ids)
|
||||
`description`, `revision`, `bom_type`, `count`, `definition` (the component names), `components` (their ids)
|
||||
and `properties`, with `total_occurrences` beside them. `path` writes them to a `.csv` file
|
||||
(the columns item, part_number, description, revision, count and definition, every field
|
||||
quoted; children follow their row) or a `.json` file. The window lists the same rows, switches
|
||||
|
||||
@@ -46,6 +46,43 @@ An appearance sets colour and surface finish only. It has no effect on mass or s
|
||||
- **Elsewhere:** appearance, visual material, colour.
|
||||
- **For translators:** not the physical material.
|
||||
|
||||
### bill of materials
|
||||
|
||||
The list of the components placed in a design or in one component, with the quantity of each.
|
||||
|
||||
It lists every part at any depth, or only the direct children. Each component's *bill of
|
||||
materials type* decides whether it has a row and whether what it holds is counted.
|
||||
|
||||
- **In SolidCraft:** `assembly.bom` ("Bill of Materials") and its window on the INSPECT panel;
|
||||
each row's `item`, `part_number` and `count`; a component's `metadata` and `properties`
|
||||
design-file keys, which `component.metadata.set` changes.
|
||||
- **Don't use:** BOM in labels; parts list (for the bill of materials itself).
|
||||
- **Elsewhere:** bill of materials, BOM, parts list.
|
||||
- **For translators:** not a parts list on a drawing, which is one table that shows it.
|
||||
|
||||
### bill of materials type
|
||||
|
||||
The rule that decides whether a component has a row in a bill of materials, and whether what it
|
||||
holds is counted there.
|
||||
|
||||
There are five:
|
||||
|
||||
- *normal*, something you make: a row; each occurrence adds one to its quantity.
|
||||
- *phantom*, something just to group: no row; what it holds counts where it is placed.
|
||||
- *reference*, something to use as a reference: no row, and nothing inside it counts.
|
||||
- *purchased*, something you buy: one row; nothing inside it is counted or listed.
|
||||
- *inseparable*, something made as one piece (welded, glued, riveted and so on): one row; of
|
||||
what it holds, only the purchased components are listed, and only in a list of every part.
|
||||
|
||||
- **In SolidCraft:** the `bom_type` part property, which `component.metadata.set` changes and
|
||||
`component.metadata` reads; the `bom_type` of each `assembly.bom` row.
|
||||
- **Don't use:** BOM structure, item type, kind; made (for *normal*: it reads as "already
|
||||
made").
|
||||
- **Elsewhere:** BOM structure, BOM type.
|
||||
- **For translators:** *normal* here is not the sketch line type, a direction or the display
|
||||
quality (see *Words with more than one meaning*); *reference* is not a construction plane;
|
||||
*phantom* is not a hidden or see-through display.
|
||||
|
||||
### body
|
||||
|
||||
One connected solid or surface shape that features create and change.
|
||||
@@ -348,6 +385,7 @@ share a catalogue entry. Each meaning below gets its own term and, in time, its
|
||||
| Normal | a sketch entity that isn't a construction entity | normal (line type) | the sketch palette's line type |
|
||||
| | the direction perpendicular to a surface | normal (direction) | the ruled surface's Normal mode |
|
||||
| | the middle display quality | normal (quality) | `ui.display_quality_normal` |
|
||||
| | a component with a row of its own in a bill of materials | normal (bill of materials type) | `bom_type` |
|
||||
|
||||
Some translations show the clash today: Japanese and Chinese show a hole's flat bottom with the
|
||||
word for "unfold" or "plane", Chinese shows "Round" as "fillet", and Chinese and Russian show the
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
# Hatch patterns, PAT import and bounded generation (W38d D1–D6)
|
||||
|
||||
D1 supplies a neutral hatch-pattern model and a bounded PAT reader for
|
||||
[W38d (#7062)][issue], following [the drawing-sketch handoff][handoff].
|
||||
D2 adds bounded line-and-dot generation inside polygonal regions with holes.
|
||||
D3 extends its phase arithmetic and clipping queries while retaining the D1
|
||||
model, public generation API, polygon-only scope and existing resource caps.
|
||||
D4 adds end-to-end PAT-to-polygon acceptance cases. D5 extends clipping to a
|
||||
canonical disk or an annulus with one concentric circular hole, retaining the
|
||||
polygon path, phase rules and caps. D6 admits bounded decimal member spacing
|
||||
by carrying exact-product enclosures through the same clipping paths.
|
||||
These are library foundations: they do not attach a fill to a sketch, save
|
||||
drawing records, change the paper scene or register a command. They do not
|
||||
complete W38d or implement section-derived hatching (W34).
|
||||
|
||||
## Public API
|
||||
|
||||
The L0 [`solidcraft_geom::hatch` module][model] contains:
|
||||
|
||||
- `Pattern { name, description, families }`.
|
||||
- `Family { angle, origin, delta_x, delta_y, dashes }`.
|
||||
- `Pattern::validate()` and `Family::validate()`, returning
|
||||
`Result<(), hatch::Invalid>`.
|
||||
|
||||
`Pattern::lines_45()` supplies original continuous 45-degree lines;
|
||||
`Pattern::cross_hatch()` supplies 45- and 135-degree families. Each family has
|
||||
1 mm nominal spacing, zero origin and tangential delta, and no dashes. Solid
|
||||
paint is a later separate alternative, not a pattern with no families.
|
||||
|
||||
The records support serde and reject unknown fields. Deserialization is not
|
||||
validation: call the validator before using a deserialized or edited value.
|
||||
These checks also do not bound allocations during decoding. An encoded-input
|
||||
adapter must enforce its own byte and collection limits.
|
||||
|
||||
L3 [`solidcraft_io::read_pat`][reader] accepts an in-memory byte slice:
|
||||
|
||||
```rust
|
||||
read_pat(bytes: &[u8], options: PatImportOptions)
|
||||
-> solidcraft_io::Result<Vec<solidcraft_geom::hatch::Pattern>>
|
||||
```
|
||||
|
||||
`PatImportOptions` has `units: PatUnits` and `scale: f64`.
|
||||
`PatUnits` is `Millimetres` or `Inches`; the default is millimetres and scale
|
||||
`1.0`. The reader returns validated definitions in input order. Duplicate names
|
||||
remain separate entries with their spelling preserved; this function does not
|
||||
register, rename, merge or overwrite patterns.
|
||||
|
||||
The reader does not open files or modify a document. It returns either the
|
||||
complete list or an error, with no partial list.
|
||||
|
||||
D2 adds [`hatch::generate`][generator], using the existing `geom::Region2`:
|
||||
|
||||
```rust
|
||||
generate(pattern: &Pattern, region: &Region2) -> Result<Vec<Mark>, hatch::Invalid>
|
||||
```
|
||||
|
||||
`Mark` is either `Line { a: Vec2, b: Vec2 }` or `Dot { at: Vec2 }`.
|
||||
These are transient geometric marks in the supplied region's paper XY frame,
|
||||
not paper-scene objects or saved fill records. A dot remains a point; it is not
|
||||
a zero-length line. Each explicit zero in a dash sequence produces its own
|
||||
eligible dot, even if another zero coincides with it. The call validates the
|
||||
pattern and supported region, leaves both inputs unchanged, and returns the
|
||||
complete result or an error. No partial output is returned after a failure.
|
||||
|
||||
## Coordinates, units and dash phase
|
||||
|
||||
All lengths in the neutral model are **paper millimetres at pattern scale 1**;
|
||||
angles are radians, counterclockwise. `origin` is in the pattern's XY frame.
|
||||
For a family with angle `a`, define its unit tangent `u = (cos(a), sin(a))`
|
||||
and normal `n = (-sin(a), cos(a))`. Its member with signed integer index `k`
|
||||
has the mathematical parameterization:
|
||||
|
||||
```text
|
||||
p(k, t) = origin + k * (delta_x * u + delta_y * n) + t * u
|
||||
```
|
||||
|
||||
`delta_x` shifts the phase along the line; `delta_y` is signed perpendicular
|
||||
spacing. The origin is not pre-rotated by the family angle. This formula defines
|
||||
the data's meaning. D2 enumerates a bounded set of members that can meet the region.
|
||||
|
||||
PAT angles arrive in degrees and become radians. The length multiplier is
|
||||
`scale` for millimetres or `25.4 * scale` for inches. It applies once to both
|
||||
origin coordinates, both deltas and every dash length, and never to the angle.
|
||||
The scale and multiplier must be finite and positive. Overflow, or conversion
|
||||
of a nonzero input into zero, is an error; values are not clamped. Model bounds
|
||||
are checked **after** conversion.
|
||||
|
||||
A family whose source spacing is `2`, imported with inches and scale `0.5`,
|
||||
therefore has `25.4 mm` perpendicular spacing. A future import command can
|
||||
show these converted values before saving anything. PAT supplies no units;
|
||||
the reader does not guess a unit system from the filename or pattern name.
|
||||
|
||||
An empty dash list means a continuous line. In a nonempty list, positive lengths
|
||||
draw, negative lengths leave gaps, and zero denotes a dot. The cycle length is
|
||||
the sum of absolute lengths. All-zero cycles are invalid; negative-only cycles
|
||||
are valid definitions that paint nothing. D2 keeps phase anchored at `p(k, 0)`
|
||||
across clipped fragments and holes; clipping does not restart the dash sequence.
|
||||
|
||||
The eventual paper adapter must keep, for example, a 3 mm hatch spacing at 3 mm
|
||||
on paper when a view scale changes. It must transform the region to paper
|
||||
coordinates without applying that view scale to the pattern a second time.
|
||||
D1–D6 have no view or placement API. A multi-family pattern also has no single
|
||||
intrinsic spacing: a future spacing control must define its relationship to
|
||||
pattern scale explicitly.
|
||||
|
||||
## Model limits
|
||||
|
||||
These are SolidCraft admission limits, not physical precision guarantees or
|
||||
claims about every PAT implementation.
|
||||
|
||||
| Value | Accepted domain |
|
||||
| --- | --- |
|
||||
| Pattern name | 1–31 ASCII characters; first a letter or digit, then letters, digits, `_`, `-` or `$` |
|
||||
| Description | At most 80 Unicode scalar values; no control characters |
|
||||
| Families per pattern | 1–64 |
|
||||
| Dash entries per family | 0–6 |
|
||||
| Origin components, deltas and dash lengths | Finite, absolute value at most `100000 mm` |
|
||||
| Angle | Finite, absolute value at most `1000000 rad` |
|
||||
| Perpendicular delta | Nonzero, including rejection of either signed zero |
|
||||
| Nonempty dash cycle | Finite, strictly positive sum of absolute lengths |
|
||||
|
||||
There is no minimum positive spacing epsilon. A definition can pass D1 yet be
|
||||
too dense for the bounded generator. Validation does not authorize unlimited
|
||||
line, intersection, dash or point expansion.
|
||||
|
||||
## PAT text policy and errors
|
||||
|
||||
The reader follows the public [pattern-definition syntax][pat-reference] and
|
||||
[dash/offset semantics][dash-reference]. Its encoding policy is UTF-8 with an
|
||||
optional single initial UTF-8 BOM; it does not guess legacy code pages.
|
||||
Additional BOMs and physical control characters other than tabs are rejected,
|
||||
including in comments.
|
||||
Names and numeric tokens use ASCII syntax. Descriptions and comments may contain
|
||||
Unicode. Limits are:
|
||||
|
||||
| Boundary | Limit |
|
||||
| --- | --- |
|
||||
| Input, checked before decoding | 1 MiB (1,048,576 bytes), including any BOM |
|
||||
| Patterns in one import | 1,000 |
|
||||
| Physical line | 80 Unicode scalar values, excluding LF or CRLF and the initial BOM, before removing comments |
|
||||
|
||||
LF and CRLF endings are accepted; a bare CR is refused. A header starts with
|
||||
`*`, followed by the name and an optional comma-separated description.
|
||||
Only the first header comma separates the name; subsequent commas belong to the
|
||||
description. A descriptor contains five required comma-separated numbers:
|
||||
angle, origin X, origin Y, tangential delta and perpendicular delta, followed
|
||||
by zero to six dash entries. Decimal exponent notation is accepted. Empty
|
||||
numeric fields, a trailing comma and nonfinite values are errors. A nonzero
|
||||
decimal that underflows to zero while parsing is also refused.
|
||||
|
||||
Blank lines and text after `;` do not contribute to syntax. Horizontal tabs
|
||||
are permitted as lexical whitespace, but control characters are not valid in a
|
||||
name or description. Empty/comment-only input, a descriptor without a header,
|
||||
and a pattern with no family are refused. Collection limits are checked before
|
||||
growing their vectors.
|
||||
|
||||
**The final blank line is required.** After the last descriptor, there must be
|
||||
a physical line containing only spaces/tabs or nothing. A single terminating
|
||||
newline is insufficient, and a comment-only line does not substitute for the
|
||||
blank line. A final spaces/tabs-only line may end at EOF without LF. Comments
|
||||
after that blank line are allowed; a subsequent header or descriptor requires
|
||||
another final blank line. For example, these are original
|
||||
synthetic test inputs, shown as escaped strings:
|
||||
|
||||
```text
|
||||
"*SC_SAMPLE, Original example\n30,0,0,0,4\n\n" accepted
|
||||
"*SC_SAMPLE, Original example\n30,0,0,0,4\n" missing final blank line
|
||||
```
|
||||
|
||||
Errors use `solidcraft_io::IoError::Invalid`. Text errors start with
|
||||
`PAT line N:`, using one-based physical line numbers, including the relevant
|
||||
header or descriptor line. Missing the blank line in a two-line definition
|
||||
reports expected line 3. UTF-8 errors identify the physical line where possible.
|
||||
Option and total byte-limit errors start with `PAT import:`, without an
|
||||
invented line number.
|
||||
|
||||
## Supported regions
|
||||
|
||||
### Polygonal regions retained from D2
|
||||
|
||||
The [clipper][clipper] accepts one `Region2`: an outer loop and zero or more
|
||||
holes, all made of `Seg2::Line`. Every loop must have at least three edges,
|
||||
close exactly, use finite nondegenerate segments and be simple. All input and
|
||||
generated coordinate components must lie within `+/-100000 mm`. Either loop
|
||||
orientation is accepted; the outer/hole roles come from `Region2`, not winding.
|
||||
Holes must lie strictly inside the outer loop, without touching, crossing or
|
||||
nesting within one another. Unsupported or
|
||||
uncertain geometry returns `hatch::Invalid`; the input is not snapped, repaired
|
||||
or approximated. D5 leaves this polygon admission unchanged; its separate
|
||||
circular path below does not admit mixed line-and-arc regions.
|
||||
|
||||
### Canonical circles and one concentric hole (D5)
|
||||
|
||||
The [circular clipper][circle-clipper] supports a disk, or an annulus whose only hole has
|
||||
exactly the same finite center and a radius strictly between zero and the outer
|
||||
radius. Each loop must contain exactly two `Seg2::Arc` records with identical
|
||||
center and radius and one of these ordered `(start, sweep)` signatures:
|
||||
|
||||
| Encoding | First arc | Second arc |
|
||||
| --- | --- | --- |
|
||||
| Forward | `(0, PI)` | `(PI, PI)` |
|
||||
| Reversed | `(TAU, -PI)` | `(PI, -PI)` |
|
||||
|
||||
Cyclic rotation of either pair is accepted. `PI` and `TAU` denote the stored
|
||||
Rust `f64` constants; matching uses exact numeric equality, including equivalence
|
||||
of signed zeros. The recognizer checks fields, not which constructor created
|
||||
them: `Loop2::circle_from` is accepted when it produces the same fields, but an
|
||||
arbitrary seam angle is not. Every segment must also pass `Seg2::check`, and the
|
||||
complete circle bounding box must satisfy the existing `+/-100000 mm` coordinate
|
||||
cap. Validating only its two seam endpoints would not establish that bound.
|
||||
|
||||
This is an explicit interpretation of the complete-circle encoding documented
|
||||
by [`Loop2::circle`][profile]. It does not snap evaluated arc endpoints or infer
|
||||
closure from a tolerance. Evaluating the trigonometric endpoints may round;
|
||||
recognition does not change those records or claim bitwise endpoint closure.
|
||||
|
||||
Other seams, a single full-turn arc, four quarter arcs, partial or mixed loops,
|
||||
eccentric holes, multiple circular holes, conics and Bézier curves remain
|
||||
unsupported. A one-ULP change to a required signature field is not repaired.
|
||||
The same applies to mismatched centers/radii and equal inner/outer radii.
|
||||
Passing `Seg2::check` alone does not establish this narrower circle contract.
|
||||
The numerical checks below can still refuse a structurally admitted circle.
|
||||
|
||||
### Shared boundary and contact policy
|
||||
|
||||
Clipping preserves positive-length interior spans and their boundary contacts.
|
||||
A span's endpoints may lie on the boundary; its interior must lie in material.
|
||||
An isolated contact splits spans, including a tangent contact that does not
|
||||
change crossing parity. A line running along a boundary does not paint that
|
||||
boundary: coincident boundary intervals are subtracted, rather than rejected
|
||||
merely for coincidence. Dots are emitted only in the strict interior, never at
|
||||
a boundary contact. The clipper does not bridge a hole or merge spans across a contact.
|
||||
|
||||
Numerical predicates use arithmetic enclosures for the represented inputs.
|
||||
Unresolved signs, overlapping contact bounds or a dot whose membership cannot
|
||||
be distinguished from a contact return an error. Endpoints and frame
|
||||
reconstruction are rounded floating-point values, not an exact coordinate or
|
||||
universal geometric-tolerance certificate. In particular, some mathematically
|
||||
valid supported regions or near-contact patterns can be refused without a fallback.
|
||||
|
||||
The generator uses a normalized tangent and its left normal. In that frame,
|
||||
member `k` has normal offset `k * delta_y` and tangential dash phase
|
||||
`k * delta_x`. The phase is applied once, including for negative member indices,
|
||||
negative perpendicular spacing and negative dash-cycle positions. Output follows
|
||||
family order, ascending signed member index, then ascending cycles within each
|
||||
clipped span. This is not a global coordinate sort.
|
||||
|
||||
## D3 exact phase and clipping parameters
|
||||
|
||||
D2 required exact binary64 results for the normal offset `k * delta_y`,
|
||||
tangential phase `k * delta_x`, and each cumulative sum of absolute dash
|
||||
lengths. That policy refused, for example, the cycle sum for `[0.1, -0.2]`
|
||||
even though the stored pattern passes D1 validation.
|
||||
|
||||
D3 originally retained the exact-binary64 check for every candidate normal
|
||||
offset `k * delta_y`, including conservative exterior candidates, while replacing
|
||||
the phase and cycle-sum restrictions with bounded exact arithmetic in the private
|
||||
[`phase` module][phase]. D6 below removes that remaining exact-product restriction
|
||||
by reusing the same arithmetic and carrying its enclosure into clipping.
|
||||
No new public type, dependency or saved field is added.
|
||||
|
||||
### What is exact
|
||||
|
||||
The arithmetic starts from the **binary64 values already stored in the
|
||||
pattern**. A private dyadic value represents `N * 2^e`, with a checked `i128`
|
||||
coefficient and an integer exponent. This does not recover exact decimal text
|
||||
from PAT or reverse earlier unit conversion.
|
||||
|
||||
The member phase `H = k * delta_x`, dash prefixes and period `P` are built
|
||||
exactly from those represented inputs. Each candidate parameter is formed as
|
||||
`H + j * P + prefix` before conversion to floating point. A prefix at `P`
|
||||
uses the exact successor cycle endpoint. Global signed cycle indices and the
|
||||
original zero-token identities remain intact across holes and contact splits.
|
||||
|
||||
Only the checked dyadic converter constructs the private `Param` value. Its
|
||||
`lo()` and `hi()` are the finite floating-point neighbours enclosing the exact
|
||||
parameter; `nearest()` is one round-to-nearest, ties-to-even conversion of the
|
||||
complete expression. An exactly representable parameter has equal bounds.
|
||||
The implementation does not first round a cycle base and then add a prefix.
|
||||
|
||||
This admits some decimal-valued dash sequences that D2 refused: for instance,
|
||||
the represented inputs `[0.1, -0.2]` can form an exact dyadic period. Success
|
||||
still depends on the region, certified normal offsets, arithmetic envelope,
|
||||
clipping classification and cumulative budgets. It is not a promise to generate every
|
||||
finite pattern or every decimal spacing.
|
||||
|
||||
### Clipping before rounding for output
|
||||
|
||||
For each span, candidate cycle bounds use the retained lower bound of its left
|
||||
contact and upper bound of its right contact. Exact signed floor division,
|
||||
followed by checked padding, selects candidate cycles. The preceding cycle
|
||||
remains eligible for a trailing zero. Rounded span midpoints never determine
|
||||
the candidate window or dot membership.
|
||||
|
||||
The private clipping adapter consumes complete parameter enclosures:
|
||||
|
||||
- A dot is emitted only when its entire enclosure is strictly inside one span.
|
||||
An enclosure proven exterior or exactly on a boundary yields no dot;
|
||||
unresolved contact overlap returns an error. Revisiting the same token for
|
||||
another candidate span must not duplicate it. Different explicit zero tokens
|
||||
at the same coordinate remain separate marks.
|
||||
- A positive dash requires its start enclosure to lie strictly before its end
|
||||
enclosure. Its intersection with a span is selected by comparisons with the
|
||||
retained contact bounds. A proven empty intersection yields no stroke;
|
||||
uncertain overlap or rounded collapse returns an error. This conservative
|
||||
adapter may refuse an exact positive dash whose endpoint enclosures overlap
|
||||
or touch. It does not weaken the pattern validator.
|
||||
- A selected exact dash endpoint uses `nearest()` once. A selected geometric
|
||||
contact keeps the clipper's existing rounded span endpoint. The resulting scalar
|
||||
endpoints are terminal output for the existing frame reconstruction; they
|
||||
are never reused as containment certificates.
|
||||
|
||||
Thus exact phase arithmetic does **not** make geometric intersections, the
|
||||
normalized frame or final `Vec2` coordinates exact. The existing finite,
|
||||
distinct-endpoint and directional-progress checks still apply. D5 sends its
|
||||
circle-root enclosures through the same clipping queries; it does not change
|
||||
the phase arithmetic or the boundary-contact rules.
|
||||
|
||||
### Bounded arithmetic envelope
|
||||
|
||||
The representation has explicit internal limits:
|
||||
|
||||
| Value | Accepted domain |
|
||||
| --- | --- |
|
||||
| Raw and normalized coefficient | `-i128::MAX..=i128::MAX`; `i128::MIN` is refused |
|
||||
| Input and normalized exponent | `-1074..=1023` |
|
||||
| Nonzero canonical coefficient | Odd after bounded normalization |
|
||||
| Canonical zero | `(0, 0)`, without merging zero tokens |
|
||||
| Converted parameter | Finite lower, upper and nearest values |
|
||||
|
||||
Alignment shifts, addition, subtraction, integer multiplication, signed floor
|
||||
division and normalization are checked. An intermediate that does not fit is an
|
||||
error, even if a wider calculation could cancel or normalize it later.
|
||||
The arithmetic never saturates, silently discards low bits or guesses a cycle
|
||||
range. For example, dividing the least positive subnormal dyadic by one may
|
||||
refuse because the required alignment does not fit `i128`, despite its
|
||||
mathematical floor being zero. Values below the least positive subnormal are
|
||||
outside this representation; they are not silently converted to zero.
|
||||
|
||||
The generator's separate `+/-2^52` admission limit still applies to enumerated
|
||||
member and cycle indices, including padded candidates. Exact arithmetic does
|
||||
not raise that limit or bypass existing progress checks: cycle endpoints and
|
||||
nonzero token endpoints must still advance after nearest rounding, including
|
||||
gap-only visits.
|
||||
|
||||
## D5 circle roots in the represented frame
|
||||
|
||||
The stored normalized tangent `u` and its left normal `n` are floating-point
|
||||
vectors. Their squared length `S = u dot u` has a certified positive enclosure;
|
||||
normalizing a vector does not establish that its represented squared length is
|
||||
exactly one. Circle clipping uses this same `Frame`, including `S`.
|
||||
|
||||
For a circle with center `C` and radius `r`, `Frame::project(C)` supplies
|
||||
bounded tangent/normal coordinates `(tc, hc)`. For a member at normal offset
|
||||
`offset = k * delta_y`, the equations on the represented frame are:
|
||||
|
||||
```text
|
||||
radius_squared_in_frame = r * r / S
|
||||
Q = radius_squared_in_frame - (offset - hc)^2
|
||||
normal range = hc +/- sqrt(radius_squared_in_frame)
|
||||
tangent roots = tc +/- sqrt(Q)
|
||||
```
|
||||
|
||||
All operations carry conservative enclosures. The normal range bounds candidate
|
||||
member enumeration. The root classification is:
|
||||
|
||||
| Enclosure for Q | Result |
|
||||
| --- | --- |
|
||||
| `Q.hi < 0` | No intersection |
|
||||
| `Q.lo == Q.hi == 0` | One contact, without a parity toggle |
|
||||
| `Q.lo > 0` | Two crossing events |
|
||||
| Otherwise | Whole-result numeric refusal |
|
||||
|
||||
A floating-point square root supplies a candidate, not a proof of its own error
|
||||
bound. At most four charged candidate checks per endpoint, moving to a neighbour
|
||||
when needed, must establish the lower/upper square inequalities with the existing
|
||||
conservative product bounds. Failure to establish those bounds is an error. There is no assumed ULP guarantee
|
||||
for `sqrt`, epsilon band, negative-radicand clamp, tessellation or open-ended
|
||||
root iteration.
|
||||
|
||||
The resulting root enclosures enter the existing event sweep and remain attached
|
||||
to its spans. Unresolved event order refuses the whole result. Candidate windows
|
||||
and D3 dot/dash queries use those retained bounds, not their rounded output
|
||||
endpoints. This is particularly important for contacts:
|
||||
|
||||
- An outer tangency produces no stroke or dot.
|
||||
- An inner tangency splits the material into two retained strokes at the
|
||||
contact, without changing material parity. A dot at the contact is excluded;
|
||||
the two strokes must not be joined across it.
|
||||
- A certified miss of the inner circle leaves one continuous material span;
|
||||
a certified secant removes a positive-width hole interval. These
|
||||
three cases are not merged by a geometric tolerance.
|
||||
|
||||
The exact phase, candidate-index limits and numeric-progress rules from D3 are
|
||||
unchanged. Root certification is not a universal accuracy guarantee for final
|
||||
rounded XY marks or a promise to handle every finite circle/frame combination.
|
||||
|
||||
## D6 bounded decimal member spacing
|
||||
|
||||
A validated family may use a decimal spacing such as `0.1 mm`. Even though the
|
||||
stored value is binary64, an integer multiple can fall between two binary64
|
||||
values. D1–D5 refused such a product before attempting to clip the member.
|
||||
D6 forms `k * delta_y` in the existing checked dyadic representation and passes
|
||||
its finite neighbour enclosure to both polygon and canonical-circle clipping.
|
||||
The product is exact for the **stored input**, not for the decimal source text.
|
||||
|
||||
Classification, intersections and circle-root predicates consume the enclosure;
|
||||
`nearest()` is used only in the final frame reconstruction. It is never fed back
|
||||
as an exact normal offset. Signed member order and the existing numeric-progress
|
||||
check remain. All arithmetic is charged to the same work budget, so the added
|
||||
checks can exhaust a tight budget sooner; none of the ceilings are increased.
|
||||
|
||||
This admits ordinary decimal-spaced members away from uncertain contacts without
|
||||
an epsilon or a snap. It does **not** promise success for every finite pattern:
|
||||
if the enclosure meets a polygon vertex/edge or an uncertain circle tangency,
|
||||
the complete generation still fails. For example, three times stored `0.1`
|
||||
lies between stored `0.3` and its successor. Where that enclosure touches a
|
||||
boundary, rounding first would guess a boundary relation; D6 refuses it.
|
||||
The supported polygon/disk/concentric-annulus topology is unchanged.
|
||||
|
||||
Original independent tests check the following, with the original D1–D5 tests
|
||||
and expectations retained:
|
||||
|
||||
| Fixture | Expected result |
|
||||
| --- | --- |
|
||||
| Rectangle `[-1,1] x [-0.95,0.95]`, signed spacing `+/-0.1` | 19 full strokes at the independently calculated member positions |
|
||||
| Rectangle with an interior hole, spacing `0.1`, phase `k/2`, `[0,-1]` | 16 dots; the hole does not restart phase |
|
||||
| Same region/phase with `[1,-1]` | 13 clipped strokes, including split hole contacts |
|
||||
| Canonical disk R1.125, optional concentric R0.25 hole, spacing `0.1` | 23 disk strokes or 28 annulus strokes, checked against circle equations |
|
||||
| Decimal offset enclosure touching a polygon or circle boundary | Explicit whole-result refusal, not a guessed hit/miss/tangent |
|
||||
| Lowered mark/member/work/event/input/dash ceilings | Refusal without a partial successful result |
|
||||
| Original PAT spacing `0.2`, millimetres at scale 0.5 | 19 strokes after one conversion to `0.1 mm` |
|
||||
| Original PAT spacing `0.125`, inches at scale 0.5 | 7 strokes in the +/-5 mm height range at `1.5875 mm` spacing |
|
||||
|
||||
The signed `0.1` member oracle uses the independent integer ratio
|
||||
`3602879701896397 / 2^55`; it does not call the dyadic implementation to obtain
|
||||
expected coordinates. The PAT cases verify complete output and unchanged inputs.
|
||||
This remains a library increment, not saved-fill, paper-output or UI acceptance.
|
||||
|
||||
## Work and output limits (D2–D6)
|
||||
|
||||
One internal [budget][budget] spans preparation, every family, clipping, dash
|
||||
enumeration, dot membership queries and output. Its default cumulative caps are:
|
||||
|
||||
| Counter | Maximum per call |
|
||||
| --- | --- |
|
||||
| Input boundary pieces | 8,192 |
|
||||
| Candidate family lines | 10,000 |
|
||||
| Geometric work units | 2,000,000 |
|
||||
| Created contact/intersection events | 65,536 |
|
||||
| Dash steps | 200,000 |
|
||||
| Output marks, lines and dots together | 40,000 |
|
||||
|
||||
Candidate lines include conservative neighbours of the projected index range,
|
||||
including lines outside the region. Dash steps include every visited dash/gap/dot
|
||||
entry in the conservatively padded cycle range, even when it produces no mark.
|
||||
Repeated work across clipped spans also counts. The event cap counts all events
|
||||
created during the call; freeing or reusing temporary storage does not restore
|
||||
that budget. These counters are work limits,
|
||||
not elapsed-time or accuracy guarantees. The internal limit injection used by
|
||||
tests can lower these ceilings but cannot raise them, and is not a public API.
|
||||
Production uses the same generator with the defaults. D3 charges its bounded
|
||||
dyadic operations and each clipping classification to the existing work
|
||||
counter, including failing queries and queries that produce no marks. It adds
|
||||
no counter or budget increase; the extra checks can exhaust the work cap sooner.
|
||||
D5 charges the original arc pieces before recognizing a complete circle: two
|
||||
pieces for a disk, four for an annulus. Support validation and candidate/root
|
||||
classification, including misses and tangencies, consume the existing work
|
||||
budget. Created events count before deduplication, and all root-certification
|
||||
attempts are bounded and charged. Compression into circle supports does not
|
||||
reset any counter.
|
||||
|
||||
Budget or allocation failure, nonfinite arithmetic and unresolved numeric
|
||||
uncertainty fail the whole call. Member and cycle indices must fit the generator's safe integer range
|
||||
(`abs(index) <= 2^52`, including padded neighbours); nonzero spacing, dash and gap
|
||||
steps must still advance numerically. A far-away origin and very fine pattern
|
||||
can therefore be refused even when few visible marks would remain.
|
||||
Passing the D1 model validator does not guarantee a region can be generated
|
||||
within these bounds. There is no implicit coarse fallback or
|
||||
truncated successful result. Caller-side input-byte/allocation limits and any
|
||||
later paper-scene record limit remain separate responsibilities.
|
||||
|
||||
## Later phases and acceptance
|
||||
|
||||
The future paper adapter must choose a visible representation for `Mark::Dot`;
|
||||
the current scene's butt-cap strokes reject degenerate geometry. D1–D6 supply no
|
||||
paper adapter, stroke width, colour, export, command or interactive preview.
|
||||
General arc and curved-region clipping beyond the explicit D5 circle subset
|
||||
remains outside this slice.
|
||||
|
||||
Connecting fills to sketches waits for W38a's drawing-owned sketch record.
|
||||
The later W38d integration owns persistent boundary identity, retained geometry
|
||||
and lost-reference reporting, commands and undo, embedded imported definitions,
|
||||
paper object kinds/layers and export acceptance. Section-derived hatching
|
||||
remains a separate feature. D1–D6 add no drawing-format field or new dependency.
|
||||
|
||||
D1 acceptance covers model validation and serde, converted units and phase data,
|
||||
Unicode/BOM/line-ending handling, strict final blanks, malformed numeric/header
|
||||
fields, duplicate ordering, and limit refusal. See the model and
|
||||
[PAT acceptance tests][tests] for the cases present in the candidate.
|
||||
|
||||
D2 acceptance is split between [clipping tests][clip-tests] and
|
||||
[generation tests][generate-tests]: original analytic polygons and holes,
|
||||
contacts and boundary runs, global dash phase, strict interior dots, malformed
|
||||
or unsupported geometry, numeric refusal and cumulative caps. D3 extends these
|
||||
checks with the independent [phase tests][phase-tests]: signed floor division,
|
||||
exact represented-input expressions, nearest-even ties, subnormal values and
|
||||
arithmetic refusal, plus enclosure-aware dash/dot integration cases.
|
||||
|
||||
D4 links import to generation in the existing [PAT tests][tests]. Its three
|
||||
cases compare every segment with an independent 45-degree rectangle oracle:
|
||||
eight segments in a 40 by 20 mm rectangle, eleven after adding the rectangular
|
||||
hole `[10, 20] x [5, 15]`, and the same construction after millimetre/inch scaling.
|
||||
Imported and directly constructed patterns must agree and leave inputs unchanged.
|
||||
This exercises conversion once in paper millimetres; it does not exercise a view
|
||||
scale or a paper-scene adapter.
|
||||
|
||||
D5 acceptance extends the existing [clipping tests][clip-tests] and
|
||||
[generation tests][generate-tests] with independent circle equations. The main
|
||||
analytic fixtures use center `(2, -1)`, outer radius `5`, optional concentric
|
||||
hole radius `3`, and horizontal lines with origin at that center, spacing
|
||||
`delta_y = 3` and `delta_x = 0` unless specified. All coordinates and lengths below
|
||||
are in millimetres:
|
||||
|
||||
| Fixture | Expected complete output |
|
||||
| --- | --- |
|
||||
| Disk, continuous | 3 strokes, total length 26 |
|
||||
| Disk, `[0, -1]` | 23 strict-interior dots |
|
||||
| Annulus, continuous | 6 strokes, total length 20; inner tangencies split strokes |
|
||||
| Annulus, `[0, -1]` | 14 dots, excluding boundary contacts |
|
||||
| Annulus, `delta_x = 1/2`, `[2, -1]` | 10 strokes, total length 13.5, with signed global phase |
|
||||
| Annulus, `delta_x = 1/2`, `[0, 0, -2, 0]` | 30 dots; distinct zero tokens keep their multiplicity |
|
||||
|
||||
At a single line's signed normal distance `q = 3 - 2^-30`, `3`, or
|
||||
`3 + 2^-30` from the same center, the annulus has respectively 2, 2, or 1
|
||||
continuous spans, and 8, 6, or 7 dots for `[0, -1]`. Their expectations follow
|
||||
from the independent roots `sqrt(25 - q*q)` and, when present,
|
||||
`sqrt(9 - q*q)`; an implementation-produced golden is not the oracle.
|
||||
Counts alone are insufficient: endpoints, hole gaps, split contacts, total
|
||||
lengths, signed phase and zero-token multiplicities must also agree.
|
||||
|
||||
Additional D5 cases cover exact translation, admitted reverse/cyclic encodings,
|
||||
the represented oblique frame, hostile signatures and unsupported shapes,
|
||||
unchanged inputs, and whole-result refusal under lowered existing caps.
|
||||
Required success cases must succeed; allowing either an error or a result would
|
||||
not establish acceptance. An unresolved numeric refusal is reported as an
|
||||
unmet success case, never as an approximate substitute pass.
|
||||
|
||||
The actual test commands and results belong to the PR's validation record,
|
||||
bound to the tested source revision. Earlier-phase results do not validate a
|
||||
later source change, and source review or written tests do not establish execution.
|
||||
Library checks do not establish paper output, persistence or interactive
|
||||
acceptance.
|
||||
|
||||
## Sources and licensing
|
||||
|
||||
The implementation and synthetic test strings are original SolidCraft work
|
||||
under MIT OR Apache-2.0. Public format documentation informs the grammar; no
|
||||
commercial pattern files, templates, sample libraries, help text or artwork are
|
||||
copied or shipped. A PAT imported by a user remains user-supplied data; parsing
|
||||
it does not grant redistribution rights. Any future bundled external pattern
|
||||
would need its own source, licence and attribution review.
|
||||
|
||||
Simple diagonal and crossed-line constructions are mathematical patterns. This
|
||||
foundation makes no ISO drafting-conformance claim and includes no standard's
|
||||
protected tables or illustrations. See the repository's
|
||||
[attribution policy](../ATTRIBUTION.md) and [NOTICE](../NOTICE).
|
||||
|
||||
[issue]: https://github.com/tangency-labs/solidcraft/issues/7062
|
||||
[handoff]: https://github.com/tangency-labs/solidcraft/issues/5425#issuecomment-6414961262
|
||||
[model]: ../crates/geom/src/hatch.rs
|
||||
[reader]: ../crates/io/src/pat.rs
|
||||
[generator]: ../crates/geom/src/hatch/generate.rs
|
||||
[clipper]: ../crates/geom/src/hatch/clip.rs
|
||||
[circle-clipper]: ../crates/geom/src/hatch/clip/circle.rs
|
||||
[profile]: ../crates/geom/src/profile.rs
|
||||
[budget]: ../crates/geom/src/hatch/budget.rs
|
||||
[phase]: ../crates/geom/src/hatch/phase.rs
|
||||
[phase-tests]: ../crates/geom/src/hatch/phase_tests.rs
|
||||
[clip-tests]: ../crates/geom/src/hatch/clip_tests.rs
|
||||
[generate-tests]: ../crates/geom/src/hatch/generate_tests.rs
|
||||
[tests]: ../crates/io/src/pat_tests.rs
|
||||
[pat-reference]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-Customization/files/GUID-A6F2E6FF-1717-44B6-A476-0CA817ADD77E.htm
|
||||
[dash-reference]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-Customization/files/GUID-B67FADB9-0F10-4536-ABC4-BB7D8BDDF5B1.htm
|
||||
@@ -0,0 +1,198 @@
|
||||
# Hidden lines (W06a)
|
||||
|
||||
This is the geometry core of Drawing package W06 from the [published plan][plan] in
|
||||
[#860][rfc]: [#2455][issue] / [PR #2457][pr]. It turns the subset that the W05a
|
||||
investigation ([#2105][w05-issue], [draft #2106][w05-pr], [findings][findings])
|
||||
showed to be ready into code: exact orthographic hidden lines for placed bodies whose
|
||||
faces are planes, cylinders, cones, spheres or tori.
|
||||
|
||||
The code is `crates/geom/src/hlr` (layer L0). It reads the neutral `geom::brep`
|
||||
records, uses no kernel or UI types, returns errors instead of panicking and builds for
|
||||
`wasm32-unknown-unknown`. It does not register commands, create views, extract bodies
|
||||
from the kernel or draw sections. The read-only kernel adapter follows the W02
|
||||
extraction contract; until then callers build `Brep` records themselves.
|
||||
|
||||
## Using it
|
||||
|
||||
```text
|
||||
prepare(&Brep, &Options) -> Result<Prepared, HlrError> once per body
|
||||
hidden_lines(&[Prepared], &[Placement], &View, &Options, &cancel) -> Result<Drawing, HlrError>
|
||||
```
|
||||
|
||||
- `prepare` checks the body (`Brep::validate`), recognises each face's surface, builds
|
||||
its trim and classifies each edge as sharp, tangent or seam. `Prepared::report` says
|
||||
what it found: surfaces and curves by kind, tangent and seam edges, faces whose trim is
|
||||
unsupported, freeform faces and the largest boundary chord error.
|
||||
- A `Placement` puts a prepared body in the scene: `world = rotation * local +
|
||||
translation`, the rotation given by its columns. Several placements may share one
|
||||
prepared body. A placement that is not a proper rotation (a mirror, a scale) is refused
|
||||
with `HlrError::Invalid`.
|
||||
- `View::new(direction_of_sight, up)` is an orthographic view. Screen coordinates are
|
||||
`(p . x, p . y)` in millimetres, with `x = direction x up` normalised and `y = x x
|
||||
direction`.
|
||||
- `cancel` is polled during the work; when it returns true the call ends with
|
||||
`HlrError::Cancelled`. `prepare_cancellable(&Brep, &Options, &cancel)` is `prepare` with
|
||||
such a callback, polled between edges, faces and surface comparisons.
|
||||
|
||||
The `Drawing` holds one `Line` per drawable edge and per silhouette curve of each
|
||||
placement, in order of placement. A line names its `placement` and `source` (`Edge { edge, tangent }`
|
||||
or `Silhouette { face }`) and is split into `pieces`. Each `Piece` has:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `t` | Parameter interval on the source curve. |
|
||||
| `visibility` | `Visible`, `Hidden` or `Uncertain`. |
|
||||
| `covered` | Another piece already draws the same projected curve here; draw only uncovered pieces. |
|
||||
| `geometry` | The piece in screen coordinates as `Seg2` segments. |
|
||||
| `accuracy` | `CurveAccuracy` of `geometry` (the [W01a vocabulary](drawing-geometry.md)). |
|
||||
|
||||
`Drawing::unsupported` lists the faces whose silhouettes were not drawn and why;
|
||||
`Drawing::stats` counts curves, crossings, pieces, rays and uncertain pieces, with phase
|
||||
timings where the platform has a clock (zero on `wasm32-unknown-unknown`).
|
||||
|
||||
## What is exact
|
||||
|
||||
| Source | Geometry | Accuracy |
|
||||
| --- | --- | --- |
|
||||
| Line edges; cylinder and cone silhouette rulings | one `Seg2::Line` | `Analytic` |
|
||||
| Circle and ellipse edges; sphere silhouettes; torus silhouettes seen along the axis | `Seg2::Conic` arcs of at most 90 degrees | `Analytic` |
|
||||
| Spline and intersection edges | chords between exact points, halved until each piece's control hull is within the sag target | `Sampled` (a bound from that hull) |
|
||||
| Torus silhouettes in other views | chords between traced points, halved to 1e-7 of the torus's size | `Sampled` (measured deviation) |
|
||||
|
||||
An orthographic projection is affine, so a circle projects to an ellipse, which a
|
||||
rational quadratic arc represents exactly. Chords are refined until their sag on screen
|
||||
is at most 1e-7 of the scene size. A fitted edge record also carries its own bound, and
|
||||
the visibility work allows for it.
|
||||
|
||||
Surfaces are recognised whether they are stored as analytic records, as revolutions of
|
||||
lines and circles or as spline patches (to 1e-9 relative; a patch whose control points lie
|
||||
in one plane is that plane). A spline curve or patch is taken for a circle, quadric or torus
|
||||
only when it lies on it at enough points of every knot span to make the check exact for its
|
||||
degree, with its largest distance there, scaled by how far apart its weights are, within
|
||||
the tolerance: weights near zero cannot hide a swerve, and widely spread weights mean chords.
|
||||
A recognised patch is trimmed and intersected in its own analytic chart. Faces that lie on one surface (a sphere
|
||||
stored as two hemispheres, a cylinder split in two) share their silhouettes: the edges
|
||||
between them are seams and are not drawn, and a silhouette running along such a seam is
|
||||
one curve. A face joins such a set only when the first face's surface passes through its
|
||||
own points to rounding (1e-13 of its size); recognition alone groups surfaces more loosely,
|
||||
and such faces keep silhouettes of their own.
|
||||
|
||||
## Visibility
|
||||
|
||||
Projected curves are split where they cross or touch, found on a screen grid and
|
||||
refined on the exact curves. Each piece is classified by rays toward the viewer against
|
||||
every trimmed face: closed forms for planes and quadrics, a sampled quartic for tori.
|
||||
A ray that grazes a face, meets one within its boundary tolerance or meets another body
|
||||
right at its start makes the piece `Uncertain`; nothing is guessed.
|
||||
|
||||
Coincident curves (an edge and a silhouette on top of each other, two touching bodies)
|
||||
follow the W05a policy: visible wins and duplicates are marked `covered`. The tolerance
|
||||
comes from the curves' own accuracy (chord sag and fit bounds) plus a rounding allowance
|
||||
of 1e-7 mm and 1e-9 of the scene size, never from the scene size alone, so small parts in
|
||||
large scenes keep their detail.
|
||||
|
||||
`prepare` moves each body to its own centre, and the work is done in a frame centred on
|
||||
the scene and shifted back at the end, so coordinates stay small: a part 1e7 mm from the
|
||||
origin, whether placed there or modelled there, is classified as at the origin.
|
||||
|
||||
## Freeform faces
|
||||
|
||||
Faces that are not planes, cylinders, cones, spheres or tori are freeform. By default
|
||||
they are listed in `Drawing::unsupported` with the reason `freeform face`, their
|
||||
silhouettes are not drawn, and a piece whose ray meets one is `Uncertain`, never
|
||||
`Hidden`. Without its silhouettes nothing marks where such a face's outline crosses a curve,
|
||||
so curves are cut at the face's screen box and every piece inside the box that can reach
|
||||
behind its nearest point (bounded by the curve's control points, not by samples) is
|
||||
`Uncertain` too, the face's own edges included (faces left out for an unsupported trim
|
||||
likewise). With `Options::freeform` set their silhouettes are contoured and their ray
|
||||
tests use seeded Newton iterations: slower, `Sampled`, and subject to the misses listed
|
||||
in the W05a findings.
|
||||
|
||||
## Limits
|
||||
|
||||
- `Options::max_tracks` (curves) and `Options::max_rays` bound the work, as do internal
|
||||
caps: crossing grid entries (50 million), crossing candidate pairs (500 million, counted
|
||||
before the search, so no set of pairs met grows with them), crossings (50 million) and
|
||||
the surface comparisons that group a body's faces (50 million). Exceeding one returns
|
||||
`HlrError::Budget`.
|
||||
- `Options::trim_samples` is 1 to `MAX_TRIM_SAMPLES` (4096), and every coordinate of a
|
||||
placed body at most `MAX_COORDINATE` (1e12 mm, the bound `geom::section` and
|
||||
`geom::brep` put on stored numbers); otherwise the call returns `HlrError::Invalid`.
|
||||
- `Options::tangent_edges` (default on) draws edges between tangent faces, flagged
|
||||
`tangent: true`; drafting policy for them is W17.
|
||||
- Tolerances are relative to the faces and the scene. They are meant for faces up to
|
||||
about 10 m across and features down to about 1e-3 mm. A spline patch only a sliver wide
|
||||
next to its radii (a few millionths of them) cannot be told from its neighbours' surfaces
|
||||
reliably and is reported as freeform. Distinct surfaces that
|
||||
recognition takes for one (closer than 1e-9 of their size or position, a nearly flat
|
||||
cone's far apex included) share the own-face margin of 1e-6 of the face's size when a
|
||||
ray leaves one of them.
|
||||
- Not in this slice: parallel per-curve work and the shared work limits of
|
||||
`geom::section`, the kernel adapter (after W02), perspective, section regions (W03),
|
||||
views and commands (W07), and mirrored placements. Of the records the kernel now saves,
|
||||
planes through three points (`PlaneThrough`) are drawn as planes; a body with a procedural
|
||||
`SurfaceIntersection` curve or a `PlacedRevolution` surface (whose placement may scale it
|
||||
unevenly) is refused by `prepare` with `HlrError::Unsupported` until the adapter handles
|
||||
them.
|
||||
|
||||
## Fixtures
|
||||
|
||||
`crates/geom/src/hlr_tests.rs` builds bodies from records and checks analytic positions
|
||||
and intervals:
|
||||
|
||||
- a cube in an isometric view hides its three back edges; a box in front hides the
|
||||
middle of an edge over exactly the covered interval;
|
||||
- a cylinder's silhouette rulings are exact lines, including one along the seam; a cone
|
||||
frustum seen from the front has exact rulings and draws each edge-on circle once;
|
||||
- a sphere's outline is exact conic arcs at its radius in three views;
|
||||
- a disc bounded by a rational circle with unevenly spaced knots, with a knot span narrower
|
||||
than rounding, or with a parameterisation that races through its first turn is drawn as
|
||||
arcs turning the full 360 degrees; splines off their circle between evenly spaced samples
|
||||
or inside such a narrow span are drawn as chords; exact arcs that turn almost all of
|
||||
their 358 degrees in the first hundredth of their parameter, or several radians in a
|
||||
knot span of 1e-6 or 1e-11, keep the turn of every piece;
|
||||
- a torus stored as two halves, seen face on, is drawn at both equators, one silhouette
|
||||
each; an edge under it is hidden over exactly the intervals the tube covers; seen
|
||||
obliquely its outline spans R + r on both sides with a deviation under 1e-3 mm;
|
||||
- cone frusta and narrow cone sectors a hair off one axis or with axes 3e-7 rad apart, and
|
||||
spherical caps through one circle with centres 5e-7 mm apart, keep their own outlines;
|
||||
- a cylinder 1e5 and 1e7 mm from the origin draws as at the origin; a cone trimmed to its
|
||||
tip 1 mm below the apex hides nothing above it;
|
||||
- a freeform patch is reported and makes the edges it covers uncertain, or hidden when
|
||||
freeform faces are enabled; a flat spline patch hides them for certain; a long edge behind
|
||||
a freeform tube is uncertain where the tube may cover it and visible elsewhere, and so is
|
||||
a sloping edge that only dips behind a freeform patch near its end, while one that leaves
|
||||
the patch's box below its level but crosses the box above it stays visible;
|
||||
- a curve that swerves 2.9 mm off its circle where its weight is 1e-12, and a nearly straight
|
||||
cubic 0.002 mm off a circle of radius 1e7, are drawn as chords; an edge whose depth dips
|
||||
behind a freeform patch between its samples is uncertain there, and so is an arc seen
|
||||
along its plane whose projection folds back under a freeform patch between two samples,
|
||||
and the edge of a freeform sheet that curls back over it 0.005 mm above;
|
||||
- a torus patch stored as a rational spline is recognised and draws its outer equator as an
|
||||
exact arc, and one with a bump in a knot span 0.002 of its parameters wide is not taken for
|
||||
a torus; a big circle passing under a thin freeform tube in a coarse scene is uncertain
|
||||
there;
|
||||
- two strips of a torus 0.0003 rad wide keep their 0.006 mm of outline;
|
||||
- two touching boxes draw each shared edge once;
|
||||
- a box whose top plane record stands one rounding (or 1e-11 of 100 mm) off its edges still
|
||||
hides its back edges, and so does a 0.001 mm cube 1e7 mm out whose top plane is one step
|
||||
above its edges; a plate's sixteen holes each hide the same part of their bottom circle;
|
||||
eight coaxial cylinders seen along their axis show one circle; 300 boxes apart on screen
|
||||
are each drawn as the first one; an arc that bulges across a line 1 mm off its chord still
|
||||
crosses it twice; the trim's chord grid agrees with a scan of every chord; surface groups
|
||||
agree with comparing every pair of faces, and preparing can be cancelled;
|
||||
- mirrored placements, bad body indices and bad views are refused; cancellation and
|
||||
budgets return errors;
|
||||
- a box stored with planes through three points draws the same bits as one with
|
||||
axis-stored planes; placed revolutions and procedural intersection curves are refused as
|
||||
not supported yet;
|
||||
- torus ray roots for thin, large and nearly tangent cases, and torus silhouettes near
|
||||
the axis direction.
|
||||
|
||||
[rfc]: https://github.com/tangency-labs/solidcraft/issues/860
|
||||
[plan]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815
|
||||
[findings]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6243691483
|
||||
[issue]: https://github.com/tangency-labs/solidcraft/issues/2455
|
||||
[pr]: https://github.com/tangency-labs/solidcraft/pull/2457
|
||||
[w05-issue]: https://github.com/tangency-labs/solidcraft/issues/2105
|
||||
[w05-pr]: https://github.com/tangency-labs/solidcraft/pull/2106
|
||||
+16
-7
@@ -9,7 +9,9 @@ Where the expected values come from (the **Source** column):
|
||||
|
||||
No expected value comes from a commercial CAD product.
|
||||
|
||||
**83 / 83 cases pass**; sources: 62 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet.
|
||||
**90 / 90 cases pass**; sources: 69 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet.
|
||||
|
||||
**Validation note:** Case 95's analytic centre-of-mass reference was corrected after the above successful run. Its previous pass status is historical and requires a new oracle replay against the corrected reference.
|
||||
|
||||
| Case | Source | Result | Surface distance mm (max / mean) | Method | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -29,13 +31,13 @@ No expected value comes from a commercial CAD product.
|
||||
| 14-combine-cut | analytic | pass | | box [-25,25]^2x[-20,0] minus sphere r=15 at the origin (lower hemisphere removed): V = abc - 2/3 pi r^3, hemisphere centroid z = -3r/8; A = 2(ab+bc+ca) - pi r^2 + 2 pi r^2 | |
|
||||
| 15-combine-intersect | analytic | pass | | cube [-20,20]^3 intersect sphere R=25: sphere minus 6 disjoint caps of height h=5 (caps cannot overlap: two would need x^2+y^2 > 800 > R^2); V = 4/3 pi R^3 - 6 pi h^2 (3R-h)/3, A = 4 pi R^2 - 6 * 2 pi R h + 6 pi (R^2-20^2) | |
|
||||
| 16-sweep | analytic | pass | | circle r=3 swept along line 30 (+Z) + tangent R20 quarter arc + line 30 (+X): two cylinders + quarter torus (Pappus: V = pi r^2 R theta, A = 2 pi r R theta; torus centroid at rho = (R^2 + r^2/4)/R * sin(theta/2)/(theta/2) from the bend axis along the bisector) + 2 end disks | |
|
||||
| 17-loft | freecad 1.1.4 | pass | 0.013 / 0.0019 (worst at -7.9, -10.8, 31.2) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | |
|
||||
| 17-loft | freecad 1.1.4 | pass | 0.013 / 0.0017 (worst at 8.7, -6.7, 37.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | |
|
||||
| 18-hole-features | analytic | pass | | plate 60x40x15 minus: counterbore (r7 x 5 deep + r4 through), countersink (90 deg cone frustum r6->r3 over 3 mm + r3 through), blind r3 hole 10 deep to the shoulder with a 118 deg cone point (height 3/tan59, centroid 1/4 up from the apex); areas: removed top/bottom disks, cylinder walls 2 pi r h, cb floor annulus, cone laterals pi r s | |
|
||||
| 19-l-bracket-constrained | analytic | pass | | solved L: (0,0)-(60,0)-(60,8)-(8,8)-(8,40)-(0,40); area 60x8 + 8x32, extruded 20: V = A2 h, A = 2 A2 + perimeter*h, centroid of the two rectangles | |
|
||||
| 20-slot-constrained | analytic | pass | | obround: arc centres (0,0) and (40,0), R6, tangent horizontal lines y=+-6: A2 = 2rL + pi r^2, V = A2 h, A = 2 A2 + perimeter (2L + 2 pi r) h | |
|
||||
| 21-draft | analytic | pass | | 40x40x30 box, 5 deg draft hinged on the bottom (z=0), pull -Z: square frustum 40 -> 40 + 2*30*tan5; V = h/3 (A0 + A1 + sqrt(A0A1)), sides 4 trapezoids of slant h/cos5 | |
|
||||
| 22-split-body | analytic | pass | | cylinder r20 z -30..30 split by the plane through the X axis tilted 30 deg (z = y tan30, stays within /z/<=11.6 inside the cylinder): 2 congruent halves (point symmetry), V total = pi r^2 h, A total = cylinder + 2 ellipses pi r (r/cos30) | |
|
||||
| 23-bracket-multifeature | freecad 1.1.4 | pass | 0.007 / 0.0010 (worst at 57.3, 49.3, 25.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part makeFillet, Part extrude join, Part extrude cut, Part makeChamfer | |
|
||||
| 23-bracket-multifeature | freecad 1.1.4 | pass | 0.007 / 0.0010 (worst at 62.7, 48.7, 25.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part makeFillet, Part extrude join, Part extrude cut, Part makeChamfer | |
|
||||
| 24-user-parameters | analytic | pass | | width 60 x depth 40 x height 15 block from the origin with a centred d10 through hole: V = (wd - pi r^2) h, A = 2(wd - pi r^2) + 2(w+d)h + 2 pi r h | |
|
||||
| 25-sketch-triangle | analytic | pass | | solved triangle (0,0), (50,0), P = t(cos60, sin60) with t = 25 + sqrt(150) from /P-(50,0)/ = 45 (the root nearest the drawn apex); V = (1/2 * 50 * Py) * 5, A = 2 A2 + perimeter*5, centroid = vertex mean | |
|
||||
| 26-sketch-belt | analytic | pass | | belt: convex hull of circles R20 @ (0,0) and r10 @ (50,0) (external tangents, sin a = (R-r)/d) minus the d8 hole at (50,0), extruded 5 as one body; hull area = (R+r) sqrt(d^2-(R-r)^2) + R^2(pi+2a)/2 + r^2(pi-2a)/2; moments by Green's theorem over the line/arc boundary | |
|
||||
@@ -43,7 +45,7 @@ No expected value comes from a commercial CAD product.
|
||||
| 28-blind-pocket-fillets | freecad 1.1.4 | pass | 0.009 / 0.0015 (worst at 60.0, 15.1, 13.4) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, Part makeFillet | |
|
||||
| 29-two-sided-and-partial-revolve | analytic | pass | | block [-20,20]x[-25,10]x[0,20] minus the 270 deg revolve (from +X towards +Y) of the 8x8 square r 4..12, z 12..20; the removed section is the annular sector minus the circular segment y > 10 (R^2 acos(d/R) - d sqrt(R^2-d^2), y-moment 2/3 (R^2-d^2)^1.5) that lies outside the block; sector moments (R^3-r^3)/3 (sin, 1-cos); A = box - top opening + floor - front-face notch 2 sqrt(44) x 8 + inner wall r th h + outer wall (th - pi + 2 asin(5/6)) R h + 2 end caps | |
|
||||
| 30-fillet-after-shell | freecad 1.1.4 | pass | 0.008 / 0.0025 (worst at 56.3, 35.7, 3.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, makeThickness shell, Part makeFillet | |
|
||||
| 31-fillet-l-all-edges | freecad 1.1.4 | pass | 0.009 / 0.0018 (worst at 7.9, 38.6, 18.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet | |
|
||||
| 31-fillet-l-all-edges | freecad 1.1.4 | pass | 0.009 / 0.0018 (worst at 6.0, 38.6, 19.9) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet | |
|
||||
| 32-boss-plate-fillets | analytic | pass | | plate 60x60x8 + boss (r10, z 8..23) with R3 concave base and R2 convex top fillets taken as one solid of revolution: V, z-moment and lateral area from Pappus/Green on its (r,z) profile (quarter-circle fillet arcs); plate top loses the r13 disc | |
|
||||
| 33-fillet-tangent-chain | analytic | pass | | obround (R8, centres 40 apart) x 10 minus R2 fillet spandrel R^2(1-pi/4) and 1 mm chamfer triangle swept round the whole loop (Pappus: section x path of its centroid, inset R(10-3pi)/(3(4-pi)) resp. c/3); surfaces likewise (fillet arc centroid inset R-2R/pi) | |
|
||||
| 34-chamfer-then-fillet | freecad 1.1.4 | pass | 0.009 / 0.0022 (worst at 38.5, 5.6, 19.9) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeChamfer, Part makeFillet | |
|
||||
@@ -51,7 +53,7 @@ No expected value comes from a commercial CAD product.
|
||||
| 36-corner-blend-two-radii | freecad 1.1.4 | pass | 0.012 / 0.0026 (worst at 45.4, 39.2, 26.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet | |
|
||||
| 37-pyramid-apex-blend | freecad 1.1.4 | pass | 0.020 / 0.0029 (worst at -0.0, 0.0, 28.4) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft, Part makeFillet | |
|
||||
| 38-coincident-cylinders | analytic | pass | | union of r10 z0..30 and r10 z20..50 = cylinder r10 h50: V = pi r^2 h, A = 2 pi r h + 2 pi r^2 | |
|
||||
| 39-tee-pipe | freecad 1.1.4 | pass | 0.008 / 0.0014 (worst at -9.7, 2.4, 40.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | |
|
||||
| 39-tee-pipe | freecad 1.1.4 | pass | 0.008 / 0.0014 (worst at 5.4, 9.1, 11.9) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | |
|
||||
| 40-box-sphere-torus | analytic | pass | | box 60x60x10 (z -10..0) + upper hemisphere r12 + spherical cap h2 below z=-10 (V = pi h^2(3R-h)/3, A = 2 pi R h) - lower half torus R22 r4 (Pappus: V = pi r^2/2 * 2 pi R, centroid -4r/3pi, A = pi r * 2 pi R); top loses disc r12 and annulus 18..26, bottom loses disc r sqrt(44) | faces: bottom, 4 sides, top split by the groove into an annulus (r 12..18) and the outer part, upper sphere, lower cap, torus |
|
||||
| 41-tube-radial-holes | freecad 1.1.4 | pass | 0.010 / 0.0012 (worst at 1.9, 19.9, 24.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied) | |
|
||||
| 42-overlapping-patterns | freecad 1.1.4 | pass | 0.009 / 0.0010 (worst at -1.0, -10.1, 2.2) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied), Part extrude cut, pattern (tools transformed and re-applied) | |
|
||||
@@ -63,7 +65,7 @@ No expected value comes from a commercial CAD product.
|
||||
| 48-pulley | analytic | pass | | revolve of the 12-segment (r,z) polygon about Z: V = 2 pi int r dA, Mz = 2 pi int r z dA, A = 2 pi int r ds (Pappus) | |
|
||||
| 49-helical-sweep | analytic | pass | | true helix coil (screw motion of the axial d4 section, R20, pitch 8, 3 turns): V = turns * 2 pi R * pi a^2; lateral A = turns * 2 pi * int sqrt(r^2 + (p/2pi)^2 (dr/ds)^2) ds (periodic trapezoid, exact to round-off) + two end discs; COM z = pitch*turns/2 | |
|
||||
| 50-sweep-tight-bend | analytic | pass | | tube r5 along line 20 + R5 quarter bend + line 20: V = pi r^2 L, A = 2 pi r L + 2 pi r^2 (Pappus, valid while bend radius >= r; here equal so the inner side pinches to a line); bend centroid at (R^2 + r^2/4)/R * sinc(pi/4) along the bisector | |
|
||||
| 51-loft-rect-circle-offset | freecad 1.1.4 | pass | 0.025 / 0.0030 (worst at -11.7, 15.0, 0.4) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | |
|
||||
| 51-loft-rect-circle-offset | freecad 1.1.4 | pass | 0.033 / 0.0028 (worst at 17.5, -14.9, 0.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | |
|
||||
| 52-circular-pattern-fillet | analytic | pass | | disc r40 h8 + 6 x (boss r6 h10 with R2 concave base fillet as a solid of revolution, Pappus/Green on its (r,z) profile) at radius 28; disc top loses 6 discs r8 | |
|
||||
| 53-path-pattern | analytic | pass | | plate 120x60x5 minus 7 through holes d6 at arc lengths k*17.875031 along the arc through (100,15),(60,45),(20,15) (centre (60,10/3), R 125/3), translated copies | |
|
||||
| 54-mirror-body-fillets | analytic | pass | | prism h10 of the 60x40 rectangle with four R5 corner rounds and two d8 holes at (+-15,20): V = A h, area = 2A + perimeter*h | |
|
||||
@@ -77,7 +79,7 @@ No expected value comes from a commercial CAD product.
|
||||
| 62-heat-sink | analytic | pass | | base 60x50x5 + 15 fins 1.5x50x25 at x = 2 + 4k: sums of boxes; fin ends coplanar with the base ends | faces: bottom, x=0, x=60, y=0 and y=50 (each merged with the fin ends), 16 base-top strips, 15x3 fin faces |
|
||||
| 63-hinge-knuckle | freecad 1.1.4 | pass | 0.008 / 0.0016 (worst at 28.0, 36.9, 4.7) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | |
|
||||
| 64-sketch-tangent-arc-chain | analytic | pass | | solved sketch: 80x20 rectangle whose top is 4 tangent R12 arcs on 20 mm chords alternating up/down (net area 1600); prism h3: V = A h, area = 2A + perimeter*h, Green's theorem for centroid | |
|
||||
| 65-sketch-gear-tooth | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at -6.5, 39.5, 0.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Sketcher-solved sketch, Part extrude | |
|
||||
| 65-sketch-gear-tooth | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at 6.5, 39.5, 8.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Sketcher-solved sketch, Part extrude | |
|
||||
| 66-sketch-linkage | analytic | pass | | solved sketch: hull of circles R12 at (0,0) and R8 at (60,0) (outer tangent lines, cos a = 4/60) minus holes d12, d8 (concentric) and d4 at (30,+-4) (symmetry keeps radii equal); prism h6 | |
|
||||
| 67-sm-base-flange | analytic | pass | | plate 100 x 60 x t, t = 2.5 (Steel (mm) rule), thickness along +Z: V = 100*60*t, A = 2(ab+bc+ca) | |
|
||||
| 68-sm-edge-flange | analytic | pass | | extrusion along x (100) of the YZ section: base y 5..60 x t; 90 deg bend annulus R 2.5..5 about (y 5, z 5); flange y 0..2.5, z 5..20. Setback (R+t)tan45 = 5 (inside bend position, outer-face height datum: outer face of flange reaches z = 20); V = L*A_sec, A = 2 A_sec + L P_sec | trim/setback and height datum are SolidCraft's documented edge-flange conventions (sheet.rs add_flanges) |
|
||||
@@ -96,6 +98,13 @@ No expected value comes from a commercial CAD product.
|
||||
| 81-fillet-spline-radius | analytic | pass | | box 40x30x20 with its top front edge (y=0, z=20) rounded at a radius that is a clamped cubic B-spline in u = x/40, knots 0,0,0,0,0.25,0.6,1,1,1,1, control radii 2, 5, 1.5, 4, 1, 3 (r(0) = 2, r(40) = 3). Sections are quarter rounds; V, moments and areas integrated along the edge, exactly per knot span | |
|
||||
| 82-thin-extrude-frame | analytic | pass | | square frame: the lines' 40 mm square walled 3 mm inside and 1 mm outside (mitred corners) is 42 x 42 less 34 x 34, 10 mm tall: V = (42^2 - 34^2) * 10, A = 2 (42^2 - 34^2) + 4 * 42 * 10 + 4 * 34 * 10 | |
|
||||
| 83-boundary-loop-thicken | analytic | pass | | region = 10 x 10 square + half disc r 5 on its top side: A = 100 + 12.5 pi, centroid by moments (half disc's at 4r / (3 pi) above its diameter); slab of thickness 2 over it (symmetric about z = 0): V = 2A, area = 2A + 2 (30 + 5 pi) | |
|
||||
| 84-plate-two-bores | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
|
||||
| 85-plate-width-100 | analytic | pass | | Plate L=100, W=100, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
|
||||
| 86-plate-first-bore-12 | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø12; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
|
||||
| 87-plate-bore1-deleted | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
|
||||
| 88-plate-four-bores | analytic | pass | | Plate L=150, W=80, T=10 mm; Bore1 at (15,15) Ø8; Bore2 at (135,15) Ø8; Bore3 at (15,65) Ø8; Bore4 at (135,65) Ø8. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
|
||||
| 94-plate-four-rounded-corners | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: four exterior vertical R10 corner rounds; four corner spandrels subtract volume T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); rounded perimeter P=2(L+W)-8R+2pi*R | |
|
||||
| 95-plate-rounded-top-chamfer | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: four exterior vertical R10 corner rounds; full upper tangent loop 2 mm chamfer; four corner spandrels subtract volume T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); rounded perimeter P=2(L+W)-8R+2pi*R; upper tangent chamfer subtracts volume Vc=P*c²/2-pi*c³/3 at (L/2,W/2,T-(P*c³/6-pi*c⁴/12)/Vc); area from upper inset face, side strips and sqrt(2)*c*(P-pi*c) bevel surface | |
|
||||
|
||||
## Not covered by a reference case
|
||||
|
||||
|
||||
+5
-4
@@ -71,10 +71,11 @@ deployment. The stable Flatpak repository is not automatically updated with nigh
|
||||
`Cargo.lock` (it changes only our own crates, never dependency versions, and needs no
|
||||
network). Inspect the diff: only `version = ` lines of workspace crates should change.
|
||||
SolidCraft does not currently implement `cargo xtask version set`.
|
||||
3. Fix the design format number: run `scripts/fold-format-pending.sh`. It moves the pending
|
||||
format changes (`crates/doc/src/format/pending/*.rs`) into the next `crates/doc/src/format/v<N>/`
|
||||
folder, so the release writes and reads that number for good, and does nothing when nothing
|
||||
is pending. Commit the move with the version files. (CONTRIBUTING.md, "Changing the design
|
||||
3. Fix the file format numbers: run `scripts/fold-format-pending.sh`. It moves the pending
|
||||
format changes of designs (`crates/doc/src/format/pending/*.rs`) and of drawings
|
||||
(`crates/doc/src/drawing/format/pending/*.rs`) into each one's next `v<N>/` folder, so the
|
||||
release writes and reads those numbers for good, and does nothing for a family with nothing
|
||||
pending. Commit the move with the version files. (CONTRIBUTING.md, "Changing the design
|
||||
file format".)
|
||||
4. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`.
|
||||
Commit the version files through the normal review flow and land them on `main`.
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Release step: give the pending design-format changes their number for good.
|
||||
# Release step: give the pending file-format changes their number for good.
|
||||
#
|
||||
# Moves every change in crates/doc/src/format/pending/ into crates/doc/src/format/v<N>/, where N
|
||||
# is the number development builds have been writing for them (the last folded format + 1).
|
||||
# Commit the result with the release's version bump. Nothing to do when nothing is pending.
|
||||
# See crates/doc/src/format/pending/README.md.
|
||||
# For each family of files in crates/doc/build.rs's FAMILIES (designs in crates/doc/src/format/,
|
||||
# drawings in crates/doc/src/drawing/format/), moves every change in its pending/ folder into its
|
||||
# v<N>/, where N is the number development builds have been writing for them (the family's last
|
||||
# folded format + 1). Commit the result with the release's version bump. Nothing to do for a family
|
||||
# with nothing pending. See crates/doc/src/format/pending/README.md.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
dir="$root/crates/doc/src/format"
|
||||
last=$(sed -n 's/^const HAND_WRITTEN: u32 = \([0-9]*\);/\1/p' "$root/crates/doc/build.rs")
|
||||
[ -n "$last" ] || { echo "HAND_WRITTEN not found in crates/doc/build.rs" >&2; exit 1; }
|
||||
for d in "$dir"/v*/; do
|
||||
[ -d "$d" ] || continue
|
||||
n=${d%/}
|
||||
n=${n##*/v}
|
||||
if [ "$n" -gt "$last" ]; then last=$n; fi
|
||||
done
|
||||
families=$(grep -o 'Family { folder: "[^"]*", base: [0-9]*' "$root/crates/doc/build.rs" | sed 's/.*folder: "\([^"]*\)", base: \([0-9]*\)/\1 \2/' || true)
|
||||
[ -n "$families" ] || { echo "FAMILIES not found in crates/doc/build.rs" >&2; exit 1; }
|
||||
|
||||
shopt -s nullglob
|
||||
pending=("$dir"/pending/*.rs)
|
||||
if [ ${#pending[@]} -eq 0 ]; then
|
||||
echo "No pending format changes: this release writes format $last."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
next=$((last + 1))
|
||||
mkdir -p "$dir/v$next"
|
||||
git -C "$root" mv "${pending[@]}" "$dir/v$next/"
|
||||
echo "Format $next is now fixed, with ${#pending[@]} change(s):"
|
||||
for f in "${pending[@]}"; do echo " ${f##*/}"; done
|
||||
echo "Commit crates/doc/src/format/ with the version bump."
|
||||
folded=0
|
||||
while read -r folder last; do
|
||||
dir="$root/crates/doc/$folder"
|
||||
for d in "$dir"/v*/; do
|
||||
n=${d%/}
|
||||
n=${n##*/v}
|
||||
if [ "$n" -gt "$last" ]; then last=$n; fi
|
||||
done
|
||||
pending=("$dir"/pending/*.rs)
|
||||
if [ ${#pending[@]} -eq 0 ]; then
|
||||
echo "No pending format changes in $folder: this release writes format $last."
|
||||
continue
|
||||
fi
|
||||
next=$((last + 1))
|
||||
mkdir -p "$dir/v$next"
|
||||
git -C "$root" mv "${pending[@]}" "$dir/v$next/"
|
||||
echo "Format $next of $folder is now fixed, with ${#pending[@]} change(s):"
|
||||
for f in "${pending[@]}"; do echo " ${f##*/}"; done
|
||||
folded=$((folded + 1))
|
||||
done <<< "$families"
|
||||
[ "$folded" -eq 0 ] || echo "Commit crates/doc/src/ with the version bump."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks the design-format change files (crates/doc/src/format/): two branches that each add a
|
||||
# pending change merge without a conflict, and the release fold moves both into the next number.
|
||||
# A drawing-format change (crates/doc/src/drawing/format/) folds into the drawing's own next number.
|
||||
# The order they apply in and the upgrades themselves are tested in crates/doc/src/format.rs.
|
||||
set -euo pipefail
|
||||
|
||||
@@ -8,11 +9,12 @@ root=$(git rev-parse --show-toplevel)
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
repo="$tmp/repo"
|
||||
mkdir -p "$repo/crates/doc/src" "$repo/scripts"
|
||||
mkdir -p "$repo/crates/doc/src/drawing" "$repo/scripts"
|
||||
cp -r "$root/crates/doc/src/format" "$repo/crates/doc/src/"
|
||||
cp -r "$root/crates/doc/src/drawing/format" "$repo/crates/doc/src/drawing/"
|
||||
cp "$root/crates/doc/build.rs" "$repo/crates/doc/"
|
||||
cp "$root/scripts/fold-format-pending.sh" "$repo/scripts/"
|
||||
rm -f "$repo"/crates/doc/src/format/pending/*.rs # start from a release: nothing pending
|
||||
rm -f "$repo"/crates/doc/src/format/pending/*.rs "$repo"/crates/doc/src/drawing/format/pending/*.rs # start from a release: nothing pending
|
||||
git() { command git -C "$repo" -c user.name=test -c user.email=test@example.com "$@"; }
|
||||
git init -q -b main
|
||||
git add -A
|
||||
@@ -20,12 +22,13 @@ git commit -qm base
|
||||
|
||||
change() {
|
||||
git switch -q -c "$1" main
|
||||
printf '//! %s\n' "$1" > "$repo/crates/doc/src/format/pending/$1.rs"
|
||||
printf '//! %s\n' "$1" > "$repo/crates/doc/src/${2:-format}/pending/$1.rs"
|
||||
git add -A
|
||||
git commit -qm "$1"
|
||||
}
|
||||
change b-second
|
||||
change a-first
|
||||
change c-drawing drawing/format
|
||||
|
||||
git switch -q main
|
||||
if ! tree=$(git merge-tree --write-tree a-first b-second); then
|
||||
@@ -36,12 +39,18 @@ git merge -q --no-edit a-first b-second >/dev/null
|
||||
listed=$(git ls-tree --name-only HEAD crates/doc/src/format/pending/ | grep '\.rs$' | sed 's#.*/##' | tr '\n' ' ')
|
||||
[ "$listed" = "a-first.rs b-second.rs " ] || { echo "FAIL: merged pending changes are: $listed" >&2; exit 1; }
|
||||
[ "$tree" = "$(git rev-parse HEAD^{tree})" ] || { echo "FAIL: merge-tree and merge disagree" >&2; exit 1; }
|
||||
git merge -q --no-edit c-drawing >/dev/null
|
||||
|
||||
before=$(ls -d "$repo"/crates/doc/src/format/v*/ 2>/dev/null | wc -l)
|
||||
drawings=$(find "$repo/crates/doc/src/drawing/format" -mindepth 1 -maxdepth 1 -type d -name 'v*' | wc -l)
|
||||
(cd "$repo" && bash scripts/fold-format-pending.sh) > "$tmp/fold.log"
|
||||
after=$(ls -d "$repo"/crates/doc/src/format/v*/ | sort -V | tail -1)
|
||||
[ "$(ls -d "$repo"/crates/doc/src/format/v*/ | wc -l)" -eq $((before + 1)) ] || { echo "FAIL: the fold made no new version" >&2; exit 1; }
|
||||
[ "$(ls "$after")" = "$(printf 'a-first.rs\nb-second.rs')" ] || { echo "FAIL: the fold moved $(ls "$after")" >&2; exit 1; }
|
||||
[ -z "$(ls "$repo/crates/doc/src/format/pending/" | grep '\.rs$' || true)" ] || { echo "FAIL: pending changes left after the fold" >&2; exit 1; }
|
||||
(cd "$repo" && bash scripts/fold-format-pending.sh) | grep -q "No pending format changes" || { echo "FAIL: a second fold did something" >&2; exit 1; }
|
||||
echo "ok: pending format changes merge cleanly and fold into $(basename "$after")"
|
||||
# A drawing's formats start at 1, so its folded ones are v2/, v3/ and so on: the next is its own, never the design's.
|
||||
drawing_next="$repo/crates/doc/src/drawing/format/v$((drawings + 2))"
|
||||
[ "$(ls "$drawing_next" 2>/dev/null)" = c-drawing.rs ] || { echo "FAIL: the drawing change did not fold into ${drawing_next##*/}/" >&2; exit 1; }
|
||||
second=$(cd "$repo" && bash scripts/fold-format-pending.sh)
|
||||
grep -q "No pending format changes" <<< "$second" && ! grep -q "now fixed" <<< "$second" || { echo "FAIL: a second fold did something" >&2; exit 1; }
|
||||
echo "ok: pending format changes merge cleanly and fold into $(basename "$after"), a drawing's into ${drawing_next##*/}"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 156858.4073464102,
|
||||
"area_mm2": 24142.477796076935,
|
||||
"center_of_mass": [
|
||||
50.20028207009981,
|
||||
40.1001410350499,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 8
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"id": "84-plate-two-bores",
|
||||
"name": "Workshop plate with two independent Ø10 bores",
|
||||
"description": "Parameterized rectangular workshop plate 100 x 80 x 20 mm with 2 independent through bores. World origin at (0,0,0).",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=100, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 196858.4073464102,
|
||||
"area_mm2": 28942.477796076935,
|
||||
"center_of_mass": [
|
||||
50.15958641014815,
|
||||
50.23937961522224,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
100,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 8
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"id": "85-plate-width-100",
|
||||
"name": "Workshop plate after width change to 100 mm",
|
||||
"description": "Parameterized rectangular workshop plate 100 x 100 x 20 mm with 2 independent through bores. World origin at (0,0,0).",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
100
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
100
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
100
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
100
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
50,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
50,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø12; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 156167.25696262045,
|
||||
"area_mm2": 24199.026463841554,
|
||||
"center_of_mass": [
|
||||
50.333939643071744,
|
||||
40.189098352100864,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 8
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"id": "86-plate-first-bore-12",
|
||||
"name": "Workshop plate after Bore1 diameter changes to Ø12",
|
||||
"description": "Parameterized rectangular workshop plate 100 x 80 x 20 mm with 2 independent through bores. World origin at (0,0,0).",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "12 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 6,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=80, T=20 mm; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 158429.2036732051,
|
||||
"area_mm2": 23671.238898038468,
|
||||
"center_of_mass": [
|
||||
49.900851844838215,
|
||||
39.900851844838215,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 7
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "87-plate-bore1-deleted",
|
||||
"name": "Workshop plate retaining Bore2 after Bore1 deletion",
|
||||
"description": "Plate 100 x 80 x 20 mm after deleting Bore1, keeping the original Bore2 identity and XY (60,50), Ø10.",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=150, W=80, T=10 mm; Bore1 at (15,15) Ø8; Bore2 at (135,15) Ø8; Bore3 at (15,65) Ø8; Bore4 at (135,65) Ø8. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 117989.38070170255,
|
||||
"area_mm2": 29203.185789489246,
|
||||
"center_of_mass": [
|
||||
74.99999999999999,
|
||||
40,
|
||||
5
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
150,
|
||||
80,
|
||||
10
|
||||
]
|
||||
},
|
||||
"faces": 10
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
{
|
||||
"id": "88-plate-four-bores",
|
||||
"name": "Workshop plate with four Ø8 through bores",
|
||||
"description": "Parameterized rectangular workshop plate 150 x 80 x 10 mm with 4 independent through bores. World origin at (0,0,0).",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "150 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore3_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore4_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
150,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
150,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
150,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
150,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
15,
|
||||
15
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
135,
|
||||
15
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_3",
|
||||
"center": {
|
||||
"at": [
|
||||
15,
|
||||
65
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_4",
|
||||
"center": {
|
||||
"at": [
|
||||
135,
|
||||
65
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_3"
|
||||
],
|
||||
"value": "bore3_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_4"
|
||||
],
|
||||
"value": "bore4_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
15,
|
||||
15,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
135,
|
||||
15,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore3",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
15,
|
||||
65,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore3_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore4",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
135,
|
||||
65,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore4_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: four exterior vertical R10 corner rounds; four corner spandrels subtract volume T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); rounded perimeter P=2(L+W)-8R+2pi*R",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 155141.5926535898,
|
||||
"area_mm2": 23627.433388230813,
|
||||
"center_of_mass": [
|
||||
50.20249841450349,
|
||||
40.10124920725174,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 12
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"id": "94-plate-four-rounded-corners",
|
||||
"name": "Workshop plate with four R10 vertical corner rounds",
|
||||
"description": "100 x 80 x 20 mm workshop plate, Ø10 through-bores, four R10 vertical corner rounds.",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance",
|
||||
"chamfer"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "fillet",
|
||||
"name": "FourVerticalCornerRounds",
|
||||
"type": "constant_radius",
|
||||
"radius": 10,
|
||||
"tangent_chain": false,
|
||||
"edge_selection": "All four vertical exterior corner edges of the plate; far from both through bores",
|
||||
"edges": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
80,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
80,
|
||||
10
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: four exterior vertical R10 corner rounds; full upper tangent loop 2 mm chamfer; four corner spandrels subtract volume T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); rounded perimeter P=2(L+W)-8R+2pi*R; upper tangent chamfer subtracts volume Vc=P*c²/2-pi*c³/3 at (L/2,W/2,T-(P*c³/6-pi*c⁴/12)/Vc); area from upper inset face, side strips and sqrt(2)*c*(P-pi*c) bevel surface",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 154464.30652785578,
|
||||
"area_mm2": 23220.575727260624,
|
||||
"center_of_mass": [
|
||||
50.20338631779784,
|
||||
40.10169315889891,
|
||||
9.959084781124005
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 20
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
{
|
||||
"id": "95-plate-rounded-top-chamfer",
|
||||
"name": "Workshop plate with four R10 corner rounds and full upper 2 mm chamfer",
|
||||
"description": "100 x 80 x 20 mm workshop plate, Ø10 through-bores, four R10 vertical corner rounds and full upper perimeter 2 mm chamfer.",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance",
|
||||
"chamfer",
|
||||
"fillet"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "fillet",
|
||||
"name": "FourVerticalCornerRounds",
|
||||
"type": "constant_radius",
|
||||
"radius": 10,
|
||||
"tangent_chain": false,
|
||||
"edge_selection": "All four vertical exterior corner edges of the plate; far from both through bores",
|
||||
"edges": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
80,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
80,
|
||||
10
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "chamfer",
|
||||
"name": "FullTopPerimeterChamfer",
|
||||
"type": "equal_distance",
|
||||
"distance": 2,
|
||||
"tangent_chain": true,
|
||||
"edge_selection": "Front top straight edge after all vertical corners are rounded; tangent follow the complete top loop",
|
||||
"edges": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
10,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"end": [
|
||||
90,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
0,
|
||||
20
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# W23a — workshop plate fixtures and future Drawing acceptance (#4950)
|
||||
|
||||
This contribution contains **geometry oracle recipes only**. Each is a reproducible
|
||||
SolidCraft 3D model with independently calculated body count, volume, surface
|
||||
area, centre of mass and bounds. Drawing records, persistent references, UI
|
||||
commands, drawing fonts, HLR and file exporters are **not** implemented here.
|
||||
|
||||
Run `python3 tools/oracle-analytic/cases_workshop.py` to regenerate the
|
||||
analytic `expected.json` values; run `cargo xtask oracle` to replay every
|
||||
reference case through SolidCraft's CLI. Formula sources and method strings are
|
||||
in the script and the corresponding expected-value files.
|
||||
|
||||
## Exact geometry and states
|
||||
|
||||
The W23/M2 checkpoint baseline is a **100 × 80 × 20 mm** plate whose lower-left
|
||||
model corner is at XY (0, 0). Two independent through-hole features have
|
||||
identical Ø10 diameters and are 50 mm apart:
|
||||
|
||||
| Persistent feature | Model XY centre (mm) | Model diameter (mm) |
|
||||
| --- | --- | --- |
|
||||
| `Bore1` (A) | (20, 20) | 10 |
|
||||
| `Bore2` (B) | (60, 50) | 10 |
|
||||
|
||||
The model parameters are `length`, `width`, `thickness`, `bore1_d` and
|
||||
`bore2_d`; each hole is a separately created feature. The stock's sketch
|
||||
dimensions drive its length and width, and its extrusion depth is `thickness`.
|
||||
Each bore also has a construction-circle guide in the stock sketch with a
|
||||
diameter dimension driven by its corresponding `boreN_d` parameter. The
|
||||
individual `solid.hole` features use the same parameters and remain separate
|
||||
CAD operations; construction guides do not cut the stock.
|
||||
In an actual interactive workflow, the parameter edit is one command, not a
|
||||
rebuild from an unrelated set of fixed numbers.
|
||||
|
||||
| Oracle case | Model state (mm) | What this verifies |
|
||||
| --- | --- | --- |
|
||||
| `84-plate-two-bores` | 100 × 80 × 20; `Bore1` Ø10 at (20,20); `Bore2` Ø10 at (60,50) | Baseline; separate references |
|
||||
| `85-plate-width-100` | 100 × 100 × 20; both bores as in 84 | Only driving `width` 80 → 100 |
|
||||
| `86-plate-first-bore-12` | 100 × 80 × 20; `Bore1` Ø12; `Bore2` Ø10 | Only `bore1_d` 10 → 12 |
|
||||
| `87-plate-bore1-deleted` | 100 × 80 × 20; only original `Bore2` Ø10 at (60,50) | Delete `Bore1`, retain `Bore2` identity |
|
||||
| `88-plate-four-bores` | 150 × 80 × 10; four Ø8 bores at (15,15), (135,15), (15,65), (135,65) | Independent four-hole workshop plate |
|
||||
| `94-plate-four-rounded-corners` | 100 × 80 × 20; both Ø10 bores; **four vertical R10 exterior corner rounds** | Rounded exterior profile |
|
||||
| `95-plate-rounded-top-chamfer` | As 94, **plus a 2 mm equal-distance chamfer along the entire tangent-connected upper perimeter** | Rounded corners and chamfered upper edges |
|
||||
|
||||
The R10 corner cuts and full upper-perimeter 2 mm chamfer are analytic using
|
||||
the same quarter-spandrel and tangent-loop perimeter mathematics as oracle
|
||||
case 33. These revised blends require a fresh full oracle replay before they
|
||||
can be called verified.
|
||||
|
||||
## A3 at 1:2 — geometry versus measured dimension values
|
||||
|
||||
For the model in case 84, an A3 landscape drawing is **420 × 297 mm**,
|
||||
scale **1:2**. Its projected plate envelope on the sheet is **50 × 40 mm**;
|
||||
each Ø10 hole appears as a Ø5 circle and the 50 mm centre separation projects
|
||||
to 25 mm. **Annotation values remain in model units**: plate length 100 mm,
|
||||
plate width 80 mm, `Bore1` Ø10 mm, centre-to-centre 50 mm. Their written
|
||||
values are not halved by the view scale. Actual text metrics and display
|
||||
precision follow their eventual upstream owners (#4813 and #4896).
|
||||
|
||||
## Acceptance sequence once drawing APIs are available
|
||||
|
||||
Every independent edit starts from case 84; the cases in this table are
|
||||
**reference states**, not a claim that these drawing commands exist today.
|
||||
|
||||
| Step / intended command | Owning session | Resulting source oracle state | Expected drawing measurements and labels (after Update if deferred) |
|
||||
| --- | --- | --- | --- |
|
||||
| Create baseline geometry from the case 84 recipe | **Source design** | 84 | Two independent bores and the original width 80 geometry |
|
||||
| Add a `Bore1` diameter, centre-distance and plate-width dimension | **Drawing** | 84 (unchanged) | Ø10, 50, width 80 |
|
||||
| Move the dimension labels to chosen paper-mm coordinates; save and reopen the drawing | **Drawing** | 84 (unchanged) | Every label remains at its user-chosen paper coordinate |
|
||||
| `parameters.change` `width` 80 → 100 | **Source design** | 85 | Width becomes 100; Ø10 and centre distance 50 remain; all label positions unchanged |
|
||||
| `parameters.change` `bore1_d` 10 → 12 | **Source design** | 86 | `Bore1` becomes Ø12; centre distance 50 and width 80 remain; labels unchanged |
|
||||
| `timeline.delete Bore1` | **Source design** | 87 | `Bore2` remains Ø10 with its original identity. `Bore1` diameter and linked centre-distance dimensions become **lost, kept with their last values**; no silent transfer to `Bore2` |
|
||||
| `edit.undo` immediately after any individual source edit | **Source design** | 84 | Baseline measurements Ø10 / 50 / width 80 and original reference state, after drawing Update |
|
||||
| `edit.redo` following that source undo | **Source design** | 85, 86 or 87, matching the undone edit | Edited measurements and corresponding lost/valid reference state, after drawing Update; manually placed labels retain their paper positions |
|
||||
| Update an out-of-date view in deferred mode | **Drawing** (cache update; **no new undo step**) | No source change | Drawing displays the current source state; label positions remain unchanged |
|
||||
| Undo/redo a label move or an explicit reference repair | **Drawing** | No source change | Reverts/reapplies only the drawing edit; must never undo a source model change |
|
||||
|
||||
Both **open-drawing** and **closed-drawing** deferred-update sequences must
|
||||
be tested when the shared drawing commands land:
|
||||
|
||||
1. **Drawing open:** Change a driving parameter. The existing drawing and
|
||||
labels stay visibly **out of date until Update**; executing Update obtains
|
||||
the new values and retains manual label positions.
|
||||
2. **Drawing closed:** Save and close the drawing, change the 3D model, then
|
||||
reopen it. The saved drawing still appears out of date until Update;
|
||||
updating does not reset manual label positions.
|
||||
3. Repeat both sequences for bore changes, deletion and **source-design**
|
||||
undo/redo. Each source change, including its undo/redo, leaves a deferred
|
||||
drawing **out of date until Update**; Update refreshes the view cache
|
||||
without creating an undo step in either session. With a lost reference,
|
||||
retain the last measured value explicitly as historical instead of
|
||||
reporting it as a current measurement. A replacement bore at the same XY
|
||||
and diameter must not silently restore old source identity; reference
|
||||
repair is an explicit **drawing-session** operation.
|
||||
4. Undo/redo drawing-session label moves and reference repairs separately;
|
||||
neither action may undo a source design's parameter edit or deletion.
|
||||
|
||||
The coordinated drawing design allows **automatic and deferred** update
|
||||
modes (#4892). Do not introduce a separate rejected-update contract here.
|
||||
Native storage, update commands and undo ownership belong to #3890 and
|
||||
#4892; annotations and numeric formats to #4896; Drawing UI to #4894;
|
||||
sheet geometry/export to #4890. Add executable engine, desktop UI, browser
|
||||
and SVG/DXF/PDF/PNG comparisons only after those APIs are implemented.
|
||||
|
||||
## Analytic provenance
|
||||
|
||||
All source values use independent closed-form mathematics. For rectangular
|
||||
plates, volume is `LWT − Σπ(d/2)²T`, and surface area includes exposed
|
||||
hole walls minus circles on both stock faces. For the four vertical R10
|
||||
rounds, the section loses `(4−π)R²`; the boundary becomes
|
||||
`P = 2(L+W)−8R+2πR`. A 2 mm upper tangent-loop chamfer removes
|
||||
`V_c=Pc²/2−πc³/3` of material. The depth first moment is
|
||||
`Pc³/6−πc⁴/12`, so its removed-volume centroid is
|
||||
`z=T−(Pc³/6−πc⁴/12)/V_c`; the inward-offset perimeter shrinks with depth.
|
||||
Surface and first moments follow this exact construction.
|
||||
Calculations are regenerated by `tools/oracle-analytic/cases_workshop.py`.
|
||||
@@ -13,6 +13,7 @@ python3 tools/oracle-analytic/cases_features.py # 32-66: fillets, shells, sw
|
||||
python3 tools/oracle-analytic/cases_sheet_metal.py # 67-76: sheet metal
|
||||
python3 tools/oracle-analytic/cases_fillets.py # 77-81: variable, chord-length, tapered and spline fillets
|
||||
python3 tools/oracle-analytic/cases_surfaces.py # 83: boundary surfaces
|
||||
python3 tools/oracle-analytic/cases_workshop.py # W23 (#4950): parameterized plates, round corners and top chamfer
|
||||
```
|
||||
|
||||
Where a value depends on a SolidCraft convention rather than on geometry alone (a shell's sharp
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analytic expected-value writer for the W23 workshop reference plates.
|
||||
|
||||
Run from the repository root:
|
||||
python3 tools/oracle-analytic/cases_workshop.py
|
||||
|
||||
All dimensions are taken from the actual parameterized oracle recipes. The
|
||||
mass properties come from exact box/cylinder subtraction, rounded rectangle
|
||||
quarter-spandrels and the same tangent-loop chamfer integration as case 33.
|
||||
No SolidCraft result is used to create any expected geometry value.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from cases_features import MP, PI, box, cyl, spandrel_area, write
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "tests" / "oracle"
|
||||
CASES = (
|
||||
"84-plate-two-bores",
|
||||
"85-plate-width-100",
|
||||
"86-plate-first-bore-12",
|
||||
"87-plate-bore1-deleted",
|
||||
"88-plate-four-bores",
|
||||
"94-plate-four-rounded-corners",
|
||||
"95-plate-rounded-top-chamfer",
|
||||
)
|
||||
|
||||
|
||||
def one(case):
|
||||
recipe = json.loads((ROOT / case / "recipe.json").read_text())
|
||||
params = {
|
||||
p["name"]: float(p["expression"].removesuffix(" mm"))
|
||||
for p in recipe["parameters"]
|
||||
}
|
||||
length, width, thickness = (params[n] for n in ("length", "width", "thickness"))
|
||||
bores = [
|
||||
(f["name"], float(f["position"][0]), float(f["position"][1]),
|
||||
params[f["diameter"]])
|
||||
for f in recipe["features"] if f["op"] == "hole"
|
||||
]
|
||||
rounded = next((f for f in recipe["features"] if f["op"] == "fillet"), None)
|
||||
chamfer = next((f for f in recipe["features"] if f["op"] == "chamfer"), None)
|
||||
perimeter = 2 * (length + width)
|
||||
top_area = length * width
|
||||
solid = box(0, 0, 0, length, width, thickness)
|
||||
|
||||
if rounded is not None:
|
||||
# Four R10 vertical edges remove the same quarter-square-minus-circle
|
||||
# cross-section from each corner. Symmetry locates their combined COM.
|
||||
r = float(rounded["radius"])
|
||||
assert 0 < r < min(length, width) / 2
|
||||
removed_xy = 4 * spandrel_area(r)
|
||||
solid -= MP.at(removed_xy * thickness,
|
||||
[length / 2, width / 2, thickness / 2])
|
||||
top_area -= removed_xy
|
||||
perimeter -= 8 * r - 2 * PI * r
|
||||
|
||||
if chamfer is not None:
|
||||
# Constant-distance top chamfer around the entire rounded perimeter.
|
||||
# At depth y below the top, the removed band has area
|
||||
# P*(c-y) - pi*(c-y)^2: the inset perimeter shortens with depth.
|
||||
# Integrate this band for exact removed volume and Z first moment.
|
||||
c = float(chamfer["distance"])
|
||||
assert rounded is not None and 0 < c < rounded["radius"]
|
||||
removed = perimeter * c ** 2 / 2 - PI * c ** 3 / 3
|
||||
mean_depth = (perimeter * c ** 3 / 6 - PI * c ** 4 / 12) / removed
|
||||
solid -= MP.at(removed, [length / 2, width / 2,
|
||||
thickness - mean_depth])
|
||||
area = (2 * top_area + perimeter * thickness
|
||||
- c * perimeter + PI * c * c
|
||||
- c * perimeter + math.sqrt(2) * c * (perimeter - PI * c))
|
||||
else:
|
||||
area = 2 * top_area + perimeter * thickness
|
||||
|
||||
holes_desc = []
|
||||
for name, x, y, diameter in bores:
|
||||
radius = diameter / 2
|
||||
solid -= cyl(x, y, radius, 0, thickness)
|
||||
area += PI * diameter * thickness - 2 * PI * radius * radius
|
||||
holes_desc.append(f"{name} at ({x:g},{y:g}) Ø{diameter:g}")
|
||||
|
||||
detail = ("rectangular" if rounded is None else
|
||||
f"four exterior vertical R{rounded['radius']:g} corner rounds")
|
||||
if chamfer:
|
||||
detail += f"; full upper tangent loop {chamfer['distance']:g} mm chamfer"
|
||||
method = (f"Plate L={length:g}, W={width:g}, T={thickness:g} mm; "
|
||||
f"{'; '.join(holes_desc)}. Box minus independently located "
|
||||
"through-cylinders: V=LWT-sum(pi*d²*T/4), "
|
||||
"area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), "
|
||||
"mass moments from exact box minus cylinders. Shape: " + detail)
|
||||
if rounded:
|
||||
method += ("; four corner spandrels subtract volume "
|
||||
"T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); "
|
||||
"rounded perimeter P=2(L+W)-8R+2pi*R")
|
||||
if chamfer:
|
||||
method += ("; upper tangent chamfer subtracts volume "
|
||||
"Vc=P*c²/2-pi*c³/3 at "
|
||||
"(L/2,W/2,T-(P*c³/6-pi*c⁴/12)/Vc); "
|
||||
"area from upper inset face, side strips and "
|
||||
"sqrt(2)*c*(P-pi*c) bevel surface")
|
||||
# Every curved corner is one new vertical side face, in addition to
|
||||
# four remaining straight sides, two planar caps and independent bores.
|
||||
# A complete tangent-chain top chamfer contributes eight more faces:
|
||||
# one over each of the four straight runs and four R10 corner arcs.
|
||||
faces = 6 + len(bores) + (4 if rounded else 0) + (8 if chamfer else 0)
|
||||
write(case, method, 1, solid, area,
|
||||
[(0, 0, 0), (length, width, thickness)], faces=faces)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for fixture in CASES:
|
||||
one(fixture)
|
||||
Reference in New Issue
Block a user