Compare commits

..
164 Commits
Author SHA1 Message Date
bherbruckandClaude Opus 5.5 a9879fa668 Release 0.7.0 (#508)
Bump the workspace to 0.7.0 and write the 0.7.0 release notes. Minor bump: the design format moved to 15 (sketches on cylinder caps and sheet metal panels), so 0.6 can't open 0.7 saves, and new features landed (Select Other, per-edge flanges, sketches on caps and sheet metal faces).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 23:15:08 -04:00
bherbruckandClaude Opus 5.5 45bc22fc30 fix: faces of an extrude drawn over a face's projected edges keep an exact source (#505)
A profile traced over a face sketch's projected boundary left the extrude's caps and sides without an exact planar source, so a sketch or offset plane on the new top face was refused. The drawn curve is now the edge's source where it covers a projection. Clearer fixed-plane message; regression tests in the engine, UI harness and design corpus, plus a corpus-wide flat-face attachment check (known gaps under #491-#495).

Fixes #489

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 23:01:35 -04:00
bherbruckandClaude Opus 5.5 4aaa5457b3 feat(ui): Select Other — pick what lies under something else (#503)
Hold the left button still (long-press on touch) or right-click › Select Other… to list everything under the cursor: sketch points and curves ("Line 3", "Point (arc 2 centre)"), vertices, edges, faces, bodies, profiles, planes and axes, filtered like a click and by the open dialog's active input. Hovering a row lights it up; clicking selects it (Shift/Ctrl add) or feeds the dialog. A repeat click on the same spot with nothing changed selects the next thing there; a double-click keeps its own actions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:56:57 -04:00
bherbruckandClaude Opus 5.5 1fd4a2f782 README demo for 0.6: a Raspberry Pi 4 case (#504)
Replace the 0.2.1 enclosure recording with a 40 s take of the current desktop UI building a Raspberry Pi 4 case: a sketch dimensioned with the board's parameters, extrude, fillets and shell, an M2.5 standoff patterned on the 58 x 49 mm hole rectangle, port cut-outs, floor vents, a colour, then height 24 -> 32 mm in Parameters. The demo script gains the pi story (now the default).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:56:53 -04:00
onenewcode 00baf1d883 fix: follow feature renames in references (#486)
Fixes #401
2026-10-12 22:39:13 -04:00
MqriusandClaude Opus 5.5 91de39d3fe fix(kernel): revolving adjacent profiles on the axis makes no zero-area faces (#490)
Profiles revolved together merge into one region that keeps a vertex where
each shared edge met the axis. With the axis edge in several pieces the loop
skipped the builders for a profile with an edge on the axis, and the generic
sweep turned each piece into a degenerate face that booleans failed on.
Runs of collinear axis lines are merged into one first.

Fixes #487

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:38:48 -04:00
bherbruckandClaude Opus 5.5 219816d282 Name untitled designs after their file (#483)
* fix: name an untitled design after its file on save and open

Design tabs all read "Untitled" because a design's name only ever came
from file.new. Saving a design still under the default name now names it
after the file (bracket.solidcraft -> "bracket"), and opening a file whose
stored name is the default shows its file name. Names chosen by the user
are kept. The rename is part of the save, so it is not an undo step and
does not leave the design marked as changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

* test: expect the file's name after saving an untitled design

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:19:26 -04:00
MqriusandClaude Opus 5.5 2880525e6b fix(ui): the Combine dialog previews without an error, so OK is enabled (#481)
The live preview also evaluates each command as a new body, to show the tool of a join or cut
(as_new_body sets every `operation` to "new"). A combine has no new-body form: its operation is
between bodies that already exist, and solid.combine rejects "new" ("operation must be join, cut
or intersect"). That error became the preview's error even when the combine itself worked, and
since OK is greyed while the preview has an error, a combine could not be applied from its
dialog at all. as_new_body now leaves solid.combine alone, so its preview shows the result.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:50:05 -04:00
Irakli KourtisandClaude Opus 5.5 aa156f4a9f fix: report every fillet refusal of a radius too big as too large (#477)
`fit: reduce` acts only on refusals marked too large. Three kinds weren't:
the corner build's contact lines leaving their faces or running backwards,
and a curved chain's contact reaching past the end of the seam it trims. Two
more were hidden: a later edge's too-large refusal lost to an earlier edge's
other error, and the corner build's lost to the local blend's in `fillet`.
The messages are unchanged.

Refs #293

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:49:00 -04:00
Irakli KourtisandClaude Opus 5.5 ccc36dc1f9 fix: shell a body whose curved edge is rounded smaller than the wall (#476)
Moved in by more than its radius, a round collapses and its neighbours meet
along a new edge. That edge was always built as a straight line keyed by its
ends, so on a disc whose circular edge is rounded the four arcs where the floor
meets the side became one chord used by four faces, and the shell failed with
"This shell is not oriented and closed" (#319).

The new edge now runs where the edge's face meets the face across the round
(the round's far side, crossed as a strip), through the edge's middle moved
onto both: a line or a circular arc, checked to lie on both moved faces, and
refused otherwise. Twins are the new edges with the same ends through the same
middle.

Fixes #319

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:48:27 -04:00
bherbruckandClaude Opus 5.5 0fe032438f Note sheet metal panel anchors under format 15 (#482)
Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:41:58 -04:00
William Dunn 72c5d974b8 fix(ui): draw sketch constraint glyphs in red (#414)
* fix(ui): draw sketch constraint glyphs in red

Traditional CAD convention marks constraint glyphs in red. glyph_edge
and glyph_text in both themes now use red values. glyph_bg is
unchanged. The same glyph box switches to the error colour when a
constraint fails. The new reds are chosen to stay distinct from that
error colour. They are also distinct from the blue used for selection
and hover. See the pull request body for the before and after RGB
values and the contrast reasoning.

* fix(ui): match constraint toolbar icons to the red glyph colour

The CONSTRAINTS panel buttons and their drop-down rows drew their accent
strokes in the normal sketch tool green. They now use glyph_text, the same
red the matching constraint glyph box uses in the sketch view. A new
panel_accent helper in toolbar.rs picks the colour: the constraint red for
the CONSTRAINTS panel, the existing sketch green for every other sketch
tool, and the plain accent everywhere else. No other toolbar styling
changed.
2026-10-12 21:37:42 -04:00
Irakli KourtisandClaude Opus 5.5 201e9d5f9f Patch fills non-planar edge loops with one smooth face (#250) (#472)
* Add a one-surface fill of closed 3D loops to geom

A closed loop of curves that does not lie in a plane has no exact surface;
the usual answer is one smooth face fitted over it. fill_loop does that:

- the loop's best-fit plane (Newell normal), with a frame chosen from the
  loop itself so the result does not depend on where the loop starts, its
  direction, or where the part sits
- the loop seen along that normal must be simple and must not fold or run
  along the normal; otherwise it is refused ("not supported yet"), never
  filled with wrong geometry
- one non-rational bicubic B-spline height field over the loop's box,
  fitted by least squares to the loop's samples (The NURBS Book 9.4.1,
  9.4.3, eq. 9.62/9.65) with a thin-plate smoothness term
  lambda*A*int(h_uu^2 + 2 h_uv^2 + h_vv^2), A one grid cell's area, so the
  fit is the same at any scale; only planes are reproduced exactly
- span counts per direction (long narrow loops fit), refined until the
  loop is within tolerance, else refused with the deviation reached

bspline: bisected span search, basis functions on a stack array, one
clamping helper shared by curves and surfaces, one binomial helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

* Patch fills non-planar loops of edges with one smooth face (#250)

surface.patch with edges refused any loop that is not planar ("not
supported yet: patching a loop that is not planar"). It now fills it:

- planar loops still get one exact planar face, but planarity is certified
  conservatively: lines by their ends, B-spline/NURBS edges by their control
  points, intersection curves by their samples lying on a support plane
  within tolerance; a loop that can't be certified is filled instead
- other loops get one face from geom's fill_loop, bounded by the body's own
  chained edges, whole (no split): its history is one generated face whose
  edges are the body's, and it stitches back into a closed solid
- intersection-curve edges are sampled by a seeded, guarded double
  projection shared by the planarity test and the fill (plane_fit)
- the face tolerance is 10x the planarity tolerance (documented: later
  operations take the face to contain its edges within it)
- edge picking runs inside the guard; the geom->truck conversion is shared
  with the T-spline sheets (spline_conv); the Newell plane fit is shared with
  STEP import

Tests: 3- to 8-sided, concave (dart, arrow, plus), flat-corner, two-edge and
imported rims fill as one face and stitch closed (volumes against an
independent integral); finite mesh normals, same result for any pick order
or placement; non-height-field loops and too-fine detail are refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

* Test the Patch dialog filling a non-planar loop of picked edges

The Patch dialog's only test used a sketch profile. Picking the four
curved rim edges of a box cut by a sideways cylinder now makes one
surface.patch command whose result is a single smooth face over the rim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:37:01 -04:00
NOWN-DEVandClaude Opus 5.5 af0440aaab feat: sketches on a sheet metal face attach to its panel (#474)
A sketch on a flat face of a sheet metal body was refused ("this face has no exact planar
source"), so the only way was a plane frozen in space. When the sheet changes (a flange's
angle, the thickness), the frozen sketch stays behind: a cut from it fails on the slanted
flange or leaves a blind pocket in the thicker sheet.

`sketch.create` with a face of a sheet metal body now attaches the sketch to the panel the
face belongs to. It keeps the face's name, `sheet:<body>:<panel>:<top|bottom>` (the panel is
the base or a flange's feature tag), and takes the panel's exact plane from its flat region
and fold each time the model is evaluated, so the sketch follows the sheet through parameter
changes, unfold and refold. Its coordinates are the flat pattern's (the bottom face's y
mirrored). A face that is gone is reported as a lost attachment, never replaced by the
nearest face. The attachment follows a renamed or copied body, shows as `sheet_face` in the
command result and as its own status in Redefine Sketch Plane. `fixed: true` still freezes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:36:58 -04:00
Irakli KourtisandClaude Opus 5.5 49956c2797 Sketch on a cylinder primitive's caps; format 15 (#460)
* Sketch on a cylinder primitive's caps (#420)

A solid.cylinder's caps carried no construction certificate, so a sketch
on its top or bottom was refused unless fixed:true (#420's crossed
cylinders, built from primitives).

- kernel: FaceSource::CylinderCap { producer, high, plane };
  Body::with_cylinder_certificate turns a cylinder's two extrude caps into
  certified caps. kernel::cylinder's output is unchanged for its other
  callers.
- doc: FaceCertificate::CylinderCap { high }, named F<id>:top / F<id>:bottom
  as the cylinder's face names already are; New Body and Join cylinders
  are certified, shells carry it as their planar source.
- format 15: designs can store the new certificate kind; version 14
  designs need no change, and older builds ask for an update instead of
  meeting an unknown kind.

Tests: top and bottom attachments follow a height and base edit and a
reopen; crossed joined cylinders keep both caps attachable; a shelled
cylinder's floor keeps its cap identity; malformed certificate JSON is
refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

* Format 15 also covers sketch_axis links (#448)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:32:07 -04:00
selune b24e478bc1 Read design metadata and previews through a bounded seekable archive (#462) 2026-10-12 21:27:49 -04:00
NOWN-DEVandClaude Opus 5.5 9ab25cfd4a fix: sketches on planes built from the model can project, intersect, include and hold text (#459)
Project, Intersect, Include 3D Geometry and Text resolved the active sketch's
plane without the model, so on a construction plane that is built from geometry
(perpendicular to a plane through a sketch line, through three points, midway
between two faces, tangent, along a path) they stopped with "this plane is built
from the model: evaluate first". Evaluation resolves the same plane against the
model before the sketch, in its component's frame; the sketch commands now do
the same, in one place, for adding a link, refreshing the links on an edit,
snapping a point onto an edge and the internal face profiles.

Tests: a sketch on a plane through a sloped sketch line projects a point and a
line of that sketch, follows a parameter that tilts the plane (also after reopen
and after another edit), holds text, intersects a body and includes an edge; the
same on a three-point plane and a midplane.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:27:28 -04:00
Irakli KourtisandClaude Opus 5.5 1ae6e9beb4 Ctrl+left drag on empty space orbits in every navigation preset (#471)
A left press with Ctrl (or Cmd) held, no Shift or Alt, on empty space orbits
for that press, unless the preset binds Ctrl+left itself. It only orbits
once the pointer moves: a Ctrl+click still adds to the selection, and
Ctrl+drag on sketch geometry still drags it without snapping. Works with a
tool active, without adding geometry. Ctrl+box selection from empty space
becomes the orbit; Shift+box selection still adds.

Preferences › Navigation and the UI tour mention it. Test drives every
preset through real pointer events.


Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:27:20 -04:00
Irakli KourtisandClaude Opus 5.5 fc1d70cc3b docs: describe the sketch solver as it is (#475)
The README called the solver damped Gauss–Newton. It now places what the
constraints fix by construction (loci intersections and rigid-cluster
decomposition, after Owen 1991 and Fudos–Hoffmann 1997) and runs
Levenberg–Marquardt on the rest, with the rank of its Jacobian giving the
remaining degrees of freedom. The constraint list also gains the ones it was
missing (point on curve, curvature, point-to-line, arc sweep, arc length, slot
length), and live drags are described.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:26:54 -04:00
bherbruckandClaude Opus 5.5 dd67472252 Orbit from empty space turns around the visible geometry (#464)
With "Orbit around: Point under the cursor", a drag that starts over
empty space used to pivot on the point under the cursor at the target's
depth, so an orbit begun near the edge of the view swung the model
around an off-screen point. It now pivots on the centre of the visible
bodies and sketches (the same bounds Fit frames), or on the view centre
when nothing is visible. A drag that starts on the model is unchanged.


Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:01:49 -04:00
bherbruckandClaude Opus 5.5 9062c739d8 docs: link preview cards for the docs site and web app (#457)
* docs: add OpenGraph and Twitter card tags to the book and web app

Links to the docs site and the browser app now unfurl with a preview card.
The book gets the tags through theme/head.hbs (og:title follows the page
title); the web app's index.html carries its own, pointing at /app/. Both
use the social preview image, now served from the book at /solidcraft/og.png.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

* docs: list the social preview image in ATTRIBUTION.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:45:21 -04:00
NOWN-DEVandClaude Opus 5.5 fb4c1d74b9 feat: edge flanges with their own angle and height per edge (#447)
One edge flange feature gave all its flanges one height and one angle. A
part whose flanges lean differently took one feature per angle, and
flanges of separate features don't close toward each other, so its
corners stayed open.

- sheet.flange (edge): an entry of `edges` is a point [x, y, z] as before,
  or {point, angle?, height?} whose angle and height (expressions, checked
  like the feature's) replace the feature's for that edge. Bad input is
  refused with the edge named. Before, the object form was read for its
  point and the rest was ignored without a word.
- The feature stores its edges as FlangeEdge. An edge without values of
  its own is written as the plain point it always was, so designs without
  them are read and written as before; an unknown key in an edge is
  refused, not dropped.
- The edges' values are feature inputs of their own (Edge angle, Edge
  height, after the feature's), so parameters, renames and
  parameters.change reach them. timeline.edit names the edges again when
  it changes them, so each value stays with its edge.
- SheetBody::add_flanges_each trims every edge by its own setback and
  adds the flanges. The corners between them close as between equal
  flanges: the closing already works from the two flanges it joins (each
  one's fold and leg), so the flat pattern follows by hand from the
  angles, the heights and the rule (the tests derive it).
- The Flange dialog shows a row per picked edge with an angle and a
  height box (empty: the dialog's value) once more than one edge is
  picked, and brings them back on Edit Feature.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:33:49 -04:00
Irakli KourtisandClaude Opus 5.5 0dee9deec1 Sketch constraints accept x / y for the sketch's own axes (#394) (#448)
`sketch.constraint.symmetry` (line), `parallel`, `perpendicular`,
`collinear`, `tangent` and `coincident` (point on curve) now take `x` /
`y` for the sketch's own axes, matching what `solid.revolve` means by
lowercase x|y. A real curve with that id still wins.

An axis becomes a linked construction line on it: a new
`LinkSource::SketchAxis` resolved in sketch coordinates, so it stays on
the sketch's axis on any plane, is shared by every constraint on that
axis, is left out of profiles and is reported in `created`. Equal,
concentric, midpoint and horizontal/vertical keep refusing axes, since
the axis line's finite length or fixed direction would make them
meaningless.


Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:31:35 -04:00
Irakli KourtisandClaude Opus 5.5 722bd007c8 Join keeps exact face attachments on faces it leaves unchanged (#420) (#453)
A Union dropped every construction certificate, so after any join no face
could take a durable sketch: sketch.create failed with "this face has no
exact planar source" even on a face the join never touched (#420, and the
"CREATE SKETCH: operation failed" reports #344, #195).

- kernel: map planar sources through a Union from both operands, with the
  same exact-support rule Cut uses. Coplanar supports of the two operands
  carry distinct identities, so a face merged from both stays without one.
- doc: Join extrude and Join box tools are certified like new bodies; a
  cap moved off its designed height (into the target, or past a curved To
  face) is not certified. Join producers are accepted when certifying.

Tests: both operands' faces attach and follow an upstream edit and a
reopen; a merged coplanar face is refused and fixed:true works; a face a
join covers whole is lost without moving to the new roof. Tests that used
a joined face as their "uncertified" example now use a merged face; the
trimmed-face case in a_sketch_on_a_face_stays_on_that_face now stays
attached to the surviving piece.


Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:31:32 -04:00
NOWN-DEVandClaude Opus 5.5 9e60080b10 fix: fit the plane of a thin sewn face about its own centre (#441)
kernel::sew refused a solid with a thin planar face away from the origin: a slab
30 x 2 mm and 0.00013 mm thick with a corner at (-300, -200, 5), turned 0.45 rad
about x, failed with "sew: face 1: cannot attach a plane to a wire that is not on
one plane", though its faces are flat to 1e-14 mm.

truck's plane fit sums the cross products of a boundary's control points about a
centre and takes the points' sum for that centre (it divides by the number of wires,
not of points): eight times as far from the origin as a rectangle of four lines. The
sum is the same about any centre, but its rounding grows with the square of the
centre's distance, and on a thin face, whose normal is short, it turns the normal far
enough for truck's own 1e-6 flatness test to fail: truck finds the slab's four thin
faces 3.0e-7 to 2.1e-6 mm out of the planes it fitted.

A planar face keeps truck's plane wherever truck gives one. Where truck refuses, the
same fit is made on a copy of the boundary moved so that the mean of its control
points is the origin, and the plane is moved back; a boundary that leaves its plane
or has no area is refused as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:27:13 -04:00
NOWN-DEVandClaude Opus 5.5 455ca86a6c fix: write each lump of a body as its own STEP solid (#455)
A body of several pieces (a plate and the islands a cut leaves inside its
holes, such as letter counters) was written as one BREP_WITH_VOIDS whose
other shells were voids. Export now writes one solid per lump, each with
its face colours; a real void stays a void of its lump.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:27:09 -04:00
bherbruck 6fde5ff55b Update funding information in FUNDING.yml 2026-10-12 20:26:45 -04:00
MqriusandClaude Opus 5.5 7f92093cc8 fix(kernel): revolve with edges on the axis is built around the origin, then moved (#452)
A full revolve of a profile with more than one edge on the axis (a spherical shell's half ring:
both radial lines lie on it) took the generic sweep, built in place. Truck tessellates the
degenerate faces those edges sweep reliably only with the axis through the world origin, so on a
sketch plane off it the body measured about a third too large (a shell from r 18.5 to 40, 10 mm
above XY: 324 339 mm³ instead of 241 561), and booleans with it failed or removed the wrong side.

Such profiles are now swept about an axis through the origin and translated into place, as
revolve_touching_axis already does for profiles with one edge on the axis.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:25:46 -04:00
selune ddd528abaf fix(ui): accept toolbar drops across panel labels and gaps (#454) 2026-10-12 20:25:38 -04:00
selune 5c7149e57d fix(ui): put Point on the default sketch toolbar (#445) 2026-10-12 20:13:31 -04:00
bherbruckandClaude Opus 5.5 809ecf781e Version 0.6.0 (#444)
Minor bump: the SolidCraft rename (#389) and the design container format (#327) both change
the files people work with; builds 0.5 and older can't open designs saved by 0.6. Release
notes cover the new format, what's new and the upgrade from SolveCraft.


Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:53:39 -04:00
Irakli KourtisandClaude Opus 5.5 01bf0e4eaf perf: pattern joins skip pushing apart faces that don't meet (#437)
Pattern copies of a post joined to a body already holding the original post had
their tops in its plane, so every join first pushed faces apart (about 190 ms,
then discarded). Faces now coincide only where their boxes meet, the push-apart
retry runs once per boolean instead of twice, and splitting prism outlines skips
the sampled arc projection for points off the arc's circle.
Sensor enclosure evaluation: 3.07 s -> 1.63 s, same bodies.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:35:43 -04:00
NOWN-DEVandClaude Opus 5.5 0d995c6a78 fix: lines and arcs that touch meet at one point in planar booleans (#432)
The planar arrangement solved a line and a circle, or two circles, for their two
crossings even where the curves only touch. At a tangent contact the two roots are
rounding noise spread along the curves by its square root: a 1e-12 mm error in the
centre's distance from the line puts them 2e-6 mm either side of the contact on a
2 mm arc, well inside the tolerance of both curves; when the noise falls the other
way there is no root at all. Cutting a rounded corner (an arc tangent to the two
edges it ends on) out of a plate with planar_regions_boolean then ran that far along
the plate's edge past the arc's end before turning onto the arc: a 200 x 37 plate
turned by 0.25 degrees came back with six edges, one of them 3.45e-6 mm long.

Curves that touch within the arrangement's tolerance now meet at the one point their
centres give: the foot of the circle's centre on the line, or the point on the line
of centres of two circles tangent inside or outside. Circles of nearly one radius
about nearly one centre keep the general formula.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:22:39 -04:00
selune 006a23a767 Add design containers with deduplicated blobs and JSON compatibility (#327)
* Add fast design containers with lossless deduplicated payloads

* Keep display caching opt-in to avoid default save overhead
2026-10-12 19:15:41 -04:00
8e0c3d2794 feat: fold sheet metal cut-outs across bends at any angle or curved (#425)
A cut-out made in the flat pattern could cross a bend only with edges
running along the bend or square to it: a round hole or a slanted slot
over a bend line failed to fold ("not supported yet: curved cut-outs
across a bend", "... cut-out edges running across a bend at an angle").

Sheet: a flat edge in a bend zone now folds whichever way it runs: along
the bend to a line, across it to an arc, and any other way to a curve on
the bend's cylinder (a slanted line to a helix). The curve is carried as
a chain of cubic Hermite pieces fitted within 1e-7 mm on both faces of
the sheet, with breaks that depend on the flat edge only, so the top
face, the bottom face and the wall share them. The wall between the two
chains is a ruled surface with radial rulings: the cut-out's wall stays
square to the sheet. The bend's own faces stay exact cylinders, now sized
to the whole boundary of their cell (a curved edge may bulge past its
corners).

Kernel: such an edge has no line, arc or conic form, so the sewing
interface gets EdgeSpec::Chain (a chain of cubic Bézier pieces: a cubic
B-spline with triple inner knots, its ends on the shared vertices) and
SurfSpec::Ruled (two chains joined by straight lines, so a wall built
from the chains holds them exactly). The sheet module's rev_edge becomes
EdgeSpec::reversed, so the new variant is handled where edges are
reversed.

The test for "along" and "across" is in millimetres (1e-6 mm at the
edge's ends) instead of relative 1e-9, so coordinates rounded by a script
fold as drawn.

Tests. Kernel: a box with one wall bulging as a cubic (chains of two
pieces at the bottom and the top, the wall ruled between them) sews to a
valid solid of the analytic volume, 27.6 (measured 27.59965); chains with
the wrong number of control points, a NaN or too many pieces, and a ruled
wall between chains of different piece counts, are refused. Sheet: a slot
turned 30° and a round hole across 72°, 90° and 108° bends fold to valid
solids that lose the cut-out's flat area times the thickness (K = 0.5:
400 and 100.53 mm³, measured within 0.02); a slanted flat edge folds to
its exact helix within 1e-7 mm (measured 1.9e-8) on both faces; lines
along and across the bend keep their line and arc. Engine:
fold_through_cut_outs now expects a round hole across a fold to fold, and
to unfold to the same round hole; the per-bend machining guard (#296)
sews a bend that carries a round cut-out, so a hole beside it cuts and a
hole in the bend is still refused.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-12 19:03:22 -04:00
NOWN-DEVandClaude Opus 5.5 f34ac5881f fix: the sheet metal flat budget counts exact lines and arcs once (#430)
The flat-pattern point budget (16 384) counted every cut-out by its 0.001 mm
chords, arcs included, so a plate with 240 round 4 mm holes (about 100 chords
each, 480 exact arcs) was refused with "sheet metal flat geometry exceeds the
bounded processing budget" when the holes were cut in the unfolded sheet. The
planar booleans work on exact lines and arcs whole, so those now count once
each; a sampled curve still counts its chords. A second limit keeps the
chords of everything, arcs included, at 65 536, the same figure the planar
boolean applies to the sampling it does itself, so the work stays bounded.

planar_region no longer samples an exact loop only to discard the result: the
complexity check right above it already covers the same budget.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:58:45 -04:00
f290382498 fix(ui): wrap long toolbar panel labels and centre one-button panels (#409) (#410)
A panel label too wide for its panel (beside its caret) wraps between words
onto a second line; a single word wider than the panel stays whole. Each line
is centred, and the label's hover area grows to hold both lines. A panel's
buttons are centred in the panel, so a one-button panel such as Finish Sketch
lines its icon up with its label.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-12 18:57:20 -04:00
deb96318a1 fix: fillet fit review follow-ups (#404)
* fix: fillet fit review follow-ups

From the review of #388 (merged before these landed):
- The Fillet dialog's Applied Radius showed nothing when editing a fillet
  that was already reduced: the preview found the radius by diffing the
  states before and after. The engine's preview now keeps the command
  replies, and the dialog reads the radius of the feature they name.
- Pattern copies of a reduced fillet try the radius the original was
  reduced to first, instead of each searching again (up to 16 fillets).
- fit: reduce with a chord or variable radius is refused instead of being
  silently ignored.
- One helper appends a note to a feature's warning; too_large is imported
  like unsupported in the loop and curve blends.

Refs #293

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* Use the solidcraft_doc crate name after the rename

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-12 18:57:09 -04:00
11ecb64059 feat(ui): heading bands for dialogs, sketch status label, tighter palette footer (#418)
- frame::heading draws a panel's title row (heading_bg) or a section heading
  (subheading_bg) on a band darker than the dialog, in both themes. Every
  docked command dialog, the Sketch Palette and Redefine Sketch Plane use it.
- Sketch Palette: "Options" and a new "Sketch Status" heading are section
  bands; the status line sits under its own heading.
- Sketch Palette: Finish Sketch no longer centres in the leftover height, so
  the palette ends one line below it, one line after the status.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-12 18:55:57 -04:00
MqriusandClaude Opus 5.5 1d7450b04c fix(doc): To Object takes the face at the pick, not the first body near it (#429)
object_height, curved_target and curved_target_any took the first solid body with any face
within its pick tolerance of the To Object point. That tolerance is 5 % of the body's size,
so a large body beside a small target took picks made on the target: a ridge extruded up
to a post 2 mm above a plate treated the plate as the target, nothing trimmed the tool, and
it ran past the post (to x 9.08 instead of -2.31).

They now share face_at: the nearest face over every solid body, still within that body's
tolerance. appearance gains nearest_face (the face and its distance) and pick_tolerance,
which face_index_at now uses as well.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:55:47 -04:00
MqriusandClaude Opus 5.5 968a22897c fix(doc): extrude and revolve in a moved component from another component's sketch (#403) (#407)
* fix(doc): extrude and revolve in a moved component from another component's sketch

A feature built from a sketch of another component read the sketch's plane as if it were in
the feature's own component. When the two components are placed differently (a moved or
jointed part), the tool landed transformed along with the feature's component: a join on a
part turned half a turn about Z, from a root sketch, came out on the far side of the origin.

Extrude and Revolve now see the sketch through both placements, as sweeps along 3D paths and
sketches on another component's face already do (sketch_in_feature_frame, using
profile_in_frame). The sheet-metal rotated-frames test built its cap witness from a sketch of
the moved component while the root was active, relying on the old reading; it now builds the
witness in the sketch's own component, which is what it measures against.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test: To Object extrude in a moved component from a root sketch

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* style: rustfmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:54:19 -04:00
NOWN-DEVandClaude Opus 5.5 0154572580 feat: close sheet flange corners at any angle (#406)
Edge flanges meeting at a corner of the base were closed only at exactly
90°; at any other angle they stayed open (a V at 72°) and the rule's gap
was not used.

The flanges of one feature now close at any convex base corner and any
bend angle: each panel end is cut square to the sheet on a line in the
flat (slanted off 90°, stored as ext plus a new per-end slope) so the
edges nearest each other, on the inner faces, run parallel the rule's gap
apart about the plane bisecting the inner faces. The construction reads
the two flanges' own angles, so it holds for unequal ones as well. At 90°
it is the extension the old code gave (reference case 69-sm-tray is
unchanged). A corner that can't close (a gap under 0.01 mm, flanges
bending opposite ways, ends that would overlap in the flat pattern) stays
open, and the flange feature reports a warning that says where and why.

A flange of a later feature leaves the corner as it was (reference case
75-sm-bracket-holes: the earlier flange keeps its length).

Tested at 72° and 90° against a hand derivation, on a trapezoid, and
every 5° from 45° to 135° on a rectangle and a trapezoid (gap exact, one
flat region without hairline edges, one valid shell holding the flat
material).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:48:19 -04:00
Tobias BreckleandClaude Opus 5.5 0bb5b7eb09 fix(ui): Sketch Palette line type buttons follow the selected curves (#422)
With sketch curves selected, the palette's Normal / Construction / Centerline
buttons now show the line type of the selection (each type of a mixed
selection); with nothing selected they show the drawing mode, as before.
Previously no button was highlighted while curves were selected.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:45:19 -04:00
Nathan Dane b447adafaa Finalize macOS icon appearances and Composer sizing (#426) 2026-10-12 18:45:14 -04:00
NOWN-DEVandClaude Opus 5.5 3938e267bb fix: fail solidcraft-cli run when a timeline feature fails (#402)
A parameter change can break a feature without failing the command that
made it; parameters.change reports it only in its `errors`. `run` then
exited 0 and exported the bodies as they were before the failing feature:
a shrunk sheet-metal tray whose unfold failed came out folded, without
the holes and cut-outs made after the unfold.

`run` now checks the evaluated timeline before writing anything. Any
feature with an error fails the run with status 1, lists the failing
features on stderr and writes none of the --out and --save files, as a
failing command already does. --allow-errors writes them anyway. `eval`
is unchanged and still reports the errors in its timeline.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:45:10 -04:00
bherbruckandClaude Opus 5.5 daed2686bc Snap package: strict core24 snap on every release, optional Snap Store upload (#413)
* Snap package: build, test and attach a strict snap to every release

packaging/linux/snap/snapcraft.yaml (core24, strict, gnome extension) repackages
the release's Linux tarball like the Flatpak does. The release workflow builds it
for amd64 and arm64, installs it with --dangerous, models a box in the confined
app on headless X11 and Wayland, and attaches solidcraft-<version>-linux-<arch>.snap
plus the version-less copy. When SNAPCRAFT_STORE_CREDENTIALS is set, nightlies go
to edge and release drafts to candidate, and snap-store.yml promotes candidate to
stable when a release is published. The README gets a Snap badge and install line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* snap smoke: stop the app's whole process group, refuse a busy control port

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* snap smoke: quit the app cleanly between backends

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:07:26 -04:00
bherbruckandClaude Opus 5.5 cddbfedb28 Remove the Generative Design workspace placeholder (#412)
It isn't planned, so it no longer appears in the workspace menu's
"coming later" list. Drops ui.generative_design from every locale and
checks the menu doesn't list it.


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 17:31:21 -04:00
bherbruckandClaude Opus 5.5 e56e055fa1 README: put a Download section with per-platform installers at the top (#411)
Badges and one-line instructions for Windows, macOS, Linux and the web app,
right after the tagline and demo. The links use releases/latest/download with
version-less file names, which release.yml now attaches (and checksums)
alongside the versioned installers. The Flatpak note and the name/compatibility
section move into the book's Getting started page.


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 17:25:27 -04:00
bherbruckandClaude Opus 5.5 296683229d Rename SolveCraft to SolidCraft everywhere, with compatibility shims (#389)
* WIP deep rename (not for push)

* WIP2

* WIP3

* WIP4 wxs fix

* rustfmt

* Rename the remaining identifiers in merged crash-report code

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 17:17:01 -04:00
27415e4dd7 Crash reports: replayable, bounded, local, reachable by agents (#384)
Every panic (in a command, in the kernel, anywhere else) and every unclean
exit (abort, stack overflow, kill) leaves a report in a local crash folder:
message, file:line, a trimmed backtrace, version, commit, system and renderer,
the command and recent commands, and when possible the design and replay.json
that reproduce it (solvecraft-cli crashes replay <id>).

- Cost: nothing until the app installs the hook (tests, CLI and web never
  do); then one small journal line per top-level command of the design on
  screen, taken back with a truncate when the command changed nothing.
- Unclean exits: each run holds a lock on its journal; a journal nobody holds
  at the next start belongs to an app that died. Autosaves restart the
  journal from the autosaved design.
- Bounded: a crash's signature (place + message, numbers ignored) is counted,
  not rewritten; at most 5 new reports per run, 20 reports, 50 MB, 90 days;
  recovered kernel panics are pruned first.
- Agents: diagnostics.crash_reports / crash_report / crash_reports.clear
  commands, the MCP crash_reports tool, solvecraft-cli crashes, solvecraft
  --crash-log; diagnostics.crash_test (with SOLVECRAFT_CRASH_TEST) checks it.
- People: the Crash Reports window opens after a crash (Help > Crash
  Reports…) with a reviewed issue draft per report; nothing is sent.
- Release builds keep line tables for file:line in backtraces.

Tests: child processes that really panic in a command, in the kernel, on a
thread, and abort; dedup counts, limits, hostile folders, journal parsing,
the UI window and draft, the MCP tool. docs/crash-reports.md.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-12 16:50:49 -04:00
Nathan Dane 55f2f29fff feat(macos): integrate approved layered app icon (#399) 2026-10-12 16:39:45 -04:00
d381d2b7f3 fix: build very flat arcs in sewn faces from their ends and midpoint (#398)
kernel::sew builds an arc edge, and the wall an extruded arc sweeps, with truck's
three-point arc (circumcentre, then a turn about it). On a very flat arc that
construction ends off its own vertex: a 12 mm chord with a 0.08 um sagitta (a 225 m
radius) ends about 1e-6 mm short of it, and the planar face holding the arc, 1238 mm
from the origin, fails with "cannot attach a plane to a wire that is not on one plane".
truck's plane fit takes the sum of the wire's control points as its centre, so a gap
of that size is scaled by a distance of the order of the coordinates and tilts the
estimated normal beyond the fit's 1e-6 flatness tolerance.

Arcs whose sagitta is under 1% of the half chord are now built as their exact rational
quadratic (apex 2/(1-t^2) along the bow, weight (1-t^2)/(1+t^2), t the sagitta over the
half chord), for edges and for the extruded walls on them. Deeper arcs keep truck's
construction.

Co-authored-by: NOWN-DEV <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 16:39:42 -04:00
c62879b00d feat: fillet fit option reduces a radius too large for its edges (#388)
* feat(kernel): typed TooLarge refusal and fillet_max search

Blend refusals caused only by the size (larger than the neighbouring faces,
taller than the walls, longer than the next edge, larger than a corner
radius) now return KernelError::TooLarge, displayed as before. fillet_max
finds the largest radius below a request at which the fillet of the edges
works, to three significant digits, by building candidates; it is an explicit
call (up to 16 fillets), not run on every refusal.

A corner edge that doesn't run square to the edge is now reported as such
instead of as a blend larger than the neighbouring faces.

Refs #293

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* feat: fillet fit option reduces a radius too large for its edges

solid.fillet takes fit: exact (the default, as before: a radius too large
for the geometry around the edges is refused) or reduce: the radius is
reduced to the largest that works for all the picked edges (kernel
fillet_max, three significant digits, every candidate built). The feature
stores the requested radius, so it refits on every evaluation; when it
reduces, the feature's warning says so and the command returns
radius_applied. Pattern copies of the fillet use the same rule. The file
format writes fit only when it is reduce.

The Fillet dialog gets a Radius Fit row (Exact / Reduce to Fit, constant
radius) and, when the preview reduced the radius, an Applied Radius line;
editing the feature keeps the choice.

One radius for all edges for now; per-edge radii and taper follow.

Refs #293

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-12 16:21:17 -04:00
Konrad 3c94f59d43 feat(ui): trackpad pan with two fingers, zoom with pinch (#386)
Two-finger scroll pans the model instead of zooming; pinch-to-zoom
zooms toward the cursor and honors reverse zoom. Trackpad scrolls
arrive bracketed (Start..End, momentum included) while mouse wheels
send bare moves, so phases tell them apart: bracketed gestures pan,
bare vertical input zooms and bare horizontal input pans (thumb wheel).
Mouse line/page input keeps zooming, and ui.scroll now sends line
input so scripts and agents are unaffected. New Navigation preference
'Two-finger scroll pans' (default on) restores the old behaviour
when off.
2026-10-12 16:15:22 -04:00
Jonas Wrede 829912c6c5 perf: cut many clear tools of any cut in one boolean (#368) (#387)
One extrude cutting many circles from one sketch still cut the plate once per
circle, each boolean rebuilding and re-meshing a plate grown by every hole before
it (32 circles 2 s, 128 circles 60 s). The #261 fix joined clear tools with
kernel::union_apart only in pattern replay; apply_traced now does it for every
cut with 8 or more tools (patterns included), keeping each piece's face lineage.
2026-10-12 16:09:46 -04:00
bherbruckandClaude Opus 5.5 6090e89b3f Release under the SolidCraft name: binaries, assets and installers (#383)
* Release under the SolidCraft name: binaries, assets and installers

The desktop binary is now `solidcraft` and the CLI `solidcraft-cli` (the two app packages
are renamed; their folders move with the rest of the rename later). Every release asset is
named solidcraft-<ver>-… / SolidCraft-<ver>-windows-x64.exe, and the installers show the
SolidCraft product name.

Kept so existing installs keep updating (docs/compatibility.md):
- `solvecraft-cli` alias binary in every package, forwarding to solidcraft-cli with a
  deprecation note on stderr (tests/legacy_alias.rs).
- Linux app ID ai.storyteller.solvecraft (Flatpak ID, desktop entry, Wayland app_id).
- MSI UpgradeCode, so the MSI upgrades in place.
- deb Replaces/Conflicts/Provides and rpm Obsoletes/Provides solvecraft.
- An old-named .zsync copy so earlier AppImages find updates; a legacy .flatpakrepo copy.
- .solvecraft file associations; SOLVECRAFT_* fallbacks in the packaging scripts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* MSI: register the legacy .solvecraft extension without a duplicate open verb

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 16:05:35 -04:00
Jonas Wrede f06b76e14f fix: number a profile added after a named multi-profile extrude after body_name (#373) (#385) 2026-10-12 16:04:54 -04:00
Jonas Wredeandbherbruck 4b8872e4d0 test: failing regression test for #374 (ignored until fixed) (#382)
Co-authored-by: bherbruck <[email protected]>
2026-10-12 15:43:56 -04:00
Jonas Wredeandbherbruck e7b5b2618e test: failing regression test for #373 (ignored until fixed) (#381)
Co-authored-by: bherbruck <[email protected]>
2026-10-12 15:35:32 -04:00
Jonas Wredeandbherbruck ef9bb0db09 test: failing regression test for #371 (ignored until fixed) (#379)
Co-authored-by: bherbruck <[email protected]>
2026-10-12 15:34:41 -04:00
Jonas Wrede f538eeb5fa test: failing regression test for #372 (ignored until fixed) (#380) 2026-10-12 15:25:36 -04:00
Jonas Wrede d470bfc52a test: failing regression test for #370 (ignored until fixed) (#378) 2026-10-12 15:25:32 -04:00
Jonas Wrede f82b6beedb test: failing regression test for #368 (ignored until fixed) (#377) 2026-10-12 15:25:27 -04:00
bherbruckandClaude Opus 5.5 80672460ed Version 0.5.0 (#376)
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 15:17:00 -04:00
bherbruckandClaude Opus 5.5 57e6396fbb Tangent hole contacts split no faces, consistently (#307) (#364)
* Tangent hole contacts: every symmetric contact gives the same faces (#307)

Three things made the four symmetric contacts of reference case 42 differ:

- The 2D prism boolean's ray-parity test miscounted a ray grazing the bottom
  of a patterned hole, whose two halves are read back with centres a few ulps
  apart: one half counted the touch, the other missed it. The loop chaining
  then failed and those cuts fell back to the general boolean, which treats
  circles tangent within its tolerance as overlapping and splits the hole.
  The ray is now moved off vertices and arc extremes by far less than any
  modelling tolerance.
- The prism result's volume check used a coarser mesh than the operands, so
  a plate with many holes read too large and correct results were rejected.
- The merged topology did not join a hole's two pieces across the line where
  two tangent holes touch (four faces meet there).

Case 42 now gives 39 faces (33 holes, 6 planes); its expected.json pins it.

Fixes #307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* Kernel test: tangent hole pinch no longer splits the wall (#307)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 15:01:03 -04:00
Iraklis KourtisandClaude Opus 5.5 a5cc1243e3 fix(doc): a pattern repeats a fillet on the edges it found, not its picks (#363)
A rectangular or circular pattern of a fillet or chamfer repeated it at the
points picked when the feature was made. The feature itself finds its edges
by name on every evaluation, but a parameter that moves the edges (a taller
pocket) leaves the old points on other geometry, and the copies then
blended the wrong edges or failed ("the faces at the edge ends must be
planar"). The model state now records, per fillet or chamfer, the edges it
blended in this evaluation (part of the state's digest), and the pattern
repeats those.

Fixes #219


Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 14:58:52 -04:00
Iraklis KourtisandClaude Opus 5.5 afcec4d0bd fix(kernel): fillets that end in faces at an angle or across two faces (#362)
A straight-edge fillet required each end of the edge to meet exactly one more
face, square to the edge. Now an end may meet one planar face at any angle
(the round ends in it along an exact ellipse, the cross-section slid along
the edge onto the plane) or two planar faces meeting at the end vertex: the
round ends across both and cuts back the edge between them where its
cylinder crosses it (Vida, Martin and Varady 1994: trimlines joined across
existing faces). The cylinder is swept to cover both trimmed ends. Square
ends are built as before.

This rounds the mitres where two chamfers meet at a plate's corner, and a
concave edge where a post stands on a plate (the round's end caps extend
the post's end faces); both were refused.

Fixes #215


Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 14:56:00 -04:00
Vitali M.andVitali M. 6efbd6ff14 Place a dimension that would over-constrain the sketch as a driven one (#366)
Sketch Dimension refused a dimension the sketch couldn't take, with
"that dimension would over-constrain the sketch; add it as a driven
(reference) dimension with driven: true", which the tool gave no way to
do. It now places it as a driven dimension instead: while placing, the
preview shows it in parentheses in the driven colour, and the click adds
it at once (it has no parameter and no value to edit), with a status line
saying why. Other refusals, such as an unsupported pick, are reported as
before, since the driven dimension is refused too.

Refs #320

Co-authored-by: Vitali M. <[email protected]>
2026-10-12 14:54:34 -04:00
Pablo López c51735c885 Toolbar drop-downs scroll when taller than the window (#360)
A panel drop-down taller than the window ran past its bottom edge, so the
last rows (Sketch → Create → Intersect and below) could not be reached. The
rows now scroll inside the window, and a row reached with the arrow keys
scrolls into view. Row ids no longer depend on the enclosing Ui, so the
keyboard focus still finds them inside the scroll area.
2026-10-12 14:38:27 -04:00
Nathan Dane 4eaadf3099 fix(macos): package the app as SolidCraft (#347) (#354) 2026-10-12 14:37:35 -04:00
bherbruckandClaude Opus 5.5 882ef0e9c6 Face counts: classify sliver faces from their exact surface (#308) (#357)
Since 4e595de a partial-depth cut that ends inside a cylinder runs through the
exact stacked-section path, which leaves 0.3 mm wide strips of the cylinder wall
beside the cut. Their tessellation is one triangle wide, too few distinct
samples for the cylinder fit, so the merged topology reported them as a sphere
and a freeform face. Where the mesh fit finds no plane or cylinder, use the
face's exact surface (plane, cylinder or sphere) instead.

Fixes #308


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 14:35:13 -04:00
bherbruckandClaude Opus 5.5 d15869ab85 CI: shard tests across 4 runners; heavy tests on main and nightly (#350)
* CI: build tests once and run them in 4 nextest shards

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* CI: quote the shard arguments

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* CI: extract the test archive into the checkout; skip the heaviest tests on pull requests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* Docs: CI shards and the tests pull requests skip

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 14:30:35 -04:00
Pablo López 511d105dcb Project faces split by hidden seams as a whole (#352)
Projecting a cylinder's wall or the face of a round along a circular edge
failed with "face projection has an open or invalid boundary": these
surfaces are stored in halves joined by hidden seams, and the projection
dropped the seams but kept only the picked half. It now projects the whole
patch, the face as drawn, through the same surface_patch the viewport uses
to highlight it.
2026-10-12 14:29:08 -04:00
bherbruckandClaude Opus 5.5 2ebceb6918 Sheet metal: sew the folded-material guard per piece so adjacent flanges cut (#296) (#355)
The guard that refuses machining through bends sewed every bend zone and
nonparallel panel into one solid. Two bends meeting at an open corner relief
share only an edge, so that sew failed with "nonmanifold edge contact" and any
cut on the base was refused. Sew and test each piece on its own instead.

Reference case 75-sm-bracket-holes now passes; its known_failure mark is removed.

Fixes #296


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 14:27:40 -04:00
7db66bb824 Pick a horizontal or vertical dimension from where its text is placed (#345)
Between two points, or on a line, that don't line up with an axis, the
Sketch Dimension tool only offered the distance between them: an auto
dimension ignored where its text went. Now the placement picks what it
measures: across from the two (between the perpendiculars through them)
the distance between them, as before; past that, above or below them the
horizontal distance, beside them the vertical one. With no text, an
explicit type, or two ends already on an axis, nothing changes.

The tool previews the dimension from the cursor, so the choice follows
the pointer while placing it.

Fixes #257

Co-authored-by: Vitali M. <[email protected]>
Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 14:18:11 -04:00
a7e47fdae6 fix: unfold sheets shrunk by a parameter change (#349)
A sheet-metal tray built at 2000 x 2500 mm with an edge flange on all four
edges (72° or 90°) and unfolded failed to rebuild when W and H went to
400 x 300, 600 x 450, or W alone to 400: "planar region arrangement failed
or exceeded its bounded processing budget". The same tray drawn at 400 x 300
unfolded, and growing it worked.

The sketch solved again after the change leaves its corners ~1e-13 mm off,
so the flanges inherit a hair of tilt and their panels and bend zones meet
the trimmed base within 1e-13 mm instead of exactly. The flat pattern is a
balanced union of those pieces, and the arrangement chains its output loops
within 10 tol. In a later round, ray parity classified a bend zone's edge
midpoint that lay level with a corner whose two lines did not quite meet:
the ray slipped through the gap without a crossing, the zone's edges counted
as inside the other operand and were dropped, and the outline no longer
closed. A rectangle turned by 1e-14 rad failed the same way.

inside_loop now closes the gap where one line ends and the next begins with
a straight connector while it counts crossings; exactly closed joints are
unaffected. Joints with an arc keep the arc's own endpoint rule, which
treats ends within its angular tolerance as on the ray (a connector there
double counted at a circle split into two arcs).

Co-authored-by: NOWN-DEV <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 14:15:55 -04:00
bherbruckandClaude Opus 5.5 ce226a88eb Regression tests for the XZ plane orientation (#78): a positive extrude from XZ and from an XZ offset plane goes +Y; a format 5 design with XZ sketches, an offset plane and extrudes opens, and resaves, with identical volumes and bounding boxes (#353)
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 14:10:48 -04:00
0b122e6d4a Support 3D mice on Linux and devices that send all six axes in report 1 (#343)
The space_mouse module was Windows-only (hidapi). On Linux it now finds the
multi-axis HID interface through /sys/class/hidraw and reads /dev/hidrawN with
plain std I/O, so there is no new dependency and no libudev needed.

A small report descriptor parser tells devices apart: a top-level Multi-Axis
Controller collection marks the 3D mouse, and report 1 declaring all six axes
selects the combined layout (X Y Z Rx Ry Rz in one report) that some homemade
devices use. Real split devices (report 1 translation, report 2 rotation) keep
their existing decoding. Windows reads the descriptor through hidapi and falls
back to the split layout if it is unavailable.

Co-authored-by: jochenhammes <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-12 13:55:54 -04:00
Vitali M.andVitali M. b2fc6d1fac Keep a zoomed-in profile's hover fill: count the slabs each edge crosses (#336)
fill_triangles bounded the slab triangulator by edges x slabs, as if every
edge crossed every slab. A ring sampled finely at a close zoom has hundreds
of edges and slabs but only four edges in each slab, so the estimate ran a
hundred times over the hover highlight's budget and the fill was dropped,
leaving only the outline. Count the slabs each edge actually spans instead.

Region2::triangulate tested every edge against every slab, so its work was
the overestimate. It now sweeps upward with the edges crossing the current
slab, giving the same triangles in time proportional to them.

Fixes #164

Co-authored-by: Vitali M. <[email protected]>
2026-10-12 13:47:11 -04:00
Tobias BreckleandClaude Opus 5.5 e6a95615a0 fix(ui): start with an anti-aliasing the graphics device can't do (#329)
Choosing 8x anti-aliasing in Preferences › Graphics on a device that
only allows 1x and 4x for the window's format made every later start
panic in wgpu's pipeline validation (`Sample count 8 is not supported
by format Bgra8Unorm on this device`), so the app could not be opened
again without editing graphics.json by hand.

- egui-wgpu (vendored): request TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
  when the adapter has it (WebGPU only guarantees 1x and 4x), and clamp
  the sample count to the largest one the device supports for both the
  colour target and the depth/stencil format. The supported counts are
  exposed as `RenderState::msaa_supported`.
- At start, a saved count the device can't do goes back to the default
  (4x, or the largest supported count below it), is written back, and a
  "Settings were changed" notice says what changed.
- The Anti-aliasing list greys out unsupported counts, `ui.prefs`
  rejects them, and an invalid saved `msaa` loads as the default.
- New messages translated in en, de, ja and ko.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 13:46:54 -04:00
palaniraja 6dab0fb2c1 openscad navigation (#211)
* openscad navigation

* addressed pr feedback: fixing box selection conflict with openscad and use constant for openscad
2026-10-12 13:46:40 -04:00
Iraklis KourtisandClaude Opus 5.5 a04ef4ca59 T-spline core library and sheet bodies (#250, step 1) (#337)
* Add B-spline basis, knot insertion and NURBS surfaces to geom

Freeform faces need local refinement and gap-free joins between patches
with different knot vectors, which tensor B-spline grids cannot give;
T-splines can, and the first user is an N-sided Boundary Fill of
non-planar loops. This lays the univariate groundwork they rest on, in
the L0 geometry crate so it stays independent of the B-rep kernel:

- Cox-de Boor basis of one function from its local knot vector, on
  half-open spans with a left-limit side for the domain end, with
  derivatives (NURBS Book eq. 2.5, 2.9)
- knot insertion into a single basis function (Sederberg et al. 2004
  section 3) and Bezier extraction of one function by repeated insertion
- Boehm knot insertion for curves (NURBS Book A5.1), de Casteljau
  evaluation and sub-segments
- a rational tensor NURBS surface with evaluation, knot insertion and
  clamping, and a rational Bezier patch type

All input is checked (finite, monotone knots, positive weights, size
caps) and returns SplineError; nothing indexes unchecked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

* Add T-spline surfaces: T-mesh, evaluation, AS check, refinement, extraction

Tensor B-spline faces refine only by whole rows and columns and cannot
join patches with different knot vectors without gaps. T-splines allow
T-junctions in the control net, which fixes both; the first user is an
N-sided Boundary Fill. This adds the maths only (no command, no change
in behaviour):

- T-mesh in index space with knot values per line, validated (outer
  rectangle, rectangular faces, no empty lines, size caps); local knot
  vectors read off rays from each vertex, rays leaving the mesh repeat
  the boundary (Sederberg et al. 2003 section 3)
- rational T-spline evaluation and first derivatives for odd bi-degree,
  with a complete (p-1)/2-line frame and the domain inside it
- T-junction extensions and the analysis-suitability check (Scott et al.
  2012 section 2)
- local refinement that keeps the surface exactly: requested knot lines
  are cut in, then every original blending function is carried as a
  record and split (violation 1) or the mesh gains edges (violations 2
  and 3) until each record is a blending function of the refined mesh
  (Sederberg et al. 2004 section 3); analysis-suitable input stays
  analysis-suitable by carrying conflicting T-junctions across faces
- NURBS <-> T-spline conversion for meshes without T-junctions, and
  Bezier extraction over the rectangles no blending-function knot line
  crosses

Measured: regular T-spline vs NURBS < 1e-12, round trip exact, partition
of unity with T-junctions < 1e-14, refinement deviation <= 1.4e-14 over
40 random refinements, Bezier patches vs surface < 1e-14.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

* Convert T-splines to sewn NURBS sheet bodies in the kernel

A T-spline is only useful to the modeller once it is a B-rep face set.
tspline_sheet builds an open shell: a mesh without T-junctions becomes
one clamped NURBS face; otherwise each Bezier element is a face, and
sides are split at the T-points where neighbours' corners land, so all
faces share vertices and edges and the sheet is one oriented shell
(V - E + F = 1). Every truck call runs under guard. No command yet;
Boundary Fill will be the first caller.

Tests: one face for a regular surface, sewn Bezier faces with
T-junctions, tessellated points on the surface to ~1e-9 mm, and hostile
control nets (coincident, 1e300, needle-thin) never panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 13:46:22 -04:00
Iraklis KourtisandClaude Opus 5.5 8e87a37511 feat(sketch): constructive solving ahead of the numeric solver (#339)
Part 1 of splitting #251: the sketch crate only. A graph-constructive front
end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass
loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the
rest, and the rank analysis stays authoritative for degrees of freedom. Solves
can follow a reference (the previous solution), so edits and drags keep their
branch instead of flipping.

New API: solve_with (hold, reference, analysis, budget) and
solve_after_value_edit; solve and solve_holding keep their signatures as
wrappers, so no caller changes here.

When the seed moves an arc's centre, the arc's unconstructed ends start carried
along (the arc translates) instead of left behind for LM to turn; found by the
reference test 20-slot-constrained.

Refs #203


Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 13:46:08 -04:00
Iraklis KourtisandClaude Opus 5.5 fe4d171e22 feat(sketch): live drag that keeps its solution, ended by sketch.drag_end (#341)
* feat(sketch): constructive solving ahead of the numeric solver

Part 1 of splitting #251: the sketch crate only. A graph-constructive front
end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass
loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the
rest, and the rank analysis stays authoritative for degrees of freedom. Solves
can follow a reference (the previous solution), so edits and drags keep their
branch instead of flipping.

New API: solve_with (hold, reference, analysis, budget) and
solve_after_value_edit; solve and solve_holding keep their signatures as
wrappers, so no caller changes here.

When the seed moves an arc's centre, the arc's unconstructed ends start carried
along (the arc translates) instead of left behind for LM to turn; found by the
reference test 20-slot-constrained.

Refs #203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* feat(doc): keep a sketch's solved state across value edits

Part 2 of splitting #251 (builds on part 1, the sketch crate):
- Sketch edits solve for the document's values continuing from the stored
  solved state (Document::solve_sketch), so a dimension edit keeps the
  sketch's branch instead of re-solving from scratch.
- A value edit (parameters, a configuration, the units) that would make a
  sketch that solved before fail or turn over is refused with the
  conflicting constraints named; otherwise the solved sketches are written
  back into the document (Model::adopt_solved_sketches), which changes what
  saved designs store.
- Later features are keyed by a sketch's solved geometry (to a 1e-6 mm
  quantum), so setting a value back reuses their earlier results.
- Solved sketches in a model state are shared (Arc) between states.

Refs #203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* feat(sketch): live drag that keeps its solution, ended by sketch.drag_end

Part 3 of splitting #251 (builds on part 2):
- sketch.drag follows a long move in short held steps, so the geometry keeps
  its branch, and reports how far it got (reached, at).
- live: true runs one frame of an interactive drag: the sketch is solved
  once and shown, later features keep their last result (marked stale)
  until sketch.drag_end or any command that reads the model evaluates it
  (CommandSpec::live, reads_model, doc_only).
- sketch.drag_end adds the constraint the drag let go on and evaluates the
  model once.
- Undo and redo evaluate a new document once (execute does) instead of
  twice.
- The viewport drives drags through live frames and drag_end; dimension
  text drags start only off sketch geometry.

Refs #203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 13:45:48 -04:00
bherbruckandClaude Opus 5.5 75a5609b23 docs/releasing.md: refresh Cargo.lock with cargo update --workspace --offline (#348)
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 13:43:42 -04:00
Pablo López fc8a5b21cf Loop blends: accept straight wall seams that are not lines (#335)
* Loop blends accept straight wall seams that are not lines

Filleting or chamfering the top outline of two overlapping cylinders whose
concave edges were rounded failed with "the wall seams must be straight":
the round's contact lines with the cylinders are straight NURBS curves, and
the loop blend only accepted seams stored as lines. A seam now counts as
straight when every sample lies on its chord; it is trimmed as a line as
before.

* Fillet and chamfer arrows point into the body

The radius arrow pointed out of the body along the edge's bisector, so
dragging it away from the edge grew the round. It now points into the
material, like the shell's thickness arrow: dragging toward the body grows
the radius or distance, dragging out of it shrinks it.
2026-10-12 13:15:30 -04:00
bherbruckandClaude Opus 5.5 e6252ae094 Version 0.4.0 (#330)
Minor bump: new capabilities since 0.3.2 and one breaking change
(legacy command ids are no longer accepted).


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 12:58:49 -04:00
bherbruckandClaude Opus 5.5 198f45edee Toolbar: no cap on pinned buttons (Fixes #323) (#326)
* Toolbar: no cap on pinned buttons (Fixes #323)

Every pinned command gets a toolbar button; `promoted()` is only the
default layout of a fresh or reset toolbar. A toolbar too narrow for every
button now collapses panels into their drop-down (one button that opens it,
listing every command, pinned ones included) from the right, then moves the
last panels into the » menu, instead of pushing a wide panel and everything
after it into ». Nothing is dropped.

Scenario s48 pins ten commands into SOLID > CREATE: all ten show at a wide
window, a narrow one collapses CREATE into its drop-down (all ten listed),
narrower still puts it in », and Reset Toolbar restores the defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* Narrow-toolbar test: panels collapse into their drop-down before »

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* kernel: drop a needless borrow that fails clippy -D warnings on the current stable

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 12:35:19 -04:00
354a267830 test: failing regression test for #260: Targeted cut deletes another body that the tool fully covers (#275)
* test: reproduce #260 (Targeted cut deletes another body that the tool fully covers)

* Primitives honour `targets` for join/cut/intersect (#260)

solid.box/cylinder/sphere/torus had no targets field, so a `targets` list was
ignored and every body of the component took part: a targeted cut also cut a
non-target body, and deleted one it covered fully. Their `targets` now become
the feature's participants.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 12:18:59 -04:00
c33fe37798 fix(kernel): loop fillet as tall as its wall uses the wall up (#318)
* fix(kernel): name the zero-thickness rim when a shell can't open beside a round

Shelling a body through a face that a convex round meets tangentially, with
the round at least as large as the thickness, puts the cavity wall through
the rim where the round meets the open face. Both shell strategies then fail
and the first one's generic error ("a corner whose faces don't meet the same
way after the move") was reported. The shell now says why and what to change.
Successful shells are unchanged.

Found by fuzz seed 33, which reaches this case now that a disc's two edges can
be rounded to half its thickness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

* fix(kernel): loop fillet as tall as its wall uses the wall up

Rounding a loop with a radius equal to the height of its walls was refused
("the blend is taller than the walls"): r = 2 on the top edge of a 2 mm
disc failed while 1.999 worked. When every wall seam ends exactly where the
wall-side rail would, the walls are consumed: their far edges become the
blend's rails and the walls and seams are removed. Walls used up only in
part, and radii past the limit, are still refused.

Refs #190

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-12 12:17:07 -04:00
667e05a0c9 test: failing regression test for #259: Join with explicit targets also merges a non-target body that the tool touches (#274)
* test: reproduce #259 (Join with explicit `targets` also merges a non-target body that the tool touches)

* Primitives honour `targets` for join/cut/intersect (#259)

solid.box/cylinder/sphere/torus had no targets field, so a `targets` list was
ignored and every body of the component took part: a join also merged a touched
non-target body, a cut also cut (or deleted) one. Their `targets` now become the
feature's participants.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 12:17:02 -04:00
Iraklis KourtisandClaude Opus 5.5 a9efae864d fix(doc): designs saved before format 14 keep their piece names (#136)
Pieces a cut splits a body into are named after their faces since names
follow history (lineage::order_lumps), so they keep their body names when
an upstream edit moves the cut. The kernel's shell order named them
before, and the two orders can differ: a slot along X through a 40x30x10
block swaps Body1 and Body1 (1). A design saved by an older build stores
body names meaning the old order (fillet and chamfer bodies, targets,
participants, materials, appearances, joints), so it would act on the
other piece.

Each feature now says which order names its pieces
(Feature::shell_piece_order). The v13 to v14 step sets it on every saved
feature, so an older design evaluates as it was saved; features made from
now on, in new or old designs, name their pieces after their faces.

The corpus gains split-body-pieces, saved by v0.3.2: a fillet, a chamfer
and an appearance refer to the two pieces by body name. Without the
setting the two pieces swap names (the corpus test fails); with it they
stay as saved. Migration tests that compared an upgraded design with the
original now expect the setting on its features.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 cc668668ce refactor(doc): format 14 marks the fields that store names, by schema place
The v13 to v14 step walked the whole design and marked any JSON key named
face, face_name or face_names, so any future field with one of those names
would be marked too. It now marks the fields that store a face or edge name
where the schema puts them: each feature's face_names and fillets' and
chamfers' edge_names, a sketch feature's links, joint snaps on a face in
joints and named origins, and face appearances. Face and edge references
(PlaneRef::Face, GeoRef::Face/Edge) can sit in many features, so they are
still found anywhere in a feature, by their tag and their picked point.

The test fixtures now have the shape real files have, and a new test checks
that same-named keys elsewhere are left alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 cfdd8a533b test(doc): drop two timing assertions on when a source's mesh is built
The world-placement tests checked that the source body's mesh was built
before a placement adopted its names. Once names follow history they no
longer need the mesh, so that is a side effect's timing, not behaviour.
What the tests protect, a placement sharing its source's names, both
still assert through the public API: Arc::ptr_eq(face_names(placed),
face_names(source)).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 08cf526fe2 feat(doc): persistent names follow the kernel's exact face history (#136)
Why: every reference a user makes to a face or edge (fillet and chamfer
edges, sketches on faces, shell/draft/offset faces, joints, appearances)
is stored as a name and found again after any upstream edit. Those names
were re-derived by sampling points of each face and voting, and pieces
of a split face were numbered by where their centres lay. After an
upstream edit that splits, merges, mirrors or shrinks a face, a fillet
or sketch could silently re-attach to the wrong face, and nothing told
the user. The kernel now reports exact history for its operations, so
names follow it and fail loudly instead of guessing.

- crates/doc/src/lineage.rs: a feature's kernel histories composed into
  one lineage over nameable operands (bodies before the feature, moved
  copies, shell cavities, tools named by role: sketch curve, cap, box
  side, imported face). Instrumented: extrude/revolve/sweep/loft tools,
  join/cut/intersect, lumps after a cut, fillet/chamfer, shell, draft,
  split face/body, pattern/mirror (bodies and features), combine,
  boundary fill, move/scale, offset/delete face, thread, import.
- crates/doc/src/history_names.rs: names from lineage; split pieces told
  apart by the faces only they touch (F2:top#[F2:side:l4,F4:side:l4]),
  merged faces answer to every name (aliases), lump bodies ordered by
  their faces' names instead of shell order.
- crates/doc/src/name_lookup.rs, piece_names.rs: references resolve by
  exact name, alias or bounding faces, else nearest piece with a
  warning; names worked out from geometry (operations without history)
  are reported when only geometry decided.
- Format 14: names stored by older formats get a legacy mark and are
  found as the build that saved them found them (some geometric names
  name a face history names differently: a tube's outer wall was named
  after its inner circle). New corpus file saved by 9a979cf.
- kernel: Body::lumps carries each piece's history over the whole.
- Adversarial tests: split, merge, mirrored twins, shrink to a sliver,
  moving cut with body pieces, history-less bodies, certified sketch.
- docs/book/src/dev/naming.md describes the real mechanism and why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 2bda050872 feat(kernel): bodies joined apart report their history (#136)
union_apart joins bodies whose boxes are clearly apart (many pattern tools,
#261) without any intersection work: their shells side by side. Each face
and edge of the result is its piece's own, unchanged, and the result now
says so (a history over the pieces), so a pattern's cut keeps the exact
names of every copy's faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 08d797235d feat(kernel): history across coalesced stepped walls (#136)
A cut of stepped prisms sews a wall over one segment through successive
slabs as one face, and the generated planar walls are then joined on
their common supports. Until now such a piece carried no history.

- prism_lineage: a sewn wall records what it is over in each slab it
  spans (Spec::Wall { levels, slabs }), so a coalesced wall comes from
  the operand walls under it in every slab
- the sewn piece's history is attached before the joining and carried
  through it (Built::Traced); planar_regularize composes the history of
  the body it joins with the join itself
- a composed face is Split only where record::mark_shared_split finds an
  origin under several faces; an inner Split alone no longer makes one
- Body::same ignores history: an operation that leaves a body as it is
  gives the same body, carrying that operation's history

The two boolean history tests that waited for this run again, and the
coalesced and regularized step cuts are covered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 97bcdb4d06 feat(kernel): settle a slab's far cap edge by operand topology (#136)
The far edge of a stepped slab (the operand cap edge where a segment's
wall ends) was recorded by testing whether each slab segment runs along
an ending cap's segment in 2D, a tolerance comparison of coordinates.
Matching by topology makes the far edge exact by construction instead:
when the wall's top is the ending level, nothing crosses it there, and it
meets the caps at that level in exactly one edge, that edge is over every
segment beside the wall, with no position compared.

A vertex walk up the wall's side edges from the segment's ends was tried
first and does not settle the cases that need it: a notched or stepped
wall continues past a segment's end (no side edge rises from it), and
walls split by earlier stations have mid-chain vertices with no side edge.
Whenever the walk succeeds, the wall is a rectangle over the segment and
the one-edge rule above already holds.

The positional match remains only where topology cannot say which edge
is over a segment: a wall that meets the ending caps in several edges (a
top split at earlier station vertices, or beside a wall crossing it at
that level) or that carries on past the level elsewhere. It is documented
in `Section::mark_far` and its pair budget no longer stops topological
marking. Far edges are also only marked on the slab that actually ends at
the level (a gap level no longer revisits an earlier slab).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 688f806682 feat(kernel): exact chamfer history after pocket, step, slot and bite cuts
Chamfer is one of the most common features and is usually applied right
after a cut. A chamfer cuts with a prism cutter, and its history came out
None after four of the six common box cuts. Without exact history, any
reference to a chamfer face (or a face it trimmed) falls back to geometric
guessing in naming, which can silently re-attach to the wrong face.

Two gaps in the stepped-prism section tags caused it, both now closed by
bookkeeping at construction, never by sampling or geometric tie-breaks:

- Cap edge at the far end of a slab. A slab's section is read off the cap
  where it starts and carried up to the cap where it ends; that cap's
  outline can run along one wall in several collinear edges, so which one
  a result edge lies on was unknown. When the stack is read, each slab
  segment that runs end to end along one segment of an ending cap's
  outline (beside the same wall) records that cap edge as `far`; the
  lineage takes it when it bounds the wall and the result cap.

- Walls an earlier cut left in coplanar pieces. A segment carried through
  a level kept the wall it was read beside, even where that wall gives
  way to another piece at the level (or within the slab), so the tag named
  the wrong face. `Stations` records the operand's edges across walls;
  at each level a carried segment keeps its wall when nothing crosses it
  there, moves to the one wall across it when it ends there, and is
  otherwise unknown (no history). A wall crossed inside a slab is unknown
  too, for simple and stepped prisms alike.

chamfer_after_box_cuts now covers all six cuts (notch, pocket, step,
slot, hole, bite), each verified against geometry and composed through
the cut over [block, tool]. The layered partial-depth cut test now
verifies its second cut, whose history was None before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 43ddde4a5d feat(kernel): an edge's meeting sides are sets of origins (merged faces)
EdgeOrigin::Meet now names each side as a Side: the sorted, de-duplicated
origins of the face there. substitute maps a side to the union of the inner
faces' origins, so a meeting with a face an inner operation merged keeps its
lineage; meeting() derives sides from whole faces. Fillets after box cuts
that leave coplanar pieces now report exact history. Prism lineage meets a
cap of several origins and resolves wall junctions away from shared
vertices by the operand's one edge between the walls. bore_union's recut
reports history only for a one-face bore (pieces of a two-arc bore's recut
wall were given both arcs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 08d7037176 feat(kernel): modifiers report exact face and edge history
Fillets (local, loop, rolling ball, corner, variable, chord), chamfers
(cutters, local corners, loops, two sides), shell, draft, offset faces,
split face/body, delete face, patch/copy/stitch/trim/extend and
transforms record their history over [body] while they build: faces
rebuilt from the body's, blends in place of edges (corner patches in
place of the edges at the corner), shell walls as offsets, edges as
pieces of the body's or where two faces meet. Multi-step operations
compose each step. The push-face offset fallback now has history, and
the bore union composes its narrowed tool.

A reflection reorders a body's edges, so transforms now match edges by
identity (mirrored copies reported the wrong edges).

Refs #136

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 d5cb276965 feat(kernel): every boolean path reports exact face and edge history
ops::boolean's result carries its history over [a, b] whichever path made
it, recorded by the code that builds the faces:

- truck path (incl. jitter retries): shapeops history mapped through the
  order-preserving copies and the cut's turned tool, then through healing
- heal records which input faces each healed face merges and which input
  edges each joined or exact edge replaces (HealMap); opt-in, so heal_keep
  callers pay nothing
- trivial shortcuts, the void, glue, faces pushed apart (push_faces records
  its own history; the slab cut back on a union maps to the operands'
  faces in that plane), the nudged retry, by_lumps and join composition
- prism booleans: section segments carry tags (operand cap edge, the wall
  beside it, its vertices) through the 2D boolean; walls, caps and edges of
  the extruded or sewn result follow from them
- planar and faceted polygon booleans: polygons carry their operand face;
  faces join the pieces across edges, edges come from the pieces either side

History gains renumber/over (and consuming forms) for composition;
record gains regions/traced/build helpers. truck-shapeops
*_with_history now return the solid with an Option history. Paths that
cannot report (bore narrowing via offset, inexpressible lineage) leave the
result without history. Boolean results, tolerances and fallback order are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 d69ebbee1b refactor(kernel): number profile loops once and record face history as faces are built
One numbered loop type (crate::numbered) carries each segment's profile
numbers through orientation, snapping, circle re-seaming, re-sampling and
rotation; every builder orients its regions through it once. Builders that
make their own side faces pair each face with its provenance as it is made
(ruled walls by the wire edges each face holds, smooth lofts, coils and path
sweeps by segment), and cap_and_close appends the caps itself. Column
following is kept only for faces truck sweeps internally (rsweep, tsweep
along paths, cones, where an apex vertex stays put). Extrude derives face
sources and history in one topology pass. One attach entry point takes
operands with their edges already indexed.

Geometry, face order, face sources and recorded origins are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 8c81bc60d0 feat(kernel): creation builders report exact face history
Extrude, revolve (partial and full turn), tapered extrude, loft (incl. to a
point), sweep, path sweep, coil, primitives, lip, emboss and thicken attach
the exact history of the bodies they build: each face's profile segment, cap
or operand face, and each edge's operand edge, recorded by topology identity
inside the building call. Nothing is sampled; a body whose faces do not follow
the expected construction carries no history. Face sources are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 cc4955a8da feat(shapeops): face and edge history from booleans
and_with_history / or_with_history return, with the solid, the operand face
each result face is a region of (and whether it is that face untouched) and,
for each result edge, the operand edge it is (a piece of) or the pair of
operand faces whose intersection made it. Faces and edges are recorded where
they are created (face division, intersection edges, split edges, edges
rebuilt on a moved vertex), resolved to operand terms at creation, so the
passes folding in extra shells compose without bookkeeping. and / or wrap
the same pass and drop the history; a missing record never fails them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
Iraklis KourtisandClaude Opus 5.5 c87efa1f35 feat(kernel): exact face and edge history model
Bodies can now carry the exact history of the operation that made them:
for each result face its origins among the operation's operands (kept,
modified, split, merged, or generated from a profile segment, cap, edge or
offset) and for each edge the faces it separates and the operand edge or
face pair it came from. History is recorded by the operation, never
sampled, and checked against the body when attached.

- history.rs: the vocabulary (moved from provenance.rs), History::check,
  identity, substitute (composes an operation with the one that made an
  operand, including bodies made from a sketch) and face-source propagation.
- One edge order for a body (body::edge_order), shared by tessellation and
  history, so history edge indices are the mesh's edge indices.
- Transforms report an identity history; placed_topology documents the
  face/edge order guarantee it relies on.
- provenance.rs keeps the geometric trace for operations that do not yet
  report history.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-12 11:52:53 -04:00
selune 65a99608d9 Prepare independent STEP bodies concurrently on desktop (#312) 2026-10-12 11:46:32 -04:00
Iraklis KourtisandClaude Opus 5.5 a9e4830ded fix(kernel): fillet that uses up a side face makes a full round (#317)
A constant-radius fillet whose setback equals the width of a neighbouring
face was refused ("the blend is larger than the neighbouring faces"), so
r = 3 on the vertical edges of a 6 mm wide box failed while 2.999 worked.
The corner edge is now consumed: the rail ends at its far vertex and a side
face used up at both ends is removed, its remaining edge becoming the rail.
A face used up at one end only, and radii past the limit, are still refused.

Refs #199


Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:44:32 -04:00
Iraklis KourtisandClaude Opus 5.5 a3ff816176 fix(kernel): name the zero-thickness rim when a shell can't open beside a round (#316)
Shelling a body through a face that a convex round meets tangentially, with
the round at least as large as the thickness, puts the cavity wall through
the rim where the round meets the open face. Both shell strategies then fail
and the first one's generic error ("a corner whose faces don't meet the same
way after the move") was reported. The shell now says why and what to change.
Successful shells are unchanged.

Found by fuzz seed 33, which reaches this case now that a disc's two edges can
be rounded to half its thickness.


Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:44:27 -04:00
Tobias BreckleandClaude Opus 5.5 06e517eb4e Home: turn off commands that act on the hidden design (#322)
The Home page hides the open design, yet Save, Undo, Redo, File › Insert,
Save As and Export, and the design's keyboard shortcuts (Ctrl+S/Z/Y,
Delete, V, F2, F6, Esc, S and tool keys such as E) still acted on it:
Delete removed the selected body unseen, Save wrote a design the user
could not see, and tool keys left Home without being asked to.

On Home the top bar's Save, Undo and Redo are dimmed with a translated
"open a design first" tooltip, the File menu's design items are greyed
out, and only New, Open and Preferences keep their shortcuts. Everything
works as before once Home is closed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:44:23 -04:00
4d4b3a715f Create construction planes along drawn 3D sketch paths (#240)
Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:44:19 -04:00
6bf86f1f1e test: failing regression test for #267: timeline.delete of a sketch leaves later features that use its body by name (#282)
* test: reproduce #267 (timeline.delete of a sketch leaves later features that use its body by name)

* fix: timeline.delete reports features that newly fail (#267)

A later feature that names a deleted body (a combine targeting a deleted extrude's body) is
listed under failing: [{feature, error}] in the reply instead of failing silently.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:44:15 -04:00
Vitali M.andVitali M. 516d80ae8d Clip lines at the near plane so the ground grid stays visible at low angles (#305)
A line whose end lies behind the eye has no screen point: vs_line divided
by a non-positive w, which flipped the segment or dropped it whole. At a
low perspective angle the grid lines running along the view direction
reach behind the camera and vanished. Cut each segment at clip z = 0
before the screen-space expansion.

Fixes #182

Co-authored-by: Vitali M. <[email protected]>
2026-10-12 11:44:11 -04:00
Tobias BreckleandClaude Opus 5.5 250d8a2c04 Preferences open over the Home page (#313)
The Home page returned from the frame before the Preferences window was
drawn, so the gear, Ctrl+, and Home's Preferences / Keyboard Shortcuts
links only set the flag and the dialog appeared after leaving Home.
Draw the Preferences window on Home too.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:44:07 -04:00
e6e0a7ebb5 Snap 3D bridge lines to points on other sketch planes (#229)
* Snap 3D bridge drawing to points on visible sketch planes

* Keep cross-sketch point picks out of sketch tools

`pick()` returned every visible sketch's points while 3D drawing was on,
so `sketch_point_at` and the sketch tools could take another sketch's
point as an active-sketch local point plus id (flattening it or making a
bad coincident reference). `pick()` is local again; the bridge snap uses
`pick_3d` explicitly and hover mirrors it for feedback. `sketch_point_at`
also ignores foreign points defensively.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(sketch3d): check local point scope with a sketch tool active

Main's Project preselection fix now offers other sketches' points to idle
selection, so assert the local scope where it still applies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* style: rustfmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:25:39 -04:00
cb8aa38cc9 test: failing regression test for #266: Multi-profile solid.extrude with body_name names only the first body (#281)
* test: reproduce #266 (Multi-profile solid.extrude with body_name names only the first body)

* fix: name every body of a multi-body feature after body_name (#266)

A new-body feature that makes more bodies than it was given names now stores Ribs (2),
Ribs (3)... for the rest instead of falling back to generic BodyN names. Command-time only:
saved designs keep their names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* fix: keep body_name list for the multi-body naming step (#266)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:24:33 -04:00
14163d0965 test: failing regression test for #264: Sheet metal: collinear tabs cannot be folded or flanged together (#279)
* test: reproduce #264 (Sheet metal: collinear tabs cannot be folded or flanged together)

* fix(sheet): fold and flange tabs that sit side by side on one line (#264)

A fold's bend zone and a flange's setback were cut with an infinite line, so
a second tab on the same line blocked a fold ('slanted or curved edge') and
two tab-end flanges in one feature shared one trimmed edge (nonmanifold).
When the zone falls apart into pieces, fold only the pieces the fold points
span, each as its own bend; trim a flange's setback only from the material
along its own edge, and attach each flange to the edge it was picked on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:19:21 -04:00
bherbruckandClaude Opus 5.5 3b8028a040 Retire legacy reference data and the external command catalog (#302)
- Remove xtask/data/fusion-catalog.tsv and xtask/data/fusion-ids.tsv.
- Remove the legacy command-id table (crates/engine/src/legacy_ids.rs) and its use in
  find_command and in the shortcut, recents and pinned-command loaders. Only SolidCraft's
  dotted ids name commands; any other id is an unknown command.
- Boundary Fill's id becomes solid.boundary_fill.
- Replace the parity report with SolidCraft's own feature checklist: `cargo xtask features`
  writes docs/features.md from the command registry.
- docs/oracle.md, docs/step-import.md (corpus section) and docs/iges-import.md: reference
  results are being rebuilt on analytic and open-source (FreeCAD) values; see #292.
  `cargo xtask oracle` and `cargo xtask step-corpus [--iges]` write these placeholders.
- AGENTS.md, CLAUDE.md, CONTRIBUTING.md, README.md, ROADMAP.md and the book describe
  SolidCraft as independent, with reference tests on analytic values and open-source tools,
  and agents do not drive or extract data from commercial CAD products.


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:19:15 -04:00
c7f0139ad4 test: failing regression test for #261: Rectangular pattern of a hole cut scales super-linearly (#276)
* test: reproduce #261 (Rectangular pattern of a hole cut scales super-linearly)

* Patterns of many hole cuts scale near-linearly (#261)

Two quadratic costs:
- Meshing a face tested every point against every segment of every boundary
  loop, so a plate face with hundreds of holes meshed in quadratic time. The
  vendored truck-meshalgo inside test now skips loops whose parameter box is
  clear of the point (they wind zero times around it).
- A pattern cut each copy in turn, rebuilding and re-checking an ever more
  complex target per copy. Copies whose boxes are clear of each other are now
  one tool of many pieces (kernel::union_apart, no intersection work), cut once.

512 holes now take ~7 s instead of not finishing. The test's volume tolerance is
widened from 1e-6 relative to 0.1 % of each hole: the measure meshes the bores
and reads each hole ~0.06 % small, on main too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hole-pattern test: a budget CI can meet; coarser apart-box meshes (#261)

CI runs every test binary at once and is about ten times slower than a
machine running this test alone (15 tests passed 60 s in the last run), so the
512-hole pattern (about 6 s alone) hit the 60 s timeout there. The budget now
only has to tell linear from the quadratic hang: 300 s timeout, 120 s budget.
union_apart meshes each copy coarsely (2 % of its size, box grown by twice that).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hole-pattern test checks the scaling, not a wall-clock time (#261)

CI runs every test binary at once: the 512-hole pattern (about 6 s alone, 10 s
CPU single-threaded) took 177 s there, so no fixed time is reliable. The test
now patterns 128 and then 512 holes and requires the second to take under 8x
the first (linear is 4x, measured 3.5-4.2x here; the old quadratic cost was 16x
or more), each with the volume check and a 900 s hang guard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:18:28 -04:00
f525704192 test: failing regression test for #268: Panic from truck-topology lib.rs:186 when joining struts that end on a bore wall (#283)
* test: reproduce #268 (Panic from truck-topology lib.rs:186 when joining struts that end on a bore wall)

* Sew refuses edges whose ends weld to one vertex (#268)

The sewer welds vertices on a 1e-6 grid but only rejected edges shorter than
1e-9, so a sliver edge between the two welded its ends into one vertex and
truck-topology panicked ("Two same vertices cannot construct an edge") inside
the guard. Such an edge is now a degenerate-edge error before any truck call,
and the boolean takes its usual fallback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:18:24 -04:00
db6ecaf446 Add Korean translation (#234)
* Create ko.json

* Update ko.json

* Update i18n.rs

* Fonts: add a Hangul fallback so Korean doesn't render as boxes

Only the first CJK font found was loaded, and on Windows and macOS that is
a Japanese font without Hangul (Meiryo, Hiragino). Load the first readable
font of each group: the existing CJK list, then Malgun Gothic (Windows),
Apple SD Gothic Neo (macOS) or Nanum Gothic (Linux). Also rustfmt the
Korean catalog test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:06:03 -04:00
59fabc16ac Reject ambiguous origin and construction plane names (#247)
* Reject ambiguous origin and construction plane names

* Reserve origin plane names for construction planes; suggest the other alias

A construction plane can no longer be created with or renamed to XY, XZ, YZ, Top, Front or
Right, so a plane argument can't become ambiguous in new designs. For older designs that
already hold such a plane, the ambiguity error now also names the origin plane's other
alias (XY for Top...). The existing tests build those legacy names directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:05:57 -04:00
aea16f2b5a test: failing regression test for #265: Renaming a pattern copy renumbers the remaining copies (#280)
* test: reproduce #265 (Renaming a pattern copy renumbers the remaining copies)

* fix: renaming a body keeps its sibling bodies' names (#265)

body.rename pinned only the names of the feature's bodies before the renamed one, so unpinned
pattern copies after it were renumbered into the freed name. Pin every sibling's current name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:59:08 -04:00
93fd18eaad test: failing regression test for #263: sheet.fold accepts a fixed point that is not on the sheet (#278)
* test: reproduce #263 (sheet.fold accepts a fixed point that is not on the sheet)

* fix(sheet): refuse a fold's fixed point that lies off the sheet's material (#263)

A fixed point inside a cut-out or outside the base outline (in the panel's
thickness) silently picked a side. Refuse it with an error that names the
point; side-face picks and points on other panels keep working.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:56:29 -04:00
selune b45cae1e48 Reuse prepared analytic sources when certifying extrusions (#273) 2026-10-12 10:45:26 -04:00
e12758ff91 Public reference tests: analytic and FreeCAD expected values (#310)
The oracle no longer uses Fusion measurements. tests/oracle/<case>/ now holds the 76 recipes
(our own format, with recorded measurements and tool-specific fields removed) and an
expected.json per case:

- 55 analytic cases: closed-form values from tools/oracle-analytic/ (stdlib Python, formulas
  in each case's "method").
- 21 FreeCAD cases: the same recipe built with FreeCAD's public scripting API by
  tools/freecad-oracle/ (an external program, not a dependency), with a reference.stl for 20.

solvecraft-cli oracle compares body count, volume, area, centre of mass, bbox, face counts
where the geometry fixes them, and, against reference.stl, the sampled Hausdorff distance and
the principal moments (spatial.rs from #301). cargo xtask oracle reads tests/oracle, writes
docs/oracle.md with the source of each value, and supports known_failure marks for tracked
bugs. CI runs it on every PR; a manual workflow regenerates the FreeCAD values. step-corpus,
seam normalisation and the through-all emulation, which only existed to compare with Fusion,
are removed.

75/76 pass; 75-sm-bracket-holes is a known failure (#296).


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:43:43 -04:00
f0dda667bb Report accepted Boolean retries on features and fuzz runs (#249)
Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:38:04 -04:00
bherbruckandClaude Opus 5.5 611422e4df Alias navigation preset uses Shift+Alt with LMB/MMB/RMB (Fixes #304) (#306)
Navigation presets are now a table of bindings (button, required modifier
keys, camera mode) instead of string matches. The Alias preset binds
Shift+Alt+left to orbit, Shift+Alt+middle to pan and Shift+Alt+right to
zoom. A left press with a binding's keys is latched until release, so that
drag neither box-selects nor drags sketch geometry; plain left drags behave
as before. Shift only requests snapping when the binding without Shift
still orbits.

The scenario harness sends Alt with ui.drag/ui.click, and camera checks can
assert panned/zoomed since the last camera step. The user guide notes Alt+drag
grabs by KDE and GNOME and how to free them.


Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:33:53 -04:00
bherbruckandClaude Opus 5.5 2cac9cad6a Toolbar: drag commands from drop-downs onto the toolbar; pin icon; Customize Toolbar… (#295)
* Toolbar drop-downs highlight the row under the pointer and the keyboard focus (#288)

A panel drop-down's command rows are full-width rows that take the theme's hover background
(icon, label and shortcut on it) when hovered or focused. Up/Down move the focus through the
enabled rows, Enter starts the command, Escape closes the drop-down. Rows publish
`dropdown:<id>` and the highlighted one `dropdown_hot:<id>`; scenario s46 checks pointer and
keyboard highlight.

Fixes #288

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* Toolbar: drag commands from drop-downs onto the toolbar; pin icon; Customize Toolbar… (#289)

- A panel drop-down row drags onto the toolbar (a button or a panel's free space, with the
  drop line) and is pinned there; the dragged command's icon follows the pointer.
- A toolbar button dragged off the toolbar, or back into the open drop-down, leaves it (its
  icon shows a cross while releasing would remove it).
- A hovered drop-down row shows a pin at its end: click to pin or unpin.
- Right-click on the toolbar's empty space: Customize Toolbar… (a window listing the tab's
  commands with check boxes, and Reset Toolbar) and Reset Toolbar; command menus have
  Customize Toolbar… too.
- Still per workspace and tab, kept with the preferences. Toolbar buttons also publish
  `button:<id>`; scenario s47 drags a row to the toolbar, unpins by dragging off and into the
  drop-down, uses the pin icon and Customize Toolbar…, and checks it survives a restart.

Fixes #289

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* Toolbar drop-down pin: a push pin drawn in code (upright and filled when pinned); control protocol lists the toolbar handles

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:27:25 -04:00
3d99ab4bb1 Appease X11 users with niri/wayland specific postfixup wrapper (#212)
* Appease X11 users with niri/wayland specific postfixup wrapper

* Linux: stay on native Wayland under niri in the app, not the Nix wrapper

Move the niri check from the flake's wrapper into apps/solvecraft/src/main.rs
next to the XWayland default, so it applies to every niri user, not only
Nix builds (flake.nix is back to main's, which also clears the conflict
with #210). SOLVECRAFT_WAYLAND=1 still keeps native Wayland, and =0 now
forces XWayland. docs/nix.md notes the trade-off: no file drag-and-drop
on native Wayland.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:25:59 -04:00
e3938defd8 Fillets along a straight edge between two cylinders: trim curved cap edges to the contacts (#238)
* Fillets along a straight edge between two cylinders: the rolling-ball blend trims curved cap edges to its contacts

The edge where two overlapping extruded arcs meet is a straight line between two cylinders
ending on planar caps. The rolling-ball blend built its sections and surface, but only
straight seams could be shortened to the contacts, so a circular cap edge was refused with
"a curved edge where the blend ends". Curved seams now keep the part of their curve between
the new ends, found from the nearest of a few samples (a search from the far end of a long
arc can settle on the opposite side of the circle).

The ball's surface projections start from each face's last answer instead of a cold search,
falling back to one when the warm answer leaves the surface's parameters or is farther than
the last point: about 4.5x faster per fillet (370 -> 80 ms), so dragging the radius is usable.

Fixes #237

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Curve blend: refuse a closed curved seam where the blend ends

A seam that starts and ends at the same vertex (a full circle) has no
"between" two contacts, so trimming it built a broken edge. Return
"not supported yet" instead. The curved-seam trim moves into
trim_curved_seam so it can be tested directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-12 10:25:52 -04:00
bherbruckandClaude Opus 5.5 f3751bd3ca Changes land through pull requests; maintainers merge their own once CI is green (#309)
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:18:25 -04:00
7d7ef18f49 test: failing regression test for #262: sheet.fold: bend zone reaching the blank outline is reported as a slanted or curved edge (#277)
* test: reproduce #262 (sheet.fold: bend zone reaching the blank outline is reported as a slanted or curved edge)

* fix(sheet): name the bend zone reaching an outline corner instead of blaming a slanted edge (#262)

A fold whose bend zone contains a corner of the panel outline (a fold at a
tab's root) was reported as crossing a slanted or curved edge. Name the
corner and how far to move the fold line to clear it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:08:38 -04:00
f77da83c93 Edit sketch text content and placement from its outlines (#253)
* Expose interactive editing and placement of sketch text

* Respect document units when editing sketch text

* Route Edit Text through the shared feature dialog

The sketch text editor is now a Kind::Text dialog opened through
Dialog::for_command (frame.rs docking, standard OK/Cancel, Enter/Esc).
Its fields are ordinary dialog fields: they take expressions, parameters
and units, and bare lengths are in the design's units. Untouched fields
keep their exact saved values. The dialog stays open across unrelated
design changes and closes only when its text is gone, the sketch ends,
or another command starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:08:15 -04:00
62847173f4 Honor design units in sketch dimensions and measurements (#241)
* Honor design units in sketch dimensions and measurements

* fix: apply document units to specialized length controls

* Accept explicit units in length fields; document design-unit dimension values

Length fields in a design-unit control now evaluate what is typed as a
length expression: a bare number stays in the design's units, while an
explicit unit ("10 mm", "0.5 in", "1 in + 5 mm") is converted.
Document that a bare numeric sketch.dimension value from scripts, the
control channel or MCP is read in the design's units.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:08:11 -04:00
bherbruckandClaude Opus 5.5 df09fdbc32 Preferences: one fixed size, buttons flush right, a real gear icon (#290)
- The dialog draws every page into a fixed, clipped 744x400 area, so no page
  (or translation) changes the window width; descriptions wrap and tall pages
  scroll inside it.
- OK / Cancel / Apply sit flush right in the bottom bar (right-to-left layout
  within the fixed width); Restore Defaults stays left.
- The "settings" icon is a gear: a solid ring with six square-ish teeth and a
  see-through centre hole, instead of the sun-like rays.
- Scenario s46 visits every page and checks the window keeps its width and
  that Apply ends at the bottom bar's right edge (new `same_width` and
  `right_edge` scenario checks).

Fixes #290

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:07:23 -04:00
bherbruckandClaude Opus 5.5 87bb2f37f8 fix(#271): document name?, body_name? and targets? on the commands that accept them
sheet.flange now lists name?/body_name?, solid.loft and solid.sweep list
targets?/name?/body_name?, and the primitives list name?/body_name? (all go
through add_feature, which reads them). A test pins the shared keys for
every body-creating feature.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 10:07:10 -04:00
jonaswre 879aacacf8 test: reproduce #271 (Command docs omit parameters that work: sheet.flange body_name, solid.loft targets) 2026-10-12 10:07:10 -04:00
bherbruckandClaude Opus 5.5 a9007d7b86 fix(#270): mcp --connect failure says how to start the app with --control
When nothing listens on the address, the error now adds: start it with
`solvecraft --control PORT` (or SOLVECRAFT_CONTROL_PORT=PORT) and retry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 10:07:04 -04:00
jonaswre 56a2ed1e4f test: reproduce #270 (solvecraft-cli mcp --connect without a listening app gives no hint) 2026-10-12 10:07:04 -04:00
bherbruckandClaude Opus 5.5 bf60758244 fix(#269): list_commands filter matches id, label, tab and panel, not params docs
Params docs name other commands ("usable in sketch.delete"), so filtering by
an id returned 56 commands. The filter now looks only at id, label, tab and
panel; the tool description and docs/mcp.md say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 10:06:57 -04:00
jonaswre d9613e29cb test: reproduce #269 (MCP list_commands filter also matches parameter docs) 2026-10-12 10:06:57 -04:00
bherbruckandClaude Opus 5.5 0a526c6f85 fix(i18n): keep saved language on font failure, tighten CJK font matching
- Web: a failed CJK font fetch shows English for this session only; the
  saved language preference is kept (CjkFontLoader, persisted on save).
- Web fetches the CJK font only when a CJK language (ja) is selected.
- fonts.rs: directory scans match CJK-specific names only (no "noto" or
  "gothic"); CJK stays the last fallback after the Latin UI font.
- Revert unrelated clippy rewrites in component_clone.rs, coplanar.rs,
  canvas.rs and issue_report.rs to match main.
- Tests: default English, fallback order, CJK-only matching, fetch only
  for CJK, failed fetch keeps the saved language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 10:06:50 -04:00
Phaiz Mithriln 4fbf19f2f9 feat(web): self-host Japanese font and load on-demand without external CDN
- Bundle BIZ UDPGothic Regular (SIL OFL 1.1) exclusively in solvecraft-web dist

- Remove external jsDelivr CDN fallback; fetch font solely from same-origin ./fonts/

- Fetch Japanese font on-demand only when UI language is set to Japanese

- Gracefully fall back to English UI on fetch failure to prevent tofu glyphs

- Update ATTRIBUTION.md, NOTICE, and THIRD-PARTY-LICENSES.txt
2026-10-12 10:06:50 -04:00
Mithril-3d 1fee62dd54 feat: add web CJK font loading and robust cross-platform font detection 2026-10-12 10:06:50 -04:00
bherbruckandClaude Opus 5.5 ab72c5ad56 Bound mesh conversion nesting certification by loop count
certify_nesting runs two planar booleans per pair of boundary loops, but
its work budget counted segments, so a mesh with many small holes could
trigger ~1M booleans. Refuse more than 50 loops (1225 pairs) with a
"too complex to convert" error and test a 400-hole plate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:06:42 -04:00
lillith df4c8a2994 Reconstruct constant-section meshes as parametric solids 2026-10-12 10:06:42 -04:00
bherbruckandClaude Opus 5.5 417511367c Drop the test description from the STEP import docs
User docs describe behaviour, not tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:06:33 -04:00
lillith c3f94ab616 Cover imported STEP colours through edits and round trips 2026-10-12 10:06:33 -04:00
lucas-aguilar89andClaude Opus 5.5 8eda982489 Pattern and Mirror dialogs copy picked bodies, fillets included (#193)
With Object Type = Bodies, the Mirror, Rectangular Pattern and Circular
Pattern dialogs sent the feature that created each picked body
({"features": ["Extrude1"]}), so the copies replayed only that feature and
lost everything done to the body afterwards (a fillet, a chamfer, a cut).
They now send the bodies themselves ({"bodies": [...]}), which the commands
already support and the Path Pattern dialog already did. Picking Features
is unchanged.

Editing a body Mirror or Pattern from the timeline now reopens the dialog
with its bodies selected.

Copies made this way take the body-copy names ("Body1 (2)"), as the
commands already give; pattern_direction_edge now looks up "Body1 (3)".

Tests: UI scenarios mirror_body_keeps_fillets and pattern_body_keeps_fillets
(filleted cube, copy and edit through the dialogs); both fail without the
fix with the copies' sharp 8000 mm3 volume.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:06:18 -04:00
lucas-aguilar89andClaude Opus 5.5 cab698bf12 Offset Plane from a planar body face (#167)
construct.plane.offset now takes a planar face as its base
({"face": [x,y,z], body?, index?, path?}), captured with the same exact
face attachment a sketch on that face uses. The Offset Plane dialog accepts
planar faces as well as origin and construction planes.

A construction plane on a face is resolved through its face anchor when the
timeline is evaluated, so it follows the face when upstream features change
(and after reopening), instead of keeping the plane as it was when picked.

Tests: engine (top and side faces, follows a box resize, survives reopen,
hostile picks are refused without adding a feature) and a UI scenario that
picks a face in the dialog and builds a pad on the new plane.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:06:10 -04:00
Levon Kirakosyan c308a1ba7a build: macOS support for the Nix flake
Add aarch64-darwin to the flake systems so packages, apps and both
devShells are exposed on Apple Silicon Macs. The Wayland/X11/EGL
runtime libraries, the /run/opengl-driver search paths, the freedesktop
packaging and the wrapProgram post-fixup are Linux-only and stay that
way: on macOS winit talks to AppKit, wgpu renders through Metal, and
the GPU drivers ship with the OS, so the darwin package needs no
wrapper. x86_64-darwin is deliberately not added: current nixpkgs
unstable no longer evaluates rustc for that platform (26.11
deprecation), so the package would fail on toolchain evaluation.

docs/nix.md: platform list updated, macOS paragraph in the graphics
section, desktop-entry paragraph qualified as Linux-only.
2026-10-12 10:06:03 -04:00
lillith 874315db8e fix(sketch): apply Project to complete preselection 2026-10-12 10:05:52 -04:00
lillith 56b499e951 Reject invalid explicit axis origins instead of using zero 2026-10-12 09:51:58 -04:00
lillith 8c13914d6c Ignore repeated initial sketch points while drawing 2026-10-12 09:30:18 -04:00
bherbruckandClaude Opus 5.5 f0292b1606 CONTRIBUTING: failing regression tests without a fix are marked #[ignore = "fails until #N is fixed"]
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 13:08:35 +00:00
lillith 732447d2dc Fix dialog header chevrons to collapse and restore forms 2026-10-12 09:08:31 -04:00
lillith db0a2a007a Apply remaining sketch constraints to complete preselection 2026-10-12 08:48:05 -04:00
lillith ed102b5393 fix: reject degenerate boolean intersection polylines 2026-10-12 08:36:40 -04:00
bherbruck 970da30a99 Merge pull request #207 from crono2250/fix/spacemouse-axis-mapping-198
Fix SpaceMouse axis mapping and apply 3DxWare settings
2026-10-12 08:14:51 -04:00
bherbruck 113ded5fab Merge pull request #222 from ich777/lang-german
feat: add German (de) UI localization
2026-10-12 07:51:46 -04:00
bherbruck b4491ac605 Remove premature craft family affiliation claims 2026-10-12 07:14:49 -04:00
ich777 53b092d287 feat: add German (de) UI localization
Adds German (Deutsch) as a third UI language, following the procedure outlined in docs/localization.md
2026-10-12 08:10:14 +02:00
kenos 25ea43e89f fix: apply 3DxWare SpaceMouse axis settings 2026-10-12 11:52:29 +09:00
789 changed files with 83507 additions and 10085 deletions

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
[profile.ci]
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
default-filter = """
not (
(package(solidcraft-engine) & (
test(=tests::hostile_params_never_panic)
| test(/^tests::robustness::/)
| test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen)
| test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs)
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
))
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
)
"""
fail-fast = false
# Every test's time in the log, so the slow ones are easy to find.
status-level = "pass"
final-status-level = "slow"
failure-output = "immediate-final"
slow-timeout = { period = "60s" }
[profile.ci.junit]
path = "junit.xml"
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [bherbruck]
ko_fi: bherbruck
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: Bug report
about: Help reproduce and fix a SolveCraft problem
about: Help reproduce and fix a SolidCraft problem
title: ''
labels: ''
assignees: ''
@@ -12,7 +12,7 @@ What did you expect, and what happened instead?
## Reproduction
Include steps, SolveCraft version/commit and relevant environment details. Prefer a small example without private design data.
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
## Screenshots of the behavior (when observable)
+63 -18
View File
@@ -3,41 +3,86 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
Compatibility names are unchanged: existing `.solvecraft` designs, `solvecraft-cli`, package
IDs and archive filenames still work. Published 0.2.0 and 0.2.1 releases keep their original
SolveCraft branding. Documentation, the browser app and the Flatpak feed use the new
SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliases.
**Why 0.7:** this is a minor bump because the design file format moved to version 15 (sketches
on cylinder caps and sheet metal panels need new references), and because it adds new features.
**What's new**
- **Sketching on stacked extrudes works again.** Creating a sketch on the top face of an extrude
stacked on another failed with "operation failed" since 0.3
([#489](https://github.com/bherbruck/solidcraft/issues/489)). It's fixed, and every flat face
of the test models is now checked for this on every change.
- **Select Other:** hold or right-click to pick something hidden under something else; click
again to cycle through what's underneath.
- **Tabs are named after their file** instead of "Untitled".
- **Modelling:** a flange angle and height per edge, Patch fills non-planar loops, sketches on
cylinder caps and on sheet metal faces, and a feature rename updates everything that refers to
it.
- **Fixes:** revolve with edges on the axis, shell and fillet with small rounds, Join keeps face
attachments, and STEP export writes each lump of a body as its own solid.
- **Navigation and UI:** orbiting from empty space turns around the visible geometry, Ctrl+drag
orbits in every preset, toolbar drops land across labels and gaps, Combine previews before OK,
and sketch constraints accept the sketch's own x / y axes.
- Opening a design reads its metadata and preview through a bounded, seekable reader; the docs
and web app have link preview cards.
**File format.** Designs are now format 15 (`solidcraft/15`). SolidCraft 0.6 and older can't
open designs saved by 0.7, as a container or as JSON; keep a copy of a file you may still open
in 0.6. Files from every earlier version still open in 0.7.
**Known limitations**
- Some faces still need the fixed-plane fallback for sketches: side faces Join merged from two
extrudes ([#491](https://github.com/bherbruck/solidcraft/issues/491)), faces split by a later
feature ([#492](https://github.com/bherbruck/solidcraft/issues/492)), Cut walls and chamfer
faces ([#493](https://github.com/bherbruck/solidcraft/issues/493)), faces after Revolve,
Offset Face or Boss ([#494](https://github.com/bherbruck/solidcraft/issues/494)) and sheet
metal thickness faces ([#495](https://github.com/bherbruck/solidcraft/issues/495)).
- Cutting a coaxial cylinder from a thin disc can lose a little volume
([#500](https://github.com/bherbruck/solidcraft/issues/500)); an edge flange with different
per-edge angles leaves gaps at the corners
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
container the default save format. `.solvecraft` designs, old settings folders and
`SOLVECRAFT_*` variables are still picked up, and `solvecraft-cli` still forwards to
`solidcraft-cli`. Want plain JSON? Use **File ▸ Save As JSON…** or `--save design.json`. On
macOS, replace `SolveCraft.app` with `SolidCraft.app`. Details:
[compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs.
**Which file?**
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
`-portable.zip` (x64, x86 and arm64).
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolveCraft.app` onto Applications.
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolidCraft.app` onto Applications.
The CLI is in `solidcraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), the `.snap`
(`sudo snap install --dangerous <file>`, or `sudo snap install solidcraft` from the Snap Store), or the `.tar.gz`.
- **FreeBSD:** `tar -xzf solidcraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
- **Web:** `solidcraft-web-{version}.zip` is a static site to host yourself.
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
- Files without a version in the name (`solidcraft-windows-x64.msi`, `solidcraft-linux-x86_64.AppImage`, …)
are the same packages under fixed names, for download links that always fetch the latest release.
For Flatpak feed installation and updates, add Flathub for the required runtime before
installing SolidCraft; keep both remotes in the same user installation:
```sh
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --user --if-not-exists solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo
flatpak install --user solvecraft ai.storyteller.solvecraft
flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
flatpak install --user solidcraft ai.storyteller.solvecraft
flatpak run ai.storyteller.solvecraft
```
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
The builds aren't signed yet. On macOS, right-click `SolveCraft.app` in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`.
+123 -11
View File
@@ -1,9 +1,15 @@
# Fast checks always run; native builds run for code/CI changes.
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
name: CI
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
inputs:
cross_compile:
@@ -16,7 +22,8 @@ permissions:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Every push to main gets its full run; a new PR push replaces the old run.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
@@ -39,7 +46,7 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
native=false
if [ "$EVENT_NAME" = workflow_dispatch ]; then
if [ "$EVENT_NAME" != pull_request ]; then
native=true
else
# Compare both old and new paths of renames; unknown paths run native checks.
@@ -92,12 +99,12 @@ jobs:
key: native-clippy
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
tests:
name: Native Linux tests
build-tests:
name: Build tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 90
timeout-minutes: 45
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
@@ -113,18 +120,117 @@ jobs:
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Compile tests
run: cargo test --workspace --profile ci --locked --no-run
- name: Run tests
# Every test binary at once (cargo test runs them one after another).
run: cargo xtask test --locked
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- name: Compile tests into a nextest archive
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nextest-archive
path: tests.tar.zst
retention-days: 1
compression-level: 0
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
tests:
name: Native Linux tests (${{ matrix.shard }}/4)
needs: build-tests
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run tests
env:
EVENT_NAME: ${{ github.event_name }}
SHARD: ${{ matrix.shard }}
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
all=()
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-${{ matrix.shard }}
path: target/nextest/ci/junit.xml
retention-days: 7
doc-tests:
name: Doc tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: doc-tests
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# nextest does not run doc tests.
- run: cargo test --workspace --profile ci --locked --doc
oracle:
name: Reference tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Build the command line
run: cargo build -p solidcraft-cli --profile ci --locked
- name: Replay tests/oracle and compare with expected.json
run: cargo xtask oracle --cli target/ci/solidcraft-cli
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: oracle-report
path: docs/oracle.md
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, tests]
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle]
if: always()
runs-on: ubuntu-24.04
steps:
@@ -134,13 +240,19 @@ jobs:
CHECKS: ${{ needs.checks.result }}
NATIVE: ${{ needs.changes.outputs.native }}
CLIPPY: ${{ needs.clippy.result }}
BUILD: ${{ needs.build-tests.result }}
TESTS: ${{ needs.tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$BUILD" = success
test "$TESTS" = success
test "$DOCS" = success
test "$ORACLE" = success
fi
wasm:
+47
View File
@@ -0,0 +1,47 @@
# Optional, run by hand: rebuild the reference cases whose expected values come from FreeCAD
# (tools/freecad-oracle/) and show what changed. FreeCAD runs as a separate program on the runner;
# it is not a dependency of SolidCraft. Nothing is committed: review the diff and the uploaded
# files, then commit them in a pull request if the change is intended.
name: FreeCAD reference values
on:
workflow_dispatch:
inputs:
cases:
description: "Case directories (space separated); empty = every case whose source is FreeCAD"
type: string
default: ""
permissions:
contents: read
jobs:
regenerate:
name: Regenerate FreeCAD expected values
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install FreeCAD
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends freecad
freecadcmd --version || true
- name: Rebuild the cases
env:
CASES: ${{ inputs.cases }}
run: |
if [ -z "$CASES" ]; then
CASES=$(grep -l '"source": *"freecad' tests/oracle/*/expected.json | xargs -n1 dirname | tr '\n' ' ')
fi
# shellcheck disable=SC2086
tools/freecad-oracle/run.sh --write $CASES
- name: Show the changes
run: git diff --stat && git diff -- tests/oracle/*/expected.json
- name: Upload the regenerated files
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: freecad-expected
path: |
tests/oracle/*/expected.json
tests/oracle/*/reference.stl
+4 -2
View File
@@ -52,8 +52,10 @@ jobs:
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solidcraft'"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
python3 -m py_compile packaging/linux/snap-smoke.py
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Build the book
run: cargo xtask book
- name: Build the web app
working-directory: apps/solvecraft-web
working-directory: apps/solidcraft-web
run: trunk build --release --public-url /solidcraft/app/
- name: Assemble the site
run: |
+134 -25
View File
@@ -6,12 +6,16 @@
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
# Web a .zip of the static site packaging/web
#
# The main installers are also attached without the version in their names
# (solidcraft-windows-x64.msi, solidcraft-linux-x86_64.AppImage, ...) so that
# releases/latest/download/<name> links in the README keep working across releases.
#
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
name: Release
@@ -79,7 +83,7 @@ jobs:
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
@@ -91,10 +95,10 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solvecraft-cli
- run: cargo build --release --locked -p solidcraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solvecraft-cli icons
make_icons target/release/solidcraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
@@ -114,8 +118,8 @@ jobs:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -135,17 +139,22 @@ jobs:
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solidcraft$|usr/bin/solidcraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
# Compatibility shims (docs/compatibility.md): the old CLI name and the package rename.
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft-cli$'
dpkg-deb --field dist/release/*.deb Replaces Conflicts Provides | grep -F solvecraft
rpm -qip dist/release/*.rpm
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
ldd target/release/solvecraft
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
ldd target/release/solidcraft
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
# Legacy AppImage update alias (docs/compatibility.md).
ls dist/release/solvecraft-*-linux-*.AppImage.zsync
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
@@ -166,8 +175,8 @@ jobs:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -181,7 +190,7 @@ jobs:
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
run: packaging/linux/flatpak-bundle.sh "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
@@ -190,6 +199,59 @@ jobs:
snap:
name: Snap ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# Repackages the Linux job's tarball, like the Flatpak. On 24.04 because the core24 base is
# Ubuntu 24.04, which lets snapcraft build on the runner itself (--destructive-mode).
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install snapcraft and the smoke-test tools
run: |
sudo snap install snapcraft --classic
sudo apt-get update
sudo apt-get install -y --no-install-recommends xvfb xauth weston dbus-x11
- name: Build the snap
run: |
sudo --preserve-env=SOLIDCRAFT_VERSION,SOLIDCRAFT_BUILD_DATE env "PATH=$PATH" \
packaging/linux/snap-pack.sh --destructive-mode "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
sudo chown -R "$(id -u):$(id -g)" dist target
- name: Install it and start the app on headless X11 and Wayland
run: |
snap_file="dist/release/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.snap"
sudo snap install --dangerous "$snap_file"
snap info --verbose "$snap_file" | head -n 12
snap connections solidcraft
solidcraft.cli --version
# Snaps expect the login session's runtime directory, which runners don't create.
sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "/run/user/$(id -u)"
python3 packaging/linux/snap-smoke.py --out snap-smoke
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: snap-smoke-${{ matrix.arch }}
path: snap-smoke/*.png
if-no-files-found: error
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-snap-${{ matrix.arch }}
path: dist/release/*.snap
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
@@ -198,8 +260,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 180
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM (tar.gz)
@@ -211,7 +273,7 @@ jobs:
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
prepare: |
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
@@ -221,10 +283,10 @@ jobs:
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
# runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
@@ -237,8 +299,8 @@ jobs:
if: needs.version.outputs.build == 'true'
runs-on: macos-14
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
@@ -269,7 +331,7 @@ jobs:
# Windows on ARM: cross-compiled on the x64 runner.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -290,7 +352,7 @@ jobs:
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
@@ -304,7 +366,7 @@ jobs:
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -328,7 +390,7 @@ jobs:
release:
name: GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, freebsd, macos, windows, web]
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
permissions:
@@ -345,6 +407,24 @@ jobs:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Version-less copies for permanent download links
# The README links to releases/latest/download/<name>, which must not change between
# releases: solidcraft-<version>-windows-x64.msi is also uploaded as
# solidcraft-windows-x64.msi, and so on for the main installers.
working-directory: artifacts
run: |
shopt -s nullglob
for f in "solidcraft-$VERSION-"*; do
case "$f" in
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap)
cp -- "$f" "solidcraft-${f#"solidcraft-$VERSION-"}" ;;
esac
done
for f in solidcraft-windows-x64.msi solidcraft-macos-universal.dmg \
solidcraft-linux-x86_64.AppImage solidcraft-linux-x86_64.deb solidcraft-linux-x86_64.rpm; do
test -s "$f" || { echo "::error::missing version-less copy $f"; exit 1; }
done
ls -l solidcraft-[!0-9]*
- name: Checksums and notes
run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
@@ -398,6 +478,35 @@ jobs:
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Snap Store: nightlies go to the edge channel and release drafts to candidate, only after
# every package built and the GitHub release was staged. Publishing the GitHub release
# promotes candidate to stable (snap-store.yml). Dry runs never upload. Skipped until the
# owner adds the SNAPCRAFT_STORE_CREDENTIALS secret (docs/releasing.md).
- id: snapstore
name: Check for Snap Store credentials
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
run: |
if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
echo "configured=true" >>"$GITHUB_OUTPUT"
else
echo "No SNAPCRAFT_STORE_CREDENTIALS secret: skipping the Snap Store upload."
fi
- name: Upload the snaps to the Snap Store
if: >-
steps.snapstore.outputs.configured == 'true' && (github.event_name == 'schedule' ||
(github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)))
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
working-directory: artifacts
run: |
sudo snap install snapcraft --classic
channel=candidate
if [ "$GITHUB_EVENT_NAME" = schedule ]; then channel=edge; fi
for arch in x86_64 aarch64; do
snapcraft upload --release="$channel" "solidcraft-$VERSION-linux-$arch.snap"
done
echo "Uploaded the snaps to the Snap Store's $channel channel." >>"$GITHUB_STEP_SUMMARY"
- name: Keep the files (dry run)
if: github.event_name == 'workflow_dispatch' && !inputs.draft
run: cp notes.md artifacts/release-notes.md
+40
View File
@@ -0,0 +1,40 @@
# Publishing a GitHub release (not a prerelease) promotes the snaps that release.yml uploaded to
# the Snap Store's candidate channel to stable, so `sudo snap install solidcraft` gets it.
# Skipped until the owner adds the SNAPCRAFT_STORE_CREDENTIALS secret; see docs/releasing.md.
name: Snap Store
on:
release:
types: [released]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: snap-store
cancel-in-progress: false
jobs:
promote:
runs-on: ubuntu-latest
steps:
- id: creds
name: Check for Snap Store credentials
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
run: |
if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
echo "configured=true" >>"$GITHUB_OUTPUT"
else
echo "No SNAPCRAFT_STORE_CREDENTIALS secret: nothing to promote."
fi
- name: Promote candidate to stable
if: steps.creds.outputs.configured == 'true'
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
run: |
sudo snap install snapcraft --classic
snapcraft status solidcraft
snapcraft promote solidcraft --from-channel=latest/candidate --to-channel=latest/stable --yes
snapcraft status solidcraft
+50 -41
View File
@@ -2,24 +2,21 @@
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name and compatibility
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
and attribution records keep their historical names. Public docs/web/Flatpak deployments
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
compatibility notes.
## Product name
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
`/solidcraft/`; do not add permanent old-name Pages aliases.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
recipes and measurements).
and the relevant `plan/architecture.md` section.
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
@@ -36,26 +33,35 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
panic restores the design and reports `EngineError::Internal`.
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
artwork, help text, tooltips, templates or materials, and never commit files produced by
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
holds ids and names, nothing else). File formats come from public specifications. **Never read,
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
- **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. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no Autodesk iconography, images,
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Every icon is drawn in code
(`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
@@ -72,10 +78,12 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- **Rust only.** Units are millimetres and radians internally; Z is up.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. After each commit that
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
@@ -112,21 +120,22 @@ version bumps solely from commit prefixes, overwrite published versions, or push
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solvecraft-cli snapshot`.
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
with Fusion's `measure.json` (writes `docs/oracle.md`).
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
`ui.render` or `solidcraft-cli snapshot`.
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
`cargo xtask oracle` recomputes `docs/oracle.md`.
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
+57 -16
View File
@@ -4,8 +4,8 @@ Every non-code asset in this repository (images, icons, fonts, example parts) is
its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset
file is missing from this table.
**Policy (mandatory):** SolveCraft contains **no Autodesk iconography, images, artwork, fonts,
templates, materials or help text.** Every asset is original work by SolveCraft contributors or
**Policy (mandatory):** SolidCraft contains **no Autodesk iconography, images, artwork, fonts,
templates, materials or help text.** Every asset is original work by SolidCraft contributors or
third-party material under an open licence. Screenshots of Autodesk software are never committed.
Generated-in-code assets are original and have no file to list:
@@ -17,37 +17,78 @@ Generated-in-code assets are original and have no file to list:
| File | Author | Source | Licence |
|---|---|---|---|
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/enclosure-demo-0.2.1.gif | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| assets/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
| assets/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
| assets/SolidCraft.icon/Assets/02-grid-dark.svg | SolidCraft contributors | original blue perspective grid with elliptical radial alpha, exported from VectorCraft for the Dark appearance | MIT OR Apache-2.0 |
| assets/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | SolidCraft contributors | original saturated blue CAD solid with transparent topology cutouts, edited and exported with VectorCraft | MIT OR Apache-2.0 |
| assets/Assets.car | SolidCraft contributors | Apple actool compilation of assets/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 |
| assets/icon.vectorcraft | SolidCraft contributors | editable VectorCraft source for assets/icon.svg, with named face and background layers | MIT OR Apache-2.0 |
| assets/icon.png | SolidCraft contributors | 1024 × 1024 transparent sRGB export of assets/icon.svg from VectorCraft | MIT OR Apache-2.0 |
| assets/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/SolidCraft.icon composition | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_16x16.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_16x16@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_32x32.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_32x32@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_128x128.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_128x128@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_256x256.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.6.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.6.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
| docs/book/src/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in apps/solidcraft-web/fonts/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/30-fillet-after-shell/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/30-fillet-after-shell/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/31-fillet-l-all-edges/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/31-fillet-l-all-edges/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/34-chamfer-then-fillet/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/34-chamfer-then-fillet/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/35-fillet-curved-curved/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/35-fillet-curved-curved/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/36-corner-blend-two-radii/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/36-corner-blend-two-radii/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/37-pyramid-apex-blend/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/37-pyramid-apex-blend/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/39-tee-pipe/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/39-tee-pipe/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/41-tube-radial-holes/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/41-tube-radial-holes/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/42-overlapping-patterns/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/42-overlapping-patterns/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/47-draft-curved-pocket/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/47-draft-curved-pocket/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/51-loft-rect-circle-offset/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/51-loft-rect-circle-offset/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/56-holes-on-curved-face/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/56-holes-on-curved-face/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/58-phone-stand/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/58-phone-stand/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/59-enclosure-base/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/59-enclosure-base/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
## Code adapted from sibling projects
| SolveCraft file | Adapted from | Licence |
| SolidCraft file | Adapted from | Licence |
|---|---|---|
| xtask/src/layers.rs | CADCraft `xtask/src/layers.rs` (dependency layering rules and tests) | MIT OR Apache-2.0 |
| apps/solvecraft/src/control_server.rs | CADCraft `apps/cadcraft/src/control_server.rs` (loopback JSON-lines server) | MIT OR Apache-2.0 |
| apps/solidcraft/src/control_server.rs | CADCraft `apps/cadcraft/src/control_server.rs` (loopback JSON-lines server) | MIT OR Apache-2.0 |
| crates/ui-egui/src/lib.rs (screenshot queue, synthetic input) | CADCraft `crates/ui-egui/src/lib.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/control.rs (method dispatch and screenshot saving) | CADCraft `crates/ui-egui/src/control.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/gpu.rs (paint-callback structure) | CADCraft `crates/ui-egui/src/gpu.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/server.rs, crates/mcp/src/backend.rs (JSON-RPC framing, MCP lifecycle, control-port bridge) | CADCraft `crates/mcp/src/{server,backend}.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solidcraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
## Recovered contributions
| SolveCraft file | Original contribution | Licence |
| SolidCraft file | Original contribution | Licence |
|---|---|---|
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solidcraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solidcraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solidcraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
## Vendored third-party code
@@ -55,7 +96,7 @@ Generated-in-code assets are original and have no file to list:
|---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
## Vendored renderer compatibility fix
+50 -41
View File
@@ -2,24 +2,21 @@
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name and compatibility
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
and attribution records keep their historical names. Public docs/web/Flatpak deployments
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
compatibility notes.
## Product name
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
`/solidcraft/`; do not add permanent old-name Pages aliases.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
recipes and measurements).
and the relevant `plan/architecture.md` section.
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
@@ -36,26 +33,35 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
panic restores the design and reports `EngineError::Internal`.
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
artwork, help text, tooltips, templates or materials, and never commit files produced by
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
holds ids and names, nothing else). File formats come from public specifications. **Never read,
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
- **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. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no Autodesk iconography, images,
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Every icon is drawn in code
(`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
@@ -72,10 +78,12 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- **Rust only.** Units are millimetres and radians internally; Z is up.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. After each commit that
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
@@ -89,21 +97,22 @@ version bumps solely from commit prefixes, overwrite published versions, or push
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solvecraft-cli snapshot`.
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
with Fusion's `measure.json` (writes `docs/oracle.md`).
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
`ui.render` or `solidcraft-cli snapshot`.
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
`cargo xtask oracle` recomputes `docs/oracle.md`.
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
+49 -25
View File
@@ -17,23 +17,23 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
```sh
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
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 and repository name. Cargo packages and executables retain
their `solvecraft` names for compatibility with existing scripts and MCP clients.
Build the desktop app with `cargo build --release -p solvecraft`, or the CLI with
`cargo build --release -p solvecraft-cli`. Run the CLI with
`cargo run --release -p solvecraft-cli -- commands` (or `target/release/solvecraft-cli commands`
after building). There is no Cargo package named `solidcraft`.
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](docs/compatibility.md); don't add new uses of it.
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
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 solvecraft`.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
### NixOS: `nix develop`
@@ -43,7 +43,7 @@ loader wgpu renders through, and libdbus-1, which rfd's file dialogs reach xdg-d
through (a dialog then never appears and the pick returns no path):
```
ERROR solvecraft: winit EventLoopError: ... The wayland library could not be loaded
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
```
@@ -59,8 +59,8 @@ The default development shell retains the Rust/C toolchain already on `PATH`;
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 and compatible `solvecraft`
command run the same wrapped app; `solvecraft-cli` is included as well. See
desktop menu. The installed `solidcraft` command runs the wrapped app; `solidcraft-cli` is included as
well. See
[Nix installation and troubleshooting](docs/nix.md) for remote-flake commands,
desktop integration and how to distinguish an old executable on `PATH`.
@@ -82,19 +82,22 @@ The [README](README.md#architecture) has the layer table. The short version:
commands.
- `crates/ui-egui`: the desktop/web front end (egui + wgpu). It's swappable; nothing below it
depends on egui.
- `crates/mcp`, `apps/solvecraft`, `apps/solvecraft-cli`, `apps/solvecraft-web`.
- `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. **Clean-room.** We observe Fusion as a black box: its UI, how parts it builds measure, and
what it does with a given input. We never copy Autodesk code, icons, artwork, help text,
tooltips, templates or materials, and we never commit files Fusion produced. Command ids are
our own (`solid.fillet`, not Fusion's internal names).
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. Permissive
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, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
3. **Every asset is original or openly licensed and has a row in
@@ -104,7 +107,7 @@ The [README](README.md#architecture) has the layer table. The short version:
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
There's no `unsafe`, and clippy denies it.
- An unsupported case returns an error ("not supported yet: …"); it never panics.
- Every call into truck goes through `solvecraft_kernel::guard`.
- 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/`, engine tests that measure the result (volume, area,
@@ -119,7 +122,7 @@ the most complete description of the house style.
|---|---|---|
| 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` |
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (see below) |
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
press keys, then assert on the result.
@@ -138,6 +141,10 @@ press keys, then assert on the result.
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
@@ -146,11 +153,28 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
SolidCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
measurements; [docs/oracle.md](docs/oracle.md) has the current results. The recipes and
measurements live outside the repo (`plan/`, gitignored), because they come from a Fusion
install. If you work on geometry and want to check against it, ask in an issue.
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
quarter each, so 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 four minutes each), which run on every push to
`main` and nightly; check the `main` run after merging. They are listed in
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness`,
`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`
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
**The reference tests** are 76 parts in [`tests/oracle/`](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. [docs/oracle.md](docs/oracle.md) has
the current results, and CI runs `cargo xtask oracle` on every pull request. 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.
## Sending a change
Generated
+53 -48
View File
@@ -3289,88 +3289,93 @@ dependencies = [
]
[[package]]
name = "solvecraft"
version = "0.3.2"
name = "solidcraft"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
"embed-resource",
"hidapi",
"log",
"quick-xml 0.38.4",
"rfd",
"serde_json",
"solvecraft-engine",
"solvecraft-ui-egui",
"solidcraft-engine",
"solidcraft-ui-egui",
"winit",
]
[[package]]
name = "solvecraft-cli"
version = "0.3.2"
name = "solidcraft-cli"
version = "0.7.0"
dependencies = [
"serde_json",
"solvecraft-engine",
"solvecraft-mcp",
"solidcraft-engine",
"solidcraft-mcp",
]
[[package]]
name = "solvecraft-doc"
version = "0.3.2"
name = "solidcraft-doc"
version = "0.7.0"
dependencies = [
"log",
"serde",
"serde_json",
"sha2",
"solvecraft-geom",
"solvecraft-kernel",
"solvecraft-sketch",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-sketch",
"thiserror 2.0.21",
"web-time",
]
[[package]]
name = "solvecraft-engine"
version = "0.3.2"
name = "solidcraft-engine"
version = "0.7.0"
dependencies = [
"log",
"serde",
"serde_json",
"solvecraft-doc",
"solvecraft-geom",
"solvecraft-io",
"solvecraft-kernel",
"solvecraft-render",
"solvecraft-sketch",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
"solidcraft-kernel",
"solidcraft-render",
"solidcraft-sketch",
"thiserror 2.0.21",
]
[[package]]
name = "solvecraft-geom"
version = "0.3.2"
name = "solidcraft-geom"
version = "0.7.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "solvecraft-io"
version = "0.3.2"
name = "solidcraft-io"
version = "0.7.0"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
"solvecraft-geom",
"solvecraft-kernel",
"serde",
"serde_json",
"sha2",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "solvecraft-kernel"
version = "0.3.2"
name = "solidcraft-kernel"
version = "0.7.0"
dependencies = [
"log",
"serde",
"solvecraft-geom",
"solidcraft-geom",
"thiserror 2.0.21",
"truck-meshalgo",
"truck-modeling",
@@ -3380,37 +3385,37 @@ dependencies = [
]
[[package]]
name = "solvecraft-mcp"
version = "0.3.2"
name = "solidcraft-mcp"
version = "0.7.0"
dependencies = [
"serde_json",
"solvecraft-engine",
"solidcraft-engine",
]
[[package]]
name = "solvecraft-render"
version = "0.3.2"
name = "solidcraft-render"
version = "0.7.0"
dependencies = [
"image",
"serde",
"serde_json",
"solvecraft-geom",
"solidcraft-geom",
]
[[package]]
name = "solvecraft-sketch"
version = "0.3.2"
name = "solidcraft-sketch"
version = "0.7.0"
dependencies = [
"serde",
"serde_json",
"solvecraft-geom",
"solidcraft-geom",
"thiserror 2.0.21",
"ttf-parser",
]
[[package]]
name = "solvecraft-ui-egui"
version = "0.3.2"
name = "solidcraft-ui-egui"
version = "0.7.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3418,22 +3423,22 @@ dependencies = [
"log",
"serde",
"serde_json",
"solvecraft-engine",
"solidcraft-engine",
"ttf-parser",
"web-time",
]
[[package]]
name = "solvecraft-web"
version = "0.3.2"
name = "solidcraft-web"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"serde_json",
"solvecraft-engine",
"solvecraft-ui-egui",
"solidcraft-engine",
"solidcraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -4848,7 +4853,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.3.2"
version = "0.7.0"
dependencies = [
"serde_json",
]
+14 -12
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.3.2"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -14,15 +14,15 @@ repository = "https://github.com/bherbruck/solidcraft"
[workspace.dependencies]
# internal
solvecraft-geom = { path = "crates/geom" }
solvecraft-sketch = { path = "crates/sketch" }
solvecraft-kernel = { path = "crates/kernel" }
solvecraft-render = { path = "crates/render" }
solvecraft-doc = { path = "crates/doc" }
solvecraft-io = { path = "crates/io" }
solvecraft-engine = { path = "crates/engine" }
solvecraft-ui-egui = { path = "crates/ui-egui" }
solvecraft-mcp = { path = "crates/mcp" }
solidcraft-geom = { path = "crates/geom" }
solidcraft-sketch = { path = "crates/sketch" }
solidcraft-kernel = { path = "crates/kernel" }
solidcraft-render = { path = "crates/render" }
solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-mcp = { path = "crates/mcp" }
# foundation
thiserror = "2"
@@ -92,9 +92,11 @@ opt-level = 2
opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Line tables only: crash reports name file:line in their backtraces (no runtime cost; a larger
# binary). See docs/crash-reports.md.
debug = "line-tables-only"
# truck-shapeops with SolveCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
# truck-shapeops with SolidCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
[patch.crates-io]
truck-shapeops = { path = "vendor/truck-shapeops" }
truck-stepio = { path = "vendor/truck-stepio" }
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 SolveCraft contributors
Copyright (c) 2026 SolidCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+8 -7
View File
@@ -1,5 +1,5 @@
SolveCraft
Copyright (c) 2026 SolveCraft contributors.
SolidCraft
Copyright (c) 2026 SolidCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
@@ -7,10 +7,10 @@ Third-party material
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
`solidcraft-cli licences`). The geometry kernel builds on the truck crates
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolidCraft
modifications (listed in vendor/README.md and marked in the source); its licence is in
vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
@@ -19,9 +19,10 @@ licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
list is in ATTRIBUTION.md.
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The
per-file list is in ATTRIBUTION.md.
SolveCraft is an independent project and is not affiliated with, sponsored by or endorsed by
SolidCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
describe compatibility goals.
+101 -46
View File
@@ -4,37 +4,70 @@
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server. Part of the storytold "craft" family.
model through the built-in MCP server.
![SolidCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif)
![SolidCraft 0.6: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, a colour and a height change in Parameters](docs/screenshots/demo-0.6.gif)
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots and a colour, then height 24 → 32 mm in Parameters and it all rebuilds. 6× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.6.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
## Download
[![Download for Windows][badge-windows]][windows-msi]
[![Download for macOS][badge-macos]][macos-dmg]
[![Download for Linux][badge-linux]][linux-appimage]
[![Get it from the Snap Store][badge-snap]][snap-store]
[![Try it in your browser][badge-web]][web-app]
- **Windows:** download and run the [installer][windows-msi].
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
[portable zip][windows-portable] that runs without installing.
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
(Apple silicon and Intel).
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora,
openSUSE), or use [Flatpak](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]).
For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm].
- **Web:** [try it in your browser][web-app], no install.
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
Older versions, checksums and other formats are on the [releases page](https://github.com/bherbruck/solidcraft/releases).
Install details, upgrading from earlier builds and building from source are in
[Getting started](https://bherbruck.github.io/solidcraft/guide/getting-started.html).
[badge-windows]: https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0yIDJoOS41djkuNUgyek0xMi41IDJIMjJ2OS41aC05LjV6TTIgMTIuNWg5LjVWMjJIMnpNMTIuNSAxMi41SDIyVjIyaC05LjV6Ii8%2BPC9zdmc%2B
[badge-macos]: https://img.shields.io/badge/macOS-Download-333333?style=for-the-badge&logo=apple&logoColor=white
[badge-linux]: https://img.shields.io/badge/Linux-AppImage-E95420?style=for-the-badge&logo=linux&logoColor=white
[badge-snap]: https://img.shields.io/badge/Snap-Snap_Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white
[badge-web]: https://img.shields.io/badge/Web-Try_in_browser-2EA44F?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwTTEyIDJhMTUgMTUgMCAwIDEgMCAyME0xMiAyYTE1IDE1IDAgMCAwIDAgMjAiLz48L3N2Zz4%3D
[windows-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
[windows-arm64-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi
[windows-x86-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x86.msi
[windows-portable]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip
[macos-dmg]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
[linux-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
[linux-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb
[linux-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm
[linux-arm64-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage
[linux-arm64-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb
[linux-arm64-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm
[linux-snap]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap
[snap-store]: https://snapcraft.io/solidcraft
[web-app]: https://bherbruck.github.io/solidcraft/app/
**Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the
[web app](https://bherbruck.github.io/solidcraft/app/) to try it in the browser.
developer guide and command reference; sources in [docs/book](docs/book)).
Linux Flatpak installation needs the Freedesktop runtime from Flathub as well as the
SolidCraft application feed. See [install and launch commands](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
**Status: early but usable for simple parts.** The historical benchmark report records
76 of 76 reference parts matching volume, area and topology ([docs/oracle.md](docs/oracle.md)).
These checks do not establish complete geometric or workflow equivalence. Feature coverage
is tracked in [docs/parity.md](docs/parity.md) and [ROADMAP.md](ROADMAP.md).
**Status: early but usable for simple parts.** 75 of 76 public reference parts
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
are listed by area in [docs/features.md](docs/features.md); status and plans are in [ROADMAP.md](ROADMAP.md).
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
## Name and compatibility
SolidCraft was previously called SolveCraft. The product display name and repository are
changing; existing identifiers remain compatible: `.solvecraft` designs, `solvecraft-cli`,
Cargo package names, settings/recovery locations and package IDs keep their existing names.
Published 0.2.0 and 0.2.1 packages and demo recordings retain their original SolveCraft branding
and filenames.
Use the SolidCraft documentation and web-app URLs above. GitHub redirects links to the
renamed repository; old-name Pages and Flatpak URLs are not maintained as permanent aliases.
## What works
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
@@ -48,11 +81,15 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
the boundary and keeps existing extrusions usable.
Curve drawing and picking adapt together to zoom and display scale, including small circles
at Fit and sketches in placed components.
- **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
rejected; fully constrained geometry turns black.
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
step. Fit keeps sketch geometry clear of the palette and navigation controls.
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
@@ -73,11 +110,14 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables;
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
separately from through-hole outlines in the flat preview and DXF.
- **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
experimental preview (explicit `preview: true` engine saves) while checking geometry before editing. Export to STEP (AP242 with body names, colours and
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies);
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
@@ -87,31 +127,45 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
control channel so agents and tests can drive it.
Feature errors and Help can open an editable **Create Issue** draft with relevant context
and an optional reviewed screenshot; opening GitHub does not submit the report.
- **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass,
- **Headless CLI** `solidcraft-cli`: run command scripts, measure (volume, area, centre of mass,
face/edge/vertex counts), export, render PNG snapshots and replay reference recipes.
- **MCP server** `solvecraft-cli mcp`: AI agents model, measure and look at parts headless or
- **MCP server** `solidcraft-cli mcp`: AI agents model, measure and look at parts headless or
in the running app (see below).
## Build and run
```sh
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo run --release -p solvecraft-cli -- commands # the command registry
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solvecraft -- part.step # open a STEP file as a new design
cargo run --release -p solvecraft-cli -- eval part.step # measure an imported STEP file
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
cargo run --release -p solidcraft-cli -- commands # the command registry
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
```
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch and a speed control for the 3D mouse.
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
device. A udev rule that adds the `uaccess` tag does that, for example
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
```
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
```
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
report descriptor, so they work as well.
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
WebGL2 with `?webgl`; `?sample` opens the sample part).
## Scripts
@@ -129,12 +183,12 @@ A script is a JSON list of commands, the same ones the UI runs:
## AI agents (MCP)
`solvecraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
started with `solvecraft --control PORT`, so you can watch the agent model. Tools:
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
`save`. Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. Details, client
`save`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
configuration and an example session: [docs/mcp.md](docs/mcp.md).
## Architecture
@@ -148,7 +202,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solvecraft`, `solvecraft-cli` | desktop app, headless CLI |
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
## Contributing
@@ -166,6 +220,7 @@ MIT OR Apache-2.0, at your option. Developed under the
[clean-room contribution rules](CLAUDE.md); third-party code and assets retain their
documented licences and attribution.
References to other products describe workflow comparisons and benchmark provenance.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
Fusion are trademarks of Autodesk, Inc.
+102 -20
View File
@@ -7,9 +7,9 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Question | Answer |
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. |
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR. |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
@@ -20,8 +20,7 @@ The issue batch adds preservation of blind pockets in through cuts, associative
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
In scope, in priority order:
1. SOLID and SKETCH (now).
@@ -38,35 +37,59 @@ In scope, in priority order:
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. The parity report's
headline counts in-scope tabs only and lists the deferred ones separately.
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Milestones
| # | Milestone | Status | Hours left |
|---|---|---|---|
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
## Dimensioning and error reporting
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
arguments reject such a name when an older design's construction plane already has it. Use
the other alias, the construction feature's numeric ID or an explicit plane; existing
stored references keep their saved meaning. Section Analysis uses the same check.
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
Content, baseline X/Y, height and rotation update the original text source in one undo step;
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
use the document units captured when editing starts; generated outlines remain associated
with their text source, and untouched inputs preserve their saved internal values.
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
associative axis expressions and construction-axis references remain unsupported.
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements.
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
the current design unit; changing units leaves existing geometry and expressions intact.
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
and pocket depths, interference volumes and parts-library lengths also use design units.
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
consumes complete point/curve selections consistently; mixed or ambiguous selections
retain the picking workflow. Rejected constraints preserve selection and undo history.
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
useful reproduction context. The draft is editable, with optional capture of the actual
@@ -75,6 +98,14 @@ headline counts in-scope tabs only and lists the deferred ones separately.
- The header exposes **Report a bug**, including an accessible compact control on
narrow windows. It opens the same editable report and screenshot review without
changing the active model or a pending dimension.
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
journal) leaves a local report with the backtrace, version and commit, the command and the
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
Crash Reports…) with a reviewed issue draft; agents read them through
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
anywhere. See [crash reports](docs/crash-reports.md).
## 0.2.0 release batch
@@ -116,6 +147,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## View navigation
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
@@ -141,7 +174,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
edges.
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
between two overlapping extruded arcs).
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
@@ -155,16 +189,15 @@ headline counts in-scope tabs only and lists the deferred ones separately.
containment shortcut. Spheres are built from six pole-free patches.
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
references resolve by name first and warn when the entity split or vanished. Kernel-reported
face history (exact through coincident surfaces) is not wired in yet.
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
references resolve by name first, follow splits and merges, and warn when the entity split,
merged or vanished, or was found by geometry only (operations that report no history yet).
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
source certificates with checked target and author occurrence paths. A missing or changed
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
@@ -231,6 +264,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
existing geometry before assigning positions, retaining saved curve IDs and constraints
across enumeration changes and resize, including concentric circles. Ambiguous identities
retain the last geometry and report a lost reference; permutation matching is bounded.
- Project accepts complete supported preselection in one undo step. Visible external sketch
curves and points retain their source sketch identity; model geometry, axes and planes use
the existing linked projection commands. Rejection preserves the document and selection;
mixed selections retain ordinary tool-first picking.
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
selecting visited values for replacement. Expression completion retains forward-Tab
priority; real keyboard regressions cover rectangles, lines and single-value circles.
@@ -250,6 +287,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
record their coordinate frame; legacy wires migrate from known provenance without changing
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
See [occurrence workflows](docs/occurrence-workflows.md).
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
button drops keep insert-before ordering.
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
Parameters opens the user/model variable editor; Configurations opens the configuration table.
Configure remains available in the panel dropdown.
@@ -274,23 +313,57 @@ headline counts in-scope tabs only and lists the deferred ones separately.
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling.
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
sampling.
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
exact holes and retained islands, including the union of multiple selected profiles, and
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
tapered or bend-crossing machining refuses atomically instead of restoring material later.
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
check, not a new Fusion oracle result. Under-resolved fits return an error.
check. Under-resolved fits return an error.
- Contact driving checks the trajectory between clear endpoints, including linked joints,
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## 3D sketch bridges
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
construction-plane selection of 3D wires and loft guide rails remain separate work.
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
## Construction on 3D paths
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
and reference-object placement remain separate work, as do loft rails and centerlines.
## Document-opening performance
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
to eight workers, retaining serial traversal, result order and persistent source identities.
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
import times; single-solid assembly parts are not scheduled across nodes. See
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
format. Generated-mesh storage measurements reduce serialization and decoding work;
evaluated geometry caching and instant cold display remain separate follow-up work.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
- Desktop Open can show an experimental, opt-in bounded saved display preview while rebuilding in the existing
file worker. Ordinary saves omit display caches; explicit `preview: true` saves cache
only already warm meshes. Document hashes and application versions reject
stale previews. Preview geometry cannot be edited or measured, and cancelled or stale
completions cannot replace a newer document. Evaluated B-rep restoration remains open.
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
@@ -304,6 +377,15 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## Kernel
- Successful Boolean retries report their strategy and numerical adjustments as
feature warnings and structured inspection data. Diagnostics survive cached
evaluation, undo/redo and cold reopening; the robustness runner counts retry
events by strategy and reports any bounded-log truncation. Existing geometry
acceptance checks remain in force.
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
polylines without panicking. Direct constructor regressions retain valid open endpoints
and closed spherical intersections; the original interactive trigger remains unidentified.
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
support and shared topology, preserving real steps and intentional boundaries. The local
+111 -1
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solvecraft-cli and the web app) is built from the 454 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 454 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -13345,3 +13345,113 @@ truck-geotrait 0.4.0: Apache-2.0 (https://github.com/ricosjp/truck)
truck-modeling 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
truck-polymesh 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
truck-topology 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
================================================================================
Bundled fonts (SIL Open Font License, Version 1.1)
================================================================================
- LiberationSans-Regular.ttf (crates/sketch/fonts/LiberationSans-Regular.ttf)
Copyright 2012 Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google)
Source: https://github.com/liberationfonts
- BIZUDPGothic-Regular.ttf (apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf)
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
================================================================================
SIL OPEN FONT LICENSE Version 1.1
================================================================================
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "solidcraft-cli"
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "solidcraft-cli"
[lints]
workspace = true
[[bin]]
name = "solidcraft-cli"
path = "src/main.rs"
# Compatibility alias for the CLI's former name: forwards to solidcraft-cli with a deprecation
# note on stderr. Shipped in every package for at least one release.
[[bin]]
name = "solvecraft-cli"
path = "src/alias.rs"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-mcp = { workspace = true }
serde_json = { workspace = true }
+30
View File
@@ -0,0 +1,30 @@
//! `solvecraft-cli`: the CLI's former name, kept for at least one release so existing scripts
//! and MCP client setups (`claude mcp add solvecraft -- solvecraft-cli mcp`) keep working.
//! Compatibility shim: it runs `solidcraft-cli` (next to this executable, else on PATH) with the
//! same arguments and standard streams, after a one-line deprecation note on stderr (stdout
//! stays clean for MCP's JSON-RPC), and exits with its status.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::process::{Command, ExitCode};
const TARGET: &str = "solidcraft-cli";
fn main() -> ExitCode {
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
let sibling = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
.filter(|p| p.is_file());
let program = sibling.map(|p| p.into_os_string()).unwrap_or_else(|| TARGET.into());
match Command::new(&program).args(std::env::args_os().skip(1)).status() {
Ok(status) => match status.code() {
Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
None => ExitCode::FAILURE,
},
Err(e) => {
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
ExitCode::FAILURE
}
}
}
+103
View File
@@ -0,0 +1,103 @@
//! `solidcraft-cli crashes`: the crash reports the desktop app wrote on this computer
//! (docs/crash-reports.md): list them, show one, replay one, delete them.
use std::sync::Mutex;
use serde_json::{Value, json};
use solidcraft_engine::crash;
const USAGE: &str = "usage: solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
pub fn run(args: &[String]) -> Result<(), String> {
let dir = crash::default_dir().ok_or("no crash folder on this system")?;
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
match args.first().map(String::as_str) {
None | Some("list") => {
let reports = crash::list(&dir);
if args.iter().any(|a| a == "--json") {
println!("{}", super::pretty(&json!({ "dir": dir.to_string_lossy(), "reports": reports })));
return Ok(());
}
println!("crash reports in {}", dir.display());
if reports.is_empty() {
println!(" (none)");
}
for r in reports {
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default();
let replay = if r.replayable { " [replayable]" } else { "" };
println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message);
}
Ok(())
}
Some("show") => {
println!("{}", super::pretty(&crash::read(&dir, id()?)?));
Ok(())
}
Some("replay") => replay(&crash::read(&dir, id()?)?),
Some("dir") => {
println!("{}", dir.display());
Ok(())
}
Some("clear") => {
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
Ok(())
}
_ => Err(USAGE.to_string()),
}
}
/// Panics seen while replaying: (location, message).
static PANICS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
/// Run a report's commands on its design and say whether the crash happens again.
fn replay(report: &Value) -> Result<(), String> {
let id = report["id"].as_str().unwrap_or_default();
let replay_path = report["replay_path"].as_str().ok_or_else(|| format!("report {id} cannot be replayed (see its notes)"))?;
let script = super::read_json(replay_path)?;
let commands = script["commands"].as_array().cloned().unwrap_or_default();
let mut s = match report["design_path"].as_str() {
Some(d) => super::load(d)?,
None => solidcraft_engine::Session::default(),
};
std::panic::set_hook(Box::new(|info| {
let loc = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
let msg = info.payload_as_str().unwrap_or("(not a text message)").to_string();
if let Ok(mut p) = PANICS.lock() {
p.push((loc, msg));
}
}));
if report["kind"] == "unclean_exit" {
println!("this crash ended the app without a report: if this process ends abruptly, it reproduced");
}
println!(
"replaying {} command(s) on {}",
commands.len(),
report["design_path"].as_str().map_or_else(|| "a new design".to_string(), |d| d.to_string())
);
for (i, c) in commands.iter().enumerate() {
let cmd = c["command"].as_str().unwrap_or_default();
let r = s.execute(cmd, c.get("params").unwrap_or(&Value::Null));
match r {
Ok(_) => println!(" {:>3}. {cmd}: ok", i + 1),
Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1),
}
}
let _ = std::panic::take_hook();
let seen = PANICS.lock().map(|p| p.clone()).unwrap_or_default();
let want = report["signature"].as_str().unwrap_or_default();
let same = seen.iter().any(|(l, m)| format!("{:016x}", crash::signature(l, m)) == want);
if same {
println!(
"reproduced: the same internal error ({}) at {}",
report["message"].as_str().unwrap_or_default(),
report["location"].as_str().unwrap_or_default()
);
} else if let Some((l, m)) = seen.first() {
println!("an internal error happened, but not the reported one: {m} at {l}");
println!("(the report says: {} at {})", report["message"].as_str().unwrap_or_default(), report["location"].as_str().unwrap_or_default());
} else {
println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)");
}
Ok(())
}
@@ -1,4 +1,4 @@
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//! `solidcraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//!
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
//! is recorded like any other failure.
@@ -9,8 +9,8 @@ use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use solvecraft_engine::fuzz;
use solvecraft_engine::fuzz::boolean_case;
use solidcraft_engine::fuzz;
use solidcraft_engine::fuzz::boolean_case;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
@@ -33,6 +33,8 @@ pub fn one(args: &[String]) -> Result<(), String> {
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
"unsupported": run.unsupported,
"rejected": run.rejected,
"boolean_fallbacks": run.boolean_fallbacks,
"boolean_reports_truncated": run.boolean_reports_truncated,
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
});
if run.outcome.is_bug() {
@@ -43,7 +45,7 @@ pub fn one(args: &[String]) -> Result<(), String> {
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
}
}
let (a, b) = solvecraft_engine::fuzz::boolean_shapes(seed);
let (a, b) = solidcraft_engine::fuzz::boolean_shapes(seed);
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
let b = boolean_case(seed);
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
@@ -179,9 +181,23 @@ fn summarise(results: &[Value], took: Duration) -> String {
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
let mut fallbacks: BTreeMap<String, [u64; 2]> = BTreeMap::new();
let mut truncated = [0usize; 2];
let mut steps = 0;
let (mut dp, mut bp) = (0, 0);
for v in results {
for (i, group) in ["design", "boolean"].into_iter().enumerate() {
if v[group]["boolean_reports_truncated"].as_bool().unwrap_or(false) {
truncated[i] += 1;
}
if let Some(counts) = v[group]["boolean_fallbacks"].as_object() {
for (strategy, count) in counts {
if let Some(total) = fallbacks.entry(strategy.clone()).or_default().get_mut(i) {
*total = total.saturating_add(count.as_u64().unwrap_or(0));
}
}
}
}
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
let k = if v.get("timeout").is_some() {
@@ -220,5 +236,28 @@ fn summarise(results: &[Value], took: Duration) -> String {
for (k, c) in &boolean {
s += &format!("- {k}: {}\n", pct(*c));
}
s += "\nSuccessful Boolean retries (events, including nested operations):\n";
for (strategy, [designs, checks]) in &fallbacks {
s += &format!("- {strategy}: designs {designs}, boolean checks {checks}\n");
}
s += &format!("- Capped reports: designs {}, boolean checks {}\n", truncated[0], truncated[1]);
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_counts_retry_events_separately_and_marks_incomplete_logs() {
let results = [
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":2}},"boolean":{"outcome":"ok","boolean_fallbacks":{"shifted_operands":3},"boolean_reports_truncated":true}}),
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":1}},"boolean":{"outcome":"ok"}}),
json!({"timeout":90}),
];
let text = summarise(&results, Duration::ZERO);
assert!(text.contains("shifted_operands: designs 3, boolean checks 3"), "{text}");
assert!(text.contains("Capped reports: designs 0, boolean checks 1"), "{text}");
assert!(text.contains("timeout: 1 (33.3 %)"), "{text}");
}
}
@@ -1,26 +1,26 @@
//! SolveCraft headless command line.
//! SolidCraft headless command line.
//!
//! ```text
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
//! solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
//! solvecraft-cli commands the command registry as JSON
//! solvecraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] loading timings as JSON lines
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod crashes;
mod fuzz;
mod oracle;
mod recipe;
mod seams;
mod spatial;
use std::process::ExitCode;
@@ -28,24 +28,25 @@ use serde_json::{Value, json};
#[path = "../../app_icon.rs"]
mod app_icon;
use solvecraft_engine::Session;
use solvecraft_engine::render::{StandardView, render_png};
use solidcraft_engine::Session;
use solidcraft_engine::render::{StandardView, render_png};
const USAGE: &str = "usage:
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solvecraft-cli exec <command> [json-params]
solvecraft-cli commands
solvecraft-cli recipe <recipe.json>
solvecraft-cli oracle <case-dir>... [--json]
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solvecraft-cli step-corpus <case-dir>... [--json] [--iges]
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
solvecraft-cli licences
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--allow-errors]
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect; or --in FILE)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
solidcraft-cli recipe <recipe.json>
solidcraft-cli oracle <case-dir>... [--json]
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT]
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
solidcraft-cli licences
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
";
fn main() -> ExitCode {
@@ -73,15 +74,15 @@ fn main() -> ExitCode {
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
}),
Some("oracle") => oracle::run(&args[1..]),
Some("step-corpus") => oracle::step_corpus(&args[1..]),
Some("fuzz") => fuzz::run(&args[1..]),
Some("fuzz-one") => fuzz::one(&args[1..]),
Some("mcp") => cmd_mcp(&args[1..]),
Some("crashes") => crashes::run(&args[1..]),
Some("icon") => cmd_icon(&args[1..]),
Some("licences" | "licenses") => {
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
let _ = std::io::stdout().lock().write_all(solidcraft_engine::licences::all().as_bytes());
Ok(())
}
// Hidden, for the kill-mid-save test: save a large design over and over.
@@ -90,7 +91,7 @@ fn main() -> ExitCode {
Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("bench-open") => cmd_bench_open(&args[1..]),
Some("--version" | "-V") => {
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
_ => Err(USAGE.to_string()),
@@ -111,19 +112,26 @@ fn pretty(v: &Value) -> String {
pub fn read_json(path: &str) -> Result<Value, String> {
// Through the engine's file access, which refuses anything that is not a regular file and
// reads no more than the cap: a character device or a FIFO would otherwise never end.
let s = solvecraft_engine::io::vfs::read_to_string(path, solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
}
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
pub fn load(path: &str) -> Result<Session, String> {
let mut s = Session::default();
if solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path) {
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
return Ok(s);
}
let v = read_json(path)?;
if v.get("format").and_then(Value::as_str).is_some_and(|f| f.starts_with("solvecraft")) {
let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
if solidcraft_engine::io::is_design_container(&bytes) {
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::new(doc);
session.path = Some(path.to_owned());
return Ok(session);
}
let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))?;
if v.get("format").and_then(Value::as_str).is_some_and(|f| solidcraft_engine::doc::format::version_of(f).is_some()) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
} else if v.get("features").is_some() && v.get("commands").is_none() {
let script = recipe::to_script(&v)?;
@@ -147,7 +155,7 @@ fn positional(args: &[String]) -> Option<&String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if a.starts_with("--") {
i += if matches!(a.as_str(), "--quiet" | "--results") { 1 } else { 2 };
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors") { 1 } else { 2 };
continue;
}
return Some(a);
@@ -182,6 +190,12 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
}
None
};
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
// the wrong part with success, so fail the run unless asked to write it anyway.
if !args.iter().any(|a| a == "--allow-errors") {
timeline_ok(&s)?;
}
for out in flags(args, "--out") {
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
}
@@ -197,6 +211,19 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Every failing timeline feature, one per line, as an error; Ok when none fails.
fn timeline_ok(s: &Session) -> Result<(), String> {
let failed: Vec<String> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
if failed.is_empty() {
return Ok(());
}
Err(format!(
"{} timeline feature(s) failed, so nothing was written (--allow-errors writes the bodies anyway):\n{}",
failed.len(),
failed.join("\n")
))
}
fn cmd_eval(args: &[String]) -> Result<(), String> {
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
let mut s = load(path)?;
@@ -220,11 +247,11 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
let s = load(path)?;
let mut cam = solvecraft_engine::view::home_camera(&s);
let mut cam = solidcraft_engine::view::home_camera(&s);
if let Some(v) = flag(args, "--view") {
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
}
let scene = solvecraft_engine::view::scene(&s, &cam);
let scene = solidcraft_engine::view::scene(&s, &cam);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
@@ -249,7 +276,7 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
}
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
solvecraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
solidcraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
}
/// A Windows icon file holding PNG images (Vista and later read these).
@@ -287,25 +314,31 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
/// a running app's control port. stdout carries only protocol messages.
fn cmd_mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn solvecraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?),
(None, Some(path)) => Box::new(solvecraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solvecraft_mcp::Headless::default()),
(Some(addr), None) => Box::new(solidcraft_mcp::Remote::connect(addr).map_err(|e| {
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
format!(
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
`solidcraft --control {port}` (or SOLIDCRAFT_CONTROL_PORT={port}) and retry"
)
})?),
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
solvecraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
solidcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
/// parameter `save_round` set to the round number each time.
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
return Err("usage: solidcraft-cli save-stress <path> <rounds>".into());
};
let mut s = Session::default();
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
let filler = (0..3000).map(|i| solidcraft_engine::doc::Param {
name: format!("p{i}"),
expr: format!("{i} mm + 0.5 mm"),
unit: "mm".into(),
@@ -324,7 +357,7 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
return Err("usage: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
return Err("usage: solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
};
let v = read_json(path)?;
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
@@ -352,21 +385,21 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
let t = std::time::Instant::now();
let step = solvecraft_engine::io::is_step_path(path);
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
let bytes = solvecraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
let step = solidcraft_engine::io::is_step_path(path);
let limit = if step { solidcraft_engine::io::MAX_STEP_BYTES } else { solidcraft_engine::io::MAX_DESIGN_BYTES };
let bytes = solidcraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
let t = std::time::Instant::now();
let doc = if step {
let imported = solvecraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
let mut doc = solvecraft_engine::doc::Document::new(&imported.name);
let imported = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
doc
} else {
solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
};
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
+263
View File
@@ -0,0 +1,263 @@
//! Reference tests: replay `tests/oracle/<case>/recipe.json` through the command engine and
//! compare the part with `expected.json` (closed-form values, or values from an independent
//! kernel; see tests/oracle/README.md), and with `reference.stl` where one exists.
#![allow(clippy::needless_range_loop)]
use serde_json::{Value, json};
use solidcraft_engine::geom::Vec3;
use crate::spatial::{self, Tri};
use crate::{read_json, recipe};
/// Default relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
pub const REL_TOL: f64 = 1e-3;
/// Default tolerance for centre of mass and bounding box, as a fraction of the bbox diagonal.
const ABS_FRAC: f64 = 1e-3;
/// Chord tolerance used to triangulate our part for the surface-distance check (mm).
const MESH_TOL: f64 = 0.01;
/// Relative tolerance on the principal second moments of the two triangulations.
const INERTIA_REL: f64 = 2e-3;
/// What SolidCraft built: totals over every body.
struct Got {
body_count: usize,
volume: f64,
area: f64,
com: [f64; 3],
bbox: ([f64; 3], [f64; 3]),
faces: u64,
}
fn v3(v: &Value) -> Option<[f64; 3]> {
let a = v.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
}
fn totals(m: &Value) -> Got {
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
let (mut volume, mut area, mut faces) = (0.0, 0.0, 0);
let mut moment = [0.0; 3];
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for b in &bodies {
let v = b["volume_mm3"].as_f64().unwrap_or(f64::NAN);
volume += v;
area += b["area_mm2"].as_f64().unwrap_or(f64::NAN);
faces += b["faces"].as_u64().unwrap_or(0);
let c = v3(&b["center_of_mass"]).unwrap_or([f64::NAN; 3]);
let (bl, bh) = (v3(&b["bbox"]["min"]).unwrap_or([f64::NAN; 3]), v3(&b["bbox"]["max"]).unwrap_or([f64::NAN; 3]));
for k in 0..3 {
moment[k] += v * c[k];
lo[k] = lo[k].min(bl[k]);
hi[k] = hi[k].max(bh[k]);
}
}
let com = moment.map(|x| x / volume);
Got { body_count: bodies.len(), volume, area, com, bbox: (lo, hi), faces }
}
struct Checks {
list: Vec<Value>,
pass: bool,
}
impl Checks {
fn push(&mut self, what: &str, got: Value, want: Value, ok: bool) {
self.pass &= ok;
self.list.push(json!({"check": what, "got": got, "want": want, "ok": ok}));
}
fn rel(&mut self, what: &str, g: f64, w: Option<f64>, rel: f64) {
let ok = w.is_some_and(|w| g.is_finite() && (g - w).abs() <= rel * w.abs().max(1.0));
self.push(what, json!(g), json!(w), ok);
}
fn vec(&mut self, what: &str, g: [f64; 3], w: Option<[f64; 3]>, tol: f64) {
let ok = w.is_some_and(|w| g.iter().zip(w).all(|(g, w)| g.is_finite() && (g - w).abs() <= tol));
self.push(what, json!(g), json!(w), ok);
}
}
fn check_case(dir: &str) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let recipe = match read_json(&format!("{dir}/recipe.json")) {
Ok(r) => r,
Err(e) => return json!({"case": name, "pass": false, "error": e}),
};
let want = read_json(&format!("{dir}/expected.json")).ok();
let script = match recipe::to_script(&recipe) {
Ok(s) => s,
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
};
let mut s = solidcraft_engine::Session::default();
if let Err(e) = s.run_script(&script) {
return json!({"case": name, "pass": false, "error": e.to_string(), "source": want.as_ref().map(|w| w["source"].clone())});
}
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => totals(&m),
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let got_json = json!({
"body_count": got.body_count, "volume_mm3": got.volume, "area_mm2": got.area,
"center_of_mass": got.com, "bbox": {"min": got.bbox.0, "max": got.bbox.1}, "faces": got.faces,
});
let Some(want) = want else {
return json!({"case": name, "pass": false, "skipped": true, "error": "no expected.json yet", "got": got_json});
};
let only: Option<Vec<String>> = want["compare"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());
let wanted = |k: &str| want.get(k).is_some_and(|v| !v.is_null()) && only.as_ref().is_none_or(|o| o.iter().any(|x| x == k));
let rel = want["tolerance"]["rel"].as_f64().unwrap_or(REL_TOL);
let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt();
let want_bbox = v3(&want["bbox"]["min"]).zip(v3(&want["bbox"]["max"]));
let abs = want["tolerance"]["abs_mm"].as_f64().unwrap_or_else(|| ABS_FRAC * want_bbox.map(diag).unwrap_or_else(|| diag(got.bbox)).max(1.0));
let mut c = Checks { list: Vec::new(), pass: true };
if wanted("body_count") {
let ok = want["body_count"].as_u64() == Some(got.body_count as u64);
c.push("body_count", json!(got.body_count), want["body_count"].clone(), ok);
}
if wanted("volume_mm3") {
c.rel("volume_mm3", got.volume, want["volume_mm3"].as_f64(), rel);
}
if wanted("area_mm2") {
c.rel("area_mm2", got.area, want["area_mm2"].as_f64(), rel);
}
if wanted("center_of_mass") {
c.vec("center_of_mass", got.com, v3(&want["center_of_mass"]), abs);
}
if wanted("bbox") {
c.vec("bbox.min", got.bbox.0, want_bbox.map(|b| b.0), abs);
c.vec("bbox.max", got.bbox.1, want_bbox.map(|b| b.1), abs);
}
if wanted("faces") {
let ok = want["faces"].as_u64() == Some(got.faces);
c.push("faces", json!(got.faces), want["faces"].clone(), ok);
}
let stl = format!("{dir}/reference.stl");
if std::path::Path::new(&stl).is_file() && only.as_ref().is_none_or(|o| o.iter().any(|x| x == "surface")) {
let tol = want["tolerance"]["surface_mm"].as_f64().unwrap_or(0.05);
reference_checks(&s, &stl, tol, &mut c);
}
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
// same volume.
let (ok, note) = step_export_check(&s, got.volume);
c.push("step_export", json!(note), json!("valid, re-imports cleanly"), ok);
let mut out = json!({"case": name, "pass": c.pass, "source": want["source"], "checks": c.list, "got": got_json});
if let Some(n) = want["note"].as_str() {
out["note"] = json!(n);
}
out
}
/// Export the session's bodies as STEP, validate the file and import it again.
fn step_export_check(s: &solidcraft_engine::Session, volume: f64) -> (bool, String) {
let st = s.model.state();
let bytes = match solidcraft_engine::io::export(&st, &[], solidcraft_engine::io::Format::Step, "oracle") {
Ok(b) => b,
Err(e) => return (false, format!("export: {e}")),
};
let text = String::from_utf8_lossy(&bytes);
if let Err(e) = solidcraft_engine::kernel::step_validate(&text) {
return (false, format!("invalid: {e}"));
}
match solidcraft_engine::kernel::step_orientation_errors(&text) {
Ok(errs) if !errs.is_empty() => return (false, format!("faces inside out: {}", errs.join("; "))),
Err(e) => return (false, format!("orientation check: {e}")),
Ok(_) => {}
}
match solidcraft_engine::kernel::step_import(&text) {
Ok(imp) if !imp.warnings.is_empty() => (false, format!("warnings: {}", imp.warnings.join("; "))),
Ok(imp) if imp.bodies.len() != st.bodies.len() => (false, format!("{} bodies back of {}", imp.bodies.len(), st.bodies.len())),
Ok(imp) => {
let m: Vec<_> = imp.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
let m0: Vec<_> = st.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
let v: f64 = m.iter().map(|x| x.volume).sum();
let (a, a0): (f64, f64) = (m.iter().map(|x| x.area).sum(), m0.iter().map(|x| x.area).sum());
let faces: usize = imp.bodies.iter().map(|b| b.file_faces).sum();
let faces0: usize = m0.iter().map(|x| x.merged.faces).sum();
let close = |x: f64, y: f64| (x - y).abs() <= REL_TOL * y.abs().max(1.0);
let faces_raw: usize = st.bodies.iter().map(|b| b.body.face_count()).sum();
// (Fewer is fine: pieces of one plane, cylinder, cone, sphere or torus are written as one face.)
let ok = close(v, volume) && close(a, a0) && faces <= faces_raw;
(ok, format!("re-imported volume {v:.3} (was {volume:.3}), area {a:.3} (was {a0:.3}), faces {faces} (was {faces0})"))
}
Err(e) => (false, format!("import: {e}")),
}
}
/// Our part as a triangle soup (curved faces refined onto the surface).
fn our_mesh(s: &solidcraft_engine::Session) -> Result<Vec<Tri>, String> {
let mut ours: Vec<Tri> = Vec::new();
for b in &s.world_state().bodies {
let m = b.body.tessellate_with(MESH_TOL, true).map_err(|e| e.to_string())?;
let p = |i: u32| m.positions.get(i as usize).map(|v: &Vec3| [v.x, v.y, v.z]);
for t in &m.triangles {
if let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) {
ours.push([a, b, c]);
}
}
}
if ours.is_empty() { Err("empty mesh".into()) } else { Ok(ours) }
}
/// Compare our part with the reference triangulation: the symmetric surface distance, and the
/// principal second moments of volume of both meshes (the shape's inertia, independent of how it
/// sits in space).
fn reference_checks(s: &solidcraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) {
let (ours, reference) = match (our_mesh(s), spatial::read_stl(stl)) {
(Ok(o), Ok(r)) if !r.is_empty() => (o, r),
(Err(e), _) | (_, Err(e)) => return c.push("surface_distance_mm", json!(e), json!(format!("<= {tol}")), false),
_ => return c.push("surface_distance_mm", json!("empty reference.stl"), json!(format!("<= {tol}")), false),
};
let (lo, hi) = spatial::bounds(&reference);
let size = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
let h = spatial::hausdorff(&ours, &reference, size.max(1.0));
let worst = h.a_to_b.max(h.b_to_a);
c.push(
"surface_distance_mm",
json!({"max": worst, "mean": h.mean, "ours_to_reference": h.a_to_b, "reference_to_ours": h.b_to_a, "worst_at": h.worst_at}),
json!(format!("<= {tol}")),
worst <= tol,
);
let ((_, _, pa), (_, _, pb)) = (spatial::mass_properties(&ours), spatial::mass_properties(&reference));
let ok = pa.iter().zip(pb).all(|(a, b)| (a - b).abs() <= INERTIA_REL * b.abs().max(1.0));
c.push("principal_moments_mm5", json!(pa), json!(pb), ok);
}
fn report(args: &[String], results: &[Value]) {
if args.iter().any(|a| a == "--json") {
println!("{}", serde_json::to_string_pretty(&Value::Array(results.to_vec())).unwrap_or_default());
return;
}
for r in results {
let status = if r["pass"].as_bool().unwrap_or(false) {
"PASS"
} else if r["skipped"].as_bool().unwrap_or(false) {
"SKIP"
} else {
"FAIL"
};
let detail: Vec<String> = r["checks"]
.as_array()
.into_iter()
.flatten()
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
.collect();
let err = r["error"].as_str().map(|e| format!(" ({e})")).unwrap_or_default();
println!(
"{status} {}{err}{}",
r["case"].as_str().unwrap_or(""),
if detail.is_empty() { String::new() } else { format!(": {}", detail.join("; ")) }
);
}
}
pub fn run(args: &[String]) -> Result<(), String> {
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if dirs.is_empty() {
return Err("oracle: give one or more case directories (each with recipe.json and expected.json)".into());
}
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
report(args, &results);
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false) && !r["skipped"].as_bool().unwrap_or(false)).count();
if failed > 0 { Err(format!("{failed} oracle case(s) failed")) } else { Ok(()) }
}
@@ -1,4 +1,4 @@
//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft command scripts.
//! Reference-test recipes (`tests/oracle/*/recipe.json`) → SolidCraft command scripts.
//!
//! A recipe lists features with sketch entities in sketch-local coordinates; point references
//! (`l1.end`) share points. Each feature becomes one or more commands.
@@ -85,14 +85,6 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
/// Extent parameters for the Extrude command.
fn extent(f: &Value, p: &mut Value) {
// Where Fusion's own result differs from the geometric meaning (its through-all is sized
// from vertices), the recipe records the extent Fusion actually used: replay that.
if let Some(fx) = f.get("fusion_effective_extent").filter(|x| x.get("type").and_then(Value::as_str) == Some("distance")) {
let dir = fx.get("direction").and_then(Value::as_str).unwrap_or("positive");
p["distance"] = fx.get("distance").cloned().unwrap_or(Value::Null);
p["direction"] = json!(if dir.starts_with("negative") { "negative" } else { "positive" });
return;
}
let e = f.get("extent").cloned().unwrap_or(Value::Null);
let ty = e.get("type").and_then(Value::as_str).unwrap_or("distance");
let dist = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null);
@@ -545,8 +537,8 @@ fn edge_points(f: &Value) -> Value {
)
}
/// Fusion names a pattern's source features with an occurrence suffix ("Hole1 (1)"); the
/// features are the recipe's own names.
/// A pattern's source features may carry an occurrence suffix ("Hole1 (1)"); the features are
/// the recipe's own names.
fn feature_refs(v: Option<&Value>) -> Value {
let list: Vec<Value> = v
.and_then(Value::as_array)
+333
View File
@@ -0,0 +1,333 @@
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
//! triangle meshes.
use std::collections::HashMap;
/// A triangle soup in mm.
pub type Tri = [[f64; 3]; 3];
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// Read a binary (or ASCII) STL file.
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
if b.len() >= 84 {
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
if b.len() == 84 + 50 * n {
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
return Ok((0..n)
.map(|i| {
let o = 84 + 50 * i + 12;
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
[v(0), v(1), v(2)]
})
.collect());
}
}
let text = String::from_utf8_lossy(&b);
let mut verts = Vec::new();
for line in text.lines() {
let mut it = line.split_whitespace();
if it.next() == Some("vertex") {
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
if c.len() == 3 {
verts.push([c[0], c[1], c[2]]);
}
}
}
if verts.is_empty() || verts.len() % 3 != 0 {
return Err(format!("{path}: not an STL file"));
}
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
}
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
let (mut v6, mut m1) = (0.0, [0.0; 3]);
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
for t in tris {
let d = dot(t[0], cross(t[1], t[2]));
v6 += d;
let s = add(add(t[0], t[1]), t[2]);
m1 = add(m1, scale(s, d / 24.0));
for (i, row) in c.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
*cell += d / 120.0 * vv;
}
}
}
let vol = v6 / 6.0;
if vol.abs() < 1e-12 {
return (0.0, [0.0; 3], [0.0; 3]);
}
let com = scale(m1, 1.0 / vol);
// Central covariance and inertia tensor I = tr(C)·1 − C.
let mut cc = c;
for (i, row) in cc.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell -= vol * com[i] * com[j];
}
}
let tr = cc[0][0] + cc[1][1] + cc[2][2];
let mut inertia = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
}
}
let mut ev = eigen_sym3(inertia);
ev.sort_by(f64::total_cmp);
(vol, com, ev)
}
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
for _ in 0..50 {
let (mut p, mut q, mut big) = (0, 1, 0.0);
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
if a[i][j].abs() > big {
(p, q, big) = (i, j, a[i][j].abs());
}
}
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
break;
}
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let t = if theta == 0.0 { 1.0 } else { t };
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
let mut b = a;
for k in 0..3 {
b[k][p] = cs * a[k][p] - sn * a[k][q];
b[k][q] = sn * a[k][p] + cs * a[k][q];
}
let mut c2 = b;
for k in 0..3 {
c2[p][k] = cs * b[p][k] - sn * b[q][k];
c2[q][k] = sn * b[p][k] + cs * b[q][k];
}
a = c2;
}
[a[0][0], a[1][1], a[2][2]]
}
/// Axis-aligned bounds of a triangle soup.
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in tris.iter().flatten() {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
let (a, b, c) = (t[0], t[1], t[2]);
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
let q = if d1 <= 0.0 && d2 <= 0.0 {
a
} else {
let bp = sub(p, b);
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
if d3 >= 0.0 && d4 <= d3 {
b
} else {
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
add(a, scale(ab, d1 / (d1 - d3)))
} else {
let cp = sub(p, c);
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
if d6 >= 0.0 && d5 <= d6 {
c
} else {
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
add(a, scale(ac, d2 / (d2 - d6)))
} else {
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
} else {
let den = va + vb + vc;
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
}
}
}
}
}
};
let d = sub(p, q);
dot(d, d).sqrt()
}
/// Uniform grid of triangles for distance queries up to `cap`.
struct Grid<'a> {
tris: &'a [Tri],
cell: f64,
cells: HashMap<(i64, i64, i64), Vec<u32>>,
}
impl<'a> Grid<'a> {
fn new(tris: &'a [Tri], cell: f64) -> Self {
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let key = |x: f64| (x / cell).floor() as i64;
for (i, t) in tris.iter().enumerate() {
let (lo, hi) = bounds(std::slice::from_ref(t));
for x in key(lo[0])..=key(hi[0]) {
for y in key(lo[1])..=key(hi[1]) {
for z in key(lo[2])..=key(hi[2]) {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Grid { tris, cell, cells }
}
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
let rmax = (cap / self.cell).ceil() as i64;
let k = |x: f64| (x / self.cell).floor() as i64;
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
let mut best = cap;
for r in 0..=rmax {
for x in cx - r..=cx + r {
for y in cy - r..=cy + r {
for z in cz - r..=cz + r {
// only the shell at Chebyshev distance r
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
continue;
}
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
for &i in list {
let Some(slot) = seen.get_mut(i as usize) else { continue };
if *slot == stamp {
continue;
}
*slot = stamp;
if let Some(t) = self.tris.get(i as usize) {
best = best.min(point_tri(p, t));
}
}
}
}
}
// Everything not yet searched is at least r·cell away from p.
if best <= r as f64 * self.cell {
break;
}
}
best
}
}
/// At most this many sample points per side (larger meshes are sampled with an even stride).
const MAX_SAMPLES: usize = 250_000;
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
/// very large meshes).
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
for t in tris.iter().step_by(stride) {
out.extend_from_slice(t);
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
}
out
}
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
/// capped at `cap`.
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
let (lo, hi) = bounds(to);
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
// finer than diag/400 (so big flat triangles don't fill too many cells).
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
let typical = (area / to.len().max(1) as f64).sqrt();
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
let mut seen = vec![0u32; to.len()];
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
for (i, p) in samples(from).into_iter().enumerate() {
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
sum += d;
n += 1;
if d > max {
max = d;
worst = p;
}
}
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
}
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
pub struct Hausdorff {
pub a_to_b: f64,
pub b_to_a: f64,
pub mean: f64,
/// Where the larger one-sided distance was found (mm, world).
pub worst_at: [f64; 3],
}
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
}
#[cfg(test)]
mod tests {
use super::*;
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
let mut t = Vec::new();
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
t
}
#[test]
fn box_moments_and_distance() {
let c = cube(10.0, [5.0, -3.0, 2.0]);
let (v, com, pm) = mass_properties(&c);
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
for m in pm {
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
}
let moved = cube(10.0, [5.0, -3.0, 2.3]);
let h = hausdorff(&c, &moved, 1.0);
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
let same = hausdorff(&c, &c, 1.0);
assert!(same.a_to_b < 1e-12);
}
}
@@ -4,7 +4,7 @@ use std::process::{Command, Stdio};
#[test]
fn registry_output_is_complete_json() {
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let commands = registry.as_array().unwrap();
@@ -16,7 +16,7 @@ fn registry_output_is_complete_json() {
#[test]
fn closed_registry_pipe_exits_without_panicking() {
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
// Close our read end before waiting. No sleeps or timing threshold are needed:
// the registry is larger than the pipe buffer, so it cannot all be written first.
drop(child.stdout.take());
@@ -0,0 +1,26 @@
# Design file corpus
Designs saved by older SolidCraft builds. `tests/design_corpus.rs` opens each one with the
current build and checks that it evaluates as it did when saved (the `.expected.json` next to
it is `solidcraft-cli eval` of that file by the build that saved it: bodies, volumes, areas,
world bounding boxes), that it is upgraded to the current file format, and that damaged and
hostile variants of it never panic.
| File | Saved by | Era |
|---|---|---|
| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut |
| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit |
| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground |
| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter |
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
app and has none). Every flat face of every final body must also take an attached sketch and an
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead.
@@ -1,5 +1,5 @@
{
"format": "solvecraft/1",
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
@@ -0,0 +1,157 @@
{
"bodies": [
{
"area_mm2": 25751.999999999945,
"bbox": {
"max": [
35.0,
42.0,
48.00000000000001
],
"min": [
-35.0,
-42.000000000000014,
0.0
]
},
"center_of_mass": [
5.524909928104648e-14,
-1.0945382096961234e-12,
22.366336633663238
],
"density_g_cm3": 1.29,
"edges": 24,
"face_types": {
"plane": 10
},
"faces": 10,
"kernel": {
"edges": 24,
"faces": 10,
"shells": 1,
"vertices": 16
},
"mass_g": 328.33079999999734,
"material": "Default",
"name": "Body1",
"vertices": 16,
"volume_mm3": 254519.9999999979
}
],
"body_count": 1,
"params": [
{
"comment": "",
"error": null,
"expression": "84",
"model": true,
"name": "d1",
"unit": "mm",
"value": 84.0
},
{
"comment": "",
"error": null,
"expression": "70",
"model": true,
"name": "d2",
"unit": "mm",
"value": 70.0
},
{
"comment": "",
"error": null,
"expression": "62",
"model": true,
"name": "d4",
"unit": "mm",
"value": 62.0
}
],
"sketches": [
{
"curves": 6,
"dof": 0,
"id": 1,
"name": "Sketch1",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 10,
"dof": 1,
"id": 3,
"name": "Sketch2",
"profiles": 3,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 0.131943,
"name": "Sketch1",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 1.0812709999999999,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.450002,
"name": "Sketch2",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 5.191514000000001,
"name": "Extrude2",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
}
],
"total": {
"area_mm2": 25751.999999999945,
"edges": 24,
"faces": 10,
"mass_g": 328.33079999999734,
"vertices": 16,
"volume_mm3": 254519.9999999979
}
}
@@ -0,0 +1,539 @@
{
"features": [
{
"id": 1,
"name": "Sketch1",
"plane": {
"name": "XY",
"type": "origin"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 0,
"type": "horizontal"
},
{
"id": "k2",
"l": 2,
"type": "horizontal"
},
{
"id": "k3",
"l": 1,
"type": "vertical"
},
{
"id": "k4",
"l": 3,
"type": "vertical"
},
{
"id": "k5",
"l": 4,
"p": 5,
"type": "midpoint"
},
{
"id": "k6",
"p": 0,
"q": 5,
"type": "coincident"
},
{
"id": "k7",
"l": 1,
"param": "d1",
"type": "length",
"value": 84.0
},
{
"id": "k8",
"l": 0,
"param": "d2",
"type": "length",
"value": 70.0
}
],
"counters": {
"k": 8,
"l": 6,
"p": 5
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"type": "line"
},
{
"a": 2,
"b": 3,
"construction": false,
"id": "l2",
"type": "line"
},
{
"a": 3,
"b": 4,
"construction": false,
"id": "l3",
"type": "line"
},
{
"a": 4,
"b": 1,
"construction": false,
"id": "l4",
"type": "line"
},
{
"a": 1,
"b": 3,
"construction": true,
"id": "l5",
"type": "line"
},
{
"a": 2,
"b": 4,
"construction": true,
"id": "l6",
"type": "line"
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": false,
"id": "p1",
"pos": [
-35.0,
-42.0
]
},
{
"fixed": false,
"id": "p2",
"pos": [
35.0,
-42.0
]
},
{
"fixed": false,
"id": "p3",
"pos": [
35.0,
42.0
]
},
{
"fixed": false,
"id": "p4",
"pos": [
-35.0,
42.0
]
},
{
"fixed": false,
"id": "p5",
"pos": [
0.0,
0.0
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "30"
},
"id": 2,
"name": "Extrude1",
"operation": "new_body",
"param_names": [
"d3"
],
"profiles": {
"loops": [
[
"l1",
"l2",
"l3",
"l4"
]
],
"type": "curves"
},
"sketch": 1,
"suppressed": false,
"type": "extrude"
},
{
"id": 3,
"name": "Sketch2",
"plane": {
"anchor": {
"author_path": [],
"component": 0,
"coordinates": [
0.0,
0.0
],
"path": [],
"target": {
"body": "Body1",
"certificate": {
"high": true,
"holes": [],
"kind": "extrude_cap",
"outer": [
"l1",
"l2",
"l3",
"l4"
],
"sketch": 1
},
"name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
"origin_feature": 2,
"point": [
0.0,
0.0,
30.0
]
},
"x_axis": [
1.0,
0.0
]
},
"at": [
2.012802880253332,
7.4635742926698185,
30.000000000000114
],
"name": "F2:top",
"plane": {
"origin": [
0.0,
0.0,
30.0
],
"x_dir": [
1.0,
0.0,
0.0
],
"y_dir": [
0.0,
1.0,
0.0
]
},
"type": "face"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 4,
"type": "horizontal"
},
{
"id": "k2",
"l": 6,
"type": "horizontal"
},
{
"id": "k3",
"l": 5,
"type": "vertical"
},
{
"id": "k4",
"l": 7,
"type": "vertical"
},
{
"id": "k5",
"l": 8,
"p": 9,
"type": "midpoint"
},
{
"id": "k6",
"p": 0,
"q": 9,
"type": "coincident"
},
{
"id": "k7",
"p": 7,
"param": "d4",
"q": 6,
"text": [
-0.2,
9.200000000000003
],
"type": "distance",
"value": 62.0
}
],
"counters": {
"j": 1,
"k": 7,
"l": 10,
"p": 9
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"link": "j1",
"type": "line"
},
{
"a": 3,
"b": 1,
"construction": false,
"id": "l2",
"link": "j1",
"type": "line"
},
{
"a": 2,
"b": 4,
"construction": false,
"id": "l3",
"link": "j1",
"type": "line"
},
{
"a": 4,
"b": 3,
"construction": false,
"id": "l4",
"link": "j1",
"type": "line"
},
{
"a": 5,
"b": 6,
"construction": false,
"id": "l5",
"type": "line"
},
{
"a": 6,
"b": 7,
"construction": false,
"id": "l6",
"type": "line"
},
{
"a": 7,
"b": 8,
"construction": false,
"id": "l7",
"type": "line"
},
{
"a": 8,
"b": 5,
"construction": false,
"id": "l8",
"type": "line"
},
{
"a": 5,
"b": 7,
"construction": true,
"id": "l9",
"type": "line"
},
{
"a": 6,
"b": 8,
"construction": true,
"id": "l10",
"type": "line"
}
],
"links": [
{
"auto_face": true,
"face_name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
"id": "j1",
"kind": "project",
"source": {
"at": [
11.666666666666666,
-13.999999999999995,
30.0
],
"body": "Body1",
"type": "face"
}
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": true,
"id": "p1",
"link": "j1",
"pos": [
-35.0,
-42.0
]
},
{
"fixed": true,
"id": "p2",
"link": "j1",
"pos": [
-35.0,
42.0
]
},
{
"fixed": true,
"id": "p3",
"link": "j1",
"pos": [
35.0,
-42.0
]
},
{
"fixed": true,
"id": "p4",
"link": "j1",
"pos": [
35.0,
42.0
]
},
{
"fixed": false,
"id": "p5",
"pos": [
-35.0,
-31.000000000002185
]
},
{
"fixed": false,
"id": "p6",
"pos": [
35.0,
-31.00000000000349
]
},
{
"fixed": false,
"id": "p7",
"pos": [
35.0,
30.99999999999603
]
},
{
"fixed": false,
"id": "p8",
"pos": [
-35.0,
30.999999999995318
]
},
{
"fixed": false,
"id": "p9",
"pos": [
0.0,
0.0
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "18"
},
"id": 4,
"name": "Extrude2",
"operation": "join",
"param_names": [
"d5"
],
"profiles": {
"loops": [
[
"l1",
"l5",
"l4",
"l7"
]
],
"type": "curves"
},
"sketch": 3,
"suppressed": false,
"type": "extrude"
}
],
"format": "solidcraft/14",
"name": "Untitled",
"next_id": 6,
"params": [
{
"expr": "84",
"model": true,
"name": "d1",
"unit": "mm"
},
{
"expr": "70",
"model": true,
"name": "d2",
"unit": "mm"
},
{
"expr": "62",
"model": true,
"name": "d4",
"unit": "mm"
}
],
"units": "mm"
}
@@ -1,5 +1,5 @@
{
"format": "solvecraft/1",
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [
@@ -1,5 +1,5 @@
{
"format": "solvecraft/1",
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [
@@ -1,5 +1,5 @@
{
"format": "solvecraft/1",
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
@@ -1,5 +1,5 @@
{
"format": "solvecraft/1",
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
@@ -1,5 +1,5 @@
{
"format": "solvecraft/1",
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [
@@ -1,5 +1,5 @@
{
"format": "solvecraft/1",
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
@@ -0,0 +1,144 @@
{
"bodies": [
{
"area_mm2": 4078.7680390666596,
"bbox": {
"max": [
40.0,
30.000000000000014,
12.000000000000002
],
"min": [
0.0,
0.0,
0.0
]
},
"center_of_mass": [
19.25890231218174,
15.000000010054242,
5.04117761026949
],
"density_g_cm3": 1.29,
"edges": 30,
"face_types": {
"cylinder": 1,
"plane": 11
},
"faces": 12,
"kernel": {
"edges": 30,
"faces": 12,
"shells": 1,
"vertices": 20
},
"mass_g": 14.435720327850033,
"material": "Default",
"name": "Body1",
"vertices": 20,
"volume_mm3": 11190.480874302351
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "SlotSketch",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.09895799999999999,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 20.768292000000002,
"name": "Block",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.057,
"name": "SlotSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 136.28875,
"name": "Slot",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 153.58625,
"name": "Round",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 169.174375,
"name": "Bevel",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
},
{
"error": null,
"id": 7,
"ms": 47.772166,
"name": "Raise",
"rolled_back": false,
"suppressed": false,
"type": "OffsetFacesFeature",
"warning": null
}
],
"total": {
"area_mm2": 4078.7680390666596,
"edges": 30,
"faces": 12,
"mass_g": 14.435720327850033,
"vertices": 20,
"volume_mm3": 11190.480874302351
}
}
@@ -0,0 +1,373 @@
{
"format": "solidcraft/13",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Base",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
},
{
"id": "k5",
"type": "coincident",
"p": 0,
"q": 1
}
],
"counters": {
"k": 5,
"l": 4,
"p": 4
}
}
},
{
"id": 2,
"name": "Block",
"suppressed": false,
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "SlotSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
15.0,
-5.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
25.0,
-5.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
25.0,
35.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
15.0,
35.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
}
],
"counters": {
"k": 4,
"l": 4,
"p": 4
}
}
},
{
"id": 4,
"name": "Slot",
"suppressed": false,
"param_names": [
"d2",
"d3"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive",
"start_offset": "5"
},
"operation": "cut"
},
{
"id": 5,
"name": "Round",
"suppressed": false,
"param_names": [
"d4"
],
"edge_refs": [
{
"dir": [
0.0,
-1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.0,
10.0
]
}
],
"edge_names": [
"F2:side:l2|F2:top#2"
],
"type": "fillet",
"edges": [
[
40.0,
15.0,
10.0
]
],
"radius": "2"
},
{
"id": 6,
"name": "Bevel",
"suppressed": false,
"param_names": [
"d5"
],
"edge_refs": [
{
"dir": [
0.0,
1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.000000000000007,
10.0
]
}
],
"edge_names": [
"F2:side:l4|F2:top#1"
],
"type": "chamfer",
"edges": [
[
0.0,
15.0,
10.0
]
],
"distance": "1.5"
},
{
"id": 7,
"name": "Raise",
"suppressed": false,
"param_names": [
"d6"
],
"face_names": [
"F2:top#1"
],
"type": "offset_face",
"faces": [
[
7.0,
15.0,
10.0
]
],
"distance": "2"
}
],
"next_id": 8
}
@@ -0,0 +1,111 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "Base"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
40,
30
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"distance": 10,
"name": "Block"
}
},
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "SlotSketch"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
15,
-5
],
"p1": [
25,
35
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"sketch": "SlotSketch",
"distance": 10,
"start_offset": 5,
"operation": "cut",
"name": "Slot"
}
},
{
"command": "solid.fillet",
"params": {
"edges": [
[
40,
15,
10
]
],
"radius": 2,
"name": "Round"
}
},
{
"command": "solid.chamfer",
"params": {
"edges": [
[
0,
15,
10
]
],
"distance": 1.5,
"name": "Bevel"
}
},
{
"command": "solid.offset_face",
"params": {
"faces": [
[
7,
15,
10
]
],
"distance": 2,
"name": "Raise"
}
}
]
}
@@ -0,0 +1,107 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "Base"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
40,
30
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"distance": 10,
"name": "Block"
}
},
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "SlotSketch"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
-5,
12
],
"p1": [
45,
18
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"sketch": "SlotSketch",
"distance": 10,
"operation": "cut",
"name": "Slot"
}
},
{
"command": "solid.fillet",
"params": {
"edges": [
[
20,
0,
10
]
],
"radius": 2,
"body": "Body1 (1)",
"name": "NearRound"
}
},
{
"command": "solid.chamfer",
"params": {
"edges": [
[
20,
30,
10
]
],
"distance": 1.5,
"body": "Body1",
"name": "FarBevel"
}
},
{
"command": "appearance.assign",
"params": {
"bodies": [
"Body1 (1)"
],
"color": "#cc3333"
}
}
]
}
@@ -0,0 +1,171 @@
{
"bodies": [
{
"area_mm2": 1962.6028137423857,
"bbox": {
"max": [
40.0,
30.0,
10.0
],
"min": [
0.0,
18.0,
0.0
]
},
"center_of_mass": [
20.0,
23.94794952681388,
4.957413249211356
],
"density_g_cm3": 1.29,
"edges": 15,
"face_types": {
"plane": 7
},
"faces": 7,
"kernel": {
"edges": 15,
"faces": 7,
"shells": 1,
"vertices": 10
},
"mass_g": 6.13395,
"material": "Default",
"name": "Body1",
"vertices": 10,
"volume_mm3": 4755.0
},
{
"area_mm2": 1963.9401350108699,
"bbox": {
"max": [
40.00000000000002,
12.000000000000002,
10.0
],
"min": [
-8.881784197001252e-15,
0.0,
0.0
]
},
"center_of_mass": [
20.00000000000079,
6.040035701025577,
4.967173993940324
],
"density_g_cm3": 1.29,
"edges": 15,
"face_types": {
"cylinder": 1,
"plane": 6
},
"faces": 7,
"kernel": {
"edges": 15,
"faces": 7,
"shells": 1,
"vertices": 10
},
"mass_g": 6.147677129601969,
"material": "Default",
"name": "Body1 (1)",
"vertices": 10,
"volume_mm3": 4765.64118573796
}
],
"body_count": 2,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "SlotSketch",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.07066700000000001,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 0.7462909999999999,
"name": "Block",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.035125,
"name": "SlotSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 9.647208000000001,
"name": "Slot",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 3.443708,
"name": "NearRound",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 3.947834,
"name": "FarBevel",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
}
],
"total": {
"area_mm2": 3926.5429487532556,
"edges": 30,
"faces": 14,
"mass_g": 12.281627129601969,
"vertices": 20,
"volume_mm3": 9520.64118573796
}
}
@@ -0,0 +1,366 @@
{
"format": "solidcraft/13",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Base",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
},
{
"id": "k5",
"type": "coincident",
"p": 0,
"q": 1
}
],
"counters": {
"k": 5,
"l": 4,
"p": 4
}
}
},
{
"id": 2,
"name": "Block",
"suppressed": false,
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "SlotSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
-5.0,
12.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
45.0,
12.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
45.0,
18.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
-5.0,
18.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
}
],
"counters": {
"k": 4,
"l": 4,
"p": 4
}
}
},
{
"id": 4,
"name": "Slot",
"suppressed": false,
"param_names": [
"d2"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "cut"
},
{
"id": 5,
"name": "NearRound",
"suppressed": false,
"param_names": [
"d3"
],
"edge_refs": [
{
"dir": [
-1.0,
0.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
12.000000000000002,
10.0
]
}
],
"edge_names": [
"F2:side:l1|F2:top"
],
"type": "fillet",
"edges": [
[
20.0,
0.0,
10.0
]
],
"radius": "2",
"body": "Body1 (1)"
},
{
"id": 6,
"name": "FarBevel",
"suppressed": false,
"param_names": [
"d4"
],
"edge_refs": [
{
"dir": [
1.0,
0.0,
0.0
],
"min": [
0.0,
18.0,
0.0
],
"max": [
40.0,
30.0,
10.0
]
}
],
"edge_names": [
"F2:side:l3|F2:top"
],
"type": "chamfer",
"edges": [
[
20.0,
30.0,
10.0
]
],
"distance": "1.5",
"body": "Body1"
}
],
"next_id": 7,
"appearances": {
"bodies": {
"Body1 (1)": {
"name": "Custom",
"color": [
204,
51,
51
],
"opacity": 1.0
}
}
}
}
+458
View File
@@ -0,0 +1,458 @@
//! Designs saved by older SolidCraft builds (tests/corpus, see its README) open and evaluate as
//! they did when they were saved; damaged and hostile design files never panic.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_engine::Session;
fn corpus() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
let mut v: Vec<PathBuf> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
v.sort();
assert!(v.len() >= 7, "corpus files in {}", dir.display());
v
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
/// Differences that are deliberate behaviour changes made before the file format had versions
/// (no migration can tell those files apart): (file, check, why).
const KNOWN: [(&str, &str, &str); 2] = [
(
"m0-revolve-primitives",
"Body1 bbox",
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
),
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
];
fn known(file: &str, check: &str) -> bool {
KNOWN.iter().any(|(f, c, _)| *f == file && *c == check)
}
/// Volumes and areas then came from coarser tessellation (and curved booleans and fillets have
/// become exact since): the same part within this relative tolerance (the oracle's).
const REL: f64 = 1e-3;
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * b.abs().max(1.0)
}
#[test]
fn old_designs_open_and_evaluate_as_saved() {
let mut problems = Vec::new();
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap();
let mut s = open(&p);
for r in &s.model.results {
if let Some(e) = &r.error {
problems.push(format!("{name}: feature {}: {e}", r.id));
}
}
assert_eq!(s.doc.format, solidcraft_engine::doc::format::format_string(solidcraft_engine::doc::format::FORMAT), "{name}: upgraded");
let got = s.execute("inspect.measure", &json!({})).unwrap();
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
}
for w in want["bodies"].as_array().into_iter().flatten() {
let bname = w["name"].as_str().unwrap_or_default();
let Some(g) = got["bodies"].as_array().into_iter().flatten().find(|g| g["name"] == w["name"]) else {
problems.push(format!("{name}: body {bname} missing"));
continue;
};
for k in ["volume_mm3", "area_mm2"] {
let (a, b) = (g[k].as_f64().unwrap_or(f64::NAN), w[k].as_f64().unwrap_or(f64::NAN));
if !close(a, b, REL) {
problems.push(format!("{name}: {bname} {k} {a} (saved {b})"));
}
}
if known(&name, &format!("{bname} bbox")) {
continue;
}
for (end, i) in [("min", 0), ("min", 1), ("min", 2), ("max", 0), ("max", 1), ("max", 2)] {
let (a, b) = (g["bbox"][end][i].as_f64().unwrap_or(f64::NAN), w["bbox"][end][i].as_f64().unwrap_or(f64::NAN));
if (a - b).abs() > 1e-3 * b.abs().max(1.0) {
problems.push(format!("{name}: {bname} bbox {end}[{i}] {a} (saved {b})"));
}
}
}
// Saved again, it reads back the same.
let again = solidcraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(again, *s.doc, "{name}: round trip");
let package = solidcraft_engine::io::write_design_container(&s.doc).unwrap();
let packaged = solidcraft_engine::io::read_design(&package).unwrap();
assert_eq!(packaged, *s.doc, "{name}: container round trip after migration");
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// Names saved before format 14 were worked out from geometry, pieces of a split face numbered by
/// where their centres lay (`F2:top#2`). They keep naming the faces they named: marked as such,
/// they are found as the build that saved them found them, without new notes.
#[test]
fn names_saved_before_exact_history_find_the_faces_they_named() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solidcraft");
let s = open(&path);
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
assert_eq!(feature("Bevel").edge_names, vec!["?F2:side:l4|F2:top#1".to_string()]);
assert_eq!(feature("Raise").face_names, vec!["?F2:top#1".to_string()]);
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
// The raised piece is the left one, as saved (the stored point is on it).
let state = s.model.state();
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
}
/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order.
/// The features after the split that refer to a piece by its body name still find that piece:
/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured).
#[test]
fn body_names_saved_before_exact_history_keep_their_pieces() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solidcraft");
let s = open(&path);
assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order");
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
let state = s.model.state();
let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}"));
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0]));
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
}
/// Flat faces that can't take an attached sketch yet, by cause: (design, body, the faces'
/// planes as outward normal and distance from the origin, or every flat face of the body,
/// issue). Face indices are not used: they can change between builds. Each issue has an
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
/// check until it is removed from here.
type Planes = &'static [([f64; 3], f64)];
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 9] = [
("issue-489-stacked-join", "Body1", Some(&[([1., 0., 0.], 35.), ([-1., 0., 0.], 35.)]), 491),
("issue-489-stacked-join", "Body1", Some(&[([0., 0., 1.], 30.)]), 492),
("m0-revolve-primitives", "Body2", Some(&[([-FRAC_1_SQRT_2, 0., FRAC_1_SQRT_2], -21. * FRAC_1_SQRT_2)]), 493),
("split-body-pieces", "Body1", Some(&[([0., FRAC_1_SQRT_2, FRAC_1_SQRT_2], 38.5 * FRAC_1_SQRT_2), ([0., -1., 0.], -18.)]), 493),
("split-body-pieces", "Body1 (1)", Some(&[([0., 1., 0.], 12.)]), 493),
("m0-revolve-primitives", "Body1", None, 494),
("naming-split-pieces", "Body1", None, 494),
("modern-plastic", "Body1", None, 494),
("modern-sheet-metal", "Body1", None, 495),
];
use std::f64::consts::FRAC_1_SQRT_2;
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
let same = |(n, d): &([f64; 3], f64)| (0..3).all(|i| (n[i] - normal[i]).abs() < 1e-6) && (d - distance).abs() < 1e-4;
NO_ATTACHMENT_YET.iter().find_map(|(d, b, planes, issue)| {
if *d != design || *b != body {
return None;
}
match planes {
None => Some((*issue, false)),
Some(planes) => planes.iter().any(same).then_some((*issue, true)),
}
})
}
fn undo_to(s: &mut Session, features: usize) {
for _ in 0..4 {
if s.doc.features.len() <= features {
return;
}
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(s.doc.features.len(), features, "undo did not remove the added feature");
}
/// Try a sketch and an offset plane on every flat face of every final body of every corpus
/// design, through the same face pick the desktop sends (body, face index, occurrence path, a
/// point on it), without `fixed`. Undo leaves the design as it was for the next face. Returns
/// (design, body, face, issue it is known under, what went wrong or None).
fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, Option<String>)> {
use solidcraft_engine::doc::{FaceAttachmentStatus, apply_point};
let mut out = Vec::new();
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let mut s = open(&p);
let state = s.model.state();
let placed = s.placed_document();
let mut picks = Vec::new();
for (world, definition, component, path, frame) in placed.body_frames_with_source(&state) {
let Some(body) = state.bodies.iter().find(|b| b.definition_name() == definition && placed.model_body_component(b) == component) else {
continue;
};
let mesh = body.body.tessellate(0.01).unwrap();
for index in 0..body.body.face_count() {
if body.body.planar_face(index).is_none() {
continue;
}
// The middle of the face's largest triangle: well inside the face.
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, f)| **f as usize == index)
.filter_map(|(t, _)| mesh.tri(t))
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0));
let Some(point) = point else { continue };
let point = apply_point(&frame, point);
let normal =
body.body.planar_face(index).map(|plane| solidcraft_engine::doc::apply_vector(&frame, plane.normal())).unwrap_or_default();
let key = ([normal.x, normal.y, normal.z], normal.dot(point));
picks.push((world.clone(), index, key, json!({"face": point, "body": world, "index": index, "path": path})));
}
}
let before = s.doc.features.len();
for (body, index, (normal, distance), plane) in picks {
let mut problem = None;
match s.execute("sketch.create", &json!({"plane": plane})) {
Ok(created) => {
let id = created["sketch"].as_u64().unwrap_or(0);
match s.placed_document().sketch_face_attachment(id, &s.model) {
FaceAttachmentStatus::Healthy { .. } | FaceAttachmentStatus::Sheet { .. } => {}
other => problem = Some(format!("sketch attachment {other:?}")),
}
s.execute("sketch.finish", &json!({})).unwrap();
undo_to(&mut s, before);
}
Err(e) => problem = Some(format!("sketch.create: {e}")),
}
match s.execute("construct.plane.offset", &json!({"base": plane, "offset": 5})) {
Ok(_) => undo_to(&mut s, before),
Err(e) => problem = Some(problem.map_or(String::new(), |p| p + "; ") + &format!("construct.plane.offset: {e}")),
}
let known = known_without_attachment(&name, &body, normal, distance);
out.push((name.clone(), body, index, known, problem));
}
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{name}: left with errors after undo");
}
out
}
/// Every flat face of every corpus design takes a durable attachment (issue #489: the top face
/// of an extrude sketched over a face's projected boundary had "no exact planar source"),
/// except the faces listed in [`NO_ATTACHMENT_YET`] under their open issues.
#[test]
fn every_flat_face_of_every_corpus_design_takes_an_attached_sketch() {
let all = flat_face_attachments();
assert!(all.len() > 50, "only {} faces checked", all.len());
let problems: Vec<String> = all
.iter()
.filter_map(|(design, body, face, known, problem)| match (known, problem) {
(None, Some(problem)) => Some(format!("{design}: {body} face {face}: {problem}")),
(Some((issue, true)), None) => Some(format!("{design}: {body} face {face} attaches now: remove it from NO_ATTACHMENT_YET (#{issue})")),
_ => None,
})
.collect();
assert!(problems.is_empty(), "{} of {} faces:\n{}", problems.len(), all.len(), problems.join("\n"));
}
fn known_faces_attach(issue: u32) {
let problems: Vec<String> = flat_face_attachments()
.into_iter()
.filter(|(.., known, problem)| known.is_some_and(|(known, _)| known == issue) && problem.is_some())
.map(|(design, body, face, _, problem)| format!("{design}: {body} face {face}: {}", problem.unwrap_or_default()))
.collect();
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[test]
#[ignore = "fails until #491 is fixed"]
fn a_side_face_join_merged_from_two_extrudes_takes_an_attached_sketch() {
known_faces_attach(491);
}
#[test]
#[ignore = "fails until #492 is fixed"]
fn a_face_split_into_pieces_by_a_later_feature_takes_an_attached_sketch() {
known_faces_attach(492);
}
#[test]
#[ignore = "fails until #493 is fixed"]
fn cut_walls_and_chamfer_faces_take_an_attached_sketch() {
known_faces_attach(493);
}
#[test]
#[ignore = "fails until #494 is fixed"]
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
known_faces_attach(494);
}
#[test]
#[ignore = "fails until #495 is fixed"]
fn sheet_metal_thickness_faces_take_an_attached_sketch() {
known_faces_attach(495);
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
const HOSTILE: [&str; 14] =
["0", "-1", "1e308", "-1e308", "1e9", "4294967297", "\"\"", "\"1/0\"", "\"((((1\"", "null", "true", "[]", "{}", "\"nope\""];
/// Every JSON value in a document, as a path of keys and indices.
fn paths(v: &Value, at: &mut Vec<Value>, out: &mut Vec<Vec<Value>>) {
out.push(at.clone());
match v {
Value::Object(m) => {
for (k, x) in m {
at.push(json!(k));
paths(x, at, out);
at.pop();
}
}
Value::Array(a) => {
for (i, x) in a.iter().enumerate() {
at.push(json!(i));
paths(x, at, out);
at.pop();
}
}
_ => {}
}
}
fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
let mut cur = v;
for k in path {
cur = match k {
Value::String(s) => cur.get_mut(s.as_str())?,
Value::Number(n) => cur.get_mut(n.as_u64()? as usize)?,
_ => return None,
};
}
Some(cur)
}
/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure.
fn try_bytes(bytes: &[u8]) {
if let Ok(doc) = solidcraft_engine::io::read_design(bytes) {
let mut s = Session::new(doc);
let _ = s.execute("inspect.measure", &json!({}));
let _ = s.execute("document.inspect", &json!({}));
}
}
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
/// cycle, which resolve through the model and back into the document.
fn generated() -> Vec<(String, Vec<u8>)> {
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
let plane = |id: u64, name: &str, side: Value| {
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
};
let design = |name: &str, features: Value| {
json!({"format": "solidcraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
};
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
// by the point count and abort on the allocation.
let point = json!({"id": "p1", "pos": [0.0, 0.0], "fixed": false});
let oversized = json!([{"id": 1, "name": "Sketch1", "suppressed": false, "type": "sketch",
"plane": {"type": "origin", "name": "XY"},
"sketch": {"points": vec![point; 200_000], "curves": [], "constraints": []}}]);
vec![
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
("sketch with 200,000 points".into(), design("bigsketch", oversized)),
]
}
#[test]
fn damaged_and_hostile_design_files_never_panic() {
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
// The cases come from one seeded sequence; trying them is independent, so threads share it.
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
for p in corpus() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let bytes = std::fs::read(&p).unwrap();
let doc: Value = serde_json::from_slice(&bytes).unwrap();
let mut all = Vec::new();
paths(&doc, &mut Vec::new(), &mut all);
let mut cases: Vec<(String, Vec<u8>)> = Vec::new();
// Truncated and bit-flipped files.
for _ in 0..8 {
let cut = rng.below(bytes.len());
cases.push((format!("truncated at {cut}"), bytes[..cut].to_vec()));
let mut b = bytes.clone();
let i = rng.below(b.len());
b[i] ^= 1 << rng.below(8);
cases.push((format!("bit flip at {i}"), b));
}
// Values replaced by hostile ones, and keys removed.
for _ in 0..25 {
let path = &all[rng.below(all.len())];
let mut v = doc.clone();
let h = HOSTILE[rng.below(HOSTILE.len())];
if let Some(slot) = at_mut(&mut v, path) {
*slot = serde_json::from_str(h).unwrap();
}
cases.push((format!("{path:?} = {h}"), v.to_string().into_bytes()));
let mut v = doc.clone();
if let Some((last, parent)) = path.split_last()
&& let (Some(Value::Object(m)), Value::String(k)) = (at_mut(&mut v, parent), last)
{
m.remove(k);
}
cases.push((format!("{path:?} removed"), v.to_string().into_bytes()));
}
// Features repeated many times; deep nesting; not JSON at all.
let mut v = doc.clone();
if let Some(Value::Array(fs)) = v.get_mut("features") {
let copy = fs.clone();
for _ in 0..2 {
fs.extend(copy.iter().cloned());
}
}
cases.push(("features repeated".into(), v.to_string().into_bytes()));
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
}
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let failures: Vec<String> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut failures = Vec::new();
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
failures
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
});
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
}
@@ -5,7 +5,7 @@ use std::process::Command;
#[test]
fn overflowing_seed_range_is_an_error_not_a_panic() {
let output =
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
@@ -15,14 +15,14 @@ fn overflowing_seed_range_is_an_error_not_a_panic() {
#[test]
fn empty_seed_range_at_maximum_is_valid() {
let output =
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
}
#[test]
fn maximum_seed_runs_once_without_wrapping() {
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
.output()
.unwrap();
@@ -6,17 +6,17 @@ use std::time::Duration;
fn round_of(path: &std::path::Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
let doc = solvecraft_engine::io::read_design(&bytes).ok()?;
let doc = solidcraft_engine::io::read_design(&bytes).ok()?;
doc.params.iter().find(|p| p.name == "save_round").map(|p| p.expr.clone())
}
#[test]
fn killed_mid_save_the_old_file_survives() {
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id()));
let exe = env!("CARGO_BIN_EXE_solidcraft-cli");
let dir = std::env::temp_dir().join(format!("solidcraft-kill-mid-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("design.solvecraft");
let path = dir.join("design.solidcraft");
let ok = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "1"]).status().unwrap();
assert!(ok.success());
assert_eq!(round_of(&path).as_deref(), Some("0"));
@@ -44,7 +44,7 @@ fn killed_mid_save_the_old_file_survives() {
let round = round_of(&path);
assert!(round.is_some(), "after kill {i} the design file is torn or missing");
seen.insert(round);
let bak = solvecraft_engine::io::backup_path(&path);
let bak = solidcraft_engine::io::backup_path(&path);
if bak.exists() {
assert!(round_of(&bak).is_some(), "after kill {i} the backup is torn");
}
+20
View File
@@ -0,0 +1,20 @@
//! The `solvecraft-cli` compatibility alias forwards to `solidcraft-cli`.
use std::process::Command;
#[test]
fn legacy_alias_forwards_with_a_deprecation_note() {
let out = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--version").output().unwrap();
assert!(out.status.success(), "{out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.trim(), format!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("deprecated") && stderr.contains("solidcraft-cli"), "{stderr}");
}
#[test]
fn legacy_alias_passes_the_exit_status_through() {
let alias = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("no-such-command").output().unwrap();
let real = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("no-such-command").output().unwrap();
assert_eq!(alias.status.code(), real.status.code());
assert_eq!(alias.stdout, real.stdout);
}
+18
View File
@@ -0,0 +1,18 @@
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
use std::net::TcpListener;
use std::process::{Command, Stdio};
#[test]
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
// A port that was free a moment ago: nothing listens on it.
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
.stdin(Stdio::null())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{stderr}");
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
}
@@ -12,7 +12,7 @@ struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solvecraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
let dir = std::env::temp_dir().join(format!("solidcraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
@@ -31,7 +31,7 @@ impl Drop for Scratch {
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
}
fn json_output(output: Output) -> Value {
@@ -43,10 +43,27 @@ fn body_count(path: &Path) -> Value {
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
}
#[test]
fn cli_loads_both_design_encodings_by_contents() {
let dir = Scratch::new();
for extension in ["solidcraft", "solvecraft", "json"] {
let saved = dir.0.join(format!("box.{extension}"));
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
let bytes = std::fs::read(&saved).unwrap();
assert_eq!(solidcraft_engine::io::is_design_container(&bytes), extension != "json");
let renamed = dir.0.join(format!("{extension}.unusual"));
std::fs::write(&renamed, bytes).unwrap();
let measured = json_output(cli().arg("eval").arg(&renamed).output().unwrap());
assert_eq!(measured["body_count"], 1);
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 24.0).abs() < 1e-6);
}
}
#[test]
fn results_still_save_and_export_the_script_once() {
let dir = Scratch::new();
let saved = dir.0.join("box.solvecraft");
// JSON is explicit here because the fixture edits its saved format tag.
let saved = dir.0.join("box.json");
let stl = dir.0.join("box.stl");
let obj = dir.0.join("box.obj");
let result = json_output(
@@ -64,8 +81,8 @@ fn results_still_save_and_export_the_script_once() {
fn results_with_base_design_and_quiet_still_report_and_save() {
let dir = Scratch::new();
let script = dir.script();
let base = dir.0.join("base.solvecraft");
let saved = dir.0.join("combined.solvecraft");
let base = dir.0.join("base.solidcraft");
let saved = dir.0.join("combined.solidcraft");
json_output(cli().arg("run").arg(&script).arg("--save").arg(&base).output().unwrap());
let result = json_output(cli().args(["run", "--results", "--quiet", "--in"]).arg(&base).arg(&script).arg("--save").arg(&saved).output().unwrap());
assert_eq!(result.as_array().unwrap().len(), 1);
@@ -77,7 +94,7 @@ fn results_with_base_design_and_quiet_still_report_and_save() {
fn results_propagate_output_errors() {
let dir = Scratch::new();
for flag in ["--save", "--out"] {
let path = dir.0.join("missing").join(if flag == "--save" { "box.solvecraft" } else { "box.stl" });
let path = dir.0.join("missing").join(if flag == "--save" { "box.solidcraft" } else { "box.stl" });
let output = cli().arg("run").arg(dir.script()).arg("--results").arg(flag).arg(&path).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(!output.stderr.is_empty());
@@ -89,9 +106,29 @@ fn results_propagate_output_errors() {
#[test]
fn results_without_script_still_fail() {
let dir = Scratch::new();
let base = dir.0.join("base.solvecraft");
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let output = cli().args(["run", "--results", "--in"]).arg(&base).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("--results needs a script"));
}
/// Compatibility shim (docs/compatibility.md): a design saved before the rename, with the
/// legacy extension and format tag, still opens; re-saving writes the current tag.
#[test]
fn legacy_extension_and_format_tag_still_open() {
let dir = Scratch::new();
// JSON is explicit here because the fixture edits its saved format tag.
let saved = dir.0.join("box.json");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
let text = std::fs::read_to_string(&saved).unwrap();
assert!(text.contains("\"solidcraft/"), "new designs carry the current tag");
let legacy = dir.0.join("old.solvecraft");
std::fs::write(&legacy, text.replace("\"solidcraft/", "\"solvecraft/")).unwrap();
assert_eq!(body_count(&legacy), 1);
let resaved = dir.0.join("resaved.solidcraft");
json_output(cli().args(["run", "--in"]).arg(&legacy).arg(dir.script()).arg("--save").arg(&resaved).output().unwrap());
assert_eq!(body_count(&resaved), 2);
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
assert!(reopened.to_json().contains("\"solidcraft/"));
}
+90
View File
@@ -0,0 +1,90 @@
//! A timeline feature that fails fails `run`, and nothing is written: the bodies lack what the
//! feature should have made, and handing them out with exit 0 hides the failure.
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-run-strict-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A filleted box whose fillet a later parameter change makes impossible: every command
/// succeeds, the timeline ends with a failing feature.
fn broken_script(&self) -> PathBuf {
let path = self.0.join("broken.json");
let script = json!({"commands": [
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
]});
std::fs::write(&path, script.to_string()).unwrap();
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
}
#[test]
fn a_failing_feature_fails_the_run_and_writes_nothing() {
let dir = Scratch::new();
let script = dir.broken_script();
let (step, saved) = (dir.0.join("part.step"), dir.0.join("part.solidcraft"));
for extra in [&[][..], &["--results"][..], &["--quiet"][..]] {
let output = cli().arg("run").arg(&script).args(extra).arg("--out").arg(&step).arg("--save").arg(&saved).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{extra:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Fillet1") && stderr.contains("nothing was written"), "{stderr}");
assert!(output.stdout.is_empty(), "{extra:?}");
assert!(!step.exists() && !saved.exists(), "{extra:?}");
}
}
#[test]
fn allow_errors_writes_anyway_and_a_saved_design_keeps_failing() {
let dir = Scratch::new();
let (step, saved) = (dir.0.join("part.step"), dir.0.join("part.solidcraft"));
let output = cli().arg("run").arg(dir.broken_script()).arg("--allow-errors").arg("--out").arg(&step).arg("--save").arg(&saved).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let measured: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(measured["total"]["volume_mm3"], json!(4000.0), "the box without its fillet");
assert!(std::fs::metadata(&step).unwrap().len() > 0);
// The saved design still holds the failing feature: running it fails too, `eval` reports it.
let again = dir.0.join("again.step");
let output = cli().arg("run").arg(&saved).arg("--out").arg(&again).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(!again.exists());
let output = cli().arg("eval").arg(&saved).output().unwrap();
assert!(output.status.success());
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(report["timeline"].as_array().unwrap().iter().any(|f| f["error"].is_string()), "{}", report["timeline"]);
}
#[test]
fn a_clean_timeline_still_writes() {
let dir = Scratch::new();
let script = dir.0.join("box.json");
std::fs::write(&script, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
let step = dir.0.join("box.step");
let output = cli().arg("run").arg(&script).arg("--out").arg(&step).arg("--quiet").output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(std::fs::metadata(&step).unwrap().len() > 0);
}
@@ -1,5 +1,5 @@
[package]
name = "solvecraft-web"
name = "solidcraft-web"
description = "SolidCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
@@ -10,12 +10,12 @@ publish = false
workspace = true
[[bin]]
name = "solvecraft-web"
name = "solidcraft-web"
path = "src/main.rs"
[dependencies]
solvecraft-engine = { workspace = true }
solvecraft-ui-egui = { workspace = true }
solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget",
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
] }
@@ -1,4 +1,4 @@
# Build with: trunk build --release (from apps/solvecraft-web)
# Build with: trunk build --release (from apps/solidcraft-web)
[build]
target = "index.html"
dist = "../../dist/web"
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>SolidCraft</title>
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="SolidCraft">
<meta property="og:title" content="SolidCraft: try it in your browser">
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:url" content="https://bherbruck.github.io/solidcraft/app/">
<meta property="og:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SolidCraft: try it in your browser">
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta name="twitter:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="copy-file" href="fonts/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="fonts/OFL.txt" data-target-path="fonts" />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
#solidcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#solidcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #4a5060; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
</head>
<body>
<canvas id="solidcraft_canvas"></canvas>
<div id="solidcraft_loading">Loading SolidCraft&hellip;</div>
</body>
</html>
@@ -1,5 +1,5 @@
//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into
//! the engine's in-memory files (`solvecraft_engine::io::vfs`) at start, written back whenever a
//! the engine's in-memory files (`solidcraft_engine::io::vfs`) at start, written back whenever a
//! command writes one. Files come in from the computer through a file picker (the File System
//! Access API where the browser has it, an `<input type=file>` otherwise) or a drop on the page,
//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
@@ -9,7 +9,7 @@ use std::cell::{Cell, RefCell};
use std::rc::Rc;
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
use solvecraft_engine::io::vfs;
use solidcraft_engine::io::vfs;
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
@@ -20,8 +20,11 @@ pub const OPFS: &str = "/opfs/";
pub const DOWNLOADS: &str = "/downloads/";
/// Files brought in from the computer.
pub const UPLOAD: &str = "/upload/";
/// The autosave of unsaved work (in OPFS: `recovery.solvecraft` and `recovery.json`).
const RECOVERY: &str = "recovery.solvecraft";
/// The autosave of unsaved work (in OPFS: `recovery.solidcraft` and `recovery.json`).
const RECOVERY: &str = "recovery.solidcraft";
/// The autosave earlier releases kept (compatibility shim, docs/compatibility.md): recovered
/// when there is no current one.
const LEGACY_RECOVERY: &str = "recovery.solvecraft";
const RECOVERY_META: &str = "recovery.json";
/// What to do with a file brought in from the computer.
@@ -87,7 +90,10 @@ thread_local! {
}
/// Local storage keys of stored files.
const LOCAL_PREFIX: &str = "solvecraft.file.";
const LOCAL_PREFIX: &str = "solidcraft.file.";
/// Local-storage files kept by earlier releases (compatibility shim, docs/compatibility.md):
/// still listed and opened; removing one removes either key.
const LEGACY_LOCAL_PREFIX: &str = "solvecraft.file.";
fn local() -> Result<web_sys::Storage, String> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).ok_or_else(|| "this browser keeps no local storage".to_string())
@@ -177,7 +183,9 @@ async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
async fn remove_file(name: &str) -> Result<(), String> {
if local_only() {
return local()?.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
let st = local()?;
st.remove_item(&format!("{LEGACY_LOCAL_PREFIX}{name}")).map_err(err)?;
return st.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
}
let dir = root().await?;
JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
@@ -190,7 +198,7 @@ fn read_local() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
let mut out = Vec::new();
for i in 0..st.length().unwrap_or(0).min(10_000) {
let Some(k) = st.key(i).ok().flatten() else { continue };
let Some(name) = k.strip_prefix(LOCAL_PREFIX) else { continue };
let Some(name) = k.strip_prefix(LOCAL_PREFIX).or_else(|| k.strip_prefix(LEGACY_LOCAL_PREFIX)) else { continue };
if let Some(v) = st.get_item(&k).ok().flatten() {
out.push((name.to_string(), decode(&v), 0.0));
}
@@ -251,7 +259,14 @@ pub fn start(files: &Rc<Files>) {
files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
}
let mut entries = Vec::new();
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
for (name, bytes, modified) in all {
if name == LEGACY_RECOVERY {
if !has_recovery {
vfs::insert(&format!("{OPFS}{RECOVERY}"), bytes);
}
continue;
}
if name == RECOVERY_META {
let meta: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
let n = meta["name"].as_str().unwrap_or("Untitled").to_string();
@@ -349,7 +364,7 @@ pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
if files.entries.borrow().iter().any(|e| e.name == to) {
return Err(format!("there is already a file named {to}"));
}
let bytes = vfs::read(&format!("{OPFS}{from}"), solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
let bytes = vfs::read(&format!("{OPFS}{from}"), solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?;
delete(files, from);
flush(files);
@@ -357,11 +372,17 @@ pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
}
/// Keep (or drop) the autosave of the open design.
pub fn autosave(files: &Rc<Files>, doc: Option<(&solvecraft_engine::doc::Document, Option<&str>)>) {
pub fn autosave(files: &Rc<Files>, doc: Option<(&solidcraft_engine::doc::Document, Option<&str>)>) {
let files = files.clone();
match doc {
Some((d, path)) => {
let bytes = solvecraft_engine::io::write_design(d);
let bytes = match solidcraft_engine::io::write_design_container(d) {
Ok(bytes) => bytes,
Err(e) => {
files.say(format!("autosave: {e}"), true);
return;
}
};
let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string();
spawn_local(async move {
let r = async {
@@ -375,6 +396,7 @@ pub fn autosave(files: &Rc<Files>, doc: Option<(&solvecraft_engine::doc::Documen
}
None => spawn_local(async move {
let _ = remove_file(RECOVERY).await;
let _ = remove_file(LEGACY_RECOVERY).await;
let _ = remove_file(RECOVERY_META).await;
}),
}
@@ -436,7 +458,7 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".solvecraft,.step,.stp,.3mf,.stl");
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl");
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
let _ = input.set_attribute("style", "display:none");
if let Some(body) = doc.body() {
@@ -1,6 +1,6 @@
//! SolveCraft in the browser.
//! SolidCraft in the browser.
//!
//! Runs the same [`solvecraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
//! sample part.
@@ -13,15 +13,15 @@ mod files;
mod web {
use std::rc::Rc;
use solvecraft_engine::Session;
use solvecraft_engine::io::vfs;
use solvecraft_ui_egui::{Services, SolveApp};
use solidcraft_engine::Session;
use solidcraft_engine::io::vfs;
use solidcraft_ui_egui::{Services, SolveApp};
use wasm_bindgen::JsCast as _;
use crate::files::{self, Files, Intent};
const CANVAS_ID: &str = "solvecraft_canvas";
const LOADING_ID: &str = "solvecraft_loading";
const CANVAS_ID: &str = "solidcraft_canvas";
const LOADING_ID: &str = "solidcraft_loading";
const DEPTH_BITS: u8 = 24;
/// Seconds between autosaves of unsaved work.
@@ -46,6 +46,10 @@ mod web {
on_unload.forget();
}
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
struct Shell {
app: SolveApp,
files: Rc<Files>,
@@ -58,6 +62,9 @@ mod web {
rename_focus: bool,
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
/// On-demand CJK font: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
}
impl Shell {
@@ -72,7 +79,7 @@ mod web {
}
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
for (name, bytes, intent) in incoming {
let design = name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
let design = solidcraft_engine::io::is_design_path(&name);
let path = if design && intent == Intent::Open {
// A design from the computer is kept in the browser (a copy), so Save works.
let path = files::free_name(&name);
@@ -126,13 +133,13 @@ mod web {
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
let recent = &mut self.app.home.recent;
recent.retain(|p| vfs::exists(p));
for e in entries.iter().filter(|e| e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT))) {
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
let p = format!("{}{}", files::OPFS, e.name);
if !recent.contains(&p) {
recent.push(p);
}
}
recent.truncate(solvecraft_ui_egui::home::MAX_RECENT);
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
}
/// The stored designs: open, insert, rename, delete, download; open from the computer.
@@ -199,7 +206,7 @@ mod web {
if ui.small_button("Open").clicked() {
act = Some(("open", e.name.clone()));
}
let design = e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
act = Some(("insert", e.name.clone()));
}
@@ -234,7 +241,7 @@ mod web {
self.app.insert_path(&path);
self.files.panel.set(false);
}
"download" => match vfs::read(&path, solvecraft_engine::io::MAX_DESIGN_BYTES) {
"download" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
Ok(b) => {
if let Err(e) = files::download(&name, &b) {
self.app.set_status(e, true);
@@ -244,8 +251,10 @@ mod web {
},
"download_open" => {
let d = &self.app.session.doc;
let bytes = solvecraft_engine::io::write_design(d);
if let Err(e) = files::download(&format!("{}.{}", d.name, solvecraft_engine::io::DESIGN_EXT), &bytes) {
let result = solidcraft_engine::io::write_design_container(d)
.map_err(|e| e.to_string())
.and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes));
if let Err(e) = result {
self.app.set_status(e, true);
}
}
@@ -304,13 +313,45 @@ mod web {
}
files::autosave(&self.files, None);
}
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
/// session falls back to English (no tofu); the saved language preference is kept.
fn tick_font(&mut self, ctx: &egui::Context) {
let done = self.font_result.borrow_mut().take();
if let Some(res) = done {
let installed = match res {
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
Err(err) => {
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
false
}
};
if installed {
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
} else {
log::warn!("CJK font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
ctx.request_repaint();
}
if self.font.tick(&mut self.app.preferences.language) {
let slot = self.font_result.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
*slot.borrow_mut() = Some(res);
ctx.request_repaint();
});
}
}
}
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.tick_font(ctx);
self.app.logic(ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
files::take_drops(&self.files, raw);
@@ -323,17 +364,31 @@ mod web {
self.recovery_prompt(&ctx);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(PREFS_KEY, self.app.prefs());
// Persist the user's own language, not a session-only English fallback.
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
self.app.preferences.language = live;
}
}
/// Where the preferences (theme) are kept (the browser's local storage).
const PREFS_KEY: &str = "solvecraft.prefs";
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Fetch font bytes asynchronously through the browser.
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?;
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "response cast failed")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
}
let ab_val = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
let uint8_arr = js_sys::Uint8Array::new(&ab_val);
Ok(uint8_arr.to_vec())
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -355,6 +410,7 @@ mod web {
canvas,
options,
Box::new(move |cc| {
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let files = Rc::new(Files::default());
files::start(&files);
let panel = files.clone();
@@ -366,13 +422,13 @@ mod web {
})),
// Designs are kept in the browser; everything else is downloaded.
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
let design = exts.contains(&solvecraft_engine::io::DESIGN_EXT);
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
})),
..Default::default()
};
let mut app = SolveApp::new(Session::default(), services);
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
@@ -380,7 +436,7 @@ mod web {
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
}
if query().contains("sample") {
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
app.set_status(format!("sample: {e}"), true);
}
app.session.doc_mut().name = "Sample Plate".into();
@@ -398,6 +454,8 @@ mod web {
renaming: None,
rename_focus: false,
recents_synced: false,
font: Default::default(),
font_result: Rc::new(std::cell::RefCell::new(None)),
}))
}),
)
@@ -421,5 +479,5 @@ fn main() {
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("solvecraft-web only runs in the browser: build it with `trunk build --release` in apps/solvecraft-web");
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
}
@@ -1,24 +1,27 @@
[package]
name = "solvecraft"
name = "solidcraft"
description = "SolidCraft desktop app"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
default-run = "solvecraft"
default-run = "solidcraft"
[lints]
workspace = true
[dependencies]
solvecraft-engine = { workspace = true }
solvecraft-ui-egui = { workspace = true }
solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
quick-xml = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
@@ -56,6 +56,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let rc = out.join("solidcraft.rc");
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
// Missing resource tools are a build error, rather than silently shipping a generic icon.
embed_resource::compile_for(&rc, ["solvecraft"], embed_resource::NONE).manifest_required()?;
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
@@ -8,8 +8,8 @@ use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use serde_json::{Value, json};
use solvecraft_engine::MAX_LINE;
use solvecraft_ui_egui::ControlRequest;
use solidcraft_engine::MAX_LINE;
use solidcraft_ui_egui::ControlRequest;
/// Connections served at once. The channel has no authentication beyond being bound to
/// loopback, so a flood must not be able to spawn threads without end.
@@ -24,11 +24,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("solvecraft: control server failed to bind 127.0.0.1:{port}: {e}");
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
eprintln!("solvecraft: control server listening on 127.0.0.1:{port}");
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
+434
View File
@@ -0,0 +1,434 @@
//! SolidCraft desktop app.
//!
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [design.solidcraft | part.step]`
//!
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
//! See `solidcraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(not(target_os = "macos"))]
#[path = "../../app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod space_mouse;
use solidcraft_engine::{Session, io};
use solidcraft_ui_egui::{Services, SolveApp};
struct App {
solve: SolveApp,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse: space_mouse::SpaceMouse,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The design on screen is the one whose commands a crash report replays.
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
// designs first; the window closes once they are answered (`quit_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
}
self.solve.logic(ctx);
#[cfg(any(target_os = "windows", target_os = "linux"))]
self.space_mouse.tick(&mut self.solve, ctx);
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.solve.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.solve.ui(ui);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
}
fn on_exit(&mut self) {
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_engine::crash::end_session();
}
}
fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(
"Designs, STEP, IGES and meshes",
&[
io::DESIGN_EXT,
io::LEGACY_DESIGN_EXT,
"json",
"step",
"stp",
"STEP",
"STP",
"igs",
"iges",
"IGS",
"IGES",
"3mf",
"3MF",
"stl",
"STL",
],
)
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
let dialog = rfd::FileDialog::new().set_file_name(name);
let dialog = if exts == [io::DESIGN_EXT, "json"] {
if name.ends_with(".json") {
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
} else {
dialog.add_filter("SolidCraft container", &[io::DESIGN_EXT]).add_filter("Plain JSON design", &["json"])
}
} else {
dialog.add_filter("File", exts)
};
dialog.save_file().map(|p| p.to_string_lossy().to_string())
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
});
command.arg(folder);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let _ = command.spawn();
})),
}
}
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.
fn settings_dir() -> Option<std::path::PathBuf> {
eframe::storage_dir("SolidCraft")
}
/// The anti-aliasing preference, kept next to eframe's storage (it is needed before the
/// window opens).
fn graphics_path() -> Option<std::path::PathBuf> {
settings_dir().map(|d| d.join("graphics.json"))
}
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
/// window's app id. The old folders are never changed.
fn adopt_legacy_settings() {
let (Some(current), Some(legacy)) = (settings_dir(), eframe::storage_dir("SolveCraft")) else { return };
let legacy_eframe = eframe::storage_dir(APP_ID);
if let Err(e) = adopt_settings(&current, &legacy, legacy_eframe.as_deref()) {
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
}
}
fn adopt_settings(current: &std::path::Path, legacy: &std::path::Path, legacy_eframe: Option<&std::path::Path>) -> std::io::Result<()> {
let fresh = !current.exists();
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
if let Some(old) = legacy_eframe.map(|d| d.join("app.ron")).filter(|p| fresh && p.is_file()) {
std::fs::create_dir_all(current)?;
let new = current.join("app.ron");
if !new.exists() {
std::fs::copy(old, new)?;
}
}
Ok(())
}
/// Anti-aliasing samples from Preferences (4 when unset).
fn msaa() -> u16 {
graphics_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
.and_then(|m| u16::try_from(m).ok())
.filter(|m| [1, 2, 4, 8].contains(m))
.unwrap_or(MSAA)
}
/// Windows and Linux: no OS title bar; the application bar is the title bar
/// (`solidcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const DEPTH_BITS: u8 = 24;
/// The section cap marks where it shows in the stencil.
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
// Let macOS use the bundle's layered icon; eframe's default bitmap would replace it.
#[cfg(target_os = "macos")]
let icon = egui::IconData::default();
#[cfg(not(target_os = "macos"))]
let icon =
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
Ok(egui::ViewportBuilder::default()
.with_title("SolidCraft")
.with_icon(icon)
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID))
}
/// The window's app id: it names the desktop entry, so it stays the packages' reverse-DNS id,
/// which is kept from before the rename (compatibility shim, docs/compatibility.md).
const APP_ID: &str = "ai.storyteller.solvecraft";
fn main() -> eframe::Result {
adopt_legacy_settings();
let samples = msaa();
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--version" | "-V" => {
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--crash-log" => {
match solidcraft_engine::crash::default_dir() {
Some(dir) => {
println!("crash reports: {}", dir.display());
for r in solidcraft_engine::crash::list(&dir).iter().take(5) {
println!(" {} {} {}", r.id, r.kind, r.message);
}
println!("more: solidcraft-cli crashes");
}
None => println!("no crash folder on this system"),
}
return Ok(());
}
"--help" | "-h" => {
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [design.solidcraft | part.step]");
return Ok(());
}
_ => files.push(a),
}
}
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
let crashed = solidcraft_engine::crash::default_dir()
.map(|dir| {
let build = solidcraft_engine::crash::Build {
version: solidcraft_ui_egui::help::VERSION,
commit: solidcraft_ui_egui::help::COMMIT,
built: solidcraft_ui_egui::help::BUILD_DATE,
};
solidcraft_engine::crash::install(dir, build)
})
.unwrap_or_default();
let options = eframe::NativeOptions {
viewport: app_viewport()?,
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
// Kept with the other settings, not in a folder named after the app id.
persistence_path: settings_dir().map(|d| d.join("app.ron")),
..Default::default()
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
#[cfg(all(unix, not(target_os = "macos")))]
if prefer_x11(
std::env::var_os("DISPLAY").is_some(),
std::env::var_os("WAYLAND_DISPLAY").is_some(),
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
solidcraft_engine::geom::env::var("WAYLAND").as_deref(),
) {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
eframe::run_native(
"SolidCraft",
options,
Box::new(move |cc| {
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let mut app = SolveApp::new(Session::default(), services());
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
// A saved anti-aliasing the device can't do: back to the default, with a notice.
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
let info = rs.adapter.get_info();
solidcraft_engine::crash::set_environment(&format!(
"Renderer: {:?} {} ({}), MSAA {}x",
info.backend, info.name, info.driver, rs.msaa_samples
));
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if sample {
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
app.set_status(format!("sample: {e}"), true);
}
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
app.session.undo.clear();
}
for f in &files {
app.open_path_async(f);
}
// No design given: the start page (not when driven over the control channel, whose
// clients expect the design view; `ui.home` shows it there).
if files.is_empty() && !sample && control_port.is_none() {
let rev = app.session.revision;
app.home.show(rev);
}
// Autosave, and offer what crashed sessions left behind.
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
}
// Tell the user about crashes since the last start (after the recovery offer is set up).
solidcraft_ui_egui::crash_ui::announce(&mut app, &crashed);
#[cfg(any(target_os = "windows", target_os = "linux"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
Ok(Box::new(App {
solve: app,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse,
}))
}),
)
}
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
/// `SOLIDCRAFT_WAYLAND=1` keeps native Wayland, `SOLIDCRAFT_WAYLAND=0` forces XWayland.
#[cfg(all(unix, not(target_os = "macos")))]
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
match wayland_override.map(str::trim) {
Some("0") => return display,
Some(_) => return false,
None => {}
}
let niri = desktop.is_some_and(|d| d.split(':').any(|name| name.trim().eq_ignore_ascii_case("niri")));
display && !(niri && wayland_display)
}
#[cfg(all(test, unix, not(target_os = "macos")))]
mod backend_tests {
use super::prefer_x11;
#[test]
fn xwayland_by_default_native_wayland_on_niri_and_the_override_wins() {
// XWayland whenever there's an X display, for file drag-and-drop.
assert!(prefer_x11(true, true, Some("GNOME"), None));
assert!(prefer_x11(true, false, None, None));
assert!(!prefer_x11(false, true, Some("KDE"), None));
// niri (alone or in a desktop list) under Wayland stays native.
assert!(!prefer_x11(true, true, Some("niri"), None));
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
// niri without a Wayland socket (nested X session) still uses X11.
assert!(prefer_x11(true, false, Some("niri"), None));
// SOLIDCRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
}
}
#[cfg(test)]
mod icon_tests {
#[cfg(target_os = "macos")]
#[test]
fn macos_preserves_the_bundle_icon_instead_of_setting_a_runtime_bitmap() {
let viewport = super::app_viewport().expect("viewport");
assert_eq!(viewport.icon.as_deref(), Some(&eframe::egui::IconData::default()));
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
let viewport = super::app_viewport().expect("viewport");
let icon = viewport.icon.expect("running window must have an icon");
assert_eq!((icon.width, icon.height), (64, 64));
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
}
}
#[cfg(test)]
mod msaa_tests {
#[test]
fn first_launch_adopts_the_previous_release_settings() {
let d = std::env::temp_dir().join(format!("solidcraft-adopt-settings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let (current, legacy, legacy_eframe) = (d.join("solidcraft"), d.join("solvecraft"), d.join("ai.storyteller.solvecraft"));
std::fs::create_dir_all(&legacy).unwrap();
std::fs::create_dir_all(&legacy_eframe).unwrap();
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
super::adopt_settings(&current, &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
// Later launches leave the current settings alone.
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
std::fs::remove_file(current.join("graphics.json")).unwrap();
super::adopt_settings(&current, &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
assert!(!current.join("graphics.json").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn saved_multisampling_uses_the_selected_backend_policy() {
use eframe::wgpu::Backend;
for requested in [1, 2, 4, 8] {
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(Backend::Gl, requested), 1);
for backend in [Backend::Vulkan, Backend::Dx12, Backend::Metal] {
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(backend, requested), requested);
}
}
}
}
+604
View File
@@ -0,0 +1,604 @@
//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). The device
//! reader never touches the document or UI; it publishes the latest axes, and the egui thread
//! applies them once per frame.
mod hid_descriptor;
#[cfg(target_os = "linux")]
mod hidraw;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui::Context;
use hid_descriptor::Layout;
#[cfg(target_os = "windows")]
use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
use solidcraft_engine::render::Camera;
use solidcraft_ui_egui::SolveApp;
const STALE_AFTER: Duration = Duration::from_millis(150);
const RETRY_AFTER: Duration = Duration::from_secs(2);
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Axes {
translation: [i16; 3],
rotation: [i16; 3],
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct AxisRule {
output: usize,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct DriverMapping {
axes: [AxisRule; 6],
overall_scale: f64,
}
impl Default for DriverMapping {
fn default() -> Self {
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Motion {
translation: [f64; 3],
rotation: [f64; 3],
}
impl DriverMapping {
fn apply(self, axes: Axes) -> Motion {
let raw = [
normalized(axes.translation[0]),
normalized(axes.translation[1]),
normalized(axes.translation[2]),
normalized(axes.rotation[0]),
normalized(axes.rotation[1]),
normalized(axes.rotation[2]),
];
let mut logical = [0.0; 6];
for (input, rule) in self.axes.iter().enumerate() {
if rule.enabled {
let sign = if rule.reversed { -1.0 } else { 1.0 };
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
}
}
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
}
}
#[derive(Clone, Copy, Debug, Default)]
struct ProfileAxis {
input: Option<usize>,
output: Option<usize>,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum ProfilePart {
Input,
Output,
}
fn axis_index(name: &str) -> Option<usize> {
match name.trim() {
"HIDMultiAxis_X" => Some(0),
"HIDMultiAxis_Y" => Some(1),
"HIDMultiAxis_Z" => Some(2),
"HIDMultiAxis_Rx" => Some(3),
"HIDMultiAxis_Ry" => Some(4),
"HIDMultiAxis_Rz" => Some(5),
_ => None,
}
}
/// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in
/// `%APPDATA%/3Dconnexion/3DxWare/Cfg/<app>-KMJ.xml`. Direct HID reads bypass the driver, so apply
/// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis.
fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
let mut reader = Reader::from_reader(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut mapping = DriverMapping::default();
let mut axis: Option<ProfileAxis> = None;
let mut part = None;
let mut field = Vec::new();
let mut found = false;
loop {
match reader.read_event_into(&mut buf).ok()? {
Event::Start(start) => match start.local_name().as_ref() {
b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }),
b"Input" => part = Some(ProfilePart::Input),
b"Output" => part = Some(ProfilePart::Output),
name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(),
_ => {}
},
Event::Text(text) => {
let value = String::from_utf8_lossy(text.as_ref());
match field.as_slice() {
b"ActionID" => {
if let Some(current) = axis.as_mut() {
match part {
Some(ProfilePart::Input) => current.input = axis_index(&value),
Some(ProfilePart::Output) => current.output = axis_index(&value),
None => {}
}
}
}
b"Enabled" => {
if let Some(current) = axis.as_mut() {
current.enabled = value.trim().eq_ignore_ascii_case("true");
}
}
b"Reversed" => {
if part == Some(ProfilePart::Output)
&& let Some(current) = axis.as_mut()
{
current.reversed = value.trim().eq_ignore_ascii_case("true");
}
}
b"Scale" => {
if part == Some(ProfilePart::Output)
&& let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite())
&& let Some(current) = axis.as_mut()
{
current.scale = scale.clamp(0.0, 10.0);
}
}
b"OverallScale" => {
if let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite()) {
mapping.overall_scale = scale.clamp(0.0, 10.0);
}
}
_ => {}
}
}
Event::End(end) => match end.local_name().as_ref() {
b"Axis" => {
if let Some(current) = axis.take()
&& let (Some(input), Some(output)) = (current.input, current.output)
{
mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale };
found = true;
}
}
b"Input" | b"Output" => part = None,
b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(),
_ => {}
},
Event::Eof => break,
_ => {}
}
buf.clear();
}
found.then_some(mapping)
}
fn load_driver_mapping() -> Option<DriverMapping> {
let cfg = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg");
// The driver names the profile after the executable; one made for the pre-rename executable
// still applies until the driver writes a new one (compatibility shim).
let profile = ["solidcraft-KMJ.xml", "solvecraft-KMJ.xml"].into_iter().map(|n| cfg.join(n)).find(|p| p.is_file())?;
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
return None;
}
let xml = std::fs::read(&profile).ok()?;
parse_driver_mapping(&xml)
}
#[derive(Clone, Copy)]
struct Reading {
axes: Axes,
translation_at: Instant,
rotation_at: Instant,
}
impl Reading {
fn new(now: Instant) -> Self {
Self { axes: Axes::default(), translation_at: now, rotation_at: now }
}
fn current(self, now: Instant) -> Axes {
Axes {
translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] },
rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] },
}
}
}
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
fn is_3d_mouse_vendor(vendor_id: u16) -> bool {
matches!(vendor_id, 0x046d | 0x256f)
}
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
#[cfg(target_os = "windows")]
fn is_3d_mouse(info: &DeviceInfo) -> bool {
is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08
}
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None };
Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])])
}
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
/// Some homemade devices send all six values in report 1 instead (`Layout::Combined`): only the
/// device's report descriptor can tell the two apart, because a padded split report 1 is as long.
fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool {
match report.first().copied() {
Some(1) if layout == Layout::Combined => {
let Some(body) = report.get(1..) else { return false };
let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false };
reading.axes = Axes { translation, rotation };
reading.translation_at = now;
reading.rotation_at = now;
true
}
Some(1) => {
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.translation = translation;
reading.translation_at = now;
true
}
Some(2) => {
let Some(rotation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.rotation = rotation;
reading.rotation_at = now;
true
}
_ => false,
}
}
/// Publishes one input report from the device to the egui thread.
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
if let Ok(mut reading) = shared.lock()
&& decode_report(report, layout, &mut reading, Instant::now())
{
ctx.request_repaint();
}
}
/// The device is gone: stop reporting its last position.
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
if let Ok(mut reading) = shared.lock() {
*reading = Reading::new(Instant::now());
}
ctx.request_repaint();
}
#[cfg(target_os = "windows")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
let Ok(mut api) = HidApi::new() else {
log::warn!("3D mouse: HID initialization failed");
return;
};
loop {
if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}");
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
let layout = device_layout(&device);
let mut report = [0_u8; 64];
loop {
match device.read_timeout(&mut report, 100) {
Ok(0) => {}
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
reset(&ctx, &shared);
}
std::thread::sleep(RETRY_AFTER);
}
}
/// How the device packs its axes, from its report descriptor; the usual split layout if the
/// descriptor cannot be read.
#[cfg(target_os = "windows")]
fn device_layout(device: &hidapi::HidDevice) -> Layout {
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
match device.get_report_descriptor(&mut descriptor) {
Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(),
Err(error) => {
log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}");
Layout::Split
}
}
}
/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is
/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for
/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
#[cfg(target_os = "linux")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
use std::io::Read;
let mut denied = None;
loop {
if let Some(device) = hidraw::find() {
match std::fs::File::open(&device.path) {
Ok(mut file) => {
denied = None;
log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout);
let mut report = [0_u8; 64];
loop {
match file.read(&mut report) {
Ok(0) => break,
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
reset(&ctx, &shared);
}
// Say so once per device node, not every retry.
Err(error) if denied.as_ref() != Some(&device.path) => {
log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display());
denied = Some(device.path);
}
Err(_) => {}
}
}
std::thread::sleep(RETRY_AFTER);
}
}
pub struct SpaceMouse {
shared: Arc<Mutex<Reading>>,
last_frame: Instant,
driver_mapping: DriverMapping,
mapping_checked_at: Instant,
}
impl SpaceMouse {
pub fn start(ctx: &Context) -> Self {
let now = Instant::now();
let shared = Arc::new(Mutex::new(Reading::new(now)));
let worker_state = Arc::clone(&shared);
let worker_ctx = ctx.clone();
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}");
}
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let now = Instant::now();
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now;
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
let updated = load_driver_mapping().unwrap_or_default();
if updated != self.driver_mapping {
log::info!("3D mouse: applying updated 3DxWare axis profile");
self.driver_mapping = updated;
}
self.mapping_checked_at = now;
}
if !app.preferences.space_mouse_enabled || app.home.open {
return;
}
let axes = match self.shared.lock() {
Ok(reading) => reading.current(now),
Err(_) => return,
};
if axes == Axes::default() {
return;
}
let speed = f64::from(app.preferences.space_mouse_speed);
if !speed.is_finite() {
return;
}
app.begin_external_navigation();
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
navigate(
&mut app.cam,
self.driver_mapping.apply(axes),
seconds * speed.clamp(0.1, 5.0),
height,
app.session.active_sketch.is_some(),
app.preferences.zoom_reverse,
);
ctx.request_repaint_after(Duration::from_millis(16));
}
}
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
/// per second to pan and about two radians per second to turn, independent of frame rate.
fn normalized(value: i16) -> f64 {
let v = f64::from(value);
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
}
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
if !(seconds > 0.0) || !seconds.is_finite() {
return;
}
let [x, y, z] = motion.translation;
let [rx, ry, rz] = motion.rotation;
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
// profile above can swap the physical forward/back and up/down axes before they reach here.
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
if !sketch {
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
// navigation mode, so swapping them again here would make Roll behave like Spin.
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
cam.roll_by(rz * 2.0 * seconds);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn raw(axes: Axes) -> Motion {
DriverMapping::default().apply(axes)
}
#[test]
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
let later = now + STALE_AFTER + Duration::from_millis(1);
assert_eq!(reading.current(later), Axes::default());
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], Layout::Split, &mut reading, later));
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later));
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn combined_report_one_carries_translation_and_rotation_together() {
let now = Instant::now();
let mut reading = Reading::new(now);
// Report id 1, then X Y Z Rx Ry Rz as signed little-endian 16-bit values.
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff];
assert!(decode_report(&report, Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [1, 0, -1] });
// The same bytes on a split device are only the translation report; its padding is not rotation.
let mut split = Reading::new(now);
assert!(decode_report(&report, Layout::Split, &mut split, now));
assert_eq!(split.current(now), Axes { translation: [-100, 300, 0], rotation: [0; 3] });
}
#[test]
fn combined_layout_ignores_short_and_other_reports() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 0, 2, 0, 3, 0], Layout::Combined, &mut reading, now));
assert!(!decode_report(&[3, 1, 0, 0, 0, 0], Layout::Combined, &mut reading, now));
assert!(!decode_report(&[], Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
// Extra padding after the six values is fine.
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 0, 0], Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default();
let before = cam;
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
assert_ne!(cam.target, before.target);
assert!(cam.distance < before.distance);
assert_ne!(cam.yaw, before.yaw);
assert_ne!(cam.pitch, before.pitch);
assert_ne!(cam.roll, before.roll);
let orientation = (cam.yaw, cam.pitch, cam.roll);
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
assert!(cam.is_valid());
}
#[test]
fn individual_axes_have_physical_spacemouse_meaning() {
let before = Camera::default();
let mut slide = before;
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(slide.target, before.target);
assert_eq!(slide.distance, before.distance);
let mut push = before;
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_eq!(push.target, before.target);
assert!(push.distance < before.distance);
let mut lift = before;
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(lift.target, before.target);
assert_eq!(lift.distance, before.distance);
let mut tilt = before;
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
assert_ne!(tilt.pitch, before.pitch);
let mut roll = before;
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
assert_ne!(roll.yaw, before.yaw);
let mut twist = before;
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
assert_ne!(twist.roll, before.roll);
}
#[test]
fn reverse_zoom_setting_applies_to_spacemouse() {
let before = Camera::default();
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
let mut normal = before;
navigate(&mut normal, motion, 0.02, 600.0, false, false);
let mut reversed = before;
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
assert!(normal.distance < before.distance);
assert!(reversed.distance > before.distance);
}
#[test]
fn applies_3dxware_axis_swaps_and_reversals() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>true</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] });
assert_eq!(motion.translation, [1.0, -1.0, 0.0]);
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
}
#[test]
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let before = Camera::default();
let mut physical_roll = before;
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
assert_ne!(physical_roll.roll, before.roll);
let mut physical_spin = before;
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_ne!(physical_spin.yaw, before.yaw);
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
}
}
@@ -0,0 +1,253 @@
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
//! it simply yields less information.
/// Generic Desktop usage page, and the usages in it that matter here.
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
const AXIS_COUNT: u32 = 6;
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
const MOTION_REPORT_ID: u8 = 1;
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
const MAX_DESCRIPTOR_BYTES: usize = 4096;
/// Bound on the usages one main item can declare through a usage range.
const MAX_USAGES_PER_ITEM: u32 = 256;
/// How a device spreads its six motion axes over input reports.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Layout {
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
#[default]
Split,
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
Combined,
}
#[derive(Clone, Copy)]
struct Item {
/// 0 main, 1 global, 2 local (3 is reserved).
kind: u8,
tag: u8,
value: u32,
}
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
let mut pos = 0_usize;
std::iter::from_fn(move || {
loop {
let prefix = *descriptor.get(pos)?;
pos += 1;
if prefix == 0xFE {
// Long item: a data length byte, a tag byte, then the data.
let length = usize::from(*descriptor.get(pos)?);
pos = pos.checked_add(2 + length)?;
continue;
}
let size = match prefix & 0b11 {
0 => 0,
1 => 1,
2 => 2,
_ => 4,
};
let data = descriptor.get(pos..pos + size)?;
pos += size;
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
}
})
}
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Descriptor {
multi_axis_controller: bool,
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
motion_report_axes: u8,
}
impl Descriptor {
pub fn parse(descriptor: &[u8]) -> Self {
let mut summary = Self::default();
let mut page = 0_u32;
let mut report_id = 0_u8;
let mut depth = 0_u32;
// Local items belong to the next main item only.
let mut usages: Vec<u32> = Vec::new();
let mut usage_min = None;
for item in items(descriptor) {
match (item.kind, item.tag) {
// Global: usage page, report id.
(1, 0) => page = item.value,
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
// Local: usage, usage minimum, usage maximum.
(2, 0) => usages.push(Self::full_usage(page, item)),
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
(2, 2) => {
if let Some(min) = usage_min.take() {
let max = Self::full_usage(page, item);
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
}
}
// Main: input, collection, end collection.
(0, 8) => {
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
summary.note_axes(&usages);
}
}
(0, 0xA) => {
// An application collection (1) at the top level names the device.
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
summary.multi_axis_controller = true;
}
depth += 1;
}
(0, 0xC) => depth = depth.saturating_sub(1),
_ => {}
}
if item.kind == 0 {
usages.clear();
usage_min = None;
}
}
summary
}
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
fn full_usage(page: u32, item: Item) -> u32 {
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
}
fn note_axes(&mut self, usages: &[u32]) {
for usage in usages {
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
if axis < AXIS_COUNT {
self.motion_report_axes |= 1 << axis;
}
}
}
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn is_multi_axis_controller(&self) -> bool {
self.multi_axis_controller
}
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
pub fn layout(&self) -> Layout {
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
const SPLIT: &[u8] = &[
0x05, 0x01, // usage page: generic desktop
0x09, 0x08, // usage: multi-axis controller
0xA1, 0x01, // collection: application
0x85, 0x01, // report id 1
0x16, 0xA2, 0xFE, // logical minimum -350
0x26, 0x5E, 0x01, // logical maximum 350
0x75, 0x10, // report size 16
0x95, 0x03, // report count 3
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
0x81, 0x06, // input: data, variable, relative
0x85, 0x02, // report id 2
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
0x81, 0x06, // input
0xC0, // end collection
];
/// Report 1 = X Y Z Rx Ry Rz.
const COMBINED: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
0x81, 0x06, 0xC0,
];
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
const COMBINED_RANGE: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
0x81, 0x06, 0xC0,
];
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
const KEYBOARD: &[u8] =
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
const MOUSE: &[u8] = &[
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
0x06, 0xC0, 0xC0,
];
#[test]
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
let descriptor = Descriptor::parse(SPLIT);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0b000_111);
assert_eq!(descriptor.layout(), Layout::Split);
}
#[test]
fn six_axes_in_report_one_are_a_combined_layout() {
for bytes in [COMBINED, COMBINED_RANGE] {
let descriptor = Descriptor::parse(bytes);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Combined);
}
}
#[test]
fn other_interfaces_are_not_3d_mice() {
for bytes in [KEYBOARD, MOUSE, &[][..]] {
let descriptor = Descriptor::parse(bytes);
assert!(!descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Split);
}
}
#[test]
fn four_byte_usages_carry_their_own_page() {
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0);
}
#[test]
fn truncated_and_hostile_descriptors_never_panic() {
for end in 0..=COMBINED.len() {
let _ = Descriptor::parse(&COMBINED[..end]);
}
// Long item with a length running past the end, huge usage ranges, deep nesting.
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
let _ = Descriptor::parse(&[0xC0; 64]);
let _ = Descriptor::parse(&[0xA1; 64]);
let mut state = 0x2545_F491_4F6C_DD1D_u64;
for _ in 0..200 {
let bytes: Vec<u8> = (0..64)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state.to_le_bytes()[0]
})
.collect();
let _ = Descriptor::parse(&bytes);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
//! plain file I/O, so no udev or hidapi library is needed.
use std::path::{Path, PathBuf};
use super::hid_descriptor::{Descriptor, Layout};
const SYSFS: &str = "/sys/class/hidraw";
const DEV: &str = "/dev";
pub struct Device {
pub path: PathBuf,
pub layout: Layout,
}
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
fn vendor_id(uevent: &str) -> Option<u16> {
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
let vendor = id.split(':').nth(1)?;
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
}
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
pub fn find() -> Option<Device> {
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
names.sort();
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
}
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
return None;
}
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_the_vendor_from_the_hid_id() {
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
assert_eq!(vendor_id(uevent), Some(0x256f));
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
}
#[test]
fn malformed_ids_are_rejected() {
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
}
}
}
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "solvecraft-cli"
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[[bin]]
name = "solvecraft-cli"
path = "src/main.rs"
[dependencies]
solvecraft-engine = { workspace = true }
solvecraft-mcp = { workspace = true }
serde_json = { workspace = true }
-277
View File
@@ -1,277 +0,0 @@
//! Replay oracle recipes and compare with Fusion's measurements; import oracle STEP files and
//! compare them with the same measurements.
use serde_json::{Value, json};
use crate::{read_json, recipe, seams};
/// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
pub const REL_TOL: f64 = 1e-3;
/// Cases compared loosely (relative tolerance on volume and area, topology not compared), and why.
const APPROXIMATE: [(&str, f64, &str); 1] = [(
"49-helical-sweep",
5e-3,
"Fusion sweeps along a spline fitted through helix points; SolidCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all",
)];
fn check_case(dir: &str) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let rp = format!("{dir}/recipe.json");
let mp = format!("{dir}/measure.json");
let (recipe, want) = match (read_json(&rp), read_json(&mp)) {
(Ok(r), Ok(m)) => (r, m),
(Err(e), _) | (_, Err(e)) => return json!({"case": name, "pass": false, "error": e}),
};
let script = match recipe::to_script(&recipe) {
Ok(s) => s,
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
};
let mut s = solvecraft_engine::Session::default();
if let Err(e) = s.run_script(&script) {
return json!({"case": name, "pass": false, "error": e.to_string()});
}
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let mut checks = Vec::new();
let mut pass = true;
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
let ok = match (g, w) {
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
_ => false,
};
pass &= ok;
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
};
// Cases where Fusion's part is itself an approximation: compare size loosely, not topology.
let approx = APPROXIMATE.iter().find(|(c, _, _)| *c == name);
let tol = approx.map(|(_, t, _)| *t).unwrap_or(REL_TOL);
let within = |k: &str, rel: f64| match (got["total"][k].as_f64(), want["total"][k].as_f64()) {
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
_ => false,
};
let size_ok =
got["body_count"] == want["body_count"] && within("volume_mm3", tol) && (FUSION_AREA_OFF.contains(&name.as_str()) || within("area_mm2", tol));
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), tol);
let area_off = FUSION_AREA_OFF.contains(&name.as_str());
if !area_off {
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), tol);
}
let mut seam_note = None;
if approx.is_none() {
let exact = ["faces", "edges", "vertices"].iter().all(|k| got["total"][k].as_f64().is_some() && got["total"][k] == want["total"][k]);
let fusion_seams = want["bodies"]
.as_array()
.and_then(|bs| bs.iter().map(seams::normalised).try_fold((0, 0, 0), |a, n| n.map(|n| (a.0 + n.0, a.1 + n.1, a.2 + n.2))));
match fusion_seams {
// Size matches and only the topology differs: compare with Fusion's seam splits merged.
Some((f, e, v)) if size_ok && !exact => {
let raw: Vec<String> = ["faces", "edges", "vertices"]
.iter()
.map(|k| {
let ours: i64 = got["bodies"].as_array().into_iter().flatten().filter_map(|b| b["kernel"][k].as_i64()).sum();
format!("{k} {ours}/{}", want["total"][k])
})
.collect();
seam_note = Some(format!(
"topology compared seam-normalised: Fusion's {}/{}/{} faces/edges/vertices become {f}/{e}/{v} with closed-surface patches counted once and seam edges and vertices dropped (raw SolidCraft/Fusion: {})",
want["total"]["faces"],
want["total"]["edges"],
want["total"]["vertices"],
raw.join(", ")
));
num("faces (seam-normalised)", got["total"]["faces"].as_f64(), Some(f as f64), 0.0);
num("edges (seam-normalised)", got["total"]["edges"].as_f64(), Some(e as f64), 0.0);
num("vertices (seam-normalised)", got["total"]["vertices"].as_f64(), Some(v as f64), 0.0);
}
_ => {
num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0);
num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0);
num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0);
}
}
}
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
// same volume.
let (ok, note) = step_export_check(&s, got["total"]["volume_mm3"].as_f64().unwrap_or(0.0));
pass &= ok;
checks.push(json!({"check": "step_export", "got": note, "want": "valid, re-imports cleanly", "ok": ok}));
let mut out = json!({"case": name, "pass": pass, "checks": checks});
if let Some((_, _, why)) = approx {
out["note"] = json!(why);
}
if let Some(n) = seam_note {
out["note"] = json!(n);
}
if area_off {
out["note"] = json!(
"area not compared: Fusion reports this tight sweep's horn torus face at three times its area (π²·R·r quarter-turn value; 740 mm² against 246.7 by Pappus); volume and topology are compared"
);
}
let effective: Vec<String> = recipe["features"]
.as_array()
.into_iter()
.flatten()
.filter(|f| f.get("fusion_effective_extent").is_some())
.map(|f| f["name"].as_str().unwrap_or("?").to_string())
.collect();
if !effective.is_empty() {
out["note"] = json!(format!(
"known Fusion difference: {} replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolidCraft's through-all goes truly through)",
effective.join(", ")
));
}
out
}
/// Export the session's bodies as STEP, validate the file and import it again.
fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, String) {
let st = s.model.state();
let bytes = match solvecraft_engine::io::export(&st, &[], solvecraft_engine::io::Format::Step, "oracle") {
Ok(b) => b,
Err(e) => return (false, format!("export: {e}")),
};
let text = String::from_utf8_lossy(&bytes);
if let Err(e) = solvecraft_engine::kernel::step_validate(&text) {
return (false, format!("invalid: {e}"));
}
match solvecraft_engine::kernel::step_orientation_errors(&text) {
Ok(errs) if !errs.is_empty() => return (false, format!("faces inside out: {}", errs.join("; "))),
Err(e) => return (false, format!("orientation check: {e}")),
Ok(_) => {}
}
match solvecraft_engine::kernel::step_import(&text) {
Ok(imp) if !imp.warnings.is_empty() => (false, format!("warnings: {}", imp.warnings.join("; "))),
Ok(imp) if imp.bodies.len() != st.bodies.len() => (false, format!("{} bodies back of {}", imp.bodies.len(), st.bodies.len())),
Ok(imp) => {
let m: Vec<_> = imp.bodies.iter().filter_map(|b| solvecraft_engine::kernel::measure(&b.body).ok()).collect();
let m0: Vec<_> = st.bodies.iter().filter_map(|b| solvecraft_engine::kernel::measure(&b.body).ok()).collect();
let v: f64 = m.iter().map(|x| x.volume).sum();
let (a, a0): (f64, f64) = (m.iter().map(|x| x.area).sum(), m0.iter().map(|x| x.area).sum());
let faces: usize = imp.bodies.iter().map(|b| b.file_faces).sum();
let faces0: usize = m0.iter().map(|x| x.merged.faces).sum();
let close = |x: f64, y: f64| (x - y).abs() <= REL_TOL * y.abs().max(1.0);
let faces_raw: usize = st.bodies.iter().map(|b| b.body.face_count()).sum();
// (Fewer is fine: pieces of one plane, cylinder, cone, sphere or torus are written as one face.)
let ok = close(v, volume) && close(a, a0) && faces <= faces_raw;
(ok, format!("re-imported volume {v:.3} (was {volume:.3}), area {a:.3} (was {a0:.3}), faces {faces} (was {faces0})"))
}
Err(e) => (false, format!("import: {e}")),
}
}
/// Cases whose Fusion area is not the geometric one: Fusion reports the horn torus face of a
/// tight sweep (tube touching its axis) at three times π²·R·r, its quarter-turn area; the
/// volumes agree. Their area is not compared.
const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"];
/// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements:
/// body count, volume and area (relative 1e-3) and the faces read from the file.
/// Open the case's `part.step` (or `part.igs` with `iges`) and compare with measure.json.
fn check_step_case(dir: &str, iges: bool) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let sp = format!("{dir}/{}", if iges { "part.igs" } else { "part.step" });
let want = match read_json(&format!("{dir}/measure.json")) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e}),
};
let t0 = std::time::Instant::now();
let mut s = solvecraft_engine::Session::default();
let opened = match s.execute("doc.open", &json!({"path": sp})) {
Ok(v) => v,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let ms = t0.elapsed().as_secs_f64() * 1000.0;
// Faces read from the file (the kernel may split a face that wraps around a closed surface).
let file_faces = std::fs::read(&sp)
.ok()
.and_then(|b| {
let text = String::from_utf8_lossy(&b);
if iges {
solvecraft_engine::kernel::iges_import(&text).ok().map(std::sync::Arc::new)
} else {
solvecraft_engine::kernel::step_import_shared(&text).ok()
}
})
.map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::<usize>() as f64);
let mut checks = Vec::new();
let mut pass = true;
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
let ok = match (g, w) {
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
_ => false,
};
pass &= ok;
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
};
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), REL_TOL);
if !FUSION_AREA_OFF.contains(&name.as_str()) {
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), REL_TOL);
}
num("faces", file_faces, want["total"]["faces"].as_f64(), 0.0);
json!({
"case": name, "pass": pass, "checks": checks, "ms": ms,
"bodies": opened["bodies"], "warnings": opened["warnings"],
"info": {"kernel_faces_merged": got["total"]["faces"], "edges": got["total"]["edges"], "want_edges": want["total"]["edges"], "vertices": got["total"]["vertices"], "want_vertices": want["total"]["vertices"]},
})
}
pub fn step_corpus(args: &[String]) -> Result<(), String> {
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if dirs.is_empty() {
return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into());
}
let iges = args.iter().any(|a| a == "--iges");
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d, iges)).collect();
report(args, &results);
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count();
if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) }
}
fn report(args: &[String], results: &[Value]) {
if args.iter().any(|a| a == "--json") {
println!("{}", serde_json::to_string_pretty(&Value::Array(results.to_vec())).unwrap_or_default());
return;
}
for r in results {
let status = if r["pass"].as_bool().unwrap_or(false) {
"PASS"
} else if r["skipped"].as_bool().unwrap_or(false) {
"SKIP"
} else {
"FAIL"
};
let detail: Vec<String> = r["checks"]
.as_array()
.into_iter()
.flatten()
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
.collect();
let err = r["error"].as_str().map(|e| format!(" ({e})")).unwrap_or_default();
println!(
"{status} {}{err}{}",
r["case"].as_str().unwrap_or(""),
if detail.is_empty() { String::new() } else { format!(": {}", detail.join("; ")) }
);
}
}
pub fn run(args: &[String]) -> Result<(), String> {
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if dirs.is_empty() {
return Err("oracle: give one or more case directories".into());
}
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
report(args, &results);
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false) && !r["skipped"].as_bool().unwrap_or(false)).count();
if failed > 0 { Err(format!("{failed} oracle case(s) failed")) } else { Ok(()) }
}
-321
View File
@@ -1,321 +0,0 @@
//! Seam-normalised topology of Fusion's measurements.
//!
//! Fusion splits a closed surface along seams: a full cylinder can be two half faces, and a
//! closed curve crossing a seam becomes two edges with a vertex on the seam. SolveCraft's merged
//! counts already count each closed surface patch once and ignore seam edges and vertices, so
//! the oracle normalises Fusion's side the same way from its face, edge and vertex lists:
//! - faces cut from one periodic surface (cylinder, cone, sphere, torus) and separated only by
//! seam edges count once;
//! - a seam edge lies on that surface and on no other face's surface: it is dropped;
//! - a vertex left with exactly two edges is no corner (a solid's corners have three or more):
//! the two edges join, and a curve that closes on itself keeps one vertex.
use std::collections::HashMap;
use serde_json::Value;
type V3 = [f64; 3];
const TOL: f64 = 1e-4;
fn v3(v: &Value) -> Option<V3> {
let a = v.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
}
fn sub(a: V3, b: V3) -> V3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: V3, b: V3) -> V3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn mul(a: V3, s: f64) -> V3 {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: V3, b: V3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: V3, b: V3) -> V3 {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn len(a: V3) -> f64 {
dot(a, a).sqrt()
}
fn unit(a: V3) -> Option<V3> {
let l = len(a);
(l > 1e-12).then(|| mul(a, 1.0 / l))
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Surf {
Plane { p: V3, n: V3 },
Cylinder { o: V3, a: V3, r: f64 },
Cone { o: V3, a: V3, r: f64, tan: f64 },
Sphere { c: V3, r: f64 },
Torus { c: V3, a: V3, big: f64, small: f64 },
Other,
}
impl Surf {
fn of(f: &Value) -> Surf {
let g = |k: &str| v3(&f[k]);
let n = |k: &str| f[k].as_f64();
let s = match f["type"].as_str().unwrap_or_default() {
"plane" => g("centroid").zip(g("plane_normal_outward").and_then(unit)).map(|(p, n)| Surf::Plane { p, n }),
"cylinder" => (|| Some(Surf::Cylinder { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")? }))(),
"cone" => {
(|| Some(Surf::Cone { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")?, tan: n("half_angle_deg")?.to_radians().tan() }))(
)
}
"sphere" => (|| Some(Surf::Sphere { c: g("center")?, r: n("radius_mm")? }))(),
"torus" => (|| Some(Surf::Torus { c: g("center")?, a: unit(g("axis")?)?, big: n("major_radius_mm")?, small: n("minor_radius_mm")? }))(),
_ => None,
};
s.unwrap_or(Surf::Other)
}
fn periodic(&self) -> bool {
matches!(self, Surf::Cylinder { .. } | Surf::Cone { .. } | Surf::Sphere { .. } | Surf::Torus { .. })
}
/// Whether the point lies on the surface; `None` when that can't be told (free-form faces).
fn contains(&self, p: V3) -> Option<bool> {
let radial = |o: V3, a: V3| {
let d = sub(p, o);
let t = dot(d, a);
(t, len(sub(d, mul(a, t))))
};
Some(match *self {
Surf::Plane { p: q, n } => dot(sub(p, q), n).abs() < TOL,
Surf::Cylinder { o, a, r } => (radial(o, a).1 - r).abs() < TOL,
Surf::Cone { o, a, r, tan } => {
let (t, rho) = radial(o, a);
(rho - (r + t * tan)).abs() < TOL || (rho - (r - t * tan)).abs() < TOL
}
Surf::Sphere { c, r } => (len(sub(p, c)) - r).abs() < TOL,
Surf::Torus { c, a, big, small } => {
let (t, rho) = radial(c, a);
((rho - big).hypot(t) - small).abs() < TOL
}
Surf::Other => return None,
})
}
fn same(&self, o: &Surf) -> bool {
let close = |a: f64, b: f64| (a - b).abs() < TOL;
let pt = |a: V3, b: V3| len(sub(a, b)) < TOL;
let par = |a: V3, b: V3| len(cross(a, b)) < TOL;
// Same axis line: parallel, and either origin on the other's axis.
let axis = |o1: V3, a1: V3, o2: V3, a2: V3| par(a1, a2) && len(cross(sub(o2, o1), a1)) < TOL;
match (*self, *o) {
(Surf::Cylinder { o: o1, a: a1, r: r1 }, Surf::Cylinder { o: o2, a: a2, r: r2 }) => axis(o1, a1, o2, a2) && close(r1, r2),
(Surf::Cone { o: o1, a: a1, r: r1, tan: t1 }, Surf::Cone { .. }) => {
// Same cone: the other's frame circle lies on this one, same opening angle.
let Surf::Cone { o: o2, a: a2, r: r2, tan: t2 } = *o else { return false };
axis(o1, a1, o2, a2) && close(t1, t2) && {
let t = dot(sub(o2, o1), a1) * dot(a1, a2).signum();
close(r2, r1 + t * t1) || close(r2, r1 - t * t1)
}
}
(Surf::Sphere { c: c1, r: r1 }, Surf::Sphere { c: c2, r: r2 }) => pt(c1, c2) && close(r1, r2),
(Surf::Torus { c: c1, a: a1, big: b1, small: s1 }, Surf::Torus { c: c2, a: a2, big: b2, small: s2 }) => {
pt(c1, c2) && par(a1, a2) && close(b1, b2) && close(s1, s2)
}
_ => false,
}
}
}
/// Points along an edge from Fusion's edge list.
fn edge_points(e: &Value) -> Vec<V3> {
let (s, t) = (v3(&e["start"]), v3(&e["end"]));
let mut pts: Vec<V3> = s.into_iter().chain(t).collect();
let (c, r, a) = (v3(&e["center"]), e["radius_mm"].as_f64(), v3(&e["axis"]).and_then(unit));
match (e["type"].as_str().unwrap_or_default(), s, t) {
("line", Some(s), Some(t)) => pts.push(mul(add(s, t), 0.5)),
("arc", Some(s), Some(t)) => {
if let (Some(c), Some(r)) = (c, r)
&& let Some(m) = unit(add(sub(s, c), sub(t, c)))
{
let long = e["length_mm"].as_f64().is_some_and(|l| l > std::f64::consts::PI * r);
pts.push(add(c, mul(m, if long { -r } else { r })));
}
}
("circle", Some(s), _) => {
if let (Some(c), Some(a)) = (c, a) {
let d = sub(s, c);
for th in [2.0 * std::f64::consts::FRAC_PI_3, 4.0 * std::f64::consts::FRAC_PI_3] {
pts.push(add(c, add(mul(d, th.cos()), mul(cross(a, d), th.sin()))));
}
}
}
_ => {}
}
pts
}
fn key(p: V3) -> [i64; 3] {
p.map(|x| (x * 1e6).round() as i64)
}
fn find(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
i = q;
}
i
}
/// Faces, edges and vertices of one Fusion body after merging seam splits; `None` when the
/// lists are missing.
pub fn normalised(body: &Value) -> Option<(usize, usize, usize)> {
let faces = body["face_list"].as_array()?;
let edges = body["edge_list"].as_array()?;
let surfs: Vec<Surf> = faces.iter().map(Surf::of).collect();
// Group faces cut from one periodic surface.
let mut group: Vec<usize> = (0..surfs.len()).collect();
for i in 0..surfs.len() {
for j in 0..i {
if surfs[i].periodic() && surfs[i].same(&surfs[j]) {
group[i] = group[j];
break;
}
}
}
let size = |g: usize| group.iter().filter(|x| **x == g).count();
// Seam edges: on a split group's surface and on no other face's surface.
let mut seams_in: HashMap<usize, usize> = HashMap::new();
let mut seam = vec![false; edges.len()];
for (ei, e) in edges.iter().enumerate() {
let pts = edge_points(e);
if pts.is_empty() {
continue;
}
let on = |s: &Surf| pts.iter().map(|p| s.contains(*p)).try_fold(true, |acc, x| Some(acc && x?));
let carriers: Vec<Option<bool>> = surfs.iter().map(on).collect();
if carriers.contains(&None) {
continue;
}
let groups: Vec<usize> = (0..surfs.len()).filter(|f| carriers[*f] == Some(true)).map(|f| group[f]).collect();
if let Some(&g) = groups.first()
&& groups.iter().all(|x| *x == g)
&& surfs[g].periodic()
&& size(g) > 1
{
seam[ei] = true;
*seams_in.entry(g).or_insert(0) += 1;
}
}
let mut face_count = 0;
for g in 0..surfs.len() {
let k = size(g);
if k > 0 {
face_count += k.saturating_sub(seams_in.get(&g).copied().unwrap_or(0)).max(1);
}
}
// Vertex degrees over the remaining edges; a closed edge counts once at its vertex.
let mut ends: Vec<(usize, Option<[i64; 3]>, Option<[i64; 3]>)> = Vec::new();
for (ei, e) in edges.iter().enumerate() {
if seam[ei] {
continue;
}
let (s, t) = (v3(&e["start"]).map(key), v3(&e["end"]).map(key));
let t = if t == s { None } else { t };
ends.push((ends.len(), s, t));
}
// A seam between two closed curves (a closed face's seam whose surface we can't evaluate):
// an open edge whose ends each meet only a closed edge. At a real vertex three edges meet.
let closed_at = |v: &[i64; 3], ends: &[(usize, Option<[i64; 3]>, Option<[i64; 3]>)]| {
let closed = ends.iter().filter(|(_, s, t)| t.is_none() && s.as_ref() == Some(v)).count();
let open = ends.iter().filter(|(_, s, t)| t.is_some() && (s.as_ref() == Some(v) || t.as_ref() == Some(v))).count();
closed == 1 && open == 1
};
let loop_seams: Vec<usize> =
ends.iter().filter(|(_, s, t)| matches!((s, t), (Some(s), Some(t)) if closed_at(s, &ends) && closed_at(t, &ends))).map(|e| e.0).collect();
ends.retain(|e| !loop_seams.contains(&e.0));
for (k, e) in ends.iter_mut().enumerate() {
e.0 = k;
}
let mut deg: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
for (i, s, t) in &ends {
for v in [s, t].into_iter().flatten() {
deg.entry(*v).or_default().push(*i);
}
}
let mut ep: Vec<usize> = (0..ends.len()).collect();
let mut merged: Vec<[i64; 3]> = Vec::new();
for (v, es) in &deg {
// Two open edges meeting: one curve split at a seam.
if let [i, j] = es[..]
&& i != j
&& ends.get(i).is_some_and(|e| e.2.is_some())
&& ends.get(j).is_some_and(|e| e.2.is_some())
{
let (ri, rj) = (find(&mut ep, i), find(&mut ep, j));
if let Some(x) = ep.get_mut(ri) {
*x = rj;
}
merged.push(*v);
}
}
let mut real: HashMap<usize, bool> = HashMap::new();
for (i, s, t) in &ends {
let r = find(&mut ep, *i);
let has_real = [s, t].into_iter().flatten().any(|v| !merged.contains(v)) || (s.is_none() && t.is_none());
*real.entry(r).or_insert(false) |= has_real;
}
let closed = real.values().filter(|r| !**r).count();
let vertices = deg.keys().filter(|v| !merged.contains(v)).count() + closed;
Some((face_count, real.len(), vertices))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A cylinder (r 5, height 10) split by Fusion into two halves along two seams.
#[test]
fn split_cylinder_counts_once() {
let cyl = json!({"type": "cylinder", "axis_origin": [0, 0, 0], "axis": [0, 0, 1], "radius_mm": 5.0});
let plane = |z: f64, n: f64| json!({"type": "plane", "centroid": [0, 0, z], "plane_normal_outward": [0, 0, n]});
let arc = |z: f64, s: [f64; 3], t: [f64; 3]| json!({"type": "arc", "start": s, "end": t, "center": [0, 0, z], "radius_mm": 5.0, "axis": [0, 0, 1], "length_mm": 15.707963});
let line = |x: f64| json!({"type": "line", "start": [x, 0, 0], "end": [x, 0, 10]});
let body = json!({
"face_list": [cyl.clone(), cyl, plane(0.0, -1.0), plane(10.0, 1.0)],
"edge_list": [line(5.0), line(-5.0), arc(0.0, [5.0, 0.0, 0.0], [-5.0, 0.0, 0.0]), arc(0.0, [-5.0, 0.0, 0.0], [5.0, 0.0, 0.0]), arc(10.0, [5.0, 0.0, 10.0], [-5.0, 0.0, 10.0]), arc(10.0, [-5.0, 0.0, 10.0], [5.0, 0.0, 10.0])],
});
assert_eq!(normalised(&body), Some((3, 2, 2)));
}
/// Oracle 35: a fillet (free-form face, surface unknown) between two cylinders is bounded by
/// two closed contact curves joined by its seam arc. The arc goes; the curves and circles stay.
#[test]
fn seam_between_closed_curves_on_an_unknown_surface() {
let circle =
|s: [f64; 3], c: [f64; 3], a: [f64; 3]| json!({"type": "circle", "start": s, "end": s, "center": c, "radius_mm": 1.0, "axis": a});
let lp = |p: [f64; 3]| json!({"type": "nurbs", "start": p, "end": p});
let (p, q) = ([0.0, -10.833333333, 10.374916332], [0.0, -10.0, 12.449899598]);
let body = json!({
"face_list": [{"type": "nurbs"}],
"edge_list": [
{"type": "arc", "start": p, "end": q, "center": [0.0, -13.0, 12.449899598], "radius_mm": 3.0, "axis": [1, 0, 0]},
circle([-40.0, 0.0, 15.0], [-40.0, 0.0, 0.0], [-1.0, 0.0, 0.0]),
circle([-10.0, 0.0, 35.0], [0.0, 0.0, 35.0], [0.0, 0.0, 1.0]),
circle([40.0, 0.0, 15.0], [40.0, 0.0, 0.0], [1.0, 0.0, 0.0]),
lp(p),
lp(q),
],
});
assert_eq!(normalised(&body), Some((1, 5, 5)));
}
/// A closed curve split into two edges at a seam vertex joins again (Fusion part 61's keyway).
#[test]
fn degree_two_vertices_join() {
let n = |s: [f64; 3], t: [f64; 3]| json!({"type": "nurbs", "start": s, "end": t});
let body = json!({"face_list": [], "edge_list": [n([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), n([1.0, 0.0, 0.0], [0.0, 0.0, 0.0])]});
assert_eq!(normalised(&body), Some((0, 1, 1)));
}
}
@@ -1,20 +0,0 @@
# Design file corpus
Designs saved by older SolveCraft builds. `tests/design_corpus.rs` opens each one with the
current build and checks that it evaluates as it did when saved (the `.expected.json` next to
it is `solvecraft-cli eval` of that file by the build that saved it: bodies, volumes, areas,
world bounding boxes), that it is upgraded to the current file format, and that damaged and
hostile variants of it never panic.
| File | Saved by | Era |
|---|---|---|
| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut |
| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit |
| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground |
| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter |
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
point is that it was written by the old one. Add new files for new eras instead.
-251
View File
@@ -1,251 +0,0 @@
//! Designs saved by older SolveCraft builds (tests/corpus, see its README) open and evaluate as
//! they did when they were saved; damaged and hostile design files never panic.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solvecraft_engine::Session;
fn corpus() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
let mut v: Vec<PathBuf> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solvecraft")).collect();
v.sort();
assert!(v.len() >= 7, "corpus files in {}", dir.display());
v
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
/// Differences that are deliberate behaviour changes made before the file format had versions
/// (no migration can tell those files apart): (file, check, why).
const KNOWN: [(&str, &str, &str); 2] = [
(
"m0-revolve-primitives",
"Body1 bbox",
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
),
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
];
fn known(file: &str, check: &str) -> bool {
KNOWN.iter().any(|(f, c, _)| *f == file && *c == check)
}
/// Volumes and areas then came from coarser tessellation (and curved booleans and fillets have
/// become exact since): the same part within this relative tolerance (the oracle's).
const REL: f64 = 1e-3;
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * b.abs().max(1.0)
}
#[test]
fn old_designs_open_and_evaluate_as_saved() {
let mut problems = Vec::new();
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap();
let mut s = open(&p);
for r in &s.model.results {
if let Some(e) = &r.error {
problems.push(format!("{name}: feature {}: {e}", r.id));
}
}
assert_eq!(s.doc.format, solvecraft_engine::doc::format::format_string(solvecraft_engine::doc::format::FORMAT), "{name}: upgraded");
let got = s.execute("inspect.measure", &json!({})).unwrap();
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
}
for w in want["bodies"].as_array().into_iter().flatten() {
let bname = w["name"].as_str().unwrap_or_default();
let Some(g) = got["bodies"].as_array().into_iter().flatten().find(|g| g["name"] == w["name"]) else {
problems.push(format!("{name}: body {bname} missing"));
continue;
};
for k in ["volume_mm3", "area_mm2"] {
let (a, b) = (g[k].as_f64().unwrap_or(f64::NAN), w[k].as_f64().unwrap_or(f64::NAN));
if !close(a, b, REL) {
problems.push(format!("{name}: {bname} {k} {a} (saved {b})"));
}
}
if known(&name, &format!("{bname} bbox")) {
continue;
}
for (end, i) in [("min", 0), ("min", 1), ("min", 2), ("max", 0), ("max", 1), ("max", 2)] {
let (a, b) = (g["bbox"][end][i].as_f64().unwrap_or(f64::NAN), w["bbox"][end][i].as_f64().unwrap_or(f64::NAN));
if (a - b).abs() > 1e-3 * b.abs().max(1.0) {
problems.push(format!("{name}: {bname} bbox {end}[{i}] {a} (saved {b})"));
}
}
}
// Saved again, it reads back the same.
let again = solvecraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(again, *s.doc, "{name}: round trip");
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
const HOSTILE: [&str; 14] =
["0", "-1", "1e308", "-1e308", "1e9", "4294967297", "\"\"", "\"1/0\"", "\"((((1\"", "null", "true", "[]", "{}", "\"nope\""];
/// Every JSON value in a document, as a path of keys and indices.
fn paths(v: &Value, at: &mut Vec<Value>, out: &mut Vec<Vec<Value>>) {
out.push(at.clone());
match v {
Value::Object(m) => {
for (k, x) in m {
at.push(json!(k));
paths(x, at, out);
at.pop();
}
}
Value::Array(a) => {
for (i, x) in a.iter().enumerate() {
at.push(json!(i));
paths(x, at, out);
at.pop();
}
}
_ => {}
}
}
fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
let mut cur = v;
for k in path {
cur = match k {
Value::String(s) => cur.get_mut(s.as_str())?,
Value::Number(n) => cur.get_mut(n.as_u64()? as usize)?,
_ => return None,
};
}
Some(cur)
}
/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure.
fn try_bytes(bytes: &[u8]) {
if let Ok(doc) = solvecraft_engine::io::read_design(bytes) {
let mut s = Session::new(doc);
let _ = s.execute("inspect.measure", &json!({}));
let _ = s.execute("document.inspect", &json!({}));
}
}
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
/// cycle, which resolve through the model and back into the document.
fn generated() -> Vec<(String, Vec<u8>)> {
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
let plane = |id: u64, name: &str, side: Value| {
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
};
let design = |name: &str, features: Value| {
json!({"format": "solvecraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
};
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
// by the point count and abort on the allocation.
let point = json!({"id": "p1", "pos": [0.0, 0.0], "fixed": false});
let oversized = json!([{"id": 1, "name": "Sketch1", "suppressed": false, "type": "sketch",
"plane": {"type": "origin", "name": "XY"},
"sketch": {"points": vec![point; 200_000], "curves": [], "constraints": []}}]);
vec![
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
("sketch with 200,000 points".into(), design("bigsketch", oversized)),
]
}
#[test]
fn damaged_and_hostile_design_files_never_panic() {
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
// The cases come from one seeded sequence; trying them is independent, so threads share it.
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
for p in corpus() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let bytes = std::fs::read(&p).unwrap();
let doc: Value = serde_json::from_slice(&bytes).unwrap();
let mut all = Vec::new();
paths(&doc, &mut Vec::new(), &mut all);
let mut cases: Vec<(String, Vec<u8>)> = Vec::new();
// Truncated and bit-flipped files.
for _ in 0..8 {
let cut = rng.below(bytes.len());
cases.push((format!("truncated at {cut}"), bytes[..cut].to_vec()));
let mut b = bytes.clone();
let i = rng.below(b.len());
b[i] ^= 1 << rng.below(8);
cases.push((format!("bit flip at {i}"), b));
}
// Values replaced by hostile ones, and keys removed.
for _ in 0..25 {
let path = &all[rng.below(all.len())];
let mut v = doc.clone();
let h = HOSTILE[rng.below(HOSTILE.len())];
if let Some(slot) = at_mut(&mut v, path) {
*slot = serde_json::from_str(h).unwrap();
}
cases.push((format!("{path:?} = {h}"), v.to_string().into_bytes()));
let mut v = doc.clone();
if let Some((last, parent)) = path.split_last()
&& let (Some(Value::Object(m)), Value::String(k)) = (at_mut(&mut v, parent), last)
{
m.remove(k);
}
cases.push((format!("{path:?} removed"), v.to_string().into_bytes()));
}
// Features repeated many times; deep nesting; not JSON at all.
let mut v = doc.clone();
if let Some(Value::Array(fs)) = v.get_mut("features") {
let copy = fs.clone();
for _ in 0..2 {
fs.extend(copy.iter().cloned());
}
}
cases.push(("features repeated".into(), v.to_string().into_bytes()));
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
}
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let failures: Vec<String> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut failures = Vec::new();
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
failures
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
});
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
}
-19
View File
@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>SolidCraft</title>
<link data-trunk rel="rust" data-bin="solvecraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
#solvecraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#solvecraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #4a5060; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
</head>
<body>
<canvas id="solvecraft_canvas"></canvas>
<div id="solvecraft_loading">Loading SolidCraft&hellip;</div>
</body>
</html>
-263
View File
@@ -1,263 +0,0 @@
//! SolveCraft desktop app.
//!
//! Usage: `solvecraft [--control <port>] [--sample] [design.solvecraft | part.step]`
//!
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
//! See `solvecraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[path = "../../app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(target_os = "windows")]
mod space_mouse;
use solvecraft_engine::Session;
use solvecraft_ui_egui::{Services, SolveApp};
struct App {
solve: SolveApp,
#[cfg(target_os = "windows")]
space_mouse: space_mouse::SpaceMouse,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
// designs first; the window closes once they are answered (`quit_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
}
self.solve.logic(ctx);
#[cfg(target_os = "windows")]
self.space_mouse.tick(&mut self.solve, ctx);
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.solve.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.solve.ui(ui);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(PREFS_KEY, self.solve.prefs());
}
fn on_exit(&mut self) {
solvecraft_ui_egui::recovery_ui::close(&mut self.solve);
}
}
/// Where the preferences (theme) are kept in eframe's storage.
const PREFS_KEY: &str = "solvecraft.prefs";
fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(
"Designs, STEP, IGES and meshes",
&["solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"],
)
.add_filter("SolidCraft design", &["solvecraft"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
});
command.arg(folder);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let _ = command.spawn();
})),
}
}
/// The anti-aliasing preference, kept next to eframe's storage (it is needed before the
/// window opens).
fn graphics_path() -> Option<std::path::PathBuf> {
eframe::storage_dir("SolveCraft").map(|d| d.join("graphics.json"))
}
/// Anti-aliasing samples from Preferences (4 when unset).
fn msaa() -> u16 {
graphics_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
.and_then(|m| u16::try_from(m).ok())
.filter(|m| [1, 2, 4, 8].contains(m))
.unwrap_or(MSAA)
}
/// Windows and Linux: no OS title bar; the application bar is the title bar
/// (`solvecraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const DEPTH_BITS: u8 = 24;
/// The section cap marks where it shows in the stencil.
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
Ok(egui::ViewportBuilder::default()
.with_title("SolidCraft")
.with_icon(egui::IconData {
rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?,
width: 64,
height: 64,
})
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id("ai.storyteller.solvecraft"))
}
fn main() -> eframe::Result {
let samples = msaa();
let mut control_port: Option<u16> = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--version" | "-V" => {
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--help" | "-h" => {
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
return Ok(());
}
_ => files.push(a),
}
}
let options = eframe::NativeOptions {
viewport: app_viewport()?,
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
..Default::default()
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; SOLVECRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SOLVECRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
eframe::run_native(
"SolidCraft",
options,
Box::new(move |cc| {
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let mut app = SolveApp::new(Session::default(), services());
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if sample {
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
app.set_status(format!("sample: {e}"), true);
}
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
app.session.undo.clear();
}
for f in &files {
app.open_path_async(f);
}
// No design given: the start page (not when driven over the control channel, whose
// clients expect the design view; `ui.home` shows it there).
if files.is_empty() && !sample && control_port.is_none() {
let rev = app.session.revision;
app.home.show(rev);
}
// Autosave, and offer what crashed sessions left behind.
if let Some(dir) = solvecraft_engine::recovery::default_dir() {
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
}
#[cfg(target_os = "windows")]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
Ok(Box::new(App {
solve: app,
#[cfg(target_os = "windows")]
space_mouse,
}))
}),
)
}
#[cfg(test)]
mod icon_tests {
#[test]
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
let viewport = super::app_viewport().expect("viewport");
let icon = viewport.icon.expect("running window must have an icon");
assert_eq!((icon.width, icon.height), (64, 64));
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
assert_eq!(viewport.app_id.as_deref(), Some("ai.storyteller.solvecraft"));
}
}
#[cfg(test)]
mod msaa_tests {
#[test]
fn saved_multisampling_uses_the_selected_backend_policy() {
use eframe::wgpu::Backend;
for requested in [1, 2, 4, 8] {
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(Backend::Gl, requested), 1);
for backend in [Backend::Vulkan, Backend::Dx12, Backend::Metal] {
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(backend, requested), requested);
}
}
}
}
-206
View File
@@ -1,206 +0,0 @@
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
//! it publishes the latest axes, and the egui thread applies them once per frame.
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui::Context;
use hidapi::{DeviceInfo, HidApi};
use solvecraft_engine::render::Camera;
use solvecraft_ui_egui::SolveApp;
const STALE_AFTER: Duration = Duration::from_millis(150);
const RETRY_AFTER: Duration = Duration::from_secs(2);
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Axes {
translation: [i16; 3],
rotation: [i16; 3],
}
#[derive(Clone, Copy)]
struct Reading {
axes: Axes,
translation_at: Instant,
rotation_at: Instant,
}
impl Reading {
fn new(now: Instant) -> Self {
Self { axes: Axes::default(), translation_at: now, rotation_at: now }
}
fn current(self, now: Instant) -> Axes {
Axes {
translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] },
rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] },
}
}
}
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
fn is_3d_mouse(info: &DeviceInfo) -> bool {
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
}
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None };
Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])])
}
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
match report.first().copied() {
Some(1) => {
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.translation = translation;
reading.translation_at = now;
true
}
Some(2) => {
let Some(rotation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.rotation = rotation;
reading.rotation_at = now;
true
}
_ => false,
}
}
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
let Ok(mut api) = HidApi::new() else {
log::warn!("3D mouse: HID initialization failed");
return;
};
loop {
if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}");
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
let mut report = [0_u8; 64];
loop {
match device.read_timeout(&mut report, 100) {
Ok(0) => {}
Ok(size) => {
if let Ok(mut reading) = shared.lock()
&& decode_report(&report[..size], &mut reading, Instant::now())
{
ctx.request_repaint();
}
}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
if let Ok(mut reading) = shared.lock() {
*reading = Reading::new(Instant::now());
}
ctx.request_repaint();
}
std::thread::sleep(RETRY_AFTER);
}
}
pub struct SpaceMouse {
shared: Arc<Mutex<Reading>>,
last_frame: Instant,
}
impl SpaceMouse {
pub fn start(ctx: &Context) -> Self {
let now = Instant::now();
let shared = Arc::new(Mutex::new(Reading::new(now)));
let worker_state = Arc::clone(&shared);
let worker_ctx = ctx.clone();
if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}");
}
Self { shared, last_frame: now }
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let now = Instant::now();
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now;
if !app.preferences.space_mouse_enabled || app.home.open {
return;
}
let axes = match self.shared.lock() {
Ok(reading) => reading.current(now),
Err(_) => return,
};
if axes == Axes::default() {
return;
}
let speed = f64::from(app.preferences.space_mouse_speed);
if !speed.is_finite() {
return;
}
app.begin_external_navigation();
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
navigate(&mut app.cam, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some());
ctx.request_repaint_after(Duration::from_millis(16));
}
}
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
/// per second to pan and about two radians per second to turn, independent of frame rate.
fn normalized(value: i16) -> f64 {
let v = f64::from(value);
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
}
fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: bool) {
if !(seconds > 0.0) || !seconds.is_finite() {
return;
}
let [x, y, z] = axes.translation.map(normalized);
let [rx, ry, rz] = axes.rotation.map(normalized);
cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height);
cam.zoom_at((-z * 1.5 * seconds).exp(), None);
if !sketch {
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
cam.roll_by(rz * 2.0 * seconds);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
let later = now + STALE_AFTER + Duration::from_millis(1);
assert_eq!(reading.current(later), Axes::default());
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default();
let before = cam;
navigate(&mut cam, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, false);
assert_ne!(cam.target, before.target);
assert!(cam.distance < before.distance);
assert_ne!(cam.yaw, before.yaw);
assert_ne!(cam.pitch, before.pitch);
assert_ne!(cam.roll, before.roll);
let orientation = (cam.yaw, cam.pitch, cam.roll);
navigate(&mut cam, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true);
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
assert!(cam.is_valid());
}
}
BIN
View File
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
# SolidCraft macOS icon
`SolidCraft.icon/` is the approved Icon Composer source, containing separate
grid and solid SVG layers and the owner's appearance settings. The saturated
blue solid has transparent geometry cutouts instead of painted edge lines.
The solid and grid are both scaled to 90%, with separate Default and Dark grid artwork.
Icon Composer supplies the system backdrop and Mono material effects.
Artwork is original and attributed in `ATTRIBUTION.md`.
`Assets.car` is compiled by Apple's `actool` and supplies the layered icon on
macOS 26 and later. It is checked in so the existing release workflow can still
package on older Xcode runners. After editing and saving `SolidCraft.icon`,
regenerate the catalog with Xcode 26 or later from the repository root:
```sh
mkdir -p target/macos-icon
xcrun actool assets/SolidCraft.icon \
--compile target/macos-icon --platform macosx \
--minimum-deployment-target 11.0 --app-icon SolidCraft \
--output-partial-info-plist target/macos-icon/icon-info.plist \
--output-format human-readable-text
cp target/macos-icon/Assets.car assets/Assets.car
cp target/macos-icon/SolidCraft.icns assets/icon.icns
```
`icon.icns` is the older-macOS fallback generated by the same Apple compiler
from the approved composition, retaining its layer sizes and positions.
Regenerate both compiled resources together with the commands above.
`icon.vectorcraft`, `icon.svg`, `icon.png` and `icon.iconset/` retain the earlier
VectorCraft artwork; packaging uses the Icon Composer compilation instead.
Only `packaging/macos/package.sh` consumes these resources. Its existing signed
app/DMG workflow copies the catalog and ICNS into `Contents/Resources`, and
`Info.plist` references `SolidCraft` through `CFBundleIconName` and
`CFBundleIconFile`. The macOS viewport opts out of eframe's runtime bitmap so
Finder, the Dock and the application switcher use the bundle icon. Other
platforms retain the existing code-drawn icon and packaging.
Design references: [Apple app-icon HIG](https://developer.apple.com/design/human-interface-guidelines/app-icons)
and [Icon Composer documentation](https://developer.apple.com/documentation/xcode/creating-your-app-icon-using-icon-composer).
No Apple artwork is shipped.
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="699.707" gradientTransform="matrix(1 0 0 0.5 512 740.159)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#81ace1"/>
<stop offset="0.3" stop-color="#81ace1" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#81ace1" stop-opacity="0.35"/>
<stop offset="1" stop-color="#81ace1" stop-opacity="0"/>
</radialGradient>
</defs>
<title>SolidCraft macOS application icon</title>
<g id="Experiment___faint_origin_grid" data-name="Experiment • faint origin grid">
<path id="Grid___elliptical_radial_alpha_beneath_solid" data-name="Grid • elliptical radial alpha beneath solid" d="M-19.692 -364.359 L1043.692 248.564 M-19.692 248.564 L1043.692 -364.359 M-19.692 -250.351 L1043.692 362.572 M-19.692 362.572 L1043.692 -250.351 M-19.692 -136.343 L1043.692 476.58 M-19.692 476.58 L1043.692 -136.343 M-19.692 -22.335 L1043.692 590.588 M-19.692 590.588 L1043.692 -22.335 M-19.692 91.673 L1043.692 704.596 M-19.692 704.596 L1043.692 91.673 M-19.692 205.681 L1043.692 818.604 M-19.692 818.604 L1043.692 205.681 M-19.692 319.689 L1043.692 932.612 M-19.692 932.612 L1043.692 319.689 M-19.692 547.705 L1043.692 1160.628 M-19.692 1160.628 L1043.692 547.705 M-19.692 661.713 L1043.692 1274.636 M-19.692 1274.636 L1043.692 661.713 M-19.692 775.721 L1043.692 1388.644 M-19.692 1388.644 L1043.692 775.721 M-19.692 889.729 L1043.692 1502.652 M-19.692 1502.652 L1043.692 889.729 M-19.692 1003.737 L1043.692 1616.66 M-19.692 1616.66 L1043.692 1003.737 M-19.692 1117.745 L1043.692 1730.668 M-19.692 1730.668 L1043.692 1117.745" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.2" stroke-miterlimit="10" opacity="0.28"/>
<path id="Origin_axes___shared_elliptical_radial_alpha" data-name="Origin axes • shared elliptical radial alpha" d="M-19.692 433.697 L1043.692 1046.62 M-19.692 1046.62 L1043.692 433.697" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.6" stroke-miterlimit="10" opacity="0.28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="699.707" gradientTransform="matrix(1 0 0 0.5 512 740.159)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#507eaa"/>
<stop offset="0.3" stop-color="#507eaa" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#507eaa" stop-opacity="0.35"/>
<stop offset="1" stop-color="#507eaa" stop-opacity="0"/>
</radialGradient>
</defs>
<title>Untitled-2</title>
<g id="_02_Grid" data-name="02 Grid">
<path id="Grid___elliptical_radial_alpha_beneath_solid" data-name="Grid • elliptical radial alpha beneath solid" d="M-19.692 -364.359 L1043.692 248.564 M-19.692 248.564 L1043.692 -364.359 M-19.692 -250.351 L1043.692 362.572 M-19.692 362.572 L1043.692 -250.351 M-19.692 -136.343 L1043.692 476.58 M-19.692 476.58 L1043.692 -136.343 M-19.692 -22.335 L1043.692 590.588 M-19.692 590.588 L1043.692 -22.335 M-19.692 91.673 L1043.692 704.596 M-19.692 704.596 L1043.692 91.673 M-19.692 205.681 L1043.692 818.604 M-19.692 818.604 L1043.692 205.681 M-19.692 319.689 L1043.692 932.612 M-19.692 932.612 L1043.692 319.689 M-19.692 547.705 L1043.692 1160.628 M-19.692 1160.628 L1043.692 547.705 M-19.692 661.713 L1043.692 1274.636 M-19.692 1274.636 L1043.692 661.713 M-19.692 775.721 L1043.692 1388.644 M-19.692 1388.644 L1043.692 775.721 M-19.692 889.729 L1043.692 1502.652 M-19.692 1502.652 L1043.692 889.729 M-19.692 1003.737 L1043.692 1616.66 M-19.692 1616.66 L1043.692 1003.737 M-19.692 1117.745 L1043.692 1730.668 M-19.692 1730.668 L1043.692 1117.745" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.938" stroke-miterlimit="10" opacity="0.28"/>
<path id="Origin_axes___shared_elliptical_radial_alpha" data-name="Origin axes • shared elliptical radial alpha" d="M-19.692 433.697 L1043.692 1046.62 M-19.692 1046.62 L1043.692 433.697" fill="none" stroke="url(#radial-gradient-1)" stroke-width="4.431" stroke-miterlimit="10" opacity="0.28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="linear-gradient-1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1d63c6"/>
<stop offset="1" stop-color="#15499e"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#459fff"/>
<stop offset="1" stop-color="#2379e7"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1b66cf"/>
<stop offset="1" stop-color="#124596"/>
</linearGradient>
<linearGradient id="linear-gradient-4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#258df5"/>
<stop offset="1" stop-color="#1461da"/>
</linearGradient>
<linearGradient id="linear-gradient-5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#51adff"/>
<stop offset="1" stop-color="#2b83f1"/>
</linearGradient>
</defs>
<title>SolidCraft • blue cutout 36 pt</title>
<g id="Layer_1" data-name="Layer 1">
<path id="Inner___upper_step___36_pt_cutout" data-name="Inner • upper step • 36 pt cutout" d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#linear-gradient-1)"/>
<path id="Top___lower_step___36_pt_cutout" data-name="Top • lower step • 36 pt cutout" d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#linear-gradient-2)"/>
<path id="Right___milled_recess___36_pt_cutout" data-name="Right • milled recess • 36 pt cutout" d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#linear-gradient-3)"/>
<path id="Left___stepped_face___36_pt_cutout" data-name="Left • stepped face • 36 pt cutout" d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#linear-gradient-4)"/>
<path id="Top___extruded_profile___36_pt_cutout" data-name="Top • extruded profile • 36 pt cutout" d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#linear-gradient-5)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+109
View File
@@ -0,0 +1,109 @@
{
"fill" : "system-light",
"groups" : [
{
"layers" : [
{
"glass-specializations" : [
{
"appearance" : "tinted",
"value" : true
}
],
"image-name" : "03-solid-blue-cutout.svg",
"name" : "03-solid-blue-cutout",
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
0
]
}
}
],
"name" : "03 Solid",
"shadow" : {
"kind" : "none",
"opacity" : 0
},
"specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.3
}
},
{
"layers" : [
{
"blend-mode-specializations" : [
{
"appearance" : "dark",
"value" : "normal"
}
],
"fill" : "none",
"glass-specializations" : [
{
"value" : false
},
{
"appearance" : "dark",
"value" : false
},
{
"appearance" : "tinted",
"value" : true
}
],
"hidden" : false,
"image-name-specializations" : [
{
"value" : "02-grid.svg"
},
{
"appearance" : "dark",
"value" : "02-grid-dark.svg"
}
],
"name" : "02-grid",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "dark",
"value" : 1
},
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
0
]
}
}
],
"name" : "02 Grid",
"shadow" : {
"kind" : "none",
"opacity" : 0
},
"specular" : false,
"translucency" : {
"enabled" : false,
"value" : 0.3
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Loaded 100 of 789 files, more files were not shown because too many files have changed in this diff. Show more