Files
solvecraft/CONTRIBUTING.md
T
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

29 KiB
Raw Blame History

Contributing to SolidCraft

Agents: start from the router in AGENTS.md, which points to a short skill in skills/ for each kind of work. This page stays the full human guide.

SolidCraft is an open-source parametric 3D CAD app written in pure Rust. You sketch on a plane, constrain the sketch, turn profiles into solids, and change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, macOS, Linux), in the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can model with.

Help is welcome, whether that's a one-line fix or owning a whole area. This page covers how to build it, how the code is laid out, the few rules that aren't negotiable, and where help is needed most.

Before choosing a task, check the command inventory and roadmap for implemented capabilities and current priorities.

Questions, ideas or want to say what you're working on? Join the Discord.

Kernel work is paused

We're evaluating a new geometry kernel. Until that's decided, please don't open issues, pull requests or discussions about kernel work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES import and export, tessellation, or anything else that changes crates/kernel/. The pinned issue on the tracker explains it. New ones get the kernel-paused label (.github/workflows/kernel-paused.yml) and a maintainer answers them. Crashes and data loss are still taken as issues, and everything outside the kernel carries on as normal.

Get it running (about 10 minutes on a fast machine)

You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web build you also need rustup target add wasm32-unknown-unknown and trunk (documentation).

git clone https://github.com/tangency-labs/solidcraft && cd solidcraft
cargo run --release -p solidcraft -- --sample      # desktop app with a sample part
cargo xtask test                                   # unit, engine and UI-scenario tests
cargo xtask ci                                     # everything a PR must pass (see below)

SolidCraft is the product, repository, Cargo package and executable name. Build the desktop app with cargo build --release -p solidcraft, or the CLI with cargo build --release -p solidcraft-cli. Run the CLI with cargo run --release -p solidcraft-cli -- commands (or target/release/solidcraft-cli commands after building). The old name appears only in the compatibility shims listed in docs/compatibility.md; don't add new uses of it.

Web: cd apps/solidcraft-web && trunk serve --release, then open the printed URL (?sample opens the sample part, ?webgl forces WebGL2). Saving and opening files in the browser needs HTTPS or localhost, because it uses the Origin Private File System.

Windows from Linux: cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft.

NixOS: nix develop

NixOS has no /usr/lib, so nothing is on LD_LIBRARY_PATH and a plain cargo run finds none of the libraries the app loads at run time: the Wayland ones winit opens the window through, the EGL loader wgpu renders through, and libdbus-1, which rfd's file dialogs reach xdg-desktop-portal through (a dialog then never appears and the pick returns no path):

ERROR solidcraft: winit EventLoopError: ... The wayland library could not be loaded
ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to create surface for any enabled backend: {}
ERROR rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found

nix develop (the flake.nix in the repository root) supplies Wayland, X11, EGL, Vulkan-loader and D-Bus runtime libraries. It also exposes the NixOS driver manifests in /run/opengl-driver/share, so Vulkan can discover the system drivers. Enable hardware.graphics.enable on NixOS; other Linux distributions may need a matching nixGL wrapper. The default development shell retains the Rust/C toolchain already on PATH; nix develop .#rust supplies the pinned toolchain and builds in target/nix.

To launch, use nix run .#solidcraft -- --sample. To install the app's terminal commands, desktop entry and generated icons, use nix profile install .#solidcraft. nix build alone creates a build result; it does not install a launcher in your desktop menu. The installed solidcraft command runs the wrapped app; solidcraft-cli is included as well. See Nix installation and troubleshooting for remote-flake commands, desktop integration and how to distinguish an old executable on PATH.

How the code is laid out

The architecture page has the layer table. The short version:

  • crates/geom: vectors, planes, meshes, measures.
  • crates/sketch: our own constraint solver, plus profile finding.
  • crates/kernel: the B-rep kernel boundary. It wraps truck, and it's the only crate allowed to touch truck. Fillets, chamfers, shell/offset, Delete Face, Split Face, STEP/IGES read and write, and face provenance are our own code here. Patched copies of three truck crates live in vendor/ and are excluded from our fmt/clippy rules.
  • crates/doc: the document. It holds parameters and expressions, the feature timeline and its incremental rebuild, components and occurrences, joints, appearances, and persistent naming.
  • crates/io: design files and mesh formats (3MF, STL, OBJ).
  • crates/engine: the session and the command registry. Everything the user can do is a command, and the toolbar, dialogs, scripts, CLI, control channel and MCP all run the same commands.
  • crates/ui-egui: the desktop/web front end (egui + wgpu). It's swappable; nothing below it depends on egui.
  • crates/mcp, apps/solidcraft, apps/solidcraft-cli, apps/solidcraft-web.

cargo xtask layers fails the build if a lower layer depends on a higher one.

Rules that aren't negotiable

  1. Independent. SolidCraft is independent. Behaviour is designed from public documentation, general CAD conventions and user feedback. Reference tests use analytic values and open-source tools. We don't drive, script, automate or extract data from commercial CAD products, and we never copy another product's code, icons, artwork, help text, tooltips, templates or materials. Command ids are our own (solid.fillet).
  2. No GPL/LGPL/AGPL code, not even reading it. That rules out OpenCASCADE, FreeCAD (including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolidCraft is MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an open-source application to get reference values for a test is fine; reading its source is not.) Permissive dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt lists it once the change lands: the Generated files workflow rewrites it on main. Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see the local command and coverage limits.
  3. Every asset is original or openly licensed and has a row in ATTRIBUTION.md. Icons are drawn in code (crates/ui-egui/src/icons.rs). cargo xtask assets enforces this.
  4. Never crash. People trust a CAD app with their work.
    • Non-test code never uses unwrap, expect, panic! or indexing on input-derived data. There's no unsafe outside apps/solidcraft/src/open_documents.rs (macOS file-open events), apps/solidcraft/src/native_crash.rs (Windows native crash capture) and crates/threedx-client/src/macos.rs (the 3Dconnexion driver's client API on macOS), and the compiler denies it.
    • An unsupported case returns an error ("not supported yet: …"); it never panics.
    • Every call into truck goes through solidcraft_kernel::guard.
    • Every crash fix comes with a regression test.
  5. Everything is a command, with a test. A new feature means a CommandSpec in crates/engine/src/cmd/ with input and output schemas (see Adding a command; a test warns about a command without them, and will fail it once required), engine tests that measure the result (volume, area, face/edge/vertex counts against a value you can derive), and for UI work a scenario (below). A command on the toolbar also ships with its help tooltip (step 6 of Adding a command).
  6. One set of camera controls. Every page or canvas that shows a view (the modelling viewport, the Render preview, and any new page such as a flat pattern or drawing) calls navigation::handle (crates/ui-egui/src/navigation.rs) for its camera input, with NavCaps saying whether it can orbit (off for flat 2D pages) and pick. New canvases must use navigation::handle; never read drags, the wheel or pinch to move the camera yourself. The hand_rolled_camera_input_is_rejected test fails if a page does.

Labels, parameter docs, messages and docs use the terms in the glossary: one term for each idea. A change that adds a new idea adds its term there.

AGENTS.md has the long form of these rules. It's written for AI agents, but it's the most complete description of the house style.

Tests

What Where Run
Unit and engine tests next to the code, or *_tests.rs files cargo xtask test (or cargo test --workspace --profile ci)
UI scenarios crates/ui-egui/tests/scenarios/*.json part of cargo test
Reference tests cargo xtask oracle tests/oracle/ (see below)

A geometry change that changes the robustness goldens re-records them in the same pull request: ROBUSTNESS_GOLDENS_BLESS="why it changed" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md); CI runs every seed on pull requests that touch geometry. docs/mutation-testing.md shows how to find kernel behaviour these black-box suites miss.

UI scenarios drive the real app headless: start a tool, click a plane or a sketch point, press keys, then assert on the result.

[
  {"start": "sketch.create"},
  {"click": {"plane": "XY"}},
  {"start": "sketch.rectangle.two_point"},
  {"click": {"sketch": [0, 0]}},
  {"click": {"sketch": [40, 30]}},
  {"expect": {"errors": 0}}
]

A new .json file in that folder is picked up automatically. To record a bug before it's fixed, put {"pending": "why"} in the scenario, and remove it in the PR that fixes the bug.

A failing test without its fix can't merge as-is: it would turn main's CI red for everyone. Either include the fix in the same PR, or mark the test #[ignore = "fails until #123 is fixed"] (it can then merge right away), and remove that line in the PR that fixes the bug.

Use the ci profile. CI runs the tests with --profile ci: light optimisation, line tables, no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It builds several times faster than --release. If a test fails only in a plain debug build, say so in the PR. cargo xtask test builds the tests with it and then runs every test binary at once (cargo test runs them one after another); arguments after -- go to each binary, e.g. cargo xtask test -- shell. Set SOLIDCRAFT_TEST_JOBS to a positive integer to opt into a shared bound on top-level test/side commands for cargo xtask test and cargo xtask ci. For example, SOLIDCRAFT_TEST_JOBS=2 allows two commands at once; the sequential wasm/doc worker consumes one of those slots. Every binary and side check still runs after failures. Without the variable the existing concurrency is unchanged. With it set, each test binary inherits RUST_TEST_THREADS and ACTION_MATRIX_JOBS of max(1, cpus / jobs) — the machine's parallelism shared between the binaries in flight — unless you set either one explicitly; explicit caller overrides always win. Cargo's build-job setting remains an independent caller override; this limit does not change test filters, timeouts or baselines. Tests run beside each other on a busy machine, so a test must not depend on wall-clock timing.

Ready pull requests test what they touch. CI maps changed files to workspace packages and runs Clippy, the test build and tests for those and their dependents. The package selector and CI workflow define which changes require native checks; documentation, packaging, other-workflow and bot-script-only changes run the repository checks only, on pull requests and on main. Workspace-wide changes, merge-queue groups, nightly runs and Run workflow select the full workspace. A push to main reuses a successful merge-queue run for the same commit, and runs only the repository checks when no code changed since the last push to main that passed; otherwise it runs the full suite. Doc tests, reference tests and the Windows jobs run on main and nightly only.

CI shards the tests. One job builds them into a cargo-nextest archive; two shards run on pull requests and four during full validation. Every test runs in its own process: don't rely on state left by another test. Pull requests skip a few heavy tests (one to twelve minutes each), which run in full validation (the merge queue, nightly, manual runs and pushes to main not already tested by the queue). Check that validation after merging. They are listed in .config/nextest.toml and the testing page:

  • solidcraft-engine: tests::hostile_params_never_panic, everything in tests::robustness and the seeds of tests::robustness_goldens (seed_00 … seed_95; pull requests that change the kernel, truck or the goldens run all but the slowest, SLOW_SEEDS in ci.yml), pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen, pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs, cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall, the atomic_commands contract tests
  • solidcraft-kernel: tests::boolean_identity_on_tangent_primitives, tspline::tests::hostile_tsplines_never_panic, and the boolean_props property tests (these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
  • solidcraft-ui-egui scenarios: acceptance_sensor_enclosure

The action matrix tries every ordinary action on every face, edge, vertex and body of a corpus of designs against a checked-in ratchet (docs/action-matrix.md). A pull request that makes an action pass should record it with cargo xtask action-matrix --bless; one that makes a passing action fail fails the sample every CI run checks.

The reference tests live in tests/oracle/, each a recipe.json in our own format with an expected.json beside it. SolidCraft rebuilds the recipe and compares body count, volume, area, centre of mass and bounding box (and, where there is a reference.stl, the surface itself). Expected values are either worked out in closed form (tools/oracle-analytic/) or computed by building the same recipe in FreeCAD, run as a separate program (tools/freecad-oracle/); each file says which. The generated reference test report has the current case count and results. The reference-test CI job runs cargo xtask oracle during full validation; pull-request runs skip it. A case that fails because of a known bug names its issue in expected.json ("known_failure": "#123"); never change an expected value to match SolidCraft's output.

Changing the design file format

A design file says which format it is in ("format": "solidcraft/N"). If your change saves something new in a design (a field, a feature type, a new kind of name), or makes older designs mean something else, it needs a format change. Add one file, and edit nothing shared:

  1. Create crates/doc/src/format/pending/<what-it-is>.rs (lowercase, dashes). Start it with a //! note: what is saved, what older designs get without it, and that the last release asks for an update instead of meeting it.
  2. If older designs must be rewritten as they open, put pub fn step(v: &mut Value) -> crate::Result<()> in the same file. Pending steps run in file-name order, after the older formats' steps.
  3. Don't touch FORMAT, JSON_STEPS, the history in crates/doc/src/format.rs, another change's file, or any v<N>/ folder: those are fixed once released.

That's all. The build counts the files: while any are pending it writes the next format number, which no release reads, so a release refuses a design that has your change instead of losing it. A file also lists the pending changes it has (format_pending), so a later development build runs only the steps that file is missing. Two pull requests that each add a change merge without a conflict. crates/doc/src/format/pending/README.md has an example.

At release time the maintainer runs scripts/fold-format-pending.sh, which moves the pending files into crates/doc/src/format/v<N>/ and fixes that number for good (see docs/releasing.md). Never add a file to a v<N>/ folder afterwards. Drawings follow the same rules with their own numbers, from 1, in crates/doc/src/drawing/format/ (see its pending/README.md), and the same script folds them.

Lists many changes add to

Some files gain an item in many pull requests at once. They are kept so that two changes that each add an item only meet when their items are neighbours, which spares everyone a conflict and a fresh CI run:

  • Message catalogs (assets/locales/*.json): one message per line, sorted by id. Add your message anywhere and run cargo xtask lists --fix; cargo xtask lists (in cargo xtask ci and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
  • mod lines: one per line, in the sorted group they belong to (cargo fmt keeps each group sorted). Add a new module into the existing group, not as a new group at the end.
  • Commands without schemas (crates/engine/src/schema/unconverted.txt): sorted, one id per line, and it only shrinks. Converting a command removes its line; a new command is never added to it.
  • Toolbar commands without help (BACKLOG in crates/ui-egui/src/command_help.rs): sorted, one id per line, and it only shrinks. Writing a command's help removes its line; a new command is never added to it.
  • ui.prefs settings (docs/control-protocol.md): one line per setting, sorted by name, in the list under the protocol table; add a new setting on its own line rather than editing a shared row.
  • Languages (crates/ui-egui/src/i18n.rs): one per line, English first, then by id (a test checks it).
  • Generated files: THIRD-PARTY-LICENSES.txt and docs/features.md are rewritten on main after each merge by the Generated files workflow (cargo xtask licences, cargo xtask features). Don't regenerate or edit them in a pull request.
  • Design format: one file per change, never an edit to a shared number (above).
  • New UI strings and attribution rows: one new file per change, assets/locales/pending/<slug>.json and attribution/pending/<slug>.md, instead of editing the catalogs or the ATTRIBUTION.md table; cargo xtask fold-pending folds them on main (see the README in each folder).

Sending a change

  1. Work is tracked in GitHub issues, labelled by area (area:kernel, area:sketch, area:doc, area:ui, area:infra). For anything bigger than a bug fix that has no issue yet, open one first. Claim the issue with a draft PR before you start, so two people don't build the same thing:
    • Check nobody has it: gh pr list -R tangency-labs/solidcraft --state open --search "#N".
    • Branch from the latest main, commit git commit --allow-empty -m "claim: #N", push, and open a draft PR titled <summary> (#N), e.g. Fillet: variable radius along a chain (#123).
    • The lowest-numbered open PR for an issue wins. If yours lost, close it (or offer to help on the other one).
    • The bot records the claim when the PR is opened: it assigns you, adds in progress and says so on the issue. It reads #N in the PR title and Fixes #N in the body. If someone already holds the issue, or an older open PR references it, it says so on your PR instead.
    • Want to stake a claim before you have a branch? Comment /claim on the issue (below).
    • Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over: comment on it first, then open your own claim.
    • Mark the PR ready for review when it is done.
  2. Branch from main and keep the PR to one topic.
  3. Run cargo xtask ci before pushing. It runs fmt, the asset, licence and layer checks, clippy (-D warnings), then the tests with the wasm build check alongside. A PR has to pass it. Heavy CI runs after review: a pull request gets the formatting and repository checks and a quick compile (cargo check) when you push. Once a maintainer has reviewed it they add the approved-for-ci label when the PR is ready to merge, which runs the full CI (build, tests, goldens, Clippy); every push after that runs the full set again. Maintainers merge approved PRs in groups by area, so PRs that touch the same code meet a conflict at most once.
  4. Reference the issue (Fixes #12). In the PR, say what you tested and how. For geometry work, include the measured numbers (and the expected ones).

Commenting /claim assigns you, adds the in progress label, and replies with its automatic cleanup deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days without pushes. These automatic cleanup deadlines are separate from the daily progress and 72-hour takeover policy above. A draft PR that references the issue claims it the same way. /unclaim releases the bot claim early (only you or a maintainer can). Issues a maintainer has taken are never freed automatically.

Help reviewers see what the words mean. For observable CAD, UI or model behavior, please include actual descriptive screenshots or a short recording in the issue or PR. This evidence is required for changes or bugs that can be demonstrated in the app, including kernel, geometry, assembly and backend work. Coding agents should run a small reproduction and capture the evidence themselves; please don't ask a human for evidence the agent can produce. Show the actual failure or result, preferably before and after, with brief steps and expected/actual behavior. An arrow or annotation can help identify the detail that matters.

Flowcharts and Mermaid diagrams cannot replace evidence of visible behavior. Diagrams are useful for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior do not need screenshot or video evidence. Use a small example without private designs or sensitive details; attach the media or link to an accessible recording rather than committing generated review artifacts.

To show a picture without committing it, attach the media to the PR (it goes to the PR's own attachment storage and renders inline in the comment). A gist is the better home for a log or another text file, and you link it; a gist does not render a picture inline. The review artifacts stay out of the diff that way, and nothing has to be pushed to a branch of its own.

Attaching works in the browser for anyone who can comment: drag the file into a comment on the pull request. A command-line agent without a browser session usually cannot attach a file (the upload needs write access to this repository). Say so in the pull request, and the person running the agent or a maintainer can attach it.

Keep an eye on your PR's status, but don't push to it only to catch up with main (no merge of main, no Update branch button, no rebase for that alone): every push restarts the whole CI run for everyone waiting behind it, and main's own run catches what lands in between. When you push a change anyway, or GitHub reports a conflict, rebase onto the latest main (push your branch with --force-with-lease) or merge main in once, and resolve conflicts without losing others' changes. Don't re-run CI on an unchanged commit either: a real failure fails again. Re-run only a job that runner trouble cancelled, with "Re-run failed jobs". While a task remains active, follow an open PR through its status changes; at handoff, clearly identify anything still awaiting merge.

Much of SolidCraft so far was written by AI coding agents (Claude) working in parallel, each owning one area, with a human setting direction and reporting bugs. Their working notes are in AGENTS.md. Human contributors are just as welcome. Use whatever tools you like, as long as the change meets the rules above.

Two quick checks run on every pull request (.github/workflows/pr-checks.yml, not required): it fails when a lower-numbered open PR by someone else already closes the same issue (the label not-a-duplicate skips it), or when the description lacks the Changes a default: no / Changes a default: yes — <what>, old behaviour kept behind preference <name> line (docs-only and bot PRs are exempt).

Labels and triage

The triage bot labels every new issue and PR (.github/workflows/triage.yml). It only adds labels; correct them freely and it won't put them back.

  • PRs get area:* from the paths they change (crates/kernel and crates/geom → area:kernel, crates/ui-egui and the apps → area:ui, .github/, scripts/, docs/ → area:infra, and so on).
  • Issues get bug (in-app reports and the bug template), enhancement (feature requests), priority:P1 for crashes, and an area:* from the title or the reported command. Until it has a priority, an issue carries needs-triage; setting a priority removes it.
  • Maintainers comment /triage area:kernel P1 (any existing label; P0–P3 for a priority, which replaces the old one; -label removes one) to sort an issue or PR.
  • Short bug reports without steps to reproduce get needs-info and one comment asking for the steps (and the version, when it's missing). An app report that arrived empty is asked to paste the report the app copied to the clipboard. The reporter's reply, or an edit that adds the steps, removes the label; an issue that stays in needs-info for 14 days is closed, and can be reopened any time. Feature requests, app reports with their details, and maintainers' issues are never asked.
  • needs-triage is the queue: open issues with it.

Where help is wanted most

Kernel speed (slow fillets, booleans, STEP): please don't rewrite the current kernel code for speed; it is being replaced by solidcore (progress). Report slow cases with a file under the kernel-perf label, and put kernel effort into solidcore instead. See #1597.

See the help wanted and good first issue labels. In order of how much they'd help users:

  1. Fillet and blend robustness. Fillets are our own code (truck has no fillet operation). Constant, variable-radius and chord fillets work, and so do corners where fillets of different radii meet, including three rounds meeting at a vertex. Next are setback corners, fillets along curved edges between curved faces, and fillets that consume neighbouring faces.
  2. Boolean robustness. A random-design fuzzer currently gets about half of its designs through without a kernel error. Each failing seed is a reproducible bug.
  3. Drawings, CAM and mesh tools. These are out of scope for now (see ROADMAP.md), but anyone who wants to own one is welcome.

Updating the generated feature list

After adding or renaming commands, run cargo xtask features. The registry is the source of truth for docs/features.md; do not hand-edit the generated rows. The generator sorts commands by ID and omits global and per-section counters, so additions to unrelated areas no longer rewrite shared summary lines.

If two branches still conflict in this file (for example, adjacent additions in the same area), first resolve the command registry and source conflicts, then run cargo xtask features again and stage the regenerated file. Do not use Git's union merge driver: it can silently retain stale or duplicate command rows. The generator tests cover stable ordering and additions that leave existing lines unchanged. The initial switch to sorted rows is a one-time larger diff.

Licence

By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest of SolidCraft. SolidCraft isn't affiliated with any CAD vendor; product names are trademarks of their owners (see NOTICE).