87 Commits

Author SHA1 Message Date
bherbruck 9bcb2ffe68 Drawings: first landing from the drawings branch (#8249)
* Drawing sheets: sizes, the ISO 5457 border and its marks (#3875) (#5108)

* Drawing sheets: sizes and the ISO 5457 border (#3875)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing W23a workshop plate oracle fixtures (#4950) (#4962)

* test(oracle): reviewer-aligned parameterized workshop plate fixtures (#4950)

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

* fix(oracle): correct W23 chamfer centroid and clarify drawing session ownership

* Paper scene: plain records of what is drawn on a sheet (#4890 A1) (#5513)

geom::paper holds what a sheet shows as plain data: groups of strokes and fills, each group tagged
with the object that drew it (an opaque id) and its kind, with one validation pass over a bounded
record count. Seg2's checks and the coordinate rule move into profile.rs so section and paper share
them, and drawing_sheet takes its side and line limits from paper, so each limit is written once.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing sheets: centring marks across the whole margin (#3875) (#5554)

The built-in sheet's centring marks now run from the trimmed sheet's edge to the drawing border.
From the grid reference border, as before, a mark was the same length as a grid field mark, and on
A3 it sat on one (the sheet's centre falls on a field edge), so it no longer read as a mark once the
grid reference border's outline is left out (Cameron, after the prototype on #3875).

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing sheets: no centring or trimming mark longer than the largest sheet's side (#3875) (#6410)

A border profile capped every length at 5000 mm (the largest sheet's side) but two: a
centring mark's reach inside the drawing border and a trimming mark's length. A file could
give either a huge whole number, such as 18446744073709551615. The read's number check passes
whole numbers, so the profile took it as 1.8e19 mm, and saving wrote it as a decimal the next
read refused: a drawing that saved but didn't open again. K2's fuzz test of drawing files
(#3890) found it.

Both now take the cap the margins have, `MAX_SIDE_MM`, in `validate`, so the file is refused
when it's read. Layout's own checks against the sheet are unchanged, so a 5000 mm mark still
validates and is refused only on a sheet too small for it.

Tests: the hostile-profile test refuses 5000.000001 mm and 1.8e19 mm for each mark, and takes
5000 mm on its own; 4 planted faults (each cap removed or made one short) are all caught.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Bill of materials types: phantom, reference, purchased and inseparable (#3874) (#5107)

* Bill of materials types: phantom, reference, purchased and inseparable (#3874)

A component's bill of materials type is a part property (`bom_type` in its metadata, set with
component.metadata.set and read by component.metadata, as part_number is), so older releases
keep it and no format change is needed. It isn't a design property: the Design Properties
dialog doesn't list it and document.metadata.set refuses it. A word this build doesn't know
reads as normal and is kept.

assembly.bom counts by type: a phantom has no row and what it holds counts where it is placed,
in the same rows as the same parts there; a reference counts nowhere, and nothing inside it
does; purchased and inseparable components are one row, and in parts only an inseparable
component's purchased parts are listed too. Rows of one part number but different types are
different rows. Rows and children carry bom_type. With no types set the rows are as before.

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

* Glossary: bill of materials and bill of materials type (#3874)

The bill of materials types are a new idea, so the glossary gets their
term, with the five types, and "normal" gets its fourth meaning in the
table of words with more than one meaning.

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

* Bill of materials types: a plain line for each (#3874)

The guide's table and the glossary entry say what each type is for in
plain words: something you make, something just to group, something to
use as a reference, something you buy, something made as one piece.
The names stay the five standard ones.

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

* Bill of materials: kits fold together only when their contents' types match (#3874)

With group_children, two kits with one part number share a row when what they hold reads the
same. That reading (kit_key) compared each child by part number and count, but not by its bill
of materials type, though rows themselves are told apart by type. So a kit holding a made bolt
and a kit holding a bought one, both KIT, folded into one KIT x2 row listing the first kit's
bolt twice: the other type was lost, and which one depended on creation order. 0xlillith found
it in review of #5107.

kit_key now includes each child's type. Equal part number, type and contents still fold.

Test: two KIT kits each holding a BOLT fold into one row of two; once one bolt is purchased they
are two rows, each listing its own bolt. Planted fault (the type left out of the key): caught.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing files: a drawing saved as a container or as plain JSON, never read as a design (#3890) (#7098)

* File format families: drawings share the design format's versioning (#3890)

The format code works for a family of files: its own tag, its own numbers and
its own folder of change files, with one reading of a file's format, one
refusal of a file a newer build saved and one upgrade for every family. Designs
(solidcraft/N) are one family and keep everything they had: FORMAT and FORMATS
are as they were, and pending_names, format_string, version_of and read mean
what they did. Drawings (solidcraft-drawing/N) are the other, with format 1 as
their first record and each later change one file in
crates/doc/src/drawing/format/pending/, as design changes are. Nothing saved
changes.

A design tag never reads as a drawing's or the other way round, and a file that
names no format is a design from before format strings, never a drawing. Every
refusal names its family; a design's read word for word as before. A design
listing a pending change this build doesn't have is now refused as a newer
build's before its content is checked, as a newer number already was, so a
newer build's fields never read as damage. The list reader is
take_names(v, key, noun), the shape #4811 uses for its own list.

For the maintainer, the release step changes: scripts/fold-format-pending.sh
now folds every family that crates/doc/build.rs lists (FAMILIES), each into
its own next number: designs into crates/doc/src/format/v<N>/ as before,
drawings into crates/doc/src/drawing/format/v<N>/ from 2. With nothing pending
in a family it says so and moves nothing. Its test folds a drawing change into
v2 beside the design changes. docs/releasing.md, CONTRIBUTING.md and AGENTS.md say so.

build.rs lists each family's changes from its own folder, each folder's
versions contiguous from its own base.

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

* Drawing record: format 1 of the drawing family (#3890)

A drawing is now a record of its own (crates/doc/src/drawing.rs): its name,
the designs it draws (sources: an id and the design file's path, relative to
the drawing's folder as a linked design's is), its properties under the design
metadata's rules, its units (each design's own, or one length unit of the
expression unit table), its content language (a BCP 47 tag) and its sheets.
A sheet holds its paper, a copy of its drawing border's profile, the
resolution its pictures are rendered at (300 dpi unless it says, 25 to 2400),
the component it documents and whether it is left out of the sheet count or
of printing. This is format 1 of the drawing family; each later kind of
record adds one file in crates/doc/src/drawing/format/pending/.

The rules: one id counter for every object, raised above the largest id when
a file is read and never rewound; every reference is an id that must resolve,
or the file is damaged and refused, never patched; 1 to 1,000 sheets, at most
64 sources, names of 1 to 256 bytes, paths of 1 to 4,096 bytes without NUL.
The same drawing always saves as the same bytes.

Drawing::from_json reads at most 16 MiB of text, then runs the family's format
check, the number check, the upgrade, the typed record (unknown fields refused
everywhere) and Drawing::validate. The number check is now one function every
family calls, Family::check_numbers, told which field holds other tools' data;
a design's refusals read as before.

Nothing saves or opens a drawing yet: the container is the next step.

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

* Drawing ids: random and collision-free from format 1 (#3890)

A drawing's objects now take random ids, the form jyviko chose on #3890 from
#1017: no counter, so objects added on two branches never share an id and
never edit one line. The allocator is one module for drawings now and designs
later (#626 Phase 3), crates/doc/src/ids.rs: an IdSource gives nonzero u64s by
splitmix64 from a seed, each checked against the ids in use and drawn again
when it is one, or when it is 0. A run never draws a number twice, so at most
one draw per id in use comes before a free one, and a seeded run over a file
the same seed made still gets a new id. IdSource::seeded gives the same ids
for tests and replays; IdSource::from_entropy seeds a session from the
standard library's hash keys, the time and a count, with no new dependency.

In a file an id is 13 characters of Crockford base32, lowercase
(0000000000001 to fzzzzzzzzzzzz): ids::encode writes it and ids::decode reads
only that spelling, case-sensitive, so an id has one form to search for.
Anything else, 0 included, makes the drawing damaged. The drawing loses
next_id (a file that holds one is refused), and Drawing::new and allocate_id
take the IdSource. Messages name ids as the file writes them. The component a
sheet documents stays its design's own number until designs move over.

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

* Drawing record: a drawing too large to reopen isn't saved (#3890)

from_json reads at most MAX_DRAWING_BYTES (16 MiB), but to_json had no limit. Other tools'
properties are kept as they came, and indenting them can grow a drawing read in at about 4 MB
past 16 MiB (two million zeros in one array), so the next read refused what the save had just
written. 0xlillith found it in review of #3891.

to_json now returns a Result and refuses JSON longer than a read takes ("drawing too large to
save (at most 16 MiB)"), so every drawing saved opens again. Nothing writes drawing files yet, so
the error reaches the future file writer before it publishes anything.

Test: that case is refused, and a smaller compact drawing saves and reopens unchanged.

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

* Drawing files: a drawing saved as a container or as plain JSON, never read as a design (#3890)

K3 of the drawing storage plan (W11.2): the file a drawing is saved in. Nothing writes one from a
command yet; opening and saving come with K5.

- A drawing container is a design container holding `drawing.json` (Deflate, as `document.json`)
  in place of `document.json`, and `container.json` says so:
  {"content": "drawing", "format": "solidcraft-container/1"}. A design's `container.json` is as it
  was, with no `content`. The plain JSON form is the drawing's own text. Both give the same bytes
  every save, so a drawing can live in version control as text.
- `io::file_kind` tells a design from a drawing by `container.json` or by the JSON's format. The
  design reader refuses a drawing ("this file is a SolidCraft drawing, not a design: open it as a
  drawing") and the drawing reader a design; plain JSON only asks when it isn't a design, so
  reading a design costs nothing more. A container's content this build doesn't know, or a drawing
  container that also holds a design's `document.json`, is refused.
- `read_drawing` (bytes, either form), `read_drawing_file` (one seekable handle, the design
  reader's checks) and `write_drawing_container`; `DRAWING_EXT` (`scdwg`) and
  `is_drawing_path` beside the design's.
- The container writer's set-up and finish, the file opening and the container sniffing move into
  one function each, which designs and drawings both use. The container's own checks (budgets,
  entries, the ZIP directory) are the design container's, word for word.

Tests: a drawing round-trips through both forms byte for byte, and the container holds exactly
`container.json` and `drawing.json`; `file_kind` on both forms of both kinds; each reader refuses
the other's file; unknown content, a stray `document.json`, a missing drawing, an unknown
container version and a damaged drawing refused; both forms from disk, and a directory refused;
mutation fuzz of both forms (an error or a drawing, never a panic).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Paper scene: SVG of a sheet, its lines, arcs, cubics and fills (#4890 A3a) (#7032)

The first export of a paper scene, as SVG first (jyviko and 0xlillith on #4890), from the research
in the plan's §16. A3b adds ellipses, hyperbolas and Béziers above degree 3, which this refuses as
"not supported yet".

- `geom::paper::Prepared`: writers read a scene through `Scene::prepare`, which checks it first, so
  none reads one unchecked. Clipping goes there too, once (A8).
- `geom::paper::arc_pieces`: an arc's ends in pieces of at most a quarter turn, the last its own end.
  A half turn drawn from its ends is ill-conditioned: rounded to the nanometre (six decimals of a millimetre) its
  centre moves up to 1e-2 mm, where a quarter turn's stays under 1e-6 mm.
- `io::paper::paper_svg`: one user unit a millimetre, the size in mm on the root; y flipped by
  writing the height less y, so the file's numbers are page coordinates and an anticlockwise arc
  has sweep flag 0; lines `L`, arcs `A` (never large-arc), cubics `C`, parabolas `Q`; fills
  black and even-odd; one path per item; groups as `data-object` and `data-kind` (object ids can't
  be XML ids). No background, so cutters see only the marks.
- Numbers are whole nanometres (six decimals of a millimetre): no exponent, no "-0", the same bytes every time. They are the
  writer's own state too, so a joint is written once and a closed path ends exactly on its first
  point. Numbers are written straight onto the document, with no text made per number. A width
  under half a nanometre is written as one, and a piece too short for the grid is merged into the
  next, both counted in the report; a path's last piece has no next, so it is kept however short
  (unless it has no length). Output over 64 MiB is refused.

Tests: the A3 drawing border's whole document; quarter, 270°, clockwise and whole-turn arcs, and a
circle of two halves giving the same four quarters; the quarter-turn boundary both sides; cubics and
parabolas, and the conics refused; the number format; a closed path whose start and end round
apart; a sliver merged; an open path's short last line and arc kept, and one of no length dropped; read back through our own SVG reader (lines exactly, the arc as an arc);
the scene's JSON mutated into the writer (never a panic).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing W17a: view definitions as data (#4816) (#4817)

* claim: #4816

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

* Drawing views as data: named views, scales and the map onto paper (#4816, W17a P1)

What a drawing view is, before anything draws it (geom::drawing_view):
- NamedView: the six principal views and the isometric views from the four upper corners, each
  an exact basis (a Plane whose normal points back at the viewer), never taken from a camera,
  whose pitch clamp would tilt a top view, with no -0 to write out; `iso` reads as the
  front-right one.
- basis_looking: any other view from its direction, an up and an origin (paper x = direction x up,
  paper y = x x direction), refusing a zero direction, an up along it, or an origin that isn't
  finite or within 1e12 mm. W07's
  {direction, up, origin} view reads through it.
- Scale: paper : model as two positive numbers, read and written as "1:2", from 1:100000 to
  1000:1; Scale::fit picks the largest ISO 5455 preset (50:1 to 1:10000) at which a view fits a
  space (W07's "fits the page" default), compared as size x paper <= room x model so an exact
  fit at a reduction isn't lost to rounding.
- ViewFrame: a view's basis, scale and place on paper, the point of its plane at `centre`
  landing at `position_mm`. Its paper map moves the view's curves and their accuracy onto
  paper (paper error = scale x model error); `boundary` maps a box of its plane onto paper from
  its four corners.

In geom, where Seg2 lives:
- Similarity2 and Seg2::similar (new): scale, turn and shift a segment exactly, an arc staying an
  arc. Similarity2::new refuses a scale that isn't above 0 and works the turn out once, not per
  point;
- Seg2::map_controls: a segment's control points mapped by any function. It was doc's
  sheet::map_controls; its four callers in doc use the method now, unchanged.

CurveAccuracy::scaled gives a curve's accuracy at another scale.

Tests: each named view's basis and viewer side, and the same bases from a direction and up; a 50 mm cube in every principal view at 1:1 and
1:2, and in isometric (edges 50 x sqrt(2/3), outline 50 x sqrt(2)); the view's centre on its
position (W07's 100 x 20 front view on A3 at 2:1); scales read, written and refused; the fitted
scale, an exact fit at 1:5 and ISO's smallest; no -0 in any named view's axes; an origin refused by
its own message; hostile bases, centres and positions; every segment kind moved by a similarity,
and the similarities refused; scaled accuracy.

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

* Drawing views: the bottom view pinned, square near parallel, bounded scales and boxes (#4816)

From DevonHope's review of #4817:
- The tests pin all six principal views exactly. The direction-and-up test uses literal ups,
  so a bottom view turned half round now fails it.
- basis_looking squares x to the direction before normalising. Near the 1e-9 threshold the
  normal was up to 8.8e-9 rad off; now it is about 1e-16.
- Scale::fit fits nothing for a size or room that is negative or not finite. A size of 0 still
  sets no limit that way.
- Each side of a scale is 1e-6 to 1e6, so a scale writes at most 49 characters.
- ViewFrame::boundary returns a Result. It refuses a box that is inside out or not finite on
  paper, instead of dropping a corner.
- paper_map, boundary and seg_to_paper say which coordinates they take.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing W18 S2: title blocks as data (#3875) (#3877)

* Title blocks: what a cell shows and where its text goes (#3875)

geom::cell holds a title-block cell's content: parts of text and fields
(component, drawing, sheet, revision or prompt, by key, with an optional
unit and decimals kept as written for the resolver to check), at most 64
parts and 4096 bytes of text, with no control character but a line break.
Beside it are a resolved value (live, overridden and drifted, or missing,
with "-" for each part that couldn't be read and why), an override with
the value it replaced, and the projection a drawing's views use (first or
third angle), which the title block's symbol will read.

A fit warns and never cuts: a count of characters, not bytes, with its own
count for text in Chinese, Japanese, Korean or Yi characters.

The cell text rule places a label and a value: the label's baseline 1 mm
plus its cap height below the cell's top, the value box under it inset by
1 mm, one line centred in the box, and wrapped or several lines down from
its top at the font's pitch, with a warning when the last falls below the
box. Wrapping by width, width warnings and glyphs a font lacks need the
text measured, so they wait for the paper scene's text.

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

* Title blocks: definitions and their place on a sheet (#3875)

geom::title_block holds a title block's definition: its size (1 to 1000
mm a side), its outline and cell line widths, and up to 64 cells. Each cell
has a snake-case name of its own, a rectangle measured from the block's
lower-right corner, an optional one-line label with its cap height, and a
value: text from cell content, with its cap height, alignment and fit, or
the projection symbol, which reads the drawing's projection and is never
fixed in the block.

validate() checks a definition on its own, as when one is read: cells
inside the block and not overlapping (touching is fine), label and value
cap heights that fit, and the content's own limits. layout() puts the
block on the drawing border's lower-right corner of a sheet, refusing one
that doesn't fit, and gives each cell's rectangle, its label's place and
its value box by the cell text rule. On A3 landscape a 180 x 54 mm block
runs from (230, 10) to (410, 64); it fits A5 landscape and every larger
sheet, but not A5 upright.

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

* Cell text: a value box lies within the paper's coordinates (#3875)

place_lines took any finite, ordered value box, but centring one far out (min x 1e308, max x
1.1e308) overflows: the anchor came back infinite, with no warning. 0xlillith found it in review
of #3877.

The paper scene already bounds every coordinate it holds by MAX_COORDINATE_MM (twenty times
the longest sheet side). That limit is now public, and place_lines keeps its box inside it, so
its anchor is always finite: one rule for what a paper coordinate may be.

Tests: the hostile-box test refuses both overflow cases from the review and a box just past the
limit on each side, and places lines in a box the size of the whole paper limit. The fuzz test's
numbers now include values past the limit (1.1e5, ±1e308).

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

* Title blocks: padding from the lines' inner edges, one label rule, nothing unread (#3875)

A cell's text is padded from the inner edge of its lines: each side of a cell moves in by half
the line drawn on it (the outline's on the block's edges, a cell line's inside), so a wide
outline never covers the text. Before, the padding ran from the line's centre.

The label's one-line rule lives with the cell text rules (`cell::check_label`), where parts
lists will find it. The cell types nothing read yet (`Override`, `Resolved`, `State`,
`Problem`, `Reason` and `MISSING`) wait for the resolver that will read them.

The tests check the exact thresholds inside the lines (a label that leaves no room, a value
that just fits), pick each of the lines, alignment and wrapping on their own so every
combination comes up, and lay fuzzed blocks out on every standard sheet, either way, with and
without the filing margin.

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

* Title blocks: the drawing border's line, descenders, room where the block sits (#3875)

From DevonHope's review of #3877:
- Each cell's lines are clamped to the inner edge of the drawing border's line. That line runs
  along the block's right and bottom edges, and along any other edge it reaches, as when the
  block fills the border. Aabb2::intersection, the 2D twin of Aabb3's, does the clamping.
- layout checks each cell's room again where the block sits (place_cells, which validate runs
  at the origin), so rounding at the real corner can't turn a cell inside out.
- The cell text rule counts descenders, at 3/10 of the cap height (ISO 3098's lettering B).
  They may reach into the padding under a value box, never past it to a line. place_lines
  warns Height when they would, and validate asks it whether one line fits.
- The module doc no longer links to a private function.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455) (#2457)

* Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455)

crates/geom/src/hlr (L0): prepare once per body, then hidden_lines for a
scene of placed bodies. Edges and face silhouettes are projected from their
exact curves, split where projections cross, and each piece is classified by
rays toward the viewer against the trimmed faces: visible, hidden or
explicitly uncertain, never guessed. Planes, cylinders, cones, spheres and
tori are supported whether stored as analytic records, revolutions or spline
patches; freeform faces are reported and make what they could hide
uncertain. Screen geometry is Seg2 with the W01a accuracy vocabulary: exact
lines and conic arcs where the projection is exact, bounded chords
otherwise. docs/drawing-hlr.md holds the contract and its limits.

* geom/hlr: handle the brep records main gained in #3093

Main's exact model storage (#3093) added Curve::SurfaceIntersection,
Surface::PlaneThrough and Surface::PlacedRevolution, and the HLR matches on
the records no longer compiled. A plane through three points is a plane: it
is trimmed and intersected as one, and a box stored that way draws the same
bits as one with axis-stored planes. The procedural intersection curve and
the placed revolution (whose affine placement may scale it unevenly) are
refused by prepare as not supported yet, until the kernel adapter handles
them. The records' positions move with the body's centre like the others.

* geom/hlr: pad plane faces' boxes, so a plane record off by rounding still hides

Plane faces took the box of their boundary chords unpadded, flat along the
normal. When the plane record and its edges disagree by rounding (a unit cube's
top plane one ulp above its edges, a 100 mm cube's 1e-11), the ray toward the
viewer reached the plane just outside that box, the slab test culled the hit,
and the back edges came out Visible with no doubt. Every face's box is now
padded by 1e-6 of its size, as curved faces' boxes already were. Reported by
DevonHope on #2457.

* geom/hlr: limits from review: trim samples, scene extent, crossing pairs, surface groups

From DevonHope's review of #2457:
- Options::trim_samples must be 1 to MAX_TRIM_SAMPLES (4096).
- Placed bodies must stay within MAX_COORDINATE (1e12 mm, as section and brep
  bound stored numbers). Placements 1e200 apart made the scene's size infinite
  and every tolerance taken from it with it, so everything came out Visible.
- The crossing search counts its candidate pairs before it starts and meets
  each pair only in the first grid cell the two segments share, instead of
  keeping a set of the pairs met, which could outgrow wasm's 4 GB before the
  500 million pair budget tripped. The same pairs in the same order.
- prepare groups faces on one quadric or torus by comparing each face with
  the faces of its kind whose size agrees to the tolerance (sorted), not with
  every face; at most 50 million comparisons. prepare_cancellable polls a
  cancel callback between edges, faces and comparisons.

* geom/hlr: boundary chords on a grid, and no fine pass over coincident curved chords

The cost items of DevonHope's review of #2457:
- Every ray hit asked how far it lies from its face's boundary by scanning all
  of the face's chords (4096 per circle; a plate's face with 256 holes has
  about a million). The chords are now bucketed on a uniform grid over the
  boundary's box, cells at least as long as the longest chord, and the test
  looks only at the cells around the point. It finds exactly the chords the
  scan finds.
- Chords of two curved curves that lie on each other end to end (aligned holes
  seen along their axes) were cut into 16 by 16 pieces and intersected, to
  find crossings that are only noise within the curves' tolerance. They are
  now skipped; visibility changes where a coincident run starts or ends, and
  chords that only partly overlap still find those.
- Fixtures: a plate with holes, coaxial cylinders seen along their axis, and
  300 placements.

* geom/hlr: pad boxes by the body's coordinates too; coincident chords only to rounding

Review of the previous commits found two holes:
- The padding grew with the face's size, but rounding grows with where the body
  was modelled: a 0.001 mm cube 1e7 mm out, its top plane one step (1.9e-9 mm)
  above its edges, still had its hit culled. The pad now adds 1e-12 of the
  body's offset.
- Two chords were taken for coincident when their ends lay within each other's
  sag, which in a coarse scene can hide real crossings: an arc can bulge across
  a line 1 mm off its chord twice. Only chords that coincide to rounding are
  skipped now; identical projections, the costly case, still are.

* geom/hlr: coincident curves meeting at shared sample points do not cross

Timing the review's scenes showed that skipping coincident chords was not
enough: chords of two coincident curves also touch end to end at every sample
point they share, and each touch was a crossing to refine and split at. 64
coaxial cylinders seen along their axis gave 8.3 million crossings (19 s), 64
cylinders one behind another along the line of sight 4.2 million (214 s;
before these commits the crossing budget refused it). A touch at a shared
sample point is now dropped when the two curves also coincide, to rounding, on
both sides of it. Where a coincident run starts or ends one side differs, and
the touch is kept.

* geom/hlr: the own-face margin allows for rounding where a body was modelled

The far 0.001 mm cube of the previous commit showed a second effect of records
rounded at large coordinates: its top plane, one ulp (1.9e-9 mm) above its
edges, hid the top edges themselves, because a ray leaving an edge met its own
face 3.2e-9 mm along, past the own-face margin of 1e-6 of the face (1.4e-9).
The margin now also covers 1e-14 of the body's offset (about 45 ulps of its
coordinates), which only matters for parts far smaller than their distance
from the origin.

* geom/hlr: no parity test for a hit on the boundary, where it is uncertain anyway

* docs/drawing-hlr: the fixtures of the review's second round

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>

---------

Co-authored-by: CamWight <91156642+CamWight@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Benny1496 <163034806+Benny1496@users.noreply.github.com>
Co-authored-by: Mateusz Sych <151755914+SychPL@users.noreply.github.com>
Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-28 13:31:16 +00:00
bherbruck 03b8f4138b New designs save as .sc; .solidcraft files still open (#7325)
* claim: new designs save as .sc

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

* New designs save as .sc; .solidcraft files still open and keep their name on Save

DESIGN_EXT is now "sc"; DESIGN_EXTS lists sc, solidcraft and solvecraft and
every open path (dialogs, drag-drop, recent files, CLI, MCP, links, the web
picker) accepts all three. Save of an opened .solidcraft file writes that
file; Save As, autosave, recovery and crash reports use .sc. Windows, macOS
and Linux register .sc alongside the older extensions.

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

* Update UI tests for the .sc default extension

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:41:40 +00:00
Dahak Builds 8630a3f0e5 External STEP corpus: nightly import checks (#1397) (#6407)
* claim: #1397

* External STEP corpus: nightly import checks on real-world files (#1397)

tests/external-corpus.toml pins 20 real-world NIST PMI STEP files (AP203
geometry-only and AP242 editions from several vendor exporters, public
domain) by archive and per-file SHA-256 with the licence recorded.

cargo xtask external-corpus downloads each source once into the gitignored
corpus/external/, verifies every hash, imports each file headless
(file.insert_step + inspect.measure) and checks body count, total volume
(0.1 %) and area (0.5 %) within tolerances and time and memory budgets.
A file whose upstream hash changed is reported as 'source changed', never
as our failure. --bless "why" re-records expectations in the manifest with
a dated reason. The External corpus workflow runs it nightly.

assets: skip corpus/ when scanning for attribution (downloaded case data
is never committed).

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 12:26:36 +00:00
wckdboy 743accca7a Triangulation: split a crossing boundary segment ourselves, never in spade's assert (#4031) (#5409)
* claim: #4031

* Triangulation: split a crossing boundary segment ourselves, never in spade's assert (#4031)

A trimmed face's boundary segment that crosses a constraint already in the
triangulation went to spade's add_constraint_and_split, which asserts
(cdt.rs:978, `!previous_region.is_empty() || from == final_vertex`) when a
split lands so near another vertex or constraint that the piece up to it is
blocked. The kernel guard caught it, but the whole operation failed and a
crash report was written; a STEP body imported empty or not at all (#4598,
also #4293, #4478, #5068, #5229).

PolyBoundary::add_constraints now adds every segment that can be added, then
splits each blocked one itself: a vertex at each crossing, inserted from the
crossed edge's own vertex, and the pieces between added where they can be. A
segment with crowded or very many crossings, or a face with more than four
blocked segments (a rim that runs back and forth across itself), leaves them
out, as truck did before #1653, so the face meshes slightly off instead of
not at all. Pinched faces (#1648) still split where they cross.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:55:24 +00:00
Benny1496 efd0e70f7d fix(oracle): use curvature-weighted centroid moments for case 33 (#5442)
* fix(oracle): use curvature-weighted centroid moments for case 33

* style(oracle): preserve reference JSON numeric formatting

* docs(oracle): remove transient case 33 validation note
2026-10-25 02:30:39 +00:00
cronos 592f6a6fee ci: fix local scheduling and Windows test baselines (#4941)
* ci: repair local nextest scheduling and Windows fixtures

* test: refresh MSVC robustness references and isolate GNU policy

Integrate the Windows reference records from #3361 and the target-environment
selection and recording policy from #4159. Keep shared records and comparison
tolerances intact, and retain seed 36 as an existing failure tracked by #3583.

Co-authored-by: Ray <raykholo@users.noreply.github.com>
Co-authored-by: chris <christopher.nourse@gmail.com>

* test: make CI subprocess and revolve fixtures deterministic

---------

Co-authored-by: Ray <raykholo@users.noreply.github.com>
Co-authored-by: chris <christopher.nourse@gmail.com>
2026-10-24 11:33:10 +00:00
bherbruck 688124937d Chamfer: a cutter that would reach into a nearby wall is not used (#4539)
* Chamfer: a cutter that would reach into a nearby wall is not used

A straight chamfer's cutter reaches several times the chamfer's size out
from its edge, and further on a larger part. On a pin standing in a socket,
that far side lands inside the socket's walls, and the cut took bites out
of them. A cutter whose far side is inside the part is now refused, as one
whose ends are, and the chamfer is built locally instead.

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

* Robustness goldens: seed 9 re-recorded for the chamfer cutter check

A chamfer in seed 9 was refused because its result changed more of the
part than a chamfer can. With its cutter refused, it now builds locally.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:05:35 -04:00
NOWN-DEV 63cb48a4ee Measure: a body whose meshes leave a gap keeps its volume wherever it is (#2069)
* Measure: a body whose meshes leave a gap keeps its volume wherever it is

A part ring cut by solid.combine (two prisms through its ends, a notch
through its chamfer) measured 992.03 mm3 at the origin, 999.17 moved
100 mm and 1063.48 moved 1000 mm; it encloses 993.92. The reference
mesher leaves a crack where the notch meets the chamfer's cone: the
volume beside it is left out, and the divergence-theorem sums, taken
about the world origin, close the crack by a cone to the origin, so the
error grows with the body's distance from it.

A closed surface's area vectors sum to zero. When the picked meshes'
sum could move the volume by more than 1e-5 within the body's size, and
the display plan (which shares every edge's nodes between its faces) is
closed, the body is measured on the plan, about its box centre. Every
other body measures exactly as before.

Tests: the cut ring at the origin and moved by up to 10000 mm, against a
grid quadrature of the cut; a revolved ring of cones and cylinders moved
100/1000/10000 mm along each axis (1e-6); a cylinder and a frustum far
from the origin against hand values. Robustness seed 64 moves by 1.4e-6
and is recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Measure: a gap the display plan can't close is measured about the box's centre

When the meshes leave a gap and the display plan isn't closed either, the
sums used to fall back to the origin-based volume, which drifts with the
body's distance from the origin (7 % at 1000 mm for the part ring). The
per-face sums are now taken about the fine mesh's box centre, which closes
the gap by a cone to a point inside the body; over a closed mesh nothing
changes. The gap decision moves into gap_closed, and a unit test with a
cube left open by hand covers closed vs open meshes, the closed plan, and
the fallback with an open plan or none.

Seeds 14, 20, 40, 55 and 61 end with such a body and are recorded with
ROBUSTNESS_GOLDENS_BLESS (volumes move by -0.51 %, +0.16 %, +1.9e-4,
+9.0e-5 and -1.5e-5; steps, outcomes and counts unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Record robustness seed 86 after measuring bodies whose meshes leave a gap

Seed 86 was added on main by #2387 after this branch's goldens were recorded. Its last
body leaves a gap in its meshes, so the measure change here moves its volume by -0.27 %
and its area by +0.13 %; steps, outcome and counts are unchanged. Seed 86 passes
unchanged on main 57d21013. Every other seed matches on the merged branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: take the display plan only where it agrees with the sums to within what the gap could move

A closed display plan meshed less finely than the sums differs from them by its own chord error,
which can be larger than anything the gap explains: an imported part with small blends measured
0.03 % low on its plan. The plan is now taken only where the two differ by at most what the
gap's area could move the volume within the body's size; otherwise the sums about the box's
centre stand.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: sum a mesh left open about its box's centre only, without the display plan

The closed display plan meshes some faces of an imported B-spline part
more coarsely than the sums do, so a body and its boolean pieces no
longer add up: clearance's union cross-check then refused a 17.3 mm3
overlap between two such parts (the pieces implied 20.5). Summing about
the box's centre alone keeps the gap closed by a cone to a point inside
the body and stays consistent with the pieces. Seeds 64 and 86 are
re-recorded with the reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: placement tests in the area's test files

The ring and frustum tests join measure.rs's tests, and the cut ring
joins measure_centre_tests.rs (AGENTS.md: a regression test goes into
the existing test file for its area). The goldens note for seeds 14, 20,
40, 55 and 61 describes the centre-only sums.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: keep the origin for a body left open on purpose

A surface, or a cup whose lid is still a separate sheet, has no volume of
its own. Measured about its box's centre, two such bodies stopped
comparing once the centre moved (a crowned lid rises), which broke the
patch crown test. Only a mesh with a small crack is measured about the
centre now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

* Robustness goldens: keep main's notes, one note for this change's seeds

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:57:03 +00:00
bherbruck 438abbcf82 STEP conformance sweep: the cones to a point read right now (#258) (#3901)
cone_point and cone_point_vertex join the sweep now that #3871 reads a cone
bounded only by its base circle up to its point.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:46 +00:00
bherbruck fc3dedfd5c STEP import: regression sweep over the step-conformance parts of known shape (#3880)
* STEP import: regression sweep over the step-conformance parts of known shape

Fifteen generated parts from step-conformance (CC0): plain, NURBS, seamed,
placed, inch and far-away cylinders, domes, a grooved shaft, a plate with 100
holes, a 3.6 m disc, a 0.5 mm pin, a B-spline half-torus and a washer. Each
reads back with its exact volume, its box and its face count. The half-torus
on a TOROIDAL_SURFACE and the cones to a point are left out until they read
right.

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

* STEP conformance sweep: the pin's volume as FRAC_PI_8 (clippy)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:07:47 +00:00
Nathan Dane 57aad71bbe Window frame with page list and the confirm dialog on the UI kit 2026-10-20 21:01:25 +02:00
Nathan Dane 1122cd44b8 UI library, tokens, fonts and icons (step 5 of #2016, after #2287) 2026-10-20 21:00:56 +02:00
Irakli Kourtis 932d8d6b4f Tessellation: trimmed faces of display meshes stay on their surfaces (#1653)
* Tessellation: trimmed faces of display meshes stay on their surfaces

Display meshes triangulate a trimmed face in parameters scaled by the
surface's speed along each, so a strip stretched along one parameter is
not fanned across, and every boundary segment stays a constraint: one that
crosses another is split where they meet. Boolean meshes keep truck's way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* Tessellation: one boundary rule for every mesh; drop the #1648 retry

A boundary segment that crosses one already added is split where they
meet in Boolean meshes too, which keeps a pinched face (#1648) whole
without the snap-and-retry. Faces whose segments all go in mesh as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* Robustness goldens: seeds 1, 23, 55, 64 and 86 re-recorded on current main for #1653

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

* Thread preview test: wait for the stale fit error to clear before the next preview

After switching back from M6x1.3 to M8x1.3, wait_feature_preview stopped at the M6 error still on
screen when the new preview started a few frames later (seen on Linux CI with #1653's meshing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

* Robustness goldens: the slow seeds 14, 20 and 40 re-recorded for #1653 from the merge queue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:49:45 +00:00
Artem Sushchev ae2ab87b99 Cut: a body with a sealed void no longer comes out empty (#3175)
truck placed a face that no intersection divides (every face of a shell
that meets nothing of the other) by counting the faces a ray crosses,
taking each face's surface as stored. A revolved body's reversed faces
counted the wrong way, and an inward shell (a void, a cut's inverted
tool) was read as a solid: a cut beside a sealed void came out empty,
refused or, with a tool whose bounding box covers the target, taken with
the body gone. Intersections of such bodies came out empty as well.

In an AND the crossings now count as each face is oriented, and an
inward shell holds what lies outside it. An OR keeps truck's count.

The corrected cut let a folded offset through: an arc edge next to a
face it meets tangentially had its ends moved past its middle, and the
arc went the long way round (robustness seed 73's shell). The offset
refuses an arc whose middle crossed its chord, as it does a straight
edge that turned round.

Goldens: seeds 0, 45 and 77 had a cut refused as empty that now takes
what it should; their volumes are checked by hand in the PR.
2026-10-20 10:30:13 +00:00
Mateusz Sych 8a562f2719 Mesh faces whose boundary touches itself within tolerance (#1648) (#1850)
A face pinched to a point, where its boundary touches itself or another of its loops within
TOLERANCE (a sphere touching a box's edge leaves one on the box), had a constraint refused by the
CDT, and the fallback cut across the face: it came out with no triangles or without those at the
pinch. When a constraint is refused, the vendored truck-meshalgo now puts every boundary point
within TOLERANCE of a segment into it (at most 8192) and triangulates the face again. Faces whose
segments all go in mesh as before.

The regression tests live in crates/kernel/src/thin_face_tests.rs, and the #1648 property test in
boolean_props.rs is no longer ignored. Robustness seeds 20 and 55 are recorded with their Linux
values.

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-20 08:28:21 +00:00
Artem Sushchev 80719744cf Union: when the slab past pushed-apart coplanar faces can't be cut off, trim it by editing faces (#2801) 2026-10-20 02:11:23 +00:00
Colin Faulkingham b622a46624 Fix large modeled threads and thin crest reconstruction (#2209)
* Fix large modeled threads and thin crest reconstruction

Helix stations follow the pitch (radial chord error under 1% of it, 48 to
720 stations), and the faceted rebuild retries at the BSP's precision when
the usual weld tolerance collapses a thin crest facet.

Rebased onto main as one commit (the branch's own commits touched UI files
that later moved to #2263 and #2264). Adapted to main's UnitVec3 thread
axis: `*k` in thread_groove and `*cylinder.axis` in the new flange test.

* Fix icon review revisions for same-timestamp edits
2026-10-20 00:06:44 +00:00
rlartot 31b41978fc Construction geometry on model vertices follows them when a parameter moves them (#568) (#2409)
Thanks @rlartot.
2026-10-19 16:11:38 -04:00
HiroKenEz 45d0b6053b Icon tools: hashes and palette revisions ignore CRLF checkouts, text I/O is UTF-8 (#1930) (#1931)
On Windows, Git's default core.autocrlf checks the icon masters and the
palette out with CRLF, and `cargo xtask icons` failed four ways:
- the accepted-icon hashes (lint_icons.py and the review page's
  review_state.master_hash) were taken over the files' bytes, so every
  accepted icon read as changed; both now use master_hash, which hashes
  the text with CRLF read as LF;
- icon_palette.revision hashed the bytes while save_colours hashed the
  text it read, so every palette save was refused as stale; revision now
  reads the text too;
- the tools and their tests read and wrote text in the locale's encoding
  (cp1252 can't write U+2194); they now say utf-8;
- the icon inventory listed UI files with backslashes; it writes /.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:49 -04:00
Victor Santos Oliveira 6f93e7a851 fix(examples): cut both mounting holes in the bracket (#1515)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:23 -04:00
phanoob 6b1fc16d47 Sketch projection: a solid's edges come in as their exact curves (#1508)
Thanks @phanoob.
2026-10-18 21:23:11 -04:00
bherbruck 3a1e1eed39 Merge pull request #2267 from tangency-labs/ci/slow-runner-timeouts
Tests: more time for the web offline launches and the icon review server
2026-10-18 21:16:09 -04:00
fkirill 9ee2de9a0f Fix drilled hole cuts through tapered walls (#2387) (#2401)
Rebase the complete PR change onto current main, preserving changes previously stored in merge commits.
2026-10-18 21:16:06 -04:00
Artem Sushchev f5a868efda Fix red main after #2247: record its robustness seeds, and keep older designs' joins of split pieces working (#2540)
#2247 changed 16 robustness seeds (bodies N -> 1, and some seeds then build
different steps; seed 54's new shell fails on #2247's parent too). Recorded
on Linux.

Designs saved before #2247 that combined a split-off piece by name (tools
Posts, Posts (1)) failed on the missing piece and left both posts off the
case. A combine now skips a missing `X (n)` when `X` is a tool too or the
join's target, with a warning. Format note for the meaning change.
2026-10-18 13:10:09 -04:00
bherbruck 72b7fc385b Fix red main: record robustness seeds 13, 67 and 94 after #2064 (#2396)
#2064 (multi-profile extrusion) merges coplanar faces the boolean used to
leave split. Seeds 67 and 94 change face/edge counts at the same volume;
seed 13 has four fewer edges before its random fillet, which then picks
different edges by index. Every step still passes the validity and
volume-direction checks. Pull request CI runs every 8th seed only, so
main's full run was the first to see these.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:38:41 -04:00
Irakli Kourtis 83c31d77c4 surface.boundary: surfaces through boundary curves and curve networks (#763) (#2129)
* feat(geom): surfaces through curve networks, and spline spaces to build them

Adds spline_space (clamped B-spline spaces, Hermite interpolation, exact
embedding by Bézier degree elevation and knot insertion, a monotone cubic
for reparameterising) and network (loft, Coons and Gordon surfaces through
curves in one or two directions, with G1/G2 cross-boundary conditions
against a neighbouring face). Part of #763.

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

* feat(kernel): boundary surfaces from sketch curves, 3D curves and edges

kernel::boundary_network turns sketch chains, 3D sketch polylines and edge chains
into network curves, gives G1 frames of the faces along open edges
(EdgeFaces), builds the B-spline face (sides split at chain vertices so a
join shares the body's edges) and joins it into a surface body by stitching.
G2 is refused for now (not supported yet). Part of #763.

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

* fix(kernel): second derivatives of B-spline surfaces in the vendored truck-geometry

BSplineSurface::uuder and vvder multiplied the second differences by p
instead of p·(p − 1), as BSplineCurve::der2 does, so every surface of
degree p ≥ 3 (NURBS too, through its homogeneous surface) reported
1/(p − 1) of its second derivative: a cubic surface's curvature read as
half. This is why a G2 boundary surface on a cylinder measured half the
curvature it carries (#763). New tests hold both to finite differences
for degrees 2 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(kernel): G2 boundary surfaces against a body's face

The network always carried the face's curvature across a G2 edge; the
refusal came from measuring it with truck's halved surface second
derivatives (fixed in the previous commit). EdgeFaces now reads the
face's second fundamental form from the exact second derivatives
instead of differencing the normal. A new test holds the curvature
across a cylinder's edge to the cylinder's within 0.1%, also with a
sheared far curve; G3 is refused (a network holds up to G2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(kernel): a boundary patch with straight corners measures its own area

A stadium's arcs meet its lines at 180°, so the Coons patch is singular
at its corners. From such a corner the reference mesher's boundary walk
ran onto the B-spline's extrapolation past its domain (a planar spline
passes through every point of its plane there), and the face measured
289 mm² for its 158.5 (#763). The vendored mesher's bounded projection
(nearest_projection, #2043) keeps the walk on the domain; this holds it
there, by the faces, the measuring mesh and the display mesh. The
display path (#1653) was never affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(geom,kernel): three-sided boundary surfaces (one side collapsed to a point)

A network curve may be a PointCurve: the apex where the two sides beside
it meet. The curves through it cross it at the point itself, its row of
the net collapses there, and the kernel's face leaves that side out of
its loop (three edges, one vertex at the apex). One point per patch, as
a side; refused elsewhere, when it moves, or with a continuity
condition. Tests: a triangle and a quarter disc filled flat with their
analytic areas (40 mm², 25π), by geom and by the kernel's faces and
meshes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(doc): boundary surfaces through curve networks and loops of three or four

FeatureKind::SurfaceNetwork saves a network as `u` and `v` curves, each
a sketch chain (planar or 3D), a chain of a body's edges with an
optional G1/G2 continuity, or a point (the apex of a three-sided
patch), and an `operation` that can join the surface into the body of
its edges. A SurfaceBoundary of three or four sketch curves, refused
until now, builds as the flat Coons patch of its loop (three collapse
the side opposite the first curve). The format change is one pending
file; feature-walk goldens record the new variant's sketches, bodies
and points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(engine): surface.boundary through loops of three or four and curve networks

The command keeps its loop form (two to four curves of one sketch; three
and four now build the flat patch of the loop, crown refused there) and
gains a network form: `u` and `v`, each curve a sketch curve id, a
{sketch?, curves} chain (planar or 3D), a {body, edges, continuity?}
edge chain (g1/g2 with the face beside it) or a {point} for a
three-sided patch, with `operation` new or join. Edits may move between
the two forms (timeline.redefine accepts the command now that it has a
dialog).

Tests: analytic areas of loop patches and of a hyperbolic paraboloid
bounded by four 3D lines (every mesh vertex on z = xy/10), a loft
across two sketches, G1 and G2 measured across a cylinder's edge with
inspect.continuity (angle < 0.1°; curvature jump 1e-16 with G2 against
0.027 with G1), 26 hostile networks refused without panicking, and u/v
added to hostile_params_never_panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(ui): Boundary Surface dialog

Curves and Cross curves take sketch curves and edges (picks that meet
end to end in one sketch or body are one chain); Continuity and
Operation (New Body or Join). Two to four sketch curves of one sketch
with nothing across are the loop patch; anything else is a network.
Edit Feature brings either back (per-side orders, crown and stations
the dialog does not show are kept). Strings in en, ja and zh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* docs: ROADMAP M8 and the bridge patch's notes for boundary networks (#763)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(oracle): a boundary surface through a loop, thickened (#763)

Case 83: four sketch curves (a square's three sides and a half circle
for its top) make one boundary surface, thickened 2 mm; analytic values
from tools/oracle-analytic/cases_surfaces.py (V = 2A, area 2A + 2P,
centroid by moments). Recipes gain boundary_surface, thicken and remove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* refactor: boundary surfaces, review cleanups

Project onto the face once per derivative evaluation (the frame is
passed to natural() and reused by scale()), keep each track's
finite-difference step instead of rescanning its table, export the
kernel's edge-pick and chain caps and the default station count for
the engine, doc and dialog to share, map TooLarge spline errors to
KernelError::TooLarge, reuse the engine's operation parser, and skip
the dialog's sketch lookup when the picks are no loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-17 21:47:38 -04:00
bherbruck a587d3339e Tests: more time for the web offline launches and the icon review server
On a busy CI machine (several fork jobs under gVisor at once) a web
offline launch took over 90 s and the icon review server took over ~2 s
to answer. Launches now get 240 s and one retry; the review server gets
up to 60 s to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:11:14 +00:00
Koschtetnix 487aa64e10 Multi profile extrusion performance (#2064)
Thanks @Koschtetnix.
2026-10-17 18:30:52 -04:00
Mateusz Sych 4a70c03deb Revolve: a profile that comes back to the axis along a curve revolves right, and cuts with it work (#1853) (#1866)
Thanks @SychPL.
2026-10-17 13:38:41 -04:00
bherbruck ccd0b5aab8 Robustness goldens: seeds 13, 71 and 94 build as main builds them, not as #853's branch did (#1929)
#853 recorded seeds 13, 71 and 94 with the face and edge counts its own branch built
(40/101, 19/50, 27/63). With main's kernel they still build as they did before #853
(41/103, 20/52, 26/61), so every full goldens run has failed since it landed. This
records them again; #853's seed 20 change and the other 93 seeds are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 04:40:23 -04:00
phanoob c9b8267b9e Action matrix: record the chamfers #1428 refuses (#1684)
Thanks @phanoob.
2026-10-17 04:09:51 -04:00
Nathan Dane 64966bab88 Implement the reviewed UI icon system and durable review workflow (#853)
Thanks @Nathan-Dane.
2026-10-17 04:07:16 -04:00
jyviko 8390a31531 Robustness goldens: seed 55 builds a valid body since #1670 and #1753
Main's CI has failed since 14:47 on `robustness_goldens::all_3`: seed 55 (a
filleted box cut by spheres and patterned) used to end with an invalid body and
now passes the validity check, and its volume moved by 1.3e-5 relative.

The outcome changed between 2ae21143 (main's last green run) and 7f9074d6,
where the kernel meshing change is #1670 (twisted ruled faces divided to the
chord tolerance). The volume changed between 8a055afd and 31b2ddc0, where
#1753 changed truck-meshalgo's triangulation (the faces of an edge share its
points exactly). The kernel's validity and volume checks measure those meshes.
Pull requests run every 8th seed, so only main's full run saw seed 55.

Only seed 55 is recorded, with the values main's CI printed on Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
2026-10-16 18:02:21 -04:00
Mateusz Sych 920ea42fe1 Revolve: a partial turn's planar faces come out whole (#726) (#1718)
Thanks @SychPL.
2026-10-16 15:26:10 -04:00
Mateusz Sych 31b2ddc0cf Split Body: the face a plane split cuts takes an attachment that follows the plane (#565) (#1746)
Thanks @SychPL.
2026-10-16 13:26:11 -04:00
phanoob 64b0b674d2 2D prism route: the same letters cut again are one exact boolean (#1644)
Thanks @phanoob.
2026-10-16 06:26:19 -04:00
bherbruck 9d82b94469 Robustness goldens: record seeds 31 and 83 after #1581 and #1495 (#1657)
Seed 31: #1581 refuses a fillet selection with an earlier round's smooth seam instead of re-rounding another nearby edge. Seed 83: #1316 was recorded before its branch took in #1495, whose shell keeps the wall beside a concave edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:55:20 -04:00
phanoob 9bb65b4d68 Booleans: a prism cut beside the target's other faces takes the exact 2D route in a column (#1629)
Thanks @phanoob.
2026-10-16 03:26:13 -04:00
H. Conrad dc6eca6830 Fix/shell saddle corners (#1316)
Thanks @Ian-cmd-ops.
2026-10-16 01:19:42 -04:00
Erikas b20fd3d9d6 Booleans: when the last-resort nudge fails both ways, try two more screw axes (#1490) (#1527)
Thanks @erkexzcx.
2026-10-15 22:19:15 -04:00
H. Conrad 723c7b61c4 Boolean: cut around a part's sealed voids when the whole cut fails (#1570)
A cut clear of every void cuts the outer shell alone and puts the voids back (robustness seed 15 now builds).
2026-10-15 22:18:31 -04:00
phanoob 6ad391fd75 Chamfer: refuse a result that grows a convex edge's part (#1428)
* Chamfer: refuse a result that grew a convex edge's part (or shrank a concave one's)

A chamfer of a curved edge that runs off a face it skims (robustness seed 57's
ball rim) built a part bigger than before. Checked from the two faces' normals
at the edge; rounds and unclear angles are left to the wedge bound.

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

* Action matrix: the sheet metal tray's relief corners refuse a 0.25 mm chamfer

Each is a 2.5 mm concave edge (the corner of a corner relief, between two
planes at right angles): a chamfer there fills at most 0.25²/2 × 2.5 = 0.08
mm³. What was built took 0.95 mm³ away; the chamfer guard now refuses it.

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

* Robustness goldens: seed 57's wrong-way chamfer is now refused (#1390)

On main with #391, seed 57's chamfer grew the part; this refuses it, so the run goes on
to its later steps and ends ok.

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-15 14:26:30 -04:00
phanoob 56747edb46 Boolean: a point at a boundary curve's end is that end, whatever its parameter (text cuts, #391) (#1195)
A point within the point tolerance of a boundary curve's end is that end in the boolean, whatever its parameter, so text cuts no longer leave unmatched sliver vertices (#391). Robustness goldens re-recorded for seeds 0, 34, 39, 41 and 57 with reasons.
2026-10-15 13:31:37 -04:00
Karl Von Richter 8a6332be83 Fix face Extrude Join targets in assemblies (#1375)
A new face-based Extrude Join (and outward Press Pull) joins the picked face's body by default and stores that scope, so an edge-touching neighbour is no longer tried first or absorbed. Explicit targets and participants still win. Related to #226.
2026-10-15 08:33:48 -04:00
Giovanni faeeff1c64 Blends: a curved seam where the edge chain runs on holds the contact on itself (#1291)
A round or chamfer whose edge chain runs on across a curved seam (the end circle of a lofted tube whose axis bends) is now built: the section solve keeps its contact on the seam through a plane taken afresh at the seam's nearest point at each step. Straight seams are unchanged. Robustness seed 41 is re-recorded: its second fillet, refused before, now builds. Fixes #1213.

Co-authored-by: gvocale <gvocale@users.noreply.github.com>
2026-10-15 01:19:51 -04:00
bherbruck 5c2fa3e9ca Action matrix regressions of #1255: knuckle rounds, thin holes, a 45° flange hole, an axis after an edit (#1280)
* Action matrix regressions of #1255: knuckle rounds, thin holes, a 45° flange hole, an axis after an edit

- Curve blends: past the end of a curved edge the run of sections along the end
  tangent wrapped round the ring of centres; it is shortened while it still
  crosses the end face (the hinge knuckle's cap rounds at 0.728 mm).
- Boolean retry guard: each body's volume is measured finer than its smallest
  radius, and a volume out of bounds is measured again ten times finer before
  it refuses (a 0.133 mm drill measured -30272 mm³; a plastic part's coarse
  mesh was off by more than the slack).
- Sheet metal: a drill cap's certificate allows the evaluation noise its fitted
  tilt shows (a hole square to a 45° flange).
- Construction geometry on a named face uses the face found by name, not a
  second pick at its nearest point (an axis through a corner round after a
  parameter moved it).

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

* Retry guard: measure the target, tool and result at one tolerance

Measured at different tolerances, the chords' error on the faces they share
didn't cancel: a blind hole in a long D-profile bar measured 20 mm³ more than
the bar.

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

* Action matrix baseline from this branch's full run: 37 actions pass again, none fewer

Blessed with cargo xtask action-matrix --from DIR --bless from the 16 shards of
run 38673859774.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:24:27 -04:00
bherbruck d73cd8a177 Robustness goldens: record seed 13 after #1164 (#1268)
#1164 joins a body that sits flush on a rounded rim into it. In seed 13 the
rectangular pattern's second box (x 5..7.5) sits flush on the half-ring's top
face with a 27 mm2 contact patch. It used to stay a separate shell (one body,
three shells); now it joins the ring (area -54 mm2, twice the patch). The
fillet edge and hole position the generator picks next come from the new
geometry, so the end state differs: 3 bodies, 2647.95 mm3 (was 4, 2300.91).
Every step stays a sound solid, and no other seed moves.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:54 -04:00
bherbruck c159a3e277 Action matrix: nightly shards cut by measured cost, a fresh baseline, and a determinism check (#1256)
* Action matrix: nightly shards cut by measured cost, heavy designs in slices, and a determinism check

The full matrix ran as four fixed quarters of the corpus and two of them hit the 60-minute
job limit. Now action_matrix_shard runs shard k of n (ACTION_MATRIX_SHARD=k/n), planned from
tests/action_matrix/costs.json: designs that take more than five minutes are split into slices,
and the pieces go largest first to the least loaded shard. The nightly runs 12 shards and uploads
each one's outcomes and timings; cargo xtask action-matrix --from DIR --bless records them in the
baseline and costs.json without running the matrix again. action_matrix_determinism runs a sample
twice and lists any action whose outcome changed.

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

* Action matrix: costs measured on runners, 10 shards, determinism on the lighter designs

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

* Action matrix: baseline and costs from a full run on main, 16 shards, timeouts listed

The baseline is regenerated from the 10 shards' results of a full run on 01b1b705
(cargo xtask action-matrix --from DIR --bless): 8 new designs, 302 actions that pass now,
names that changed, and 40 actions that passed before and fail now, each listed in #1255
(22 fillets refused since #949, 4 hinge blends since #1101, 9 on the loft cut, 5 single).
costs.json now holds every design's measured time, and the nightly runs 16 shards of about
17 minutes. An action that passes in the baseline and times out is listed but not judged,
and stays passing when blessed: two runs of the same build differed only on 10 actions that
took 50-84 s against the 90 s limit.

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

* Action matrix: slice heavy designs by a hash of each action's name

Slicing by index gave some slices only fillets on oracle 41 (28 slices, a multiple of the
actions per edge), three times the planned time; two shards ran past their limit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:11 -04:00
phanoob beb3ea01e7 Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept) (#1204)
Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept).
2026-10-14 17:55:58 -04:00
bherbruck 563bcaff5b Refuse fillets and chamfers that run past their faces or cross each other (#1080) (#1124)
* Refuse fillets and chamfers that run past their faces or cross each other (#1080)

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

* Blend fit: chamfers keep their old acceptance of crossed loops; skip the volume bound along tangent chains

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

* Blend fit: one selection per check, a coarse volume pass first; record the refused oversize blends in the action matrix baseline

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:46:44 -04:00