mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 514ad79e7b | |||
| 5d658c3089 | |||
| e01f2f363b | |||
| 4c63a88fc3 | |||
| 5e23f58f62 | |||
| 01a542d6e8 | |||
| f0cc73eb83 |
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
use std::f64::consts::PI;
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use super::*;
|
||||
use crate::{MAX_BEZIER_CTRL, bezier};
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"id": "84-plate-two-bores",
|
||||
"name": "Workshop plate with two independent Ø10 bores",
|
||||
"description": "Parameterized rectangular workshop plate 100 x 80 x 20 mm with 2 independent through bores. World origin at (0,0,0).",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=100, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 196858.4073464102,
|
||||
"area_mm2": 28942.477796076935,
|
||||
"center_of_mass": [
|
||||
50.15958641014815,
|
||||
50.23937961522224,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
100,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 8
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"id": "85-plate-width-100",
|
||||
"name": "Workshop plate after width change to 100 mm",
|
||||
"description": "Parameterized rectangular workshop plate 100 x 100 x 20 mm with 2 independent through bores. World origin at (0,0,0).",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
100
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
100
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
100
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
100
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
50,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
50,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø12; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 156167.25696262045,
|
||||
"area_mm2": 24199.026463841554,
|
||||
"center_of_mass": [
|
||||
50.333939643071744,
|
||||
40.189098352100864,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 8
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"id": "86-plate-first-bore-12",
|
||||
"name": "Workshop plate after Bore1 diameter changes to Ø12",
|
||||
"description": "Parameterized rectangular workshop plate 100 x 80 x 20 mm with 2 independent through bores. World origin at (0,0,0).",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "12 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 6,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=80, T=20 mm; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 158429.2036732051,
|
||||
"area_mm2": 23671.238898038468,
|
||||
"center_of_mass": [
|
||||
49.900851844838215,
|
||||
39.900851844838215,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 7
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "87-plate-bore1-deleted",
|
||||
"name": "Workshop plate retaining Bore2 after Bore1 deletion",
|
||||
"description": "Plate 100 x 80 x 20 mm after deleting Bore1, keeping the original Bore2 identity and XY (60,50), Ø10.",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=150, W=80, T=10 mm; Bore1 at (15,15) Ø8; Bore2 at (135,15) Ø8; Bore3 at (15,65) Ø8; Bore4 at (135,65) Ø8. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 117989.38070170255,
|
||||
"area_mm2": 29203.185789489246,
|
||||
"center_of_mass": [
|
||||
74.99999999999999,
|
||||
40,
|
||||
5
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
150,
|
||||
80,
|
||||
10
|
||||
]
|
||||
},
|
||||
"faces": 10
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
{
|
||||
"id": "88-plate-four-bores",
|
||||
"name": "Workshop plate with four Ø8 through bores",
|
||||
"description": "Parameterized rectangular workshop plate 150 x 80 x 10 mm with 4 independent through bores. World origin at (0,0,0).",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "150 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore3_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore4_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
150,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
150,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
150,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
150,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
15,
|
||||
15
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
135,
|
||||
15
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_3",
|
||||
"center": {
|
||||
"at": [
|
||||
15,
|
||||
65
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_4",
|
||||
"center": {
|
||||
"at": [
|
||||
135,
|
||||
65
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_3"
|
||||
],
|
||||
"value": "bore3_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_4"
|
||||
],
|
||||
"value": "bore4_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
15,
|
||||
15,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
135,
|
||||
15,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore3",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
15,
|
||||
65,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore3_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore4",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
135,
|
||||
65,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore4_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: four exterior vertical R10 corner rounds; four corner spandrels subtract volume T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); rounded perimeter P=2(L+W)-8R+2pi*R",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 155141.5926535898,
|
||||
"area_mm2": 23627.433388230813,
|
||||
"center_of_mass": [
|
||||
50.20249841450349,
|
||||
40.10124920725174,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 12
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"id": "94-plate-four-rounded-corners",
|
||||
"name": "Workshop plate with four R10 vertical corner rounds",
|
||||
"description": "100 x 80 x 20 mm workshop plate, Ø10 through-bores, four R10 vertical corner rounds.",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance",
|
||||
"chamfer"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "fillet",
|
||||
"name": "FourVerticalCornerRounds",
|
||||
"type": "constant_radius",
|
||||
"radius": 10,
|
||||
"tangent_chain": false,
|
||||
"edge_selection": "All four vertical exterior corner edges of the plate; far from both through bores",
|
||||
"edges": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
80,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
80,
|
||||
10
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: four exterior vertical R10 corner rounds; full upper tangent loop 2 mm chamfer; four corner spandrels subtract volume T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); rounded perimeter P=2(L+W)-8R+2pi*R; upper tangent chamfer subtracts volume Vc=P*c²/2-pi*c³/3 at (L/2,W/2,T-(P*c³/6-pi*c⁴/12)/Vc); area from upper inset face, side strips and sqrt(2)*c*(P-pi*c) bevel surface",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 154464.30652785578,
|
||||
"area_mm2": 23220.575727260624,
|
||||
"center_of_mass": [
|
||||
50.20338631779784,
|
||||
40.10169315889891,
|
||||
9.959084781124005
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 20
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
{
|
||||
"id": "95-plate-rounded-top-chamfer",
|
||||
"name": "Workshop plate with four R10 corner rounds and full upper 2 mm chamfer",
|
||||
"description": "100 x 80 x 20 mm workshop plate, Ø10 through-bores, four R10 vertical corner rounds and full upper perimeter 2 mm chamfer.",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance",
|
||||
"chamfer",
|
||||
"fillet"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "length",
|
||||
"expression": "100 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "20 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "StockSketch",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"normal": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
100,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
100,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l3.end",
|
||||
"at": [
|
||||
0,
|
||||
80
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "l1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l4"
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_1",
|
||||
"center": {
|
||||
"at": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
60,
|
||||
50
|
||||
]
|
||||
},
|
||||
"radius": 5,
|
||||
"construction": true
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"type": "coincident",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "horizontal",
|
||||
"refs": [
|
||||
"l3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical",
|
||||
"refs": [
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": [
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l1.start",
|
||||
"l1.end"
|
||||
],
|
||||
"value": "length",
|
||||
"unit": "mm",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"type": "distance",
|
||||
"refs": [
|
||||
"l2.start",
|
||||
"l2.end"
|
||||
],
|
||||
"value": "width",
|
||||
"unit": "mm",
|
||||
"orientation": "vertical"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_1"
|
||||
],
|
||||
"value": "bore1_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_2"
|
||||
],
|
||||
"value": "bore2_d",
|
||||
"unit": "mm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "extrude",
|
||||
"name": "Stock",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"sketch": "StockSketch",
|
||||
"loops": [
|
||||
{
|
||||
"outer": true,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extent": {
|
||||
"type": "distance",
|
||||
"distance": "thickness",
|
||||
"direction": "positive"
|
||||
},
|
||||
"result_bodies": [
|
||||
"Plate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore1",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore1_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore2",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
40,
|
||||
20
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
60,
|
||||
50,
|
||||
20
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "fillet",
|
||||
"name": "FourVerticalCornerRounds",
|
||||
"type": "constant_radius",
|
||||
"radius": 10,
|
||||
"tangent_chain": false,
|
||||
"edge_selection": "All four vertical exterior corner edges of the plate; far from both through bores",
|
||||
"edges": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
80,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
80,
|
||||
10
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "chamfer",
|
||||
"name": "FullTopPerimeterChamfer",
|
||||
"type": "equal_distance",
|
||||
"distance": 2,
|
||||
"tangent_chain": true,
|
||||
"edge_selection": "Front top straight edge after all vertical corners are rounded; tangent follow the complete top loop",
|
||||
"edges": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
10,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"end": [
|
||||
90,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
0,
|
||||
20
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# W23a — workshop plate fixtures and future Drawing acceptance (#4950)
|
||||
|
||||
This contribution contains **geometry oracle recipes only**. Each is a reproducible
|
||||
SolidCraft 3D model with independently calculated body count, volume, surface
|
||||
area, centre of mass and bounds. Drawing records, persistent references, UI
|
||||
commands, drawing fonts, HLR and file exporters are **not** implemented here.
|
||||
|
||||
Run `python3 tools/oracle-analytic/cases_workshop.py` to regenerate the
|
||||
analytic `expected.json` values; run `cargo xtask oracle` to replay every
|
||||
reference case through SolidCraft's CLI. Formula sources and method strings are
|
||||
in the script and the corresponding expected-value files.
|
||||
|
||||
## Exact geometry and states
|
||||
|
||||
The W23/M2 checkpoint baseline is a **100 × 80 × 20 mm** plate whose lower-left
|
||||
model corner is at XY (0, 0). Two independent through-hole features have
|
||||
identical Ø10 diameters and are 50 mm apart:
|
||||
|
||||
| Persistent feature | Model XY centre (mm) | Model diameter (mm) |
|
||||
| --- | --- | --- |
|
||||
| `Bore1` (A) | (20, 20) | 10 |
|
||||
| `Bore2` (B) | (60, 50) | 10 |
|
||||
|
||||
The model parameters are `length`, `width`, `thickness`, `bore1_d` and
|
||||
`bore2_d`; each hole is a separately created feature. The stock's sketch
|
||||
dimensions drive its length and width, and its extrusion depth is `thickness`.
|
||||
Each bore also has a construction-circle guide in the stock sketch with a
|
||||
diameter dimension driven by its corresponding `boreN_d` parameter. The
|
||||
individual `solid.hole` features use the same parameters and remain separate
|
||||
CAD operations; construction guides do not cut the stock.
|
||||
In an actual interactive workflow, the parameter edit is one command, not a
|
||||
rebuild from an unrelated set of fixed numbers.
|
||||
|
||||
| Oracle case | Model state (mm) | What this verifies |
|
||||
| --- | --- | --- |
|
||||
| `84-plate-two-bores` | 100 × 80 × 20; `Bore1` Ø10 at (20,20); `Bore2` Ø10 at (60,50) | Baseline; separate references |
|
||||
| `85-plate-width-100` | 100 × 100 × 20; both bores as in 84 | Only driving `width` 80 → 100 |
|
||||
| `86-plate-first-bore-12` | 100 × 80 × 20; `Bore1` Ø12; `Bore2` Ø10 | Only `bore1_d` 10 → 12 |
|
||||
| `87-plate-bore1-deleted` | 100 × 80 × 20; only original `Bore2` Ø10 at (60,50) | Delete `Bore1`, retain `Bore2` identity |
|
||||
| `88-plate-four-bores` | 150 × 80 × 10; four Ø8 bores at (15,15), (135,15), (15,65), (135,65) | Independent four-hole workshop plate |
|
||||
| `94-plate-four-rounded-corners` | 100 × 80 × 20; both Ø10 bores; **four vertical R10 exterior corner rounds** | Rounded exterior profile |
|
||||
| `95-plate-rounded-top-chamfer` | As 94, **plus a 2 mm equal-distance chamfer along the entire tangent-connected upper perimeter** | Rounded corners and chamfered upper edges |
|
||||
|
||||
The R10 corner cuts and full upper-perimeter 2 mm chamfer are analytic using
|
||||
the same quarter-spandrel and tangent-loop perimeter mathematics as oracle
|
||||
case 33. These revised blends require a fresh full oracle replay before they
|
||||
can be called verified.
|
||||
|
||||
## A3 at 1:2 — geometry versus measured dimension values
|
||||
|
||||
For the model in case 84, an A3 landscape drawing is **420 × 297 mm**,
|
||||
scale **1:2**. Its projected plate envelope on the sheet is **50 × 40 mm**;
|
||||
each Ø10 hole appears as a Ø5 circle and the 50 mm centre separation projects
|
||||
to 25 mm. **Annotation values remain in model units**: plate length 100 mm,
|
||||
plate width 80 mm, `Bore1` Ø10 mm, centre-to-centre 50 mm. Their written
|
||||
values are not halved by the view scale. Actual text metrics and display
|
||||
precision follow their eventual upstream owners (#4813 and #4896).
|
||||
|
||||
## Acceptance sequence once drawing APIs are available
|
||||
|
||||
Every independent edit starts from case 84; the cases in this table are
|
||||
**reference states**, not a claim that these drawing commands exist today.
|
||||
|
||||
| Step / intended command | Owning session | Resulting source oracle state | Expected drawing measurements and labels (after Update if deferred) |
|
||||
| --- | --- | --- | --- |
|
||||
| Create baseline geometry from the case 84 recipe | **Source design** | 84 | Two independent bores and the original width 80 geometry |
|
||||
| Add a `Bore1` diameter, centre-distance and plate-width dimension | **Drawing** | 84 (unchanged) | Ø10, 50, width 80 |
|
||||
| Move the dimension labels to chosen paper-mm coordinates; save and reopen the drawing | **Drawing** | 84 (unchanged) | Every label remains at its user-chosen paper coordinate |
|
||||
| `parameters.change` `width` 80 → 100 | **Source design** | 85 | Width becomes 100; Ø10 and centre distance 50 remain; all label positions unchanged |
|
||||
| `parameters.change` `bore1_d` 10 → 12 | **Source design** | 86 | `Bore1` becomes Ø12; centre distance 50 and width 80 remain; labels unchanged |
|
||||
| `timeline.delete Bore1` | **Source design** | 87 | `Bore2` remains Ø10 with its original identity. `Bore1` diameter and linked centre-distance dimensions become **lost, kept with their last values**; no silent transfer to `Bore2` |
|
||||
| `edit.undo` immediately after any individual source edit | **Source design** | 84 | Baseline measurements Ø10 / 50 / width 80 and original reference state, after drawing Update |
|
||||
| `edit.redo` following that source undo | **Source design** | 85, 86 or 87, matching the undone edit | Edited measurements and corresponding lost/valid reference state, after drawing Update; manually placed labels retain their paper positions |
|
||||
| Update an out-of-date view in deferred mode | **Drawing** (cache update; **no new undo step**) | No source change | Drawing displays the current source state; label positions remain unchanged |
|
||||
| Undo/redo a label move or an explicit reference repair | **Drawing** | No source change | Reverts/reapplies only the drawing edit; must never undo a source model change |
|
||||
|
||||
Both **open-drawing** and **closed-drawing** deferred-update sequences must
|
||||
be tested when the shared drawing commands land:
|
||||
|
||||
1. **Drawing open:** Change a driving parameter. The existing drawing and
|
||||
labels stay visibly **out of date until Update**; executing Update obtains
|
||||
the new values and retains manual label positions.
|
||||
2. **Drawing closed:** Save and close the drawing, change the 3D model, then
|
||||
reopen it. The saved drawing still appears out of date until Update;
|
||||
updating does not reset manual label positions.
|
||||
3. Repeat both sequences for bore changes, deletion and **source-design**
|
||||
undo/redo. Each source change, including its undo/redo, leaves a deferred
|
||||
drawing **out of date until Update**; Update refreshes the view cache
|
||||
without creating an undo step in either session. With a lost reference,
|
||||
retain the last measured value explicitly as historical instead of
|
||||
reporting it as a current measurement. A replacement bore at the same XY
|
||||
and diameter must not silently restore old source identity; reference
|
||||
repair is an explicit **drawing-session** operation.
|
||||
4. Undo/redo drawing-session label moves and reference repairs separately;
|
||||
neither action may undo a source design's parameter edit or deletion.
|
||||
|
||||
The coordinated drawing design allows **automatic and deferred** update
|
||||
modes (#4892). Do not introduce a separate rejected-update contract here.
|
||||
Native storage, update commands and undo ownership belong to #3890 and
|
||||
#4892; annotations and numeric formats to #4896; Drawing UI to #4894;
|
||||
sheet geometry/export to #4890. Add executable engine, desktop UI, browser
|
||||
and SVG/DXF/PDF/PNG comparisons only after those APIs are implemented.
|
||||
|
||||
## Analytic provenance
|
||||
|
||||
All source values use independent closed-form mathematics. For rectangular
|
||||
plates, volume is `LWT − Σπ(d/2)²T`, and surface area includes exposed
|
||||
hole walls minus circles on both stock faces. For the four vertical R10
|
||||
rounds, the section loses `(4−π)R²`; the boundary becomes
|
||||
`P = 2(L+W)−8R+2πR`. A 2 mm upper tangent-loop chamfer removes
|
||||
`V_c=Pc²/2−πc³/3` of material. The depth first moment is
|
||||
`Pc³/6−πc⁴/12`, so its removed-volume centroid is
|
||||
`z=T−(Pc³/6−πc⁴/12)/V_c`; the inward-offset perimeter shrinks with depth.
|
||||
Surface and first moments follow this exact construction.
|
||||
Calculations are regenerated by `tools/oracle-analytic/cases_workshop.py`.
|
||||
@@ -13,6 +13,7 @@ python3 tools/oracle-analytic/cases_features.py # 32-66: fillets, shells, sw
|
||||
python3 tools/oracle-analytic/cases_sheet_metal.py # 67-76: sheet metal
|
||||
python3 tools/oracle-analytic/cases_fillets.py # 77-81: variable, chord-length, tapered and spline fillets
|
||||
python3 tools/oracle-analytic/cases_surfaces.py # 83: boundary surfaces
|
||||
python3 tools/oracle-analytic/cases_workshop.py # W23 (#4950): parameterized plates, round corners and top chamfer
|
||||
```
|
||||
|
||||
Where a value depends on a SolidCraft convention rather than on geometry alone (a shell's sharp
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analytic expected-value writer for the W23 workshop reference plates.
|
||||
|
||||
Run from the repository root:
|
||||
python3 tools/oracle-analytic/cases_workshop.py
|
||||
|
||||
All dimensions are taken from the actual parameterized oracle recipes. The
|
||||
mass properties come from exact box/cylinder subtraction, rounded rectangle
|
||||
quarter-spandrels and the same tangent-loop chamfer integration as case 33.
|
||||
No SolidCraft result is used to create any expected geometry value.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from cases_features import MP, PI, box, cyl, spandrel_area, write
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "tests" / "oracle"
|
||||
CASES = (
|
||||
"84-plate-two-bores",
|
||||
"85-plate-width-100",
|
||||
"86-plate-first-bore-12",
|
||||
"87-plate-bore1-deleted",
|
||||
"88-plate-four-bores",
|
||||
"94-plate-four-rounded-corners",
|
||||
"95-plate-rounded-top-chamfer",
|
||||
)
|
||||
|
||||
|
||||
def one(case):
|
||||
recipe = json.loads((ROOT / case / "recipe.json").read_text())
|
||||
params = {
|
||||
p["name"]: float(p["expression"].removesuffix(" mm"))
|
||||
for p in recipe["parameters"]
|
||||
}
|
||||
length, width, thickness = (params[n] for n in ("length", "width", "thickness"))
|
||||
bores = [
|
||||
(f["name"], float(f["position"][0]), float(f["position"][1]),
|
||||
params[f["diameter"]])
|
||||
for f in recipe["features"] if f["op"] == "hole"
|
||||
]
|
||||
rounded = next((f for f in recipe["features"] if f["op"] == "fillet"), None)
|
||||
chamfer = next((f for f in recipe["features"] if f["op"] == "chamfer"), None)
|
||||
perimeter = 2 * (length + width)
|
||||
top_area = length * width
|
||||
solid = box(0, 0, 0, length, width, thickness)
|
||||
|
||||
if rounded is not None:
|
||||
# Four R10 vertical edges remove the same quarter-square-minus-circle
|
||||
# cross-section from each corner. Symmetry locates their combined COM.
|
||||
r = float(rounded["radius"])
|
||||
assert 0 < r < min(length, width) / 2
|
||||
removed_xy = 4 * spandrel_area(r)
|
||||
solid -= MP.at(removed_xy * thickness,
|
||||
[length / 2, width / 2, thickness / 2])
|
||||
top_area -= removed_xy
|
||||
perimeter -= 8 * r - 2 * PI * r
|
||||
|
||||
if chamfer is not None:
|
||||
# Constant-distance top chamfer around the entire rounded perimeter.
|
||||
# At depth y below the top, the removed band has area
|
||||
# P*(c-y) - pi*(c-y)^2: the inset perimeter shortens with depth.
|
||||
# Integrate this band for exact removed volume and Z first moment.
|
||||
c = float(chamfer["distance"])
|
||||
assert rounded is not None and 0 < c < rounded["radius"]
|
||||
removed = perimeter * c ** 2 / 2 - PI * c ** 3 / 3
|
||||
mean_depth = (perimeter * c ** 3 / 6 - PI * c ** 4 / 12) / removed
|
||||
solid -= MP.at(removed, [length / 2, width / 2,
|
||||
thickness - mean_depth])
|
||||
area = (2 * top_area + perimeter * thickness
|
||||
- c * perimeter + PI * c * c
|
||||
- c * perimeter + math.sqrt(2) * c * (perimeter - PI * c))
|
||||
else:
|
||||
area = 2 * top_area + perimeter * thickness
|
||||
|
||||
holes_desc = []
|
||||
for name, x, y, diameter in bores:
|
||||
radius = diameter / 2
|
||||
solid -= cyl(x, y, radius, 0, thickness)
|
||||
area += PI * diameter * thickness - 2 * PI * radius * radius
|
||||
holes_desc.append(f"{name} at ({x:g},{y:g}) Ø{diameter:g}")
|
||||
|
||||
detail = ("rectangular" if rounded is None else
|
||||
f"four exterior vertical R{rounded['radius']:g} corner rounds")
|
||||
if chamfer:
|
||||
detail += f"; full upper tangent loop {chamfer['distance']:g} mm chamfer"
|
||||
method = (f"Plate L={length:g}, W={width:g}, T={thickness:g} mm; "
|
||||
f"{'; '.join(holes_desc)}. Box minus independently located "
|
||||
"through-cylinders: V=LWT-sum(pi*d²*T/4), "
|
||||
"area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), "
|
||||
"mass moments from exact box minus cylinders. Shape: " + detail)
|
||||
if rounded:
|
||||
method += ("; four corner spandrels subtract volume "
|
||||
"T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); "
|
||||
"rounded perimeter P=2(L+W)-8R+2pi*R")
|
||||
if chamfer:
|
||||
method += ("; upper tangent chamfer subtracts volume "
|
||||
"Vc=P*c²/2-pi*c³/3 at "
|
||||
"(L/2,W/2,T-(P*c³/6-pi*c⁴/12)/Vc); "
|
||||
"area from upper inset face, side strips and "
|
||||
"sqrt(2)*c*(P-pi*c) bevel surface")
|
||||
# Every curved corner is one new vertical side face, in addition to
|
||||
# four remaining straight sides, two planar caps and independent bores.
|
||||
# A complete tangent-chain top chamfer contributes eight more faces:
|
||||
# one over each of the four straight runs and four R10 corner arcs.
|
||||
faces = 6 + len(bores) + (4 if rounded else 0) + (8 if chamfer else 0)
|
||||
write(case, method, 1, solid, area,
|
||||
[(0, 0, 0), (length, width, thickness)], faces=faces)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for fixture in CASES:
|
||||
one(fixture)
|
||||
Reference in New Issue
Block a user