Compare commits

...

7 Commits

Author SHA1 Message Date
lillith 514ad79e7b wip(drawings): refresh embedded storage and integrated foundations (#6157) 2026-10-27 08:27:47 +01:00
lillith 5d658c3089 docs(drawings): preserve delivery map and acceptance traceability WIP 2026-10-26 18:06:04 +01:00
lillith e01f2f363b claim: #6157 2026-10-24 17:06:09 +02:00
CamWight 4c63a88fc3 Drawing sheets: centring marks across the whole margin (#3875) (#5554)
The built-in sheet's centring marks now run from the trimmed sheet's edge to the drawing border.
From the grid reference border, as before, a mark was the same length as a grid field mark, and on
A3 it sat on one (the sheet's centre falls on a field edge), so it no longer read as a mark once the
grid reference border's outline is left out (Cameron, after the prototype on #3875).

- DrawingBorderProfile.centring_mark_start: sheet_edge (the built-in's, and when not given) or
  grid_border (ISO 5457's figure 5); ISO's full border in the tests asks for grid_border.
- A mark still needs a length: one that stops on the drawing border needs room outside it on the
  side it starts from, every margin for sheet_edge, the grid reference border for grid_border.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:48:17 -04:00
CamWight 5e23f58f62 Paper scene: plain records of what is drawn on a sheet (#4890 A1) (#5513)
geom::paper holds what a sheet shows as plain data: groups of strokes and fills, each group tagged
with the object that drew it (an opaque id) and its kind, with one validation pass over a bounded
record count. Seg2's checks and the coordinate rule move into profile.rs so section and paper share
them, and drawing_sheet takes its side and line limits from paper, so each limit is written once.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:23:18 -04:00
Benny1496 01a542d6e8 Drawing W23a workshop plate oracle fixtures (#4950) (#4962)
* test(oracle): reviewer-aligned parameterized workshop plate fixtures (#4950)

Independent analytic W23 expected values and seven verified models,
including four R10 corners and the 2 mm tangent top chamfer.
Document accepted Drawing states and commit regenerated docs/oracle.md
from successful workflow run #29 (90/90 pass).

* fix(oracle): correct W23 chamfer centroid and clarify drawing session ownership
2026-10-23 07:35:28 -04:00
CamWight f0cc73eb83 Drawing sheets: sizes, the ISO 5457 border and its marks (#3875) (#5108)
* Drawing sheets: sizes and the ISO 5457 border (#3875)

A sheet is a paper size and an orientation: ISO A0 to A5, Letter, Legal,
ANSI A to F, architectural A to E1, or a custom size (finite, above 0 and
at most 5,000 mm a side), landscape by default and either way for every
size.

A border profile is the rule for its border, and layout() works it out
for a sheet: the frame inside the margins, and the reference fields with
their labels. The ISO profile has a 20 mm filing margin on the left and
10 mm elsewhere, a 0.7 mm frame, fields of 50 mm out from the centre
lines with the corner fields taking what's left, rows lettered from the
top without I and O (AA, AB and on past Z), columns numbered from the
left, and an A4 sheet labelled on its top and right only. A profile can
divide the frame equally instead, or have no grid.

The records sit in geom, next to the section records, so saving,
rendering and export can all use them. Nothing is saved or drawn yet:
there is no command and no format change. Margins that leave no drawing
space, values that aren't finite, fields outside 1 to 1000 mm, more than
200 equal fields and unknown JSON fields are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing sheets: ISO 5457's centring and trimming marks (#3875)

A drawing border profile now also holds the grid reference border and the
centring and trimming marks, either of which can be left out. The ISO
profile follows ISO 5457:1999 (read in its identical adoption,
IS 10711:2001):
- the grid reference border 5 mm outside the drawing border on every
  side, with the field labels between the two;
- four centring marks on the sheet's centre lines, from the grid
  reference border to 10 mm inside the drawing border;
- at each corner a trimming mark of two overlapping 10 x 5 mm
  rectangles, in the margin outside the grid reference border.

The labelled sides are now a rule worked out for each sheet: ISO 5457's
(the top and right of A4, all four sides of the others) or fixed sides.
So one profile labels every size correctly. validate() checks what
doesn't depend on the sheet, for when a profile is read; layout() calls
it, then checks the rest against the sheet.

Refused:
- a grid reference border that isn't finite, is negative or is wider
  than a margin;
- a centring mark that isn't above 0, would reach past the sheet's
  centre lines, or is on a drawing border those lines miss;
- a trimming mark that isn't above 0, is wider than it is long, is
  wider than the margin left outside the grid reference border, or is
  longer than half the sheet's shorter side.

The grid rule's JSON is now externally tagged ("none",
{"from_centre": {"field_mm": 50}}), as the sheet size is, so an unknown
field is refused in every form; the internally tagged "none" ignored
one. Nothing is saved yet, so the shape change costs nothing.

The names follow the glossary, and ISO 5457's "drawing sheets": the
module is drawing_sheet (was sheet, which means sheet metal elsewhere
here), DrawingBorderProfile and SheetLayout (were BorderProfile and
BorderLayout), sheet_border and drawing_border (were paper and frame),
and Sheet::size_mm (was paper_mm). The first part of S1 is still a draft,
so the renames are made here rather than in a commit of their own.

The extra centring marks ISO 5457 asks for on sheets larger than A0
aren't here yet. Still nothing is saved or drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing sheets: a lighter built-in border, the filing margin a choice (#3875)

Cameron's decisions after seeing the sheet in the app, 2026-10-23:
- No trimming marks in the built-in profile: the filled corner blocks
  take a lot of ink. trimming_mark_mm stays, so a profile can still ask
  for them.
- Centring marks stop at the drawing border: from the grid reference
  border to the drawing border, never into the drawing area (on A4 they
  crossed the tables). centring_mark_mm keeps its meaning, how far a
  mark reaches inside the drawing border, and now takes 0, the built-in
  value, as grid_border_mm already takes 0 for "on the drawing border".
  A mark of no length (a reach of 0 with no grid reference border) is
  refused.
- Margins of 10 mm all round. ISO 5457's 20 mm filing margin on the
  left, for drawings that get bound, is a named choice: filing_margin,
  off when not given. On, it stands in for margins.left_mm, which is
  kept, and still checked, for when it's off again. The 20 mm is
  FILING_MARGIN_MM, and validate() and layout() both take the margins
  from one helper, laid_out_margins().

A3 landscape is now 400 x 277 at (10, 10), with eight 50 mm columns.
With the filing margin it is 390 x 277 at (20, 10) again, the same as a
20 mm left margin set by hand. A profile with all three choices on
(ISO 5457's full border) lays out exactly as the built-in profile did
before, and the tests keep the old hand values for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:01:08 -04:00
28 changed files with 5366 additions and 50 deletions
+519
View File
@@ -0,0 +1,519 @@
//! 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, 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 (`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 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 {
require(reach.is_finite() && reach >= 0.0, "a centring mark ends on the drawing border or 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 {
require(length.is_finite() && width > 0.0 && width <= length, "a trimming mark is finite, above 0 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;
+602
View File
@@ -0,0 +1,602 @@
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 },
// ...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 },
// ...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 },
] {
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);
}
}
+2
View File
@@ -17,6 +17,7 @@ pub mod bspline;
mod bvh;
mod chain;
pub mod compare;
pub mod drawing_sheet;
pub mod env;
pub mod fill;
pub mod fill_general;
@@ -24,6 +25,7 @@ mod grid;
mod mesh;
pub mod mesh_distance;
pub mod network;
pub mod paper;
mod plane;
mod profile;
pub mod section;
+180
View File
@@ -0,0 +1,180 @@
//! 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.
use serde::{Deserialize, Serialize};
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.
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
}
impl Scene {
/// 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;
+305
View File
@@ -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();
}
+60
View File
@@ -76,6 +76,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();
@@ -586,6 +642,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();
+5 -42
View File
@@ -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;
const FRAME_TOLERANCE: f64 = 1e-8;
/// A section's geometric result, without source identity, freshness or persistence semantics.
@@ -111,16 +108,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 +130,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 -1
View File
@@ -1,4 +1,4 @@
use std::f64::consts::PI;
use std::f64::consts::{PI, TAU};
use super::*;
use crate::{MAX_BEZIER_CTRL, bezier};
+655
View File
@@ -0,0 +1,655 @@
# Drawing delivery and handoffs
Use the checklist in the body of [RFC #860][rfc] for live progress. This file is
a dated dependency map; the [final W00–W47 plan][plan] remains the delivery plan.
Coordination snapshot: **2026-10-24**, `tangency-labs/solidcraft`, branch
[`drawings` at `4c63a88fc3035ac91b4b9dc847fffa0d2d5ec155`][base].
This W00b child ([#6157][w00b], [PR #6163][w00b-pr]) reconciles the
[W00–W47 plan][plan] with later decisions and public claims. It is a dated map,
not a live dashboard, a new assignment or a release announcement.
Read current [AGENTS](../AGENTS.md) and [CONTRIBUTING](../CONTRIBUTING.md), then
recheck the linked discussions and claims before taking a child issue. The
[W01a ledger](drawing-foundations.md) and [geometry contract](drawing-geometry.md)
retain their original structural-validation scope. This map covers every package;
**atomic traceability of every research-study requirement is still unfinished W00
work**. Research is requirements evidence, not copied implementation or standards
certification. The [updated study, attachment 33742361][study-current], linked from
[its author's discussion][study], adds the §18.10 BOM and sheet-source decisions.
Where they differ, §18.10 supersedes the earlier study choices; later owner
agreements take precedence in turn, as recorded below.
## 27 October 2026 — current decisions overlay
This update reconciles only the changed contracts and delivered slices. The
[live RFC checklist](https://github.com/tangency-labs/solidcraft/issues/860)
remains authoritative for the remaining packages. All the 24–25 October
evidence below is dated; a draft is not an implemented product feature.
- **G3 has changed:** [the maintainer's embedded storage decision][g3]
replaces separate `.scdwg` files. Drawings now belong inside the parent
design's `.solidcraft` container, as separate `drawings/<id>.json` entries,
or inline in plain JSON. Model and drawing have distinct undo histories.
K3 [#7098](https://github.com/tangency-labs/solidcraft/pull/7098) merged;
K3b [#7490](https://github.com/tangency-labs/solidcraft/pull/7490)
is a draft; the K4-prime in-file session follows in [#3891][storage-pr].
- **W17a** [#4817][view-pr] merged into `drawings` on 27 October
as `de7bf4fc`. This delivers frame and scale data, not saved views.
- **W06a** [#2457][hlr-pr] merged into `drawings` on 27 October
as `ebafe074`. The parallel-work and work-budget [#3013][hlr-parallel]
follow-up remains open; HLR-to-paper is not complete.
- **X1** [#6184][dxf-claim] remains draft at `3fb2eef9`.
Six of its seven prior sheet regressions pass, and the 53 original DXF
byte pins are unchanged. The remaining curved-to-object trim case requires
source-proven circular identity; there is no passing full CI or wasm
evidence for this revised source.
## What the evidence means
| Status | Meaning in this snapshot |
| --- | --- |
| Integrated | The PR is merged and its merge commit is an ancestor of the pinned `drawings` commit. Only the named slice is available. |
| Main only | Merged into `main`, but its merge commit is not an ancestor of the pinned `drawings` commit. Integration is still a dependency. |
| Open code | The PR has a nonempty diff. Its implementation, validation and review remain attributable to that PR revision; this map does not certify them. |
| Claim | An open draft has zero changed files against its base at the check. This reserves work; it supplies no implementation. A parent may have separately merged children. |
| Plan | A requirement or proposed slice remains. No separate implementation claim was identified in this review; check related parent ownership before claiming. |
The initial review checked the complete open inventory (686 PRs and 2,502 issues), the
relevant PR metadata and the pinned Git tree/ancestry. It ran no compilation,
tests, benchmarks or application scenarios. An open PR's synthetic merge SHA is
not evidence that it landed. A claim's empty diff is not evidence that its owner
has done no local work.
Same-day follow-up: [K1 is now open for review][k1-pr] at
`1a2bd57f802c5f26122424353ce78306df7878c8`, targeting the pinned `drawings`
base, with eight changed files. It introduces shared format families and release
folding, not Drawing records or a reader for those records. It also deliberately
checks unknown pending changes before validating design content. It is not
merged; its author's validation report is not an independently reproduced gate.
The accepted accuracy, shading and editor contracts below were also refreshed
from their owners' replies. Those agreements do not establish implemented APIs.
**2026-10-25 follow-up:** the updates below use remote
[`main` at `2741cd9a8b0b84b7d33001d8f9473198b5d53f73`][main-oct25] and
[`drawings` at `672ef13eb8b8e2cb0ddf9c795e337b73d043eb07`][drawings-oct25].
Other entries retain the 2026-10-24 snapshot; this is not a new full inventory.
- [#6130][sheet-dxf-options] merged on 2026-10-25 at 02:10:07 UTC as
`9d226b35358920a8b80ae4e0c43470d0f54edf97`. That commit is an ancestor of
the `main` pin above, but not the `drawings` pin: **main only** under this map's
ancestry definition. [#5709][dxf-text-refusal] remains open at
`da1baa044c1ac12081d2b1ffddf67a3284fa66fd`; the overlapping DXF chain still
waits for that prerequisite and output byte pins. [Composition review][dxf-composition]
against the `main` pin identified two needed fallible-writer adaptations:
`?` in the new sheet DXF `one_layer` branch and `.unwrap()` in the degree-nine
round-trip test. This is source/textual-composition evidence; no Cargo run or
executed failure of the combined code was reported.
- K1 [#6196][k1-pr] remains open at the same `1a2bd57f` revision recorded above.
- W38.0 [#6336][sketch-store-pr] remains open into `main`, now at
`c283ecd1169e75103f9daa8d5c3944fcc9e59e0d`. The [author's update][sketch-store-fix]
reports broader active-sketch value scanning, planted let-else/match-arm faults
and five more commands using `target`, including changed bad/missing-sketch
refusals. Its reported 5,147 passing tests are author evidence, not independent
validation or approval by this map. [Subsequent source review][sketch-store-selection]
resolved the original bypass finding but raised a P2: an absent numeric sketch
ID can fall back to a matching name in four newly centralized callers. The
exact-ID regression remains open; the example was not executed. `sketch.inspect`
already had that fallback and is not counted as a new regression.
- W07 V1 has the zero-file draft claim [#6482][mesh-lines-pr] into `main`, at
`f8374713b6078deee425d521077dd27967944f70`. Its [revised V1/V3 boundary][mesh-lines-split]
below is an agreed contract, not a delivered projection or identity API.
- W17b S1 has the zero-file draft claim [#6483][shade-s1-pr] into `main`, at
`c3b7d77d29d8599fc07ab799b726546888c1b097`. It reserves the shared tile-renderer
refactor; it supplies no API yet. S1/S3 acceptance still needs actual providers.
**Text-metrics follow-up (2026-10-25):** [#4815][text-pr] merged at 06:13:31 UTC
as `d557c09c3b5a059e553a5d9f1fb2682e52ecbaae`. The merge is an ancestor of
[`main` at `ac53fc1e6fab85a50bdc410178cc75133183393f`][text-main-check], but not
of [`drawings` at `672ef13eb8b8e2cb0ddf9c795e337b73d043eb07`][text-drawings-check]:
**main only**. This separate ancestry check does not refresh other rows. The
library's `TextMetrics` contract is delivered on `main`; Drawing layout, fonts
and their qualification remain separate work.
### Integrated slices and dependencies outside the branch
| Slice | Verified delivery | Limit / next consumer |
| --- | --- | --- |
| W01a, `0xlillith` | [#2085][w01-pr], merge `7c9f8a58`; `geom::section` | Neutral records and structural checks, not body sections, persistent identity or certified material regions. |
| W09a, `0xlillith` | [#2086][w09-pr], merge `6422ef84` | Shared length display formatting, not the complete Drawing number/tolerance/profile system. |
| W18 sheet geometry, CamWight | [#5108][sheets-pr] (`f0cc73eb`) and [#5554][marks-pr] (`4c63a88f`); `geom::drawing_sheet` | Sizes, borders and marks, not saved sheets, title blocks or a Drawing workspace. |
| W10 A1, CamWight | [#5513][paper-pr], merge `5e23f58f`; `geom::paper` | Strokes/fills grouped by object ID/kind with bounded validation. Prepared traversal, resolved clipping, pictures, measured text and writers remain later slices. |
| W23a, Benny1496 | [#4962][fixture-pr], merge `01a542d6`; [fixture contract](../tests/oracle/W23a-workshop-acceptance.md) | Original model recipes and analytic expected states, not executed associative Drawing acceptance. |
| DXF degree fix, CamWight | [#5281][degree-pr], merge `e8a6a5d3`, **main only** | Needed before the shared DXF refactors; it is absent from this pinned branch. |
## Decisions that supersede the original plan
- **Integration branch:** Drawing feature PRs target `drawings`; shared foundations
follow the agreed owner/integration target. The maintainer shepherd handles
integration from `main`; do not open competing synchronization PRs.
See the [branch decision][branch] and [shepherd clarification][shepherd]. This
is the Drawing-specific update to the plan's original `main` instructions.
- **Kernel boundary:** the [current kernel pause][kernel-pause] and
[CONTRIBUTING](../CONTRIBUTING.md#kernel-work-is-paused) still apply. The
[kernel/SolidCore tracking PR #4012][kernel-tracking] is separate work, not
proof that a portable extraction or Drawing HLR API has landed. Coordinate
W02–W06 with their current authors; do not start another truck-specific backend.
- **G3 — drawings are embedded:** the [new in-design contract][g3]
supersedes the earlier linked Drawing-file option. Each authored drawing
lives in a separate entry in the design's container, or inline in plain
JSON. Undo and revision history are per drawing, without separate files.
The [older JSON amendment][g3-json] is still relevant to stable and
lossless save/reopen, but not to the old file split.
- **Storage and sessions:** [#3890][storage] owns the drawing record,
versioning, in-file commands and recovery. K3 merged in
[#7098](https://github.com/tangency-labs/solidcraft/pull/7098);
K3b [#7490](https://github.com/tangency-labs/solidcraft/pull/7490)
updates persistence, and K4-prime follows in [#3891][storage-pr].
A drawing's source model is the same design file, not a relative external
link. Paper scenes and render results remain disposable caches. These
changes are staged; the complete workflow is not delivered here.
- **Reference work can start earlier:** [#4892's revised slices][refs-plan]
first handle planar faces, curved faces, edges/vertices and source keys without
saving anything. Publication, automatic/deferred updates and background hosts
still depend on the later storage/evaluation contracts. Do not substitute nearest
geometry or display indices for full occurrence paths, persistent names or intent.
Accuracy, confidence and freshness remain separate.
- **BOM and sheet sources (2026-10-25 reconciliation):** [study §18.10][study-current]
makes the design's BOM the authority for item numbers, saved per BOM view and
shared by parts lists and balloons. Part/assembly roles derive from component
contents, including child placements; they are not separate Drawing kinds.
Components have normal, phantom, reference, purchased or inseparable BOM types,
with per-occurrence overrides. Each sheet has an explicit source component,
initially seeded by its first placed view, then persisted and editable; deleting
that view does not retarget the sheet. Title fields and template tables use that
source, with configurable field sources and marked overrides.
- **Later BOM agreements override older study wording:** [#3874's plan][bom-plan]
keeps purchased parts inside an inseparable component in the parent's parts-only
list. Its [later update][bom-update] saves item numbers in the same undo step as
the change adding a row, keyed by component and BOM type so part-number edits
retain them. Any component can carry a typed quantity expression, with shared
mass/volume units; quantities are not restricted to integer counts. Parts lists
consume BOM numbers and keep local overrides marked until explicitly saved to
the BOM. These are agreed contracts, not a claim that W24–W26 are complete.
- **W04 is JSON-only in the current PR:** [#2068][section-pr] at
`d2a9e4852cdfa9afd769083d7408e2f69683a14a` returns `inspect.section_curves`
JSON, with no SVG/DXF export. The [author's rework][section-json] separates
shared crossings into [#6272][section-crossings], which must land first, and
reports an error beyond `MAX_PIECES` instead of a partial cut. [Cam's agreement][section-adapter]
puts the neutral `SectionGeometry` adapter after both PRs as a W07 follow-up.
Physical SVG/DXF output remains a later requirement; it is not delivered by
this command PR.
- **SVG first:** [the agreed export order][svg-first] puts SVG and the shared
paper traversal before the paused Drawing DXF adapter. The
[canvas/export boundary][scene-boundary] places shared traversal and resolved
clipping in `geom`, while display tessellation stays in `ui-egui`. The later
[`Scene::prepare`/picture plan][writers-plan] is not A1's shipped API.
[PDF dependencies have been selected][pdf-choice]; embedding, fonts, shaping,
glyph coverage, bounds and writer qualification still need their own evidence.
- **W07 now names `drawing.view.snapshot`:** the [current command plan][snapshot]
is read-only, transient and non-undoable. It uses exact W17a view bases,
fitted scale and centring, explicit occurrence subtrees/hiding and projected
measurements. It does not save associative annotations. First/third angle
belongs to the Drawing and projected-view placement, not a single snapshot.
- **W07 V1/V3 split (2026-10-25):** [V1][mesh-lines-split] moves the shared
projection into `engine/src/mesh_lines/` on `main`: `prepare` takes caller-selected
bodies, errors name the calling command, and a `View` supplies origin,
right/up/back basis and optional perspective distance. View DXF constructs that
value from its camera and must retain byte-identical output. V3 on `drawings`
adds each line's placement and edge/silhouette-face source, splits stretches
when their owner changes, and retains triangle face IDs. The first-visible-source
ownership rule must be tested against W07; V1 does not supply that identity API.
- **W17a frame and error contracts:** [normal points toward the viewer][view-map],
HLR direction is its negative, and the shared map converts model to paper mm
while scaling accuracy deviations. [Exact named bases and ratio scale][view-bases]
avoid camera pitch clamps. Requested tolerance is not automatically achieved
accuracy. The [accepted W07 accuracy contract][accuracy-contract] reports the
achieved class per piece in paper mm, preserving `Sampled(0)`. Planned optional
`max_error_mm` is a check, never a producer input or a clamped value: analytic
pieces meet a request, bounded pieces require scale × bound ≤ request, and
sampled pieces never certify one. An unmet request refuses with counts by
accuracy class. Without a request the result reports achieved accuracy. The
mesh-only interim path cannot satisfy a request. The planned sampling parameter
is `chord_mm`, replacing `tolerance_mm`; the exact path ignores it, and an
out-of-range model chord is refused against the view-DXF 0.0001–1 mm limits.
- **W17b is in the first ship:** [the shaded-view plan][shading] uses a shared
CPU tile renderer first, one map for pixels/vectors, transparent backgrounds
and five display styles. A future GPU executor needs comparison against CPU.
[Latest resolution policy][sheet-dpi] is **sheet-only DPI**, default 300;
it removes the older per-view override. PNG exports use their own DPI. The
[accepted registration contract][shade-acceptance] anchors a picture's grid at
the view frame's lower-left corner. With `p = 25.4/dpi`, its outward width is
`ceil(width_mm/p - 1e-9)` pixels: 100 mm at 300 dpi is 1182 px, spanning
`1182*p` mm without stretching or cutting. Samples use pixel centres (or quarter
and three-quarter positions for 2×2 sampling). PNG output re-renders onto the
sheet's top-left pixel grid for pixel-copy compositing; a shared rectangle/grid
function is planned in `geom::paper`. Pixels are sRGB-encoded 8-bit straight
RGBA with transparent background, blended in linear light onto white paper,
not screen colours. Accepted checks bind to S1's tile renderer and S3's paper
picture when their PRs exist. Reference-only views are allowed; reference-part
occlusion and drawing light remain separate follow-ups.
- **Shared visual-style naming is still proposed:** [#5307's updated proposal][style-proposal]
would store a stable visual-style name in a display state. It is not an agreed
descriptor or parameter rename. The viewport [#5219][viewport-style] and display
states [#5310][display-states] remain separate open work.
- **W17c belongs to existing claims:** the [view-edit plan][view-edit] spans
W07/W16/W17a/W17b, not a new unowned package. Saved-view editing, inheritance,
boundaries and picking must share their providers. The [inheritance contract][inheritance]
resolves unset properties through parent/default values; alignment and scale
have their own rules. Boundary picking is distinct from selecting content.
[Pick boundaries/status][pick-data] do not become printed scene content.
The [accepted editor contract][ux-acceptance] gives each changed Apply one
Drawing undo step; unchanged Apply gives none. X discards only edits since the
last Apply and closes, preserving earlier applied steps. OK applies remaining
changes as one step and closes; unchanged OK only closes. Accepted behavioural
checks bind to `drawing.view.inspect`, `drawing.view.edit` and the window host
once available; Nathan-Dane owns the window's visual integration.
- **W23a replaces the earlier proposed workshop fixture:** the integrated
[100 × 80 × 20 mm plate with two independent Ø10 bores][fixture-contract]
drives width, bore edit/delete, deferred update and per-file undo expectations.
It does not replace W01a's separate 100 × 60 section fixture. Source-version
acceptance is already assigned to [#5428 / #5430][version-pr]; do not duplicate it.
## Study §18.10 requirement traceability
This **2026-10-25** increment maps the complete §18.10 of the [updated study][study-current]
to the [final plan][plan] and the later agreements in [#3874][bom-plan] and
[#3875][sheet-plan]. **D1–D19** below refer to that section's numbered decisions;
**E1–E14** refer to its numbered acceptance examples. They are source locators,
not new issue IDs. The glossary policy is included separately. This does not
complete the rest of W00's study traceability or replace the RFC's live checklist.
Ownership stays with the existing claims: **BOM** means CamWight's
W24/W25/W26/W31 scope in [#3874 / #3876][bom-pr]; **Sheets** means CamWight's
W18 scope in [#3875 / #3877][sheets-parent]. **Storage** is CamWight's
[#3890 / #3891][storage-pr] dependency for W12/W15. W16/W40 are cooperating
workspace/resource packages, not new assignments. Later custom-table work maps
to W43; no separate child owner is assigned here.
Evidence pins for this increment:
| Evidence | Revision and observed scope |
| --- | --- |
| BOM B1 | [#5107][bom-types-pr], open, `43388eec3600cf4357e36392fdaf55b68e3aa5be`; BOM types child. |
| BOM D | [#3876][bom-pr], open draft, `d1ec61b09097cfe4d786f81190190a2aa3fc72c8`; typed `doc::bom` rows, first-level/parts-only views, occurrence lookup and mass/material data. Its `Row::item` is positional until numbers are saved; `Row::count` remains an integer. |
| Sheets S2 | [#3877][sheets-parent], open draft, `9e32f29424703fb012b1d7f17e73f5b1efe6dad2`; cell content and title-block rectangles/label/value boxes. This supersedes the older zero-file observation for this parent elsewhere in this dated map. |
| Storage K2 | [#3891][storage-pr], open draft, `cef6b585e988a526bb69cef29e521a17323b1371`; Drawing/sheet records include `Sheet::documents: Option<SheetComponent>` with explicit source/component IDs. No view or table record in that slice. |
| Field keys | [#4786][field-keys-pr], open, `8b1b4e1fe0675a082b904e15ba6d4ac9fd2b2639`; S2's resolver still depends on the property-key work landing. |
| Typed quantities | [#4812][quantity-pr], open draft, `2876ae39ff27aa888b5bf598a0bd04aaf0405548`, zero changed files at this check: a claim, not a delivered quantity evaluator. |
| Sheet geometry | [#5108][sheets-pr] and [#5554][marks-pr] are the integrated geometry slices recorded above. They do not deliver template editing or persisted title blocks. |
**Open code** below means source inspected at these pins, not approved or executed
acceptance. **Gap** means the required behaviour is not established by those
slices or their current delivery reports; the existing owner must supply the
missing implementation/acceptance evidence. It is not a statement about unpublished
work. No Cargo, application, export or save/reopen scenario was run for this
traceability increment. Author test reports in [BOM D][bom-d-report] and
[S2][sheet-s2-report] remain author evidence.
### Drawing, templates and component roles
| Source / current requirement | Existing scope | Evidence/state | Missing acceptance or dependency |
| --- | --- | --- | --- |
| D1: one Drawing kind can contain part, assembly or mixed views. | W12/W17; Storage, Sheets | Open code: K2 has one Drawing record, without a part/assembly discriminator; views remain later. | Create mixed-content views in one saved Drawing; templates must not introduce another Drawing kind. |
| D1: add no content beyond the selected template; a parts list is placed explicitly or supplied by that template. | W18/W25; Sheets, BOM | Gap: neither S2 nor K2 creates template tables. | E12: a part template without a list adds none; an assembly template adds only its specified content. |
| D1: allow a manually placed one-row parts list for a part. | W24/W25; BOM | Gap: a Drawing parts-list consumer is not established. | E12: a body-owning component without children yields its own row when used as this list's source. |
| D2: templates carry sheets, borders, title blocks, notes and sheet tables. | W18/W40; Sheets | Plan: [S3 template scope][sheet-plan]; S2 supplies only cell/block definitions. | Instantiate all five kinds, with saved resource snapshots and valid object references. |
| D2: separate user defaults for part and assembly templates, with machining/fabrication examples per sheet size. | W18/W40; Sheets | Plan: [template update][sheet-update]; usable defaults wait for Storage/app/CLI. | Select and persist both defaults; verify size-specific original examples without storing a part/assembly Drawing type. |
| D2: a template list reads the sheet component and stays empty until that component is assigned. | W18/W25; Sheets, BOM; W12 dependency | K2 stores the source; table binding is a gap. | E12: empty list before first view, then the correct source; explicit source changes update the list. |
| D3: a part has bodies and no child component occurrences. | W24; BOM | Partial: D examines bodies/contents for counting; the complete role/glossary contract is not demonstrated. | Classify a body-owning leaf without making every body a separate part. |
| D3: an assembly has child component occurrences, whether or not it also owns bodies. | W24; BOM | Partial: D uses both contents and bodies; this is not acceptance of all consumers' terminology. | Test a welded body-owning component with bolts and an assembly with no own bodies. |
| D3: a subassembly is an assembly occurring inside another component. | W24; BOM | Gap in complete role/glossary evidence. | Distinguish definition role from its occurrence in a parent; verify nested assembly presentation. |
| D3: roles derive from current contents and are not stored component types. | W24; BOM | Partial source evidence only. | E11: add/remove the sole bolt and observe part → assembly → part, including undo and reopen. |
| D3: a body is not a part; an occurrence is a placement of a component. | W24; BOM | D keeps bodies, definitions and full occurrence paths distinct. | Verify quantity and labels for multiple bodies and repeated occurrences; preserve the existing occurrence terminology. |
| Terms/D3: add each introduced term to the glossary with its introducing PR; use table “kind”, BOM “type”, and occurrence rather than instance. | W18/W24–W26/W31; Sheets, BOM | Partial: #3876 adds bill of materials/type and occurrence-command references. | Audit all 20 proposed terms against their introducing children, including part/assembly/component wording. The two additions do not close the full glossary obligation. |
### BOM authority, numbering and views
| Source / current requirement | Existing scope | Evidence/state | Missing acceptance or dependency |
| --- | --- | --- | --- |
| D4: BOM data belongs to the design and exists independently of a Drawing. | W24; BOM | Open code: D supplies typed model-side rows used by the command/window. | Review/integrate the shared service; Drawing consumers must use its typed data, not reinterpret command JSON. |
| D4: many lists on different Drawings can filter/sort one BOM while sharing item numbers. | W25/W39; BOM | Gap: shared persisted numbering and Drawing lists are not delivered by D. | E6 plus differently sorted/filtered lists: identical authoritative row numbers, no quantity changes from filtering. |
| D5: persist stable item numbers in the design. | W24/W25; BOM | Gap: D's positional `Row::item` is explicitly provisional. [Later agreement][bom-update] uses stable component/type row identity. | Save/reopen; edit names/part numbers without renumbering; bind numbers per source and BOM view. |
| D5: allocate the next free number for a new row. | W24/W25; BOM | Gap. [Later agreement][bom-update] requires allocation in the row-creating change's same undo step. | Add a row, undo once, redo and reopen; a read-only query must not create saved numbering. |
| D5: deleting a row leaves other numbers unchanged until explicit renumbering. | W24/W25; BOM | Gap: provisional ordering is not stable numbering. | E5: delete item 3 and preserve 4–9 through save/reopen. |
| D6: double-click opens the parts-list editor with its explicit renumber/update action. | W25/W16; BOM, workspace integration | Gap in the inspected slices. | Exercise the actual editor; do not turn ordinary BOM refresh into implicit renumbering. |
| D6: renumber in the chosen list's order with start number and minimum digits. | W25; BOM | Plan in [BOM agreement][bom-plan]. | Test custom start/padding and current sort order; E5 yields 1–8 after explicit renumber. |
| D6: renumber updates all lists of that BOM view and reports affected balloons. | W25/W26/W39; BOM | Gap: no persistent list/balloon consumer evidenced. | Cross-Drawing/list propagation and an affected-balloon report; preserve other BOM views' numbering. |
| D6: typed item numbers are visibly marked local overrides, propagated only to that list's balloons. | W25/W26; BOM | Gap; S2's generic override data does not implement list behaviour. | E6: changing A's item 4 to 10 changes A and its balloons, while B remains 4. |
| D6: an explicit save-to-BOM operation makes an override authoritative. | W25; BOM | Plan in [BOM agreement][bom-plan]. | E6: only the explicit save changes B to 10; verify undo, conflicts and persistence. |
| D6: locked item numbers are skipped by renumbering. | W25; BOM | Gap. | E5: keep locked 7 while other rows become 1–6 and 8; no collision or silent unlock. |
| D6: duplicate/conflicting item numbers are flagged. | W25; BOM | Gap. | Deliberately conflict local/saved/locked values; show the conflict before applying an invalid change. |
| D7: structured first level lists direct contents and is the parts-list default. | W24/W25; BOM | D has `FirstLevel`; its service default is `PartsOnly`, not evidence of the future table default. | E1 top-level gearbox/plate; assert the newly placed parts list selects first level explicitly. |
| D7: structured all levels includes nested rows numbered 1, 1.1, 1.2. | W24/W25; BOM | Gap: D's `View` has only `PartsOnly` and `FirstLevel`. Folded children/`rows_deep` are not a third view. | E1: nested bolt at 2.1 under the gearbox, with stable hierarchical numbering. |
| D7: parts only flattens eligible parts, excluding phantom/reference rows. | W24; BOM | Open code: D has `PartsOnly` and type-dependent traversal. | E1/E3/E4 and later purchased/inseparable rules; integrate and execute the view's acceptance. |
| D7: each BOM view owns its own saved item-number sequence. | W24/W25; BOM | Gap: D exposes provisional positions only. | Switching views preserves each saved sequence; structured nesting does not overwrite flat numbering. |
### Types, quantities and columns
| Source / current requirement | Existing scope | Evidence/state | Missing acceptance or dependency |
| --- | --- | --- | --- |
| D8: Normal is the default type; each occurrence contributes to its row. | W24; BOM | Open code: B1/D type rules and typed rows. | E1 and repeated nested components; old designs default correctly and unrelated type overrides do not merge. |
| D8: Phantom has no row; multiply/promote its contents into its parent. | W24; BOM | Open code: B1/D traversal rules. | E3: four bolts contribute four without a phantom row; repeated/nested phantoms retain multiplicity. |
| D8: Reference remains visible but contributes no BOM row. | W24/W26; BOM | D has reference exclusion and `Unlisted::Reference`; Drawing warning/visibility is pending. [Later agreement][bom-update] excludes reference descendants from mass/volume too. | E4: of two brackets, one reference leaves quantity 1; visible reference balloon warns; physical totals exclude its subtree. |
| D8: Purchased is one bought row, without listing its contents as independent rows. | W24; BOM | Open code: B1/D rules. | E2: purchased gearbox stays one row; optional inspection of children must not count them again. |
| D8: Inseparable is one made item; its own BOM can list its contents. | W24; BOM | Open code: B1/D rules. The [later purchased-inside exception][bom-plan] overrides the study's blanket parent-list exclusion. | E2: ordinary bolt stays out of the parent's parts-only list, but purchased contents appear there; the gearbox's own BOM lists its contents. |
| D8: Inseparable differs from Normal only for an assembly, and purchased-only filtering distinguishes made from bought. | W24/W25; BOM | Type data exists; full filter/role acceptance is not established. | Compare leaf versus assembly types; E2's purchased filter includes the purchased gearbox and excludes the inseparable one. |
| D9: an occurrence defaults to following its component's type and can override any of the five types. | W24; BOM | Open code in #3876; [command report][occurrence-type-report] describes reset-to-component and one-step undo. | Change the component after reset; test independent repeated occurrences, persistence and undo at the reviewed revision. |
| D9: expose the per-occurrence type chooser in its context menu. | W24/W16; BOM, workspace integration | Gap: the command report explicitly leaves the right-click control for later. | Exercise all choices and the following-component state through the actual menu. |
| D10: virtual components without bodies participate in counting, numbering and balloons in every applicable list. | W24–W26; BOM | Plan: [later agreement][bom-update] requires an explicit virtual kind, not an empty-component heuristic. | E10: Grease is a real BOM member with a stable number and a usable balloon despite having no geometry. |
| D10: locally typed custom parts-list rows come later. | W43, coordinated with BOM | Deferred requirement; no separate child/owner identified here. | Define local-row identity/quantity/balloon policy before implementing; do not substitute such a row for a virtual design component. |
| D11: quantities support typed expressions on any component. | W24/W25; BOM; shared units #4812 | **Superseded:** the study's counts-only first delivery is replaced by [typed quantity agreement][bom-update]. D still exposes integer counts; #4812 is a zero-file claim. | Test count, mass, length, area and volume as supported by the agreed shared evaluator; unit conversion and multiplicity preserve dimensions. |
| D10/D11: virtual mass contributes, using the shared mass/volume unit path. | W24; BOM; shared units #4812 | Plan in [later agreement][bom-update]; no completed virtual-quantity path evidenced. | E10's “Grease 50 g, quantity 1” must not freeze counts-only semantics: specify the authored quantity and resulting count/mass, then check totals without double multiplication. |
| D12: defaults are Item, Quantity, Name, Description, Material, Mass, with Mass enabled and last. | W25; BOM | D supplies row data, not the placed table's columns. | Check exact default order and visibility in a new parts list. |
| D12: Name uses the browser component name and is always filled. | W24/W25; BOM | D carries component names; table presentation is a gap. | E14: Plate appears as Name even without a part number; verify grouped-row name policy explicitly. |
| D12: Part Number is optional and stays blank until entered; Revision/free properties are addable columns. | W24/W25; BOM | D carries part number, revision and properties; column editing is pending. | E14: missing part number is not copied from Name; add each optional column and retain it on reopen. |
| D12: Material and Mass use the agreed source/unit rules. | W24/W25; BOM | D includes material/mass data. [Later table agreement][table-layout] requires uniform assembly material unless explicitly overridden; [unit agreement][bom-update] uses user mass units, default kg with two decimals. | Mixed-material assembly stays blank unless overridden; verify unit display separately from unrounded data and unknown mass. |
| D12/D15: saved column widths and wrapped cells keep text inside table borders. | W25/W31; BOM | [Later agreement][table-wrap] applies to every table column; no Drawing table layout is evidenced. | Drag and reopen a column width; long text wraps and increases row height. Keep the agreed 7 mm initial row height adjustable; title-block cells do not auto-grow like table rows. |
### Balloons and placed tables
| Source / current requirement | Existing scope | Evidence/state | Missing acceptance or dependency |
| --- | --- | --- | --- |
| D13: each balloon links an occurrence to its chosen parts-list row/item number. | W26; BOM; W13/W19/W25 dependencies | D has full-path `row_of`, but no persisted balloon/list link. | Repeated nested occurrences resolve the intended row; saved/overridden list numbers display correctly. |
| D13: when several lists exist, placement chooses one, defaulting to the first. | W26; BOM | Gap. | Place balloons against two differently ordered lists and verify the selected list survives reopen. |
| D13: reference occurrences warn instead of inventing a row. | W26; BOM | D distinguishes reference exclusion; UI behaviour is pending. | E4: reference bracket stays visible, gives a clear warning and acquires no false item number. |
| D13: picking a phantom targets an occurrence inside it. | W26; BOM | D distinguishes a missing own row; the pick workflow is a gap. | Pick the phantom's child and resolve its promoted row, including nested phantoms. |
| D13: a deleted row leaves a retained pink lost-reference balloon. | W26; BOM; W13/W14 | Gap. | Delete/undo the row: retain balloon placement and identity, with textual lost status as well as pink. |
| D14: Add Item repeatedly appends picked balloons until Esc, preserving the first item's number. | W26; BOM | Gap; stack workflow belongs to the existing balloon scope. | E8: bolt, washer, washer, nut; Esc ends picking without losing the accepted items. |
| D14: all attached balloons share the stack's one leader. | W26; BOM | Gap. | E8: four touching balloons, one leader, each retaining its own occurrence/number. This is distinct from W26's multi-leader balloon whose branches resolve to one row. |
| D14: neighbours touch, with 45-degree snapping and free-angle placement. | W26; BOM | Gap. | Check snapped and unsnapped angles, contact spacing and saved placement. |
| D14: a stack may repeat an item number. | W26; BOM | Gap. | E8: both washers remain in the stack; do not deduplicate by row number. |
| D14: deleting the first balloon transfers the leader to the next. | W26; BOM | Gap. | E8: remove the bolt balloon; next balloon inherits the leader and undo restores the original arrangement. |
| D14: lost stacked balloons retain their objects and turn pink. | W26; BOM | Gap. | Delete a stacked item's row; retain the stack with per-item lost status, including after reopen. |
| D15: use one extensible sheet-table object family; parts lists and revision tables are its first kinds. | W25/W31; BOM; W43 later | Plan in [BOM agreement][bom-plan]; S2 cells do not constitute a saved table model. | Both kinds share cell/layout infrastructure without mixing their data authority; custom kinds remain extensible. |
| D15: place tables manually or through templates. | W25/W31/W18; BOM, Sheets | Gap. | Create and move both table kinds in each path without adding unrequested tables. |
| D15: table edges snap to drawing border, title block or another table. | W25/W31/W18; BOM, Sheets | Plan; [later defaults][table-layout] place parts lists top-right and revisions bottom-left, with headings on top by default and bottom optional. | Exercise all three targets and both heading positions; save the chosen attachment. |
| D15: resizing the sheet preserves a table's chosen anchor. | W25/W31/W18; BOM, Sheets | Geometry is integrated; persisted table anchors are not evidenced. | E9: A3 → A2 keeps a title-block-attached list attached; report out-of-bounds content rather than arbitrarily moving it. |
| D16: sheet border is the physical paper edge; drawing border is the editable inner outline. | W18; Sheets | Integrated geometry in #5108/#5554; editor/save integration is pending. | Keep both identities distinct through border edits, size/orientation changes and reopen. |
| D16: title block is a separate object from drawing border. | W18; Sheets | Open code: S2 defines a separate block, not its persisted instance. | Save/edit/replace a block independently. [Later lock agreement][sheet-lock] locks block and border by default while field values keep updating. |
### Revisions and explicit field sources
| Source / current requirement | Existing scope | Evidence/state | Missing acceptance or dependency |
| --- | --- | --- | --- |
| D17: revision rows contain Revision, Description, Designer, Checker and Date. | W31; BOM | Plan in [BOM agreement][bom-plan]; no revision-table records in K2/S2. | Create/edit rows and render these columns; preserve author-entered data without implying approval. |
| D17: revisions cover the Drawing by default, with per-sheet scope available. | W31/W39; BOM | Gap. | E7: three sheets share revision 3 after adding it; a deliberately sheet-scoped revision affects only that sheet. |
| D17: numeric revisions are the default. | W31; BOM | Plan; [later agreement][bom-plan] allows start 1 or 0. | Check initial sequence and saved numbering choice. |
| D17: letter mode skips I, O, Q, S, X and Z by default. | W31; BOM | Gap. | E7: H advances to J; exercise every skipped letter and persistence of the chosen scheme. |
| D17: manually entered excluded letters remain possible with a warning. | W31; BOM | Gap. | E7: enter I, retain it with an explanatory warning rather than silently changing it. |
| D18: persist the explicit component documented by each sheet. | W12/W18; Storage, Sheets | Open code: K2's `SheetComponent` records source/component IDs and validates the source reference. | Full source-resolution, persistence and missing-component acceptance; a schema field alone is not a functioning binding. |
| D18: seed the source from the first placed view, then allow explicit sheet-property edits. | W17/W18/W15; Sheets, Storage | Gap: K2 has no view-placement command. | Place first and later views; only the first initialises an unset source; explicit edits update its consumers in the proper undo scope. |
| D18: deleting that view never retargets the sheet component. | W17/W18; Sheets, Storage | Gap in lifecycle acceptance. | E13: delete the first view and retain component fields; missing source must be reported rather than replaced by another view. |
| D18: title block and template parts list use that same explicit sheet component. | W18/W25; Sheets, BOM | K2/S2 supply source records/tags separately; no joined resolver/table path evidenced. | E12/E13: two sheets for gearbox/shaft retain their own title/list values through view deletion and source edits. |
| D19: each title-block field has a configurable source. | W18/W40; Sheets | S2 has component/drawing/sheet/revision/prompt source tags and field keys. [Resolver sequence][sheet-s2-plan] waits for #4786. | Resolve actual values with checked keys; edit/reopen a source without flattening the field to literal text. |
| D19: an absent property or stale source shows "-" for the live field, never a previous value as current. | W18; Sheets | [Source-state agreement][sheet-plan] and [missing-value rule][sheet-update]; S2 represents missing/override states, but the joined resolver and persistence acceptance are pending. | Remove a property and separately make its source stale, then recover and reopen in each state: retain the binding, show "-" while unavailable, and resolve the current value on recovery. Repeat with a marked manual override; keep it distinct from live/source status without detaching the field or deleting the override. |
| D19: component name, description, material and mass read the sheet component by default. | W18; Sheets | Source tags exist; default definitions/resolution remain pending. | E13: gearbox and shaft show their own data; use current material/mass rules, not whichever view was last inspected. |
| D19: Drawing number defaults to the first sheet component's name. | W18/W39; Sheets | Plan; K2's generic Drawing name is not evidence of this default/binding. | E13: Drawing number remains Gearbox while the shaft sheet retains its own component fields; specify/review first-sheet reorder/delete handling before implementation. |
| D19: sheet number/count, projection and paper size come from Drawing/sheet context. | W18/W39; Sheets | K2 has ordered sheets/paper and count exclusion; S2 has a projection-symbol kind, without resolved rendering. | Test n/m with excluded sheets, correct paper size and first/third-angle symbols shared with views. |
| D19: scale is configurable, but absent from the default title block. | W18; Sheets | **Default changed** by [later layout agreement][sheet-defaults]; individual views state their scales. | Mixed-scale Drawing has no misleading single default-block scale; preserve explicit custom-field capability only with a defined source. |
| D19: revision/person/date fields can read the newest revision row. | W18/W31; Sheets, BOM | S2 has a revision source tag. [Later agreement][sheet-revision-fields] maps Created by / Approved by / Date of issue to that row, showing “-” before it exists. | E7: adding revision 3 updates all linked fields from row 3; no stale prior values when the source is missing. |
| D19: the default block omits Rev, Document Type and Document Status. | W18/W31; Sheets, BOM | **Default changed** by [later layout agreement][sheet-defaults]; revision/status live in the revision table. | Review the original default definition; retain configurable revision fields without reintroducing the superseded default. |
| D19: the original default block follows the accepted 180 × 54 mm layout and field set. | W18; Sheets | [Accepted prototype][sheet-prototype] plus [default-field agreement][sheet-defaults]; S2 defines generic block geometry, not the built-in template. | Verify Title, Identification, Created by, Approved by, Date of issue, Sheet, Legal owner, Material, Finish, Mass, General tolerance and Projection; part/assembly templates provide their different tolerance notes. |
| D19: manual field overrides are marked and keep their source. | W18; Sheets | S2 represents resolved/overridden/missing values; no persistent editing/resolution path evidenced. | Override, update source, reopen and remove override: recover the current linked value and retain clear override status. |
| D19: field presentation uses shared units, date settings and measured text. | W18/W10; Sheets | [Later agreements][sheet-update] set mass to user units (default kg, two decimals); [date agreement][sheet-revision-fields] separates UI display format from ISO script dates. S2 layout does not measure text. | Integrate #4890 paper text after shared metrics; verify actual wrapping/width warnings, 1 mm padding, fixed cell bounds and complete values without truncation. |
| D19: dates are stored as ISO 8601; each Drawing captures and persists its display format. | W18/W12; Sheets, Storage | [Date-storage agreement][sheet-update] captures the device format for a new Drawing; [platform/CLI agreement][sheet-revision-fields] uses platform settings (locale-derived order where needed) and ISO 8601 for scripts/CLI. Persistence and presentation acceptance remain pending. | Save and reopen under another device/browser locale: retain the Drawing's display format and ISO date value. An explicit display-format change updates presentation without changing that value; CLI/script output stays ISO 8601 across locales and format changes. |
Acceptance coverage is explicit: **E1** views; **E2** purchased/inseparable;
**E3** phantom multiplicity; **E4** reference count/warning; **E5** deletion,
renumbering and locks; **E6** local versus saved numbers; **E7** revision scope,
fields and lettering; **E8** stacks; **E9** anchors on resize; **E10** virtual
quantities; **E11** derived roles; **E12** templates and one-row lists;
**E13** sheet sources; **E14** name versus part number. None is marked passed by
this document. E2, E10 and the default title-block part of E7/E13 must be read
with the later agreements above, not the superseded study defaults.
Remaining traceability gaps are concrete: child PRs for saved numbering,
all-levels views, Drawing tables/balloons/revisions, virtual typed quantities,
template defaults and field-resolution/editor integration have not been
identified as completed slices in this check. The existing owners should bind
those acceptances to their eventual revisions. The complete glossary audit and
the Drawing-number default's first-sheet reorder/delete policy also need evidence
or a decision. No new owner, issue, API or successful gate is implied.
## Package inventory
Dependencies below preserve the [plan][plan], with the linked later splits above.
They are delivery gates, not a requirement to finish an entire large package before
any independent child. `Plan` rows retain scope without reserving it. CamWight's
parent claims remain relevant even when a particular child is integrated.
### M0/M1: contracts, sections and transient tools
| Package | State / owner and discussion | Dependencies | Next handoff / acceptance |
| --- | --- | --- | --- |
| W00 ledger | W01a bootstrap integrated; W00b documentation claim [#6163][w00b-pr], `0xlillith` | RFC, current rules | Current package/decision map; atomic study requirement traceability still open. |
| W01 neutral contracts | W01a integrated [#2085][w01-pr] | W00 | Finish source/provenance/outcome contracts; structural success does not certify a section. |
| W02 live extraction | Backend coordination; no standalone W02 delivery verified | Reviewed W01, kernel agreement | Guarded, fallible neutral adapter; capability/provenance matrix, no cache-byte authority. |
| W03 shared sections | Open shared-crossing code [#6272][section-crossings], then [#2068][section-pr], NOWN-DEV, base `main` | W02 | Shared crossing provider first; classified cut outcomes, winding/nesting and analytic accuracy evidence remain acceptance requirements. |
| W04 section command/output | Open JSON-only [#2068][section-pr] at `d2a9e485` (2026-10-25) | W03; #6272 first; W10 geometry subset for later output | Explicit plane/occurrence input; read-only JSON and complete-or-error output; Cam's neutral adapter follows both merges. Physical SVG/DXF is a separate later export requirement. |
| W05 HLR investigation | Open draft code [#2106][investigation-pr], SychPL; [findings][hlr-findings] | W01, W02 as needed | Review supported surfaces, analytic intervals, uncertainty and native/web cost before promotion. |
| W06 HLR subset | [#2457][hlr-pr] merged into drawings on 27 Oct as ebafe074; [#3013][hlr-parallel] open — SychPL | Reviewed W05; W02 integration remains a separate provider | Analytic HLR core is integrated. Parallel work/aggregate budgets and full scene-to-paper acceptance remain subsequent pieces. |
| W07 snapshot command | Parent claim [#5867][snapshot-pr], CamWight; V1 claim [#6482][mesh-lines-pr] into `main` (2026-10-25); [current plan][snapshot] | W17a; preview adapter or W06; W09b; W10 | V1 shared projection first; V3 on `drawings` adds source/owner identity under the [revised split][mesh-lines-split]. Frame/scale, occurrences, references, labels, scene, exact lines and shading remain later slices. |
| W08 snapshot preview/output | Plan; coordinate W07, W10 and W16 owners | W07, W10 text/dimension subset; W04 reuse | SVG/DXF/PNG and preview with snapshot/accuracy status, physical size and command parity. |
Standalone section work does not wait for persistent Drawing files, fonts or PDF.
The existing viewport section and approximate view-DXF tools do not by themselves
complete W03–W08. W01a radians, accuracy and result-local IDs require explicit
adaptation to an existing producer's schema.
### Shared foundations and M2: persistent single-part checkpoint
| Package | State / owner and discussion | Dependencies | Next handoff / acceptance |
| --- | --- | --- | --- |
| W09 formats | W09a integrated; W09b claim [#5315][number-pr], armin0411; broader claim [#4897][dimensions-pr], CamWight | W00 | Shared numbers below UI, then typed tolerances/profiles; retain raw values and locale-independent meaning. |
| W10 paper/layout | A1 integrated; parent claim [#4891][export-pr], CamWight | W01; W09/font contract for text | SVG/shared prepared traversal and earlier clipping, then text, dimensions/tables and pictures. |
| W11 storage decision | Embedded-in-design G3 [#3890][storage]; K3 [#7098](https://github.com/tangency-labs/solidcraft/pull/7098) merged, K3b [#7490](https://github.com/tangency-labs/solidcraft/pull/7490) draft | W00 | Per-drawing undo/revisions within one design, no independent .scdwg file. Old linked-source acceptance rows are historical and require new in-file fixtures. |
| W12 saved records | CamWight [#3891][storage-pr] at 36cf48a3; [K3b](https://github.com/tangency-labs/solidcraft/pull/7490) remains draft | W11, W01, W09 | Bounded in-design records, versioned drawing entries and old-reader refusal. Complete saved/reopen acceptance is pending. |
| W13 references | Claim [#4893][refs-pr], CamWight | W01; first read-only slices can precede W12 | Full placement/name/intent; explicit ambiguous/lost/unverified states; bounded repair candidates. |
| W14 invalidation/update | Same claim [#4893][refs-pr] | W12/W13; W06 for projected results | Source/layout/display keys; coherent publication and late-result rejection across edit/undo/close/cancel. |
| W15 commands/undo | CamWight [#3891][storage-pr] (K4-prime in-file sessions) | W12–W14; [revised storage plan][storage-plan] | One drawing's edits and undo are isolated from its siblings and model; unchanged drawing data cannot trigger source evaluation. |
| W16 workspace | Parent claim [#4895][workspace-pr], CamWight; open scaffold [#5349][ui-pr], Nathan-Dane | W15, W10 basic scene | Connect real providers to the shared UI kit/navigation; empty preview canvas is not an editor. |
| W17 views | Parent package, split below | W06, W12–W16 for persistent views | Multiple aligned/derived views with independent style and visibility; reject graph cycles. |
| W17a view data | Integrated [#4817][view-pr] on 27 Oct in drawings at de7bf4fc | Independent L0 data slice using W01a | Frame/scale definitions delivered; persisting, displaying and editing views are later work. |
| W17b shaded views | Claim [#5626][shade-pr], CamWight | W17a, W10 picture; W06 for vector overlays | Tile CPU renderer → picture/grid → snapshot → outputs/canvas → cache; sheet DPI and honest pending/failure state. |
| W17c view editing | [Plan inside existing claims][view-edit], CamWight | W12/W17a; W16-6 for the sheet side | Inspect/edit, inheritance, model-derived extents and boundary/content picking; source, undo and printed bytes remain coherent. |
| W18 sheets/resources | Sheet children integrated; parent [#3877][sheets-parent], CamWight, now zero-file diff | W09/W10/W12/W15 for full objects | Typed title fields and anchored furniture; integrated border geometry is only the first slice. |
| W19 dimensions/notes | Claim [#4897][dimensions-pr], CamWight | W09/W10/W13/W15/W17 | Intent/value/text/placement separated; manual placement and lost/override status preserved. |
| W20 round/chain/centres | Same claim [#4897][dimensions-pr] | W19 | Analytic diameter/radius, picked directions, idempotent centre marks and paper-sized gaps. |
| W21 SVG/DXF/PNG | Claim [#4891][export-pr], CamWight | W10/W17–W20; geometry-only children earlier | SVG first; physical size, view scale, lineweights, grouping and declared conversions across writers. |
| W22 PDF | Same claim [#4891][export-pr]; library choice recorded | W10/W18–W20, fonts | Vector physical pages, searchable embedded fonts, reproducible metadata and layers before industrial gate. |
| W23 M2 integration | Parent claim [#4895][workspace-pr]; W23a integrated [#4962][fixture-pr] | W14–W22 | Native/browser/headless save, edit, update, repair, undo and output. Model fixtures alone do not pass M2. |
### M3: first industrial part-and-assembly release
| Package | State / owner and discussion | Dependencies | Next handoff / acceptance |
| --- | --- | --- | --- |
| W24 BOM service | Open draft code [#3876][bom-pr], CamWight; first child [#5107][bom-types-pr] open | W00/G7, W12 source contract | Fallible nested typed quantities, stable product/row identity and authoritative BOM item numbers under [the later agreements][bom-update]; types and properties independent of UI. |
| W25 sheet tables | Same parent [#3876][bom-pr]; complete Drawing table delivery not verified | W24/W10/W12/W15 | Consume BOM item numbers; typed linked/literal/computed cells, marked local overrides and explicit save to BOM; no implicit row merging. |
| W26 balloons | Same parent [#3876][bom-pr]; complete balloon delivery not verified | W13/W19/W25 | Every leader resolves to its row/occurrence; view hiding never changes product quantity. |
| W27 tolerances/fits | Plan; coordinate W09/W19 owners | W09/W13/W19–W20, reviewed profile evidence | Supported tolerance/fit semantics, nominal precision and truthful unavailable metadata. |
| W28 datums/GD&T | Plan | W10/W13/W19/W27, drafting review | Bounded semantic grammar and original symbols with edition-specific evidence. |
| W29 feature callouts | Plan | W19/W27, feature metadata audit | Hole/thread/chamfer identity and occurrence scope; never infer process data from appearance. |
| W30 manufacturing notes | Plan | W10/W19/W27, per-family evidence | Shared leaders/text; reviewed original weld/surface/edge symbol families in small children. |
| W31 revisions | Revision-table scope in [#3876][bom-pr], CamWight; full package not delivered | W10/W12/W15/W18, release-status decisions | Separate human-entered issue/check data, row/tag IDs, preserved history and explicit override/export policy. |
| W32 industrial acceptance | Plan | W23–W31, agreed assembly subset of W33 | Real part and assembly jobs, integrity check, coherent exports and package publication; explicitly resolve any first-ship gap. |
**M2 is not M3.** The single-part checkpoint cannot replace assembly quantities,
balloons, supported tolerances/GD&T/callouts, live fields/revisions, reliable updates
and production outputs. The later shaded-view decision also belongs to first ship.
Neither this map nor a UI scaffold declares either milestone complete.
### M4/M5 and separately reviewed M6 extensions
| Package | State / owner and discussion | Dependencies | Next handoff / acceptance |
| --- | --- | --- | --- |
| W33 assembly/state views | Plan; M3 needs its agreed minimum | W13–W17/W24 | Explicit configuration and full occurrence identity; separate placement, hiding, appearance and section participation. |
| W34 sections | Plan reusing NOWN-DEV Stage A | W03/W06/W13–W17/W10 hatch | Full, half and aligned cuts, depth/participation and hatching; persistent references survive edits. |
| W35 details/crops | Plan | W06/W13–W17, clipping | Defined boundary/scale/label; references leaving a crop are reported, not silently deleted. |
| W36 broken views | Plan | W13–W20; W35 clipping where reused | Nonuniform paper mapping preserves true engineering lengths through edits and removal. |
| W37 fabrication | Plan; independent flat/cut utilities may advance earlier | W03/W04 for cuts; W13–W18 for sheets | Authoritative perforations, bend identity, A-side/handedness and true-size output; not mixed-scale sheet cutting. |
| W38 sheet/view sketches | Parent claim [#5426][draft-pr], CamWight; W38.0 is open code [#6336][sketch-store-pr], targeting `main` | W10/W12/W15–W18; W34 for section hatch | W38.0 centralizes sketch-command access through `cmd::sketch_store` before W38a/b; it does not deliver saved Drawing sketches. Sheet/view-owned geometry, projected links and fills/images remain later slices, with no implicit model-sketch mutation. |
| W39 multiple sheets | Plan | W12–W18; W25–W26/W31 for linked objects | Stable IDs/numbering through reorder/copy/delete, navigation and sheet sets. |
| W40 reusable resources | Plan; coordinate W18 title/template owner | W18/current libraries; W31/W39 later | Original editors/templates, immutable embedded resource snapshots and explicit updates. |
| W41 annotation productivity | Plan | W19–W20/W26–W30 | Placement-preserving arrange, sets, leaders and text in separately tested increments. |
| W42 copy/derive/replace | Plan | W13–W15/W39–W40, clipboard contract | Bounded ID/reference/resource remapping; retain lost objects; mirrored drawings recompute semantics. |
| W43 richer tables | Plan; coordinate W24–W26/W31 owner | W25/W29/W31/W37 | Hole/bend/general tables, explicit quantity scopes and safe spreadsheet exports. |
| W44 richer DXF | Plan; shared writer refactors are prerequisites | W21/W25–W31; W34/W38 as supported | Native dimensions/hatches/text/blocks/layouts in declared subsets; exploded output remains selectable. |
| W45 PDF/print production | Plan; basic layers required before M3 or explicitly renegotiated | W22; W39 multi-sheet; W32 release batch | Shared vectors, selected scopes, physical print/batch; archival/accessibility claims separately qualified. |
| W46 specialist domains | Plan / separately reviewed extensions | Relevant views/annotations and model-side owners | Shaft, weld, stock, routing, harness and staged manufacture each need trustworthy metadata and a domain reviewer. |
| W47 checking/automation | Plan / separately reviewed extensions | Stable contracts, G24 and format gates | Scoped coverage/unknown states, comparisons and ownership-aware generators; no implied manufacturing-completeness proof. |
## Immediate dependency handoffs
| Boundary | Current owner / prerequisite | Small next delivery and evidence |
| --- | --- | --- |
| Shared paths → Drawing sources | CarelT [#5420][paths-pr] (open code targeting `main`) → [#5449][link-state-pr] (open draft); [Cam's agreement][path-handoff] | Reuse the path move and common loader/status contract. Do not duplicate K5 helpers or confuse file availability with Drawing result freshness. |
| Versioning → storage → commands | CamWight [#3890][storage] and [#3891][storage-pr]; embedded plan [G3][g3] | K3 #7098 merged; K3b #7490 draft; K4-prime in-file sessions and subsequent view/source/cache integration follow. Separate file linking/packaging slices described in earlier comments are superseded; keep the new in-file undo and versioning acceptance explicit. |
| Source versions → acceptance | amolinari-autotex [#5430][version-pr], open draft with 11 files | Bind minimal deterministic fixtures to the accepted storage API before executable integration claims. |
| Shared numbers/text → labels | armin0411 [#5315][number-pr] (claim); CamWight [#4815][text-pr] merged as `d557c09c`, main only under the 2026-10-25 ancestry check above | Number formatting and measured text are separate dependencies; Drawing consumers still need integration. A metric API alone does not deliver shaping, fonts or Drawing layout. |
| Paper A1 → canvas/SVG | CamWight [#4890][export], Nathan-Dane [#5349][ui-pr] | Shared prepared traversal/clipping and content token before picking/output. Distinguish valid empty results from unavailable, pending, failed and stale results; source freshness remains separate. |
| Main DXF → Drawing adapter | [#5318][dxf-paths] / draft [#6184][dxf-claim] → [#5319][dxf-file] → [#5320][dxf-read] → [#5321][dxf-one] | Original 53 byte pins remain unchanged. X1 head 3fb2eef9 has 6/7 sheet failures fixed, one source-provenance blocker, and is not merge-ready; X2–X4 and paper A2 follow only after its qualified integration. [Accepted main-base agreement][dxf-decision]. |
| HLR → frame → snapshot | SychPL [#2457][hlr-pr]/[#3013][hlr-parallel], CamWight [#4816][view-data]/[#5866][snapshot-issue] | [Accepted scale × accuracy × occurrence matrix][accuracy-contract] beside W07 snapshot tests, using neutral HLR fixtures. Bind to the actual `max_error_mm` check and separate `chord_mm` sampling API; no silent clamping or Sampled certification. |
| Shading → paper picture | CamWight [#5624][shade-issue], [picture plan][writers-plan] | [Registration/colour/alpha conventions and acceptance child agreed][shade-acceptance]. Bind runnable checks to S1 and S3 when their PRs exist; no second renderer or claim of a shipped provider. |
| View editing → canvas | CamWight [W17c][view-edit], Nathan-Dane [#5349][ui-pr] | [Behaviour/undo matrix accepted][ux-acceptance]; bind to the actual inspect/edit commands and window host. Real providers precede enabled editing; scaffold validation is not persisted-editor acceptance. |
## Milestone acceptance and maintenance
The Drawing M0–M6 names below come from [the RFC plan][plan]; they are distinct
from the repository roadmap's M0–M14 (Drawings is roadmap M11).
| Milestone | Evidence needed before declaring completion |
| --- | --- |
| M0 contracts/decisions | Reviewed small interfaces, fixtures, provenance/accuracy vocabulary and ownership; unresolved gates remain linked. |
| M1 standalone tools | Body-derived sections/visibility and labelled transient measurements with explicit unsupported cases, native/browser costs and command parity. |
| M2 single-part integration | W23a source edits, lost-reference repair, deferred/automatic update, separate undo, save/reopen and physically matching native/browser/headless exports. |
| M3 industrial part/assembly | W32's part and assembly jobs, BOM/balloons, supported standards subset, callouts/revisions and first-ship shading/output; no silent stale values, substitutions or omissions. |
| M4 derived/fabrication | Sections/details/breaks and true-size cut/bend output preserve model meaning and declared accuracy. |
| M5 production/productivity | Multi-sheet identity, reusable resources, copying and packages remain coherent through edits. |
| M6 extensions | Separate owners, lawful sources, bounded scope and specific acceptance for each domain, automation or interchange proposal. |
Use independent analytic expectations alongside scene/entity/text assertions;
self-round-trips and pictures alone cannot establish geometry or physical scale.
Fixtures should cover nonfinite/cyclic/oversized input, missing/replaced/suppressed
sources, repeated placements, stale completions, cancel boundaries and unchanged
model evaluation for paper-only edits. Benchmarks report cold/warm work, memory
and cancellation separately; a source review is not a runtime measurement.
For each child handoff, record its public issue/claim, exact base/head, owned paths,
contract revision, dependency PRs, supported cases, fixtures, actual checks and
remaining limits. Current repository gates govern validation and publishing;
draft or skipped CI is not a passing run. Recheck branch membership after a merge
and update only the delivered slice. Do not close the umbrella RFC or claim full
study traceability from completion of this documentation child.
[base]: https://github.com/tangency-labs/solidcraft/tree/4c63a88fc3035ac91b4b9dc847fffa0d2d5ec155
[main-oct25]: https://github.com/tangency-labs/solidcraft/tree/2741cd9a8b0b84b7d33001d8f9473198b5d53f73
[drawings-oct25]: https://github.com/tangency-labs/solidcraft/tree/672ef13eb8b8e2cb0ddf9c795e337b73d043eb07
[sheet-dxf-options]: https://github.com/tangency-labs/solidcraft/pull/6130
[dxf-text-refusal]: https://github.com/tangency-labs/solidcraft/pull/5709
[sketch-store-fix]: https://github.com/tangency-labs/solidcraft/pull/6336#issuecomment-6390580099
[mesh-lines-pr]: https://github.com/tangency-labs/solidcraft/pull/6482
[mesh-lines-split]: https://github.com/tangency-labs/solidcraft/issues/5866#issuecomment-6393854680
[dxf-composition]: https://github.com/tangency-labs/solidcraft/pull/5709#issuecomment-6394720621
[sketch-store-selection]: https://github.com/tangency-labs/solidcraft/pull/6336#issuecomment-6394700363
[shade-s1-pr]: https://github.com/tangency-labs/solidcraft/pull/6483
[plan]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815
[rfc]: https://github.com/tangency-labs/solidcraft/issues/860
[study]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6216908058
[study-current]: https://github.com/user-attachments/files/33742361/inventor-drawings-research.md
[bom-plan]: https://github.com/tangency-labs/solidcraft/issues/3874#issuecomment-6303870854
[bom-update]: https://github.com/tangency-labs/solidcraft/issues/3874#issuecomment-6338062010
[sheet-plan]: https://github.com/tangency-labs/solidcraft/issues/3875#issuecomment-6303871228
[sheet-update]: https://github.com/tangency-labs/solidcraft/issues/3875#issuecomment-6338062425
[sheet-revision-fields]: https://github.com/tangency-labs/solidcraft/issues/3875#issuecomment-6339854611
[sheet-lock]: https://github.com/tangency-labs/solidcraft/issues/3875#issuecomment-6359337075
[sheet-defaults]: https://github.com/tangency-labs/solidcraft/issues/3875#issuecomment-6363027096
[sheet-prototype]: https://github.com/tangency-labs/solidcraft/issues/3875#issuecomment-6364627034
[sheet-s2-plan]: https://github.com/tangency-labs/solidcraft/issues/3875#issuecomment-6388563507
[sheet-s2-report]: https://github.com/tangency-labs/solidcraft/issues/3875#issuecomment-6389693817
[bom-d-report]: https://github.com/tangency-labs/solidcraft/issues/3874#issuecomment-6388108030
[occurrence-type-report]: https://github.com/tangency-labs/solidcraft/issues/3874#issuecomment-6324471095
[table-layout]: https://github.com/tangency-labs/solidcraft/issues/3874#issuecomment-6358344907
[table-wrap]: https://github.com/tangency-labs/solidcraft/issues/3874#issuecomment-6359336735
[field-keys-pr]: https://github.com/tangency-labs/solidcraft/pull/4786
[quantity-pr]: https://github.com/tangency-labs/solidcraft/pull/4812
[section-crossings]: https://github.com/tangency-labs/solidcraft/pull/6272
[section-json]: https://github.com/tangency-labs/solidcraft/pull/2068#issuecomment-6387996533
[section-adapter]: https://github.com/tangency-labs/solidcraft/pull/2068#issuecomment-6388998429
[text-main-check]: https://github.com/tangency-labs/solidcraft/compare/d557c09c3b5a059e553a5d9f1fb2682e52ecbaae...ac53fc1e6fab85a50bdc410178cc75133183393f
[text-drawings-check]: https://github.com/tangency-labs/solidcraft/compare/d557c09c3b5a059e553a5d9f1fb2682e52ecbaae...672ef13eb8b8e2cb0ddf9c795e337b73d043eb07
[w00b]: https://github.com/tangency-labs/solidcraft/issues/6157
[w00b-pr]: https://github.com/tangency-labs/solidcraft/pull/6163
[w01-pr]: https://github.com/tangency-labs/solidcraft/pull/2085
[w09-pr]: https://github.com/tangency-labs/solidcraft/pull/2086
[sheets-pr]: https://github.com/tangency-labs/solidcraft/pull/5108
[marks-pr]: https://github.com/tangency-labs/solidcraft/pull/5554
[paper-pr]: https://github.com/tangency-labs/solidcraft/pull/5513
[fixture-pr]: https://github.com/tangency-labs/solidcraft/pull/4962
[degree-pr]: https://github.com/tangency-labs/solidcraft/pull/5281
[branch]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6351210283
[shepherd]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6352192270
[kernel-pause]: https://github.com/tangency-labs/solidcraft/issues/3992
[kernel-tracking]: https://github.com/tangency-labs/solidcraft/pull/4012
[g3]: https://github.com/tangency-labs/solidcraft/issues/3890#issuecomment-6427665746
[g3-json]: https://github.com/tangency-labs/solidcraft/issues/3890#issuecomment-6352154058
[storage]: https://github.com/tangency-labs/solidcraft/issues/3890
[storage-plan]: https://github.com/tangency-labs/solidcraft/issues/3890#issuecomment-6427665746
[ids]: https://github.com/tangency-labs/solidcraft/issues/3890#issuecomment-6375022562
[refs-plan]: https://github.com/tangency-labs/solidcraft/issues/4892#issuecomment-6344611677
[svg-first]: https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6360998195
[scene-boundary]: https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6369541766
[writers-plan]: https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6380548192
[pdf-choice]: https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6349414755
[snapshot]: https://github.com/tangency-labs/solidcraft/issues/5866#issuecomment-6384152867
[view-map]: https://github.com/tangency-labs/solidcraft/issues/4816#issuecomment-6342112690
[view-bases]: https://github.com/tangency-labs/solidcraft/issues/4816#issuecomment-6384153494
[accuracy-contract]: https://github.com/tangency-labs/solidcraft/issues/5866#issuecomment-6387949895
[shading]: https://github.com/tangency-labs/solidcraft/issues/5624#issuecomment-6380783992
[sheet-dpi]: https://github.com/tangency-labs/solidcraft/issues/5624#issuecomment-6384153289
[style-proposal]: https://github.com/tangency-labs/solidcraft/issues/5307#issuecomment-6384774858
[viewport-style]: https://github.com/tangency-labs/solidcraft/pull/5219
[display-states]: https://github.com/tangency-labs/solidcraft/pull/5310
[view-edit]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6384152604
[inheritance]: https://github.com/tangency-labs/solidcraft/issues/3890#issuecomment-6382967724
[pick-data]: https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6382967982
[fixture-contract]: ../tests/oracle/W23a-workshop-acceptance.md
[version-pr]: https://github.com/tangency-labs/solidcraft/pull/5430
[section-pr]: https://github.com/tangency-labs/solidcraft/pull/2068
[investigation-pr]: https://github.com/tangency-labs/solidcraft/pull/2106
[hlr-findings]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6243691483
[hlr-pr]: https://github.com/tangency-labs/solidcraft/pull/2457
[hlr-parallel]: https://github.com/tangency-labs/solidcraft/pull/3013
[snapshot-pr]: https://github.com/tangency-labs/solidcraft/pull/5867
[number-pr]: https://github.com/tangency-labs/solidcraft/pull/5315
[dimensions-pr]: https://github.com/tangency-labs/solidcraft/pull/4897
[export-pr]: https://github.com/tangency-labs/solidcraft/pull/4891
[storage-pr]: https://github.com/tangency-labs/solidcraft/pull/3891
[k1-pr]: https://github.com/tangency-labs/solidcraft/pull/6196
[k1-published]: https://github.com/tangency-labs/solidcraft/issues/3890#issuecomment-6386431674
[refs-pr]: https://github.com/tangency-labs/solidcraft/pull/4893
[workspace-pr]: https://github.com/tangency-labs/solidcraft/pull/4895
[ui-pr]: https://github.com/tangency-labs/solidcraft/pull/5349
[view-pr]: https://github.com/tangency-labs/solidcraft/pull/4817
[shade-pr]: https://github.com/tangency-labs/solidcraft/pull/5626
[sheets-parent]: https://github.com/tangency-labs/solidcraft/pull/3877
[bom-pr]: https://github.com/tangency-labs/solidcraft/pull/3876
[bom-types-pr]: https://github.com/tangency-labs/solidcraft/pull/5107
[draft-pr]: https://github.com/tangency-labs/solidcraft/pull/5426
[sketch-store-pr]: https://github.com/tangency-labs/solidcraft/pull/6336
[paths-pr]: https://github.com/tangency-labs/solidcraft/pull/5420
[link-state-pr]: https://github.com/tangency-labs/solidcraft/pull/5449
[path-handoff]: https://github.com/tangency-labs/solidcraft/pull/3891#issuecomment-6363719119
[text-pr]: https://github.com/tangency-labs/solidcraft/pull/4815
[export]: https://github.com/tangency-labs/solidcraft/issues/4890
[dxf-paths]: https://github.com/tangency-labs/solidcraft/issues/5318
[dxf-file]: https://github.com/tangency-labs/solidcraft/issues/5319
[dxf-read]: https://github.com/tangency-labs/solidcraft/issues/5320
[dxf-one]: https://github.com/tangency-labs/solidcraft/issues/5321
[dxf-decision]: https://github.com/tangency-labs/solidcraft/issues/5318#issuecomment-6385918078
[dxf-claim]: https://github.com/tangency-labs/solidcraft/pull/6184
[view-data]: https://github.com/tangency-labs/solidcraft/issues/4816
[snapshot-issue]: https://github.com/tangency-labs/solidcraft/issues/5866
[shade-issue]: https://github.com/tangency-labs/solidcraft/issues/5624
[shade-acceptance]: https://github.com/tangency-labs/solidcraft/issues/5624#issuecomment-6388998143
[ux-acceptance]: https://github.com/tangency-labs/solidcraft/issues/4894#issuecomment-6388998672
+14
View File
@@ -1,5 +1,12 @@
# Drawing foundations: first geometry slice
For live progress, use the checklist in the body of [RFC #860][rfc]; the
[final W00–W47 plan][plan] remains the delivery plan.
For the dated package map, claims, integrated children and later
decisions, see [Drawing delivery and handoffs](drawing-delivery.md). The ledger
below preserves the original W01a scope and its historical coordination snapshot;
its open storage and ownership questions are not the current delivery status.
This is the W00 bootstrap ledger for the [published Drawing plan][plan] in
[#860][rfc]. The first implementation is W01a, [#2053][w01-issue] /
[PR #2085][w01-pr], owned by `0xlillith`: neutral section-geometry records,
@@ -28,6 +35,12 @@ requirements evidence; its descriptions and proposed standards rules are not
implementation code, independently verified conformance or permission to copy
protected content.
Later source note (2026-10-25): the [updated study, attachment 33742361][study-current],
especially §18.10, adds BOM and explicit sheet-source decisions. The
[delivery map](drawing-delivery.md#decisions-that-supersede-the-original-plan)
records those decisions and the later owner agreements that take precedence.
The original attachment and historical W01a snapshot below are retained.
Here, **W01a** means code in this slice; **future** means a requirement still to
implement or decide. A merged representation or validator does not establish a
geometry producer's capability. Drawing milestones in the RFC are distinct from
@@ -130,6 +143,7 @@ instructions govern any later change to those areas.
[fabrication]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6231770565
[study-comment]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6216908058
[study]: https://github.com/user-attachments/files/33548772/inventor-drawings-research.md
[study-current]: https://github.com/user-attachments/files/33742361/inventor-drawings-research.md
[w01-issue]: https://github.com/tangency-labs/solidcraft/issues/2053
[w01-pr]: https://github.com/tangency-labs/solidcraft/pull/2085
[w09-issue]: https://github.com/tangency-labs/solidcraft/issues/2054
+16 -7
View File
@@ -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
@@ -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
}
+335
View File
@@ -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
}
+335
View File
@@ -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
]
}
]
}
]
}
+115
View File
@@ -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`.
+1
View File
@@ -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
+115
View File
@@ -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)