Compare commits

...

84 Commits

Author SHA1 Message Date
bherbruck c90070c497 Agent cursor speed: a slider from 1× to 5× with Instant at the far right. Instant no longer breaks: the command runs at once (as with the cursor off) and the pointer jumps to where it acted, with a click ripple. ui.agent_cursor speed takes 1–5 or "instant"; saved "slow" and "normal" read as 1×. Scenario covers 1×, 3× and Instant against the cursor-off result
Fixes #79

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 16:56:30 +00:00
bherbruck 5ec6d3562e Version 0.1.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 16:42:41 +00:00
CypherNaugh_0x 9508ee749c Shell refuses a wall thicker than a rim instead of returning a corrupt body: the topology-preserving face offset now checks every planar face's moved loops, and a loop that turns inside out or two loops that cross (a rim or ledge narrower than the move, e.g. a bowl opening sliding past the slab's outer edge) is an error naming the spot. Before, the self-crossing cavity went into the boolean and the shelled body came back with roughly twice its volume and its centre of mass outside the body, so every later cut failed its volume check
The check is geometric, not a tuned threshold: on the reported basin (18 mm rim, sloped bowl wall) the rim strip reaches zero width at 15.17 mm and the shell is refused from there on. Tolerances only absorb rounding (scaled to the body size), and at most 2000 straight edges per face are compared pairwise. Arc edges and curved faces are not checked.

This is a guard, not the full fix. Building such a shell needs the offset to change topology: drop the strip that collapses and let its neighbours meet, as offset_body already does for rounds thinner than the move, extended to planar faces. That is substantially more work in the most delicate part of the kernel (rims, ledges, steps, arc-bounded strips) and is left for a separate change; until then the workaround is to model the hollow directly (an offset loft and a box cut).
2026-10-10 12:32:03 -04:00
CypherNaugh_0x a0f456d4f4 Clippy passes on macOS and Windows again: the options rebinding for the XWayland default is compiled only where the Linux block that mutates it is, instead of leaving an unused mut that -D warnings rejects (#75) 2026-10-10 12:31:50 -04:00
CypherNaugh_0x 9a22d9df92 Section Analysis takes a world coordinate: inspect.section {plane, at} places the cut at that coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), so XZ's -Y normal no longer flips the sign; the params doc gives each named plane's normal; the result reports axis and at for named planes. at with offset, or with an {origin, normal} plane, is an error 2026-10-10 12:29:46 -04:00
Dylan M. Taylor b8bd43f797 chore: bump GitHub Actions to fix Node 20 deprecation warnings
Pins actions/checkout v7.0.1, actions/upload-artifact v7.0.2, actions/download-artifact v8.0.2 and
Swatinem/rust-cache v2.9.2 by commit. Re-applied onto current main by the maintainer (the workflows
changed since the PR was opened); dtolnay/rust-toolchain stays on @stable, because pinned to a commit
it needs an explicit toolchain input, and it is a composite action with no Node runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 12:25:38 -04:00
CypherNaugh_0x 5d5ff44db2 Drawing connects to existing points: a new point landing exactly on an existing point (the origin too) gets a coincident constraint in the engine, for every drawing command and every caller (MCP, scripts, clicks), in the same undo step; connect: false opts out. Re-tying an existing coincident returns it ("existing": true) instead of failing; the UI's after-the-fact tie_snaps is gone 2026-10-10 12:08:19 -04:00
bherbruck 448ea769eb CI builds tests and lints with a ci profile instead of --release
Light optimisation (deps at 2), line tables only, no LTO, debug assertions off (truck's debug-only
checks reject valid geometry, the only reason tests used --release). Cold full `cargo xtask ci`: 14 min
with all 647 tests passing; the oracle, step-corpus and app builds stay on --release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 15:48:43 +00:00
CypherNaugh_0x 49d6682fe7 Rectangles name their sides and corners: sketch.rectangle.two_point and .center return sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids), so they can be dimensioned without a sketch.inspect round trip 2026-10-10 11:14:03 -04:00
David Gray d57e90a32a View cube labels are printed on their faces: each face's name is laid out flat and mapped through the cube's projection, so it turns and foreshortens with the face and fades out as the face turns edge-on (instead of floating upright at the face's centre and vanishing past 70 degrees). TOP and BOTTOM read upright in the plan views clicking them turns to. Tests: every label reads upright in its own view and stays within its face from every direction
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:01:31 -04:00
Dylan M. Taylor 52d2936f94 Linux: default to XWayland for file drag-and-drop on Wayland desktops 2026-10-10 10:50:10 -04:00
bherbruck 365879c722 Merge pull request #72 from bherbruck/fix/riscv64-apt-sources
Split PR CI, make cross-compilation opt-in, and fix RISC-V APT
2026-10-10 10:30:19 -04:00
bherbruck ba0f683e89 ci: separate native tests and lint, make cross checks opt-in 2026-10-10 09:56:31 -04:00
bherbruck e5ef451214 fix(ci): configure Ubuntu ports for RISC-V dependencies 2026-10-10 09:33:08 -04:00
bherbruck b314e3f175 fix(ci): configure Ubuntu ports for RISC-V dependencies 2026-10-10 09:33:04 -04:00
bherbruck cf1ab075ec Merge pull request #65 from N3MI-DG/fix/view-cube-edge-on-flares
View cube, move triad and Offset Plane preview: no flashing lines while orbiting
2026-10-10 09:32:27 -04:00
David Gray 03790f5509 View cube, move triad plane squares and the Offset Plane preview no longer flash lines while orbiting: faces turned nearly edge-on are drawn with projected_polygon (an unfeathered fill, outlined edge by edge) instead of egui's convex_polygon, whose miter joins shot spikes hundreds of pixels long at the slivers' sharp ends. Tests check everything drawn stays within each shape from 4050 view directions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:28:08 +11:00
bherbruck be095132b9 Re-land of the shell branches on the renamed ids (#6): the occurrence Move window goes through frame.rs like every window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 07:19:58 +00:00
bherbruck 8b60d77dad Scenario s40 (toolbar customisation survives a restart) runs now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 07:19:58 +00:00
bherbruck 5f45f0789f Status bar: commands by their labels ("Fillet done", "Shell: offset must be …") instead of internal ids
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 07:19:58 +00:00
bherbruck 4812ec6c0b Toolbar customisation: a command's menu (right-click a toolbar button, a panel drop-down row or an S box row) pins it to the toolbar, removes it or pins it to the S box's favourites; drag buttons to reorder within and between panels (drop line); Reset Toolbar; per workspace and tab, kept with the preferences, exported and imported with the keyboard shortcuts. Workspace switcher spans the toolbar's height and opens the workspace menu (Design; the others coming later)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 07:19:58 +00:00
bherbruck b5e74030c0 Occurrence Move/Copy uses the shared move triad (arrows, plane squares, rings, ball; snapped): distances and X/Y/Z angles, turning about the occurrence's starting origin, pending until Capture Position
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 07:19:58 +00:00
bherbruck 9d3cbb4acc Browser fold arrows: each row is one widget with a stable id, and where the click lands decides what it does (fold arrow with padding, eye, radio, or the row), so an arrow click toggles without also selecting or picking the row or starting a drag; Collapse/Expand tips; ui.at {browser|chevron} and ui.browser for automation; scenario browser_chevrons clicks every folder's arrow (document settings, named views, origin, groups, sketches, construction, canvases, components, bodies, the design) and checks the children; the inch-width units test runs now that params reads unit-less lengths in design units
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 07:19:58 +00:00
bherbruck fd4d38d31e Test (ignored until params reads unit-less length parameters in design units): a bare inch width stays 50.8 mm when the design goes metric
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 07:19:58 +00:00
bherbruck 535bd5dfff Section Analysis visuals: the cut faces are hatched at 45 degrees in the cap colour, and each body's outline along the cut is drawn
Part of #51 (per-body cap colours and the cut in snapshots to follow)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 07:11:40 +00:00
bherbruck 794a394b28 Scenario acceptance_lip_on_rounded_rim enabled: the Lip on the enclosure's rounded rim works since #29's fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 07:03:21 +00:00
bherbruck 9dd901649c View cube with Y up (#3): the cube is drawn and clicked in the camera's up-axis frame, so TOP is the world's up; TOP and BOTTOM turn to the standard plan views (yaw 0). Scenario view_cube_y_up; the camera check can test the world direction the camera looks from
Refs #15, #3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 06:55:10 +00:00
bherbruck d1cdc4d061 Silhouette edges on curved surfaces: drawn in the surface shader where it turns away from the eye (about 1.5 px, only on the visible surface, so nothing shows through thin walls), in the viewport and live previews; snapshots draw them too. The back-face outline pass is gone
Fixes #48

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 06:45:19 +00:00
bherbruck 62746045af Fillet dialog: middle radii for variable fillets (add, remove, position and radius) and the Smooth toggle
Variable type shows a Mid Radius row per point (position along the edge from the start end, 0.01 to 0.99, and a radius expression, with a remove button), an Add Mid Radius button and, with points present, Smooth. They go to solid.fillet as mid and smooth and come back on Edit Feature. Test: the round trip and removing every point.

Refs #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 06:15:30 +00:00
bherbruck be18bd6573 Variable-radius fillets through middle points: radii at positions along the edge, straight or smooth between
kernel::fillet_variable_points(body, edge, start, [(position, radius)...], smooth): the radius law is a B-spline of the edge parameter (straight pieces, or C1 cubic pieces with Catmull-Rom slopes), and since every section's arc is affine in its radius the blend is exactly a rational B-spline (degree 2 across, the law's degree along) with B-spline contact curves on the faces. The largest radius along the edge decides whether the blend fits. Fillet type variable takes mid: [[position, radius expr], ...] and smooth; middle radii are parameters of the feature (Mid radius) and follow user parameters. Tests: straight pieces against (1 - pi/4) L (a^2 + ab + b^2) / 3 per piece, smooth against the integrated law, either end as start, refusals; engine test with a parameter-driven middle radius.

Refs #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 06:06:33 +00:00
bherbruck a7fe0429f0 IGES reader checked against Fusion's IGES exports: 75 of 76 reference parts read back exactly; cone tips read whole
`cargo xtask step-corpus --iges` (and `solvecraft-cli step-corpus --iges`) opens every oracle part.igs as File → Open does and compares body count, volume, area and face count with Fusion's measurements, writing docs/iges-import.md. The run found faces closing at an apex (a drill point's cone, a sphere's pole: the seam runs out to the apex and straight back) kept whole and meshed wrongly; they are now split along the iso-line opposite the seam like bands are (18-hole-features and 40-box-sphere-torus pass). The one failure left, 50-sweep-tight-bend, is written by Fusion as five loose, partly degenerate surfaces that Fusion cannot read back as a solid either; documented in the report. Test: a drilled blind hole round-trips through IGES.

Fixes #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 05:56:53 +00:00
bherbruck 72bc6fb010 A pattern or mirror of a suppressed feature has nothing to copy: its copies go with it and the pattern warns (the acceptance enclosure loses all four standoffs when Standoff is suppressed); scenario acceptance_pattern_of_suppressed_feature enabled
Fixes #42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 05:27:01 +00:00
bherbruck d1428ef27c UI tests clear the frame's texture changes: no "Dropped TexturesDelta" panic in debug builds
drive_value_takes_the_keyboard and boundary_fill_finds_the_cells_and_fills_the_ticked_ones dropped egui's output with texture deltas still pending (epaint's debug check); they clear them as the scenario harness does. All ui-egui tests pass in a debug build.

Fixes #60

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 05:17:12 +00:00
bherbruck 7318b98b8d Preferences: one Fusion-style dialog from the gear at the top right and Ctrl+, (Cmd+,)
Pages: General (theme Dark/Light/System, UI scale, units of new designs, autosave, display
precision, the agent cursor), Navigation (Fusion, SolidWorks, Inventor, Alias and Tinkercad
mouse presets, reverse zoom, zoom to the cursor, orbit centre, view-change animation on/off and
speed), Keyboard Shortcuts (the old Shortcuts window as a page: search, rebind, conflicts),
Design (default modelling orientation Y up / Z up for #3, and the offer to stand up a Y-up STEP
or IGES file: a quarter turn about X as one undoable Move), Graphics, Sketch, Selection.
Changes apply at once; Cancel or Esc puts back what was there when the dialog opened, Apply
keeps them, OK closes, Restore Defaults resets the page. The theme, ground shadow and agent
cursor settings are now kept between runs. File and Help › Keyboard Shortcuts open the page;
the unused docked Preferences dialog is gone. Control: ui.prefs {open, button, stand_up, …}.
Scenario s45_preferences; tests for Cancel/Apply/defaults and standing a Y-up file up.

Fixes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 05:09:03 +00:00
bherbruck 132acc9863 Perpendicular Plane (construct.plane.perpendicular {plane, line}): the plane through a straight edge, sketch line or axis, square to a plane or planar face; a line square to the plane is refused (no single answer)
Fixes #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 05:00:55 +00:00
bherbruck 0e2d8efd5c See-through bodies and faces are drawn back to front: the glass triangles are re-sorted along the view direction when the view turns (more than about 2 degrees), so overlapping glass blends in order. Large triangles that cut through each other can still show a seam (sorted by centre)
Fixes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 04:11:10 +00:00
bherbruck a5729f1f97 Constraint glyphs: one per constrained entity, beside it, readable
Fixes #43

- Two-entity constraints (parallel, tangent, equal, ...) get a glyph on each entity.
- Glyphs sit beside their entity (offset along its screen normal), step along it when crowded,
  with a faint leader when pushed away; 15 px boxes with the toolbar's constraint icons
  (H/V stay letters), coloured from the theme's glyph tokens in both themes.
- Hovering a glyph lights both of its glyphs and its curves/points and names the constraint.
- Removes the old duplicate glyph drawer in viewport.rs (it drew a second, green set).
- Scenario expect key `glyphs` ({constraint id: count}, none overlapping) and the scenario
  constraint_glyphs_both_entities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 03:27:42 +00:00
bherbruck eac7306d59 Merge pull request #39 from dylanmtaylor/ci/flatpak-pages
Publish Flatpak repository through GitHub Pages
2026-10-09 22:19:56 -04:00
bherbruck 183301bce9 Merge pull request #54 from dylanmtaylor/feat/riscv-build
ci: Add RISC-V Linux build
2026-10-09 21:18:54 -04:00
bherbruck 721c8b96a4 Merge pull request #55 from bherbruck/ci/agent-managed-releases
Use agent-managed draft releases from the release branch
2026-10-09 21:17:14 -04:00
bherbruck 1e5356e056 Selected and hovered edges, and a selected face's outline, show dimmed where the model hides them
Fixes #50

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 00:51:37 +00:00
bherbruck 1c23620bd9 Merge pull request #56 from bherbruck/docs/readme-fusion-introduction
docs: introduce SolveCraft as a Fusion 360 reimplementation
2026-10-09 20:23:56 -04:00
bherbruck 2dd540397d docs: highlight Fusion 360 comparison in README introduction 2026-10-09 20:23:17 -04:00
Dylan M. Taylor 7044399da1 ci: add RISC-V Linux build 2026-10-09 20:20:30 -04:00
bherbruck 7a98800699 ci: stage agent-managed releases from the release branch
Build drafts from explicit workspace versions, preserve dry runs, and document
agent verification and publication. Reject existing release tags and published
versions instead of publishing on every development push.
2026-10-09 20:18:13 -04:00
bherbruck 88b4c5b883 fix: preserve behavior under new Clippy lints
Keep the fixed ray direction explicit and simplify a single-element loop.
2026-10-09 20:18:05 -04:00
bherbruck a3a1d15d02 Test: Project and Include from other bodies (an edge, a face's loop, a silhouette, a 3D circle) follow a parameter edit of that body and are reported lost, not failed, when it is deleted
Part of #47 (components' occurrences wait for the frames fix, 62d22e2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 00:14:00 +00:00
bherbruck 61f86d073d Sketch Fillet: several corners at once (points: one radius dimension, the other arcs equal to it); a radius too large for a line corner is a clear error naming the length it needs, and nothing changes; tests for a constrained rectangle's corners (areas exact), line-arc and arc-arc corners, too-large fillets and chamfers, chamfer areas
Fixes #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 00:14:00 +00:00
bherbruck 307ef11c0a Sketch Trim/Extend/Offset robustness: Extend reaches splines, ellipses and conics (their polyline, then pinned on the curve); Offset refuses a distance its shape can't take (a straight piece overrun by its neighbours) instead of drawing a turned-inside-out copy; adversarial tests for trims (chords, tangent touches, lone lines, collinear overlaps, splines, ellipses, hostile points), extends and offsets (rectangles in and out, slots, splines, a spike, hostile distances), each leaving the sketch sound
Fixes #45

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 00:14:00 +00:00
bherbruck ab574952b1 Hidden origin planes and axes are not pickable: Create Sketch only brings the Origin back when no body is shown; origin and construction planes give way to a face or profile at the same depth (a shelled box's floor where the XZ plane crosses it). Scenarios hidden_origin_planes_not_pickable and face_wins_over_origin_plane
Fixes #31

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 00:14:00 +00:00
bherbruck 67c79ec91c Lips on rounded, shelled rims: the band along the rim's edge is raised from the body's own faces (kernel::raise_band: the rim face split along the band's exact offset edge, the band's walls swept up from the walls below, a top), since a join can't merge walls that continue each other; grooves cut with a band reaching into the free side so no wall coincides; tests against the band's area times its height
Fixes #29

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 00:04:37 +00:00
bherbruck 80f5a58806 Re-land the robustness batch on main (#6): fillets running into an earlier round go to blend_corner (any radius) instead of the same-radius undo; the lip and drilled-hole regression tests move into tests/robustness.rs with the new command ids; measuring 5000 faces is checked for its counts, its time in an ignored perf test run alone
Part of #6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 00:04:37 +00:00
bherbruck 0e9ddef4d8 docs/robustness.md: the first tutorial's fixes and measuring speed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 00:04:37 +00:00
bherbruck f967b8d3ff Measuring bodies of thousands of faces: 13 s to 0.8 s for 5000 faces (the body's size was recomputed for every face); merged topology counts right on finely faceted bodies (vertices welded only when they coincide, planes merged only when parallel within 1e-9), with a test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 00:04:37 +00:00
bherbruck 6ab39679e7 Rounding an edge that runs into an earlier round of the same radius (a box's edges filleted one at a time, the first tutorial): the earlier round is undone (its faces deleted and healed) and both edges rounded together, the same body as picking both at once; another radius says what to do instead of 'the faces at the edge ends must be planar'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 00:04:37 +00:00
bherbruck d7f9f0a516 Shell of rounded parts (the first tutorial): a round thinner than the shell collapses to a sharp inner edge (as in Fusion) instead of failing; when an open face's tangent round can't follow it out, every face moves in and the opening is the open face's copy swept out through the body; spindle tori (a bottom round running round a rounded corner) are taken by the offset; shells after a drilled hole; engine tests against the analytic volumes (box with rounded vertical edges and bottom loop, the tutorial plate at 2 mm and 1.5 mm)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 00:04:37 +00:00
bherbruck 1bf377c14b No panics in the boolean's final assembly (the web build aborts on any panic: the Plastic Enclosure sample panicked 8 times and crashed the browser's start page); face pushes check their result and fall back to the general offset (a boss standing on a plate joins again with off-axis cylinder seams); offset errors name the face they can't move; docs/robustness.md with the before and after rates; caught panics counted per design in the robustness runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 00:04:37 +00:00
bherbruck ccde7a4837 Robustness runs and the kernel fixes they found
- solvecraft-cli fuzz / fuzz-one: seeded random modelling sessions (boxes, extruded profiles with arcs, revolves; joins and cuts of boxes, cylinders and spheres, fillets, chamfers, shells, holes, patterns) on a grid that makes coincident faces and tangencies common, every step checked (command ok, every body a sound solid via the new Body::validity, volume moving the right way), plus the boolean volume identity on random primitives; one process per seed with a time limit; failures minimised to scripts
- shell/offset: corners where a curved face meets another at an angle are refined onto the moved surfaces (Newton); edges that are neither lines nor arcs are rebuilt through their moved points; one shell per piece; faces that would cross say so (not supported yet) instead of leaving a cavity inside out; flat revolved faces count as planes; the unhealed body is tried when the healed one fails
- cylinders start their seams at an odd angle (grid-aligned seams broke booleans along them)
- partial revolves of profiles touching the axis build no zero-area face on the axis
- booleans on bodies in several pieces retry a piece at a time
- regression tests for each, and a fixed set of seeds in cargo test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 00:04:37 +00:00
bherbruck 5d69452db3 View cube: the axis triad is drawn behind the cube, so only its parts outside the cube show
Refs #15

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-09 23:56:55 +00:00
bherbruck 322a357dee Y-up worlds in the camera: an up axis on the camera (Z by default, or Y), with yaw and pitch measured about it; standard views, the home view, Look At, orbit, pan, the ground grid (XZ with Y up), the ground shadow and snapshots follow it. ui upAxis setting (app.set_up_axis re-aims and frames); cam.up_dir / to_frame / from_frame for the view cube and others
Part of #3 (the camera half; the preference and import offer are shell's)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-09 23:49:12 +00:00
bherbruck abb9d7460f Acceptance project (#27): examples/acceptance/sensor_enclosure.json, a sensor enclosure sized from user parameters (case with USB cut-out, patterned PCB standoffs and lid bosses, snap fit; lid with a lip from offsets of the case's rim and patterned screw holes). Scenario acceptance_sensor_enclosure edits it (USB moved, suppress and back, save and reopen, STEP out and back in); pending scenarios for what fails: #40 (lid lip after a resize), #41 (snap fit after a resize), #42 (pattern of a suppressed feature), #29 (Lip on the rounded rim). Harness: a script step runs a command script, $DIR in call parameters is the scenario's folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-09 23:41:13 +00:00
bherbruck 141f3acc85 Split pieces stay separate faces: Measure counts them apart and Offset Face moves only the picked piece
The merged face count no longer joins faces across a Split Face line, and Offset Face's same-surface grouping stops there. A piece whose move the body's topology cannot follow (its neighbour stays put) is swept along its normal and joined on or cut in, giving walls along the split line as in Fusion. Engine test: a split box top counts 7 faces; one half up 5 (27000 mm³), the other down 3 (25200 mm³).

Fixes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-09 23:31:52 +00:00
bherbruck a00153fc99 Snapshots show appearances: the CPU renderer colours bodies by their appearance (or imported colour) and faces with looks of their own, as the viewport does
Fixes #4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-09 22:51:18 +00:00
bherbruck 3a416ccb7d Profiles picked by index are kept by the curves around them: add_feature turns profile indices into the curve ids of each profile's outer loop (Extrude, Revolve, Sweep, Loft, Emboss, Sheet Base, Patch), so a face sketch whose projected outline is rebuilt still extrudes the picked region; a profile that is gone is an error on the feature, never another pick
Fixes #30

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-09 22:13:01 +00:00
bherbruck 4c8f080691 Faces split on purpose stay split: the body remembers its Split Face lines, export and healing keep the pieces apart
Split Face records the new edges as polylines on the body (Body::split_lines); the document carries them through later features. STEP export no longer joins pieces of one surface across such a line, and healing (fillets, chamfers, booleans, shell, draft) no longer merges coplanar pieces there, while incidental pieces (boolean leftovers, seam halves) still merge as before. Tests: plane and cylinder splits keep their face count through a STEP round trip (and join without the record), a second split keeps both lines, and an engine test exports a split box after a fillet with 8 faces.

Fixes #11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-09 21:31:16 +00:00
Dylan M. Taylor 06be3500f8 ci: publish Flatpak repository to Pages 2026-10-09 17:14:35 -04:00
bherbruck 0a1ab40ea0 View cube with chamfered corners: its only targets are the 6 faces (orthographic views) and the 8 corner facets (iso views); edges are not targets, a point on one goes to a neighbouring face or facet, and hover lights exactly the face or facet under the pointer. Design decision.
Fixes #15

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:28:16 +00:00
bherbruck eef2430bc7 Surface tool dialogs: Patch (sketch profiles or a loop of edges), Stitch (tolerance), Thicken (thickness, one side or symmetric, operation), Extend (edges, distance), Trim (tool plane or face, side to keep) and Split Face; Split Body takes any face as its tool (a curved face's surface extended). Live previews and Edit Feature; icons for patch, stitch and thicken drawn in code
Fixes #19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:59:41 +00:00
bherbruck a1a6e2b624 Release packaging: the MSI's licence files get a component GUID, the mounted dmg is verified without --strict, shellcheck follows env.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:50:26 +00:00
bherbruck 6b146942bd Dialog rows for the newer engine options: Thread Modeled; Shell Tangent Chain (on by default) and Direction (inside, outside, both); Revolve Type (Angle, To Object) and Direction (one side, two sides with Angle 2, symmetric); Extrude and Hole extents To Object; Hole on all points of a sketch; Fillet Radius Type (constant, chord length, variable with an end radius and Swap Ends); Combine into a New Component; Align snaps (as picked, centre, circle centre; edges snap at their middle); Boundary Fill dialog (the tools' cells found by sampling, ticked cells filled, Remove Tools). Edit Feature restores them; tests for each mapping
Fixes #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:40:24 +00:00
bherbruck ce61aef4a6 Dialog labels show in full: the label column grows to its longest label (up to 150 px, then wraps) instead of cutting labels off. Feature previews that remove material (Hole, pockets) show the cut's new faces through the body in the cut colour, so the hole is seen from any side
Fixes #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:27:26 +00:00
bherbruck 7e0618d64b Release pipeline after PhotoCraft's: AppImage/deb/rpm/Flatpak/tar.gz for Linux x86_64 and aarch64, FreeBSD, a macOS dmg and CLI zip, Windows x64/x86/arm64 MSI and portable zip (plus the plain x64 exe), web zip, SHA256SUMS.txt; version 0.1.2
Adapted from storytold/photocraft's release.yml and packaging/ (Apache-2.0; ATTRIBUTION.md and
NOTICE say so). Packages install the licence files, THIRD-PARTY-LICENSES.txt included. The app
icon is drawn by `solvecraft-cli icon` (a blue block with a bore, rendered on a transparent
background; .ico too), so no image is committed. Notes: a short header plus GitHub's generated
What's Changed. A dry run (Run workflow, publish off) keeps the files as the release-assets
artifact. packaging-lint.yml checks the workflows, scripts and metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:18:29 +00:00
bherbruck 90fde44c32 Construction axes and points in the viewport: axes dashed in the construction colour across the model (past it either side), points as small stars; axes are picked like the origin axes and work as axes in dialogs (pattern directions, revolve, rotate); hidden like construction planes; timeline icons
Fixes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:07:19 +00:00
bherbruck cbdc4fe0d8 View cube hover lights the whole region a click turns to: a corner its 3 patches, an edge its 2 strips, a face its middle (one hit test for hover and click; test over all 26 regions). View animations ease with smoothstep and take longer for turns past a quarter turn, so no frame sweeps much more than 9 degrees; s38 and s39 enabled
Fixes #15

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:45:27 +00:00
bherbruck 3b650cac5f Release workflow: the macOS job mounts the .dmg and checks it (the app, its Info.plist, both architectures, the Applications link)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:32:26 +00:00
bherbruck 50a74ac66d Agent cursor: a violet pointer labelled Agent acts out control-channel and MCP commands. It glides to the command's toolbar button (or its panel) and clicks with a ripple, then to each pick (faces, edges and points projected to the screen, sketch points, a new sketch's plane, the profiles an extrude takes), then the command runs; the reply waits for it and later requests queue behind. Speed instant/normal/slow; follow camera turns to a new sketch and back on finish. View settings on the navigation bar (Show Agent Cursor, speed, follow camera; remembered, off by default); ui.agent_cursor {show, speed, follow_camera}; "animate": false per call. Tests: pick to screen mapping; scenario with the cursor on (replies wait for the animation) matching the cursor-off result
Fixes #35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:21:47 +00:00
bherbruck 9c0632b07c Release workflow: a dry run (Run workflow with publish off) keeps the assets as an artifact instead of releasing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:01:08 +00:00
bherbruck bf2b64f597 Releases offer plain programs; the licences are built in
Release assets are now the programs themselves: SolveCraft and solvecraft-cli .exe for Windows,
plain Linux binaries plus the AppImage, a macOS .dmg (SolveCraft.app with an Applications link) and
the universal CLI binary; only the web app stays a zip. The notes template takes {tag}, says to
chmod +x on Linux and macOS, and GitHub adds the change list.

Since no LICENSE or NOTICE file rides along any more, the programs carry them:
THIRD-PARTY-LICENSES.txt (written by `cargo xtask licences` from cargo metadata and each shipped
crate's own licence files, 419 crates; `cargo xtask ci` checks it is current) is embedded with
LICENSE-MIT, LICENSE-APACHE and NOTICE in solvecraft_engine::licences, shown in Help ▸ About ▸
Licences (third-party text laid out by visible rows) and printed by `solvecraft-cli licences`.
Scenario s44_about_licences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:53:19 +00:00
bherbruck 3a1f05bacf Release workflow: only a tag with a suffix (v0.2.0-rc1) is a pre-release; v0.1.1 notes no longer say pre-release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:03 +00:00
bherbruck 3966b8b3b9 Version 0.1.1: the save prompt on quit (#33) and the web black-screen fix (#34); release notes for v0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:40:04 +00:00
bherbruck bd8e8c4809 Web: the viewport shader compiles in browsers (a new design no longer shows a black screen)
Fixes #34

The surface-analysis curvature map took screen-space derivatives (dpdx/dpdy) inside a branch
of fs_solid. naga accepts that, but browsers' WGSL compiler (Tint, WebGPU) requires derivatives
in uniform control flow and rejects the whole `sc_viewport` shader module, so every viewport
pipeline was invalid and the viewport stayed black as soon as a design was shown. The
curvature is now worked out first thing in fs_solid and passed in, and the canvas image
shader samples its texture before discarding.

Regression test: every fragment entry point makes derivative and implicit-LOD sampling calls
(directly or through helpers) before any branch or return. Checked in headless Chromium with
WebGPU (SwiftShader): before, "dpdx must only be called from uniform control flow" and invalid
pipelines on New Design; after, no shader or pipeline errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:22:22 +00:00
bherbruck 4562c8e5ba Save prompt on quit: "Save changes to <name>?" for every unsaved design
The title bar's ×, File ▸ Quit, the OS closing the window (Alt+F4, Cmd+Q, the window manager:
close_requested is cancelled until the designs are answered) and app.quit / ui.window close all
go through documents::request_quit, which asks Save / Don't Save / Cancel once per design with
unsaved changes. Save saves (Save As for a never-saved design) and quits only if it saved; Don't
Save discards (no recovery copy is kept); Cancel or Esc stays open. Closing a tab asks the same
question (now titled "Save changes to <name>?"); New and Open never replace an unsaved design
(they open a tab). The web app asks through beforeunload while a design is unsaved. Control:
ui.documents {action: quit, answer: save|dont_save|cancel}, app.quit {force}. Tests: quit with
Save then Don't Save across two tabs, Cancel, a Save that saves nothing, Save As; scenario
s43_quit_asks_to_save.

Fixes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:03:32 +00:00
bherbruck d2bc8e9847 README: a 3x-speed demo GIF at the top (agent builds the enclosure over MCP, then edits parameters)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:54:48 +00:00
bherbruck 8601611d0c MCP demo v3 (enclosure_ui_mcp.py --story v3, default): one standoff placed from the parameters (width/2 - inset, depth/2 - inset) and patterned 2 x 2 in the Rectangular Pattern dialog with spacings width - 2*inset and depth - 2*inset, then width 80 -> 100 and wall 2 -> 3 typed into the Parameters dialog; record_v2.sh is now record_ui.sh STORY. A pattern's Direction can be a straight model edge (an edge along an origin axis points the axis's way), scenario pattern_direction_edge. Dialog rows publish their widgets as dialog:<label> handles (combo items dialog:<label>:<item>, OK), the Parameters dialog its expressions (param:<name>), title and close button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:09:29 +00:00
162 changed files with 24580 additions and 1382 deletions
+21 -58
View File
@@ -1,62 +1,25 @@
SolveCraft is an open-source parametric 3D CAD application in pure Rust: sketch on a plane,
constrain and dimension the sketch, turn profiles into solids on a feature timeline, and change a
parameter to watch the timeline rebuild. This is the first public build, a **pre-release**: it is
early, and we'd like your bug reports.
SolveCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solvecraft/)
· [try it in the browser](https://bherbruck.github.io/solvecraft/app/) ·
[contributing](https://github.com/bherbruck/solvecraft/blob/main/CONTRIBUTING.md) ·
[issues](https://github.com/bherbruck/solvecraft/issues). AI agents can model with it over MCP:
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solvecraft/guide/mcp.html)).
- **Documentation:** https://bherbruck.github.io/solvecraft/
- **Try it in the browser:** https://bherbruck.github.io/solvecraft/app/ (`?sample` opens a sample part)
- **Contributing:** [CONTRIBUTING.md](https://github.com/bherbruck/solvecraft/blob/main/CONTRIBUTING.md)
- **Bugs and ideas:** [issues](https://github.com/bherbruck/solvecraft/issues)
**Which file?**
- **Windows:** `SolveCraft-{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 onto Applications.
The CLI is in `solvecraft-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.
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
## What works
- Sketches with SolveCraft's own constraint solver: lines, rectangles, circles, arcs, polygons,
slots, splines, text; constraints and parameter-driven dimensions; automatic profiles.
- Solid features on a parametric timeline that rebuilds incrementally: extrude, revolve, sweep,
loft, fillet and chamfer, shell, draft, holes and threads, patterns, mirror, combine, split,
primitives. Faces and edges are referenced by persistent names, so upstream edits don't
scramble downstream features.
- Parameters with unit-aware expressions, and configurations.
- Components, joints, motion studies, sheet metal with flat patterns, and a plastic-enclosure
subset (bosses, lips, snap fits).
- Files: `.solvecraft` designs; STEP (AP203/214/242) and IGES import and export; 3MF, STL and OBJ;
DXF. Atomic saves, autosave and crash recovery.
- 76 of 76 reference parts built in Fusion rebuild in SolveCraft with the same volume, area and
topology.
## Built for AI agents
Everything the UI does is a command with JSON parameters, so an agent can do it too.
`solvecraft-cli mcp` is a Model Context Protocol server: headless, or bridged to the running app
(`solvecraft --control 7878`, then `solvecraft-cli mcp --connect 127.0.0.1:7878`) so you can
watch the agent model. In Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. The
attached video shows an agent building an enclosure through the UI and MCP.
## Downloads
| File | What |
|---|---|
| `solvecraft-v0.1.0-windows-x86_64.zip` | Windows 10/11: `solvecraft.exe` (the app) and `solvecraft-cli.exe` |
| `solvecraft-v0.1.0-linux-x86_64.tar.gz` | Linux x86_64 (glibc 2.35 or newer): `solvecraft` and `solvecraft-cli` |
| `SolveCraft-v0.1.0-x86_64.AppImage` | Linux, the app as one file: `chmod +x` and run it |
| `solvecraft-v0.1.0-macos-universal.zip` | macOS 11 or newer, Apple silicon and Intel: `SolveCraft.app` and `solvecraft-cli` |
| `solvecraft-v0.1.0-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
| `SHA256SUMS` | checksums: `sha256sum -c SHA256SUMS` |
### The binaries are not signed
- **macOS:** the first time, right-click (or Control-click) `SolveCraft.app` and choose **Open**,
then **Open** again in the dialog. If macOS says the app is damaged, run
`xattr -dr com.apple.quarantine SolveCraft.app`. Do the same for `solvecraft-cli`.
- **Windows:** SmartScreen may say "Windows protected your PC". Click **More info**, then
**Run anyway**.
## Known limitations
SolveCraft is early. Some fillet, chamfer and boolean configurations still fail (with an error,
never a crash); surfaces, drawings, CAM, mesh tools and simulation are out of scope for now; the
model is Z-up only, so STEP files from Y-up tools open lying on their backs. The full list is in
The builds aren't signed yet. On macOS, right-click SolveCraft in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.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/solvecraft/blob/main/ROADMAP.md#known-limitations).
SolveCraft is MIT OR Apache-2.0. It is a clean-room implementation and is not affiliated with
Autodesk; Fusion is a trademark of Autodesk, Inc.
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env bash
# Package the Linux release build: a tar.gz with both binaries, and an AppImage of the desktop app.
# Usage: .github/release/package-linux.sh <tag> (from the repository root, after
# `cargo build --release -p solvecraft -p solvecraft-cli`). Writes out/.
set -euo pipefail
tag=${1:?usage: package-linux.sh <tag>}
bin=${CARGO_TARGET_DIR:-target}/release
name="solvecraft-$tag-linux-x86_64"
rm -rf stage out && mkdir -p "stage/$name" out
cp "$bin/solvecraft" "$bin/solvecraft-cli" README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
tar -C stage -czf "out/$name.tar.gz" "$name"
# AppImage. The icon is rendered by SolveCraft itself from the bracket example, so no image file
# is committed.
app=stage/SolveCraft.AppDir
mkdir -p "$app/usr/bin"
cp "$bin/solvecraft" "$bin/solvecraft-cli" "$app/usr/bin/"
"$bin/solvecraft-cli" snapshot examples/bracket.json --out "$app/solvecraft.png" --width 256 --height 256 >/dev/null
cat > "$app/solvecraft.desktop" <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=SolveCraft
Comment=Parametric 3D CAD
Exec=solvecraft %f
Icon=solvecraft
Categories=Graphics;Engineering;
MimeType=model/step;
DESKTOP
cat > "$app/AppRun" <<'APPRUN'
#!/bin/sh
here="$(dirname "$(readlink -f "$0")")"
exec "$here/usr/bin/solvecraft" "$@"
APPRUN
chmod +x "$app/AppRun"
tool=stage/appimagetool
curl -sSfL -o "$tool" https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x "$tool"
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$tool" --no-appstream "$app" "out/SolveCraft-$tag-x86_64.AppImage"
ls -l out
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bash
# Package the macOS release build: a universal (arm64 + x86_64) SolveCraft.app and CLI in one zip.
# Usage: .github/release/package-macos.sh <tag> (from the repository root, after building both
# aarch64-apple-darwin and x86_64-apple-darwin). Writes out/. The app is not signed.
set -euo pipefail
tag=${1:?usage: package-macos.sh <tag>}
version=${tag#v}
t=${CARGO_TARGET_DIR:-target}
name="solvecraft-$tag-macos-universal"
rm -rf stage out && mkdir -p "stage/$name" out
app="stage/$name/SolveCraft.app"
mkdir -p "$app/Contents/MacOS"
lipo -create -output "$app/Contents/MacOS/solvecraft" "$t/aarch64-apple-darwin/release/solvecraft" "$t/x86_64-apple-darwin/release/solvecraft"
lipo -create -output "stage/$name/solvecraft-cli" "$t/aarch64-apple-darwin/release/solvecraft-cli" "$t/x86_64-apple-darwin/release/solvecraft-cli"
cat > "$app/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>SolveCraft</string>
<key>CFBundleDisplayName</key><string>SolveCraft</string>
<key>CFBundleIdentifier</key><string>io.github.bherbruck.solvecraft</string>
<key>CFBundleExecutable</key><string>solvecraft</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleVersion</key><string>$version</string>
<key>CFBundleShortVersionString</key><string>$version</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
PLIST
cp README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
lipo -info "$app/Contents/MacOS/solvecraft"
(cd stage && ditto -c -k --keepParent "$name" "../out/$name.zip")
ls -l out
+174 -8
View File
@@ -1,10 +1,18 @@
# Every pull request must pass `cargo xtask ci`: fmt, clippy -D warnings, tests in release mode,
# the asset and layer checks, and the wasm build check.
# Fast checks always run; native builds run for code/CI changes.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
name: CI
on:
pull_request:
workflow_dispatch:
inputs:
cross_compile:
description: "Also check WebAssembly and RISC-V (before a release)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
@@ -14,14 +22,172 @@ env:
CARGO_TERM_COLOR: always
jobs:
ci:
runs-on: ubuntu-latest
changes:
name: Select checks
runs-on: ubuntu-24.04
outputs:
native: ${{ steps.paths.outputs.native }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: paths
name: Detect code and CI changes
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
native=false
if [ "$EVENT_NAME" = workflow_dispatch ]; then
native=true
else
# Compare both old and new paths of renames; unknown paths run native checks.
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
while IFS= read -r -d '' path; do
case "$path" in
.github/workflows/ci.yml) native=true ;;
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
*) native=true ;;
esac
done < "$RUNNER_TEMP/ci-paths"
fi
echo "native=$native" >> "$GITHUB_OUTPUT"
checks:
name: Format and repository checks
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: repo-checks
- name: Format
run: cargo fmt --all -- --check
- name: Asset attribution
run: cargo xtask assets
- name: Licences
run: cargo xtask licences --check
- name: Dependency layers
run: cargo xtask layers
clippy:
name: Native Linux Clippy
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-clippy
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
tests:
name: Native Linux tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
- name: Compile tests
run: cargo test --workspace --profile ci --locked --no-run
- name: Run tests
run: cargo test --workspace --profile ci --locked
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, tests]
if: always()
runs-on: ubuntu-24.04
steps:
- name: Verify required checks
env:
CHANGES: ${{ needs.changes.result }}
CHECKS: ${{ needs.checks.result }}
NATIVE: ${{ needs.changes.outputs.native }}
CLIPPY: ${{ needs.clippy.result }}
TESTS: ${{ needs.tests.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$TESTS" = success
fi
wasm:
name: Compile WebAssembly (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo xtask ci
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: wasm
- run: cargo xtask wasm
riscv64-gates:
name: Compile RISC-V (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: riscv64
- name: Install cross-compilation dependencies
run: |
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
# The 24.04 runner uses deb822 sources, so scope every Ubuntu stanza.
sudo sed -i '/^Architectures:/d; /^Types:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
sudo tee /etc/apt/sources.list.d/riscv64.sources >/dev/null <<'EOF'
Types: deb
URIs: http://ports.ubuntu.com/ubuntu-ports
Suites: noble noble-updates noble-backports noble-security
Components: main restricted universe multiverse
Architectures: riscv64
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
sudo dpkg --add-architecture riscv64
sudo apt-get update
sudo apt-get install -y gcc-riscv64-linux-gnu libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \
libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Cargo check on RISC-V
env:
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: 1
run: |
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
+162
View File
@@ -0,0 +1,162 @@
name: Publish Flatpak repository to Pages
on:
release:
types: [published]
workflow_dispatch:
inputs:
release_tag:
description: "Published GitHub release tag to publish (for example, v1.2.3)"
required: true
type: string
permissions:
contents: read
concurrency:
group: flatpak-pages-${{ github.repository }}
cancel-in-progress: false
jobs:
build:
name: Build Flatpak repository
runs-on: ubuntu-24.04
permissions:
contents: read
pages: write
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
APP_ID: ai.storyteller.solvecraft
steps:
- name: Configure Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download and validate release bundles
shell: bash
run: |
set -euo pipefail
tag="$RELEASE_TAG"
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
exit 1
fi
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
'select(.draft == false and .published_at != null) | .tag_name')"
if [[ "$published_tag" != "$tag" ]]; then
echo "::error::$tag is not a published release in $GITHUB_REPOSITORY"
exit 1
fi
version="${tag#v}"
mkdir -p bundles
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern SHA256SUMS.txt --dir bundles
for arch in x86_64 aarch64; do
asset="solvecraft-$version-linux-$arch.flatpak"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset" --dir bundles
test -s "bundles/$asset"
(
cd bundles
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
SHA256SUMS.txt | sha256sum --check -
)
done
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
for arch in x86_64 aarch64; do
asset="bundles/solvecraft-$version-linux-$arch.flatpak"
validation_repo="$(mktemp -d)"
ostree --repo="$validation_repo" init --mode=archive-z2
flatpak build-import-bundle "$validation_repo" "$asset"
expected_ref="app/$APP_ID/$arch/stable"
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
echo "::error::$(basename "$asset") does not contain $expected_ref"
exit 1
}
rm -rf "$validation_repo"
done
- name: Build repository and site
shell: bash
env:
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: |
set -euo pipefail
site="$RUNNER_TEMP/site"
repo_dir="$site/flatpak/repo"
mkdir -p "$repo_dir"
ostree --repo="$repo_dir" init --mode=archive-z2
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
if curl --fail --silent --show-error --location \
--output /dev/null "$previous_url/summary"; then
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
ostree --repo="$repo_dir" pull --mirror previous
ostree --repo="$repo_dir" remote delete previous
fi
version="${RELEASE_TAG#v}"
for arch in x86_64 aarch64; do
flatpak build-import-bundle "$repo_dir" \
"bundles/solvecraft-$version-linux-$arch.flatpak"
done
flatpak build-update-repo \
--title="SolveCraft" \
--comment='Official SolveCraft releases' \
--description='Official Flatpak repository for SolveCraft' \
--homepage="https://github.com/$GITHUB_REPOSITORY" \
--default-branch=stable \
"$repo_dir"
for arch in x86_64 aarch64; do
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
done
test -s "$repo_dir/summary"
base_url="${PAGES_BASE_URL%/}"
repo_url="$base_url/flatpak/repo/"
cat >"$site/flatpak/solvecraft.flatpakrepo" <<META
[Flatpak Repo]
Title=SolveCraft
Comment=Official SolveCraft releases
Description=Official Flatpak repository for SolveCraft
Url=$repo_url
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft Flatpak repository</title></head><body><main><h1>SolveCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolveCraft repository</a></p><pre>flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
flatpak install solvecraft $APP_ID</pre></main></body></html>
HTML
cat >"$site/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft downloads</title></head><body><main><h1>SolveCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
HTML
touch "$site/.nojekyll"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ${{ runner.temp }}/site
include-hidden-files: true
deploy:
name: Deploy Pages
needs: build
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+53
View File
@@ -0,0 +1,53 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, the
# packaging shell and PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
# Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
name: Packaging lint
on:
push:
branches: [main]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: packaging-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: '"$RUNNER_TEMP/actionlint" -color'
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- 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*
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'"
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Install mdbook and trunk
run: |
mkdir -p "$HOME/.local/bin"
+383 -95
View File
@@ -1,136 +1,424 @@
# Release builds: push a tag `v*` (or run it by hand with a tag name) and this builds the desktop
# app and the CLI for Linux, Windows and macOS plus the web app, and attaches them to a GitHub
# release (a pre-release while the version is 0.x) with SHA256SUMS. Notes:
# .github/release-notes.md.
# Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
#
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
# 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
# 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
#
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
name: Release
on:
push:
tags: ["v*"]
branches: [release]
workflow_dispatch:
inputs:
tag:
description: "Tag to release (must exist), e.g. v0.1.0"
required: true
draft:
description: "Create/update a draft (release branch only; off: build artifacts only)"
type: boolean
default: false
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
TAG: ${{ inputs.tag || github.ref_name }}
TRUNK_VERSION: v0.21.14
CARGO_INCREMENTAL: 0
jobs:
linux:
runs-on: ubuntu-22.04 # an older glibc, so the binaries run on older distributions
timeout-minutes: 60
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --release --locked -p solvecraft -p solvecraft-cli
- name: Package
run: .github/release/package-linux.sh "$TAG"
- uses: actions/upload-artifact@v4
with:
name: linux
path: out/*
windows:
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --release --locked -p solvecraft -p solvecraft-cli
- name: Package
shell: pwsh
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
env:
REQUEST_DRAFT: ${{ inputs.draft || false }}
run: |
$name = "solvecraft-$env:TAG-windows-x86_64"
New-Item -ItemType Directory -Force -Path "stage/$name", out | Out-Null
Copy-Item target/release/solvecraft.exe, target/release/solvecraft-cli.exe, README.md, LICENSE-MIT, LICENSE-APACHE, NOTICE, ATTRIBUTION.md "stage/$name/"
Compress-Archive -Path "stage/$name" -DestinationPath "out/$name.zip"
- uses: actions/upload-artifact@v4
. packaging/env.sh
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then
echo "::error::Draft releases must be built from the release branch"
exit 1
fi
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolveCraft 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
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
name: windows
path: out/*
key: release-icons
- run: cargo build --release --locked -p solvecraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solvecraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { 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 }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends desktop-file-utils appstream zsync rpm
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- 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'
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
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
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder than 22.04 ships.
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:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_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 flatpak-builder
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# 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"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
runs-on: ubuntu-24.04
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- name: Install cross-compilation dependencies
run: |
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
# The 24.04 runner uses deb822 sources, so scope every Ubuntu stanza.
sudo sed -i '/^Architectures:/d; /^Types:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
sudo tee /etc/apt/sources.list.d/riscv64.sources >/dev/null <<'EOF'
Types: deb
URIs: http://ports.ubuntu.com/ubuntu-ports
Suites: noble noble-updates noble-backports noble-security
Components: main restricted universe multiverse
Architectures: riscv64
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
sudo dpkg --add-architecture riscv64
sudo apt-get update
sudo apt-get install -y gcc-riscv64-linux-gnu libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \
libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Build and package
env:
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: 1
run: |
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
export CROSS_ARCH=riscv64
export CROSS_TARGET=riscv64gc-unknown-linux-gnu
export DEB_ARCH=riscv64
# We skip nfpm (deb/rpm) and AppImage for now on riscv64
bash packaging/linux/package.sh --formats "tar"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest
timeout-minutes: 180
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "SOLVECRAFT_VERSION SOLVECRAFT_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
run: |
set -e
. "$HOME/.cargo/env"
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
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$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
macos:
# One universal build: arm64 natively, x86_64 cross-compiled on the same runner, joined by lipo.
name: macOS universal
needs: version
runs-on: macos-14
timeout-minutes: 90
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
- run: |
cargo build --release --locked -p solvecraft -p solvecraft-cli --target aarch64-apple-darwin
cargo build --release --locked -p solvecraft -p solvecraft-cli --target x86_64-apple-darwin
- name: Package
run: .github/release/package-macos.sh "$TAG"
- uses: actions/upload-artifact@v4
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
name: macos
path: out/*
key: release-macos-universal
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
timeout-minutes: 60
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
run: |
mkdir -p "$HOME/.local/bin"
curl -sSfL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build (relative public URL, so it can be served from any path)
working-directory: apps/solvecraft-web
run: trunk build --release
- name: Package
run: |
name="solvecraft-$TAG-web"
mkdir -p out && cp -r dist/web "$name"
cp LICENSE-MIT LICENSE-APACHE NOTICE "$name/"
zip -qr "out/$name.zip" "$name"
- uses: actions/upload-artifact@v4
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: web
path: out/*
name: release-web
path: dist/release/*
if-no-files-found: error
release:
needs: [linux, windows, macos, web]
name: Draft GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, freebsd, macos, windows, web]
runs-on: ubuntu-latest
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
ref: ${{ env.TAG }}
- uses: actions/download-artifact@v4
with:
path: assets
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: assets
run: sha256sum * > SHA256SUMS && cat SHA256SUMS
- uses: softprops/action-gh-release@v2
- name: Checksums and notes
run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
- name: Create or update the draft release
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
working-directory: artifacts
run: |
tag="v$VERSION"
# Refuse existing tags: a draft must point at this build, never an older tag.
# matching-refs returns [] for an absent tag and fails on API/auth errors.
existing_refs="$(gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" \
--jq '.[].ref')"
if printf '%s\n' "$existing_refs" | grep -Fxq "refs/tags/$tag"; then
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
exit 1
fi
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != true ]; then
echo "::error::Release $tag is already published. Bump the version before releasing."
exit 1
fi
# Replace the complete draft asset set, including assets removed in this build.
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
while IFS= read -r asset; do
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
done <<<"$assets"
gh release edit "$tag" --draft --title "SolveCraft v$VERSION" \
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
gh release create "$tag" --draft --title "SolveCraft v$VERSION" \
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$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
- if: github.event_name == 'workflow_dispatch' && !inputs.draft
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
tag_name: ${{ env.TAG }}
name: SolveCraft ${{ env.TAG }}
body_path: .github/release-notes.md
prerelease: ${{ startsWith(env.TAG, 'v0.') }}
files: assets/*
name: release-assets
path: artifacts/*
+9
View File
@@ -71,6 +71,15 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
version bumps solely from commit prefixes, overwrite published versions, or push release tags
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
+2
View File
@@ -18,6 +18,7 @@ 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/demo.gif | SolveCraft contributors | screen recording of SolveCraft itself driven by examples/demo (3x speed) | 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 |
@@ -39,6 +40,7 @@ Generated-in-code assets are original and have no file to list:
| 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 |
| 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 |
## Vendored third-party code
+9
View File
@@ -71,6 +71,15 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
version bumps solely from commit prefixes, overwrite published versions, or push release tags
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
+8 -5
View File
@@ -18,7 +18,7 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
```sh
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo test --workspace --release # unit, engine and UI-scenario tests
cargo test --workspace --profile ci # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
@@ -59,7 +59,8 @@ The [README](README.md#architecture) has the layer table. The short version:
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolveCraft is
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
dependencies are fine. Note which one you're adding and why in the PR.
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
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
`cargo xtask assets` enforces this.
@@ -80,7 +81,7 @@ the most complete description of the house style.
| What | Where | Run |
|---|---|---|
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo test --workspace --release` |
| Unit and engine tests | next to the code, or `*_tests.rs` files | `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) |
@@ -101,8 +102,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.
**Release mode matters.** CI runs the tests with `--release`. truck has debug-only assertions
that can fail on valid geometry, so if a test fails only in debug, say so in the PR.
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
in the PR.
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
Generated
+14 -13
View File
@@ -3059,7 +3059,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"eframe",
"egui",
@@ -3068,11 +3068,12 @@ dependencies = [
"serde_json",
"solvecraft-engine",
"solvecraft-ui-egui",
"winit",
]
[[package]]
name = "solvecraft-cli"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3081,7 +3082,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"log",
"serde",
@@ -3095,7 +3096,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"log",
"serde",
@@ -3111,7 +3112,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"serde",
"serde_json",
@@ -3119,7 +3120,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3131,7 +3132,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"log",
"serde",
@@ -3146,7 +3147,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3154,7 +3155,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"image",
"serde",
@@ -3163,7 +3164,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"serde",
"serde_json",
@@ -3174,7 +3175,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"egui",
"egui-wgpu",
@@ -3188,7 +3189,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"eframe",
"egui",
@@ -4487,7 +4488,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"serde_json",
]
+16 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.1.0"
version = "0.1.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -72,6 +72,21 @@ opt-level = 2
[profile.dev]
opt-level = 1
# Tests and lints (`cargo xtask ci`, GitHub CI): quick to build, quick enough to run. Light
# optimisation, line tables only (file:line in backtraces), no LTO. Debug assertions are off because
# truck's debug-only checks reject valid geometry (seam edges, closed edges); that was the only
# reason the tests used to build with --release, which cost most of CI's time.
[profile.ci]
inherits = "dev"
opt-level = 1
debug = "line-tables-only"
debug-assertions = false
overflow-checks = true
incremental = false
[profile.ci.package."*"]
opt-level = 2
[profile.release]
opt-level = 3
lto = "thin"
+5 -1
View File
@@ -5,7 +5,9 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
Third-party material
Software dependencies are listed in Cargo.lock. The geometry kernel builds on the truck crates
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
(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
@@ -14,6 +16,8 @@ vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
Some code is adapted from sibling projects in the storytold family (CADCraft, GridCraft), which are
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.
+9 -6
View File
@@ -1,12 +1,15 @@
# SolveCraft
SolveCraft is an open-source parametric 3D CAD application written in Rust, in the style of
Autodesk Fusion: sketch on a plane, constrain and dimension the sketch, turn profiles into solids
with extrude and revolve, round edges, combine bodies, and change a parameter to watch the whole
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
storytold "craft" family.
**Parametric 3D CAD; an open-source, [clean-room](CLAUDE.md) reimplementation of Autodesk
Fusion 360, built in pure Rust.**
![SolveCraft with the sample plate](docs/screenshots/sample-plate.png)
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.
![An AI agent builds a parametric enclosure in SolveCraft over MCP, then the width is changed in the Parameters dialog and the model rebuilds (3x speed)](docs/screenshots/demo.gif)
*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).*
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
//! `solvecraft-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.
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read};
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;
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)
}
/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr).
pub fn one(args: &[String]) -> Result<(), String> {
let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one <seed> [--steps N] [--minimise]")?;
let steps = flag(args, "--steps", 8usize);
let minimise = args.iter().any(|a| a == "--minimise");
// Panics caught inside the kernel: harmless natively, fatal in the web build.
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
std::panic::set_hook(Box::new(|_| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}));
eprintln!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
let mut design = json!({
"outcome": run.outcome,
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
"unsupported": run.unsupported,
"rejected": run.rejected,
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
});
if run.outcome.is_bug() {
design["script"] = run.script();
if minimise {
eprintln!("minimise");
let m = fuzz::minimise(&run);
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);
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);
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
Ok(())
}
struct Job {
seed: u64,
child: std::process::Child,
started: Instant,
err: std::sync::mpsc::Receiver<String>,
last: String,
}
/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each;
/// write failing scripts to `--out` and a summary to `--report`.
pub fn run(args: &[String]) -> Result<(), String> {
let from = flag(args, "--from", 0u64);
let count = flag(args, "--count", 100u64);
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
let steps = flag(args, "--steps", 8usize);
let out_dir = args.iter().position(|a| a == "--out").and_then(|i| args.get(i + 1)).cloned();
let report = args.iter().position(|a| a == "--report").and_then(|i| args.get(i + 1)).cloned();
let minimise = args.iter().any(|a| a == "--minimise");
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
if let Some(d) = &out_dir {
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
}
let mut next = from;
let mut running: Vec<Job> = Vec::new();
let mut results: Vec<Value> = Vec::new();
let t0 = Instant::now();
while next < from + count || !running.is_empty() {
while running.len() < jobs && next < from + count {
let mut cmd = Command::new(&exe);
cmd.args(["fuzz-one", &next.to_string(), "--steps", &steps.to_string()]);
if minimise {
cmd.arg("--minimise");
}
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
let (tx, rx) = std::sync::mpsc::channel();
if let Some(err) = child.stderr.take() {
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
}
running.push(Job { seed: next, child, started: Instant::now(), err: rx, last: String::new() });
next += 1;
}
std::thread::sleep(Duration::from_millis(50));
let mut i = 0;
while i < running.len() {
let Some(j) = running.get_mut(i) else { break };
while let Ok(l) = j.err.try_recv() {
if l.starts_with("step") || l.starts_with("boolean") || l.starts_with("minimise") {
j.last = l;
}
}
let done = match j.child.try_wait() {
Ok(Some(status)) => {
let mut s = String::new();
if let Some(mut o) = j.child.stdout.take() {
let _ = o.read_to_string(&mut s);
}
Some(match serde_json::from_str::<Value>(s.trim()) {
Ok(v) => v,
Err(_) => json!({"seed": j.seed, "crash": format!("exit {status}"), "at": j.last}),
})
}
Ok(None) if j.started.elapsed() > timeout => {
let _ = j.child.kill();
let _ = j.child.wait();
Some(json!({"seed": j.seed, "timeout": timeout.as_secs(), "at": j.last}))
}
Ok(None) => None,
Err(e) => Some(json!({"seed": j.seed, "crash": e.to_string(), "at": j.last})),
};
match done {
Some(v) => {
record(&v, out_dir.as_deref());
results.push(v);
running.swap_remove(i);
}
None => i += 1,
}
}
}
let summary = summarise(&results, t0.elapsed());
println!("{summary}");
if let Some(r) = report {
std::fs::write(&r, &summary).map_err(|e| format!("{r}: {e}"))?;
}
Ok(())
}
/// Print a failure as it comes in and keep its script.
fn record(v: &Value, out_dir: Option<&str>) {
let seed = v["seed"].as_u64().unwrap_or(0);
let d = &v["design"];
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
}
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
if bk != "ok" {
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
}
if let Some(dir) = out_dir {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(format!("{dir}/results.jsonl")) {
let _ = writeln!(f, "{v}");
}
if bug {
let script = if d.get("minimal").is_some() { &d["minimal"]["script"] } else { &d["script"] };
let _ = std::fs::write(format!("{dir}/design-{seed}.json"), serde_json::to_string_pretty(script).unwrap_or_default());
}
if bk != "ok" {
let _ = std::fs::write(format!("{dir}/boolean-{seed}.json"), serde_json::to_string_pretty(&v["boolean"]).unwrap_or_default());
}
}
}
fn summarise(results: &[Value], took: Duration) -> String {
let n = results.len().max(1);
let mut design: BTreeMap<String, usize> = BTreeMap::new();
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 steps = 0;
let (mut dp, mut bp) = (0, 0);
for v in results {
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() {
"timeout".to_string()
} else if v.get("crash").is_some() {
"crash".to_string()
} else {
v["design"]["outcome"]["kind"].as_str().unwrap_or("?").to_string()
};
if k != "ok" {
let last = v["design"]["steps"].as_array().and_then(|a| a.last()).and_then(Value::as_str).unwrap_or("?");
*at_step.entry(format!("{k} at {last}")).or_default() += 1;
}
*design.entry(k).or_default() += 1;
*boolean.entry(v["boolean"]["outcome"].as_str().unwrap_or("not run").to_string()).or_default() += 1;
steps += v["design"]["steps"].as_array().map(Vec::len).unwrap_or(0);
for u in v["design"]["unsupported"].as_array().into_iter().flatten() {
*unsupported.entry(u[0].as_str().unwrap_or("?").to_string()).or_default() += 1;
}
}
let pct = |c: usize| format!("{c} ({:.1} %)", 100.0 * c as f64 / n as f64);
let mut s = format!("{} seeds, {steps} design steps, {:.0} s\n\nDesigns:\n", results.len(), took.as_secs_f64());
for (k, c) in &design {
s += &format!("- {k}: {}\n", pct(*c));
}
s += "\nFailures by step:\n";
for (k, c) in &at_step {
s += &format!("- {k}: {c}\n");
}
s += "\nSteps refused as not supported (undone, the design went on):\n";
for (k, c) in &unsupported {
s += &format!("- {k}: {c}\n");
}
s += &format!("\nCaught panics (fatal in the web build): designs {}, boolean checks {}\n", pct(dp), pct(bp));
s += "\nBoolean identity (union, intersection, cut of two primitives):\n";
for (k, c) in &boolean {
s += &format!("- {k}: {}\n", pct(*c));
}
s
}
+79 -1
View File
@@ -10,10 +10,13 @@
//! 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)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod fuzz;
mod oracle;
mod recipe;
mod seams;
@@ -33,8 +36,11 @@ const USAGE: &str = "usage:
solvecraft-cli commands
solvecraft-cli recipe <recipe.json>
solvecraft-cli oracle <case-dir>... [--json]
solvecraft-cli step-corpus <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
";
fn main() -> ExitCode {
@@ -54,7 +60,16 @@ fn main() -> ExitCode {
}),
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("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());
Ok(())
}
// Hidden, for the kill-mid-save test: save a large design over and over.
Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md).
@@ -196,6 +211,69 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
Ok(())
}
/// The app icon: a blue block with a bore, drawn by the CPU renderer on a transparent background
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
fn cmd_icon(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
let size: usize = flag(args, "--size").and_then(|x| x.parse().ok()).unwrap_or(256).clamp(8, 2048);
let bytes = if out.to_ascii_lowercase().ends_with(".ico") {
let pngs: Vec<(usize, Vec<u8>)> =
[16, 20, 24, 32, 40, 48, 64, 128, 256].into_iter().map(|s| icon_png(s).map(|p| (s, p))).collect::<Result<_, _>>()?;
ico(&pngs)?
} else {
icon_png(size)?
};
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "bytes": bytes.len()})));
Ok(())
}
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
use solvecraft_engine::render::{Rgb, Scene, SceneLine, SceneMesh, png, render_transparent};
let mut s = Session::default();
for (cmd, p) in [
("solid.box", json!({"length": 20, "width": 20, "height": 20, "center": [0, 0, 0]})),
("solid.cylinder", json!({"diameter": 9, "height": 30, "base": [0, 0, -15], "operation": "cut"})),
] {
s.execute(cmd, &p).map_err(|e| format!("icon: {e}"))?;
}
let mut cam = solvecraft_engine::view::home_camera(&s);
cam.distance *= 0.95;
let mut scene = Scene { radius: 60.0, ..Default::default() };
let px = size as f64 / 256.0;
for body in &s.world_state().bodies {
let mesh = body.mesh();
scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240), face_colors: Vec::new() });
for (i, e) in mesh.edges.iter().enumerate() {
if !mesh.seams.get(i).copied().unwrap_or(false) {
scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false });
}
}
}
let c = render_transparent(&mut scene, &cam, size, size);
png(c.w, c.h, c.rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
}
/// A Windows icon file holding PNG images (Vista and later read these).
fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
let n = u16::try_from(images.len()).map_err(|_| "too many icon sizes")?;
let mut out = Vec::new();
out.extend_from_slice(&[0, 0, 1, 0]);
out.extend_from_slice(&n.to_le_bytes());
let mut offset = 6 + 16 * images.len();
for (size, data) in images {
let dim = if *size >= 256 { 0 } else { *size as u8 };
out.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
out.extend_from_slice(&u32::try_from(data.len()).map_err(|_| "icon too large")?.to_le_bytes());
out.extend_from_slice(&u32::try_from(offset).map_err(|_| "icon too large")?.to_le_bytes());
offset += data.len();
}
for (_, data) in images {
out.extend_from_slice(data);
}
Ok(out)
}
fn cmd_exec(args: &[String]) -> Result<(), String> {
let id = args.first().ok_or_else(|| USAGE.to_string())?;
let p: Value = match args.get(1) {
+13 -4
View File
@@ -170,9 +170,10 @@ 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.
fn check_step_case(dir: &str) -> Value {
/// 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}/part.step");
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}),
@@ -191,7 +192,14 @@ fn check_step_case(dir: &str) -> Value {
// 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| solvecraft_engine::kernel::step_import_shared(&String::from_utf8_lossy(&b)).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;
@@ -221,7 +229,8 @@ pub fn step_corpus(args: &[String]) -> Result<(), String> {
if dirs.is_empty() {
return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into());
}
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d)).collect();
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(()) }
+1
View File
@@ -29,4 +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",
] }
+23 -1
View File
@@ -27,6 +27,25 @@ mod web {
/// Seconds between autosaves of unsaved work.
const AUTOSAVE_S: f64 = 30.0;
thread_local! {
/// An open design has unsaved changes: leaving the page asks first.
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
/// unsaved changes (the page can't show its own dialog when it is being closed).
fn ask_before_unload(window: &web_sys::Window) {
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
if UNSAVED.with(std::cell::Cell::get) {
e.prevent_default();
e.set_return_value("unsaved");
}
});
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_unload.forget();
}
struct Shell {
app: SolveApp,
files: Rc<Files>,
@@ -291,6 +310,7 @@ mod web {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.app.logic(ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solvecraft_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);
@@ -317,7 +337,9 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
let Some(document) = window.document() else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
+3
View File
@@ -18,3 +18,6 @@ egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+18
View File
@@ -21,6 +21,12 @@ struct App(SolveApp);
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.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solvecraft_ui_egui::documents::request_quit(&mut self.0);
}
self.0.logic(ctx);
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -126,6 +132,18 @@ fn main() -> eframe::Result {
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(
"SolveCraft",
options,
+5 -1
View File
@@ -148,7 +148,11 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
*at = apply_point(m, *at);
map_geo_ref(m, face);
}
PlaneRef::Midplane { .. } | PlaneRef::TwoEdges { .. } | PlaneRef::ThreePoints { .. } | PlaneRef::AlongPath { .. } => {
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::AlongPath { .. }
| PlaneRef::Perpendicular { .. } => {
for r in p.refs_mut() {
map_geo_ref(m, r);
}
+10
View File
@@ -513,6 +513,15 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
let (on, n) = surface_normal(s, *at).ok_or_else(|| bad("the point is on the face's axis"))?;
Plane::from_normal(on, n).ok_or_else(degenerate)
}
PlaneRef::Perpendicular { plane: base, line: l } => {
let pl = plane(ctx, base)?;
let (o, d) = line(ctx, l)?;
let n = d
.cross(pl.normal())
.normalized()
.ok_or_else(|| bad("the line is square to the plane: every plane through it is perpendicular; pick a line in or along the plane"))?;
Plane::new(o, d, n.cross(d)).ok_or_else(degenerate)
}
PlaneRef::AlongPath { path, t } => {
let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?;
let (p, tan) = along(&path_points(ctx, path)?, t).ok_or_else(|| bad("the path has no length"))?;
@@ -621,6 +630,7 @@ impl PlaneRef {
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => vec![a.as_mut(), b.as_mut()],
PlaneRef::Perpendicular { plane, line } => vec![plane.as_mut(), line.as_mut()],
PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()],
PlaneRef::Tangent { face, .. } => vec![face.as_mut()],
PlaneRef::AlongPath { path, .. } => vec![path.as_mut()],
+30 -4
View File
@@ -54,6 +54,8 @@ pub enum PlaneRef {
Tangent { face: Box<GeoRef>, at: Vec3 },
/// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it.
AlongPath { path: Box<GeoRef>, t: String },
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
Perpendicular { plane: Box<GeoRef>, line: Box<GeoRef> },
/// A planar body face: found again on each evaluation (the face with this plane's normal
/// nearest `at`), so sketches on it follow when earlier features change; `plane` is the
/// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's
@@ -771,8 +773,16 @@ pub enum FilletStyle {
Constant,
/// `radius` is the chord: the width across the blend.
Chord,
/// `radius` at the end of each edge nearest `start`, `radius2` at the other, linear between.
Variable { radius2: String, start: Vec3 },
/// `radius` at the end of each edge nearest `start`, `radius2` at the other; `mid` radii at
/// positions between (0 to 1 from the start end); straight between them, or `smooth`.
Variable {
radius2: String,
start: Vec3,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
mid: Vec<(f64, String)>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
smooth: bool,
},
}
impl FilletStyle {
@@ -1078,6 +1088,20 @@ impl FeatureKind {
/// Every expression the feature uses.
/// Points on faces the feature refers to (shell, draft, offset and replace faces, a hole's
/// placement), for persistent face names.
/// The feature's profile selections, each with its sketch.
pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> {
match self {
FeatureKind::Extrude { sketch, profiles, .. }
| FeatureKind::Revolve { sketch, profiles, .. }
| FeatureKind::Sweep { sketch, profiles, .. }
| FeatureKind::Emboss { sketch, profiles, .. }
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sk), profiles, .. } => vec![(*sk, profiles)],
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
_ => Vec::new(),
}
}
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
match self {
FeatureKind::Shell { faces, .. }
@@ -1132,8 +1156,9 @@ impl FeatureKind {
}
FeatureKind::Fillet { radius, style, .. } => {
v.push(radius);
if let FilletStyle::Variable { radius2, .. } = style {
if let FilletStyle::Variable { radius2, mid, .. } = style {
v.push(radius2);
v.extend(mid.iter().map(|(_, r)| r.as_str()));
}
}
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
@@ -1794,7 +1819,8 @@ impl Document {
| PlaneRef::TwoEdges { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::Tangent { .. }
| PlaneRef::AlongPath { .. } => {
| PlaneRef::AlongPath { .. }
| PlaneRef::Perpendicular { .. } => {
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st }, p)
}
+34 -6
View File
@@ -283,6 +283,12 @@ impl ModelState {
(key, &b.name).hash(&mut h);
b.key = h.finish();
b.names = crate::naming::NameCell::new(Some(origin.clone()));
// Faces split on purpose stay split through later features (for export).
if b.body.split_lines().is_none()
&& let Some(lines) = before.bodies.iter().find(|x| x.name == b.name).and_then(|x| x.body.split_lines())
{
b.body = b.body.clone().with_split_lines(Some(lines.clone()));
}
// An imported look outlives the operations on the body.
if b.body.paint().is_none()
&& b.body.color().is_none()
@@ -1942,11 +1948,29 @@ fn path_at(segs: &[kernel::PathSeg], d: f64) -> (Vec3, Vec3) {
}
/// Apply transformed copies of the tools of `features` (patterns, mirrors).
fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, features: &[String], mats: &[Mat]) -> Result<()> {
fn replay(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &mut ModelState,
features: &[String],
mats: &[Mat],
warning: &mut Option<String>,
) -> Result<()> {
if features.is_empty() {
return Err(DocError::Invalid("no features selected".into()));
}
for name in features {
// A suppressed feature has nothing to copy: its copies go with it.
let suppressed: Vec<&String> = features.iter().filter(|n| doc.find_feature(n).is_some_and(|x| x.suppressed)).collect();
if !suppressed.is_empty() {
let list = suppressed.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ");
*warning = Some(if suppressed.len() == features.len() {
format!("nothing to copy: {list} suppressed")
} else {
format!("{list} suppressed and not copied")
});
}
for name in features.iter().filter(|n| !suppressed.contains(n)) {
// A sketch and its extrude often share a name; the feature that makes geometry wins.
let src = doc
.features
@@ -2509,7 +2533,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Pattern { features, pattern, bodies } => {
let mats = pattern_transforms(vals, st, pattern)?;
if bodies.is_empty() {
return replay(doc, vals, f, st, features, &mats);
return replay(doc, vals, f, st, features, &mats, warning);
}
// Bodies: each copy is a new body named after its source.
for n in bodies {
@@ -2525,7 +2549,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Mirror { features, plane, bodies, combine } => {
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
if bodies.is_empty() {
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)]);
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)], warning);
}
let m = mirror_matrix(&pl);
for n in bodies {
@@ -2596,11 +2620,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
};
let nb = match &f.kind {
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => kernel::fillet_chord(&mb.body, &edges, r)?,
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start }, .. } => {
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => {
let r2 = val(vals, radius2, Kind::Length)?;
let mut pts = vec![(0.0, r), (1.0, r2)];
for (t, e) in mid {
pts.push((*t, val(vals, e, Kind::Length)?));
}
let mut b = mb.body.clone();
for p in &edges {
b = kernel::fillet_variable(&b, *p, *start, r, r2)?;
b = kernel::fillet_variable_points(&b, *p, *start, &pts, *smooth)?;
}
b
}
+20 -3
View File
@@ -783,11 +783,28 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
return Err(DocError::Invalid("pick the rim face of a shelled body (a face with an inner and an outer edge)".into()));
};
let bw = if *groove { w + g } else { w };
// The band along the chosen edge, inside the rim face.
// A lip: the band raised from the rim's own faces (its walls continue the walls
// below, which a join can't do: they would coincide).
if !*groove
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
{
*slot = ModelBody::new(target.clone(), b, slot.feature);
return Ok(());
}
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
// the edge into the free side (the cavity, or outside), so no wall coincides.
let free = if *groove { bw } else { 0.0 };
let band = if *outside {
Region2 { outer: Loop2::polygon(&outer).ccw(), holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()] }
Region2 {
outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(),
holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()],
}
} else {
Region2 { outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(), holes: vec![Loop2::polygon(&inner).ccw().reversed()] }
Region2 {
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
}
};
let delta = (h * 0.05).clamp(1e-3, 0.5);
if *groove {
+2 -1
View File
@@ -75,9 +75,10 @@ impl FeatureKind {
FeatureKind::Fillet { radius, style, .. } => match style {
crate::FilletStyle::Constant => v.push(("Radius", radius, L)),
crate::FilletStyle::Chord => v.push(("Chord length", radius, L)),
crate::FilletStyle::Variable { radius2, .. } => {
crate::FilletStyle::Variable { radius2, mid, .. } => {
v.push(("Start radius", radius, L));
v.push(("End radius", radius2, L));
v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L)));
}
},
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
+17
View File
@@ -441,6 +441,23 @@ mod tests {
assert_eq!(s.doc.units, "mm");
}
#[test]
fn a_bare_width_keeps_its_inches_when_the_design_goes_metric() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "parameters.add", json!({"name": "w", "expression": "2", "unit": ""}));
run(&mut s, "solid.box", json!({"length": 10, "width": "w", "height": 5}));
let width = |s: &mut Session| {
let b = &run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
b["max"][1].as_f64().unwrap() - b["min"][1].as_f64().unwrap()
};
assert!((width(&mut s) - 50.8).abs() < 1e-6, "2 in an inch design");
let r = run(&mut s, "document.units", json!({"units": "mm"}));
assert!((width(&mut s) - 50.8).abs() < 1e-6, "still 50.8 mm: {r}");
assert_eq!(r["kept"], json!(["w"]));
assert_eq!(s.doc.param("w").map(|p| p.expr.as_str()), Some("2 in"));
}
#[test]
fn named_views_are_saved_renamed_and_deleted() {
let mut s = Session::default();
+9
View File
@@ -83,6 +83,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"),
CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane").params(
"plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane",
),
];
/// A reference from its JSON form.
@@ -249,3 +252,9 @@ fn plane_along(s: &mut Session, p: &Value) -> Result<Value> {
let t = t_param(s, p, cmd)?;
plane(s, p, PlaneRef::AlongPath { path: Box::new(path), t })
}
fn plane_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.perpendicular";
let (base, line) = (geo(s, p.get("plane"), cmd, "plane")?, geo(s, p.get("line"), cmd, "line")?);
plane(s, p, PlaneRef::Perpendicular { plane: Box::new(base), line: Box::new(line) })
}
+53 -2
View File
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]"),
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
@@ -209,6 +209,37 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
.ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)"))
}
/// Profile indices become the curve ids of each profile's loops as the sketch is now solved.
fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
let st = s.model.state();
'sels: for (sketch, sel) in kind.profile_sels_mut() {
let ProfileSel::Indices { indices } = sel else { continue };
let Some(ss) = st.sketch(sketch) else { continue };
let mut loops = Vec::with_capacity(indices.len());
for i in indices.iter() {
let pr = ss
.profiles
.get(*i)
.ok_or_else(|| EngineError::Other(format!("no profile {i} in sketch `{}` (it has {})", ss.name, ss.profiles.len())))?;
// Two profiles with the same outer curves (one inside the other's holes cannot be):
// keep the index then.
if pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1 {
continue 'sels;
}
loops.push(pr.outer_curves.clone());
}
*sel = ProfileSel::Curves { loops };
}
Ok(())
}
fn same_loop(a: &[String], b: &[String]) -> bool {
let (mut a, mut b) = (a.to_vec(), b.to_vec());
a.sort();
b.sort();
a == b
}
pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
match v {
@@ -270,6 +301,9 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
*e = doc.with_design_unit(e, k);
}
}
// Profiles picked by index are kept by the curves around them: a recompute can renumber a
// sketch's profiles (a face sketch's projected outline rebuilt), never the curves.
stable_profiles(s, &mut kind)?;
// Blends remember where their edges sat, to find them again after upstream edits.
let (refs, names) = match &kind {
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
@@ -570,7 +604,24 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
check_expr(s, &radius2, Kind::Length, cmd, "radius2")?;
let start =
p.get("start").and_then(vec3).ok_or_else(|| bad(cmd, "a variable fillet needs `start`: [x,y,z] near the end that takes `radius`"))?;
solvecraft_doc::FilletStyle::Variable { radius2, start }
let mut mid = Vec::new();
for m in p.get("mid").and_then(Value::as_array).into_iter().flatten().take(100) {
let (Some(t), Some(r)) = (m.get(0).and_then(Value::as_f64), m.get(1)) else {
return Err(bad(cmd, "`mid` entries are [position, radius]"));
};
if !(t.is_finite() && t > 0.0 && t < 1.0) {
return Err(bad(cmd, "`mid` positions lie between 0 and 1 (from the start end)"));
}
let r = match r {
Value::String(x) => x.clone(),
Value::Number(n) => n.to_string(),
_ => return Err(bad(cmd, "a `mid` radius is a number or an expression")),
};
check_expr(s, &r, Kind::Length, cmd, "mid radius")?;
mid.push((t, r));
}
let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false);
solvecraft_doc::FilletStyle::Variable { radius2, start, mid, smooth }
}
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))),
};
+2
View File
@@ -179,6 +179,8 @@ fn deepest(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, tol: f64) -> f6
/// Ray parity along a skew direction (no axis-aligned edge hits).
fn inside(p: Vec3, tris: &[[Vec3; 3]]) -> bool {
// Fixed ray coordinates, not an approximation used to compute Euler's constant.
#[allow(clippy::approx_constant)]
let dir = Vec3::new(0.577_215_664_9, 0.327_118_441_9, 0.751_891_438_7);
let mut n = 0;
for [a, b, c] in tris {
+39 -12
View File
@@ -8,11 +8,12 @@ use super::CommandSpec;
use crate::params::{bad, bool_, expr, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.section", "Section Analysis", section)
.at("SOLID", "INSPECT")
.icon("section")
.noundo()
.params("plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the normal; flip?: bool; or clear: true")];
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params(
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: -Y, YZ: +X); \
at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \
flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at",
)];
fn section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.section";
@@ -21,21 +22,34 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
s.revision += 1;
return Ok(json!({"section": null}));
}
let (origin, normal) = match p.get("plane") {
// A named plane's world axis: which component of a point lies along its normal.
let (origin, normal, axis) = match p.get("plane") {
Some(Value::String(n)) => {
let pl = Plane::named(n).ok_or_else(|| bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ)")))?;
(pl.origin, pl.normal())
let normal = pl.normal();
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
(pl.origin, normal, axis)
}
Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let n = o.get("normal").and_then(vec3).and_then(|n| n.normalized()).ok_or_else(|| bad(cmd, "the plane needs a non-zero `normal`"))?;
(origin, n)
(origin, n, None)
}
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
};
let offset = match expr(p, "offset") {
Some(e) => s.doc.eval(&e, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("offset: {e}")))?,
None => 0.0,
let length = |key: &str| -> Result<Option<f64>> {
match expr(p, key) {
Some(e) => s.doc.eval(&e, solvecraft_doc::expr::Kind::Length).map(Some).map_err(|e| bad(cmd, format!("{key}: {e}"))),
None => Ok(None),
}
};
let (offset, at) = (length("offset")?, length("at")?);
let offset = match (offset, at, axis) {
(Some(_), Some(_), _) => return Err(bad(cmd, "give either `offset` (along the normal) or `at` (a world coordinate), not both")),
(_, Some(_), None) => return Err(bad(cmd, "`at` needs a named plane (XY, XZ, YZ); move an {origin, normal} plane by its origin")),
// The named planes pass through the world origin, so the offset is `at` signed by the normal.
(_, Some(at), Some(i)) => at * axis_component(normal, i).signum(),
(offset, None, _) => offset.unwrap_or(0.0),
};
if !origin.is_finite() || !offset.is_finite() {
return Err(bad(cmd, "the plane must be finite"));
@@ -44,5 +58,18 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
let origin = origin + normal * offset * if bool_(p, "flip").unwrap_or(false) { -1.0 } else { 1.0 };
s.section = Some((origin, normal));
s.revision += 1;
Ok(json!({"section": {"origin": origin, "normal": normal}}))
let mut section = json!({"origin": origin, "normal": normal});
if let Some(i) = axis {
section["axis"] = json!(["X", "Y", "Z"].get(i).copied().unwrap_or("?"));
section["at"] = json!(axis_component(origin, i));
}
Ok(json!({"section": section}))
}
fn axis_component(v: Vec3, i: usize) -> f64 {
match i {
0 => v.x,
1 => v.y,
_ => v.z,
}
}
+118 -35
View File
@@ -9,6 +9,13 @@ use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
use crate::{EngineError, Result, Session};
/// The `connect` parameter every drawing command takes (see `connect_points`).
macro_rules! connect_doc {
() => {
"; connect?: bool (default true: a new point landing exactly on an existing point, the origin too, is made coincident with it)"
};
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
@@ -21,71 +28,71 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("line")
.key("L")
.enabled(in_sketch)
.params("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)"),
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
.at("SKETCH", "CREATE")
.icon("rect")
.key("R")
.enabled(in_sketch)
.params("p0, p1: opposite corners [x,y]; construction?"),
.params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
.at("SKETCH", "CREATE")
.icon("rect3")
.enabled(in_sketch)
.params("p0, p1: first edge; p2: point on the opposite edge"),
.params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!())),
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
.at("SKETCH", "CREATE")
.icon("rect_center")
.enabled(in_sketch)
.params("center, corner"),
.params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
.at("SKETCH", "CREATE")
.icon("circle")
.key("C")
.enabled(in_sketch)
.params("center: [x,y] or point ref, radius | diameter: number"),
.params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!())),
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
.at("SKETCH", "CREATE")
.icon("circle2")
.enabled(in_sketch)
.params("p0, p1: diameter ends"),
.params(concat!("p0, p1: diameter ends", connect_doc!())),
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
.at("SKETCH", "CREATE")
.icon("circle3")
.enabled(in_sketch)
.params("p0, p1, p2"),
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"),
.params(concat!("p0, p1, p2", connect_doc!())),
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!())),
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
.at("SKETCH", "CREATE")
.icon("arc_center")
.enabled(in_sketch)
.params("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees"),
.params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!())),
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params("center, radius (to vertices), sides, angle?: deg"),
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!())),
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params("center, radius (to edge midpoints), sides, angle?: deg"),
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!())),
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params("p0, p1: one edge, sides"),
.params(concat!("p0, p1: one edge, sides", connect_doc!())),
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("p0, p1: arc centres, width"),
.params(concat!("p0, p1: arc centres, width", connect_doc!())),
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("p0, p1: overall ends, width"),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"),
.params(concat!("p0, p1: overall ends, width", connect_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)",
),
@@ -218,6 +225,47 @@ pub(super) fn edit<T>(
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
}
/// How close (mm) a new point must land to an existing one to be connected to it.
const CONNECT_TOL: f64 = 1e-6;
/// New points (index `first` on) that land on an existing point, the origin included, get a
/// coincident constraint to it, as when a click snaps there; `connect: false` leaves them free.
fn connect_points(sk: &mut Sketch, first: usize, p: &Value) -> Result<Vec<String>> {
if !bool_(p, "connect").unwrap_or(true) {
return Ok(Vec::new());
}
// The existing points by x, for a range lookup per new point.
let mut old: Vec<(Vec2, usize)> = (0..first.min(sk.points.len())).filter_map(|i| Some((sk.point(i)?, i))).collect();
old.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
let mut ties = Vec::new();
for q in first..sk.points.len() {
let Some(at) = sk.point(q) else { continue };
let from = old.partition_point(|(o, _)| o.x < at.x - CONNECT_TOL);
let hit =
old.iter().skip(from).take_while(|(o, _)| o.x <= at.x + CONNECT_TOL).filter(|(o, _)| o.dist(at) < CONNECT_TOL).map(|(_, i)| *i).min();
if let Some(o) = hit {
ties.push((o, q));
}
}
ties.into_iter().map(|(o, q)| add_c(sk, ConstraintKind::Coincident { p: o, q })).collect()
}
/// `edit` for drawing commands whose step returns (curve ids, constraint ids): the points it
/// adds are connected to the existing points they land on (see [`connect_points`]).
pub(super) fn draw(
s: &mut Session,
p: &Value,
cmd: &str,
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(Vec<String>, Vec<String>)>,
) -> Result<((Vec<String>, Vec<String>), Value)> {
edit(s, p, cmd, false, |sk, doc| {
let first = sk.points.len();
let (curves, mut cons) = f(sk, doc)?;
cons.extend(connect_points(sk, first, p)?);
Ok((curves, cons))
})
}
/// A point argument: `[x, y]` (new point), a point reference string, or `{at, ref}`.
#[derive(Clone)]
pub(super) enum PArg {
@@ -425,7 +473,7 @@ fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
let closed = bool_(p, "closed").unwrap_or(false);
let infer = bool_(p, "infer").unwrap_or(false);
let ids = string_list(p, "ids");
let ((curves, cons), info) = edit(s, p, cmd, false, |sk, _| {
let ((curves, cons), info) = draw(s, p, cmd, |sk, _| {
let args: Vec<PArg> = list.iter().map(|v| parg(sk, v, cmd)).collect::<Result<_>>()?;
let n = args.len();
let segs = if closed { n } else { n - 1 };
@@ -526,6 +574,27 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result<Vec<String>> {
Ok(cons)
}
/// Which side and corner of an axis-aligned rectangle is which, from its lines and corner
/// points as `polygon_lines` made them (counter-clockwise from the bottom-left corner), so a
/// caller can dimension it without inspecting the sketch first.
fn rect_names(sk: &Sketch, l: &[usize], pts: &[usize]) -> Value {
let curve = |i: usize| l.get(i).and_then(|c| sk.curves.get(*c)).map(|c| c.id.clone());
let point = |i: usize| pts.get(i).and_then(|q| sk.points.get(*q)).map(|q| q.id.clone());
json!({
"sides": {"bottom": curve(0), "right": curve(1), "top": curve(2), "left": curve(3)},
"corners": {"bottom_left": point(0), "bottom_right": point(1), "top_right": point(2), "top_left": point(3)},
})
}
/// `result` plus the rectangle's named sides and corners.
fn rect_result(sk_out: (Vec<String>, Vec<String>), names: Value, info: Value) -> Value {
let mut out = result(sk_out, info);
if let (Some(o), Value::Object(n)) = (out.as_object_mut(), names) {
o.extend(n);
}
out
}
fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.rectangle.two_point";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
@@ -533,13 +602,15 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "the corners must differ in x and y"));
}
let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)));
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (l, _) = polygon_lines(sk, &[lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)])?;
let cons = rect_constraints(sk, &l)?;
let ((out, names), info) = edit(s, p, cmd, false, |sk, _| {
let first = sk.points.len();
let (l, pts) = polygon_lines(sk, &[lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)])?;
let mut cons = rect_constraints(sk, &l)?;
mark_construction(sk, &l, p);
Ok((ids_of(sk, &l), cons))
cons.extend(connect_points(sk, first, p)?);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
})?;
Ok(result(out, info))
Ok(rect_result(out, names, info))
}
fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
@@ -549,7 +620,8 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
if h.x < 1e-9 || h.y < 1e-9 {
return Err(bad(cmd, "the corner must differ from the centre in x and y"));
}
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ((out, names), info) = edit(s, p, cmd, false, |sk, _| {
let first = sk.points.len();
let (l, pts) = polygon_lines(sk, &[c - h, Vec2::new(c.x + h.x, c.y - h.y), c + h, Vec2::new(c.x - h.x, c.y + h.y)])?;
let mut cons = rect_constraints(sk, &l)?;
// Both diagonals (construction), the centre at their crossing. Only the first carries
@@ -567,9 +639,10 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
cons.push(add_c(sk, ConstraintKind::Midpoint { p: cp, l: diag })?);
}
mark_construction(sk, &l, p);
Ok((ids_of(sk, &l), cons))
cons.extend(connect_points(sk, first, p)?);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
})?;
Ok(result(out, info))
Ok(rect_result(out, names, info))
}
fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
@@ -581,7 +654,7 @@ fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
if w.abs() < 1e-9 {
return Err(bad(cmd, "p2 must not be on the first edge"));
}
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = draw(s, p, cmd, |sk, _| {
let (l, _) = polygon_lines(sk, &[a, b, b + n * w, a + n * w])?;
let mut cons = Vec::new();
if let [l0, l1, l2, l3] = l[..] {
@@ -609,7 +682,7 @@ fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let r = radius_arg(p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = draw(s, p, cmd, |sk, _| {
let c = req_parg(sk, p, "center", cmd)?;
let ci = sk.add_circle(c.pos(), r, c.idx(), id.as_deref())?;
mark_construction(sk, &[ci], p);
@@ -625,7 +698,7 @@ fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
if r < 1e-9 {
return Err(bad(cmd, "the points must differ"));
}
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = draw(s, p, cmd, |sk, _| {
let ci = sk.add_circle((a + b) * 0.5, r, None, None)?;
Ok((ids_of(sk, &[ci]), Vec::new()))
})?;
@@ -650,7 +723,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.three_point";
let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?);
let (o, r) = circumcircle(a, b, c).ok_or_else(|| bad(cmd, "the points are collinear"))?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = draw(s, p, cmd, |sk, _| {
let ci = sk.add_circle(o, r, None, None)?;
Ok((ids_of(sk, &[ci]), Vec::new()))
})?;
@@ -661,7 +734,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.arc.three_point";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = draw(s, p, cmd, |sk, _| {
let (a, b) = (req_parg(sk, p, "start", cmd)?, req_parg(sk, p, "end", cmd)?);
let t = req_vec2(cmd, p, "through")?;
let (o, _) = circumcircle(a.pos(), t, b.pos()).ok_or_else(|| bad(cmd, "the points are collinear"))?;
@@ -684,7 +757,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let sweep = num(p, "sweep");
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = draw(s, p, cmd, |sk, _| {
let c = req_parg(sk, p, "center", cmd)?;
let a = req_parg(sk, p, "start", cmd)?;
if let Some(sw) = sweep {
@@ -721,7 +794,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result<Value> {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = draw(s, p, cmd, |sk, _| {
let corners: Vec<Vec2> = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect();
let (l, pts) = polygon_lines(sk, &corners)?;
let mut cons = Vec::new();
@@ -797,7 +870,7 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w:
let h = w / 2.0;
let d = (c1 - c0).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?;
let n = d.perp();
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = draw(s, p, cmd, |sk, _| {
let pts: Vec<usize> =
[c0 - n * h, c1 - n * h, c1 + n * h, c0 + n * h].iter().map(|q| sk.add_point(*q, None)).collect::<std::result::Result<_, _>>()?;
let (p0, p1, p2, p3) = (pts[0], pts[1], pts[2], pts[3]);
@@ -837,11 +910,12 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.point";
let at = req_vec2(cmd, p, "point")?;
let id = str_(p, "id").map(str::to_string);
let (pid, info) = edit(s, p, cmd, false, |sk, _| {
let ((pid, cons), info) = edit(s, p, cmd, false, |sk, _| {
let i = sk.add_point(at, id.as_deref())?;
Ok(sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default())
let cons = connect_points(sk, i, p)?;
Ok((sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default(), cons))
})?;
Ok(json!({"point": pid, "sketch": info}))
Ok(json!({"point": pid, "constraints": cons, "sketch": info}))
}
// ---------------------------------------------------------------------------------------------
@@ -1049,6 +1123,15 @@ fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.coincident";
// Already tied (e.g. connected when it was drawn there): nothing to add.
let sk = s.doc.sketch(target_sketch(s, p, cmd)?)?;
let pt = |k: &str| p.get(k).and_then(Value::as_str).and_then(|r| sk.resolve_point(r));
if let (Some(a), Some(b)) = (pt("a"), pt("b"))
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
{
let rep = solve(&mut sk.clone());
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
}
constrain(s, p, cmd, |sk| {
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
@@ -99,7 +99,7 @@ fn curvature_polygon_arc_length_linear_diameter() {
#[test]
fn glyphs_and_snaps() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10], "connect": false}));
let l = ids(&r["curves"]);
let g = run(&mut s, "sketch.glyphs", json!({}));
assert_eq!(g["glyphs"].as_array().unwrap().len(), 4);
@@ -0,0 +1,98 @@
//! Sketch Fillet and Chamfer at corners (#46): line–line, line–arc and arc–arc corners, the
//! corners of a constrained rectangle, several corners at once, and radii too large for the
//! corner (a clear error that leaves the sketch as it was).
use serde_json::{Value, json};
use crate::Session;
fn new_sketch() -> Session {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn curves(r: &Value) -> Vec<String> {
r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn sk(s: &Session) -> solvecraft_sketch::Sketch {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
}
fn solved(s: &Session) -> bool {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.ok()
}
fn area(s: &Session) -> f64 {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().profiles.iter().map(|p| p.area).sum()
}
#[test]
fn fillet_and_chamfer_corners() {
use std::f64::consts::PI;
// A constrained 40 × 20 rectangle: a fillet r5 on one corner keeps it solved and closed,
// with the area of the corner taken off.
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
let c = curves(&r);
let a0 = area(&s);
run(&mut s, "sketch.fillet", json!({"a": c[0], "b": c[1], "radius": 5}));
assert!(solved(&s));
assert!((area(&s) - (a0 - (25.0 - PI * 25.0 / 4.0))).abs() < 1e-6, "{}", area(&s));
// Several corners at once: the rest of the rectangle's corners.
let pts: Vec<String> = {
let k = sk(&s);
let li = |id: &str| k.curve_index(id).unwrap();
[(1, 2), (2, 3), (3, 0)]
.iter()
.map(|(x, y)| {
let (cx, cy) = (&k.curves[li(&c[*x])].kind, &k.curves[li(&c[*y])].kind);
let shared = cx.point_ids().into_iter().find(|p| cy.point_ids().contains(p)).unwrap();
k.points[shared].id.clone()
})
.collect()
};
run(&mut s, "sketch.fillet", json!({"points": pts, "radius": 5}));
assert!(solved(&s));
assert!((area(&s) - (a0 - 4.0 * (25.0 - PI * 25.0 / 4.0))).abs() < 1e-6, "{}", area(&s));
// Too large for a corner: a clear error, nothing changed.
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
let c = curves(&r);
let n = sk(&s).curves.len();
let e = s.execute("sketch.fillet", &json!({"a": c[0], "b": c[1], "radius": 30})).unwrap_err().to_string();
assert!(e.contains("too large") || e.contains("does not fit"), "{e}");
assert_eq!(sk(&s).curves.len(), n);
let e = s.execute("sketch.chamfer.equal_distance", &json!({"a": c[0], "b": c[1], "distance": 25})).unwrap_err().to_string();
assert!(e.contains("too large") || e.contains("does not fit") || e.contains("longer"), "{e}");
assert_eq!(sk(&s).curves.len(), n);
// A line meeting an arc, and two arcs (a slot's end rounded into a line).
let mut s = new_sketch();
let l = curves(&run(&mut s, "sketch.line", json!({"points": [[-20, 10], [0, 10]]})))[0].clone();
let a = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": format!("{l}.end"), "end": [10, 0], "through": [7.0710678, 7.0710678]})))
[0]
.clone();
run(&mut s, "sketch.fillet", json!({"a": l, "b": a, "radius": 2}));
assert!(solved(&s));
let mut s = new_sketch();
let a1 = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": [-10, 0], "end": [0, 0], "through": [-5, -5]})))[0].clone();
let a2 = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": format!("{a1}.end"), "end": [10, 0], "through": [5, 2]})))[0].clone();
run(&mut s, "sketch.fillet", json!({"a": a1, "b": a2, "radius": 1}));
assert!(solved(&s));
// Chamfers keep a closed profile.
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
let c = curves(&r);
let a0 = area(&s);
run(&mut s, "sketch.chamfer.equal_distance", json!({"a": c[0], "b": c[1], "distance": 4}));
assert!(solved(&s));
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
}
+79 -38
View File
@@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY")
.icon("sketch_fillet")
.enabled(in_sketch)
.params("point: corner shared by two curves (\"l1.end\") | a, b: two lines or arcs meeting at a corner; radius: expr"),
.params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr"),
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
@@ -376,11 +376,13 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
if oi == ci {
continue;
}
let Some(other) = sk.shape(oi) else { continue };
for q in intersections(&unbounded, &other) {
if on_bounded(&other, q).is_none() {
continue;
}
// Lines and circles exactly; free-form curves (splines, ellipses, conics) by the
// pieces of their polyline.
let pieces: Vec<Shape> = match sk.shape(oi) {
Some(other) => vec![other],
None => sk.polyline(oi).windows(2).map(|w| Shape::Line { a: w[0], b: w[1] }).collect(),
};
for q in pieces.iter().flat_map(|other| intersections(&unbounded, other).into_iter().filter(|q| on_bounded(other, *q).is_some())) {
let t = sh.param(q);
let gap = match sh {
Shape::Line { a, b } => {
@@ -478,48 +480,67 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.fillet";
let r_expr = expr(p, "radius").ok_or_else(|| bad(cmd, "`radius` must be a number or an expression"))?;
// Several corners at once (`points`), each like a single `point`.
let corners: Vec<Value> = match p.get("points").and_then(Value::as_array) {
Some(a) if !a.is_empty() && a.len() <= 500 => a.iter().map(|q| json!({ "point": q })).collect(),
Some(_) => return Err(bad(cmd, "`points` must list 1…500 corners")),
None => vec![p.clone()],
};
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let r = doc.eval(&r_expr, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?;
if !(r > 1e-9 && r < 1e8) {
return Err(bad(cmd, "radius must be positive"));
}
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
Ok(x) => x,
// Not two lines: a corner with an arc.
Err(e) => {
let Some((arc, cons)) = round_corner(sk, p, r, cmd)? else { return Err(e) };
let mut cons = cons;
let pname = doc.new_model_param(&r_expr, "mm");
cons.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?);
return Ok((ids_of(sk, &[arc]), cons, pname));
}
};
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
return Err(bad(cmd, "the lines are parallel"));
}
let d = r / half.tan();
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
let center = pc + bis * (r / half.sin());
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
let ccw = (qa - center).cross(qb - center) > 0.0;
let arc = if ccw {
sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)?
} else {
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
};
let mut cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
let pname = doc.new_model_param(&r_expr, "mm");
cons.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?);
Ok((ids_of(sk, &[arc]), cons, pname))
let (mut arcs, mut cons) = (Vec::new(), Vec::new());
for q in &corners {
let (arc, mut c) = fillet_corner(sk, q, r, cmd)?;
// One radius for them all: the first carries the dimension, the rest are equal to it.
match arcs.first() {
None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?),
Some(first) => c.push(add_c(sk, ConstraintKind::Equal { a: *first, b: arc })?),
}
cons.extend(c);
arcs.push(arc);
}
Ok((ids_of(sk, &arcs), cons, pname))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info}))
}
/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves:
/// the arc and the tangent constraints.
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>)> {
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
Ok(x) => x,
// Not two lines: a corner with an arc.
Err(e) => return round_corner(sk, p, r, cmd)?.ok_or(e),
};
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
return Err(bad(cmd, "the lines are parallel"));
}
let d = r / half.tan();
if d >= pa.dist(pc) - 1e-9 || d >= pb.dist(pc) - 1e-9 {
return Err(bad(cmd, format!("the radius is too large for that corner (it needs {d:.3} mm of each line)")));
}
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
let center = pc + bis * (r / half.sin());
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
let ccw = (qa - center).cross(qb - center) > 0.0;
let arc = if ccw {
sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)?
} else {
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
};
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
Ok((arc, cons))
}
/// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching
/// both, nearest the corner; both curves are cut back to it. None when the corner is not of
/// that kind.
@@ -847,6 +868,18 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
if !closed {
joints.push(end_of(&pieces[n - 1]));
}
// A straight piece whose joints come out reversed (or meet) was overrun by its
// neighbours: the offset is larger than the shape allows there.
for (k, pc) in pieces.iter().enumerate() {
if let Piece::Line(a, b) = pc {
let (ja, jb) = (joints.get(k).copied(), joints.get(if closed { (k + 1) % n } else { k + 1 }).copied());
if let (Some(ja), Some(jb)) = (ja, jb)
&& (jb - ja).dot(*b - *a) <= TOL * a.dist(*b)
{
return Err(bad(cmd, "the offset is larger than the shape allows (the offset curves would cross)"));
}
}
}
let pts: Vec<usize> = joints.iter().map(|q| sk.add_point(*q, None)).collect::<std::result::Result<_, _>>()?;
for (k, pc) in pieces.iter().enumerate() {
let (pa, pb) = (pts[k], if closed { pts[(k + 1) % n] } else { pts[k + 1] });
@@ -1149,3 +1182,11 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "sketch_modify_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "sketch_robust_tests.rs"]
mod robust_tests;
#[cfg(test)]
#[path = "sketch_corner_tests.rs"]
mod corner_tests;
+43 -1
View File
@@ -296,7 +296,8 @@ fn isoparametric_curves_of_a_cylinder() {
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
let zs: Vec<f64> = w.pts.iter().map(|p| p.z).collect();
assert!(zs.iter().cloned().fold(f64::MIN, f64::max) > 19.9 && zs.iter().cloned().fold(f64::MAX, f64::min) < 0.1, "{zs:?}");
assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-3 && p.y.abs() < 1e-3));
// Within the mesh's sag: the cut runs across facets unless a seam happens to lie there.
assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-2 && p.y.abs() < 1e-2), "{:?}", w.pts);
}
#[test]
@@ -395,3 +396,44 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
let v = st.bodies.last().unwrap().mesh().measure().volume;
assert!((v - 4.0 * len).abs() < 0.05 * 4.0 * len, "{v} vs {}", 4.0 * len);
}
/// #47: references to other bodies: an edge, a face's loops and a silhouette of a second body
/// project into a sketch on the first; they follow an edit of that body (a parameter) and
/// are reported lost, not crashed on, when it goes away.
#[test]
fn project_and_include_from_other_bodies() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 40, "width": 30, "height": 5}));
run(&mut s, "solid.box", json!({"corner": [50, 0, 0], "length": "w", "width": 10, "height": 20, "operation": "new"}));
run(&mut s, "solid.cylinder", json!({"base": [80, 5, 0], "radius": 4, "height": 10, "operation": "new"}));
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 5]}, "project_edges": false}));
// An edge on the second box's top, its top face's loop, the cylinder's silhouette.
run(&mut s, "sketch.project", json!({"refs": [{"edge": [55, 0, 20]}]}));
run(&mut s, "sketch.project", json!({"refs": [{"face": [55, 5, 20]}]}));
run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [80, 1, 10]}]}));
run(&mut s, "sketch.project", json!({"refs": [{"body": "Body3"}]}));
let si = inspect(&mut s);
let lc = linked_curves(&si);
// (Geometry already there is not drawn twice: the edge lies on the face's loop, the included
// circle on the silhouette.)
assert!(lc.len() >= 6, "{}", lc.len());
// The projected edge of the second box runs from x 50 to 60 at y 0.
let xs = |si: &Value| -> Vec<f64> {
linked_curves(si).iter().flat_map(|c| [c["start_at"][0].as_f64().unwrap_or(0.0), c["end_at"][0].as_f64().unwrap_or(0.0)]).collect()
};
assert!(xs(&si).iter().any(|x| (x - 60.0).abs() < 1e-6), "{:?}", xs(&si));
run(&mut s, "sketch.finish", json!({}));
// Widen the second box: the projection follows (its far edge is at 70).
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
let id = s.doc.features.iter().find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
let si = run(&mut s, "sketch.inspect", json!({"sketch": id}));
assert!(xs(&si).iter().any(|x| (x - 70.0).abs() < 1e-6), "{:?}", xs(&si));
// Remove the cylinder: its silhouette and edge are lost, with a warning and no error.
let cyl = s.doc.features.iter().find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Cylinder { .. })).unwrap().id;
run(&mut s, "timeline.delete", json!({"feature": cyl}));
let t = run(&mut s, "document.inspect", json!({}));
let row = t["timeline"].as_array().unwrap().iter().find(|r| r["id"] == json!(id)).cloned().unwrap();
assert!(row["error"].is_null(), "{row}");
assert!(row["warning"].as_str().is_some_and(|w| w.contains("lost")), "{row}");
}
+1 -1
View File
@@ -23,7 +23,7 @@ fn rect(kind: &str) -> (Session, Vec<String>) {
let r = match kind {
"center" => run(&mut s, "sketch.rectangle.center", json!({"center": [3, 2], "corner": [13, 9]})),
"two" => run(&mut s, "sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [21, 15]})),
_ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
_ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15], "connect": false})),
};
let sides = ids(&r["curves"]);
assert_eq!(sides.len(), 4, "{r}");
@@ -0,0 +1,175 @@
//! Adversarial Trim, Extend and Offset cases (#45): each gives the expected result or a clear
//! error, and the sketch always stays sound (it solves, every point is finite, nothing
//! degenerate is left behind).
use serde_json::{Value, json};
use crate::Session;
fn new_sketch() -> Session {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s
}
fn curves(r: &Value) -> Vec<String> {
r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn add(s: &mut Session, id: &str, p: Value) -> Vec<String> {
match s.execute(id, &p) {
Ok(r) => curves(&r),
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// The sketch is sound: it solves, every point is finite, no curve has collapsed.
fn sound(s: &Session) {
let id = s.active_sketch.unwrap();
let st = s.model.state();
let ss = st.sketch(id).unwrap();
assert!(ss.report.ok(), "the sketch no longer solves: {:?}", ss.report.failing);
for p in &ss.sketch.points {
assert!(p.pos.is_finite(), "point {} is {:?}", p.id, p.pos);
}
for (i, c) in ss.sketch.curves.iter().enumerate() {
let len: f64 = ss.sketch.segs(i).iter().map(|g| g.length()).sum();
assert!(len.is_finite() && len > 1e-9, "curve {} has length {len}", c.id);
}
}
fn count(s: &Session) -> usize {
let id = s.active_sketch.unwrap();
s.model.state().sketch(id).unwrap().sketch.curves.len()
}
#[test]
fn trim_cases() {
// A line crossing a circle twice: trimming the chord leaves the two outer stubs.
let mut s = new_sketch();
add(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
let l = add(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}))[0].clone();
s.execute("sketch.trim", &json!({"curve": l, "at": [0, 0]})).unwrap();
sound(&s);
assert_eq!(count(&s), 3, "circle + two stubs");
// Trimming at a tangent touch, at a curve end, on a curve with no crossings.
let mut s = new_sketch();
let c = add(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))[0].clone();
add(&mut s, "sketch.line", json!({"points": [[-20, 10], [20, 10]]}));
let r = s.execute("sketch.trim", &json!({"curve": c, "at": [0, -10]}));
sound(&s);
let _ = r;
let mut s = new_sketch();
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
// No crossings: the whole line goes (Fusion deletes it).
let r = s.execute("sketch.trim", &json!({"curve": l, "at": [5, 0]}));
sound_or_empty(&s);
assert!(r.is_ok() || r.is_err());
// Overlapping collinear lines.
let mut s = new_sketch();
let a = add(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))[0].clone();
add(&mut s, "sketch.line", json!({"points": [[5, 0], [15, 0]]}));
let _ = s.execute("sketch.trim", &json!({"curve": a, "at": [10, 0]}));
sound(&s);
// A spline crossed by a line, trimmed between the crossings; an ellipse cut by a line.
let mut s = new_sketch();
let sp = add(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 10], [20, -10], [30, 0]]}))[0].clone();
add(&mut s, "sketch.line", json!({"points": [[-5, 2], [35, 2]]}));
s.execute("sketch.trim", &json!({"curve": sp, "at": [10, 9]})).unwrap();
sound(&s);
let mut s = new_sketch();
let e = add(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))[0].clone();
add(&mut s, "sketch.line", json!({"points": [[-15, 0], [15, 0]]}));
s.execute("sketch.trim", &json!({"curve": e, "at": [0, -4]})).unwrap();
sound(&s);
// Hostile points.
let mut s = new_sketch();
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
for at in [json!([f64::MAX, 0]), json!([1e300, 1e300]), json!("x"), json!([0])] {
let _ = s.execute("sketch.trim", &json!({"curve": l, "at": at}));
sound(&s);
}
}
fn sound_or_empty(s: &Session) {
if count(s) > 0 {
sound(s);
}
}
#[test]
fn extend_cases() {
// A line extends to a circle; to a spline; with nothing to reach it is a clear error.
let mut s = new_sketch();
add(&mut s, "sketch.circle.center", json!({"center": [20, 0], "radius": 5}));
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).unwrap();
sound(&s);
let id = s.active_sketch.unwrap();
let st = s.model.state();
let ss = st.sketch(id).unwrap();
let li = ss.sketch.curve_index(&l).unwrap();
let end = ss.sketch.segs(li).iter().map(|g| g.end().x.max(g.start().x)).fold(f64::NEG_INFINITY, f64::max);
assert!((end - 15.0).abs() < 1e-6, "{end}");
drop(st);
let mut s = new_sketch();
add(&mut s, "sketch.spline.fit_point", json!({"points": [[20, -10], [22, 0], [20, 10]]}));
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).unwrap();
sound(&s);
let mut s = new_sketch();
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
assert!(s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).is_err());
sound(&s);
// An arc extends along its circle to a line.
let mut s = new_sketch();
add(&mut s, "sketch.line", json!({"points": [[-20, -3], [20, -3]]}));
let a = add(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [0, 10], "through": [7.0710678, 7.0710678]}))[0].clone();
let _ = s.execute("sketch.extend", &json!({"curve": a, "at": [10, 0]}));
sound(&s);
}
#[test]
fn offset_cases() {
// A rectangle offset outward and inward; inward past its middle vanishes (clear error).
let mut s = new_sketch();
let r = add(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
let n0 = count(&s);
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 2, "side": [-5, -5]})).unwrap();
sound(&s);
assert!(count(&s) > n0);
let n1 = count(&s);
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 2, "side": [10, 5]})).unwrap();
sound(&s);
assert!(count(&s) > n1);
let n2 = count(&s);
assert!(
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 6, "side": [10, 5]})).is_err(),
"inward 6 on a 10 wide rectangle"
);
assert_eq!(count(&s), n2, "a failed offset adds nothing");
sound(&s);
// A slot (lines and arcs), a spline chain, an open zig-zag whose inner offset self-crosses.
let mut s = new_sketch();
let slot = add(&mut s, "sketch.slot.center_to_center", json!({"p0": [0, 0], "p1": [20, 0], "width": 6}));
if let Some(c) = slot.first() {
s.execute("sketch.offset", &json!({"curves": [c], "distance": 1, "side": [10, 10]})).unwrap();
sound(&s);
}
let mut s = new_sketch();
let sp = add(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 6], [20, -6], [30, 0]]}))[0].clone();
s.execute("sketch.offset", &json!({"curves": [sp], "distance": 1.5})).unwrap();
sound(&s);
let mut s = new_sketch();
let z = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10.5, 5], [11, 0], [20, 0]]}));
let r = s.execute("sketch.offset", &json!({"curves": [z[0].clone()], "distance": 2, "side": [10, -5]}));
sound(&s);
let _ = r;
// Hostile distances.
let mut s = new_sketch();
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
for d in [json!(0), json!(-1e308), json!(f64::MAX), json!("x"), json!(1e-12)] {
let _ = s.execute("sketch.offset", &json!({"curves": [l.clone()], "distance": d}));
sound(&s);
}
}
+6 -6
View File
@@ -71,7 +71,7 @@ fn every_dimension_kind_has_a_drawable_frame() {
#[test]
fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "connect": false}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let text = |s: &Session| {
@@ -126,7 +126,7 @@ fn dof(s: &mut Session) -> i64 {
#[test]
fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]], "connect": false}));
let free = dof(&mut s);
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], true);
@@ -333,7 +333,7 @@ fn two_fully_constrained_entities_conflict_and_nothing_moves() {
fn line_pairs_keep_the_first_line() {
let lines = || {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]], "connect": false}));
run(&mut s, "sketch.line", json!({"points": [[5, 20], [25, 35]]}));
s
};
@@ -360,7 +360,7 @@ fn line_pairs_keep_the_first_line() {
fn circle_pairs_keep_the_first_circle() {
let circles = || {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 10], "radius": 8}));
s
};
@@ -471,7 +471,7 @@ fn dragging_a_rectangle_side_moves_that_side_only() {
#[test]
fn dragging_circles_and_arcs() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false}));
// The edge changes the radius about a fixed centre; the centre moves the circle.
drag(&mut s, "c1", [5.0, 0.0], [0.0, 8.0]);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
@@ -553,7 +553,7 @@ fn chain_follows_shared_and_coincident_ends() {
#[test]
fn constraints_and_dimensions_can_be_selected_and_deleted_together() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "connect": false}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
let ids: Vec<String> = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect();
+15
View File
@@ -257,3 +257,18 @@ fn construction_lives_in_the_component_frame() {
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 20, 0]}));
assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 20.0, 10.0)), "{:?}", point(&s, "PinTop"));
}
#[test]
fn plane_perpendicular_to_a_plane_through_a_line() {
let mut s = part();
// Through the box's front bottom edge (along X), square to the bottom: the front face's plane.
run(&mut s, "construct.plane.perpendicular", json!({"plane": {"face": [20, 15, 0]}, "line": {"edge": [20, 0, 0]}, "name": "F"}));
let f = plane(&s, "F");
assert!(parallel(f.normal(), Vec3::Y) && on_plane(&f, Vec3::new(7.0, 0.0, 13.0)), "{f:?}");
// Through the world Z axis, square to XZ: the YZ plane.
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XZ", "line": {"axis": "Z"}, "name": "G"}));
let g = plane(&s, "G");
assert!(parallel(g.normal(), Vec3::X) && on_plane(&g, Vec3::new(0.0, 5.0, 9.0)), "{g:?}");
// A line square to the plane has no single answer.
assert!(s.execute("construct.plane.perpendicular", &json!({"plane": "XY", "line": {"axis": "Z"}})).is_err());
}
+372
View File
@@ -0,0 +1,372 @@
//! Random modelling sessions for robustness testing (`solvecraft-cli fuzz`, docs/robustness.md).
//!
//! A seeded generator builds realistic feature sequences: a base (box, extruded sketch profile
//! with an arc, revolved section), then joins and cuts of boxes, cylinders and spheres placed on
//! a coarse grid against the body (so coincident faces and tangencies come up often), fillets
//! and chamfers on random edges, shells, holes and patterns. Every step is checked: the command
//! succeeds, every body is a sound solid, and the volume moves the right way.
use serde::Serialize;
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
use solvecraft_kernel::fuzz::Rng;
pub use solvecraft_kernel::fuzz::{BoolCase, boolean_case, boolean_shapes};
use crate::{EngineError, Session};
/// One step: the commands it runs (a sketch and its feature go together).
#[derive(Clone, Debug, Serialize)]
pub struct Step {
pub kind: &'static str,
pub commands: Vec<(String, Value)>,
/// How the total volume must move.
#[serde(skip)]
expect: Expect,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Expect {
Any,
Grow,
Shrink,
}
/// What happened at a step.
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct Outcome {
/// "ok", "unsupported" (a feature the kernel doesn't do yet), "rejected" (the generator's
/// parameters were refused), "failed", "panic" (one that escaped every guard), "invalid" (a
/// body that isn't a sound solid), "volume".
pub kind: String,
pub detail: String,
}
impl Outcome {
fn ok() -> Outcome {
Outcome { kind: "ok".into(), detail: String::new() }
}
fn new(kind: &str, detail: impl Into<String>) -> Outcome {
Outcome { kind: kind.into(), detail: detail.into() }
}
/// A failure the kernel should not have (not a known limitation or a refused input).
pub fn is_bug(&self) -> bool {
matches!(self.kind.as_str(), "failed" | "panic" | "invalid" | "volume")
}
}
/// A design's run: its steps, and what happened at the last one run.
#[derive(Clone, Debug, Serialize)]
pub struct Run {
pub seed: u64,
pub steps: Vec<Step>,
pub outcome: Outcome,
/// Steps refused as not supported (kind, message): undone, not part of the design.
pub unsupported: Vec<(String, String)>,
/// Steps whose generated parameters a command refused.
pub rejected: usize,
}
impl Run {
/// The commands as a script for `solvecraft-cli run`.
pub fn script(&self) -> Value {
Value::Array(self.steps.iter().flat_map(|s| s.commands.iter().map(|(c, p)| json!({"command": c, "params": p}))).collect())
}
}
fn total_volume(s: &Session) -> f64 {
s.model.state().bodies.iter().filter_map(|b| solvecraft_kernel::measure(&b.body).ok()).map(|m| m.volume).sum()
}
fn classify(e: &EngineError) -> Outcome {
let msg = e.to_string();
match e {
EngineError::Internal(..) => Outcome::new("panic", msg),
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
// A failed boolean is a failure even when it says "not supported" (coincident faces on
// curved bodies): those are the cases to fix. Features the kernel doesn't do yet (a
// fillet between curved faces) are limitations.
// Picks the generator got wrong (a centroid off its face).
EngineError::Other(_) if msg.starts_with("invalid input") => Outcome::new("rejected", msg),
EngineError::Other(_) if msg.contains("boolean") => Outcome::new("failed", msg),
EngineError::Other(_) if msg.contains("not supported") || msg.contains("only ") => Outcome::new("unsupported", msg),
EngineError::Other(_) => Outcome::new("failed", msg),
}
}
/// Run one step and check it.
fn apply(s: &mut Session, step: &Step) -> Outcome {
let before = total_volume(s);
for (c, p) in &step.commands {
if let Err(e) = s.execute(c, p) {
return classify(&e);
}
}
let st = s.model.state();
if let Some(f) = s.model.results.iter().find(|r| r.error.is_some()) {
return Outcome::new("failed", format!("{}: {}", f.name, f.error.clone().unwrap_or_default()));
}
for b in &st.bodies {
let bad = b.body.validity();
if !bad.is_empty() {
return Outcome::new("invalid", format!("{}: {}", b.name, bad.join("; ")));
}
}
let after = total_volume(s);
let slack = 1e-4 * before.max(after) + 1e-6;
let wrong = match step.expect {
Expect::Any => false,
Expect::Grow => after < before - slack,
Expect::Shrink => after > before + slack,
};
if wrong || !after.is_finite() {
return Outcome::new("volume", format!("{} moved the volume the wrong way: {before:.4} → {after:.4}", step.kind));
}
Outcome::ok()
}
fn step(kind: &'static str, expect: Expect, commands: Vec<(&str, Value)>) -> Step {
Step { kind, expect, commands: commands.into_iter().map(|(c, p)| (c.to_string(), p)).collect() }
}
/// The bodies' bounds (min, max).
fn bounds(s: &Session) -> Option<(Vec3, Vec3)> {
let st = s.model.state();
let mut it = st.bodies.iter().map(|b| b.mesh().bounds());
let first = it.next()?;
Some(it.fold((first.min, first.max), |(lo, hi), b| {
(Vec3::new(lo.x.min(b.min.x), lo.y.min(b.min.y), lo.z.min(b.min.z)), Vec3::new(hi.x.max(b.max.x), hi.y.max(b.max.y), hi.z.max(b.max.z)))
}))
}
const G: f64 = 2.5;
fn snap(x: f64) -> f64 {
(x / G).round() * G
}
/// A coordinate in the bounds: often exactly on a side (coincident faces), else on the grid.
fn coord(r: &mut Rng, lo: f64, hi: f64) -> f64 {
match r.below(4) {
0 => lo,
1 => hi,
_ => snap(lo + (hi - lo) * r.unit()),
}
}
fn base(r: &mut Rng) -> Step {
match r.below(3) {
0 => {
let (l, w, h) = (r.grid(10.0, 60.0, G), r.grid(10.0, 60.0, G), r.grid(5.0, 40.0, G));
step("box", Expect::Any, vec![("solid.box", json!({"length": l, "width": w, "height": h}))])
}
1 => {
// A profile of lines closed by an arc, extruded.
let (w, h) = (r.grid(15.0, 50.0, G), r.grid(10.0, 40.0, G));
let bulge = r.grid(2.5, (w / 2.0).min(15.0), G);
let notch = r.chance(0.5);
let mut pts = vec![json!([0, 0]), json!([w, 0]), json!([w, h])];
if notch {
let nx = snap(w * 0.6);
pts.push(json!([nx, h]));
pts.push(json!([nx, snap(h * 0.5)]));
pts.push(json!([snap(w * 0.3), snap(h * 0.5)]));
pts.push(json!([snap(w * 0.3), h]));
}
pts.push(json!([0, h]));
let d = r.grid(5.0, 40.0, G);
step(
"extruded profile",
Expect::Any,
vec![
("sketch.create", json!({"plane": "XY"})),
("sketch.line", json!({"points": pts})),
("sketch.arc.three_point", json!({"start": [0, h], "end": [0, 0], "through": [-bulge, h / 2.0]})),
("sketch.finish", json!({})),
("Extrude", json!({"distance": d})),
],
)
}
_ => {
let (r0, r1, h) = (r.grid(0.0, 10.0, G), r.grid(12.5, 30.0, G), r.grid(5.0, 30.0, G));
let angle = r.pick(&["360 deg", "180 deg", "90 deg", "270 deg"]).unwrap_or("360 deg");
step(
"revolved section",
Expect::Any,
vec![
("sketch.create", json!({"plane": "XZ"})),
("sketch.rectangle.two_point", json!({"p0": [r0, 0], "p1": [r1, h]})),
("sketch.finish", json!({})),
("Revolve", json!({"axis": "Z", "angle": angle})),
],
)
}
}
}
fn random_point(r: &mut Rng, lo: Vec3, hi: Vec3) -> [f64; 3] {
[coord(r, lo.x, hi.x), coord(r, lo.y, hi.y), coord(r, lo.z, hi.z)]
}
/// The next step for the design as it stands (None: nothing fits, try another).
fn next_step(r: &mut Rng, s: &Session, last_feature: &Option<String>) -> Option<Step> {
let (lo, hi) = bounds(s)?;
let st = s.model.state();
let body = st.bodies.get(r.below(st.bodies.len()))?;
let tol = (body.body.size() * 1e-3).max(1e-3);
Some(match r.below(12) {
0 | 1 => {
let a = random_point(r, lo, hi);
let size = [r.grid(2.5, 25.0, G), r.grid(2.5, 25.0, G), r.grid(2.5, 25.0, G)];
let join = r.chance(0.5);
let (kind, expect, op) = if join { ("join box", Expect::Grow, "join") } else { ("cut box", Expect::Shrink, "cut") };
step(kind, expect, vec![("solid.box", json!({"corner": a, "length": size[0], "width": size[1], "height": size[2], "operation": op}))])
}
2 | 3 => {
let base = random_point(r, lo, hi);
let axis = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]])?;
let join = r.chance(0.4);
let (kind, expect, op) = if join { ("join cylinder", Expect::Grow, "join") } else { ("cut cylinder", Expect::Shrink, "cut") };
let p = json!({"base": base, "axis": axis, "radius": r.grid(1.25, 10.0, 1.25), "height": r.grid(2.5, 30.0, G), "operation": op});
step(kind, expect, vec![("solid.cylinder", p)])
}
4 => {
let c = random_point(r, lo, hi);
step("cut sphere", Expect::Shrink, vec![("solid.sphere", json!({"center": c, "radius": r.grid(2.5, 10.0, G), "operation": "cut"}))])
}
5 | 6 => {
let edges = body.body.edges(tol).ok()?;
let n = 1 + r.below(3);
let picks: Vec<[f64; 3]> = (0..n).filter_map(|_| edges.get(r.below(edges.len()))).map(|e| [e.mid.x, e.mid.y, e.mid.z]).collect();
let rad = r.pick(&[0.5, 1.0, 2.0, 2.5, 3.0])?;
step("fillet", Expect::Any, vec![("solid.fillet", json!({"edges": picks, "radius": rad}))])
}
7 => {
let edges = body.body.edges(tol).ok()?;
let n = 1 + r.below(3);
let picks: Vec<[f64; 3]> = (0..n).filter_map(|_| edges.get(r.below(edges.len()))).map(|e| [e.mid.x, e.mid.y, e.mid.z]).collect();
step("chamfer", Expect::Shrink, vec![("solid.chamfer", json!({"edges": picks, "distance": r.pick(&[0.5, 1.0, 2.0])?}))])
}
8 => {
let faces: Vec<_> = body.body.faces(tol).ok()?.into_iter().filter(|f| f.plane_normal.is_some()).collect();
let f = faces.get(r.below(faces.len()))?;
let p = [f.centroid.x, f.centroid.y, f.centroid.z];
step("shell", Expect::Shrink, vec![("solid.shell", json!({"faces": [p], "thickness": r.pick(&[1.0, 1.5, 2.0])?}))])
}
9 => {
let faces: Vec<_> = body.body.faces(tol).ok()?.into_iter().filter(|f| f.plane_normal.is_some()).collect();
let f = faces.get(r.below(faces.len()))?;
let p = [f.centroid.x, f.centroid.y, f.centroid.z];
let mut h = json!({"position": p, "diameter": r.grid(2.0, 8.0, 1.0)});
if r.chance(0.5) {
h["depth"] = json!(r.grid(2.5, 20.0, G));
if r.chance(0.5) {
h["type"] = json!("drilled");
}
}
step("hole", Expect::Shrink, vec![("solid.hole", h)])
}
10 => {
let name = last_feature.clone()?;
let dir = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])?;
let p = json!({"features": [name], "dir1": dir, "count1": 2 + r.below(2), "spacing1": r.grid(5.0, 20.0, G)});
step("rectangular pattern", Expect::Any, vec![("solid.pattern.rectangular", p)])
}
_ => {
let name = last_feature.clone()?;
let p = json!({"features": [name], "axis": "Z", "count": 2 + r.below(5)});
step("circular pattern", Expect::Any, vec![("solid.pattern.circular", p)])
}
})
}
/// Generate and run a design of up to `max_steps` steps, stopping at the first problem. Steps
/// refused as unsupported (or with parameters the command rejects) are undone and counted.
pub fn run(seed: u64, max_steps: usize) -> Run {
run_with(seed, max_steps, &mut |_| {})
}
/// [`run`], telling `on_step` about each step before it runs (to see where a hang happened).
pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> Run {
let mut r = Rng::new(seed);
let mut s = Session::default();
let mut run = Run { seed, steps: Vec::new(), outcome: Outcome::ok(), unsupported: Vec::new(), rejected: 0 };
let mut last_feature: Option<String> = None;
let n = 2 + r.below(max_steps.max(2) - 1);
let mut tries = 0;
while run.steps.len() < n && tries < n * 4 {
tries += 1;
let st = if run.steps.is_empty() { Some(base(&mut r)) } else { next_step(&mut r, &s, &last_feature) };
let Some(st) = st else { continue };
let before = s.doc.features.len();
on_step(&st);
let out = apply(&mut s, &st);
if out.kind == "rejected" || out.kind == "unsupported" {
// Not this one: undo what it added and try another.
while s.doc.features.len() > before {
s.doc_mut().features.pop();
}
s.refresh();
if out.kind == "rejected" {
run.rejected += 1;
} else {
run.unsupported.push((st.kind.to_string(), out.detail));
}
continue;
}
let kind = st.kind;
run.steps.push(st);
if out.kind != "ok" {
run.outcome = out;
return run;
}
if matches!(kind, "join box" | "cut box" | "join cylinder" | "cut cylinder" | "cut sphere" | "hole") {
last_feature = s.doc.features.last().map(|f| f.name.clone());
}
}
run
}
/// Run fixed steps (a recorded or minimised design): the index and outcome of the first step
/// that isn't ok (or the last step's, ok).
pub fn replay(steps: &[Step]) -> (usize, Outcome) {
let mut s = Session::default();
for (i, st) in steps.iter().enumerate() {
let out = apply(&mut s, st);
if out.kind != "ok" {
return (i, out);
}
}
(steps.len().saturating_sub(1), Outcome::ok())
}
/// The start of a message, to tell failures apart (numbers vary between versions of a case).
fn gist(d: &str) -> String {
d.chars().take_while(|c| !c.is_ascii_digit()).take(60).collect()
}
/// A failing design cut down: steps are dropped while the last one still fails the same way.
pub fn minimise(run: &Run) -> Run {
let mut steps = run.steps.clone();
let (kind, g) = (run.outcome.kind.clone(), gist(&run.outcome.detail));
let same = |st: &[Step]| {
let (i, o) = replay(st);
i + 1 == st.len() && o.kind == kind && gist(&o.detail) == g
};
let mut changed = true;
while changed && steps.len() > 1 {
changed = false;
let mut i = 0;
while i + 1 < steps.len() {
let mut t = steps.clone();
t.remove(i);
if same(&t) {
steps = t;
changed = true;
} else {
i += 1;
}
}
}
let (_, outcome) = replay(&steps);
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0 }
}
+6
View File
@@ -9,7 +9,9 @@
pub mod cmd;
pub mod frames;
pub mod fuzz;
pub mod legacy_ids;
pub mod licences;
pub mod params;
pub mod recovery;
pub mod sample;
@@ -432,6 +434,10 @@ mod delete_face_tests;
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
mod pattern_suppress_tests;
#[cfg(test)]
mod profile_refs_tests;
#[cfg(test)]
mod split_face_tests;
#[cfg(test)]
mod tests;
+26
View File
@@ -0,0 +1,26 @@
//! SolveCraft's licence texts and the third-party licences, built into every program so a single
//! downloaded executable carries them (Help ▸ About ▸ Licences, `solvecraft-cli licences`).
//! THIRD-PARTY-LICENSES.txt is written by `cargo xtask licences` and checked by `cargo xtask ci`.
pub const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
pub const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
pub const NOTICE: &str = include_str!("../../../NOTICE");
pub const THIRD_PARTY: &str = include_str!("../../../THIRD-PARTY-LICENSES.txt");
/// Everything, as one text.
pub fn all() -> String {
let rule = "=".repeat(80);
format!(
"SolveCraft is licensed under the MIT License or the Apache License, Version 2.0, at your option.\n\n{rule}\nNOTICE\n{rule}\n\n{NOTICE}\n{rule}\nLICENSE-MIT\n{rule}\n\n{LICENSE_MIT}\n{rule}\nLICENSE-APACHE\n{rule}\n\n{LICENSE_APACHE}\n{rule}\n{THIRD_PARTY}"
)
}
#[cfg(test)]
mod tests {
#[test]
fn the_licences_are_built_in() {
assert!(super::LICENSE_MIT.contains("MIT License") && super::LICENSE_APACHE.contains("Apache License"));
assert!(super::THIRD_PARTY.contains("truck") && super::THIRD_PARTY.contains("egui"));
assert!(super::all().contains("NOTICE"));
}
}
@@ -0,0 +1,35 @@
//! A pattern of a suppressed feature has nothing to copy (#42): its copies go too, and the
//! pattern says so.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &Session, body: &str) -> f64 {
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
}
#[test]
fn suppressing_a_patterned_feature_removes_its_copies() {
let mut s = Session::default();
let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap();
s.run_script(&script).unwrap();
let full = volume(&s, "Case");
run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": true}));
let without = volume(&s, "Case");
// All four standoffs go (each 116.8 mm³), not just the seed.
assert!((full - without - 4.0 * 116.8).abs() < 2.0, "{full} → {without}");
let pattern = s.doc.find_feature("RectangularPattern1").unwrap().id;
let r = s.model.result(pattern).unwrap();
assert!(r.error.is_none() && r.warning.as_deref().is_some_and(|w| w.contains("Standoff")), "{r:?}");
// Back on: the four standoffs return.
run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": false}));
assert!((volume(&s, "Case") - full).abs() < 1e-6 * full);
}
+87
View File
@@ -0,0 +1,87 @@
//! Profile picks survive recomputes (#30): picked by index, they are kept by the curves around
//! them, so a face sketch whose projected outline is rebuilt still extrudes the same region.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// The demo enclosure (80 × 60 × 30, rounded, shelled) with a USB port cut through a side, the
/// port picked by index as the Extrude dialog does.
fn enclosure() -> Session {
let mut s = Session::default();
let script = json!([
{"command": "parameters.change", "params": {"name": "width", "expression": "80 mm", "comment": "outside, X"}},
{"command": "parameters.change", "params": {"name": "depth", "expression": "60 mm", "comment": "outside, Y"}},
{"command": "parameters.change", "params": {"name": "height", "expression": "30 mm"}},
{"command": "parameters.change", "params": {"name": "wall", "expression": "2 mm"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Outline"}},
{"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [40, 30]}},
{"command": "sketch.constraint.coincident", "params": {"a": "p5", "b": "origin"}},
{"command": "sketch.dimension", "params": {"entities": ["l1"], "value": "width"}},
{"command": "sketch.dimension", "params": {"entities": ["l2"], "value": "depth"}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "height", "body_name": "Enclosure"}},
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": "wall"}},
{"command": "sketch.create", "params": {"plane": {"face": [40, 10, 20]}, "name": "USB port"}},
{"command": "sketch.rectangle.center", "params": {"center": [0, 12], "corner": [6, 15.5]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "-wall", "operation": "cut", "profiles": [1]}}
]);
s.run_script(&script).unwrap();
s
}
fn bodies(s: &Session) -> Vec<(String, f64)> {
s.model.state().bodies.iter().map(|b| (b.name.clone(), b.body.tessellate(1e-2).unwrap().measure().volume)).collect()
}
#[test]
fn a_port_picked_by_index_stays_a_cut_after_a_width_change() {
let mut s = enclosure();
let f = s.doc.features.last().unwrap();
let solvecraft_doc::FeatureKind::Extrude { profiles, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert!(matches!(profiles, solvecraft_doc::ProfileSel::Curves { .. }), "stored by curves: {profiles:?}");
let before = bodies(&s);
assert_eq!(before.len(), 1, "{before:?}");
run(&mut s, "parameters.change", json!({"name": "width", "expression": "100 mm"}));
let after = bodies(&s);
assert_eq!(after.len(), 1, "the port is still a cut, not a new body: {after:?}");
// The longer box less the same port: more wall and floor, the port still taken out.
assert!(after[0].1 > before[0].1 + 1000.0, "{before:?} {after:?}");
let id = s.doc.features.last().unwrap().id;
assert!(s.model.result(id).is_some_and(|r| r.error.is_none()), "{:?}", s.model.result(id));
}
/// A pick the sketch no longer has is an error on the feature, never a different profile.
#[test]
fn a_lost_profile_is_an_error_not_another_pick() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10], "ids": ["a", "b", "c", "d"]}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 5], "radius": 3, "id": "ring"}));
run(&mut s, "sketch.finish", json!({}));
let ring = s.model.state().sketches[0].profiles.iter().position(|p| p.outer_curves == ["ring"]).unwrap();
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [ring], "distance": 5}));
let n = s.model.state().bodies.len();
assert_eq!(n, 1);
// Delete the picked circle: the extrude fails with a message instead of taking the square.
let sk = s.doc.find_feature("S").unwrap().id;
let picked = match &s.doc.features.last().unwrap().kind {
solvecraft_doc::FeatureKind::Extrude { profiles: solvecraft_doc::ProfileSel::Curves { loops }, .. } => loops[0].clone(),
k => panic!("{k:?}"),
};
run(&mut s, "sketch.edit", json!({"sketch": sk}));
run(&mut s, "sketch.delete", json!({"entities": picked}));
run(&mut s, "sketch.finish", json!({}));
assert!(s.model.state().bodies.is_empty(), "no silent re-pick: {:?}", bodies(&s));
let id = s.doc.features.last().unwrap().id;
assert!(s.model.result(id).is_some_and(|r| r.error.is_some()), "{:?}", s.model.result(id));
}
+35
View File
@@ -62,3 +62,38 @@ fn a_tool_that_misses_is_refused() {
assert!(s.execute("solid.split_face", &json!({"faces": [[10, 15, 20]], "plane": {"origin": [100, 0, 0], "normal": [1, 0, 0]}})).is_err());
assert_eq!(s.doc.features.len(), n);
}
#[test]
fn export_keeps_split_faces_through_later_features() {
let mut s = boxed();
run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}}));
// A later feature rebuilds the body; the split stays a split in the file.
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 0]], "radius": 2}));
let (f, _) = faces_volume(&mut s, "B");
assert_eq!(f, 8);
let dir = std::env::temp_dir().join(format!("solvecraft-split-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let step = dir.join("split.step");
run(&mut s, "file.export", json!({"path": step.to_string_lossy()}));
let text = std::fs::read_to_string(&step).unwrap();
let _ = std::fs::remove_dir_all(&dir);
// Six box faces, the top's second piece and the fillet.
assert_eq!(text.matches("ADVANCED_FACE(").count(), 8, "{text}");
}
#[test]
fn split_pieces_count_and_move_apart() {
let mut s = boxed();
run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}}));
// Measure counts the two halves of the top as two faces.
let m = run(&mut s, "inspect.measure", json!({ "bodies": ["B"] }))["bodies"][0].clone();
assert_eq!(m["faces"].as_u64(), Some(7), "{m}");
// Offset Face on one half moves that half only (Fusion): a 20 × 30 step 5 high.
run(&mut s, "solid.offset_face", json!({"faces": [[10, 15, 20]], "distance": 5}));
let (_, v) = faces_volume(&mut s, "B");
assert!((v - 27000.0).abs() < 1e-6 * 27000.0, "{v}");
// And the other half down 3: a 20 × 30 pocket.
run(&mut s, "solid.offset_face", json!({"faces": [[30, 15, 20]], "distance": -3}));
let (_, v) = faces_volume(&mut s, "B");
assert!((v - 25200.0).abs() < 1e-6 * 25200.0, "{v}");
}
+105
View File
@@ -21,6 +21,7 @@ fn rel(a: f64, b: f64) -> f64 {
}
mod component_frames;
mod robustness;
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
#[test]
@@ -636,6 +637,19 @@ fn section_analysis() {
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
run(&mut s, "inspect.section", json!({"clear": true}));
assert!(s.section.is_none());
// XZ's normal is -Y: an offset moves the cut to -Y, `at` places it at a world Y, and the
// result says where the cut landed either way.
let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10}));
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(-10.0)), "{r}");
for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] {
let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip}));
assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}");
let (o, _) = s.section.unwrap();
assert!(((o.x + o.y + o.z) - at).abs() < 1e-12, "{plane} {o:?}");
}
assert!(s.execute("inspect.section", &json!({"plane": "XZ", "at": 1, "offset": 1})).is_err());
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 1, 0]}, "at": 1})).is_err());
run(&mut s, "inspect.section", json!({"clear": true}));
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(s.undo.len(), undo);
}
@@ -1068,6 +1082,29 @@ fn chord_and_variable_fillets() {
let removed = (1.0 - PI / 4.0) * 20.0 * (4.0 + 10.0 + 25.0) / 3.0;
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v}");
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable"})).is_err());
// A radius through a middle point (a user parameter), straight between: 2 → 4 → 2.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
run(&mut s, "parameters.add", json!({"name": "waist", "expression": "4 mm"}));
run(
&mut s,
"solid.fillet",
json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[0.5, "waist"]]}),
);
let vol = |s: &Session| solvecraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume;
let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 8.0 + 16.0) / 3.0;
assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s));
// The middle radius follows its parameter.
run(&mut s, "parameters.change", json!({"name": "waist", "expression": "3 mm"}));
let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 6.0 + 9.0) / 3.0;
assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s));
assert!(
s.execute(
"solid.fillet",
&json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[1.5, 3]]})
)
.is_err()
);
}
/// Components export as a STEP assembly: products, occurrences, placements.
@@ -1823,3 +1860,71 @@ fn boundary_fill_cells() {
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 2.0 * 10.0 * 20.0 * 20.0) < 1e-9, "{m}");
assert!(s.execute("SurfaceSculpt", &json!({"tools": ["A"], "cells": [[100, 100, 100]]})).is_err());
}
/// Rectangles name their sides and corners, so they can be dimensioned without inspecting the
/// sketch first, whichever way round the corners were given.
#[test]
fn rectangle_names_sides_and_corners() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 30], "p1": [0, 0]}));
let pos = |s: &Session, id: &Value| {
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
sk.point(sk.resolve_point(id.as_str().unwrap()).unwrap()).unwrap()
};
for (corner, at) in [("bottom_left", [0.0, 0.0]), ("bottom_right", [40.0, 0.0]), ("top_right", [40.0, 30.0]), ("top_left", [0.0, 30.0])] {
let p = pos(&s, &r["corners"][corner]);
assert!((p.x - at[0]).abs() < 1e-9 && (p.y - at[1]).abs() < 1e-9, "{corner}: {p:?} in {r}");
}
for (side, from, to) in [
("bottom", "bottom_left", "bottom_right"),
("right", "bottom_right", "top_right"),
("top", "top_right", "top_left"),
("left", "top_left", "bottom_left"),
] {
let line = r["sides"][side].as_str().unwrap();
assert!(r["curves"].as_array().unwrap().iter().any(|c| c == line), "{r}");
assert_eq!(pos(&s, &json!(format!("{line}.start"))), pos(&s, &r["corners"][from]), "{side} in {r}");
assert_eq!(pos(&s, &json!(format!("{line}.end"))), pos(&s, &r["corners"][to]), "{side} in {r}");
}
run(&mut s, "sketch.constraint.coincident", json!({"a": r["corners"]["bottom_left"], "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [r["sides"]["bottom"]], "value": 50}));
run(&mut s, "sketch.dimension", json!({"entities": [r["sides"]["left"]], "value": 20}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
let p = pos(&s, &r["corners"]["top_right"]);
assert!((p.x - 50.0).abs() < 1e-6 && (p.y - 20.0).abs() < 1e-6, "{p:?}");
let c = run(&mut s, "sketch.rectangle.center", json!({"center": [100, 0], "corner": [90, 5]}));
let p = pos(&s, &c["corners"]["bottom_left"]);
assert!((p.x - 90.0).abs() < 1e-9 && (p.y + 5.0).abs() < 1e-9, "{c}");
assert_eq!(pos(&s, &json!(format!("{}.start", c["sides"]["top"].as_str().unwrap()))), pos(&s, &c["corners"]["top_right"]), "{c}");
}
/// Points drawn exactly onto an existing point (the origin, a corner) are connected to it;
/// `connect: false` leaves them free, and tying them again by hand is a no-op, not a conflict.
#[test]
fn drawing_on_existing_points_connects_to_them() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let n = |v: &Value| v["constraints"].as_array().map_or(0, Vec::len);
// Horizontal/vertical x4, plus the corner on the origin.
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
assert_eq!(n(&r), 5, "{r}");
run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][0]], "value": 40}));
run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][1]], "value": 30}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "fully constrained without a hand-made coincident: {si}");
let again = run(&mut s, "sketch.constraint.coincident", json!({"a": "origin", "b": "l1.start"}));
assert_eq!(again["existing"], true, "{again}");
// A line from a corner, a circle on a corner and a point on the origin connect too.
assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[40, 30], [60, 30]]}))), 1);
assert_eq!(n(&run(&mut s, "sketch.circle.center", json!({"center": [0, 30], "radius": 3}))), 1);
assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [0, 0]}))), 1);
// Close is not on it, and `connect: false` opts out.
assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [40.001, 0]}))), 0);
assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -10]], "connect": false}))), 0);
let si = run(&mut s, "sketch.inspect", json!({}));
assert_ne!(si["status"], "conflict", "{si}");
}
+312
View File
@@ -0,0 +1,312 @@
//! Cases found by the robustness runs (`solvecraft-cli fuzz`, docs/robustness.md), cut down to
//! the steps that matter, and a fixed set of seeds that must keep running clean.
use std::f64::consts::PI;
use serde_json::{Value, json};
use super::{run, volume};
use crate::Session;
use crate::fuzz;
/// Run a script; every command must succeed and every body be a sound solid.
fn script(steps: Value) -> Session {
let mut s = Session::default();
for st in steps.as_array().into_iter().flatten() {
let (c, p) = (st["command"].as_str().unwrap_or(""), &st["params"]);
if let Err(e) = s.execute(c, p) {
panic!("{c} {p}: {e}");
}
}
for b in &s.model.state().bodies {
let bad = b.body.validity();
assert!(bad.is_empty(), "{}: {bad:?}", b.name);
}
s
}
/// Shell of a notched plate closed by a shallow arc, opening a notch wall: the arc's cylinder
/// meets the plates at an angle, so its corners need more than one linear step to move.
#[test]
fn shell_with_a_shallow_arc_side() {
script(json!([
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.line", "params": {"points": [[0, 0], [37.5, 0], [37.5, 37.5], [22.5, 37.5], [22.5, 20.0], [12.5, 20.0], [12.5, 37.5], [0, 37.5]]}},
{"command": "sketch.arc.three_point", "params": {"end": [0, 0], "start": [0, 37.5], "through": [-5.0, 18.75]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 7.5}},
{"command": "solid.shell", "params": {"faces": [[12.5, 28.75, 3.75]], "thickness": 2.0}}
]));
}
/// Shell of a plate with a hole whose wall meets the plate's arc side: the edge where the two
/// cylinders meet is neither a line nor an arc, and is rebuilt through its moved points.
#[test]
fn shell_with_a_hole_through_a_curved_side() {
script(json!([
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.line", "params": {"points": [[0, 0], [42.5, 0], [42.5, 20.0], [0, 20.0]]}},
{"command": "sketch.arc.three_point", "params": {"end": [0, 0], "start": [0, 20.0], "through": [-10.0, 10.0]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 27.5}},
{"command": "solid.cylinder", "params": {"axis": [1.0, 0.0, 0.0], "base": [-10.000000000000002, 12.5, 12.5], "height": 17.5, "operation": "cut", "radius": 5.0}},
{"command": "solid.shell", "params": {"faces": [[42.5, 10.0, 13.75]], "thickness": 2.0}}
]));
}
/// Shell of a body in two pieces (a cylinder joined clear of a ring): each piece is shelled.
#[test]
fn shell_of_a_body_in_two_pieces() {
script(json!([
{"command": "sketch.create", "params": {"plane": "XZ"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [2.5, 0], "p1": [22.5, 17.5]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.revolve", "params": {"angle": "360 deg", "axis": "Z"}},
{"command": "solid.cylinder", "params": {"axis": [0.0, 1.0, 0.0], "base": [-22.5, 22.5, 0.0], "height": 30.0, "operation": "join", "radius": 10.0}},
{"command": "solid.shell", "params": {"faces": [[-22.5, 22.5, 0.0]], "thickness": 2.0}}
]));
}
/// A shell thicker than a step under a notch would turn the cavity inside out: a clear "not
/// supported yet" error instead of a failed boolean.
#[test]
fn shell_thicker_than_a_step_says_so() {
let mut s = Session::default();
for (c, p) in [
("solid.box", json!({"height": 7.5, "length": 30.0, "width": 35.0})),
("solid.box", json!({"corner": [0.0, 30.0, 2.5], "height": 22.5, "length": 22.5, "operation": "cut", "width": 20.0})),
] {
s.execute(c, &p).unwrap();
}
let e = s.execute("solid.shell", &json!({"faces": [[18.75, 35.0, 2.5]], "thickness": 1.5})).unwrap_err();
assert!(e.to_string().contains("not supported yet"), "{e}");
}
/// Panics inside the kernel are caught natively, but abort the web build (wasm can't unwind).
/// Building the samples must not panic at all (the Plastic Enclosure's bosses used to, 8 times).
#[test]
fn samples_build_without_panics() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let me = std::thread::current().id();
let count = Arc::new(AtomicUsize::new(0));
let c2 = count.clone();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if std::thread::current().id() == me {
c2.fetch_add(1, Ordering::SeqCst);
}
let _ = info;
}));
let mut failed = Vec::new();
for sample in crate::sample::samples() {
let mut s = Session::default();
for c in (sample.script)()["commands"].as_array().into_iter().flatten() {
let _ = s.execute(c["command"].as_str().unwrap_or(""), &c["params"]);
}
let n = count.swap(0, Ordering::SeqCst);
if n > 0 {
failed.push(format!("{}: {n} panic(s)", sample.name));
}
}
std::panic::set_hook(prev);
assert!(failed.is_empty(), "{failed:?}");
}
fn total_volume(s: &Session) -> f64 {
s.model.state().bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).map(|m| m.volume).unwrap_or(0.0)).sum()
}
/// Volume of a box L×W×H with its vertical edges rounded (R) and its bottom loop filleted
/// (ρ ≤ R): the straight runs lose (1 − π/4)ρ² each, the corners a quarter turn of that
/// (Pappus, about the corner's axis).
fn rounded_box(l: f64, w: f64, h: f64, r: f64, rho: f64) -> f64 {
use std::f64::consts::PI;
let a = (1.0 - PI / 4.0) * rho * rho;
let xbar = rho * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI));
l * w * h - (4.0 - PI) * r * r * h - a * (2.0 * (l - 2.0 * r) + 2.0 * (w - 2.0 * r)) - 2.0 * PI * (r - xbar) * a
}
/// Box, fillets, shell: a 60×40×30 box with its vertical edges rounded r5 and its bottom loop
/// r3, shelled 2 mm with the top open. The bottom round runs round the corners as spindle tori
/// (the tube wider than its circle), which the offset now takes.
#[test]
fn shell_of_a_filleted_box() {
let s = script(json!([
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 15], [60, 0, 15], [60, 40, 15], [0, 40, 15]], "radius": 5}},
{"command": "solid.fillet", "params": {"edges": [[30, 0, 0], [60, 20, 0], [30, 40, 0], [0, 20, 0]], "radius": 3}},
{"command": "solid.shell", "params": {"faces": [[30, 20, 30]], "thickness": 2}}
]));
let want = rounded_box(60.0, 40.0, 30.0, 5.0, 3.0) - rounded_box(56.0, 36.0, 28.0, 3.0, 1.0);
let v = total_volume(&s);
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
}
/// The first tutorial part: a 60×40×15 plate with its top front edge and front right edge
/// rounded r2, shelled with the top open. At 2 mm the rounds' inner sides collapse to sharp
/// edges (as in Fusion); at 1.5 mm the top's round, tangent to the open top, keeps its
/// tangency (every face moves in, the opening is swept out through the top).
#[test]
fn shell_of_the_tutorial_plate() {
let base = json!([
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}},
{"command": "solid.fillet", "params": {"edges": [[30, 0, 15], [60, 0, 7]], "radius": 2}}
]);
let s0 = script(base.clone());
let body = total_volume(&s0);
// 2 mm: the cavity is a sharp 56 × 36 box from z = 2 up through the top.
let mut steps = base.as_array().cloned().unwrap_or_default();
steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 2}}));
let v = total_volume(&script(Value::Array(steps)));
let want = body - 56.0 * 36.0 * 13.0;
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
// 1.5 mm: a 57 × 37 × 12 cavity with r0.5 rounds, opened by its top face (57 × 36.5)
// swept up through the top (estimated: the rounds' mitre is left out, hence 1e-3).
let mut steps = base.as_array().cloned().unwrap_or_default();
steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 1.5}}));
let v = total_volume(&script(Value::Array(steps)));
let a = (1.0 - std::f64::consts::PI / 4.0) * 0.25;
let want = body - (57.0 * 37.0 * 12.0 - a * (57.0 + 12.0)) - 57.0 * 36.5 * 1.5;
assert!((v - want).abs() < 1e-3 * want, "{v} vs {want}");
}
/// Shell after a drilled hole (its tip a cone): the cone moves like the other faces.
#[test]
fn shell_after_a_drilled_hole() {
for open in [[30.0, 0.0, 7.5], [10.0, 10.0, 15.0]] {
script(json!([
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}},
{"command": "solid.hole", "params": {"position": [30, 20, 15], "diameter": 5, "depth": 8, "type": "drilled"}},
{"command": "solid.shell", "params": {"faces": [open], "thickness": 2}}
]));
}
}
/// Rounding a box's edges one at a time: an edge running into an earlier round (of the same
/// radius or another) takes off about what each round alone would (the corner where they meet
/// is small).
#[test]
fn fillet_into_an_earlier_round() {
let box_ = json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}});
for r2 in [2.0, 3.0] {
let s = script(json!([
box_,
{"command": "solid.fillet", "params": {"edges": [[30, 0, 15]], "radius": 2}},
{"command": "solid.fillet", "params": {"edges": [[60, 0, 7]], "radius": r2}}
]));
let lost = |r: f64, len: f64| (1.0 - PI / 4.0) * r * r * len;
let want = 36000.0 - lost(2.0, 60.0) - lost(r2, 15.0);
let v = total_volume(&s);
assert!((v - want).abs() < 2.0, "r2 {r2}: {v} vs {want}");
}
}
/// Seeds that run clean: no failure, no invalid body, no volume going the wrong way.
#[test]
fn fixed_seeds_run_clean() {
for seed in FIXED_SEEDS {
let r = fuzz::run(*seed, 8);
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
}
}
const FIXED_SEEDS: &[u64] = &[3, 5, 11, 16, 21, 29, 33, 35];
/// A drilled hole as deep as the plate: the drill point breaks through the bottom.
#[test]
fn hole_as_deep_as_the_plate() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 10}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [25, 12.5, 10], "diameter": 6, "depth": 10, "type": "drilled"}));
let v = volume(&mut s);
assert!(v < v0 - PI * 9.0 * 9.0 && v > v0 - PI * 9.0 * 10.0 - 1.0, "{v0} {v}");
// Other sizes and places, the defaults, and a tapped hole.
for (i, p) in [
json!({"position": [10, 10, 10], "diameter": 5, "depth": 10, "type": "drilled"}),
json!({"position": [40, 15, 10], "diameter": 5, "depth": 10}),
json!({"position": [12, 6, 10], "diameter": 8.5, "depth": 10}),
json!({"position": [30, 7, 10], "thread": "M6", "depth": 10}),
json!({"position": [20, 18, 10], "diameter": 4, "depth": 10, "tip_angle": 90}),
]
.into_iter()
.enumerate()
{
let before = volume(&mut s);
let r = s.execute("solid.hole", &p);
assert!(r.is_ok(), "{i} {p}: {r:?}");
assert!(volume(&mut s) < before - 1.0, "{i} {p}");
}
}
/// An enclosure built in the usual order: shell, bosses, then the lip on the rim.
#[test]
fn lip_after_boss() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut s, "plastic.boss", json!({"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
run(&mut s, "plastic.boss", json!({"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
let v0 = volume(&mut s);
run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
let v = volume(&mut s);
assert!(v > v0 + 1.0, "{v0} {v}");
// Bosses up to the rim, with ribs, against a wall; lips, grooves and outside rims.
let bosses = [
json!({"position": [15, 15, 2], "diameter": 8, "height": 28, "hole_diameter": 3}),
// Ribs out to the inner walls (15 − 4 − 9 = 2), and short of them.
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 9}),
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6}),
json!({"position": [5.5, 25, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}),
json!({"position": [40, 25, 2], "diameter": 8, "height": 20, "draft": "1 deg", "fillet": 1}),
];
let lips = [
json!({"face": [1, 25, 30], "width": 1, "height": 2}),
json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove"}),
json!({"face": [1, 25, 30], "width": 1, "height": 2, "side": "outside"}),
];
for (i, b) in bosses.iter().enumerate() {
for (j, l) in lips.iter().enumerate() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
let r = s.execute("plastic.boss", b);
assert!(r.is_ok(), "boss {i}: {r:?}");
let v0 = volume(&mut s);
let r = s.execute("plastic.lip", l);
assert!(r.is_ok(), "boss {i} lip {j}: {r:?}");
assert!((volume(&mut s) - v0).abs() > 1.0, "boss {i} lip {j}");
}
}
}
/// A lip, a groove and an outside lip on the rim of a rounded, shelled enclosure (#29: the
/// lip's walls continue the shell's walls, which a boolean join couldn't do).
#[test]
fn lip_on_a_rounded_shelled_rim() {
use std::f64::consts::PI;
let base = json!([
{"command": "solid.box", "params": {"corner": [-40, -30, 0], "length": 80, "width": 60, "height": 30}},
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": 2}}
]);
let v0 = total_volume(&script(base.clone()));
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
let inside = ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0);
for (extra, want) in [
(json!({}), v0 + inside * 1.5),
(json!({"side": "outside"}), v0 + (ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0)) * 1.5),
// (The groove's band comes from the rim's meshed outline: within its sag.)
(json!({"type": "groove"}), v0 - inside * 1.5),
] {
let mut p = json!({"face": [0, 29, 30], "width": 1, "height": 1.5});
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
let mut steps = base.as_array().cloned().unwrap_or_default();
steps.push(json!({"command": "plastic.lip", "params": p}));
let v = total_volume(&script(Value::Array(steps)));
assert!((v - want).abs() < 1e-3 * want, "{extra}: {v} vs {want}");
}
}
+63 -5
View File
@@ -124,16 +124,28 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() };
let b = bounds(s);
sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);
// Grid on XY.
// Grid on the ground plane (XY with Z up, XZ with Y up).
let (minor, major) = grid_step(cam.half_height());
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
let c = Vec3::new((cam.target.x / major).round() * major, (cam.target.y / major).round() * major, 0.0);
let tf = cam.to_frame(cam.target);
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
let w = |p: Vec3| cam.from_frame(p);
let n = ((ext / minor) as i64).clamp(1, 400);
for i in -n..=n {
let t = i as f64 * minor;
let col = if (t / major).fract().abs() < 1e-9 { colors::GRID_MAJOR } else { colors::GRID };
sc.lines.push(SceneLine { points: vec![c + Vec3::new(t, -ext, 0.0), c + Vec3::new(t, ext, 0.0)], color: col, width: 0.6, on_top: false });
sc.lines.push(SceneLine { points: vec![c + Vec3::new(-ext, t, 0.0), c + Vec3::new(ext, t, 0.0)], color: col, width: 0.6, on_top: false });
sc.lines.push(SceneLine {
points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))],
color: col,
width: 0.6,
on_top: false,
});
sc.lines.push(SceneLine {
points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))],
color: col,
width: 0.6,
on_top: false,
});
}
let axis = cam.half_height() * 0.25;
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
@@ -142,7 +154,17 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
for body in &st.bodies {
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
let mesh = body.mesh();
sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color: if selected { colors::BODY_SELECTED } else { colors::BODY } });
// The body's appearance (or imported colour) and faces with looks of their own, as in
// the viewport.
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
let color =
if selected { colors::BODY_SELECTED } else { s.doc.body_color(body).map_or(colors::BODY, |[r, g, b]| Rgb(to8(r), to8(g), to8(b))) };
let face_colors = if selected {
Vec::new()
} else {
s.doc.face_colors(body).into_iter().map(|(f, l)| (f as u32, Rgb(l.color[0], l.color[1], l.color[2]))).collect()
};
sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color, face_colors });
for (i, e) in mesh.edges.iter().enumerate() {
if mesh.seams.get(i).copied().unwrap_or(false) {
continue;
@@ -236,3 +258,39 @@ fn constructs(s: &Session) -> Vec<(u64, String, solvecraft_doc::construct::Const
})
.collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
/// The CPU renderer (snapshots) shows appearances as the viewport does: a body's colour and
/// faces with looks of their own.
#[test]
fn snapshot_scene_carries_appearances() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
s.execute("solid.cylinder", &json!({"diameter": 14, "height": 30, "base": [50, 15, 0], "operation": "new"})).unwrap();
s.execute("appearance.assign", &json!({"bodies": ["Body2"], "color": [220, 60, 40]})).unwrap();
s.execute("appearance.assign", &json!({"faces": [[20, 15, 20]], "body": "Body1", "color": [40, 170, 80]})).unwrap();
let cam = home_camera(&s);
let sc = scene(&s, &cam);
assert!(sc.meshes.iter().any(|m| m.color == Rgb(220, 60, 40)));
assert!(sc.meshes.iter().any(|m| m.face_colors.iter().any(|(_, c)| *c == Rgb(40, 170, 80))));
// And the rendered picture has the green top.
let img = solvecraft_render::render(&sc, &cam, 200, 140);
assert!(img.rgba.chunks(4).any(|p| p[1] > 120 && p[0] < 90 && p[2] < 110), "no green pixels");
}
/// Curved surfaces get a dark silhouette line in snapshots where they turn away.
#[test]
fn snapshots_draw_silhouettes_on_curved_surfaces() {
let mut s = Session::default();
s.execute("solid.sphere", &json!({"diameter": 20})).unwrap();
let cam = home_camera(&s);
let img = solvecraft_render::render(&scene(&s, &cam), &cam, 400, 400);
let dark = img.rgba.chunks(4).filter(|p| p[0] < 70 && p[1] < 70 && p[2] < 75).count();
assert!(dark > 40, "silhouette pixels: {dark}");
}
}
+2 -2
View File
@@ -285,7 +285,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
return Err(KernelError::Invalid("too many edges".into()));
}
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
// Every edge of a convex planar body: rounded as a whole (sphere corners).
if shape == Shape::Round && edges.len() >= 6 {
let solid = cur.deep_copy();
@@ -700,7 +700,7 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
}
}
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
if let Some(groups) = loop_groups(&cur, edges)? {
for g in groups {
let size = cur.size();
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
if edges.iter().any(|(p, r)| !(p.is_finite() && r.is_finite() && *r > 1e-6 && *r < 1e6)) {
return Err(KernelError::Invalid("fillet size must be positive".into()));
}
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
let mut sel = edges.to_vec();
let mut first: Option<KernelError> = None;
// Earlier rounds at the edges' ends come off and are blended again with the new edges.
+128 -9
View File
@@ -56,11 +56,78 @@ fn edge_frame(faces: &[&mt::Face], edge: &mt::Edge) -> Result<EdgeFrame> {
/// Fillet one straight edge with radius `r0` at its end nearest `start` and `r1` at the other.
pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64) -> Result<Body> {
fillet_variable_points(body, edge_at, start, &[(0.0, r0), (1.0, r1)], false)
}
/// The radius along the edge as a B-spline of the edge parameter (0 at the edge's front):
/// degree, knots and control radii.
struct RadiusLaw {
deg: usize,
knots: Vec<f64>,
radii: Vec<f64>,
}
impl RadiusLaw {
/// Through (position, radius) points sorted by position from 0 to 1: straight between them,
/// or `smooth` (cubic pieces with Catmull-Rom slopes, C¹ through every point).
fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusLaw {
if !smooth || pts.len() < 3 {
let mut knots = vec![0.0];
knots.extend(pts.iter().map(|p| p.0));
knots.push(1.0);
return RadiusLaw { deg: 1, knots, radii: pts.iter().map(|p| p.1).collect() };
}
let n = pts.len();
let at = |i: usize| pts.get(i).copied().unwrap_or((0.0, 0.0));
let slope = |i: usize| {
let (a, b) = (at(i.saturating_sub(1)), at((i + 1).min(n - 1)));
if b.0 > a.0 { (b.1 - a.1) / (b.0 - a.0) } else { 0.0 }
};
let mut knots = vec![0.0; 4];
let mut radii = vec![at(0).1];
for i in 0..n - 1 {
let ((t0, r0), (t1, r1)) = (at(i), at(i + 1));
let h = t1 - t0;
radii.extend([r0 + slope(i) * h / 3.0, r1 - slope(i + 1) * h / 3.0, r1]);
knots.extend(if i + 2 == n { vec![1.0; 4] } else { vec![t1; 3] });
}
RadiusLaw { deg: 3, knots, radii }
}
/// Greville abscissae: where each control radius sits along the edge.
fn greville(&self) -> Vec<f64> {
(0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.deg).sum::<f64>() / self.deg as f64).collect()
}
/// The radius at `t` (sampled for checks).
fn eval(&self, t: f64) -> f64 {
let kv = mt::KnotVec::from(self.knots.clone());
let b = kv.try_bspline_basis_functions(self.deg, t.clamp(0.0, 1.0)).unwrap_or_default();
b.iter().zip(&self.radii).map(|(x, r)| x * r).sum()
}
}
/// Fillet one straight edge with a radius that varies along it: (position, radius) points with
/// positions 0 (the end nearest `start`) to 1 (the other end) and any between, the radius
/// straight between them or `smooth`.
pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &[(f64, f64)], smooth: bool) -> Result<Body> {
body.require_brep("fillet")?;
for r in [r0, r1] {
if !(r.is_finite() && r > 1e-6 && r < 1e6) {
if points.len() < 2 || points.len() > 100 {
return Err(KernelError::Invalid("a variable radius needs 2…100 points".into()));
}
for (t, r) in points {
if !(r.is_finite() && *r > 1e-6 && *r < 1e6) {
return Err(KernelError::Invalid("fillet radii must be positive".into()));
}
if !(t.is_finite() && (0.0..=1.0).contains(t)) {
return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into()));
}
}
let mut pts = points.to_vec();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9);
if pts.first().map(|p| p.0) != Some(0.0) || pts.last().map(|p| p.0) != Some(1.0) || pts.len() < 2 {
return Err(KernelError::Invalid("give the radius at both ends of the edge".into()));
}
let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?;
let size = body.size();
@@ -78,8 +145,17 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
let faces: Vec<&mt::Face> = shell.face_iter().collect();
let fr = edge_frame(&faces, &edge)?;
let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone());
// r0 at the end nearest `start` (the edge keeps its own direction).
let (ra, rb) = if vtx(&v1).dist(start) < vtx(&v0).dist(start) { (r1, r0) } else { (r0, r1) };
// Positions from the end nearest `start` (the edge keeps its own direction).
if vtx(&v1).dist(start) < vtx(&v0).dist(start) {
pts = pts.iter().rev().map(|(t, r)| (1.0 - t, *r)).collect();
}
let law = RadiusLaw::through(&pts, smooth);
let (ra, rb) = (law.eval(0.0), law.eval(1.0));
// The largest radius along the edge decides whether the blend fits.
let rmax = (0..=200).map(|k| law.eval(k as f64 / 200.0)).fold(0.0, f64::max);
if (0..=200).any(|k| law.eval(k as f64 / 200.0) <= 1e-6) {
return Err(KernelError::Invalid("the radius must stay positive along the edge".into()));
}
let (p0, p1) = (vtx(&v0), vtx(&v1));
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
let tol = (size * 1e-7).max(1e-9);
@@ -114,7 +190,7 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
}
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
let along = (other - p).dot(t);
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r) + tol {
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r.max(rmax)) + tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
}
@@ -133,8 +209,18 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
};
let c0 = builder::circle_arc(&a0, &b0, p3(arc_mid(p0, s0, ra)));
let c1 = builder::circle_arc(&a1, &b1, p3(arc_mid(p1, s1, rb)));
let la = builder::line(&a0, &a1);
let lb = builder::line(&b0, &b1);
// Contact curves: the edge moved along each face by the setback of the radius there.
let g = law.greville();
let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result<mt::Edge> {
if law.deg == 1 && law.radii.len() == 2 {
return Ok(builder::line(a, b));
}
let ctrl: Vec<mt::Point3> = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect();
let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?;
mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}")))
};
let la = contact(fr.t1, &a0, &a1)?;
let lb = contact(fr.t2, &b0, &b1)?;
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
@@ -166,8 +252,11 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
// weight sin(φ/2)) at each end, joined linearly.
let w = (fr.phi / 2.0).sin();
let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt);
let ctrl = vec![vec![h(pa0, 1.0), h(pa1, 1.0)], vec![h(p0, w), h(p1, w)], vec![h(pb0, 1.0), h(pb1, 1.0)]];
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)), ctrl);
let row = |t: Vec3, wt: f64, off: bool| -> Vec<mt::Vector4> {
g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect()
};
let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)];
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl);
let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp));
// Outward: away from the arc centre on a convex edge, toward it on a concave one.
let (c, mid) = (p0 + fr.t1 * s0 + fr.n1 * (ra * fr.side), arc_mid(p0, s0, ra));
@@ -260,6 +349,36 @@ mod tests {
assert!(fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 2.0, 50.0).is_err());
}
/// A radius through a middle point: straight pieces (exact: each piece removes
/// (1 − π/4) L (a² + ab + b²) / 3), and smooth (the removed volume integrates the law).
#[test]
fn variable_radius_through_a_middle_point() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let (at, start) = (Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0));
let k = 1.0 - std::f64::consts::FRAC_PI_4;
let pts = [(0.0, 2.0), (0.5, 4.0), (1.0, 2.0)];
let f = fillet_variable_points(&b, at, start, &pts, false).unwrap();
let removed = 2.0 * k * 10.0 * (4.0 + 8.0 + 16.0) / 3.0;
let v = measure(&f).unwrap().volume;
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v} vs {}", 6000.0 - removed);
assert_eq!(f.unmeshed_faces(), 0);
let s = fillet_variable_points(&b, at, start, &pts, true).unwrap();
let law = RadiusLaw::through(&pts, true);
let n = 20000;
let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64 * 20.0;
assert!((law.eval(0.5) - 4.0).abs() < 1e-12 && (law.eval(0.0) - 2.0).abs() < 1e-12);
let v = measure(&s).unwrap().volume;
assert!(rel(v, 6000.0 - k * integral) < 1e-4, "{v} vs {}", 6000.0 - k * integral);
// Unsorted, from the other end: the same.
let r = fillet_variable_points(&b, at, Vec3::new(10.0, 20.0, 30.0), &[(1.0, 2.0), (0.0, 2.0), (0.5, 4.0)], true).unwrap();
assert!(rel(measure(&r).unwrap().volume, v) < 1e-9);
// A middle radius too big for the faces, missing ends, bad positions: refused.
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 40.0), (1.0, 2.0)], false).is_err());
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 4.0)], false).is_err());
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (f64::NAN, 4.0), (1.0, 2.0)], false).is_err());
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (1.0, -1.0)], false).is_err());
}
#[test]
fn chord_length_on_a_right_angle() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
+45 -1
View File
@@ -19,6 +19,9 @@ pub struct Body {
pub(crate) color: Option<[f32; 3]>,
/// Opacity and face colours for export (set by the document; operations drop it).
pub(crate) paint: Option<std::sync::Arc<Paint>>,
/// Lines the user split faces along (Split Face), as polylines: export keeps the faces on
/// either side apart where it would otherwise join pieces of one surface.
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
}
/// How a body looks beyond its colour, for export: its opacity and its faces' own colours.
@@ -278,6 +281,7 @@ impl Body {
}
&& self.color == other.color
&& self.paint == other.paint
&& self.splits == other.splits
}
pub(crate) fn new(solid: Solid) -> Result<Body> {
@@ -302,7 +306,7 @@ impl Body {
if v < 0.0 {
solid.not();
}
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None })
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None, splits: None })
}
/// A deep copy of the solid that can be mutated without affecting other bodies.
@@ -335,6 +339,21 @@ impl Body {
self
}
/// Lines faces were split along on purpose (Split Face), as polylines.
pub fn split_lines(&self) -> Option<&std::sync::Arc<Vec<Vec<Vec3>>>> {
self.splits.as_ref()
}
pub(crate) fn split_keep(&self) -> &[Vec<Vec3>] {
self.splits.as_ref().map(|l| l.as_slice()).unwrap_or(&[])
}
/// The same body with the lines its faces were split along on purpose.
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
self.splits = s.filter(|l| !l.is_empty());
self
}
/// Opacity and face colours for export, if any.
pub fn paint(&self) -> Option<&Paint> {
self.paint.as_deref()
@@ -597,6 +616,31 @@ impl Body {
let mut seen = std::collections::HashSet::new();
self.solid.vertex_iter().filter(|v| seen.insert(v.id())).count()
}
/// What is wrong with the body as a solid: shells that aren't closed and consistently
/// oriented, faces that don't mesh, a volume that isn't positive. Empty when it is sound.
pub fn validity(&self) -> Vec<String> {
if let Some(m) = &self.mesh {
return if crate::meshbody::is_closed(m) { Vec::new() } else { vec!["the mesh is not closed".into()] };
}
let mut out = Vec::new();
for (i, sh) in self.solid.boundaries().iter().enumerate() {
let c = sh.shell_condition();
if c != truck_topology::shell::ShellCondition::Closed {
out.push(format!("shell {i} is {c:?}, not closed"));
}
}
let missing = self.unmeshed_faces();
if missing > 0 {
out.push(format!("{missing} face(s) don't mesh"));
}
match crate::measure(self) {
Ok(m) if m.volume.is_finite() && m.volume > 0.0 => {}
Ok(m) => out.push(format!("volume {}", m.volume)),
Err(e) => out.push(format!("no measure: {e}")),
}
out
}
pub fn shell_count(&self) -> usize {
match &self.mesh {
Some(_) => 1,
+139 -1
View File
@@ -205,6 +205,13 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
let radial = (c - o) - axis * (c - o).dot(axis);
let toward = axis.cross(radial).dot(plane.normal());
let angle = if toward < 0.0 { -angle } else { angle };
// A profile with an edge on the axis: no zero-area face where that edge turns on itself.
if r.holes.is_empty()
&& let Some(b) = revolve_partial_on_axis(plane, &r.outer, axis_origin, d, angle)?
{
out.push(b);
continue;
}
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
out.push(Body::new(solid)?);
}
@@ -226,6 +233,9 @@ pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
v.pop().ok_or_else(|| KernelError::Failed("box".into()))
}
/// Where primitives start their circles (radians from the frame's x axis).
pub(crate) const SEAM_ANGLE: f64 = 0.6131;
/// Cylinder with base centre `base`, axis direction `axis`, radius and height.
pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body> {
let plane = Plane::from_normal(base, axis).ok_or_else(|| KernelError::Invalid("cylinder axis".into()))?;
@@ -233,7 +243,9 @@ pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body
if radius <= 1e-6 {
return Err(KernelError::Invalid("radius must be positive".into()));
}
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] };
// The seams at an odd angle: on the frame's axes they would lie exactly where faces placed
// on a grid cut the cylinder (along a seam edge, which booleans don't survive).
let r = Region2 { outer: Loop2::circle_from(Vec2::ZERO, radius, SEAM_ANGLE), holes: vec![] };
let mut v = extrude(&plane, &[r], 0.0, finite(height, "height")?)?;
v.pop().ok_or_else(|| KernelError::Failed("cylinder".into()))
}
@@ -367,6 +379,132 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
(same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r))
}
/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces,
/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the
/// loop isn't like that.
fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let n = lp.segs.len();
let axis_segs: Vec<usize> =
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
let [k] = axis_segs[..] else { return Ok(None) };
// The open chain after the axis segment, back around to it: only its ends on the axis.
let chain: Vec<Seg2> = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect();
if chain.is_empty() || chain.iter().skip(1).any(|s| on_axis(s.start())) || chain.iter().any(|s| on_axis(s.mid())) {
return Ok(None);
}
// Built with the axis through the world origin, then moved into place.
let shift = plane.to_world(axis_origin);
let local = Plane { origin: plane.origin - shift, ..*plane };
let plane = &local;
let axis = plane.dir_to_world(d);
let rot = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle));
let half = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle * 0.5));
let turn = |m: &mt::Matrix4, p: Vec3| from_p3(mt::EuclideanSpace::from_vec((m * p3(p).to_homogeneous()).truncate()));
guard("revolve", || {
let fail = |m: &str| KernelError::Failed(format!("revolve: {m}"));
let pts: Vec<Vec3> = chain.iter().map(|s| plane.to_world(s.start())).chain(chain.last().map(|s| plane.to_world(s.end()))).collect();
let last = pts.len() - 1;
let pv: Vec<mt::Vertex> = pts.iter().map(|p| builder::vertex(p3(*p))).collect();
// The ends stay where they are (on the axis).
let qv: Vec<mt::Vertex> = pts
.iter()
.enumerate()
.map(|(i, p)| {
if i == 0 || i == last { pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p))) } else { builder::vertex(p3(turn(&rot, *p))) }
})
.collect();
let mut e: Vec<mt::Edge> = Vec::new();
let mut er: Vec<mt::Edge> = Vec::new();
for (i, sg) in chain.iter().enumerate() {
let (Some(a), Some(b), Some(qa), Some(qb)) = (pv.get(i), pv.get(i + 1), qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) };
let edge = match *sg {
Seg2::Line { .. } => builder::line(a, b),
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))),
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b))
}
};
let curve = edge.oriented_curve().transformed(rot);
er.push(mt::Edge::new(qa, qb, curve));
e.push(edge);
}
// Arcs swept by the vertices off the axis.
let mut arcs: Vec<Option<mt::Edge>> = vec![None; pts.len()];
for i in 1..last {
let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue };
if let Some(slot) = arcs.get_mut(i) {
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
}
}
// Side faces: each edge's swept surface, oriented to agree with its boundary.
let mut faces: Vec<mt::Face> = Vec::new();
for (i, sg) in chain.iter().enumerate() {
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
if let Some(Some(c)) = arcs.get(i + 1) {
ws.push(c.clone());
}
ws.push(eri.inverse());
if let Some(Some(c)) = arcs.get(i) {
ws.push(c.inverse());
}
let wire: mt::Wire = ws.into();
let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
let f = if radial {
builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))?
} else {
let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(axis), mt::Rad(angle));
let mut surface = template.face_iter().next().map(|f| f.oriented_surface()).ok_or_else(|| fail("swept face"))?;
// The loop's turning (Newell) against the surface normal halfway round.
let mut nw = Vec3::ZERO;
let poly: Vec<Vec3> = wire
.edge_iter()
.flat_map(|ed| {
let c = ed.oriented_curve();
let (t0, t1) = c.range_tuple();
(0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::<Vec<_>>()
})
.collect();
for (j, p) in poly.iter().enumerate() {
if let Some(q) = poly.get((j + 1) % poly.len()) {
nw += p.cross(*q);
}
}
let probe = turn(&half, plane.to_world(sg.mid()));
let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v));
if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) {
surface = surface.inverse();
}
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
};
faces.push(f);
}
// Caps: the profile at the start and at the end, sharing the axis edge.
let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) };
let axis_edge = builder::line(an, a0);
let start: mt::Wire = e.iter().rev().map(|x| x.inverse()).chain(std::iter::once(axis_edge.inverse())).collect::<Vec<_>>().into();
let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::<Vec<_>>().into();
let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?;
let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?;
let mut last_err = String::new();
for flip in 0..4 {
let mut all = faces.clone();
all.push(if flip & 1 == 0 { cs.clone() } else { cs.inverse() });
all.push(if flip & 2 == 0 { ce.clone() } else { ce.inverse() });
match Solid::try_new(vec![all.into()]) {
Ok(solid) => {
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
return Body::new(solid).map(Some);
}
Err(er) => last_err = er.to_string(),
}
}
Err(fail(&last_err))
})
}
fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
let n = lp.segs.len();
+6 -3
View File
@@ -42,8 +42,11 @@ fn planes(b: &Body) -> Vec<(usize, PlaneFace)> {
/// Push the planar faces of `b` lying in the plane (n, d) — n their outward normal — along n
/// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane.
fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
match push_faces_exact(b, n, d, delta) {
pub(crate) fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
// The exact rebuild, unless it came out unsound (a curved side it couldn't re-sweep).
match push_faces_exact(b, n, d, delta)
.and_then(|r| if r.validity().is_empty() { Ok(r) } else { Err(KernelError::Failed("pushing a face: an unsound result".into())) })
{
Ok(r) => Ok(r),
// Faces of planes and cylinders: every face follows (any neighbours).
Err(e) => {
@@ -66,7 +69,7 @@ fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
}
}
fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
let size = b.size();
let tol = (size * 1e-7).max(1e-9);
let fail = |m: &str| KernelError::Failed(format!("pushing a face: {m}"));
+160
View File
@@ -0,0 +1,160 @@
//! Robustness checks on random inputs (`solvecraft-cli fuzz`, docs/robustness.md): a small
//! seeded random generator, and the boolean volume identity on random pairs of primitives
//! placed on a coarse grid, so coincident faces and tangencies come up often.
use serde::Serialize;
use solvecraft_geom::Vec3;
use crate::{Body, BoolOp, boolean, box_solid, cylinder, measure, sphere, torus};
/// xorshift64*: small, fast, and the same everywhere (wasm included).
#[derive(Clone, Debug)]
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Rng {
// Spread nearby seeds apart (splitmix64), never zero.
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
Rng((z ^ (z >> 31)) | 1)
}
pub fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// 0…n−1 (0 when n is 0).
pub fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
}
/// In [0, 1).
pub fn unit(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
pub fn chance(&mut self, p: f64) -> bool {
self.unit() < p
}
pub fn pick<T: Copy>(&mut self, xs: &[T]) -> Option<T> {
xs.get(self.below(xs.len())).copied()
}
/// A multiple of `step` in [lo, hi].
pub fn grid(&mut self, lo: f64, hi: f64, step: f64) -> f64 {
let n = ((hi - lo) / step).floor().max(0.0) as usize;
lo + step * self.below(n + 1) as f64
}
}
/// A primitive used by the boolean checks, as plain data (so a failure can be written down).
#[derive(Clone, Debug, Serialize)]
pub enum Shape {
Box { a: [f64; 3], b: [f64; 3] },
Cylinder { base: [f64; 3], axis: [f64; 3], radius: f64, height: f64 },
Sphere { center: [f64; 3], radius: f64 },
Torus { center: [f64; 3], major: f64, minor: f64 },
}
impl Shape {
pub fn body(&self) -> crate::Result<Body> {
let v = |p: &[f64; 3]| Vec3::new(p[0], p[1], p[2]);
match self {
Shape::Box { a, b } => box_solid(v(a), v(b)),
Shape::Cylinder { base, axis, radius, height } => cylinder(v(base), v(axis), *radius, *height),
Shape::Sphere { center, radius } => sphere(v(center), *radius),
Shape::Torus { center, major, minor } => torus(v(center), *major, *minor),
}
}
fn random(r: &mut Rng) -> Shape {
let g = 2.5;
let mut p = || [r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g)];
let at = p();
match r.below(10) {
0..=4 => {
let size = [r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g)];
Shape::Box { a: at, b: [at[0] + size[0], at[1] + size[1], at[2] + size[2]] }
}
5..=7 => {
let axis = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).unwrap_or([0.0, 0.0, 1.0]);
Shape::Cylinder { base: at, axis, radius: r.grid(2.5, 10.0, g), height: r.grid(5.0, 30.0, g) }
}
8 => Shape::Sphere { center: at, radius: r.grid(2.5, 12.5, g) },
_ => {
let minor = r.grid(2.5, 5.0, g);
Shape::Torus { center: at, major: minor + r.grid(2.5, 10.0, g), minor }
}
}
}
}
/// One boolean check: the shapes, what went wrong (none when the identity holds).
#[derive(Clone, Debug, Serialize)]
pub struct BoolCase {
pub seed: u64,
pub a: Shape,
pub b: Shape,
/// "ok", or the kind of failure: "error", "invalid", "identity".
pub outcome: String,
pub detail: String,
}
fn vol(b: &Option<Body>) -> crate::Result<f64> {
match b {
Some(b) => Ok(measure(b)?.volume),
None => Ok(0.0),
}
}
/// The two primitives [`boolean_case`] uses for a seed.
pub fn boolean_shapes(seed: u64) -> (Shape, Shape) {
let mut r = Rng::new(seed);
(Shape::random(&mut r), Shape::random(&mut r))
}
/// Union, intersection and cut of two random primitives: each result is a sound solid, and
/// V(A∪B) + V(A∩B) = V(A) + V(B) and V(A−B) = V(A) − V(A∩B), within 0.2 %.
pub fn boolean_case(seed: u64) -> BoolCase {
let (a, b) = boolean_shapes(seed);
let mut case = BoolCase { seed, a: a.clone(), b: b.clone(), outcome: "ok".into(), detail: String::new() };
let fail = |c: &mut BoolCase, kind: &str, d: String| {
c.outcome = kind.into();
c.detail = d;
};
let (Ok(ba), Ok(bb)) = (a.body(), b.body()) else {
fail(&mut case, "error", "a primitive could not be built".into());
return case;
};
let mut res = Vec::new();
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
match boolean(&ba, &bb, op) {
Ok(x) => {
if let Some(body) = &x {
let bad = body.validity();
if !bad.is_empty() {
fail(&mut case, "invalid", format!("{op:?}: {}", bad.join("; ")));
return case;
}
}
res.push(x);
}
Err(e) => {
fail(&mut case, "error", format!("{op:?}: {e}"));
return case;
}
}
}
let v = |b: &Option<Body>| vol(b).unwrap_or(f64::NAN);
let (va, vb) = (v(&Some(ba)), v(&Some(bb)));
let [u, i, c] = &res[..] else { return case };
let (vu, vi, vc) = (v(u), v(i), v(c));
let tol = 2e-3 * (va + vb);
let e1 = (vu + vi - va - vb).abs();
let e2 = (vc - (va - vi)).abs();
if !(e1 <= tol && e2 <= tol) {
fail(&mut case, "identity", format!("A {va:.3}, B {vb:.3}, A∪B {vu:.3}, A∩B {vi:.3}, A−B {vc:.3}: off by {e1:.3} and {e2:.3}"));
}
case
}
+29 -3
View File
@@ -79,7 +79,26 @@ fn inside(p: Vec3, edges: &[mt::Edge], n: Vec3) -> bool {
/// Merge each group of edge-connected coplanar faces into one face. Returns None when nothing
/// changed or the result would not be a valid solid.
fn merge_coplanar(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
/// Do the points lie along one of the lines faces were split along on purpose (polylines;
/// chords of a curved line sag by under a tenth of their length)?
pub(crate) fn on_split_line(pts: &[Vec3], keep: &[Vec<Vec3>]) -> bool {
!pts.is_empty()
&& keep.iter().any(|line| {
let seg = line.windows(2).filter_map(|w| Some(w.first()?.dist(*w.get(1)?))).fold(0.0, f64::max);
let tol = 1e-6 * (1.0 + seg) + if line.len() > 2 { seg * 0.1 } else { 0.0 };
pts.iter().all(|q| line.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| q.dist_to_segment(*a, *b) <= tol)))
})
}
/// Points along an edge (ends and three inside).
pub(crate) fn edge_points(e: &mt::Edge) -> Vec<Vec3> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
let (t0, t1) = c.range_tuple();
(0..=4).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 4.0))).collect()
}
fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec<Vec3>]) -> Option<mt::Shell> {
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
let planes: Vec<Option<(Vec3, f64)>> = faces.iter().map(plane_of).collect();
let mut parent: Vec<usize> = (0..faces.len()).collect();
@@ -90,9 +109,11 @@ fn merge_coplanar(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
}
}
let mut merged_any = false;
for fs in by_edge.values() {
let edges: HashMap<mt::EdgeID, mt::Edge> = shell.edge_iter().map(|e| (e.id(), e)).collect();
for (id, fs) in &by_edge {
if let [a, b] = fs[..]
&& a != b
&& (keep.is_empty() || !edges.get(id).is_some_and(|e| on_split_line(&edge_points(e), keep)))
&& let (Some(Some((na, da))), Some(Some((nb, db)))) = (planes.get(a), planes.get(b))
&& na.dot(*nb) > 1.0 - 1e-9
&& (da - db).abs() < tol
@@ -378,6 +399,11 @@ fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
/// Heal a solid; returns the input unchanged when nothing applies or healing fails.
pub fn heal(solid: Solid, size: f64) -> Solid {
heal_keep(solid, size, &[])
}
/// [`heal`], keeping faces apart along lines they were split along on purpose.
pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
let tol = (size * 1e-7).max(1e-9);
let mut shells = Vec::new();
let mut changed = false;
@@ -391,7 +417,7 @@ pub fn heal(solid: Solid, size: f64) -> Solid {
cur = m;
changed = true;
}
if let Some(m) = merge_coplanar(&cur, tol) {
if let Some(m) = merge_coplanar(&cur, tol, keep) {
cur = m;
changed = true;
}
+15
View File
@@ -141,3 +141,18 @@ fn trimmed_surfaces_sew_into_a_solid() {
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
}
#[test]
fn a_drill_point_round_trips_through_iges() {
// A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the
// apex and straight back).
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap();
let profile =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] };
let hole = crate::revolve(&Plane::XZ, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap();
let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0);
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
round_trip(&drilled);
}
+4 -1
View File
@@ -21,10 +21,12 @@ mod coplanar;
mod curveblend;
mod delete_face;
mod freeform;
pub mod fuzz;
mod heal;
mod helix;
mod iges_in;
mod iges_out;
mod lip;
mod loopblend;
mod measure;
mod meshbody;
@@ -48,13 +50,14 @@ mod topo;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
pub use blend_corner::fillet_radii;
pub use blend_variable::{fillet_chord, fillet_variable};
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
pub use delete_face::delete_faces;
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
pub use iges_out::iges_export_bodies;
pub use lip::raise_band;
pub use measure::{BodyMeasure, measure};
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use offset::with_same_surface;
+223
View File
@@ -0,0 +1,223 @@
//! Raising a band of a planar face (a plastic part's lip): the band along one of the face's
//! boundary loops, a set width into the face, is pushed out along the face's normal. Built
//! directly from the body's faces (the band's walls continue the walls below, which a boolean
//! union can't join: their faces would coincide).
use solvecraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::{KernelError, Result, guard};
fn fail(m: &str) -> KernelError {
KernelError::Failed(format!("raising a band: {m}"))
}
/// A boundary edge of the face, as its ends and (for an arc) a point halfway.
#[derive(Clone, Copy)]
struct Piece {
a: Vec3,
b: Vec3,
mid: Option<Vec3>,
}
/// The planar face of `body` at `at`: its index and outward normal.
fn face_at(body: &Body, at: Vec3) -> Result<(usize, Vec3)> {
let size = body.size();
let mesh = body.tessellate((size * 1e-3).max(1e-3))?;
let near = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter_map(|(t, f)| {
let [a, b, c] = mesh.tri(t)?;
let n = (b - a).cross(c - a).normalized()?;
let q = at - n * (at - a).dot(n);
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9);
inside.then_some(((at - a).dot(n).abs(), *f as usize))
})
.min_by(|x, y| x.0.total_cmp(&y.0));
let (d, f) = near.ok_or_else(|| fail("no face there"))?;
if d > size * 1e-3 {
return Err(fail("no face there"));
}
let face = body.solid.face_iter().nth(f).ok_or_else(|| fail("face"))?;
let mt::Surface::Plane(pl) = face.oriented_surface() else { return Err(fail("the face must be planar")) };
let n = pl.normal();
Ok((f, Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| fail("normal"))?))
}
/// The face's loop moved `w` into the face (to the left of its edges, as a face's boundary has
/// its inside on the left), corners mitred where the offsets don't already meet.
fn offset_loop(pieces: &[Piece], n: Vec3, w: f64, tol: f64) -> Result<Vec<Piece>> {
let left = |a: Vec3, b: Vec3| n.cross(b - a).normalized();
let mut out: Vec<Piece> = Vec::new();
for p in pieces {
out.push(match p.mid {
None => {
let l = left(p.a, p.b).ok_or_else(|| fail("zero edge"))?;
Piece { a: p.a + l * w, b: p.b + l * w, mid: None }
}
Some(m) => {
// Each point moves square to the arc (along its radius), to the left.
let (la, lm, lb) =
(left(p.a, m).ok_or_else(|| fail("arc"))?, left(p.a, p.b).ok_or_else(|| fail("arc"))?, left(m, p.b).ok_or_else(|| fail("arc"))?);
let c = circle_centre(p.a, m, p.b).ok_or_else(|| fail("arc"))?;
let radial = |q: Vec3, hint: Vec3| {
let r = (q - c).normalized().unwrap_or(hint);
if r.dot(hint) >= 0.0 { r } else { -r }
};
let _ = (la, lb);
Piece { a: p.a + radial(p.a, lm) * w, b: p.b + radial(p.b, lm) * w, mid: Some(m + radial(m, lm) * w) }
}
});
}
// Joints: where the offsets don't meet (sharp corners of lines), the lines are extended or
// cut back to their crossing.
let k = out.len();
for i in 0..k {
let j = (i + 1) % k;
let (Some(pi), Some(pj)) = (out.get(i).copied(), out.get(j).copied()) else { continue };
if pi.b.dist(pj.a) < tol {
if let Some(x) = out.get_mut(j) {
x.a = pi.b;
}
continue;
}
match (pi.mid, pj.mid) {
(None, None) => {
let (d1, d2) = (pi.b - pi.a, pj.b - pj.a);
let w0 = pj.a - pi.a;
let cr = d1.cross(d2);
let den = cr.len2();
if den < 1e-24 {
return Err(fail("parallel edges at a corner"));
}
let t = w0.cross(d2).dot(cr) / den;
let x = pi.a + d1 * t;
if let Some(e) = out.get_mut(i) {
e.b = x;
}
if let Some(e) = out.get_mut(j) {
e.a = x;
}
}
_ => return Err(fail("an arc meeting another edge at a corner")),
}
}
Ok(out)
}
fn circle_centre(a: Vec3, m: Vec3, b: Vec3) -> Option<Vec3> {
let (u, v) = (b - a, m - a);
let w = u.cross(v);
let d = 2.0 * w.len2();
if d < 1e-300 {
return None;
}
Some(a + (v.cross(w) * u.len2() + w.cross(u) * v.len2()) * (1.0 / d))
}
/// Raise the band of the planar face at `at` that runs `width` wide along its outer loop
/// (`outside`) or its inner loop, by `height` along the face's normal.
pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool) -> Result<Body> {
body.require_brep("a lip")?;
if !(width > 1e-6 && height > 1e-6 && width.is_finite() && height.is_finite()) {
return Err(KernelError::Invalid("the band's width and height must be positive".into()));
}
if body.solid.boundaries().len() != 1 {
return Err(fail("a body in one piece"));
}
let size = body.size();
let tol = (size * 1e-7).max(1e-9) * 100.0;
let (fi, n) = face_at(body, at)?;
let faces: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
let f = faces.get(fi).ok_or_else(|| fail("face"))?;
let wires = f.boundaries();
if wires.len() != 2 {
return Err(fail("the face must have an outer and one inner loop (a rim)"));
}
// Outer loop: turning positively about n.
let turn = |w: &mt::Wire| -> f64 {
let pts: Vec<Vec3> = w.vertex_iter().map(|v| from_p3(v.point())).collect();
let mut s = Vec3::ZERO;
for (k, p) in pts.iter().enumerate() {
if let Some(q) = pts.get((k + 1) % pts.len()) {
s += p.cross(*q);
}
}
s.dot(n)
};
let (w0, w1) = (wires.first().ok_or_else(|| fail("loop"))?, wires.get(1).ok_or_else(|| fail("loop"))?);
let (outer, inner) = if turn(w0) >= turn(w1) { (w0, w1) } else { (w1, w0) };
let (along, other) = if outside { (outer, inner) } else { (inner, outer) };
let pieces: Vec<Piece> = along
.edge_iter()
.map(|e| {
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
match crate::offset::arc_mid(e, size * 1e-6) {
Some(None) => Ok(Piece { a, b, mid: None }),
Some(Some(m)) => Ok(Piece { a, b, mid: Some(m) }),
None => Err(fail("edges must be lines or arcs")),
}
})
.collect::<Result<_>>()?;
let moved = offset_loop(&pieces, n, width, tol)?;
guard("lip", || {
// The band's other edge, on the face.
let verts: Vec<mt::Vertex> = moved.iter().map(|p| builder::vertex(p3(p.a))).collect();
let k = verts.len();
let mut cedges: Vec<mt::Edge> = Vec::with_capacity(k);
for (i, p) in moved.iter().enumerate() {
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % k)) else { return Err(fail("vertex")) };
cedges.push(match p.mid {
None => builder::line(a, b),
Some(m) => builder::circle_arc(a, b, p3(m)),
});
}
let c: mt::Wire = cedges.into();
// The rest of the face: its other loop, and the band's edge as a loop of its own.
// (The band's edge runs the face's way round: a hole when the band follows the inner
// loop, the outer loop when it follows the outer.)
let rest = mt::Face::try_new(vec![other.clone(), c.clone()], f.oriented_surface()).map_err(|e| fail(&e.to_string()))?;
// Walls: the face's loop and the band's edge swept out; the band's top.
let up = v3(n * height);
let wall_along: mt::Shell = builder::tsweep(along, up);
let wall_c: mt::Shell = builder::tsweep(&c, up);
let top_of = |sh: &mt::Shell| -> Vec<mt::Wire> {
sh.extract_boundaries().into_iter().filter(|w| w.vertex_iter().all(|v| (from_p3(v.point()) - at).dot(n) > height * 0.5)).collect()
};
let flipped = |sh: &mt::Shell, yes: bool| -> mt::Shell {
if yes { sh.face_iter().map(|x| x.inverse()).collect::<Vec<_>>().into() } else { sh.clone() }
};
let mut last = String::new();
for flip in 0..8u32 {
let (wa, wc) = (flipped(&wall_along, flip & 1 == 1), flipped(&wall_c, flip & 2 == 2));
let mut tops = top_of(&wa);
tops.extend(top_of(&wc));
if tops.len() != 2 {
return Err(fail("the band's top"));
}
// The top closes both walls: its loops run against theirs.
let tops: Vec<mt::Wire> = tops.iter().map(|w| w.inverse()).collect();
let Ok(top) = builder::try_attach_plane(&tops) else { continue };
let top = if flip & 4 == 4 { top.inverse() } else { top };
let mut all: Vec<mt::Face> = faces.iter().enumerate().filter(|(i, _)| *i != fi).map(|(_, x)| x.clone()).collect();
all.push(rest.clone());
all.extend(wa.face_iter().cloned());
all.extend(wc.face_iter().cloned());
all.push(top);
match Solid::try_new(vec![all.into()]) {
Ok(s) => {
let b = Body::new(s)?;
if b.validity().is_empty() {
return Ok(b);
}
last = b.validity().join("; ");
}
Err(e) => last = e.to_string(),
}
}
Err(fail(&last))
})
}
+3 -3
View File
@@ -101,7 +101,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
t.swap(1, 2);
}
}
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None })
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None, splits: None })
}
/// Wind every connected piece one way: a triangle that runs a side in the same direction as
@@ -147,7 +147,7 @@ fn unify_winding(m: &mut TriMesh) {
}
/// Every triangle side is shared by exactly two triangles in opposite directions.
fn is_closed(m: &TriMesh) -> bool {
pub(crate) fn is_closed(m: &TriMesh) -> bool {
let mut count: HashMap<(u32, u32), i32> = HashMap::new();
for t in &m.triangles {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
@@ -185,6 +185,6 @@ impl Body {
t.swap(1, 2);
}
}
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone() })
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone(), splits: self.splits.clone() })
}
}
+333 -22
View File
@@ -64,6 +64,15 @@ impl Surf {
}
}
}
/// The gradient of [`Surf::dist`] (the normal before orientation).
fn grad(&self, p: Vec3) -> Option<Vec3> {
match *self {
Surf::Plane { n, .. } => Some(n),
Surf::Cylinder { convex, .. } | Surf::Sphere { convex, .. } | Surf::Cone { convex, .. } | Surf::Torus { convex, .. } => {
self.normal(p).map(|n| if convex { n } else { -n })
}
}
}
fn moved(&self, s: f64) -> Option<Surf> {
match *self {
Surf::Plane { n, d } => Some(Surf::Plane { n, d: d + s }),
@@ -222,6 +231,10 @@ pub(crate) fn surf_of(f: &mt::Face, tol: f64) -> Option<Surf> {
return Some(t);
}
let (p0, n0) = *s.first()?;
// Flat though not built as a plane (a radial line swept round an axis).
if s.iter().all(|(p, n)| n.dot(n0) > 1.0 - 1e-9 && (*p - p0).dot(n0).abs() < tol) {
return Some(Surf::Plane { n: n0, d: n0.dot(p0) });
}
// The normal most square to the first (a half cylinder's two ends are opposite).
let far = s.iter().map(|x| x.1).min_by(|a, b| a.dot(n0).abs().total_cmp(&b.dot(n0).abs()))?;
let Some(a) = n0.cross(far).normalized() else { return sphere_or_cone(&s, tol) };
@@ -277,7 +290,9 @@ fn torus_of(f: &mt::Face, s: &[(Vec3, Vec3)], tol: f64) -> Option<Surf> {
}
let x = crate::polyhedron::solve3_pub(m, rhs)?;
let (big, h, r) = (x[0], x[1], x[2]);
if !(big > 1e-9 && r.abs() > 1e-9 && r.abs() < big) {
// (A tube wider than its circle, a spindle torus, is fine: blends use its outer part, where
// each point's tube centre is the one on its own side of the axis.)
if !(big > 1e-9 && r.abs() > 1e-9) {
return None;
}
let t = Surf::Torus { o, a, big, h, r: r.abs(), convex: r > 0.0 };
@@ -354,9 +369,26 @@ fn sphere_or_cone(s: &[(Vec3, Vec3)], tol: f64) -> Option<Surf> {
/// n_i · δ = s_i for each face's normal n_i at p, then checked against the moved surfaces.
fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) -> Option<Vec3> {
let ns: Vec<Vec3> = old.iter().map(|sf| sf.normal(p)).collect::<Option<_>>()?;
let mut q = p + span_step(&ns, shifts)?;
// The step is exact for planes; a curved face meeting another at an angle needs a few
// Newton steps onto the moved surfaces.
for _ in 0..30 {
let d: Vec<f64> = new.iter().map(|sf| sf.dist(q)).collect();
if d.iter().all(|x| x.abs() < tol) {
return Some(q);
}
let gs: Vec<Vec3> = new.iter().map(|sf| sf.grad(q)).collect::<Option<_>>()?;
let rhs: Vec<f64> = d.iter().map(|x| -x).collect();
q += span_step(&gs, &rhs)?;
}
new.iter().all(|sf| sf.dist(q).abs() < tol).then_some(q)
}
/// The smallest move `v` (in the span of `ns`) with `n_i · v = s_i` (least squares).
fn span_step(ns: &[Vec3], s: &[f64]) -> Option<Vec3> {
// Orthonormal basis of the normals' span (tangent faces share a normal).
let mut basis: Vec<Vec3> = Vec::new();
for n in &ns {
for n in ns {
let mut v = *n;
for e in &basis {
v = v - *e * v.dot(*e);
@@ -369,13 +401,13 @@ fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) ->
let r = basis.len();
let mut m = [[0.0f64; 3]; 3];
let mut rhs = [0.0f64; 3];
for (n, s) in ns.iter().zip(shifts) {
for (n, si) in ns.iter().zip(s) {
let row: Vec<f64> = basis.iter().map(|e| n.dot(*e)).collect();
for i in 0..r {
for j in 0..r {
m[i][j] += row[i] * row[j];
}
rhs[i] += row[i] * s;
rhs[i] += row[i] * si;
}
}
// Pad unused dimensions with the identity.
@@ -383,15 +415,79 @@ fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) ->
row[i] = 1.0;
}
let c = crate::polyhedron::solve3_pub(m, rhs)?;
let mut q = p;
let mut v = Vec3::ZERO;
for (k, e) in basis.iter().enumerate() {
q += *e * c[k];
v += *e * c[k];
}
new.iter().all(|sf| sf.dist(q).abs() < tol).then_some(q)
Some(v)
}
/// A line or circular arc through an edge (by samples): `None` for other curves.
fn arc_mid(e: &mt::Edge, tol: f64) -> Option<Option<Vec3>> {
/// Maximum straight edges per face checked pairwise for crossings.
const MAX_CROSSING_EDGES: usize = 2000;
/// Where the moved straight edges of a planar face cross each other (or a loop of straight
/// edges turns round), if they do. Faces next to a collapsed round are left to the other checks.
fn crossed_loops(
faces: &[mt::Face],
old: &[Surf],
newpos: &HashMap<mt::VertexID, Vec3>,
edge_collapsed: &dyn Fn(&mt::Edge) -> bool,
size: f64,
) -> Option<Vec3> {
let eps = size * 1e-9;
for (f, sf) in faces.iter().zip(old) {
let Surf::Plane { n, .. } = *sf else { continue };
if f.edge_iter().any(|e| edge_collapsed(&e)) {
continue;
}
let (u, w) = (n.any_perp(), n.cross(n.any_perp()));
let flat = |p: Vec3| (p.dot(u), p.dot(w));
// Straight edges as (vertex ids, old ends, new ends) in the plane.
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2])> = Vec::new();
for wire in f.absolute_boundaries() {
let (mut area_old, mut area_new, mut straight) = (0.0, 0.0, true);
for e in wire.edge_iter() {
let (v0, v1) = (e.front(), e.back());
let (Some(q0), Some(q1)) = (newpos.get(&v0.id()), newpos.get(&v1.id())) else { return None };
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(*q0), flat(*q1));
area_old += o0.0 * o1.1 - o1.0 * o0.1;
area_new += n0.0 * n1.1 - n1.0 * n0.1;
if arc_mid(e, size * 1e-6) == Some(None) {
segs.push(([v0.id(), v1.id()], [n0, n1]));
} else {
straight = false;
}
}
if straight && area_old.abs() > eps && area_old * area_new <= 0.0 {
return wire.vertex_iter().next().and_then(|v| newpos.get(&v.id()).copied());
}
}
if segs.len() > MAX_CROSSING_EDGES {
continue;
}
let side = |a: (f64, f64), c: (f64, f64), p: (f64, f64)| (c.0 - a.0) * (p.1 - a.1) - (c.1 - a.1) * (p.0 - a.0);
for (i, (ia, [a0, a1])) in segs.iter().enumerate() {
for (ib, [b0, b1]) in segs.iter().skip(i + 1) {
if ia.iter().any(|v| ib.contains(v)) {
continue;
}
let (s0, s1) = (side(*a0, *a1, *b0), side(*a0, *a1, *b1));
let (t0, t1) = (side(*b0, *b1, *a0), side(*b0, *b1, *a1));
let tol = eps * size;
if ((s0 > tol && s1 < -tol) || (s0 < -tol && s1 > tol)) && ((t0 > tol && t1 < -tol) || (t0 < -tol && t1 > tol)) {
let k = t0 / (t0 - t1);
let (x, y) = (a0.0 + (a1.0 - a0.0) * k, a0.1 + (a1.1 - a0.1) * k);
let d = n.dot(newpos.get(&ia[0]).copied().unwrap_or_default());
return Some(u * x + w * y + n * d);
}
}
}
}
None
}
pub(crate) fn arc_mid(e: &mt::Edge, tol: f64) -> Option<Option<Vec3>> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
let (t0, t1) = c.range_tuple();
@@ -423,8 +519,13 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
let mut old = Vec::with_capacity(faces.len());
let mut new = Vec::with_capacity(faces.len());
let mut shifts = Vec::with_capacity(faces.len());
// Faces moved to nothing (a round thinner than the move): they go, their neighbours meet.
let mut collapsed: Vec<bool> = Vec::with_capacity(faces.len());
for (i, f) in faces.iter().enumerate() {
let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("only bodies with planes, cylinders, cones, spheres and tori"))?;
let sf = surf_of(f, tol * 100.0).ok_or_else(|| {
let at = samples(f).first().map(|(p, _)| format!(" (a face at [{:.2}, {:.2}, {:.2}])", p.x, p.y, p.z)).unwrap_or_default();
fail(&format!("only bodies with planes, cylinders, cones, spheres and tori{at}"))
})?;
let n = match sf {
Surf::Plane { n, .. } => n,
Surf::Cylinder { a, .. } | Surf::Cone { a, .. } | Surf::Torus { a, .. } => a.any_perp(),
@@ -432,7 +533,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
};
let s = shift(i, n);
old.push(sf);
new.push(sf.moved(s).ok_or_else(|| fail("a cylinder shrinks to nothing"))?);
match sf.moved(s) {
Some(x) => {
new.push(x);
collapsed.push(false);
}
None => {
new.push(sf);
collapsed.push(true);
}
}
shifts.push(s);
}
let pick = |ids: &[usize]| -> (Vec<Surf>, Vec<f64>, Vec<Surf>) {
@@ -459,11 +569,102 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
}
}
}
let is_collapsed = |i: usize| collapsed.get(i).copied().unwrap_or(false);
let any_collapsed = collapsed.iter().any(|c| *c);
// A collapsed face's place is taken by the faces around it (through other collapsed ones).
let mut nbrs: HashMap<usize, Vec<usize>> = HashMap::new();
for fs in efaces.values() {
for a in fs {
for b2 in fs {
if a != b2 {
let e = nbrs.entry(*a).or_default();
if !e.contains(b2) {
e.push(*b2);
}
}
}
}
}
let expand = |fs: &[usize]| -> Vec<usize> {
let mut out: Vec<usize> = fs.iter().copied().filter(|i| !is_collapsed(*i)).collect();
let mut queue: Vec<usize> = fs.iter().copied().filter(|i| is_collapsed(*i)).collect();
let mut seen = queue.clone();
while let Some(c) = queue.pop() {
for nb in nbrs.get(&c).into_iter().flatten() {
if is_collapsed(*nb) {
if !seen.contains(nb) {
seen.push(*nb);
queue.push(*nb);
}
} else if !out.contains(nb) {
out.push(*nb);
}
}
}
out
};
// Each edge's ends and middle, and the faces either side.
let edge_pts: Vec<(Vec<usize>, [Vec3; 3])> = b
.solid
.edge_iter()
.filter_map(|e| {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
let (t0, t1) = c.range_tuple();
Some((
efaces.get(&e.id())?.clone(),
[from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()), from_p3(c.subs((t0 + t1) / 2.0))],
))
})
.collect();
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
for v in b.solid.vertex_iter() {
let fs = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
let (o, s, n) = pick(fs);
let at = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
let p = from_p3(v.point());
let fs: Vec<usize> = if at.iter().any(|i| is_collapsed(*i)) {
// On a collapsed round: its own faces, then the faces along the rounds it is on,
// nearest first, until they fix a point.
let group = expand(at);
let mut own: Vec<usize> = at.iter().copied().filter(|i| !is_collapsed(*i)).collect();
let mut cand: Vec<(f64, usize)> = group
.iter()
.copied()
.filter(|nb| !own.contains(nb))
.map(|nb| {
let d = edge_pts
.iter()
.filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i)))
.flat_map(|(_, q)| q.iter().map(|x| x.dist(p)))
.fold(f64::INFINITY, f64::min);
(d, nb)
})
.collect();
cand.sort_by(|x, y| x.0.total_cmp(&y.0));
let rank = |ids: &[usize]| -> usize {
let mut basis: Vec<Vec3> = Vec::new();
for i in ids {
let Some(nv) = old.get(*i).and_then(|sf| sf.normal(p)) else { continue };
let mut w = nv;
for e in &basis {
w = w - *e * w.dot(*e);
}
if let Some(u) = w.normalized().filter(|_| w.len() > 1e-6) {
basis.push(u);
}
}
basis.len()
};
for (_, c) in cand {
if rank(&own) >= 3 {
break;
}
own.push(c);
}
own
} else {
at.clone()
};
let (o, s, n) = pick(&fs);
// A cone's apex goes with the cone (its normal is not defined there).
let apex = o.iter().zip(&n).find_map(|(a, b)| match (a, b) {
(Surf::Cone { v: va, .. }, Surf::Cone { v: vb, .. }) if va.dist(p) < size * 1e-6 => Some(*vb),
@@ -475,18 +676,109 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
};
newpos.insert(v.id(), q);
}
// An edge turned round: faces crossed over (a wall thinner than the move), which would
// leave the body inside out.
let edge_collapsed = |e: &mt::Edge| efaces.get(&e.id()).is_some_and(|fs| fs.iter().any(|i| is_collapsed(*i)));
for e in b.solid.edge_iter() {
if edge_collapsed(&e) {
continue;
}
let (f0, f1) = (e.absolute_front(), e.absolute_back());
let (Some(q0), Some(q1)) = (newpos.get(&f0.id()), newpos.get(&f1.id())) else { continue };
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), *q1 - *q0);
if old_v.len() > size * 1e-6 && old_v.dot(new_v) < 0.0 {
return Err(fail("not supported yet: the move is larger than a wall or step is thick (faces would cross)"));
}
}
// A plane's loops crossing after the move: a rim or ledge narrower than the move (the hole
// in a top face sliding past its outer edge) turns part of the face inside out, though no
// edge turns round.
if let Some(at) = crossed_loops(&faces, &old, &newpos, &edge_collapsed, size) {
return Err(fail(&format!(
"not supported yet: the move is larger than a rim or ledge near [{:.1}, {:.1}, {:.1}] is wide (a face would turn inside out)",
at.x, at.y, at.z
)));
}
guard("offset", || {
let verts: HashMap<mt::VertexID, mt::Vertex> = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect();
let mut edges: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
// Vertices of collapsed faces that land together become one.
let on_collapsed: std::collections::HashSet<mt::VertexID> = faces
.iter()
.enumerate()
.filter(|(i, _)| is_collapsed(*i))
.flat_map(|(_, f)| f.vertex_iter().map(|v| v.id()).collect::<Vec<_>>())
.collect();
let mut verts: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
let mut welded: Vec<(Vec3, mt::Vertex)> = Vec::new();
for v in b.solid.vertex_iter() {
let Some(p) = newpos.get(&v.id()).copied() else { continue };
let nv = if any_collapsed && on_collapsed.contains(&v.id()) {
match welded.iter().find(|(q, _)| q.dist(p) < size * 1e-6) {
Some((_, w)) => w.clone(),
None => {
let w = builder::vertex(p3(p));
welded.push((p, w.clone()));
w
}
}
} else {
builder::vertex(p3(p))
};
verts.insert(v.id(), nv);
}
// Old edge → new edge, and whether it runs the other way (merged with its twin).
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
for e in b.solid.edge_iter() {
if edges.contains_key(&e.id()) {
continue;
}
let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) };
if from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
let touches = edge_collapsed(&e);
if a == c || from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
if touches {
continue;
}
return Err(fail("an edge vanishes (the offset is too large)"));
}
let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("edges must be lines or arcs"))?;
if efaces.get(&e.id()).is_some_and(|fs| fs.iter().all(|i| is_collapsed(*i))) {
continue;
}
if touches {
// Along a collapsed round: a straight edge, shared with its twin from the
// round's other side.
let ne = match merged.get(&(a.id(), c.id())).or_else(|| merged.get(&(c.id(), a.id()))) {
Some(x) => x.clone(),
None => {
let x = builder::line(a, c);
merged.insert((a.id(), c.id()), x.clone());
x
}
};
let flipped = ne.front() != a;
edges.insert(e.id(), (ne, flipped));
continue;
}
let Some(kind) = arc_mid(&e, size * 1e-6) else {
// Another curve (where two cylinders meet): its points moved onto the moved
// faces, through a smooth curve.
use mt::{BoundedCurve, ParametricCurve};
let fs = efaces.get(&e.id()).ok_or_else(|| fail("edge faces"))?;
let (o, s, n) = pick(fs);
let cv = e.curve();
let (t0, t1) = cv.range_tuple();
let k = 16;
let mut pts = vec![from_p3(a.point())];
for j in 1..k {
let p = from_p3(cv.subs(t0 + (t1 - t0) * j as f64 / k as f64));
pts.push(
move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("an edge whose faces don't meet the same way after the move"))?,
);
}
pts.push(from_p3(c.point()));
let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| fail("edge curve"))?;
edges.insert(e.id(), (mt::Edge::new(a, c, mt::Curve::BSplineCurve(curve)), false));
continue;
};
let ne = match kind {
None => builder::line(a, c),
Some(m) => {
@@ -497,16 +789,21 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
builder::circle_arc(a, c, p3(m2))
}
};
edges.insert(e.id(), ne);
edges.insert(e.id(), (ne, false));
}
let mut out = Vec::new();
for (i, f) in faces.iter().enumerate() {
if is_collapsed(i) {
continue;
}
let wires: Vec<mt::Wire> = f
.absolute_boundaries()
.iter()
.map(|w| {
w.edge_iter()
.filter_map(|e| edges.get(&e.id()).map(|ne| if e.front() == e.absolute_front() { ne.clone() } else { ne.inverse() }))
.filter_map(|e| {
edges.get(&e.id()).map(|(ne, flip)| if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() })
})
.collect::<Vec<_>>()
.into()
})
@@ -540,8 +837,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
}
out.push(nf);
}
let shell: mt::Shell = out.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| fail(&e.to_string()))?;
// One shell per shell of the body (pieces, voids), less the collapsed faces.
let mut shells: Vec<mt::Shell> = Vec::new();
let mut it = out.into_iter();
let mut k = 0;
for sh in b.solid.boundaries() {
let keep = (k..k + sh.len()).filter(|i| !is_collapsed(*i)).count();
k += sh.len();
shells.push(it.by_ref().take(keep).collect::<Vec<_>>().into());
}
let solid = Solid::try_new(shells).map_err(|e| fail(&e.to_string()))?;
Body::new(solid)
})
}
@@ -746,7 +1051,7 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom:
}
/// The chosen faces and every face on the same surface joined to them (the halves of a hole's
/// wall), as face indices.
/// wall), as face indices. Pieces of a face split on purpose (Split Face) stay apart.
pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
let faces: Vec<mt::Face> = b.solid.face_iter().cloned().collect();
let size = b.size();
@@ -766,6 +1071,7 @@ pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
}
_ => false,
};
let keep = b.split_keep();
let mut out: Vec<usize> = chosen.to_vec();
let mut grew = true;
while grew {
@@ -777,7 +1083,12 @@ pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
let Some(Some(si)) = surfs.get(i) else { continue };
let joins = out.iter().any(|j| {
surfs.get(*j).and_then(|x| x.as_ref()).is_some_and(|sj| same(si, sj))
&& faces.get(*j).is_some_and(|g| g.edge_iter().any(|e| f.edge_iter().any(|x| x.id() == e.id())))
&& faces.get(*j).is_some_and(|g| {
g.edge_iter().any(|e| {
f.edge_iter().any(|x| x.id() == e.id())
&& (keep.is_empty() || !crate::heal::on_split_line(&crate::heal::edge_points(&e), keep))
})
})
});
if joins {
out.push(i);
+96 -2
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec3;
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3, v3};
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::{KernelError, Result, guard};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -162,6 +162,25 @@ thread_local! {
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
/// and retried with shifted copies and other tolerances when they fail or look wrong.
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let r = boolean_whole(a, b, op);
// Bodies in several pieces: when the whole failed (or came out empty), a piece at a time.
let retry = match &r {
Err(_) => true,
Ok(None) => op != BoolOp::Intersect,
Ok(Some(_)) => false,
};
if retry && (a.solid.boundaries().len() > 1 || b.solid.boundaries().len() > 1) {
let (la, lb) = (a.lumps()?, b.lumps()?);
if (la.len() > 1 || lb.len() > 1)
&& let Ok(Some(x)) = by_lumps(la, lb, op)
{
return Ok(Some(x));
}
}
r
}
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let size = a.size().max(b.size());
@@ -241,6 +260,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
}
}
let mut empty_votes = 0;
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
for (attempt, j) in JITTER.iter().enumerate() {
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
for k in [2e-3, 5e-4] {
@@ -267,7 +287,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
empty_votes += 1;
last = "empty result".into();
}
Ok(s) => match Body::new(crate::heal::heal(s, size)) {
Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) {
Ok(body) => {
let (ok, v) = check(&body);
if ok {
@@ -338,6 +358,80 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
}
/// A boolean of bodies given as their pieces; the result's pieces in one body.
fn by_lumps(la: Vec<Body>, lb: Vec<Body>, op: BoolOp) -> Result<Option<Body>> {
let boxes_meet = |x: &Body, y: &Body| -> bool {
let (Ok(mx), Ok(my)) = (x.tessellate(x.size() * 0.05 + 1e-3), y.tessellate(y.size() * 0.05 + 1e-3)) else { return true };
let (bx, by) = (mx.bounds(), my.bounds());
let g = (x.size() + y.size()) * 0.01;
bx.min.x <= by.max.x + g
&& by.min.x <= bx.max.x + g
&& bx.min.y <= by.max.y + g
&& by.min.y <= bx.max.y + g
&& bx.min.z <= by.max.z + g
&& by.min.z <= bx.max.z + g
};
let mut out: Vec<Body> = Vec::new();
match op {
BoolOp::Cut => {
for x in la {
let mut cur = Some(x);
for y in &lb {
cur = match cur {
Some(c) if boxes_meet(&c, y) => boolean(&c, y, BoolOp::Cut)?,
other => other,
};
}
out.extend(cur);
}
}
BoolOp::Intersect => {
for x in &la {
for y in &lb {
if boxes_meet(x, y)
&& let Some(r) = boolean(x, y, BoolOp::Intersect)?
{
out.push(r);
}
}
}
}
BoolOp::Union => {
out = la;
for y in lb {
let mut cur = y;
let mut rest = Vec::new();
for x in out {
if boxes_meet(&x, &cur) {
match boolean(&x, &cur, BoolOp::Union)? {
Some(u) if u.solid.boundaries().len() == 1 => cur = u,
_ => rest.push(x),
}
} else {
rest.push(x);
}
}
rest.push(cur);
out = rest;
}
}
}
join_pieces(out)
}
/// Bodies (apart from each other) as one body of several pieces.
pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
match pieces.len() {
0 => Ok(None),
1 => Ok(pieces.into_iter().next()),
_ => {
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect();
let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?;
Body::new(solid).map(Some)
}
}
}
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
if m.iter().flatten().any(|x| !x.is_finite()) {
+84 -9
View File
@@ -192,7 +192,7 @@ pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64)
/// Draft of a non-convex body: walls (planes, cylinders) next to caps square to `pull`.
fn draft_walls(b: &Body, at: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result<Body> {
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
let size = healed.size();
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
let mut chosen: Vec<usize> = Vec::new();
@@ -338,7 +338,7 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result<Body> {
/// by the thickness (open faces outward, past the body) keeping the body's topology, which
/// gives the cavity; the shell is the body minus the cavity.
fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
let size = healed.size();
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
// Faces to open: the face of the triangle nearest each point.
@@ -361,21 +361,77 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
return Err(KernelError::Invalid("select at least one face to remove".into()));
}
let margin = thickness + size * 0.05;
let cavity = offset_planar(&healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness })?;
match crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut) {
// Open faces pushed out past the body: the simple way, unless a face tangent to an open
// one (a fillet along its edge) can't follow; then every face moves in (tangencies keep)
// and the opening is the open face's copy swept out through the body.
let cavity = match offset_planar(&healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness }) {
Ok(c) => c,
Err(e) => return shell_by_opening(&healed, &opened, thickness, margin).map_err(|_| e),
};
let r = crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut);
if r.is_err()
&& let Ok(x) = shell_by_opening(&healed, &opened, thickness, margin)
{
return Ok(x);
}
match r {
Ok(Some(r)) => Ok(r),
Ok(None) => Err(KernelError::Failed("the shell removed everything".into())),
// Healing can leave faces the boolean dislikes (revolved patches): the body as it was.
Err(e) if healed.face_count() == b.face_count() => {
// Healing can leave faces the boolean dislikes (revolved patches): the body as it was,
// its faces to open found again.
Err(e) => {
let raw = b.tessellate((size * 1e-3).max(1e-3)).map_err(|_| e.clone())?;
let mut opened_raw: Vec<usize> = Vec::new();
for p in open {
let near = raw
.triangles
.iter()
.zip(&raw.tri_face)
.filter_map(|(t, f)| raw.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize)))
.min_by(|a, c| a.0.total_cmp(&c.0));
if let Some((d, f)) = near
&& d < size * 1e-3 + 1e-6
&& !opened_raw.contains(&f)
{
opened_raw.push(f);
}
}
let opened = opened_raw;
let cavity = offset_planar(b, |fi, _| if opened.contains(&fi) { margin } else { -thickness }).map_err(|_| e.clone())?;
crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)
.map_err(|_| e)?
.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))
}
Err(e) => Err(e),
}
}
/// A shell made from the cavity with every face moved in by the thickness (open faces too),
/// joined with each open face's moved copy swept out through the body.
fn shell_by_opening(b: &Body, opened: &[usize], thickness: f64, reach: f64) -> Result<Body> {
let closed = offset_planar(b, |_, _| -thickness)?;
let faces: Vec<mt::Face> = closed.solid.face_iter().cloned().collect();
let mut cavity = closed.clone();
for fi in opened {
let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("shell: open face".into()))?;
let mt::Surface::Plane(pl) = f.oriented_surface() else {
return Err(unsupported_shell("the open face must be planar here"));
};
let n = pl.normal();
let n = Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| KernelError::Failed("shell: open face normal".into()))?;
let prism = guard("shell opening", || {
let solid: Solid = builder::tsweep(f, v3(n * (thickness + reach)));
Ok(solid)
})?;
let prism = Body::new(prism)?;
cavity = crate::ops::boolean(&cavity, &prism, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?;
}
crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))
}
fn unsupported_shell(m: &str) -> KernelError {
KernelError::Failed(format!("not supported yet: {m}"))
}
fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
let n = (b - a).cross(c - a);
let Some(nn) = n.normalized() else { return f64::INFINITY };
@@ -422,7 +478,7 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
if !distance.is_finite() || distance.abs() > 1e6 {
return Err(KernelError::Invalid("offset distance".into()));
}
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?.with_split_lines(b.splits.clone());
let size = healed.size();
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
let mut chosen: Vec<usize> = Vec::new();
@@ -443,5 +499,24 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
}
}
let chosen = crate::offset::with_same_surface(&healed, &chosen);
offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 })
match offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) {
Ok(r) => Ok(r),
// A piece of a split face (its neighbour stays): the piece swept along its normal
// joins on (outward) or cuts in, with walls along the split lines.
Err(e) if !healed.split_keep().is_empty() => press_pieces(&healed, &chosen, distance).map_err(|_| e),
Err(e) => Err(e),
}
}
fn press_pieces(b: &Body, chosen: &[usize], distance: f64) -> Result<Body> {
let faces: Vec<mt::Face> = b.solid.face_iter().cloned().collect();
let mut cur = b.clone();
for fi in chosen {
let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("offset: face".into()))?;
let n = crate::blend::plane_normal(f).ok_or_else(|| KernelError::Failed("offset: only planar pieces".into()))?;
let tool = guard("offset face", || Body::new(builder::tsweep(f, crate::body::v3(n * distance))))?;
let op = if distance > 0.0 { crate::BoolOp::Union } else { crate::BoolOp::Cut };
cur = crate::ops::boolean(&cur, &tool, op)?.ok_or_else(|| KernelError::Failed("offset: the piece moved off the body".into()))?;
}
Ok(cur.with_split_lines(b.splits.clone()))
}
+49 -2
View File
@@ -845,6 +845,16 @@ pub fn split_faces(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<Bod
/// Like [`split_faces`], with the body face each face of the result came from (in face order).
pub fn split_faces_with_map(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<(Body, Vec<usize>)> {
let (b, imp) = split_any(body, faces, tool)?;
// The new edges, remembered so that export keeps the pieces apart.
let mut lines: Vec<Vec<Vec3>> = body.splits.as_ref().map(|l| l.to_vec()).unwrap_or_default();
lines.extend(imp.new_edges.iter().filter_map(|e| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
let n = if matches!(c, mt::Curve::Line(_)) { 1 } else { 64 };
let pts: Vec<Vec3> = (0..=n).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / n as f64))).collect();
pts.iter().all(|p| p.is_finite()).then_some(pts)
}));
let b = b.with_split_lines(Some(std::sync::Arc::new(lines)));
if let Some(missed) = faces.iter().find(|f| imp.made.get(f).copied().unwrap_or(0) == 0) {
return Err(KernelError::Invalid(format!("the tool does not cross face {missed}")));
}
@@ -867,7 +877,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
}
let shells = std::mem::take(&mut imp.shells);
let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect());
Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None }, imp))
Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None, splits: body.splits.clone() }, imp))
})
}
@@ -1156,6 +1166,42 @@ mod tests {
assert!(rel(v, 6000.0 - std::f64::consts::PI * 4.0 * 30.0) < 1e-3, "{v}");
}
/// Faces after a STEP round trip.
fn round_trip_faces(b: &Body) -> usize {
let ex = crate::ExportBody { name: "B".into(), body: b, color: None };
let text = crate::step_export_bodies(&[ex], &crate::StepHeader::default()).unwrap();
let imp = crate::step_import(&text).unwrap();
imp.bodies.iter().map(|x| x.body.face_count()).sum()
}
#[test]
fn export_keeps_faces_split_on_purpose() {
// A box top split by a plane, and a cylinder's side split by a plane across its axis.
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let pl = Plane::new(Vec3::new(4.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let s = split_faces(&b, &[top(&b, 30.0)], &SplitTool::Plane(pl)).unwrap();
assert_eq!(s.face_count(), 7);
assert_eq!(round_trip_faces(&s), 7);
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
let mid = Plane::new(Vec3::new(0.0, 0.0, 5.0), Vec3::X, Vec3::Y).unwrap();
let cs = split_faces(&c, &[side], &SplitTool::Plane(mid)).unwrap();
let n = cs.face_count();
assert!(n > c.face_count());
// The pieces stay apart; the cylinder's incidental seam pieces still join as before.
assert_eq!(round_trip_faces(&cs), round_trip_faces(&c) + 1);
// Without the record the export joins the top's pieces again (as it does for pieces a
// boolean left).
assert_eq!(round_trip_faces(&s.clone().with_split_lines(None)), 6);
// A split after a split keeps both lines.
let pl2 = Plane::new(Vec3::new(0.0, 12.0, 0.0), Vec3::X, Vec3::Z).unwrap();
let tops: Vec<usize> =
s.faces(0.01).unwrap().iter().enumerate().filter(|(_, f)| (f.centroid.z - 30.0).abs() < 1e-6).map(|(i, _)| i).collect();
let s2 = split_faces(&s, &tops, &SplitTool::Plane(pl2)).unwrap();
assert_eq!(s2.face_count(), 9);
assert_eq!(round_trip_faces(&s2), 9);
}
#[test]
fn sketch_curve_splits_a_cylinder_side_and_a_circle_splits_a_top() {
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
@@ -1184,7 +1230,8 @@ mod tests {
let mut tops: Vec<f64> = s.faces(0.01).unwrap().iter().filter(|f| (f.centroid.z - 20.0).abs() < 1e-6).map(|f| f.area).collect();
tops.sort_by(f64::total_cmp);
let pi = std::f64::consts::PI;
assert!(rel(tops[0], pi * 16.0) < 1e-3 && rel(tops[1], pi * 84.0) < 1e-3, "{tops:?}");
// (Face areas come from meshes: within their sag, which depends on where seams lie.)
assert!(rel(tops[0], pi * 16.0) < 2e-3 && rel(tops[1], pi * 84.0) < 2e-3, "{tops:?}");
}
/// A plane through a hole in a plate's top: the top splits in two, each piece holding
+70
View File
@@ -299,6 +299,10 @@ fn plan(face: &mt::Face) -> R<Option<Plan>> {
_ => chains.push((seam, vec![e])),
}
}
// A cone's tip: the seam runs from the ring to the apex and straight back.
if let [(true, sm), (false, ring)] = chains.as_slice() {
return apex_plan(face, &s, (d, mid, period), sm, ring, &wires, outer_idx).map(Some);
}
let [(true, s1), (false, r1), (true, s2), (false, r2)] = chains.as_slice() else {
return Err(format!("unsupported seam loop layout ({} runs)", chains.len()));
};
@@ -358,6 +362,72 @@ fn plan(face: &mt::Face) -> R<Option<Plan>> {
Ok(Some(Plan { cuts: c1.into_iter().chain(c2).collect(), faces: (fa, fb) }))
}
/// Split a face closing at an apex (loop: seam out to the apex, seam back, the ring) along the
/// iso-line opposite the seam, from the ring to the apex.
fn apex_plan(
face: &mt::Face,
s: &mt::Surface,
(d, mid, period): (Dir, f64, f64),
sm: &[mt::Edge],
ring: &[mt::Edge],
wires: &[mt::Wire],
outer_idx: usize,
) -> R<Plan> {
let h = sm.len() / 2;
if !sm.len().is_multiple_of(2) || h == 0 {
return Err("seam edges are not used back and forth".into());
}
let (out, back) = (sm.get(..h).ok_or("seam")?, sm.get(h..).ok_or("seam")?);
if out.iter().map(mt::Edge::id).collect::<Vec<_>>() != back.iter().rev().map(mt::Edge::id).collect::<Vec<_>>() {
return Err("seam edges are not used back and forth".into());
}
let apex = out.last().ok_or("seam")?.back().clone();
let (k, t, q) = crossing(s, d, ring, mid, period)?;
let seam_line: Vec<mt::Point3> = out.iter().flat_map(|e| samples(&e.oriented_curve(), 16).into_iter().map(|x| x.1)).collect();
let iso = iso_curve(s, d, mid, q, apex.point(), &seam_line)?;
let (ra, rb, c, vq) = split_chain(ring, k, t, q)?;
let e = mt::Edge::new_unchecked(&vq, &apex, iso);
// Face A: out to the apex, down the split line, the ring's second part; face B the rest.
let mut wa: Vec<mt::Edge> = out.to_vec();
wa.push(e.inverse());
wa.extend(rb.iter().cloned());
let mut wb: Vec<mt::Edge> = vec![e];
wb.extend(back.iter().cloned());
wb.extend(ra);
let (wire_a, wire_b): (mt::Wire, mt::Wire) = (wa.into(), wb.into());
if !wire_a.is_closed() || !wire_b.is_closed() {
return Err("split loops do not close".into());
}
let side_a = rb
.iter()
.flat_map(|e| samples(&e.oriented_curve(), 4))
.filter_map(|(_, p)| uv_of(s, p).map(|uv| coord(d, uv)))
.find(|w| (w - mid).abs() > period * 1e-3)
.map(|w| w < mid)
.ok_or("cannot place the split halves")?;
let (mut ha, mut hb) = (vec![wire_a], vec![wire_b]);
for (i, w) in wires.iter().enumerate() {
if i == outer_idx {
continue;
}
let ws: Vec<f64> = w.edge_iter().flat_map(|e| samples(&e.curve(), 4)).filter_map(|(_, p)| uv_of(s, p).map(|uv| coord(d, uv))).collect();
if ws.iter().all(|x| (*x < mid) == side_a) {
ha.push(w.clone());
} else if ws.iter().all(|x| (*x < mid) != side_a) {
hb.push(w.clone());
} else {
return Err("a hole crosses the split line".into());
}
}
let mut fa = mt::Face::new_unchecked(ha, s.clone());
let mut fb = mt::Face::new_unchecked(hb, s.clone());
if !face.orientation() {
fa.invert();
fb.invert();
}
Ok(Plan { cuts: c.into_iter().collect(), faces: (fa, fb) })
}
/// Replace cut edges in a face's loops.
fn recut(face: &mt::Face, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))]) -> mt::Face {
let touches = face.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).any(|e| cuts.iter().any(|(c, _)| c.id() == e.id()));
+12 -3
View File
@@ -11,6 +11,8 @@
use std::collections::HashMap;
use std::fmt::Write as _;
use solvecraft_geom::Vec3;
use crate::body::Body;
use crate::step_in::p21::{self, Param};
use crate::{KernelError, Result};
@@ -832,7 +834,13 @@ fn face_edges(ex: &p21::Exchange, face: &p21::Entity) -> Option<Vec<Vec<(u64, bo
/// Faces that truck splits (a revolution in two or three turns) joined back into one face per
/// analytic surface, by dropping the edges between them. Faces whose union would lose all its
/// edges, or whose leftover edges do not close into loops, stay apart.
fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>) -> HashMap<u64, V3> {
fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>, keep: &[Vec<Vec3>]) -> HashMap<u64, V3> {
// An edge on a line the user split along: every sample within a hair of the polyline.
let on_split = |ex: &p21::Exchange, e: u64| -> bool {
let Some(pts) = edge_samples(ex, e) else { return false };
let pts: Vec<Vec3> = pts.iter().map(|q| Vec3::new(q[0], q[1], q[2])).collect();
crate::heal::on_split_line(&pts, keep)
};
let mut merged_surfaces = std::collections::HashSet::new();
let mut next = ex.entities.keys().max().copied().unwrap_or(0) + 1;
let shells: Vec<u64> = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "CLOSED_SHELL" | "OPEN_SHELL")).map(|(i, _)| *i).collect();
@@ -923,7 +931,7 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>)
let all: Vec<(u64, bool)> = ea.into_iter().chain(eb).flatten().collect();
let shared: std::collections::HashSet<u64> =
all.iter().filter(|(e, d)| all.iter().any(|(e2, d2)| e2 == e && d2 != d)).map(|(e, _)| *e).collect();
if shared.is_empty() {
if shared.is_empty() || shared.iter().any(|e| on_split(ex, *e)) {
continue;
}
// Chain oriented edges into loops by vertices (None: they do not close).
@@ -1200,7 +1208,8 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap<u64, V
}
}
let before = shell_face_count(&ex);
let merged = if merge { merge_split_faces(&mut ex, &mut paint) } else { Default::default() };
let keep = b.split_lines().map(|l| l.as_slice()).unwrap_or(&[]);
let merged = if merge { merge_split_faces(&mut ex, &mut paint, keep) } else { Default::default() };
MERGES.with(|m| m.set(m.get() + before.saturating_sub(shell_face_count(&ex))));
Ok((ex, merged, paint))
}
+2 -2
View File
@@ -22,7 +22,7 @@ impl Body {
/// A body of faces as they are (no orientation fix: a surface has no inside).
fn surface_body(faces: Vec<mt::Face>) -> Body {
let shell = mt::Shell::from(faces);
Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None }
Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None }
}
/// A body from faces: a solid when they close, else a surface.
@@ -31,7 +31,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
if shell.shell_condition() == mt::ShellCondition::Closed {
Body::new(mt::Solid::new_unchecked(vec![shell]))
} else {
Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None })
Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None })
}
}
+138
View File
@@ -782,6 +782,27 @@ fn shell_non_convex() {
assert!(rel(v, outer - cavity) < 1e-6, "{v} vs {}", outer - cavity);
}
/// A basin: a slab with a tapered pocket and a 4 mm front rim, shelled from below. A wall
/// thicker than the rim (with the pocket's slope) would turn the top face inside out: refused,
/// not a body with wrong mass properties.
#[test]
fn shell_thicker_than_a_rim_is_refused() {
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
let at = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::X, Vec3::Y).unwrap();
let pocket = loft(&[(at(30.0), rect(20.0, 4.0, 80.0, 50.0)), (at(10.0), rect(30.0, 20.0, 70.0, 40.0))]).unwrap();
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
let open = [Vec3::new(50.0, 30.0, 0.0)];
let e = shell(&basin, &open, 6.0).unwrap_err().to_string();
assert!(e.contains("rim or ledge"), "{e}");
let (whole, thin) = (measure(&basin).unwrap(), measure(&shell(&basin, &open, 1.0).unwrap()).unwrap());
assert!(thin.volume > 0.0 && thin.volume < whole.volume, "{} vs {}", thin.volume, whole.volume);
let (lo, hi) = (thin.bbox.min, thin.bbox.max);
let c = thin.centroid;
assert!(c.x > lo.x && c.x < hi.x && c.y > lo.y && c.y < hi.y && c.z > lo.z && c.z < hi.z, "{c:?}");
assert!((c.x - 50.0).abs() < 1e-3, "{c:?}");
}
#[test]
#[ignore]
fn debug_drilled_hole() {
@@ -1297,3 +1318,120 @@ fn tight_bend_counts() {
let m = measure(&b).unwrap().merged;
assert_eq!((m.faces, m.edges, m.vertices), (5, 6, 4), "{m:?}");
}
#[test]
#[ignore]
fn boolean_identity_survey() {
let n: u64 = std::env::var("N").ok().and_then(|x| x.parse().ok()).unwrap_or(100);
let seeds: Vec<u64> = match std::env::var("SEEDS") {
Ok(s) => s.split(',').filter_map(|x| x.parse().ok()).collect(),
Err(_) => (0..n).collect(),
};
let mut bad = 0;
for seed in seeds {
let c = crate::fuzz::boolean_case(seed);
if c.outcome != "ok" {
bad += 1;
println!("{seed}: {} {} | {:?} | {:?}", c.outcome, c.detail, c.a, c.b);
}
}
println!("{bad}/{n} failed");
}
/// A quarter turn of a rectangle with an edge on the axis: no zero-area face along the axis, so
/// a cylinder can be cut through the corner (it failed in every boolean attempt before).
#[test]
fn partial_revolve_on_the_axis() {
let sq =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] };
let pie = revolve(&Plane::XZ, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
assert!(pie.validity().is_empty(), "{:?}", pie.validity());
let v = measure(&pie).unwrap().volume;
assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}");
assert_eq!(measure(&pie).unwrap().merged.faces, 5);
let cyl = cylinder(Vec3::new(2.5, 0.0, 5.0), Vec3::Z, 3.75, 22.5).unwrap();
let cut = boolean(&pie, &cyl, BoolOp::Cut).unwrap().unwrap();
assert!(cut.validity().is_empty());
let vc = measure(&cut).unwrap().volume;
assert!(vc < v - 1.0 && vc > v - PI * 3.75 * 3.75 * 2.5, "{vc}");
}
/// Boolean identities on primitives the robustness runs found failing: cylinders tangent to box
/// faces from inside and out, crossing cylinders touching along a line (their seams used to lie
/// exactly where the other body cut them).
#[test]
fn boolean_identity_on_tangent_primitives() {
for seed in [1, 32, 169] {
let c = crate::fuzz::boolean_case(seed);
assert_eq!(c.outcome, "ok", "seed {seed}: {} {:?} {:?}", c.detail, c.a, c.b);
}
}
/// A body in two pieces: booleans go a piece at a time.
#[test]
fn boolean_of_a_body_in_pieces() {
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).unwrap();
let two = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
assert_eq!(two.shell_count(), 2);
let tool = cylinder(Vec3::new(-5.0, 5.0, 5.0), Vec3::X, 2.0, 40.0).unwrap();
let cut = boolean(&two, &tool, BoolOp::Cut).unwrap().unwrap();
assert_eq!(cut.shell_count(), 2);
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
let v = measure(&cut).unwrap().volume;
assert!((v - (2000.0 - 2.0 * PI * 4.0 * 10.0)).abs() < 1.0, "{v}");
}
/// Measuring a body of 5000 faces (a modelled thread is like that) counts right (it took 13 s,
/// a per-face recomputation of the body's size; now under a second).
#[test]
fn measure_of_a_body_with_many_faces() {
let n = 5000;
let pts: Vec<Vec2> = (0..n)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / n as f64;
Vec2::new(20.0 * a.cos(), 20.0 * a.sin())
})
.collect();
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (n + 2, 3 * n, 2 * n), "{:?}", m.merged);
}
/// The time it takes (run alone, the suite's other tests share the machine):
/// `cargo test --release -p solvecraft-kernel perf_measure_5000_faces -- --ignored`.
#[test]
#[ignore]
fn perf_measure_5000_faces() {
let pts: Vec<Vec2> = (0..5000)
.map(|i| {
let a = std::f64::consts::TAU * i as f64 / 5000.0;
Vec2::new(20.0 * a.cos(), 20.0 * a.sin())
})
.collect();
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
let t = std::time::Instant::now();
measure(&b).unwrap();
assert!(t.elapsed().as_secs_f64() < 1.5, "{:?}", t.elapsed());
}
/// A lip on a rounded, shelled rim (#29): the band along the rim's inner (or outer) edge raised,
/// its walls continuing the walls below. Exactly the band's area times its height is added.
#[test]
fn raise_band_on_a_rounded_rim() {
let b = box_solid(Vec3::new(-40.0, -30.0, 0.0), Vec3::new(40.0, 30.0, 30.0)).unwrap();
let f =
fillet(&b, &[Vec3::new(40.0, 30.0, 15.0), Vec3::new(-40.0, 30.0, 15.0), Vec3::new(-40.0, -30.0, 15.0), Vec3::new(40.0, -30.0, 15.0)], 6.0)
.unwrap();
let sh = shell(&f, &[Vec3::new(0.0, 0.0, 30.0)], 2.0).unwrap();
let v0 = measure(&sh).unwrap().volume;
// A rounded rectangle's area.
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
for outside in [false, true] {
let lip = crate::lip::raise_band(&sh, Vec3::new(0.0, 29.0, 30.0), 1.0, 1.5, outside).unwrap();
assert!(lip.validity().is_empty(), "{:?}", lip.validity());
let band = if outside { ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0) } else { ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0) };
let v = measure(&lip).unwrap().volume;
assert!((v - (v0 + band * 1.5)).abs() < 1e-5 * v, "outside {outside}: {v} vs {}", v0 + band * 1.5);
}
}
+10 -5
View File
@@ -42,7 +42,7 @@ impl Surf {
}
fn same(&self, o: &Surf, tol: f64) -> bool {
match (*self, *o) {
(Surf::Plane { n, d }, Surf::Plane { n: n2, d: d2 }) => n.dot(n2) > 1.0 - 1e-6 && (d - d2).abs() < tol,
(Surf::Plane { n, d }, Surf::Plane { n: n2, d: d2 }) => n.dot(n2) > 1.0 - 1e-9 && (d - d2).abs() < tol,
(Surf::Cylinder { axis, p, r }, Surf::Cylinder { axis: a2, p: p2, r: r2 }) => {
let off = p2 - p;
axis.dot(a2).abs() > 1.0 - 1e-6 && (r - r2).abs() < tol && (off - axis * off.dot(axis)).len() < tol
@@ -382,13 +382,14 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
*a += (q - p).cross(r - p).len() * 0.5;
}
}
let degenerate: Vec<bool> = area.iter().map(|a| *a < (b.size() * 1e-6).powi(2)).collect();
let tiny = (b.size() * 1e-6).powi(2);
let degenerate: Vec<bool> = area.iter().map(|a| *a < tiny).collect();
let is_deg = |f: usize| degenerate.get(f).copied().unwrap_or(false);
// Edge → faces, edge → vertices. Vertices and edges lying on each other count once (two
// tangent holes touch along a line that both bodies' pieces carry): welded by position,
// edges by their ends and the point halfway along them.
let q = tol * 10.0;
let q = (b.size() * 1e-6).max(1e-9);
let cell = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64);
let mut edge_faces: HashMap<usize, Vec<usize>> = HashMap::new();
let mut edge_verts: HashMap<usize, (usize, usize)> = HashMap::new();
@@ -447,10 +448,14 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
});
smooth && seen > 0
};
// Pieces of a face split on purpose count apart.
let b_keep = b.split_keep();
let keep_split = !b_keep.is_empty();
let mut fp: Vec<usize> = (0..nf).collect();
for (ei, faces) in &edge_faces {
let other = |f: usize| matches!(surfs.get(f), Some(Surf::Other(_)));
if let [a, b] = faces[..]
&& !(keep_split && poly(*ei).is_some_and(|pl| crate::heal::on_split_line(pl, b_keep)))
&& (same_surface(&surfs, &verts, poly(*ei), a, b, tol * 10.0)
|| (other(a) && other(b) && exact_smooth(a, b, poly(*ei)) && {
let (Some(va), Some(vb)) = (verts.get(a), verts.get(b)) else { return Ok(TopoCounts::default()) };
@@ -524,7 +529,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
}
}
let mut ep: Vec<usize> = (0..remaining.len()).collect();
let mut merge_vertex: Vec<usize> = Vec::new();
let mut merge_vertex: std::collections::HashSet<usize> = std::collections::HashSet::new();
for (v, es) in &deg {
if let [i, j] = es[..]
&& let (Some(a), Some(b)) = (remaining.get(i), remaining.get(j))
@@ -534,7 +539,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
if let Some(s) = ep.get_mut(ri) {
*s = rj;
}
merge_vertex.push(*v);
merge_vertex.insert(*v);
}
}
let mut edge_groups: BTreeMap<usize, bool> = BTreeMap::new(); // root → has a real end vertex
+97 -15
View File
@@ -72,10 +72,11 @@ impl StandardView {
}
}
/// Smooth ease-in-out on [0, 1] (cubic).
/// Smooth ease-in-out on [0, 1] (smoothstep: at its fastest, mid-way, 1.5 times the average
/// speed, so long turns stay gentle).
pub fn ease_in_out(t: f64) -> f64 {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
if t < 0.5 { 4.0 * t * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 }
t * t * (3.0 - 2.0 * t)
}
/// A unit quaternion (w, x, y, z).
@@ -162,8 +163,13 @@ pub struct CameraAnim {
impl CameraAnim {
/// The usual length of a view change, in seconds.
pub const DURATION: f64 = 0.5;
/// A view change: half a second, longer for turns past a quarter turn (so a big turn, such
/// as from the home view to the bottom, never sweeps more than about 9 degrees a frame).
pub fn new(from: Camera, to: Camera, now: f64) -> CameraAnim {
CameraAnim { from, to, start: now, duration: CameraAnim::DURATION }
let dy = (to.yaw - from.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let turn = dy.abs().max((to.pitch - from.pitch).abs());
let k = if turn.is_finite() { (turn / std::f64::consts::FRAC_PI_2).clamp(1.0, 2.0) } else { 1.0 };
CameraAnim { from, to, start: now, duration: CameraAnim::DURATION * k }
}
/// The camera at time `now`, and whether the move has finished.
pub fn sample(&self, now: f64) -> (Camera, bool) {
@@ -175,7 +181,42 @@ impl CameraAnim {
}
}
/// Orbit camera around `target` (Z up).
/// Which world axis points up in the view (#3): Z (the default) or Y, as most STEP files from
/// other systems are modelled.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UpAxis {
#[default]
Z,
Y,
}
impl UpAxis {
/// The world's up direction.
pub fn dir(self) -> Vec3 {
match self {
UpAxis::Z => Vec3::Z,
UpAxis::Y => Vec3::Y,
}
}
/// A world vector in the camera's Z-up frame (yaw turns about the frame's Z).
pub fn to_frame(self, v: Vec3) -> Vec3 {
match self {
UpAxis::Z => v,
UpAxis::Y => Vec3::new(v.x, -v.z, v.y),
}
}
/// A Z-up frame vector in the world.
pub fn from_frame(self, v: Vec3) -> Vec3 {
match self {
UpAxis::Z => v,
UpAxis::Y => Vec3::new(v.x, v.z, -v.y),
}
}
}
/// Orbit camera around `target`. Yaw and pitch are about the up axis (Z, or Y for a Y-up
/// world): with Y up, the front view looks along −Z and the top view down −Y.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Camera {
pub target: Vec3,
@@ -187,12 +228,15 @@ pub struct Camera {
pub distance: f64,
/// Vertical field of view (radians); 0 = orthographic.
pub fov: f64,
/// The world's up axis.
#[serde(default)]
pub up: UpAxis,
}
impl Default for Camera {
fn default() -> Self {
let (yaw, pitch) = StandardView::Iso.angles();
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0 }
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, up: UpAxis::Z }
}
}
@@ -200,7 +244,19 @@ impl Camera {
/// Direction from the target toward the eye.
pub fn back(&self) -> Vec3 {
let (cp, sp) = (self.pitch.cos(), self.pitch.sin());
Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z)
self.up.from_frame(Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z))
}
/// The world's up direction.
pub fn up_dir(&self) -> Vec3 {
self.up.dir()
}
/// A world vector in the camera's Z-up frame (where yaw and pitch are measured).
pub fn to_frame(&self, v: Vec3) -> Vec3 {
self.up.to_frame(v)
}
/// A Z-up frame vector in the world.
pub fn from_frame(&self, v: Vec3) -> Vec3 {
self.up.from_frame(v)
}
pub fn eye(&self) -> Vec3 {
self.target + self.back() * self.distance
@@ -208,8 +264,8 @@ impl Camera {
/// Camera basis: right, up, back (orthonormal).
pub fn basis(&self) -> (Vec3, Vec3, Vec3) {
let b = self.back();
let right = Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0);
let up = b.cross(right).normalized().unwrap_or(Vec3::Z);
let right = self.up.from_frame(Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0));
let up = b.cross(right).normalized().unwrap_or(self.up.dir());
(right, up, b)
}
pub fn view(&self) -> Mat4 {
@@ -311,7 +367,7 @@ impl Camera {
/// The same camera looking from direction `back` (target → eye). Straight up or down
/// keeps the yaw, rounded to the nearest quarter turn so the view stays square.
pub fn looking_from(&self, back: Vec3) -> Camera {
let Some(b) = back.normalized() else { return *self };
let Some(b) = self.up.to_frame(back).normalized() else { return *self };
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
let pitch = b.z.clamp(-1.0, 1.0).asin().clamp(-lim, lim);
let yaw = if b.x.hypot(b.y) < 1e-6 {
@@ -349,6 +405,7 @@ impl Camera {
pitch,
distance: a.distance + (b.distance - a.distance) * s,
fov: a.fov + (b.fov - a.fov) * s,
up: b.up,
}
}
pub fn is_valid(&self) -> bool {
@@ -396,10 +453,10 @@ mod tests {
let anim = CameraAnim::new(a, b, 10.0);
// Endpoints are exact.
assert_eq!(anim.sample(10.0).0, a);
assert_eq!(anim.sample(10.0 + CameraAnim::DURATION), (b, true));
assert_eq!(anim.sample(10.0 + anim.duration), (b, true));
assert_eq!(anim.sample(99.0), (b, true));
// Midpoint: half way in heading, elevation, target and distance.
let (m, done) = anim.sample(10.0 + CameraAnim::DURATION / 2.0);
let (m, done) = anim.sample(10.0 + anim.duration / 2.0);
assert!(!done && m.is_valid());
let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
assert!((m.yaw - (a.yaw + dy / 2.0)).abs() < 1e-12 && (m.pitch - (a.pitch + b.pitch) / 2.0).abs() < 1e-12);
@@ -476,7 +533,7 @@ mod tests {
for a in &cams {
for b in &cams {
let anim = CameraAnim::new(*a, *b, 0.0);
let frames = 30;
let frames = (anim.duration * 60.0).round() as usize;
let mut prev = anim.sample(0.0).0;
let (mut steps, mut done_at) = (Vec::new(), None);
for f in 1..=frames + 2 {
@@ -495,11 +552,36 @@ mod tests {
let total_turn: f64 = steps.iter().map(|s| s.1).sum();
let total_move: f64 = steps.iter().map(|s| s.2).sum();
for (f, turn, moved) in &steps {
// The eased curve's fastest frame is 1.5x the average: allow 3x.
assert!(*turn <= 3.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})");
assert!(*moved <= 3.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}");
// The eased curve's fastest frame is 1.5x the average: allow 2x.
assert!(*turn <= 2.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})");
assert!(*moved <= 2.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}");
}
}
}
}
/// With Y up: the front view looks along −Z (eye on +Z), the top view looks down −Y with X
/// to the right, orbiting turns about Y, and the picture's up is the world's up.
#[test]
fn y_up_views() {
let mut c = Camera { up: UpAxis::Y, distance: 100.0, ..Default::default() };
c.set_view(StandardView::Front);
assert!(c.back().dist(Vec3::Z) < 1e-9, "front eye {:?}", c.back());
let (r, u, b) = c.basis();
assert!(r.dist(Vec3::X) < 1e-9 && u.dist(Vec3::Y) < 1e-9 && b.dist(Vec3::Z) < 1e-9);
c.set_view(StandardView::Top);
assert!(c.back().dist(Vec3::Y) < 1e-5, "top eye {:?}", c.back());
assert!(c.basis().0.dist(Vec3::X) < 1e-9);
// Orbiting sideways keeps the eye's height above the XZ ground.
c.set_view(StandardView::Iso);
let h = c.back().y;
c.orbit(120.0, 0.0);
assert!((c.back().y - h).abs() < 1e-12);
// Looking from a world direction lands on it.
let d = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap();
assert!(c.looking_from(d).back().dist(d) < 1e-9);
// Frames round-trip.
let v = Vec3::new(1.0, 2.0, 3.0);
assert!(UpAxis::Y.from_frame(UpAxis::Y.to_frame(v)).dist(v) < 1e-12);
}
}
+2 -2
View File
@@ -7,5 +7,5 @@
mod camera;
mod raster;
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, ease_in_out};
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, render, render_png};
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
+51 -6
View File
@@ -19,6 +19,8 @@ impl Rgb {
pub struct SceneMesh {
pub mesh: Arc<Mesh>,
pub color: Rgb,
/// Faces in colours of their own: (B-rep face index, colour).
pub face_colors: Vec<(u32, Rgb)>,
}
pub struct SceneLine {
@@ -102,13 +104,18 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
let view_dir = cam.back();
for sm in &scene.meshes {
let m = &sm.mesh;
let base = sm.color.f();
let body = sm.color.f();
let pts: Vec<Option<[f64; 3]>> = m.positions.iter().map(|p| project(*p)).collect();
for t in &m.triangles {
for (ti, t) in m.triangles.iter().enumerate() {
let face = m.tri_face.get(ti).copied();
let base = sm.face_colors.iter().find(|(f, _)| Some(*f) == face).map_or(body, |(_, c)| c.f());
let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else {
continue;
};
let ns: Vec<Vec3> = t.iter().map(|k| m.normals.get(*k as usize).copied().unwrap_or(Vec3::Z)).collect();
// A curved surface (its normals turn across the triangle) gets a silhouette line
// where it turns away from the eye.
let curved = ns.iter().any(|n| n.dist(ns[0]) > 1e-6);
let (x0, x1) = (a[0].min(b[0]).min(cc[0]).floor().max(0.0), a[0].max(b[0]).max(cc[0]).ceil().min(sw as f64 - 1.0));
let (y0, y1) = (a[1].min(b[1]).min(cc[1]).floor().max(0.0), a[1].max(b[1]).max(cc[1]).ceil().min(sh as f64 - 1.0));
if !(x0 <= x1 && y0 <= y1) {
@@ -134,7 +141,15 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
*d = z;
}
let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z);
c.put(x, y, shade(n, view_dir, base), 1.0);
let mut col = shade(n, view_dir, base);
let ndv = n.dot(view_dir).abs();
if curved && ndv < 0.14 {
let a = 1.0 - ndv / 0.14;
for (k, e) in col.iter_mut().zip([0.07, 0.08, 0.10]) {
*k = *k * (1.0 - a) + e * a;
}
}
c.put(x, y, col, 1.0);
}
}
}
@@ -200,12 +215,38 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
/// Render to PNG bytes.
pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option<Vec<u8>> {
let c = render(scene, cam, w, h);
let img = image::RgbaImage::from_raw(c.w as u32, c.h as u32, c.rgba)?;
png(c.w, c.h, c.rgba)
}
/// RGBA pixels (`w × h`) to PNG bytes.
pub fn png(w: usize, h: usize, rgba: Vec<u8>) -> Option<Vec<u8>> {
let img = image::RgbaImage::from_raw(u32::try_from(w).ok()?, u32::try_from(h).ok()?, rgba)?;
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).ok()?;
Some(buf.into_inner())
}
/// Render on a transparent background: the scene drawn over black and over white, and the
/// alpha recovered from the difference (exact for the anti-aliased edges).
pub fn render_transparent(scene: &mut Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
let keep = scene.background;
scene.background = Some((Rgb(0, 0, 0), Rgb(0, 0, 0)));
let black = render(scene, cam, w, h);
scene.background = Some((Rgb(255, 255, 255), Rgb(255, 255, 255)));
let mut out = render(scene, cam, w, h);
scene.background = keep;
for (o, b) in out.rgba.as_chunks_mut::<4>().0.iter_mut().zip(black.rgba.as_chunks::<4>().0) {
// white - black = 255 (1 - alpha) in every channel; average them.
let d: u32 = (0..3).map(|k| u32::from(o[k].saturating_sub(b[k]))).sum();
let a = 255.0 - d as f64 / 3.0;
for k in 0..3 {
o[k] = if a > 0.5 { (f64::from(b[k]) * 255.0 / a).round().clamp(0.0, 255.0) as u8 } else { 0 };
}
o[3] = a.round().clamp(0.0, 255.0) as u8;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -222,8 +263,12 @@ mod tests {
seams: vec![],
edge_faces: vec![],
};
let scene =
Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 };
let scene = Scene {
meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30), face_colors: Vec::new() }],
lines: vec![],
background: None,
radius: 20.0,
};
let mut cam = Camera { distance: 50.0, ..Default::default() };
cam.set_view(StandardView::Front);
let c = render(&scene, &cam, 64, 64);
+419
View File
@@ -0,0 +1,419 @@
//! The agent cursor (#35): a second, clearly marked pointer that acts out what an agent does
//! over the control channel or MCP. Before an `engine.execute` runs, the pointer glides to the
//! command's toolbar button (or its panel when the button sits in a drop-down) and clicks it,
//! then glides to each pick in the parameters (faces, edges and points projected to the screen,
//! sketch points, the plane of a new sketch), and only then does the command run. The reply
//! waits for the animation, so an agent's timing stays honest. With "follow camera" on, a new
//! sketch turns the view to face it and finishing the sketch turns back.
//!
//! The pointer is drawn by the app, so screen recordings capture it. It is off by default (View
//! settings on the navigation bar, or `ui.agent_cursor {show, speed, follow_camera}`); a call
//! with `"animate": false` skips it.
use std::collections::VecDeque;
use std::sync::mpsc::Sender;
use egui::{Color32, FontId, Pos2, Shape, Stroke, pos2, vec2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use solvecraft_engine::geom::{Vec2, Vec3};
use crate::SolveApp;
use crate::control::ControlResponse;
/// How fast the pointer moves: 1× to 5×, or Instant (no animation: the command runs at once
/// and the pointer jumps to where it acted, with a click ripple). Saved and sent as a number,
/// or "instant"; the old "slow" and "normal" read as 1×.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Speed {
Times(f64),
Instant,
}
impl Default for Speed {
fn default() -> Self {
Speed::Times(1.0)
}
}
impl Speed {
/// From a number (1–5) or a name ("instant"; "slow" and "normal" are 1×).
pub fn from_value(v: &Value) -> Option<Speed> {
match v {
Value::Number(n) => n.as_f64().filter(|x| x.is_finite() && (1.0..=5.0).contains(x)).map(Speed::Times),
Value::String(s) => match s.to_ascii_lowercase().as_str() {
"instant" => Some(Speed::Instant),
"slow" | "normal" => Some(Speed::Times(1.0)),
"fast" => Some(Speed::Times(2.0)),
_ => None,
},
_ => None,
}
}
pub fn label(self) -> String {
match self {
Speed::Instant => "Instant".into(),
Speed::Times(x) => {
let s = format!("{x:.1}");
format!("{}×", s.trim_end_matches(".0"))
}
}
}
/// Seconds to glide to a stop, and to click there.
fn times(self) -> (f64, f64) {
match self {
Speed::Instant => (0.0, 0.0),
Speed::Times(x) => {
let x = x.clamp(1.0, 5.0);
(0.45 / x, 0.25 / x)
}
}
}
}
impl Serialize for Speed {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Speed::Instant => s.serialize_str("instant"),
Speed::Times(x) => s.serialize_f64(*x),
}
}
}
impl<'de> Deserialize<'de> for Speed {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
Ok(Speed::from_value(&v).unwrap_or_default())
}
}
/// The speed as a slider: 1× to 5× in half steps, and Instant at the far right.
pub fn speed_slider(ui: &mut egui::Ui, speed: &mut Speed) -> egui::Response {
let mut v = match *speed {
Speed::Times(x) => x.clamp(1.0, 5.0),
Speed::Instant => 5.5,
};
let r = ui.add(
egui::Slider::new(&mut v, 1.0..=5.5).step_by(0.5).custom_formatter(|v, _| if v > 5.25 { "Instant".into() } else { Speed::Times(v).label() }),
);
if r.changed() {
*speed = if v > 5.25 { Speed::Instant } else { Speed::Times(v) };
}
r
}
/// The settings (kept with the other view settings).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub show: bool,
pub speed: Speed,
pub follow_camera: bool,
}
/// A request held back while the pointer acts it out.
pub struct Job {
method: String,
params: Value,
reply: Sender<ControlResponse>,
stops: VecDeque<Pos2>,
/// The leg under way: where it started and when.
from: Pos2,
started: f64,
/// Clicked at the current stop (waiting out the click).
clicked: bool,
}
/// The pointer's state.
#[derive(Default)]
pub struct AgentCursor {
pub pos: Option<Pos2>,
job: Option<Job>,
/// Click ripples: where and when.
ripples: Vec<(Pos2, f64)>,
/// The view before a followed sketch (restored when it finishes).
saved_view: Option<solvecraft_engine::render::Camera>,
}
impl AgentCursor {
/// A request is being acted out (later requests wait behind it).
pub fn busy(&self) -> bool {
self.job.is_some()
}
}
/// Keys whose [x, y, z] values are directions or amounts, not places to point at.
const NOT_PLACES: [&str; 14] =
["translate", "dir1", "dir2", "axis", "direction", "normal", "pull", "color", "x_dir", "y_dir", "scale", "values", "limits", "size"];
/// Where the pointer goes for a command: its toolbar button (or its panel), then each pick.
pub fn stops(app: &SolveApp, command: &str, params: &Value) -> Vec<Pos2> {
let mut out = Vec::new();
if let Some(spec) = solvecraft_engine::find_command(command) {
let button = crate::scenario::handle_at(&format!("toolbar:{}", spec.id))
.or_else(|| crate::scenario::handle_at(&format!("panel:{}:{}", spec.tab, spec.panel)));
out.extend(button);
}
let Some(rect) = app.viewport.rect else { return out };
let proj = crate::viewport::projection(app, rect);
for w in picks(app, command, params) {
if let Some(p) = proj.to_screen(w).filter(|p| rect.contains(*p)) {
out.push(p);
}
}
out
}
/// The places a command's parameters pick, in the world: [x, y, z] points (faces, edges,
/// vertices, positions), active-sketch [x, y] points, and a new sketch's plane.
pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec<Vec3> {
let id = solvecraft_engine::find_command(command).map_or(command, |c| c.id);
let mut out = Vec::new();
if id == "sketch.create"
&& let Some(n) = params.get("plane").and_then(Value::as_str)
&& let Some((_, _, q)) = crate::viewport::origin_planes(app).into_iter().find(|(name, _, _)| *name == n)
{
out.push((q[0] + q[1] + q[2] + q[3]) / 4.0);
}
let st = app.session.world_state();
let sketch = app.session.active_sketch.and_then(|s| st.sketch(s)).map(|ss| ss.plane);
walk(params, None, &mut |key, v| {
let num = |i: usize| v.get(i).and_then(Value::as_f64).filter(|x| x.is_finite());
let place = !key.is_some_and(|k| NOT_PLACES.contains(&k));
match (v.as_array().map(Vec::len), num(0), num(1), num(2)) {
(Some(3), Some(x), Some(y), Some(z)) if place => out.push(Vec3::new(x, y, z)),
(Some(2), Some(x), Some(y), None) if place => {
if let Some(pl) = &sketch {
out.push(pl.to_world(Vec2::new(x, y)));
}
}
_ => {}
}
});
// A profile feature without picks takes the profiles of the active (else the last) sketch:
// point at them.
if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") {
let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()));
if let Some(ss) = ss {
out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point())));
}
}
out
}
/// Visit every value with the key it sits under (array items inherit their array's key).
fn walk<'a>(v: &'a Value, key: Option<&'a str>, f: &mut dyn FnMut(Option<&'a str>, &'a Value)) {
match v {
Value::Object(m) => {
for (k, x) in m {
walk(x, Some(k.as_str()), f);
}
}
Value::Array(a) => {
// A point is a leaf; a list of points or other values is walked.
if a.len() >= 2 && a.len() <= 3 && a.iter().all(Value::is_number) {
f(key, v);
} else {
for x in a {
walk(x, key, f);
}
}
}
_ => {}
}
}
/// Take over a control request when the pointer should act it out first: returns the request
/// back when it should simply run.
pub fn intercept(app: &mut SolveApp, method: &str, params: &Value, reply: &Sender<ControlResponse>) -> bool {
let s = app.ui.agent_cursor;
if !s.show || !matches!(method, "engine.execute" | "command") {
return false;
}
if params.get("animate").and_then(Value::as_bool) == Some(false) {
return false;
}
let Some(cmd) = params.get("command").or(params.get("id")).and_then(Value::as_str) else { return false };
let inner = params.get("params").cloned().unwrap_or(json!({}));
let stops: VecDeque<Pos2> = stops(app, cmd, &inner).into();
if s.speed == Speed::Instant {
// No animation: the command runs now; the pointer jumps to where it acted.
if let Some(last) = stops.back() {
app.agent.pos = Some(*last);
app.agent.ripples.push((*last, app.now));
}
return false;
}
if stops.is_empty() {
return false;
}
let from = app.cursor_start();
app.agent.job =
Some(Job { method: method.to_string(), params: params.clone(), reply: reply.clone(), stops, from, started: app.now, clicked: false });
true
}
impl SolveApp {
/// Where the agent pointer starts: where it was, else the middle of the viewport.
fn cursor_start(&self) -> Pos2 {
self.agent.pos.or_else(|| self.viewport.rect.map(|r| r.center())).unwrap_or(pos2(600.0, 400.0))
}
}
fn ease(t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Move the pointer along; when the last stop is clicked, run the held request and reply.
pub fn step(app: &mut SolveApp, ctx: &egui::Context) {
let now = app.now;
app.agent.ripples.retain(|(_, t)| now - t < 0.6);
if !app.agent.ripples.is_empty() {
ctx.request_repaint();
}
let (glide, click) = app.ui.agent_cursor.speed.times();
let Some(job) = app.agent.job.as_mut() else { return };
ctx.request_repaint();
let Some(&to) = job.stops.front() else {
finish(app, ctx);
return;
};
let t = if glide > 0.0 { (now - job.started) / glide } else { 1.0 };
if !job.clicked {
let e = ease(t) as f32;
app.agent.pos = Some(job.from + (to - job.from) * e);
if t >= 1.0 {
job.clicked = true;
job.started = now;
app.agent.ripples.push((to, now));
}
return;
}
if now - job.started >= click {
job.stops.pop_front();
job.from = to;
job.started = now;
job.clicked = false;
if job.stops.is_empty() {
finish(app, ctx);
}
}
}
/// Run the held request now (as a call without animation) and send its reply.
fn finish(app: &mut SolveApp, ctx: &egui::Context) {
let Some(job) = app.agent.job.take() else { return };
let mut params = job.params;
params["animate"] = json!(false);
let cmd = params.get("command").or(params.get("id")).and_then(Value::as_str).unwrap_or_default().to_string();
let id = solvecraft_engine::find_command(&cmd).map_or(cmd.clone(), |c| c.id.to_string());
let follow = app.ui.agent_cursor.follow_camera;
if follow && id == "sketch.create" {
app.agent.saved_view = Some(app.cam);
}
let (req, _rx) = crate::control::ControlRequest::new(job.method, params);
let out = match crate::control::handle(app, ctx, &req) {
crate::control::Outcome::Done(v) => v,
crate::control::Outcome::Screenshot { .. } => json!({"ok": false, "error": "not a command"}),
};
let ran = out.get("ok").and_then(Value::as_bool).unwrap_or(false);
if follow && ran {
if id == "sketch.create" {
crate::dialogs::look_at_sketch(app);
} else if id == "sketch.finish"
&& let Some(cam) = app.agent.saved_view.take()
{
app.animate_to(cam);
}
}
let _ = job.reply.send(out);
}
/// The pointer's colour: a violet no part of the UI uses.
const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
/// Draw the pointer, its "Agent" tag and the click ripples over everything.
pub fn paint(app: &SolveApp, ctx: &egui::Context) {
if !app.ui.agent_cursor.show {
return;
}
let Some(p) = app.agent.pos else { return };
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Debug, egui::Id::new("sc_agent_cursor")));
for (at, t0) in &app.agent.ripples {
let k = ((app.now - t0) / 0.6).clamp(0.0, 1.0) as f32;
painter.circle_stroke(*at, 6.0 + 22.0 * k, Stroke::new(3.0 * (1.0 - k) + 0.5, AGENT.gamma_multiply(1.0 - k)));
}
// An arrow pointer, tip at `p`.
let pts: Vec<Pos2> =
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
painter.rect_filled(r, 4.0, AGENT);
painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use solvecraft_engine::Session;
fn app() -> SolveApp {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let mut a = SolveApp::new(s, Services::default());
a.viewport.rect = Some(egui::Rect::from_min_size(pos2(250.0, 120.0), vec2(1100.0, 760.0)));
a.fit_view();
a
}
#[test]
fn picks_are_the_places_in_the_parameters() {
let a = app();
// Points and lists of points, not directions or colours.
let p = json!({"edges": [[20, 0, 20], [40, 15, 20]], "position": [5, 5, 20], "translate": [1, 2, 3], "color": [255, 0, 0], "radius": 2});
let w = picks(&a, "solid.fillet", &p);
assert_eq!(w.len(), 3, "{w:?}");
assert!(w.contains(&Vec3::new(20.0, 0.0, 20.0)) && w.contains(&Vec3::new(5.0, 5.0, 20.0)));
// A new sketch's origin plane is a place too (the middle of its square).
assert_eq!(picks(&a, "sketch.create", &json!({"plane": "XY"})).len(), 1);
}
#[test]
fn picks_land_where_the_viewport_draws_them() {
let a = app();
let rect = a.viewport.rect.unwrap();
let proj = crate::viewport::projection(&a, rect);
let at = Vec3::new(20.0, 15.0, 20.0);
let s = stops(&a, "solid.hole", &json!({"position": [20, 15, 20], "diameter": 5}));
let want = proj.to_screen(at).unwrap();
assert!(s.iter().any(|p| p.distance(want) < 0.01), "{s:?} vs {want:?}");
assert!(rect.contains(want));
// Off-screen places are skipped rather than pointed at outside the view.
assert!(stops(&a, "solid.hole", &json!({"position": [1e6, 0, 0]})).iter().all(|p| rect.contains(*p)));
}
#[test]
fn speeds_read_old_and_new_settings() {
// Saved settings from before #79: slow and normal are 1×, instant stays.
for (v, want) in [
(json!("slow"), Speed::Times(1.0)),
(json!("normal"), Speed::Times(1.0)),
(json!("instant"), Speed::Instant),
(json!(3), Speed::Times(3.0)),
] {
assert_eq!(Speed::from_value(&v), Some(want), "{v}");
let s: Settings = serde_json::from_value(json!({"show": true, "speed": v})).unwrap();
assert_eq!(s.speed, want);
}
assert_eq!(Speed::from_value(&json!(9)), None);
assert_eq!(serde_json::to_value(Speed::Instant).unwrap(), json!("instant"));
assert_eq!(serde_json::to_value(Speed::Times(2.5)).unwrap(), json!(2.5));
assert_eq!((Speed::Times(1.0).label(), Speed::Times(2.5).label(), Speed::Instant.label()), ("1×".into(), "2.5×".into(), "Instant".into()));
// Faster glides take less time.
assert!(Speed::Times(5.0).times().0 < Speed::Times(1.0).times().0);
}
}
+129 -90
View File
@@ -17,6 +17,17 @@ use crate::context_menu::{Item, Target};
use crate::theme::Tokens;
use crate::{SolveApp, icons};
thread_local! {
/// The rows drawn this frame: (label, row, fold arrow area, open). For automation
/// (`ui.at {browser | chevron}`, `ui.browser`).
static DRAWN: std::cell::RefCell<Vec<(String, Rect, Option<Rect>, Option<bool>)>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// The rows on screen: (label, row rect, fold arrow area, open).
pub fn drawn_rows() -> Vec<(String, Rect, Option<Rect>, Option<bool>)> {
DRAWN.with(|d| d.borrow().clone())
}
/// Browser state kept between frames.
#[derive(Default)]
pub struct TreeState {
@@ -48,13 +59,18 @@ pub struct TreeState {
#[derive(Clone, Debug)]
pub struct OccMove {
pub occurrence: u64,
/// Distances along X, Y, Z (mm) and turns about X, Y, Z (degrees) from where it was.
pub translate: [f64; 3],
pub angle_deg: f64,
pub angles: [f64; 3],
/// The occurrence's origin when the move began: what it turns about.
pub pivot: Option<solvecraft_engine::geom::Vec3>,
/// The values when the current triad drag began.
drag0: Option<([f64; 3], [f64; 3])>,
}
impl OccMove {
pub fn new(occurrence: u64) -> Self {
OccMove { occurrence, translate: [0.0; 3], angle_deg: 0.0 }
OccMove { occurrence, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None }
}
}
@@ -246,6 +262,8 @@ struct RowResp {
hovered: bool,
/// The row's response (drag and drop).
resp: Option<egui::Response>,
/// The press began on the fold arrow, eye or radio (not a drag of the row).
on_control: bool,
}
fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
@@ -256,7 +274,10 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
ui.allocate_space(r.size());
return RowResp { rect: Some(r), ..Default::default() };
}
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag());
// A stable id per row (not the layout position), so a press and its release stay on the
// same row while rows above open or close.
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::hover());
let resp = ui.interact(r, id, Sense::click_and_drag());
// While something is dragged (an appearance swatch, drawn under the pointer) `hovered` is
// off; the row under the pointer still counts, so a drop lands on it.
let dragged_over = egui::DragAndDrop::has_any_payload(ui.ctx()) && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p));
@@ -269,6 +290,27 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
let mut x = r.left() + 2.0 + row.depth as f32 * INDENT;
let cy = r.center().y;
let ink = if row.dim { t.text_dim } else { t.icon };
// The row is one widget: where a click lands decides what it does (the fold arrow with
// padding, the eye, the radio, or the row itself), so a click on the arrow never also
// selects the row or starts a drag.
let fold_r = Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom()));
let eye_r = Rect::from_center_size(pos2(x + 14.0 + 8.0, cy), vec2(19.0, r.height()));
let radio_r = Rect::from_center_size(pos2(x + 32.0 + 6.0, cy), vec2(15.0, r.height()));
let at = |p: Pos2| -> u8 {
if row.fold.is_some() && fold_r.contains(p) {
1
} else if row.eye.is_some() && eye_r.contains(p) {
2
} else if row.radio.is_some() && radio_r.contains(p) {
3
} else {
0
}
};
DRAWN.with(|d| d.borrow_mut().push((row.label.to_string(), r, row.fold.map(|_| fold_r), row.fold)));
let press_on = ui.input(|i| i.pointer.press_origin()).map_or(0, at);
let click_on = resp.interact_pointer_pos().map_or(0, at);
let hover_on = resp.hover_pos().map_or(0, at);
// Fold arrow.
if let Some(open) = row.fold {
let ar = Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0));
@@ -277,9 +319,10 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
} else {
vec![pos2(ar.left() + 4.0, ar.top() + 2.0), pos2(ar.right() - 3.0, ar.center().y), pos2(ar.left() + 4.0, ar.bottom() - 2.0)]
};
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
out.fold = ui.interact(ar.expand(3.0), id.with("fold"), Sense::click()).clicked();
crate::scenario::publish_handle(&format!("fold:{}", row.label), ar.center());
let ink = if hover_on == 1 { t.text } else { t.text_dim };
ui.painter().add(egui::Shape::convex_polygon(pts, ink, Stroke::NONE));
out.fold = resp.clicked() && click_on == 1;
crate::scenario::publish_handle(&format!("fold:{}", row.label), fold_r.center());
}
x += 14.0;
// Eye.
@@ -289,9 +332,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
if !vis {
ui.painter().line_segment([er.left_bottom() + vec2(2.0, -2.0), er.right_top() + vec2(-2.0, 2.0)], Stroke::new(1.4, t.text_dim));
}
let e = ui.interact(er.expand(2.0), id.with("eye"), Sense::click());
out.eye = e.clicked();
e.on_hover_text(if vis { "Hide" } else { "Show" });
out.eye = resp.clicked() && click_on == 2;
}
x += 18.0;
// Activation radio (components).
@@ -301,9 +342,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
if active {
ui.painter().circle_filled(rc, 2.8, t.accent);
}
let rr = ui.interact(Rect::from_center_size(rc, vec2(14.0, 14.0)), id.with("radio"), Sense::click());
out.radio = rr.clicked();
rr.on_hover_text(if active { "Active component" } else { "Activate" });
out.radio = resp.clicked() && click_on == 3;
x += 15.0;
}
if !row.icon.is_empty() {
@@ -322,9 +361,20 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
bx += 15.0;
}
crate::scenario::publish_handle(&format!("row:{}", row.label), r.center());
let tip = match hover_on {
1 => Some(if row.fold == Some(true) { "Collapse" } else { "Expand" }),
2 => Some(if row.eye == Some(true) { "Hide" } else { "Show" }),
3 => Some(if row.radio == Some(true) { "Active component" } else { "Activate" }),
_ => None,
};
let resp = match tip {
Some(t) => resp.on_hover_text(t),
None => resp,
};
out.on_control = press_on != 0;
out.clicked = resp.clicked() && click_on == 0;
out.double = resp.double_clicked() && click_on == 0;
out.resp = Some(resp.clone());
out.clicked = resp.clicked();
out.double = resp.double_clicked();
if resp.secondary_clicked() {
out.secondary = resp.interact_pointer_pos().or(Some(r.left_bottom()));
}
@@ -424,6 +474,7 @@ struct Actions {
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
app.tree.order.clear();
DRAWN.with(|d| d.borrow_mut().clear());
let mut acts = Actions::default();
egui::Panel::left("sc_browser")
.exact_size(250.0)
@@ -447,7 +498,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
if let Some((at, target)) = acts.menu {
crate::context_menu::open_for(app, at, target);
}
occurrence_panel(app, ui.ctx());
occurrence_panel(app, ui);
capture_prompt(app, ui.ctx());
canvas_panel(app, ui.ctx());
redefine_panel(app, ui.ctx());
@@ -1227,7 +1278,7 @@ fn entry_row(
}
let Some(resp) = &r.resp else { return };
// Drag: the selected items of this folder when this one is selected, else just this one.
if resp.drag_started() {
if resp.drag_started() && !r.on_control {
let keys: Vec<String> = if e.selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] };
resp.dnd_set_drag_payload(Drag { comp, folder, keys });
}
@@ -1311,20 +1362,24 @@ fn apply_drop(app: &mut SolveApp, d: DropAction) {
// ---------------------------------------------------------------------------------------------
/// Move/Copy of an occurrence: offsets and a rotation about Z, pending until Capture Position.
fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
let Some(mut m) = app.tree.occurrence_move.clone() else { return };
let ctx = ui.ctx().clone();
if m.pivot.is_none() {
m.pivot = occurrence_origin(app, m.occurrence);
}
let name = app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.name.clone()).unwrap_or_default();
let mut open = true;
let mut action: Option<&str> = None;
let before = (m.translate, m.angle_deg);
crate::frame::window(ctx, format!("Move: {name}"), crate::frame::Width::Normal)
let before = (m.translate, m.angles);
crate::frame::window(&ctx, format!("Move: {name}"), crate::frame::Width::Normal)
.id(egui::Id::new("sc_occ_move"))
.pivot(egui::Align2::LEFT_TOP)
.default_pos(panel_pos(app))
.open(&mut open)
.resizable(false)
.collapsible(false)
.show(ctx, |ui| {
.show(&ctx, |ui| {
egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() {
ui.label(*axis);
@@ -1333,9 +1388,13 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
}
ui.end_row();
}
ui.label("Z angle");
ui.add(egui::DragValue::new(&mut m.angle_deg).speed(1.0).suffix(" deg"));
ui.end_row();
for (k, axis) in ["X angle", "Y angle", "Z angle"].iter().enumerate() {
ui.label(*axis);
if let Some(v) = m.angles.get_mut(k) {
ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg"));
}
ui.end_row();
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
@@ -1347,14 +1406,9 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
}
});
});
gizmo(app, ctx, &mut m);
if (m.translate, m.angle_deg) != before {
// The pending move is the typed offset from where the occurrence is now.
let _ = app.session.execute("component.revert_position", &json!({}));
let _ = app.run(
"occurrence.move",
json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [0, 0, 1], "angle": m.angle_deg.to_radians(), "capture": false }),
);
triad(app, ui, &mut m);
if (m.translate, m.angles) != before {
apply_occurrence_move(app, &m);
}
match action {
Some("capture") => {
@@ -1373,6 +1427,50 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
}
}
/// The pending move: turned about X, then Y, then Z through the starting origin, then moved.
pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) {
use crate::gizmo;
use solvecraft_engine::geom::Vec3;
let a = m.angles.map(f64::to_radians);
let r = gizmo::mul(&gizmo::mul(&gizmo::rotation(Vec3::Z, a[2]), &gizmo::rotation(Vec3::Y, a[1])), &gizmo::rotation(Vec3::X, a[0]));
let (axis, angle) = gizmo::axis_angle(&r);
let o = m.pivot.unwrap_or(Vec3::ZERO);
let _ = app.session.execute("component.revert_position", &json!({}));
let _ = app.run(
"occurrence.move",
json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }),
);
}
/// The shared move triad at the occurrence's origin: arrows and squares move it, rings turn it.
fn triad(app: &mut SolveApp, ui: &mut egui::Ui, m: &mut OccMove) {
use crate::gizmo::{self, Change};
let (Some(rect), Some(pivot)) = (app.viewport.rect, m.pivot) else { return };
let proj = crate::viewport::projection(app, rect);
let center = pivot + solvecraft_engine::geom::Vec3::new(m.translate[0], m.translate[1], m.translate[2]);
let layer = egui::LayerId::new(egui::Order::Middle, egui::Id::new("sc_occ_triad_layer"));
let mut gui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(rect));
let painter = gui.painter().with_clip_rect(rect);
let t = gizmo::Triad { center, translate: true, rotate: true };
let step = crate::canvas::snap_step(app.cam.half_height());
let Some(g) = gizmo::show(&mut gui, &painter, &proj, egui::Id::new("sc_occ_triad"), &t, step) else { return };
if g.started || m.drag0.is_none() {
m.drag0 = Some((m.translate, m.angles));
}
let (t0, a0) = m.drag0.unwrap_or((m.translate, m.angles));
match g.change {
Change::Translate(v) => m.translate = [t0[0] + v.x, t0[1] + v.y, t0[2] + v.z],
Change::Rotate { axis, angle } => {
if let Some(slot) = m.angles.get_mut(axis) {
*slot = a0.get(axis).copied().unwrap_or(0.0) + angle.to_degrees();
}
}
}
if g.done {
m.drag0 = None;
}
}
/// Dimensions of finished sketches whose dimensions are shown (Show Dimensions), drawn over the
/// viewport like the sketch being edited draws its own (`crate::dim_view`), but not editable.
pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) {
@@ -1517,65 +1615,6 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option<solvecraft_engin
Some(solvecraft_engine::doc::apply_point(&solvecraft_engine::doc::mat_mul(&parent, &own), solvecraft_engine::geom::Vec3::ZERO))
}
/// Screen length of the gizmo's arrows.
const GIZMO_PX: f32 = 80.0;
/// Arrows along X, Y and Z at the occurrence's origin: dragging one moves it along that axis.
fn gizmo(app: &mut SolveApp, ctx: &egui::Context, m: &mut OccMove) {
use solvecraft_engine::geom::Vec3;
let t = Tokens::get();
let (Some(rect), Some(o)) = (app.viewport.rect, occurrence_origin(app, m.occurrence)) else { return };
let proj = crate::viewport::projection(app, rect);
let Some(p0) = proj.to_screen(o) else { return };
if !rect.contains(p0) {
return;
}
let axes = [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)];
// Per axis: the screen direction and pixels per millimetre.
let mut arms = Vec::new();
for (k, (a, col)) in axes.iter().enumerate() {
let Some(p1) = proj.to_screen(o + *a) else { continue };
let d = p1 - p0;
let px_per_mm = d.length();
if px_per_mm < 1e-6 || !px_per_mm.is_finite() {
continue;
}
arms.push((k, d / px_per_mm, px_per_mm, *col));
}
let bbox = Rect::from_center_size(p0, vec2(2.0 * GIZMO_PX + 30.0, 2.0 * GIZMO_PX + 30.0)).intersect(rect);
egui::Area::new(egui::Id::new("sc_occ_gizmo")).fixed_pos(bbox.min).order(egui::Order::Middle).show(ctx, |ui| {
let (_, _) = ui.allocate_exact_size(bbox.size(), Sense::hover());
let painter = ui.painter_at(bbox);
for (k, dir, px_per_mm, col) in &arms {
// An axis seen end-on has no arrow to drag.
let len = GIZMO_PX * dir.length().min(1.0);
let tip = p0 + *dir * len;
let handle =
Rect::from_center_size(p0 + *dir * (len * 0.6), vec2(len * 0.8, len * 0.8)).intersect(Rect::from_two_pos(p0, tip).expand(9.0));
let resp = ui.interact(handle, ui.id().with(("arm", *k)), Sense::drag());
let lit = resp.hovered() || resp.dragged();
let w = if lit { 4.0 } else { 2.5 };
painter.line_segment([p0 + *dir * 10.0, tip], Stroke::new(w, *col));
let n = vec2(-dir.y, dir.x);
painter.add(egui::Shape::convex_polygon(vec![tip + *dir * 10.0, tip + n * 5.5, tip - n * 5.5], *col, Stroke::NONE));
if resp.dragged() {
let mm = resp.drag_delta().dot(*dir) / px_per_mm;
if let Some(v) = m.translate.get_mut(*k)
&& mm.is_finite()
{
*v += f64::from(mm);
}
}
if resp.drag_stopped()
&& let Some(v) = m.translate.get_mut(*k)
{
*v = (*v * 100.0).round() / 100.0;
}
}
painter.circle(p0, 6.0, t.panel, Stroke::new(1.5, t.accent));
});
}
/// Redefine Sketch Plane: pick an origin or construction plane, or the selected planar face.
fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) {
let Some(id) = app.tree.redefine else { return };
+34 -10
View File
@@ -13,7 +13,7 @@ use solvecraft_engine::geom::{Mesh, Vec3};
use crate::SolveApp;
use crate::dialogs::{Dialog, Kind};
use crate::theme::Tokens;
use crate::viewport::Proj;
use crate::viewport::{Proj, projected_polygon};
/// Where the dialog's geometry is: a base point and, for values that can be dragged, the
/// direction a growing value points.
@@ -145,6 +145,13 @@ fn format_len(v: f64) -> String {
format!("{} mm", if s == "-0" { "0" } else { s })
}
/// The offset plane preview: a square of half side `h` around `c`, square to `n`.
fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option<Vec<Pos2>> {
let u = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized()?;
let v = n.cross(u);
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect()
}
#[allow(clippy::too_many_arguments)]
fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option<Vec3>) {
let t = Tokens::get();
@@ -168,16 +175,9 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
// An offset plane shows where it will be.
if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir)
&& let Ok(off) = app.session.doc.eval(offset, ValueKind::Length)
&& let Some(u) = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized()
&& let Some(q) = offset_plane_quad(proj, base + n * off, n, half_height * 0.25)
{
let v = n.cross(u);
let h = half_height * 0.25;
let c = base + n * off;
let q: Vec<Pos2> =
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().filter_map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect();
if q.len() == 4 {
painter.add(egui::Shape::convex_polygon(q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge)));
}
painter.extend(projected_polygon(&q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge)));
}
// Holes: the depth arrow points into the material (empty depth: through all); the
// diameter is typed in the box.
@@ -412,6 +412,30 @@ fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
mod tests {
use super::*;
#[test]
fn offset_plane_preview_stays_within_its_square() {
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let mut worst = f32::NEG_INFINITY;
for n in [Vec3::X, Vec3::Y, Vec3::Z] {
for dir in crate::viewport::orbit_directions() {
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
let h = cam.half_height() * 0.25;
let (Some(q), Some(c), Some(side)) =
(offset_plane_quad(&proj, Vec3::ZERO, n, h), proj.to_screen(Vec3::ZERO), proj.to_screen(cam.basis().0 * h))
else {
continue;
};
// The square's half diagonal on screen (an orthographic view), plus stroke and feathering.
let limit = c.distance(side) * 2f32.sqrt() + 3.0;
for p in crate::viewport::drawn_points(projected_polygon(&q, Color32::GRAY, Stroke::new(1.2, Color32::BLACK))) {
worst = worst.max(p.distance(c) - limit);
}
}
}
assert!(worst <= 0.0, "a vertex {worst} px past the square");
}
#[test]
fn snapping_steps_and_formatting() {
assert_eq!(snap_step(40.0), 1.0);
+20 -2
View File
@@ -46,6 +46,12 @@ pub enum Target {
Joint {
id: u64,
},
/// A command (toolbar button, panel drop-down row, S box row).
ToolbarCommand {
id: String,
},
/// The workspace switcher.
Workspace,
/// The Named Views folder, and one view (saved or standard) in it.
NamedViews,
NamedView {
@@ -228,6 +234,8 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
| Target::Origin
| Target::Units
| Target::Joint { .. }
| Target::ToolbarCommand { .. }
| Target::Workspace
| Target::NamedViews
| Target::NamedView { .. } => Vec::new(),
}
@@ -290,11 +298,19 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
Target::Origin => crate::browser::origin_items(app),
Target::NamedViews => vec![act("ui.newView", "New Named View", "perspective")],
Target::Joint { id } => joint_items(app, *id),
Target::ToolbarCommand { id } => crate::toolbar_custom::command_items(app, id),
Target::Workspace => {
let mut v = vec![act("ui.workspace", "Design", "box").key("current")];
for w in ["Generative Design", "Render", "Animation", "Simulation", "Manufacture", "Drawing"] {
v.push(act("ui.workspace", w, "").key("coming later").on(false));
}
v
}
Target::Units => crate::prefs::UNITS
.iter()
.map(|u| {
let label = if app.session.doc.units == *u { format!("{u} ✓") } else { (*u).to_string() };
cmd(app, "document.units", &label).with(json!({ "units": u }))
let item = cmd(app, "document.units", u).with(json!({ "units": u }));
if app.session.doc.units == *u { item.key("current") } else { item }
})
.collect(),
Target::NamedView { name } => crate::browser::view_items(app, name),
@@ -773,6 +789,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
}
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
"ui.workspace" => {}
"ui.editJoint" | "ui.driveJoint" => {
if let Some(id) = id_of(&p, "joint") {
let d = if item.id == "ui.editJoint" {
+51 -4
View File
@@ -27,6 +27,9 @@
//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa,
//! length_decimals, angle_decimals); returns them all
//! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question
//! - `ui.agent_cursor {show?, speed?: 1–5 (times) | "instant", follow_camera?}`: the agent cursor
//! (#35); while it is shown, `engine.execute` first glides to the command's button and picks
//! and replies once the command has run (`"animate": false` skips that one call)
//! - `ui.resize {width, height}`, `app.quit`
use std::sync::mpsc::Sender;
@@ -95,6 +98,22 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
}
wrap(r)
}
"ui.agent_cursor" => {
let s = &mut app.ui.agent_cursor;
if let Some(v) = p.get("show").and_then(Value::as_bool) {
s.show = v;
}
if let Some(v) = p.get("follow_camera").and_then(Value::as_bool) {
s.follow_camera = v;
}
if let Some(v) = p.get("speed") {
match crate::agent_cursor::Speed::from_value(v) {
Some(sp) => s.speed = sp,
None => return err("speed must be a number from 1 to 5, or \"instant\""),
}
}
ok(json!(app.ui.agent_cursor))
}
"engine.script" => wrap(app.session.run_script(p).map(|v| json!(v)).map_err(|e| e.to_string())),
"engine.commands" => wrap(app.session.execute("engine.commands", &json!({})).map_err(|e| e.to_string())),
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
@@ -111,6 +130,8 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
"home": app.home.open,
"documents": app.docs.count(),
"quit_requested": app.quit_requested,
"quitting": app.docs.quitting.is_some(),
})),
"ui.documents" => ok(crate::documents::control(app, p)),
"ui.home" => {
@@ -266,6 +287,20 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
None => err(format!("handle {h} is not shown")),
};
}
// A browser row by its label: the row itself, or its fold arrow.
for (k, chevron) in [("browser", false), ("chevron", true)] {
if let Some(label) = p.get(k).and_then(Value::as_str) {
let rows = crate::browser::drawn_rows();
let found = rows
.iter()
.find(|r| r.0 == label)
.and_then(|r| if chevron { r.2.map(|f| f.center()) } else { Some(r.1.left_center() + egui::vec2(120.0, 0.0)) });
return match found {
Some(q) => ok(json!([q.x, q.y])),
None => err(format!("no browser row `{label}`{} on screen", if chevron { " with a fold arrow" } else { "" })),
};
}
}
if let Some(d) = p.get("dimension").and_then(Value::as_str) {
return match crate::dim_view::text_at(d) {
Some(q) => ok(json!([q.x, q.y])),
@@ -405,6 +440,11 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
None => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})),
}
}
"ui.browser" => ok(json!({
"rows": crate::browser::drawn_rows().iter().map(|(label, r, _, open)| json!({"label": label, "y": r.center().y, "open": open})).collect::<Vec<_>>(),
"selection": app.session.selection,
"picked_components": app.tree.picked_components,
})),
"ui.capture" => match s("action") {
Some(a) => match crate::browser::answer_capture(app, a) {
Ok(()) => ok(json!({"pending_moves": app.session.pending_moves.len()})),
@@ -412,7 +452,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
},
None => ok(json!({"question": app.tree.capture_prompt, "pending_moves": app.session.pending_moves.len()})),
},
"ui.prefs" => wrap(crate::prefs::set(app, p)),
"ui.prefs" => wrap(crate::prefs::control(app, p)),
"ui.help" => match s("item") {
Some(i @ ("about" | "shortcuts" | "report")) => {
crate::help::run(app, ctx, i);
@@ -459,9 +499,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
}
}
"app.quit" => {
app.quit_requested = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
// `force`: quit without asking about unsaved designs.
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
app.quit_requested = true;
} else {
crate::documents::request_quit(app);
}
if app.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
ok(json!({"quitting": app.quit_requested, "asking": app.docs.closing.is_some()}))
}
other => err(format!("unknown method `{other}`")),
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -250,7 +250,8 @@ fn drive_value_takes_the_keyboard() {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let frame = |app: &mut SolveApp, events: Vec<egui::Event>| {
let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()))
// Nothing renders here: drop the frame's texture changes deliberately.
ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())).textures_delta.clear();
};
frame(&mut app, vec![]);
frame(&mut app, vec![]);
+264
View File
@@ -0,0 +1,264 @@
//! Dialogs for the surface tools and face splitting (#19): Patch, Stitch, Thicken, Extend,
//! Trim and Split Face. Each has a live preview and Edit Feature.
use serde_json::{Map, Value, json};
use solvecraft_engine::Sel;
use solvecraft_engine::doc::expr::Kind as ValueKind;
use solvecraft_engine::doc::{FaceAt, FeatureKind, PlaneRef};
use solvecraft_engine::geom::Vec3;
use crate::SolveApp;
use crate::dialogs::{Kind, OP_LABELS, OPS, combo, field, op_index, row_label};
use crate::selection::{BODIES, EDGES, FACES, PLANES, PROFILES, SelInput};
/// A surface tool dialog's values.
#[derive(Clone, Debug, PartialEq)]
pub enum Surf {
Patch,
Stitch { tolerance: String },
Thicken { thickness: String, symmetric: bool, operation: usize },
Extend { distance: String },
Trim,
SplitFace,
}
impl Surf {
pub fn title(&self) -> &'static str {
match self {
Surf::Patch => "PATCH",
Surf::Stitch { .. } => "STITCH",
Surf::Thicken { .. } => "THICKEN",
Surf::Extend { .. } => "EXTEND",
Surf::Trim => "TRIM",
Surf::SplitFace => "SPLIT FACE",
}
}
/// The value on the canvas.
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
Some(match self {
Surf::Thicken { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
Surf::Extend { distance } => ("Distance", ValueKind::Length, distance),
_ => return None,
})
}
}
fn inputs(k: &Surf) -> Vec<SelInput> {
match k {
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
Surf::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)],
Surf::Trim => vec![
SelInput::new("Surface", BODIES, false),
SelInput::new("Trimming tool", PLANES | FACES, false),
SelInput::new("Side to keep", FACES, false),
],
Surf::SplitFace => vec![SelInput::new("Faces to split", FACES, true), SelInput::new("Splitting tool", PLANES | FACES, false)],
}
}
/// The dialog for a surface command.
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let k = match id {
"surface.patch" => Surf::Patch,
"surface.stitch" => Surf::Stitch { tolerance: String::new() },
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
"surface.extend" => Surf::Extend { distance: "10 mm".into() },
"surface.trim" => Surf::Trim,
"solid.split_face" => Surf::SplitFace,
_ => return None,
};
let i = inputs(&k);
Some((Kind::Surface(k), i))
}
/// The rows after the inputs; true when Enter was pressed in a field.
pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
let mut enter = false;
match k {
Surf::Stitch { tolerance } => {
row_label(ui, "Tolerance");
let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight"));
crate::params_dialog::complete(ui, &r, tolerance);
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
}
Surf::Thicken { thickness, symmetric, operation } => {
row_label(ui, "Thickness");
enter |= field(ui, thickness);
ui.end_row();
row_label(ui, "Direction");
let mut dir = usize::from(*symmetric);
combo(ui, "th_dir", &["One Side", "Symmetric"], &mut dir);
*symmetric = dir == 1;
ui.end_row();
row_label(ui, "Operation");
combo(ui, "th_op", &OP_LABELS, operation);
ui.end_row();
}
Surf::Extend { distance } => {
row_label(ui, "Distance");
enter |= field(ui, distance);
ui.end_row();
}
Surf::Patch | Surf::Trim | Surf::SplitFace => {}
}
enter
}
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
}
fn pt(v: Vec3) -> Value {
json!([v.x, v.y, v.z])
}
fn bodies(inputs: &[SelInput], i: usize) -> Vec<String> {
items(inputs, i).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect()
}
/// A splitting or trimming tool: a plane by name, a planar face as its plane, or any other
/// face as {body, point} (its surface extended).
fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), String> {
match sel {
Some(Sel::Plane { name }) => Ok(("plane", json!(name))),
Some(Sel::Face { body, index, point }) => match crate::dialogs::planar_face(&app.session, body, *index) {
Some((_, n)) => Ok(("plane", json!({"origin": pt(*point), "normal": pt(n)}))),
None => Ok(("tool", json!({"body": body, "point": pt(*point)}))),
},
_ => Err("pick a plane or a face as the tool".into()),
}
}
/// The command a surface dialog makes.
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
let (cmd, mut p) = match k {
Surf::Patch => {
need(0, "a boundary (sketch profiles or a loop of edges)")?;
let profiles: Vec<(u64, usize)> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect();
if let Some((sk, _)) = profiles.first() {
let idx: Vec<usize> = profiles.iter().filter(|(s, _)| s == sk).map(|(_, i)| *i).collect();
("surface.patch", json!({"sketch": sk, "profiles": idx}))
} else {
let edges: Vec<Value> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None });
("surface.patch", json!({"edges": edges, "body": body}))
}
}
Surf::Stitch { tolerance } => {
need(0, "the surfaces to stitch")?;
let mut p = json!({ "bodies": bodies(inputs, 0) });
if !tolerance.trim().is_empty() {
p["tolerance"] = json!(tolerance);
}
("surface.stitch", p)
}
Surf::Thicken { thickness, symmetric, operation } => {
need(0, "the surfaces to thicken")?;
(
"surface.thicken",
json!({"bodies": bodies(inputs, 0), "thickness": thickness, "symmetric": symmetric, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
)
}
Surf::Extend { distance } => {
need(0, "the edges to extend")?;
let body =
items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None }).unwrap_or_default();
let edges: Vec<Value> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
("surface.extend", json!({"body": body, "edges": edges, "distance": distance}))
}
Surf::Trim => {
need(0, "the surface to trim")?;
need(1, "the trimming tool")?;
need(2, "the side to keep (a point on it)")?;
let body = bodies(inputs, 0).into_iter().next().unwrap_or_default();
let (key, t) = tool(app, items(inputs, 1).first())?;
let keep = items(inputs, 2)
.first()
.and_then(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None })
.ok_or("pick the side to keep")?;
let mut p = json!({"body": body, "keep": keep});
p[key] = t;
("surface.trim", p)
}
Surf::SplitFace => {
need(0, "the faces to split")?;
need(1, "the splitting tool")?;
let faces: Vec<Value> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect();
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Face { body, .. } = x { Some(body.clone()) } else { None });
let (key, t) = tool(app, items(inputs, 1).first())?;
let mut p = json!({"faces": faces, "body": body});
p[key] = t;
("solid.split_face", p)
}
};
if let Value::Object(m) = &mut p {
for (k, v) in extra {
m.entry(k.clone()).or_insert_with(|| v.clone());
}
}
Ok(vec![(cmd.to_string(), p)])
}
/// A tool as a selection again: a plane, else the face at the stored point.
fn tool_sel(app: &SolveApp, plane: &PlaneRef, tool: Option<&FaceAt>) -> Option<Sel> {
match tool {
Some(t) => crate::dialogs::face_sel(&app.session, t.point),
None => crate::dialogs::plane_sel(&app.session, plane),
}
}
/// The dialog for editing a surface feature.
pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelInput>, Map<String, Value>)> {
let s = &app.session;
let edge = |p: &Vec3| crate::dialogs::edge_sel(s, *p);
let (k, fill): (Surf, Vec<Vec<Sel>>) = match kind {
FeatureKind::Patch { sketch, edges, .. } => {
let mut sels: Vec<Sel> = edges.iter().filter_map(edge).collect();
if let Some(sk) = sketch {
let st = s.world_state();
if let Some(ss) = st.sketch(*sk) {
sels.extend((0..ss.profiles.len()).map(|index| Sel::Profile { sketch: *sk, index }));
}
}
(Surf::Patch, vec![sels])
}
FeatureKind::Stitch { bodies, tolerance } => (
Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } },
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
),
FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => (
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
),
FeatureKind::SurfaceExtend { edges, distance, .. } => {
(Surf::Extend { distance: distance.clone() }, vec![edges.iter().filter_map(edge).collect()])
}
FeatureKind::SurfaceTrim { body, plane, tool, keep } => (
Surf::Trim,
vec![
vec![Sel::Body { name: body.clone() }],
tool_sel(app, plane, tool.as_ref()).into_iter().collect(),
crate::dialogs::face_sel(s, *keep).into_iter().collect(),
],
),
FeatureKind::SplitFace { faces, plane, tool, .. } => (
Surf::SplitFace,
vec![faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect(), tool_sel(app, plane, tool.as_ref()).into_iter().collect()],
),
_ => return None,
};
let mut ins = inputs(&k);
for (inp, items) in ins.iter_mut().zip(fill) {
inp.items = items;
}
Some((Kind::Surface(k), ins, Map::new()))
}
+168 -20
View File
@@ -1,7 +1,8 @@
//! Several open designs, as tabs in the application bar. The active design lives in the app as
//! always (`app.session`, `app.cam`…); the others wait here with their own session (undo,
//! selection), camera and per-design view state, and are swapped in when their tab is picked.
//! Closing a design with unsaved changes asks first.
//! Closing a design with unsaved changes asks first, and so does quitting: one "save changes?"
//! per design with unsaved changes ([`request_quit`]).
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
use serde_json::{Value, json};
@@ -48,6 +49,8 @@ pub struct Documents {
pub active: usize,
/// A tab waiting for the answer to "save changes?".
pub closing: Option<usize>,
/// Quitting: the tabs answered so far with Don't Save (`None`: not quitting).
pub quitting: Option<Vec<usize>>,
}
impl Documents {
@@ -214,6 +217,44 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
}
}
/// Quit (the title bar's ×, File ▸ Quit, the window being closed): asks "save changes?" for each
/// design with unsaved changes, then sets `app.quit_requested` for the host to close the window.
/// Cancel, or a Save that didn't save, stays open.
pub fn request_quit(app: &mut SolveApp) {
if app.docs.quitting.is_none() {
app.docs.quitting = Some(Vec::new());
}
quit_step(app);
}
/// Ask about the next design with unsaved changes, or quit when none is left.
fn quit_step(app: &mut SolveApp) {
let Some(skip) = app.docs.quitting.clone() else { return };
if app.docs.closing.is_some() {
return;
}
match (0..app.docs.count()).find(|i| !skip.contains(i) && dirty(app, *i)) {
Some(i) => {
switch(app, i);
app.docs.closing = Some(i);
}
None => {
// Don't Save means discard: no recovery copy is kept for the next launch either.
for i in skip {
switch(app, i);
app.session.mark_saved();
}
app.docs.quitting = None;
app.quit_requested = true;
}
}
}
/// Is any open design unsaved (the web page asks before it is left)?
pub fn any_dirty(app: &SolveApp) -> bool {
(0..app.docs.count()).any(|i| dirty(app, i))
}
fn dirty(app: &SolveApp, i: usize) -> bool {
if i == app.docs.active || app.docs.slots.is_empty() {
return app.session.is_dirty();
@@ -236,18 +277,28 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> {
.collect()
}
/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force`
/// closes without asking).
/// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force`
/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?".
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
match p.get("action").and_then(Value::as_str) {
Some("new") => new_design(app),
Some("switch") => switch(app, i.unwrap_or(app.docs.active)),
Some("close") => close(app, i.unwrap_or(app.docs.active), p.get("force").and_then(Value::as_bool).unwrap_or(false)),
Some("quit") => request_quit(app),
_ => {}
}
let answer = match p.get("answer").and_then(Value::as_str) {
Some("save") => Some(Answer::Save),
Some("dont_save") => Some(Answer::DontSave),
Some("cancel") => Some(Answer::Cancel),
_ => None,
};
if let Some(a) = answer {
answer_prompt(app, a);
}
let tabs: Vec<Value> = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect();
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing})
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing, "quitting": app.docs.quitting.is_some()})
}
/// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close,
@@ -375,50 +426,81 @@ pub fn layout(n: usize, active: usize, room: f32) -> (usize, usize, f32) {
(first, fit, MIN_TAB)
}
/// The "save changes?" prompt for a tab being closed.
/// What "save changes?" was answered.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
Save,
DontSave,
Cancel,
}
/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting.
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
quit_step(app);
let Some(i) = app.docs.closing else { return };
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
let mut answer: Option<u8> = None;
crate::frame::window(ctx, "Save changes?", crate::frame::Width::Normal)
let mut answer: Option<Answer> = None;
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
.id(egui::Id::new("sc_close_prompt"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
.show(ctx, |ui| {
ui.label(format!("{name} has unsaved changes."));
ui.label(format!("{name} has unsaved changes. If you don't save, they are lost."));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
answer = Some(0);
answer = Some(Answer::Save);
}
if ui.button("Don't Save").clicked() {
answer = Some(1);
answer = Some(Answer::DontSave);
}
if ui.button("Cancel").clicked() {
answer = Some(2);
answer = Some(Answer::Cancel);
}
});
});
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
answer = Some(2);
answer = Some(Answer::Cancel);
app.esc_handled = true;
}
if let Some(a) = answer {
answer_prompt(app, a);
}
}
/// Act on the "save changes?" answer (also the control channel's `ui.documents {answer}`).
pub fn answer_prompt(app: &mut SolveApp, answer: Answer) {
let Some(i) = app.docs.closing else { return };
match answer {
Some(0) => {
Answer::Save => {
switch(app, i);
crate::toolbar::save(app);
// Saved (not cancelled in the file picker): close it.
if !app.session.is_dirty() {
let saved = !app.session.is_dirty();
app.docs.closing = None;
if app.docs.quitting.is_some() {
// Quit only once it is saved; a cancelled picker or a failed save stays open.
if !saved {
app.docs.quitting = None;
}
} else if saved {
close(app, app.docs.active, true);
} else {
app.docs.closing = None;
}
}
Some(1) => close(app, i, true),
Some(2) => app.docs.closing = None,
_ => {}
Answer::DontSave => {
if let Some(skip) = app.docs.quitting.as_mut() {
skip.push(i);
app.docs.closing = None;
} else {
close(app, i, true);
}
}
Answer::Cancel => {
app.docs.closing = None;
app.docs.quitting = None;
}
}
quit_step(app);
}
#[cfg(test)]
@@ -473,6 +555,72 @@ mod tests {
assert!(active_is_blank(&a) && a.home.open);
}
/// Quitting asks once per design with unsaved changes: Don't Save moves on (and keeps no
/// recovery copy), Save saves and moves on, and the app quits after the last answer.
#[test]
fn quit_asks_about_each_unsaved_design_then_quits() {
let mut a = app();
request_quit(&mut a);
assert!(a.quit_requested, "nothing unsaved: quits at once");
let mut a = app();
let file = std::env::temp_dir().join(format!("solvecraft-quit-save-{}.solvecraft", std::process::id()));
let _ = std::fs::remove_file(&file);
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
a.run("file.save_as", json!({"path": file.to_string_lossy()})).unwrap();
a.run("solid.sphere", json!({"diameter": 4})).unwrap();
new_design(&mut a);
a.run("solid.sphere", json!({"diameter": 8})).unwrap();
assert!(dirty(&a, 0) && dirty(&a, 1));
request_quit(&mut a);
assert!(!a.quit_requested);
assert_eq!(a.docs.closing, Some(0), "the first unsaved design is asked about first");
assert_eq!(a.docs.active, 0, "and shown");
answer_prompt(&mut a, Answer::Save);
assert!(!dirty(&a, 0), "saved");
assert_eq!(
std::fs::read(&file).ok().and_then(|b| solvecraft_engine::io::read_design(&b).ok()).map(|d| d.features.len()),
Some(2),
"the file holds the change"
);
assert_eq!(a.docs.closing, Some(1), "then the next one");
assert!(!a.quit_requested);
answer_prompt(&mut a, Answer::DontSave);
assert_eq!(a.docs.closing, None);
assert!(a.quit_requested, "every design answered: quit");
assert!(!any_dirty(&a), "Don't Save discards: nothing is kept for recovery");
let _ = std::fs::remove_file(&file);
}
/// Cancel, Esc's answer, or a Save that saves nothing (a never-saved design whose Save As
/// picker is cancelled) stay open with the changes.
#[test]
fn quit_cancelled_or_unsaved_stays_open() {
let mut a = app();
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
request_quit(&mut a);
assert_eq!(a.docs.closing, Some(0));
answer_prompt(&mut a, Answer::Cancel);
assert!(!a.quit_requested && a.docs.closing.is_none() && a.docs.quitting.is_none());
assert!(a.session.is_dirty(), "the changes are still there");
// No Save As picker answer (the default services have none): not saved, so no quit.
request_quit(&mut a);
answer_prompt(&mut a, Answer::Save);
assert!(!a.quit_requested && a.docs.quitting.is_none() && a.session.is_dirty());
// A picked file: saved, then quit.
let file = std::env::temp_dir().join(format!("solvecraft-quit-saveas-{}.solvecraft", std::process::id()));
let _ = std::fs::remove_file(&file);
let to = file.to_string_lossy().to_string();
a.services.pick_save = Some(Box::new(move |_: &str, _: &[&str]| Some(to.clone())));
request_quit(&mut a);
answer_prompt(&mut a, Answer::Save);
assert!(a.quit_requested && file.exists());
let _ = std::fs::remove_file(&file);
}
/// A design built elsewhere replaces a blank one and gets a tab otherwise.
#[test]
fn adopt_reuses_a_blank_design() {
+23 -2
View File
@@ -10,7 +10,7 @@ use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, vec2};
use solvecraft_engine::geom::Vec3;
use crate::theme::Tokens;
use crate::viewport::Proj;
use crate::viewport::{Proj, projected_polygon};
/// A part of the triad.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -264,7 +264,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I
}
for (i, q) in &sh.planes {
let c = col(Handle::Plane(*i), *i);
painter.add(Shape::convex_polygon(q.clone(), c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c)));
painter.extend(projected_polygon(q, c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c)));
}
for (i, a, b) in &sh.arrows {
arrow(painter, *a, *b, col(Handle::Axis(*i), *i), hot(Handle::Axis(*i)));
@@ -413,6 +413,27 @@ pub fn axis_angle(m: &[[f64; 3]; 3]) -> (Vec3, f64) {
mod tests {
use super::*;
#[test]
fn plane_squares_stay_within_the_triad() {
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let t = Triad { center: Vec3::ZERO, translate: true, rotate: false };
// The squares reach 0.45 √2 of the arrow length from the centre, plus stroke and feathering.
let limit = (0.45 * 2f64.sqrt() * LEN_PX) as f32 + 3.0;
let mut worst = 0.0f32;
for dir in crate::viewport::orbit_directions() {
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
let l = mm_per_px(&proj, t.center).unwrap() * LEN_PX;
let sh = shapes(&proj, &t, l).unwrap();
for (_, q) in &sh.planes {
for p in crate::viewport::drawn_points(projected_polygon(q, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) {
worst = worst.max(p.distance(sh.ball));
}
}
}
assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})");
}
#[test]
fn rotations_compose_to_one_axis_angle() {
let r = mul(&rotation(Vec3::Z, 0.7), &rotation(Vec3::X, 0.4));
+170 -70
View File
@@ -173,6 +173,9 @@ struct Batches {
tris: Option<Batch>,
trans: Option<Batch>,
glass: Option<Batch>,
/// The glass triangles as built, and the view direction they were last sorted for.
glass_cpu: Vec<u8>,
glass_sorted_for: Option<[f32; 3]>,
lines: Option<Batch>,
hidden: Option<Batch>,
overlays: Option<Batch>,
@@ -189,7 +192,9 @@ impl Batches {
if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) {
self.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE);
self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE);
self.glass = upload(device, "sc_glass", &sc.glass, TRI_SIZE);
self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST);
self.glass_cpu = sc.glass;
self.glass_sorted_for = None;
self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE);
self.hidden = upload(device, "sc_hidden", &sc.hidden, LINE_SIZE);
self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE);
@@ -214,8 +219,6 @@ struct Resources {
line: wgpu::RenderPipeline,
/// Lines behind the model (depth test >).
line_hidden: wgpu::RenderPipeline,
/// Silhouettes: back faces pushed out a little on screen, drawn dark behind the model.
outline: wgpu::RenderPipeline,
overlay: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
bind: wgpu::BindGroup,
@@ -271,7 +274,7 @@ const SHADER: &str = r#"
struct U {
vp: mat4x4<f32>,
back: vec4<f32>, // xyz = toward the eye, w = 1 for linear output
screen: vec4<f32>, // viewport width, height (px), depth bias
screen: vec4<f32>, // viewport width, height (px), depth bias, silhouettes on (1)
clip: vec4<f32>, // section plane: normal, d (zero normal: no section)
cap: vec4<f32>, // colour of the inside of cut bodies
ana: vec4<f32>, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter
@@ -318,18 +321,41 @@ fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c:
/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
@fragment
fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
// Derivatives first, in uniform control flow (browsers' WGSL compilers reject them after
// a branch that returns; the whole shader module then fails and nothing is drawn).
let k = curvature(i);
let ndv = dot(normalize(i.n), normalize(u.back.xyz));
let fw = fwidth(ndv);
if (clipped(i.wp)) { discard; }
if (!front && length(u.clip.xyz) > 0.5) {
return out_color(u.cap);
// The cut face: the cap colour, hatched at 45 degrees on screen.
let h = fract((i.pos.x + i.pos.y) / 9.0);
return out_color(vec4<f32>(select(u.cap.rgb, u.cap.rgb * 0.62, h < 0.22), u.cap.a));
}
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
return analysis(i);
return analysis(i, k);
}
return shade(i);
let c = shade(i);
// Silhouettes (styles with edges): where a curved surface turns away from the eye, a line
// about 1.5 px wide, on the visible surface only (depth-correct).
if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) {
let a = 1.0 - smoothstep(fw * 0.8, fw * 2.0, abs(ndv));
return vec4<f32>(mix(c.rgb, out_color(vec4<f32>(0.07, 0.08, 0.10, 1.0)).rgb, a), c.a);
}
return c;
}
/// Surface analysis colours: zebra stripes, draft angle bands, curvature map.
fn analysis(i: TOut) -> vec4<f32> {
/// How fast the normal turns across the pixel (per mm).
fn curvature(i: TOut) -> f32 {
let nn = normalize(i.n);
let kx = length(dpdx(nn)) / max(length(dpdx(i.wp)), 1e-6);
let ky = length(dpdy(nn)) / max(length(dpdy(i.wp)), 1e-6);
return max(kx, ky);
}
/// Surface analysis colours: zebra stripes, draft angle bands, curvature map (`k`: the
/// surface's curvature here, worked out by the caller).
fn analysis(i: TOut, k: f32) -> vec4<f32> {
let v = normalize(u.back.xyz);
var n = normalize(i.n);
if (dot(n, v) < 0.0) { n = -n; }
@@ -385,10 +411,7 @@ fn analysis(i: TOut) -> vec4<f32> {
return out_color(vec4<f32>(c * lit, 1.0));
}
// Curvature from how fast the normal turns across the pixel.
let nn = normalize(i.n);
let kx = length(dpdx(nn)) / max(length(dpdx(i.wp)), 1e-6);
let ky = length(dpdy(nn)) / max(length(dpdy(i.wp)), 1e-6);
let t = clamp(max(kx, ky) * u.ana.y, 0.0, 1.0);
let t = clamp(k * u.ana.y, 0.0, 1.0);
let c = select(mix(vec3<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.0 - 1.0),
mix(vec3<f32>(0.15, 0.35, 0.95), vec3<f32>(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
return out_color(vec4<f32>(c * lit, 1.0));
@@ -465,28 +488,6 @@ fn fs_line(i: LOut) -> @location(0) vec4<f32> {
return out_color(i.c);
}
/// Silhouettes: back faces pushed out along their screen-space normal by about 1.5 px.
@vertex
fn vs_outline(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
o.pos = u.vp * vec4<f32>(p, 1.0);
let nc = (u.vp * vec4<f32>(n, 0.0)).xy * u.screen.xy;
if (length(nc) > 1e-6) {
let d = normalize(nc) * 1.5 * 2.0 / u.screen.xy;
o.pos = vec4<f32>(o.pos.xy + d * o.pos.w, o.pos.zw);
}
o.n = vec3<f32>(0.0);
o.c = c;
o.wp = p;
return o;
}
@fragment
fn fs_outline(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return out_color(vec4<f32>(0.07, 0.08, 0.10, 1.0));
}
struct IOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -511,8 +512,8 @@ fn vs_img(@location(0) p: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) a:
@fragment
fn fs_img(i: IOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
let c = textureSample(img_t, img_s, i.uv);
if (clipped(i.wp)) { discard; }
return out_color(vec4<f32>(c.rgb, c.a * i.a));
}
@@ -742,31 +743,6 @@ impl Resources {
xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
line_hidden: pipeline("sc_lines_hidden", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Greater, false), alpha),
outline: device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sc_outline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_outline"),
buffers: &[Some(tri_layout())],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: Some(wgpu::Face::Front),
..Default::default()
},
depth_stencil: depth(wgpu::CompareFunction::Less, false),
multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_outline"),
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
}),
overlay: pipeline("sc_overlay", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
uniform,
bind,
@@ -779,19 +755,44 @@ impl Resources {
}
fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option<Batch> {
upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX)
}
fn upload_with(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage: wgpu::BufferUsages) -> Option<Batch> {
if bytes.len() < stride {
return None;
}
Some(Batch {
buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: bytes,
usage: wgpu::BufferUsages::VERTEX,
}),
buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: bytes, usage }),
count: u32::try_from(bytes.len() / stride).unwrap_or(0),
})
}
/// Triangles (3 vertices of `TRI_SIZE` bytes each, position first) ordered far to near along
/// the view direction `back` (toward the eye), so blended glass draws back to front.
pub fn sort_back_to_front(bytes: &[u8], back: [f32; 3]) -> Vec<u8> {
let tri = TRI_SIZE * 3;
let depth = |t: &[u8]| -> f32 {
let f = |o: usize| t.get(o..o + 4).and_then(|b| b.try_into().ok()).map_or(0.0, f32::from_le_bytes);
(0..3).map(|v| (0..3).map(|k| f(v * TRI_SIZE + k * 4) * back[k]).sum::<f32>()).sum::<f32>()
};
let mut tris: Vec<(f32, &[u8])> = bytes.chunks_exact(tri).map(|t| (depth(t), t)).collect();
tris.sort_by(|a, b| a.0.total_cmp(&b.0));
tris.into_iter().flat_map(|(_, t)| t.iter().copied()).collect()
}
impl Batches {
/// Re-sort the glass for a new view direction (when it turned more than about 2 degrees).
fn sort_glass(&mut self, queue: &wgpu::Queue, back: [f32; 3]) {
let (Some(g), false) = (&self.glass, self.glass_cpu.is_empty()) else { return };
let turned = self.glass_sorted_for.is_none_or(|s| s[0] * back[0] + s[1] * back[1] + s[2] * back[2] < 0.9994);
if turned {
queue.write_buffer(&g.buffer, 0, &sort_back_to_front(&self.glass_cpu, back));
self.glass_sorted_for = Some(back);
}
}
}
/// Create the pipelines. Call once with the app's render state and its depth/MSAA settings.
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) -> GpuTarget {
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples };
@@ -813,6 +814,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
res.model.take(device, self.key, &self.slot);
res.highlight.take(device, self.hl_key, &self.hl_slot);
res.preview.take(device, self.pv_key, &self.pv_slot);
res.model.sort_glass(queue, self.back);
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out));
if let Some(a) = &self.access
&& a.version != res.access_version
@@ -900,11 +902,9 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
tris(pass, &res.tri, &m.tris);
tris(pass, &res.tri, &pv.tris);
tris(pass, &res.tri_hl, &h.tris);
if self.outline {
tris(pass, &res.outline, &m.tris);
}
lines(pass, &res.line, &m.lines);
lines(pass, &res.line_hidden, &m.hidden);
lines(pass, &res.line_hidden, &h.hidden);
lines(pass, &res.line, &pv.lines);
lines(pass, &res.line, &h.lines);
for (id, q) in &res.quads {
@@ -930,7 +930,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec<u8> {
let mut v: Vec<f32> = cb.view_proj.iter().flatten().copied().collect();
v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]);
v.extend([w.max(1.0), h.max(1.0), 2e-4, 0.0]);
v.extend([w.max(1.0), h.max(1.0), 2e-4, if cb.outline { 1.0 } else { 0.0 }]);
v.extend(cb.clip);
v.extend(cb.cap);
v.extend(cb.analysis);
@@ -954,4 +954,104 @@ mod tests {
let module = naga::front::wgsl::parse_str(super::SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::SHADER)));
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
}
/// Derivatives and implicit-lod sampling run only in uniform control flow: browsers' WGSL
/// compilers (Tint) reject them after a branch, and the whole viewport shader then fails,
/// a black screen on the web (naga accepts it). Every fragment entry point makes such calls
/// (directly or through a helper) before its first branch or return.
#[test]
fn derivatives_only_in_uniform_control_flow() {
// Without comments (their words are not code).
let src: String = super::SHADER.lines().map(|l| l.split("//").next().unwrap_or("")).collect::<Vec<_>>().join("\n");
let src = src.as_str();
let derivs = ["dpdx(", "dpdy(", "fwidth(", "textureSample("];
// Functions: name -> body.
let mut fns: Vec<(String, String, bool)> = Vec::new();
let mut rest = src;
let mut fragment = false;
while let Some(at) = rest.find("fn ") {
let before = &rest[..at];
if before.contains("@fragment") {
fragment = true;
}
let Some(open) = rest[at..].find('{') else { break };
let name = rest[at + 3..].split('(').next().unwrap_or("").trim().to_string();
let start = at + open;
let mut depth = 0;
let mut end = start;
for (k, ch) in rest[start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = start + k;
break;
}
}
_ => {}
}
}
fns.push((name, rest[start..=end].to_string(), fragment));
fragment = false;
rest = &rest[end + 1..];
}
// Helpers that take derivatives (directly or through another helper).
let mut uses: Vec<String> = Vec::new();
for _ in 0..8 {
for (n, body, _) in &fns {
if !uses.contains(n) && (derivs.iter().any(|d| body.contains(d)) || uses.iter().any(|u| body.contains(&format!("{u}(")))) {
uses.push(n.clone());
}
}
}
for (n, body, frag) in &fns {
let calls: Vec<usize> = derivs
.iter()
.map(|d| d.to_string())
.chain(uses.iter().map(|u| format!("{u}(")))
.flat_map(|c| body.match_indices(&c).map(|(i, _)| i).collect::<Vec<_>>())
.collect();
if calls.is_empty() {
continue;
}
if !*frag {
// A helper: its own derivatives must come before any branch or return in it.
let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX);
for d in derivs {
if let Some(i) = body.find(d) {
assert!(i < first, "{n}: {d} after a branch");
}
}
continue;
}
let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX);
for c in calls {
assert!(c < first, "fragment {n}: a derivative after a branch or return: {}", &body[c..(c + 40).min(body.len())]);
}
}
}
}
#[cfg(test)]
mod glass_tests {
use super::*;
#[test]
fn glass_is_drawn_far_to_near() {
// Two triangles at z = 5 and z = -5, given near first; looking down from +Z.
let mut sc = GpuScene::default();
for z in [5.0f32, -5.0] {
for p in [[0.0, 0.0, z], [1.0, 0.0, z], [0.0, 1.0, z]] {
sc.glass_tri(p, [0.0, 0.0, 1.0], [255, 0, 0, 100]);
}
}
let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, 1.0]);
let z0 = f32::from_le_bytes(sorted[8..12].try_into().unwrap());
assert_eq!(z0, -5.0, "the far triangle first");
assert_eq!(sorted.len(), sc.glass.len());
// Seen from below, the order flips.
let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, -1.0]);
assert_eq!(f32::from_le_bytes(sorted[8..12].try_into().unwrap()), 5.0);
}
}
+31 -8
View File
@@ -13,8 +13,7 @@ pub const BUILD_DATE: &str = env!("SOLVECRAFT_BUILD_DATE");
/// The user and developer guide (docs/book, published by .github/workflows/pages.yml).
pub const DOCS_URL: &str = "https://bherbruck.github.io/solvecraft/";
const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md");
const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
use solvecraft_engine::licences::{LICENSE_APACHE, LICENSE_MIT, NOTICE, THIRD_PARTY};
/// Fonts: what this build carries.
pub const FONTS: &str = "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and \
the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans \
@@ -112,17 +111,28 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").default_open(true).show(ui, |ui| {
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").show(ui, |ui| {
ui.label(RichText::new(ATTRIBUTION).monospace().size(11.0));
});
egui::CollapsingHeader::new("Fonts").show(ui, |ui| {
ui.label(FONTS);
});
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
});
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
egui::CollapsingHeader::new("Licences").show(ui, |ui| {
egui::CollapsingHeader::new("NOTICE").show(ui, |ui| {
ui.label(RichText::new(NOTICE).monospace().size(11.0));
});
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
});
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
});
egui::CollapsingHeader::new("Third-party licences").show(ui, |ui| {
if ui.button("Copy all licences").clicked() {
ctx.copy_text(solvecraft_engine::licences::all());
}
third_party(ui);
});
});
});
});
@@ -131,6 +141,19 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
}
}
/// THIRD-PARTY-LICENSES.txt (over 10 000 lines): only the lines in view are laid out.
fn third_party(ui: &mut egui::Ui) {
let lines: Vec<&str> = THIRD_PARTY.lines().collect();
let font = egui::FontId::monospace(11.0);
let row_h = ui.fonts_mut(|f| f.row_height(&font));
egui::ScrollArea::both().id_salt("sc_third_party").max_height(360.0).show_rows(ui, row_h, lines.len(), |ui, rows| {
for line in lines.get(rows).unwrap_or_default() {
// One row per line (show_rows needs equal rows): long lines scroll sideways.
ui.add(egui::Label::new(RichText::new(*line).font(font.clone())).extend());
}
});
}
#[cfg(test)]
mod tests {
use super::*;
+19
View File
@@ -768,6 +768,25 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.line(&[(15.0, 15.0), (19.0, 19.0)], a);
pen.line(&[(19.0, 15.0), (15.0, 19.0)], a);
}
"patch" => {
// A wire loop with a surface spanned across it.
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], Color32::from_rgb(170, 200, 240), ink);
pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
}
"stitch" => {
// Two surface patches sewn along their shared edge.
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], Color32::from_rgb(170, 200, 240), ink);
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], Color32::from_rgb(150, 185, 235), ink);
for y in [6.0, 9.0, 12.0] {
pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
}
}
"thicken" => {
// A curved sheet given thickness: its offset copy and the side walls.
pen.arc(12.0, 20.0, 11.0, 3.6, 5.8, ink);
pen.arc(12.0, 20.0, 7.0, 3.6, 5.8, a);
pen.arrow((12.0, 9.0), (12.0, 13.5), a);
}
"extend" => {
pen.line(&[(20.0, 3.0), (20.0, 21.0)], ink);
pen.line(&[(3.0, 12.0), (10.0, 12.0)], ink);
+43 -23
View File
@@ -13,13 +13,14 @@ use crate::SolveApp;
use crate::theme::Tokens;
/// Keys the application handles before any command shortcut.
pub const RESERVED: &[&str] = &["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O"];
pub const RESERVED: &[&str] =
&["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma"];
#[derive(Default)]
pub struct Keymap {
/// Command id → key (`""`: no key). Only the changed ones.
pub custom: BTreeMap<String, String>,
/// The Keyboard Shortcuts window.
/// Asks for the Keyboard Shortcuts page of Preferences (File and Help menus).
pub open: bool,
/// Opened from Help: a list to read, not to edit.
pub read_only: bool,
@@ -113,11 +114,10 @@ pub fn reset(app: &mut SolveApp, id: Option<&str>) {
}
}
/// The Keyboard Shortcuts window.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if !app.keymap.open {
return;
}
/// The Keyboard Shortcuts page of Preferences: search, rebind (click, then press the key), clear,
/// reset; a key another command uses is reported and taken over only on request.
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
// A key press while waiting for one binds it (Esc cancels).
if let Some(id) = app.keymap.capturing.clone() {
let press = ctx.input(|i| {
@@ -142,18 +142,20 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
}
let t = Tokens::get();
let mut open = true;
let mut action: Option<(String, &str)> = None;
crate::frame::window(ctx, "Keyboard Shortcuts", crate::frame::Width::Wide)
.id(egui::Id::new("sc_keymap"))
.open(&mut open)
.default_size([520.0, 480.0])
.show(ctx, |ui| {
{
{
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut app.keymap.filter).hint_text("Filter commands or keys").desired_width(300.0));
if !app.keymap.read_only && ui.button("Reset All").clicked() {
action = Some((String::new(), "reset_all"));
}
if ui.button("Export…").on_hover_text("Keys, S box favourites and the toolbar, as a file").clicked() {
action = Some((String::new(), "export"));
}
if !app.keymap.read_only && ui.button("Import…").clicked() {
action = Some((String::new(), "import"));
}
});
if let Some(m) = &app.keymap.message {
ui.label(RichText::new(m).color(t.warning));
@@ -170,7 +172,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
ui.separator();
let q = app.keymap.filter.to_lowercase();
egui::ScrollArea::vertical().show(ui, |ui| {
egui::ScrollArea::both().max_height(330.0).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("sc_keymap_grid").num_columns(4).striped(true).spacing([10.0, 4.0]).show(ui, |ui| {
for c in command_specs() {
let key = effective(app, c.id).unwrap_or_default();
@@ -193,8 +195,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.end_row();
continue;
}
let b =
egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(110.0, 0.0));
let b = egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(80.0, 0.0));
if ui.add(b).on_hover_text("Click, then press the new key").clicked() {
action = Some((c.id.to_string(), "capture"));
}
@@ -210,7 +211,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
});
});
});
}
}
match action {
Some((id, "capture")) => {
app.keymap.capturing = Some(id);
@@ -220,6 +222,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Some((id, "clear")) => drop(bind(app, &id, "", false)),
Some((id, "reset")) => reset(app, Some(&id)),
Some((_, "reset_all")) => reset(app, None),
Some((_, "export")) => {
let text = crate::toolbar_custom::export(app).to_string();
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f("SolveCraft shortcuts.json", &["json"])) {
app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}"));
}
}
Some((_, "import")) => {
if let Some(path) = app.services.pick_open.as_ref().and_then(|f| f()) {
let r = std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|t| serde_json::from_str::<Value>(&t).map_err(|e| e.to_string()))
.and_then(|v| crate::toolbar_custom::import(app, &v));
app.keymap.message = r.err().map(|e| format!("import: {e}"));
}
}
Some((pair, "replace")) => {
if let Some((id, key)) = pair.split_once('\n') {
let _ = bind(app, id, key, true);
@@ -233,15 +250,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
_ => {}
}
if !open {
app.keymap.open = false;
app.keymap.capturing = None;
}
}
/// Is the window waiting for a key (so the key must not also run a command)?
/// Stop waiting for a key (the dialog closed).
pub fn stop_capture(app: &mut SolveApp) {
app.keymap.capturing = None;
app.keymap.conflict = None;
app.keymap.message = None;
}
/// Is the page waiting for a key (so the key must not also run a command)?
pub fn capturing(app: &SolveApp) -> bool {
app.keymap.open && app.keymap.capturing.is_some()
app.prefs_window.open && app.prefs_window.section == crate::prefs::PAGE_SHORTCUTS && app.keymap.capturing.is_some()
}
#[cfg(test)]
+112 -7
View File
@@ -9,6 +9,7 @@
#![forbid(unsafe_code)]
pub mod access;
pub mod agent_cursor;
pub mod browser;
pub mod canvas;
pub mod context_menu;
@@ -22,6 +23,7 @@ pub mod dialogs_move;
pub mod dialogs_parts;
pub mod dialogs_plastic;
pub mod dialogs_sheet;
pub mod dialogs_surface;
pub mod dim_view;
pub mod documents;
pub mod drag_snap;
@@ -35,6 +37,23 @@ pub mod inference;
pub mod keymap;
#[cfg(test)]
mod menu_tests;
#[cfg(test)]
mod status_tests {
#[test]
fn the_status_bar_names_commands_by_label() {
assert_eq!(super::command_label("solid.fillet"), "Fillet");
assert_eq!(
super::friendly_error("solid.shell", "invalid parameters for `solid.shell`: offset: must be positive"),
"Shell: offset: must be positive"
);
assert_eq!(super::friendly_error("edit.undo", "nothing to undo"), "Undo: nothing to undo");
let mut app = super::SolveApp::new(solvecraft_engine::Session::default(), Default::default());
app.run("solid.box", serde_json::json!({"length": 5, "width": 5, "height": 5})).unwrap();
assert_eq!(app.session.log.last().map(String::as_str), Some("Box done"));
let _ = app.run("edit.redo", serde_json::json!({}));
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
}
}
pub mod palette;
pub mod params_dialog;
pub mod prefs;
@@ -52,10 +71,12 @@ pub mod sketch_dims;
mod sketch_edit_tests;
pub mod sketch_palette;
pub mod sketch_tools;
pub mod stand_up;
pub mod theme;
pub mod timeline;
pub mod titlebar;
pub mod toolbar;
pub mod toolbar_custom;
pub mod tools;
pub mod viewport;
pub mod workspace;
@@ -95,6 +116,8 @@ pub struct UiState {
pub visual_style: u8,
/// A soft shadow on the ground under the model (seen from above).
pub ground_shadow: bool,
/// The world's up axis (Z or Y; #3): the camera, view cube, home view and ground follow it.
pub up_axis: solvecraft_engine::render::UpAxis,
/// Sketches hidden one by one, and finished sketches shown although a feature uses them.
pub hidden_sketches: Vec<u64>,
pub shown_sketches: Vec<u64>,
@@ -104,6 +127,8 @@ pub struct UiState {
pub shown_dims: Vec<u64>,
/// Bodies locked in the browser (no move or delete).
pub locked_bodies: Vec<String>,
/// The agent cursor: shown, speed, follow camera (#35).
pub agent_cursor: agent_cursor::Settings,
}
impl Default for UiState {
@@ -124,11 +149,13 @@ impl Default for UiState {
pick_bodies: false,
visual_style: 0,
ground_shadow: true,
up_axis: Default::default(),
hidden_sketches: Vec::new(),
shown_sketches: Vec::new(),
hidden_profiles: Vec::new(),
shown_dims: Vec::new(),
locked_bodies: Vec::new(),
agent_cursor: Default::default(),
}
}
}
@@ -172,6 +199,8 @@ pub struct SolveApp {
pub keymap: keymap::Keymap,
/// The Help menu and the About window.
pub help: help::HelpState,
/// Toolbar buttons the user pinned, removed or reordered.
pub toolbar_custom: toolbar_custom::ToolbarCustom,
/// Preferences kept between runs, and their window.
pub preferences: prefs::Prefs,
pub prefs_window: prefs::PrefsWindow,
@@ -186,6 +215,8 @@ pub struct SolveApp {
pub autosave: Option<solvecraft_engine::recovery::Autosaver>,
/// Designs left by crashed apps, offered for recovery.
pub recoverable: Vec<solvecraft_engine::recovery::Entry>,
/// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`).
pub stand_up: Option<Vec<String>>,
/// The last command was a big operation: autosave after it.
pub big_operation: bool,
/// Autosave interval (minutes; 0 = only after big operations). A preference.
@@ -199,6 +230,8 @@ pub struct SolveApp {
/// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves).
pub synthetic_mods: egui::Modifiers,
control_rx: Option<Receiver<ControlRequest>>,
/// The agent cursor acting out control requests.
pub agent: agent_cursor::AgentCursor,
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>,
queued_shots: Vec<(u64, f64, u32)>,
shot_token: u64,
@@ -231,6 +264,7 @@ impl SolveApp {
sbox: shortcut_box::ShortcutBox::default(),
keymap: keymap::Keymap::default(),
help: help::HelpState::default(),
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
preferences: prefs::Prefs::default(),
prefs_window: prefs::PrefsWindow::default(),
preview: preview::PreviewState::default(),
@@ -240,6 +274,7 @@ impl SolveApp {
quit_requested: false,
autosave: None,
recoverable: Vec::new(),
stand_up: None,
big_operation: false,
autosave_minutes: 5.0,
custom_titlebar: false,
@@ -248,6 +283,7 @@ impl SolveApp {
synthetic: Vec::new(),
synthetic_mods: egui::Modifiers::default(),
control_rx: None,
agent: Default::default(),
pending_shots: Vec::new(),
queued_shots: Vec::new(),
shot_token: 0,
@@ -280,7 +316,10 @@ impl SolveApp {
"recent": self.home.recent,
"shortcut_box": self.sbox.prefs(),
"shortcuts": self.keymap.prefs(),
"toolbar": self.toolbar_custom.prefs(),
"autosave_minutes": self.autosave_minutes,
"ground_shadow": self.ui.ground_shadow,
"agent_cursor": self.ui.agent_cursor,
"preferences": self.preferences,
})
.to_string()
@@ -331,9 +370,22 @@ impl SolveApp {
if let Some(s) = v.get("shortcuts") {
self.keymap.load(s);
}
if let Some(t) = v.get("toolbar") {
self.toolbar_custom.load(t);
}
if let Some(p) = v.get("preferences").and_then(|p| serde_json::from_value::<prefs::Prefs>(p.clone()).ok()) {
self.preferences = p;
}
// Saved before the theme was a preference: the Dark/Light flag says which.
if v.get("preferences").and_then(|p| p.get("theme")).is_none() {
self.preferences.theme = if self.ui.dark { "dark" } else { "light" }.into();
}
if let Some(g) = flag("ground_shadow") {
self.ui.ground_shadow = g;
}
if let Some(a) = v.get("agent_cursor").and_then(|a| serde_json::from_value(a.clone()).ok()) {
self.ui.agent_cursor = a;
}
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
@@ -352,7 +404,7 @@ impl SolveApp {
}
match &r {
Ok(_) => {
self.session.echo(format!("{id}: done"));
self.session.echo(format!("{} done", command_label(id)));
if matches!(id, "file.new" | "doc.open") {
self.cam = Camera::default();
self.fit_view();
@@ -364,8 +416,9 @@ impl SolveApp {
}
}
Err(e) => {
self.session.echo(format!("{id}: {e}"));
self.set_status(e.clone(), true);
let msg = friendly_error(id, e);
self.session.echo(msg.clone());
self.set_status(msg, true);
}
}
if self.session.active_sketch.is_some() && self.ui.tab != "SKETCH" {
@@ -536,8 +589,12 @@ impl SolveApp {
}
/// Is the origin widget shown (always while picking a sketch plane)?
/// Origin planes and axes are drawn and pickable. Hidden ones only come back while Create
/// Sketch has nothing else to offer (no body shown).
pub fn origin_visible(&self) -> bool {
self.ui.show_origin || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch))
self.ui.show_origin
|| (self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch))
&& !self.session.world_state().bodies.iter().any(|b| !self.ui.hidden_bodies.contains(&b.name)))
}
pub fn set_status(&mut self, s: impl Into<String>, error: bool) {
@@ -549,6 +606,7 @@ impl SolveApp {
documents::prepare_open(self);
if self.run("doc.open", json!({ "path": path })).is_ok() {
self.fit_view();
stand_up::offer(self, path, &[]);
}
}
@@ -556,8 +614,10 @@ impl SolveApp {
/// current design.
pub fn insert_path(&mut self, path: &str) {
let cmd = if solvecraft_engine::io::is_mesh_path(path) { "file.insert_mesh" } else { "file.insert_step" };
let before: Vec<String> = self.session.world_state().bodies.iter().map(|b| b.name.clone()).collect();
if let Ok(r) = self.run(cmd, json!({ "path": path })) {
self.fit_view();
stand_up::offer(self, path, &before);
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false);
}
@@ -576,6 +636,16 @@ impl SolveApp {
}
/// Frame the model at once (programmatic use; the toolbar animates).
/// Make `up` the world's up axis: the camera turns to the same standard view in that world
/// (home) and frames the model.
pub fn set_up_axis(&mut self, up: solvecraft_engine::render::UpAxis) {
self.ui.up_axis = up;
self.cam_anim = None;
self.cam.up = up;
self.cam.set_view(StandardView::Iso);
self.fit_view();
}
pub fn fit_view(&mut self) {
self.cam_anim = None;
self.cam = self.fitted(self.cam);
@@ -598,7 +668,18 @@ impl SolveApp {
if !to.is_valid() {
return;
}
self.cam_anim = Some(CameraAnim::new(self.cam, to, self.now));
// Preferences › Navigation: animated (at a chosen speed) or immediate.
if !self.preferences.look_at_anim {
self.cam = to;
self.cam_anim = None;
return;
}
let mut anim = CameraAnim::new(self.cam, to, self.now);
let speed = f64::from(self.preferences.look_at_speed);
if speed.is_finite() && speed > 0.0 {
anim.duration /= speed;
}
self.cam_anim = Some(anim);
}
/// Animated view changes for the view cube and navigation bar.
@@ -641,6 +722,7 @@ impl SolveApp {
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
self.esc_handled = false;
prefs::apply(self, ctx);
if !self.styled || theme::is_dark() != self.ui.dark {
theme::set_dark(self.ui.dark);
theme::apply(ctx);
@@ -651,8 +733,12 @@ impl SolveApp {
self.fitted = true;
}
self.now = ctx.input(|i| i.time);
if self.cam.up != self.ui.up_axis {
self.set_up_axis(self.ui.up_axis);
}
self.step_view_animation(ctx);
self.drain_control(ctx);
agent_cursor::step(self, ctx);
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
@@ -724,21 +810,28 @@ impl SolveApp {
sketch_palette::show(self, ui.ctx());
params_dialog::show(self, ui.ctx());
recovery_ui::show(self, ui.ctx());
stand_up::show(self, ui.ctx());
context_menu::show(self, ui.ctx());
delete::show(self, ui.ctx());
if self.custom_titlebar {
titlebar::resize_zones(ui);
}
shortcut_box::show(self, ui.ctx());
keymap::show(self, ui.ctx());
prefs::show(self, ui.ctx());
agent_cursor::paint(self, ui.ctx());
self.frame_ms = now_ms() - t0;
}
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
// While the agent cursor acts out a request, later ones wait their turn.
while !self.agent.busy() {
let Ok(req) = rx.try_recv() else { break };
let reply = req.reply.clone();
if agent_cursor::intercept(self, &req.method, &req.params, &reply) {
ctx.request_repaint();
continue;
}
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
@@ -805,6 +898,18 @@ impl SolveApp {
}
}
/// A command's name as people see it (its label), for the status bar.
pub fn command_label(id: &str) -> &str {
solvecraft_engine::find_command(id).map_or(id, |c| c.label)
}
/// An engine error with the command's label instead of its id: "Shell: offset must be …".
pub fn friendly_error(id: &str, e: &str) -> String {
let label = command_label(id);
let msg = e.replace(&format!("invalid parameters for `{id}`: "), "").replace(&format!("{id}: "), "").replace(&format!("`{id}`"), label);
if msg.starts_with(label) { msg } else { format!("{label}: {msg}") }
}
pub fn now_ms() -> f64 {
use web_time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
+73
View File
@@ -455,3 +455,76 @@ fn f2_renames_the_selected_item() {
app.run("select.clear", json!({})).unwrap();
assert!(!context_menu::rename_selection(&mut app, pos2(0.0, 0.0)), "nothing selected");
}
#[test]
fn occurrence_moves_turn_about_their_origin_then_move() {
let mut app = sample_app();
let b = body(&app);
app.run("component.from_bodies", json!({"bodies": [b]})).unwrap();
let occ = app.session.doc.occurrences[0].id;
let mut m = crate::browser::OccMove::new(occ);
m.pivot = Some(solvecraft_engine::geom::Vec3::new(5.0, 0.0, 0.0));
m.translate = [10.0, 0.0, 0.0];
m.angles = [0.0, 0.0, 90.0];
crate::browser::apply_occurrence_move(&mut app, &m);
let t = *app.session.pending_moves.get(&occ).unwrap();
// The pivot lands 10 mm along X; X turns into Y.
let p = solvecraft_engine::doc::apply_point(&t, solvecraft_engine::geom::Vec3::new(5.0, 0.0, 0.0));
let x = solvecraft_engine::doc::apply_vector(&t, solvecraft_engine::geom::Vec3::X);
assert!((p.x - 15.0).abs() < 1e-9 && p.y.abs() < 1e-9, "{p:?}");
assert!((x.y - 1.0).abs() < 1e-9, "{x:?}");
// Changing the values again starts from where it was, not from the last move.
m.translate = [0.0, 0.0, 0.0];
m.angles = [0.0; 3];
crate::browser::apply_occurrence_move(&mut app, &m);
assert_eq!(*app.session.pending_moves.get(&occ).unwrap(), solvecraft_engine::doc::IDENTITY);
}
#[test]
fn toolbar_pins_removes_reorders_and_keeps_its_layout() {
use crate::toolbar_custom as tc;
let mut app = sample_app();
app.ui.tab = "SOLID".into();
let ids = |app: &SolveApp, panel: &str| -> Vec<&'static str> {
let lists = tc::panel_lists("SOLID");
let (_, cmds, n) = lists.iter().find(|(p, ..)| *p == panel).unwrap();
tc::buttons(app, "SOLID", panel, cmds, *n).iter().map(|c| c.id).collect()
};
let create = ids(&app, "CREATE");
// Pin a command that isn't a button yet: it lands at the end of its panel.
let extra =
tc::panel_lists("SOLID").iter().find(|(p, ..)| *p == "CREATE").unwrap().1.iter().map(|c| c.id).find(|id| !create.contains(id)).unwrap();
act(&mut app, &Target::ToolbarCommand { id: extra.into() }, "Pin to Toolbar");
assert_eq!(ids(&app, "CREATE").last(), Some(&extra));
// Remove a default button.
act(&mut app, &Target::ToolbarCommand { id: create[0].into() }, "Remove from Toolbar");
assert!(!ids(&app, "CREATE").contains(&create[0]));
// Reorder: the pinned one before the second default; then into another panel.
tc::reorder(&mut app, "SOLID", extra, "CREATE", Some(create[1]));
assert_eq!(ids(&app, "CREATE")[0], extra);
tc::reorder(&mut app, "SOLID", extra, "MODIFY", None);
assert!(!ids(&app, "CREATE").contains(&extra) && ids(&app, "MODIFY").last() == Some(&extra));
// Pin to Shortcuts goes to the S box's favourites.
act(&mut app, &Target::ToolbarCommand { id: "Extrude".into() }, "Pin to Shortcuts");
assert!(app.sbox.pinned.iter().any(|p| p == "Extrude"));
// Kept between runs, and exported and imported with the shortcuts.
let mut other = sample_app();
other.load_prefs(&app.prefs());
assert_eq!(other.toolbar_custom, app.toolbar_custom);
let file = tc::export(&app);
let mut third = sample_app();
tc::import(&mut third, &file).unwrap();
assert_eq!(third.toolbar_custom, app.toolbar_custom);
assert!(tc::import(&mut third, &json!({"format": "nope"})).is_err());
// Reset Toolbar puts the tab back.
act(&mut app, &Target::ToolbarCommand { id: extra.into() }, "Reset Toolbar");
assert_eq!(ids(&app, "CREATE"), create);
}
#[test]
fn the_workspace_menu_lists_design_and_greys_the_rest() {
let app = sample_app();
let items = context_menu::items(&app, &Target::Workspace);
assert!(find(&items, "Design").enabled);
assert!(!find(&items, "Render").enabled && find(&items, "Render").shortcut == "coming later");
}
+6
View File
@@ -188,6 +188,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
});
});
// The title bar's close button, for scenarios and demos.
if let Some(r) = ctx.memory(|m| m.area_rect(egui::Id::new("sc_params_dialog"))) {
crate::scenario::publish_handle("params:close", egui::pos2(r.right() - 16.0, r.top() + 15.0));
crate::scenario::publish_handle("params:title", egui::pos2(r.center().x, r.top() + 15.0));
}
if !open {
app.params = None;
return;
@@ -271,6 +276,7 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
te = te.text_color(t.error);
}
let resp = ui.add_sized([170.0, 20.0], te);
crate::scenario::publish_handle(&format!("param:{}", r.name), resp.rect.center());
let completed = complete(ui, &resp, &mut e);
if resp.changed() || completed {
d.editing.insert(r.name.clone(), e.clone());
+537 -118
View File
@@ -1,7 +1,12 @@
//! Preferences (File › Preferences), in the spirit of Fusion's: General (theme, units of new
//! designs, autosave interval), Navigation (mouse mapping preset, zoom direction, orbit centre), Display
//! (grid, perspective, visual style with or without edges, display precision, anti-aliasing), Sketch and Selection. Everything
//! applies at once and is kept between runs, except anti-aliasing, which needs a restart.
//! Preferences, one dialog in the spirit of Fusion's, opened from the gear at the top right of the
//! window, File › Preferences or Ctrl+, (Cmd+, on macOS): General (theme, UI scale, units of new
//! designs, autosave, display precision, the agent cursor), Navigation (mouse presets, zoom
//! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search,
//! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics
//! (visual style, grid, shadow, perspective, anti-aliasing), Sketch and Selection. Changes apply
//! at once; Cancel puts back what was there when the dialog opened, OK keeps them, Apply keeps
//! them and stays open, and Restore Defaults resets the page shown. Everything is kept between
//! runs, except anti-aliasing, which needs a restart.
use egui::{Modifiers, Pos2, RichText};
use serde::{Deserialize, Serialize};
@@ -14,7 +19,13 @@ use crate::viewport::NavMode;
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
/// Mouse mappings: (id, label).
pub const NAV_PRESETS: [(&str, &str); 3] = [("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor")];
pub const NAV_PRESETS: [(&str, &str); 5] =
[("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor"), ("alias", "Alias"), ("tinkercad", "Tinkercad")];
/// The dialog's pages, in order.
pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"];
pub const PAGE_SHORTCUTS: usize = 2;
pub const PAGE_DESIGN: usize = 3;
pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")];
pub const MSAA: [u32; 4] = [1, 2, 4, 8];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -33,6 +44,19 @@ pub struct Prefs {
/// Decimals shown for lengths and angles.
pub length_decimals: u8,
pub angle_decimals: u8,
/// `dark`, `light` or `system` (follow the operating system).
pub theme: String,
/// Interface scale (1 = 100 %).
pub ui_scale: f32,
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
pub zoom_to_cursor: bool,
/// View changes (view cube, Look At, Home) animate; `look_at_speed` scales how fast.
pub look_at_anim: bool,
pub look_at_speed: f32,
/// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3).
pub up_axis: String,
/// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`).
pub offer_stand_up: bool,
}
impl Default for Prefs {
@@ -45,6 +69,13 @@ impl Default for Prefs {
msaa: 4,
length_decimals: 2,
angle_decimals: 1,
theme: "dark".into(),
ui_scale: 1.0,
zoom_to_cursor: true,
look_at_anim: true,
look_at_speed: 1.0,
up_axis: "z".into(),
offer_stand_up: true,
}
}
}
@@ -53,7 +84,127 @@ impl Default for Prefs {
#[derive(Default)]
pub struct PrefsWindow {
pub open: bool,
section: usize,
/// The page shown (index into [`PAGES`]).
pub section: usize,
/// Everything as it was when the dialog opened (or at the last Apply), for Cancel.
snapshot: Option<Snapshot>,
}
/// Open the dialog at a page.
pub fn open_at(app: &mut SolveApp, page: usize) {
app.prefs_window.open = true;
app.prefs_window.section = page.min(PAGES.len() - 1);
}
/// Every setting the dialog changes, for Cancel and Restore Defaults.
#[derive(Clone)]
struct Snapshot {
prefs: Prefs,
dark: bool,
grid: bool,
shadow: bool,
perspective: bool,
style: u8,
pick_bodies: bool,
agent: crate::agent_cursor::Settings,
autosave: f64,
auto_project: bool,
keys: std::collections::BTreeMap<String, String>,
}
impl Snapshot {
fn take(app: &SolveApp) -> Snapshot {
Snapshot {
prefs: app.preferences.clone(),
dark: app.ui.dark,
grid: app.ui.show_grid,
shadow: app.ui.ground_shadow,
perspective: app.ui.perspective,
style: app.ui.visual_style,
pick_bodies: app.ui.pick_bodies,
agent: app.ui.agent_cursor,
autosave: app.autosave_minutes,
auto_project: app.session.auto_project,
keys: app.keymap.custom.clone(),
}
}
fn restore(self, app: &mut SolveApp) {
let msaa = app.preferences.msaa;
app.preferences = self.prefs;
app.ui.dark = self.dark;
app.ui.show_grid = self.grid;
app.ui.ground_shadow = self.shadow;
app.ui.perspective = self.perspective;
app.ui.visual_style = self.style;
app.ui.pick_bodies = self.pick_bodies;
app.ui.agent_cursor = self.agent;
app.autosave_minutes = self.autosave;
set_autosave(app);
app.session.auto_project = self.auto_project;
app.keymap.custom = self.keys;
if app.preferences.msaa != msaa {
save_graphics(app);
}
}
}
/// Restore Defaults for one page.
fn defaults(app: &mut SolveApp, page: usize) {
let d = Prefs::default();
let p = &mut app.preferences;
match page {
0 => {
p.theme = d.theme;
p.ui_scale = d.ui_scale;
p.default_units = d.default_units;
p.length_decimals = d.length_decimals;
p.angle_decimals = d.angle_decimals;
app.ui.dark = true;
app.ui.agent_cursor = Default::default();
app.autosave_minutes = 5.0;
set_autosave(app);
}
1 => {
p.nav = d.nav;
p.zoom_reverse = d.zoom_reverse;
p.zoom_to_cursor = d.zoom_to_cursor;
p.orbit_cursor = d.orbit_cursor;
p.look_at_anim = d.look_at_anim;
p.look_at_speed = d.look_at_speed;
}
PAGE_SHORTCUTS => crate::keymap::reset(app, None),
PAGE_DESIGN => {
p.up_axis = d.up_axis;
p.offer_stand_up = d.offer_stand_up;
}
4 => {
let msaa = p.msaa != d.msaa;
p.msaa = d.msaa;
app.ui.visual_style = 0;
app.ui.show_grid = true;
app.ui.ground_shadow = true;
app.ui.perspective = false;
if msaa {
save_graphics(app);
}
}
5 => app.session.auto_project = true,
_ => app.ui.pick_bodies = false,
}
}
/// Settings that follow the preferences every frame: the theme when it follows the system, and
/// the interface scale.
pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
if app.preferences.theme == "system"
&& let Some(t) = ctx.system_theme()
{
app.ui.dark = t == egui::Theme::Dark;
}
let z = app.preferences.ui_scale.clamp(0.5, 2.5);
if z.is_finite() && (ctx.zoom_factor() - z).abs() > 1e-3 {
ctx.set_zoom_factor(z);
}
}
/// A new, empty design takes the preferred units (not an undo step, not a change to save).
@@ -80,7 +231,8 @@ pub fn set_autosave(app: &mut SolveApp) {
/// Which navigation a mouse drag does under the preset.
pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers) -> Option<NavMode> {
if secondary && !middle {
return Some(NavMode::Orbit);
// Tinkercad: Shift+right pans; everywhere else right drag orbits.
return Some(if app.preferences.nav == "tinkercad" && mods.shift { NavMode::Pan } else { NavMode::Orbit });
}
if !middle {
return None;
@@ -103,6 +255,10 @@ pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers)
} else {
NavMode::Pan
}),
// Alias: middle pans, Ctrl+middle zooms (right drag orbits).
"alias" => Some(if ctrl { NavMode::Zoom } else { NavMode::Pan }),
// Tinkercad: middle pans.
"tinkercad" => Some(NavMode::Pan),
// Fusion: middle pans, Shift+middle orbits.
_ => Some(if mods.shift { NavMode::Orbit } else { NavMode::Pan }),
}
@@ -198,141 +354,311 @@ fn save_graphics(app: &SolveApp) {
}
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page.
if app.keymap.open {
app.keymap.open = false;
open_at(app, PAGE_SHORTCUTS);
}
if !app.prefs_window.open {
app.prefs_window.snapshot = None;
return;
}
if app.prefs_window.snapshot.is_none() {
app.prefs_window.snapshot = Some(Snapshot::take(app));
}
let t = Tokens::get();
let mut open = true;
let sections = ["General", "Navigation", "Display", "Sketch", "Selection"];
let mut button: Option<&str> = None;
let before = app.preferences.clone();
crate::frame::window(ctx, "Preferences", crate::frame::Width::Wide)
.id(egui::Id::new("sc_prefs"))
.open(&mut open)
.default_size([560.0, 360.0])
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
ui.horizontal_top(|ui| {
// A fixed page area: the vertical separator fills the height it is given, so an
// auto-sized one would grow every frame.
ui.allocate_ui_with_layout(egui::vec2(700.0, 400.0), egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.set_min_size(egui::vec2(700.0, 400.0));
ui.vertical(|ui| {
ui.set_width(120.0);
for (i, s) in sections.iter().enumerate() {
ui.set_width(140.0);
for (i, s) in PAGES.iter().enumerate() {
if ui.selectable_label(app.prefs_window.section == i, *s).clicked() {
app.prefs_window.section = i;
app.keymap.read_only = false;
}
}
});
ui.separator();
ui.vertical(|ui| {
egui::Grid::new("sc_prefs_grid").num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
0 => {
ui.label("Theme");
ui.horizontal(|ui| {
ui.radio_value(&mut app.ui.dark, true, "Dark");
ui.radio_value(&mut app.ui.dark, false, "Light");
});
ui.end_row();
ui.label("Units of new designs");
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
for u in UNITS {
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
}
});
ui.end_row();
ui.label("Autosave every");
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
set_autosave(app);
}
ui.end_row();
ui.label("");
ui.label(RichText::new("0 turns it off").size(11.0).color(t.text_dim));
ui.end_row();
}
1 => {
ui.label("Mouse");
egui::ComboBox::from_id_salt("pref_nav")
.selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1))
.show_ui(ui, |ui| {
for (id, label) in NAV_PRESETS {
if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() {
app.preferences.zoom_reverse = id == "solidworks";
}
}
});
ui.end_row();
ui.label("");
let how = match app.preferences.nav.as_str() {
"solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms",
"inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms",
_ => "Middle drag pans, Shift+middle orbits",
};
ui.label(RichText::new(format!("{how}; right drag orbits; the wheel zooms at the cursor")).size(11.0).color(t.text_dim));
ui.end_row();
ui.label("Reverse zoom direction");
ui.checkbox(&mut app.preferences.zoom_reverse, "");
ui.end_row();
ui.label("Orbit around");
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor");
});
ui.end_row();
}
2 => {
ui.label("Grid");
ui.checkbox(&mut app.ui.show_grid, "");
ui.end_row();
ui.label("Perspective view");
ui.checkbox(&mut app.ui.perspective, "");
ui.end_row();
ui.label("Visual style");
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(
ui,
|ui| {
for (i, s) in styles.iter().enumerate() {
ui.selectable_value(&mut app.ui.visual_style, i as u8, *s);
}
},
);
ui.end_row();
ui.label("Length decimals");
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
ui.end_row();
ui.label("Angle decimals");
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
ui.end_row();
ui.label("Anti-aliasing");
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
for m in MSAA {
ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"));
}
});
ui.end_row();
ui.label("");
ui.label(RichText::new("Anti-aliasing applies the next time SolveCraft starts").size(11.0).color(t.text_dim));
ui.end_row();
}
3 => {
ui.label("Project face edges into new sketches");
let mut auto = app.session.auto_project;
if ui.checkbox(&mut auto, "").changed() {
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
}
ui.end_row();
}
_ => {
ui.label("Click selects whole bodies");
ui.checkbox(&mut app.ui.pick_bodies, "");
ui.end_row();
}
});
// A fixed width, so the window keeps its size from page to page.
ui.set_width(540.0);
ui.label(RichText::new(PAGES.get(app.prefs_window.section).copied().unwrap_or_default()).strong().size(14.0));
ui.add_space(6.0);
page(app, ui, &t);
});
});
ui.separator();
ui.horizontal(|ui| {
if ui.button("Restore Defaults").on_hover_text("Reset the settings on this page").clicked() {
button = Some("defaults");
}
crate::frame::right_aligned(ui, 700.0, 170.0, |ui| {
if ui.button("OK").clicked() {
button = Some("ok");
}
if ui.button("Cancel").clicked() {
button = Some("cancel");
}
if ui.button("Apply").clicked() {
button = Some("apply");
}
});
});
});
if app.preferences.msaa != before.msaa {
save_graphics(app);
}
if !open {
app.prefs_window.open = false;
if app.preferences.up_axis != before.up_axis {
up_axis_changed(app);
}
let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::keymap::capturing(app);
match button {
Some("defaults") => defaults(app, app.prefs_window.section),
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
Some("ok") => close(app, false),
Some("cancel") => close(app, true),
_ if !open || esc => close(app, !open || esc),
_ => {}
}
}
/// Close the dialog; `revert`: put back what was there when it opened (Cancel, Esc, ×).
fn close(app: &mut SolveApp, revert: bool) {
if let Some(s) = app.prefs_window.snapshot.take()
&& revert
{
let up = app.preferences.up_axis.clone();
s.restore(app);
if app.preferences.up_axis != up {
up_axis_changed(app);
}
}
app.prefs_window.open = false;
app.keymap.read_only = false;
crate::keymap::stop_capture(app);
}
/// The default orientation changed: the view turns to match.
fn up_axis_changed(app: &mut SolveApp) {
app.animate_view("home");
}
/// One page of the dialog.
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
let note = |ui: &mut egui::Ui, s: &str| {
ui.label("");
ui.label(RichText::new(s).size(11.0).color(t.text_dim));
ui.end_row();
};
if app.prefs_window.section == PAGE_SHORTCUTS {
crate::keymap::page(app, ui);
return;
}
egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
0 => {
ui.label("Theme");
ui.horizontal(|ui| {
for (id, label) in THEMES {
if ui.radio(app.preferences.theme == id, label).clicked() {
app.preferences.theme = id.to_string();
if id != "system" {
app.ui.dark = id == "dark";
}
}
}
});
ui.end_row();
ui.label("UI scale");
ui.add(egui::Slider::new(&mut app.preferences.ui_scale, 0.75..=2.0).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
ui.end_row();
ui.label("Units of new designs");
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
for u in UNITS {
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
}
});
ui.end_row();
ui.label("Autosave every");
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
set_autosave(app);
}
ui.end_row();
note(ui, "0 turns it off");
ui.label("Length decimals");
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
ui.end_row();
ui.label("Angle decimals");
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
ui.end_row();
ui.label("Agent cursor");
let s = &mut app.ui.agent_cursor;
ui.checkbox(&mut s.show, "Show the pointer an AI agent moves (#35)");
ui.end_row();
ui.label("Agent cursor speed");
ui.add_enabled_ui(s.show, |ui| {
crate::agent_cursor::speed_slider(ui, &mut s.speed);
});
ui.end_row();
ui.label("");
ui.add_enabled(s.show, egui::Checkbox::new(&mut s.follow_camera, "Follow the camera (Look At sketches)"));
ui.end_row();
}
1 => {
ui.label("Pan, zoom, orbit");
egui::ComboBox::from_id_salt("pref_nav")
.selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1))
.show_ui(ui, |ui| {
for (id, label) in NAV_PRESETS {
if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() {
app.preferences.zoom_reverse = id == "solidworks";
}
}
});
ui.end_row();
note(ui, &format!("{}; the wheel zooms", nav_help(&app.preferences.nav)));
ui.label("Reverse zoom direction");
ui.checkbox(&mut app.preferences.zoom_reverse, "");
ui.end_row();
ui.label("Zoom to the cursor");
ui.checkbox(&mut app.preferences.zoom_to_cursor, "")
.on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)");
ui.end_row();
ui.label("Orbit around");
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor");
});
ui.end_row();
ui.label("Animate view changes");
ui.checkbox(&mut app.preferences.look_at_anim, "View cube, Look At, Home and Fit");
ui.end_row();
ui.label("Animation speed");
ui.add_enabled(
app.preferences.look_at_anim,
egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")),
);
ui.end_row();
}
PAGE_DESIGN => {
ui.label("Default modelling orientation");
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.up_axis, "z".to_string(), "Z up");
ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), "Y up");
});
ui.end_row();
note(ui, "Which axis points up in the views (view cube, Home, Look At). Designs are unchanged.");
ui.label("Offer to stand up Y-up files");
ui.checkbox(&mut app.preferences.offer_stand_up, "")
.on_hover_text("After a STEP or IGES file opens with Z up, offer a quarter turn about X");
ui.end_row();
}
4 => {
ui.label("Visual style");
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(ui, |ui| {
for (i, s) in styles.iter().enumerate() {
ui.selectable_value(&mut app.ui.visual_style, i as u8, *s);
}
});
ui.end_row();
ui.label("Grid");
ui.checkbox(&mut app.ui.show_grid, "");
ui.end_row();
ui.label("Ground shadow");
ui.checkbox(&mut app.ui.ground_shadow, "");
ui.end_row();
ui.label("Perspective view");
ui.checkbox(&mut app.ui.perspective, "");
ui.end_row();
ui.label("Anti-aliasing");
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
for m in MSAA {
ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"));
}
});
ui.end_row();
note(ui, "Anti-aliasing applies the next time SolveCraft starts");
ui.label("Renderer");
ui.label(match (&app.viewport.gpu, &app.help.gpu) {
(Some(_), Some(g)) => format!("GPU: {g}"),
(Some(_), None) => "GPU".to_string(),
_ => "CPU (software)".to_string(),
});
ui.end_row();
}
5 => {
ui.label("Project face edges into new sketches");
let mut auto = app.session.auto_project;
if ui.checkbox(&mut auto, "").changed() {
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
}
ui.end_row();
}
_ => {
ui.label("Click selects whole bodies");
ui.checkbox(&mut app.ui.pick_bodies, "");
ui.end_row();
}
});
}
/// What the mouse does under a preset.
pub fn nav_help(preset: &str) -> &'static str {
match preset {
"solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits",
"inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits",
"alias" => "Alt+right or right drag orbits, middle drag pans, Ctrl+middle zooms",
"tinkercad" => "Right drag orbits, middle or Shift+right drag pans",
_ => "Middle drag pans, Shift+middle orbits, right drag orbits",
}
}
/// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button`
/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer;
/// any other keys set preferences. Returns them all, the open page and the offer.
pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Value, String> {
let mut rest = p.as_object().cloned().unwrap_or_default();
if let Some(page) = rest.remove("open") {
let name = page.as_str().unwrap_or("General");
let i = PAGES.iter().position(|x| x.eq_ignore_ascii_case(name)).ok_or_else(|| format!("no page `{name}` ({})", PAGES.join(", ")))?;
open_at(app, i);
if app.prefs_window.snapshot.is_none() {
app.prefs_window.snapshot = Some(Snapshot::take(app));
}
}
if rest.remove("stand_up").and_then(|v| v.as_bool()) == Some(true) {
crate::stand_up::stand_up(app)?;
}
let button = rest.remove("button");
let mut out = set(app, &serde_json::Value::Object(rest))?;
match button.as_ref().and_then(|b| b.as_str()) {
Some("ok") => close(app, false),
Some("cancel") => close(app, true),
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
Some("defaults") => defaults(app, app.prefs_window.section),
Some(b) => return Err(format!("unknown button `{b}` (ok, cancel, apply, defaults)")),
None => {}
}
if button.is_some() {
out = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?;
}
if let Some(o) = out.as_object_mut() {
o.insert("dialog".into(), json!(app.prefs_window.open.then(|| PAGES.get(app.prefs_window.section).copied().unwrap_or_default())));
o.insert("stand_up_offer".into(), json!(app.stand_up));
o.insert("dark".into(), json!(app.ui.dark));
}
Ok(out)
}
/// Set preferences from JSON (control channel); returns them all.
@@ -351,13 +677,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
return Err(format!("units must be one of {}", UNITS.join(", ")));
}
if !NAV_PRESETS.iter().any(|(id, _)| *id == new.nav) || !MSAA.contains(&new.msaa) {
return Err("`nav` must be fusion, solidworks or inventor and `msaa` 1, 2, 4 or 8".into());
return Err("`nav` must be fusion, solidworks, inventor, alias or tinkercad and `msaa` 1, 2, 4 or 8".into());
}
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
}
if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) {
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
}
if new.theme != app.preferences.theme && new.theme != "system" {
app.ui.dark = new.theme == "dark";
}
let up_changed = new.up_axis != app.preferences.up_axis;
let msaa_changed = new.msaa != app.preferences.msaa;
app.preferences = new;
if msaa_changed {
save_graphics(app);
}
if up_changed {
up_axis_changed(app);
}
serde_json::to_value(&app.preferences).map_err(|e| e.to_string())
}
@@ -390,6 +729,86 @@ mod tests {
assert_eq!(format_length(25.4, "in", 3), "1.000 in");
}
/// The dialog: changes apply at once, Cancel puts back what was there when it opened, Apply
/// keeps them, Restore Defaults resets one page, and the shortcuts menus open its page.
#[test]
fn preferences_dialog_cancel_apply_and_defaults() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
control(&mut app, &json!({"open": "general"})).unwrap();
assert!(app.prefs_window.open && app.prefs_window.section == 0);
control(&mut app, &json!({"theme": "light", "ui_scale": 1.25, "zoom_to_cursor": false})).unwrap();
assert!(!app.ui.dark, "light applies at once");
app.keymap.custom.insert("solid.extrude".into(), "Shift+E".into());
let v = control(&mut app, &json!({"button": "cancel"})).unwrap();
assert_eq!(v["dialog"], serde_json::Value::Null);
assert!(app.ui.dark && app.preferences.theme == "dark" && app.preferences.ui_scale == 1.0 && app.preferences.zoom_to_cursor);
assert!(app.keymap.custom.is_empty(), "a rebinding made while the dialog was open is undone too");
control(&mut app, &json!({"open": "Navigation", "nav": "alias", "look_at_anim": false})).unwrap();
control(&mut app, &json!({"button": "apply"})).unwrap();
control(&mut app, &json!({"nav": "tinkercad"})).unwrap();
control(&mut app, &json!({"button": "cancel"})).unwrap();
assert_eq!(app.preferences.nav, "alias", "Cancel goes back to the last Apply");
let to = {
let mut c = app.cam;
c.set_view(solvecraft_engine::render::StandardView::Top);
c
};
app.animate_to(to);
assert!(app.cam_anim.is_none() && app.cam == to, "no animation: the view jumps");
control(&mut app, &json!({"open": "Navigation"})).unwrap();
control(&mut app, &json!({"button": "defaults"})).unwrap();
assert_eq!(app.preferences.nav, "fusion");
assert!(app.preferences.look_at_anim);
control(&mut app, &json!({"button": "ok"})).unwrap();
assert!(!app.prefs_window.open);
// File and Help › Keyboard Shortcuts open the same dialog at its page.
app.keymap.open = true;
let ctx = egui::Context::default();
let _ = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
assert!(app.prefs_window.open && app.prefs_window.section == PAGE_SHORTCUTS && !app.keymap.open);
assert!(control(&mut app, &json!({"open": "Plugins"})).is_err());
assert!(set(&mut app, &json!({"up_axis": "x"})).is_err());
assert!(set(&mut app, &json!({"theme": "sepia"})).is_err());
}
/// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable.
#[test]
fn a_y_up_step_file_can_be_stood_up() {
let dir = std::env::temp_dir().join(format!("solvecraft-standup-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("tall_in_y.step").to_string_lossy().to_string();
let mut src = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
src.run("solid.box", json!({"length": 10, "width": 40, "height": 5})).unwrap();
src.run("file.export", json!({"path": path})).unwrap();
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
app.open_path(&path);
let offered = app.stand_up.clone().unwrap_or_default();
assert_eq!(offered.len(), 1, "the file's body is offered");
let size = |app: &SolveApp| solvecraft_engine::view::bounds(&app.session);
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6);
control(&mut app, &json!({"stand_up": true})).unwrap();
let b = size(&app);
assert!((b.max.z - b.min.z - 40.0).abs() < 1e-6, "Y became Z: {b:?}");
assert!(app.stand_up.is_none());
app.run("edit.undo", json!({})).unwrap();
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6, "one undo step");
// Not offered when turned off, or with Y up as the default orientation.
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
app.preferences.offer_stand_up = false;
app.open_path(&path);
assert!(app.stand_up.is_none());
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
app.preferences.up_axis = "y".into();
app.open_path(&path);
assert!(app.stand_up.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn orbiting_around_the_cursor_keeps_that_point_still() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
+181
View File
@@ -219,3 +219,184 @@ fn chamfer_types_send_their_values() {
let e = crate::dialogs::for_feature(&app, ch, None).unwrap();
assert!(matches!(&e.kind, Kind::Fillet { chamfer: true, ctype: 2, angle, flip: true, .. } if angle == "30 deg"), "{:?}", e.kind);
}
/// Apply a dialog's commands to the session; the new feature's id.
fn apply(app: &mut SolveApp, d: &Dialog) -> u64 {
for (id, p) in apply_commands(app, d).unwrap() {
app.session.execute(&id, &p).unwrap();
}
app.session.doc.features.last().unwrap().id
}
#[test]
fn shell_direction_and_tangent_chain_round_trip() {
let mut app = boxed();
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
let mut d = start(&mut app, vec![top], "solid.shell");
// Tangent Chain is on, as in Fusion; Direction Outside.
assert_eq!(d.extra.get("tangent_chain"), Some(&json!(true)));
d.extra.insert("direction".into(), json!("outside"));
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert_eq!((p["direction"].clone(), p["tangent_chain"].clone()), (json!("outside"), json!(true)));
let id = apply(&mut app, &d);
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
assert_eq!(e.extra.get("direction"), Some(&json!("outside")));
assert_eq!(e.extra.get("tangent_chain"), Some(&json!(true)));
}
#[test]
fn variable_fillet_round_trip() {
let mut app = boxed();
let e0 = edge(&app, Vec3::new(40.0, 15.0, 20.0));
let mut d = start(&mut app, vec![e0], "solid.fillet");
d.inputs[0].items.truncate(1);
d.extra.insert("type".into(), json!("variable"));
d.extra.insert("radius2".into(), json!("4 mm"));
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert_eq!((p["type"].clone(), p["radius2"].clone()), (json!("variable"), json!("4 mm")));
assert!(p["start"].is_array(), "{p}");
let id = apply(&mut app, &d);
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
assert_eq!(e.extra.get("type"), Some(&json!("variable")));
assert_eq!(e.extra.get("radius2"), Some(&json!("4 mm")));
}
#[test]
fn variable_fillet_mid_radii_round_trip() {
let mut app = boxed();
let e0 = edge(&app, Vec3::new(40.0, 15.0, 20.0));
let mut d = start(&mut app, vec![e0], "solid.fillet");
d.inputs[0].items.truncate(1);
d.extra.insert("type".into(), json!("variable"));
d.extra.insert("radius2".into(), json!("2 mm"));
d.extra.insert("mid".into(), json!([[0.5, "4 mm"]]));
d.extra.insert("smooth".into(), json!(true));
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert_eq!((p["mid"].clone(), p["smooth"].clone()), (json!([[0.5, "4 mm"]]), json!(true)), "{p}");
let id = apply(&mut app, &d);
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
assert_eq!(e.extra.get("mid"), Some(&json!([[0.5, "4 mm"]])));
assert_eq!(e.extra.get("smooth"), Some(&json!(true)));
// All middle radii removed: a plain two-radius fillet again.
let mut d = e;
d.extra.insert("mid".into(), json!([]));
let p = apply_commands(&app, &d).unwrap().remove(0).1;
// (an edit: the feature's new parameters)
assert_eq!(p["params"]["mid"], json!([]), "{p}");
}
#[test]
fn newer_options_reach_their_commands() {
let mut app = boxed();
// Thread: Modeled.
let side = face(&app, Vec3::new(20.0, 0.0, 10.0));
let mut d = start(&mut app, vec![side.clone()], "solid.thread");
d.extra.insert("modeled".into(), json!(true));
assert_eq!(apply_commands(&app, &d).unwrap()[0].1["modeled"], json!(true));
// Combine into a new component.
app.dialog = None;
let mut d = start(&mut app, vec![], "solid.combine");
d.extra.insert("new_component".into(), json!(true));
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
d.inputs[1].items = vec![Sel::Body { name: "Body2".into() }];
assert_eq!(apply_commands(&app, &d).unwrap()[0].1["new_component"], json!(true));
// Hole: To Object sends the place to drill to instead of a depth.
app.dialog = None;
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
let mut d = start(&mut app, vec![top], "solid.hole");
d.inputs.push(crate::selection::SelInput::new("To Object", crate::selection::FACES | crate::selection::VERTICES, false));
d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))];
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert!(p["to"].is_array() && p.get("depth").is_none(), "{p}");
}
#[test]
fn boundary_fill_finds_the_cells_and_fills_the_ticked_ones() {
let mut app = app_with(json!([
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}},
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20, "corner": [10, 10, 0], "operation": "new"}}
]));
let mut d = start(&mut app, vec![], "SurfaceSculpt");
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }];
app.dialog = Some(d);
// The dialog finds the cells on its next frame.
let ctx = egui::Context::default();
ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear();
let mut d = app.dialog.take().unwrap();
let Kind::BoundaryFill { cells, .. } = &mut d.kind else { panic!() };
// Only A, only B, and both.
assert_eq!(cells.len(), 3, "{cells:?}");
assert!(cells.iter().any(|c| c.1 == "Inside Body1, Body2"));
// Fill just the overlap.
for c in cells.iter_mut() {
c.2 = c.1 == "Inside Body1, Body2";
}
let id = apply(&mut app, &d);
assert!(app.session.model.result(id).is_some_and(|r| r.error.is_none()));
// A new body filling the 10 x 10 x 20 overlap.
let vols: Vec<f64> = app.session.model.state().bodies.iter().map(|b| b.mesh().measure().volume).collect();
assert!(vols.len() == 3 && vols.iter().any(|v| (v - 2000.0).abs() < 1.0), "{vols:?}");
}
#[test]
fn align_snaps_to_centres_and_edge_middles() {
let mut app = boxed();
let mut d = start(&mut app, vec![], "solid.align");
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))];
d.inputs[2].items = vec![edge(&app, Vec3::new(20.0, 0.0, 20.0))];
d.extra.insert("from_snap".into(), json!("center"));
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert_eq!(p["from"]["snap"], json!("face_center"));
assert_eq!(p["to"]["snap"], json!("edge_mid"));
}
#[test]
fn surface_dialogs_make_their_commands() {
// A planar patch of a sketch's rectangle, then thickened; Edit Feature brings both back.
let mut app = app_with(json!([
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [30, 20]}},
{"command": "sketch.finish", "params": {}}
]));
let sk = app.session.doc.features.last().unwrap().id;
let mut d = start(&mut app, vec![], "surface.patch");
d.inputs[0].items = vec![Sel::Profile { sketch: sk, index: 0 }];
let patch = {
for (id, p) in apply_commands(&app, &d).unwrap() {
app.session.execute(&id, &p).unwrap();
}
app.session.doc.features.last().unwrap().id
};
let surf = app.session.model.state().bodies.last().unwrap().name.clone();
assert!(matches!(crate::dialogs::for_feature(&app, patch, None).unwrap().kind, Kind::Surface(_)));
app.dialog = None;
let mut d = start(&mut app, vec![], "surface.thicken");
d.inputs[0].items = vec![Sel::Body { name: surf }];
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!(id, "surface.thicken");
app.session.execute(&id, &p).unwrap();
let v = app.session.model.state().bodies.iter().map(|b| b.mesh().measure().volume).fold(0.0, f64::max);
assert!((v - 30.0 * 20.0 * 2.0).abs() < 1.0, "{v}");
}
#[test]
fn split_body_by_a_curved_face_sends_it_as_a_tool() {
let mut app = app_with(json!([
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}},
{"command": "solid.cylinder", "params": {"diameter": 20, "height": 40, "base": [20, 15, -10], "operation": "new"}}
]));
let st = app.session.model.state();
let cyl = st.bodies.iter().find(|b| b.name != "Body1").unwrap();
let m = cyl.mesh();
// A point on the round side.
let p = Vec3::new(30.0, 15.0, 5.0);
let t = m.triangles.iter().position(|t| crate::dialogs::point_tri_dist(p, m.tri(t).unwrap()) < 0.3).unwrap();
let side = Sel::Face { body: cyl.name.clone(), index: m.tri_face[t] as usize, point: p };
drop(st);
let mut d = start(&mut app, vec![], "solid.split_body");
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
d.inputs[1].items = vec![side];
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert!(p["tool"]["point"].is_array() && p.get("plane").is_none(), "{p}");
}
+21 -1
View File
@@ -270,12 +270,19 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
}
let nf = am.tri_face.iter().max().map_or(0, |m| *m as usize + 1);
let added: Vec<bool> = (0..nf).map(|f| old.as_ref().is_none_or(|bm| face_is_new(&am, f, bm))).collect();
// Material removed from a body (a hole, a pocket): its new faces also show through the
// body in the cut colour, so the cut is seen from any side.
let cut = old.as_ref().is_some_and(|bm| am.measure().volume < bm.measure().volume - 1e-6);
for (t, f) in am.triangles.iter().zip(&am.tri_face) {
let col = if added.get(*f as usize).copied().unwrap_or(true) { c.added } else { c.body };
let new = added.get(*f as usize).copied().unwrap_or(true);
let col = if new { c.added } else { c.body };
for k in t {
let i = *k as usize;
if let (Some(p), Some(n)) = (am.positions.get(i), am.normals.get(i)) {
sc.tri(p.to_f32(), n.to_f32(), col);
if new && cut {
sc.xray_tri(p.to_f32(), n.to_f32(), c.removed);
}
}
}
}
@@ -364,6 +371,19 @@ mod tests {
bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count()
}
#[test]
fn a_hole_shows_its_cut_through_the_body() {
let mut s = solvecraft_engine::Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let p = s.preview(&[("solid.hole".to_string(), json!({"position": [20, 15, 20], "diameter": 5, "depth": 10}))]).unwrap();
let b = build(&p.before, &p.after, colors());
// The hole's walls are drawn see-through in the cut colour (seen from any side).
assert!(count(&b.scene.xray, 28, colors().removed) > 0);
// A feature that only adds material shows no cut.
let p = s.preview(&[("solid.box".to_string(), json!({"length": 10, "width": 10, "height": 30, "operation": "join"}))]).unwrap();
assert_eq!(count(&build(&p.before, &p.after, colors()).scene.xray, 28, colors().removed), 0);
}
fn plate() -> Session {
let mut s = Session::default();
for (id, p) in [
+146 -3
View File
@@ -9,6 +9,9 @@
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
//! or `{"sketch": [x,y]}` (the sketch being edited)
//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an
//! agent's arrive (through the app's request queue, answered between frames, so the agent
//! cursor can act it out first); fails if the answer came in fewer than `min_frames` frames
//! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an
//! error)
//! - `{"make_image": "pic.png"}`: a picture in the scenario's folder
@@ -21,9 +24,12 @@
//! - `{"camera": {"yaw": rad, "pitch": rad}}` puts the camera at an orbit; `{"view_anim": "top",
//! "max_deg": 10}` animates to a view and fails if any frame turns the view more than that
//! - AT may also be `{"plane": "XY"}` (the middle of an origin plane's square), `{"axis": "Z"}`,
//! `{"dimension": "d1"}` or `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`])
//! `{"dimension": "d1"}`, `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`]),
//! `{"browser": "Bodies"}` (a browser row by its label) or `{"chevron": "Bodies"}` (its fold arrow)
//! - `{"autosave": true}`: autosave into the scenario's recovery folder; `{"restart": "crash" |
//! "close"}`: the app dies (or closes) and a new one starts on the same folders
//! - `{"script": "examples/…/x.json"}`: run a command script through the engine; `"$DIR"` in a
//! `call` step's string parameters is the scenario's folder
//! - `{"note": "…"}`: a comment; `{"pending": "why"}`: the scenario waits for a fix (its test
//! is ignored)
//! - `{"expect": {…}}`: checks, see [`check`]; `{"until": {…}}` waits (frames) until they pass
@@ -92,6 +98,16 @@ pub fn clear_handles() {
COUNTS.with(|h| h.borrow_mut().clear());
}
/// `v` with "$DIR" in its strings replaced by `dir`.
fn with_dir(v: Value, dir: &str) -> Value {
match v {
Value::String(t) => Value::String(t.replace("$DIR", dir)),
Value::Array(a) => Value::Array(a.into_iter().map(|x| with_dir(x, dir)).collect()),
Value::Object(o) => Value::Object(o.into_iter().map(|(k, x)| (k, with_dir(x, dir))).collect()),
other => other,
}
}
/// A fresh folder for one scenario's files.
fn scratch_dir() -> PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -112,6 +128,8 @@ pub struct Harness {
pub dir: PathBuf,
/// The file the next Open picker returns (a `choose_file` step).
next_file: Rc<RefCell<Option<String>>>,
/// The app's request queue, opened by the first queued request.
queue: Option<std::sync::mpsc::Sender<ControlRequest>>,
time: f64,
size: egui::Vec2,
}
@@ -139,6 +157,7 @@ impl Harness {
output: Default::default(),
dir: scratch_dir(),
next_file: Rc::new(RefCell::new(None)),
queue: None,
time: 0.0,
size: egui::vec2(1600.0, 1000.0),
};
@@ -201,6 +220,28 @@ impl Harness {
}
/// One control request, then frames until its input has been handled.
/// Send a request through the app's queue, as the control channel does, and run frames
/// until it is answered: (the reply, frames it took).
pub fn call_queued(&mut self, method: &str, params: Value) -> (Value, usize) {
let tx = self.queue.get_or_insert_with(|| {
let (tx, rx) = std::sync::mpsc::channel();
self.app.control_rx = Some(rx);
tx
});
let (req, rx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
return (json!({"ok": false, "error": "the request queue is closed"}), 0);
}
for n in 1..=3000 {
self.frame();
if let Ok(v) = rx.try_recv() {
self.settle();
return (v, n);
}
}
(json!({"ok": false, "error": "no answer in 3000 frames"}), 3000)
}
pub fn call(&mut self, method: &str, params: Value) -> Value {
let (req, _rx) = ControlRequest::new(method, params);
let out = match handle(&mut self.app, &self.ctx, &req) {
@@ -328,8 +369,36 @@ impl Harness {
let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?;
let steps = s.get("steps").cloned().unwrap_or(json!(8));
self.call("ui.drag", json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl")}))
} else if let Some(m) = s.get("queued").and_then(Value::as_str) {
let (v, frames) = self.call_queued(m, s.get("params").cloned().unwrap_or(json!({})));
let min = s.get("min_frames").and_then(Value::as_u64).unwrap_or(0);
if (frames as u64) < min {
return Err(format!("{m} was answered after {frames} frames: the agent cursor did not act it out ({min})"));
}
if let Some(max) = s.get("max_frames").and_then(Value::as_u64)
&& frames as u64 > max
{
return Err(format!("{m} was answered after {frames} frames, more than {max}"));
}
v
} else if let Some(f) = s.get("script").and_then(Value::as_str) {
// A command script (`{"commands": [{command, params}]}`, path from the repository
// root) run through the engine, as `solvecraft-cli run` does.
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(f);
let text = std::fs::read_to_string(&path).map_err(|e| format!("{f}: {e}"))?;
let v: Value = serde_json::from_str(&text).map_err(|e| format!("{f}: {e}"))?;
for (i, c) in v["commands"].as_array().into_iter().flatten().enumerate() {
let r = self.call("engine.execute", json!({"command": c["command"], "params": c.get("params").cloned().unwrap_or(json!({}))}));
if r["ok"] != true {
return Err(format!("{f}: step {} ({}): {}", i + 1, c["command"], r["error"]));
}
}
self.frames(2);
return Ok(());
} else if let Some(m) = s.get("call").and_then(Value::as_str) {
self.call(m, s.get("params").cloned().unwrap_or(json!({})))
// "$DIR" in a string parameter is the scenario's folder.
let params = with_dir(s.get("params").cloned().unwrap_or(json!({})), &self.dir.to_string_lossy());
self.call(m, params)
} else if s.get("note").is_some() || s.get("pending").is_some() {
return Ok(());
} else if let Some(name) = s.get("shot").and_then(Value::as_str) {
@@ -455,7 +524,7 @@ fn approx(got: f64, want: &Value, what: &str) -> Result<(), String> {
/// Checks: `bodies` (count), `volume` / `area` (total, number or [value, rel tol]), `faces`,
/// `features` (count), `errors` (timeline features in error, default 0), `dialog` (null: none
/// open, or a word its kind must contain), `tool` (null or command id), `sketching` (bool),
/// `selection` (count), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query`
/// `selection` (count), `glyphs` ({constraint id: count}, none overlapping), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query`
/// ({command, params?, path, value}: a command's result).
pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
let doc = h.call("document.inspect", json!({"measure": true}))["result"].clone();
@@ -513,6 +582,30 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
return Err(format!("handle order: got {xs:?}"));
}
}
"camera" => {
// {"camera": {"yaw_deg": y, "pitch_deg": p, "back"?: [x,y,z]}}: where the camera
// looks from (yaw and pitch in its up-axis frame, ±0.5°),
// once any view animation has finished.
for _ in 0..240 {
if h.app.cam_anim.is_none() {
break;
}
h.frame();
}
let (yaw, pitch) = (h.app.cam.yaw.to_degrees(), h.app.cam.pitch.to_degrees());
let near = |got: f64, want: &Value| want.as_f64().is_none_or(|w| ((got - w + 540.0).rem_euclid(360.0) - 180.0).abs() < 0.5);
if !near(yaw, &v["yaw_deg"]) || !near(pitch, &v["pitch_deg"]) {
return Err(format!("camera yaw {yaw:.2}°, pitch {pitch:.2}°, want {v}"));
}
// "back": the world direction from the target to the eye.
if let Some(want) = v["back"].as_array() {
let b = h.app.cam.back();
let w = |i: usize| want.get(i).and_then(Value::as_f64).unwrap_or(f64::NAN);
if (b.x - w(0)).abs() > 1e-3 || (b.y - w(1)).abs() > 1e-3 || (b.z - w(2)).abs() > 1e-3 {
return Err(format!("camera looks from {b:?}, want {want:?}"));
}
}
}
"count" => {
// {"count": {"name": n, "value": x}}: a published count.
let n = v["name"].as_str().unwrap_or_default();
@@ -578,6 +671,21 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
return Err(format!("tool: got {}, want {v}", ui["tool"]));
}
}
"glyphs" => {
// {constraint id: glyphs drawn for it}; glyphs never overlap one another.
let g = crate::sketch_tools::glyph_positions();
for (id, n) in v.as_object().ok_or("glyphs takes {constraint id: count}")? {
let got = g.iter().filter(|(x, _)| x == id).count();
if n.as_u64() != Some(got as u64) {
return Err(format!("glyphs of {id}: got {got}, want {n} (drawn: {g:?})"));
}
}
for (i, (a, p)) in g.iter().enumerate() {
if let Some((b, _)) = g.iter().skip(i + 1).find(|(_, q)| q.distance(*p) < 14.0) {
return Err(format!("glyphs of {a} and {b} overlap at {p:?}"));
}
}
}
"sketching" => {
if doc["active_sketch"].is_null() == v.as_bool().unwrap_or(false) {
return Err(format!("sketching: want {v}, active sketch {}", doc["active_sketch"]));
@@ -738,6 +846,20 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
return Err(format!("start page open: got {}, want {v}", ui["home"]));
}
}
"prefs" => {
// {key: value…}: those entries of `ui.prefs` (preferences, `dialog`, `dark`…).
let got = h.call("ui.prefs", json!({}))["result"].clone();
for (pk, pv) in v.as_object().into_iter().flatten() {
if got[pk.as_str()] != *pv {
return Err(format!("preference {pk}: got {}, want {pv}", got[pk.as_str()]));
}
}
}
"quit_requested" | "quitting" => {
if ui[k] != *v {
return Err(format!("{k}: got {}, want {v}", ui[k]));
}
}
"documents" => {
if ui["documents"] != *v {
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
@@ -748,6 +870,27 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
return Err(format!("design name: got {}, want {v}", doc["name"]));
}
}
"browser_has" | "browser_lacks" => {
// Browser rows on screen, by label.
let r = h.call("ui.browser", json!({}));
let labels: Vec<String> =
r["result"]["rows"].as_array().into_iter().flatten().filter_map(|x| x["label"].as_str().map(str::to_string)).collect();
for want in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
let there = labels.iter().any(|l| l == want);
if there != (k.as_str() == "browser_has") {
return Err(format!("browser row `{want}` {}: rows {labels:?}", if there { "still shown" } else { "missing" }));
}
}
}
"browser_selected" => {
// Nothing selected and no component picked (a fold click selects nothing).
let r = h.call("ui.browser", json!({}));
let none = r["result"]["selection"].as_array().is_none_or(|a| a.is_empty())
&& r["result"]["picked_components"].as_array().is_none_or(|a| a.is_empty());
if none == v.as_bool().unwrap_or(false) {
return Err(format!("browser selection: {}", r["result"]));
}
}
"query" => {
// {command, params?, path: [keys or indices], value}: a command's result at path.
let cmd = v["command"].as_str().ok_or("query needs `command`")?;
+9
View File
@@ -152,6 +152,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
let mut picked: Option<&'static str> = None;
let mut pin: Option<&'static str> = None;
let mut menu: Option<(Pos2, &'static str)> = None;
let h = 380.0;
let pos = pos2(
at.x.clamp(screen.left() + 4.0, (screen.right() - W - 4.0).max(screen.left())),
@@ -237,6 +238,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
} else if resp.clicked() && enabled {
picked = Some(id);
}
if resp.secondary_clicked() {
menu = Some((resp.interact_pointer_pos().unwrap_or(r.left_bottom()), id));
}
}
});
});
@@ -244,6 +248,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(id) = pin {
app.sbox.toggle_pin(id);
}
// A row's menu: pin to the toolbar or the shortcuts.
if let Some((at, id)) = menu {
crate::context_menu::open_for(app, at, crate::context_menu::Target::ToolbarCommand { id: id.to_string() });
return;
}
if enter
&& picked.is_none()
&& let Some((id, ..)) = list.get(app.sbox.index)
+138 -50
View File
@@ -291,7 +291,7 @@ pub fn refine_snap(
/// The constraint glyph under a screen position (its constraint id).
pub fn glyph_at(pos: Pos2) -> Option<String> {
GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= 7.0).map(|(id, _)| id.clone()))
GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= GLYPH * 0.6).map(|(id, _)| id.clone()))
}
/// Constraint glyphs drawn last frame: (constraint id, screen centre).
@@ -304,7 +304,7 @@ pub fn click_glyph(app: &mut SolveApp, _proj: &Proj, pos: Pos2) -> bool {
if app.session.active_sketch.is_none() {
return false;
}
let hit = GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= 7.0).map(|(id, _)| id.clone()));
let hit = GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= GLYPH * 0.6).map(|(id, _)| id.clone()));
let used = hit.is_some();
PICKED.with(|p| *p.borrow_mut() = hit.clone());
if let Some(id) = hit {
@@ -891,7 +891,10 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
/// Constraint glyphs of the active sketch: a small badge per constraint at its anchor
/// (conflicting ones red).
fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
/// Glyph size and spacing (screen pixels).
const GLYPH: f32 = 15.0;
fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Pos2>) {
let Some(sid) = active_sketch(app) else { return };
let st = app.session.world_state();
let Some(ss) = st.sketch(sid) else { return };
@@ -900,87 +903,172 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
return;
}
let tk = crate::theme::Tokens::get();
let mut placed: Vec<Pos2> = Vec::new();
let picked = PICKED.with(|p| p.borrow().clone());
let mut drawn: Vec<(String, Pos2)> = Vec::new();
// Where each glyph goes: beside its entity (to its left, away from the line), every entity
// of a two-entity constraint getting one; glyphs that would overlap step along.
let mut placed: Vec<(String, Pos2, Pos2)> = Vec::new();
for c in &ss.sketch.constraints {
if c.kind.is_dimension() {
continue;
}
let Some(at) = glyph_anchor(&ss.sketch, &c.kind) else { continue };
let Some(mut sp) = proj.to_screen(ss.plane.to_world(at)) else { continue };
sp += egui::vec2(9.0, -9.0);
// Keep glyphs at one spot from overlapping.
while placed.iter().any(|q| q.distance(sp) < 12.0) {
sp += egui::vec2(13.0, 0.0);
for (at, dir) in glyph_anchors(&ss.sketch, &c.kind) {
let Some(base) = proj.to_screen(ss.plane.to_world(at)) else { continue };
// The entity's direction on screen, and its side.
let side = match dir.and_then(|d| proj.to_screen(ss.plane.to_world(at + d))) {
Some(q) => {
let t = (q - base).normalized();
if t.length() > 0.5 { egui::vec2(-t.y, t.x) } else { egui::vec2(0.7, -0.7) }
}
None => egui::vec2(0.7, -0.7),
};
let step = if dir.is_some() { egui::vec2(side.y, -side.x) } else { egui::vec2(1.0, 0.0) };
let mut sp = base + side * (GLYPH * 0.9);
for _ in 0..12 {
if !placed.iter().any(|(_, q, _)| q.distance(sp) < GLYPH + 1.0) {
break;
}
sp += step * (GLYPH + 2.0);
}
placed.push((c.id.clone(), sp, base));
}
placed.push(sp);
let failing = ss.report.failing.contains(&c.id);
let selected = picked.as_deref() == Some(c.id.as_str())
|| app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchConstraint { id } if *id == c.id));
}
let hovered = hover.and_then(|h| placed.iter().find(|(_, q, _)| q.distance(h) <= GLYPH * 0.6).map(|(id, _, _)| id.clone()));
// The hovered (or selected) constraint's entities lit up.
if let Some(id) = hovered.as_ref().or(picked.as_ref())
&& let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id)
{
let (cs, ps) = constraint_entities(&c.kind);
for ci in cs {
let pts: Vec<Pos2> = ss.sketch.polyline(ci).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
painter.add(egui::Shape::line(pts, Stroke::new(3.0, tk.accent.gamma_multiply(0.7))));
}
for pi in ps {
if let Some(q) = ss.sketch.point(pi).and_then(|q| proj.to_screen(ss.plane.to_world(q))) {
painter.circle_filled(q, 4.5, tk.accent);
}
}
}
let mut drawn: Vec<(String, Pos2)> = Vec::new();
for (id, sp, base) in &placed {
let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id) else { continue };
let failing = ss.report.failing.contains(id);
let selected = picked.as_deref() == Some(id.as_str())
|| app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchConstraint { id: x } if x == id));
let lit = selected || hovered.as_deref() == Some(id.as_str());
let col = if failing {
tk.error
} else if selected {
} else if lit {
tk.accent
} else {
tk.text_dim
tk.glyph_text
};
drawn.push((c.id.clone(), sp));
let r = egui::Rect::from_center_size(sp, egui::vec2(12.0, 12.0));
painter.rect_filled(r, 2.0, tk.panel.gamma_multiply(0.85));
painter.rect_stroke(r, 2.0, Stroke::new(0.8, col), egui::StrokeKind::Inside);
painter.text(sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(9.0), col);
// A faint leader back to the entity when the glyph was pushed away from it.
if sp.distance(*base) > GLYPH * 1.6 {
painter.line_segment([*base, *sp], Stroke::new(0.8, tk.text_dim.gamma_multiply(0.6)));
}
let r = egui::Rect::from_center_size(*sp, egui::vec2(GLYPH, GLYPH));
painter.rect_filled(r, 3.0, if lit { tk.accent_soft } else { tk.glyph_bg });
painter.rect_stroke(r, 3.0, Stroke::new(1.0, if failing || lit { col } else { tk.glyph_edge }), egui::StrokeKind::Inside);
match glyph_icon(c.kind.name()) {
Some(icon) => crate::icons::paint(painter, r.shrink(2.0), icon, col, tk.glyph_bg, col),
None => {
painter.text(*sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(10.5), col);
}
}
drawn.push((id.clone(), *sp));
}
if let (Some(id), Some(h)) = (hovered, hover)
&& let Some(c) = ss.sketch.constraints.iter().find(|c| c.id == id)
{
let label = format!("{} ({id})", c.kind.name());
let at = h + egui::vec2(12.0, 12.0);
let g = painter.layout_no_wrap(label, egui::FontId::proportional(11.0), tk.text);
painter.rect_filled(egui::Rect::from_min_size(at, g.size()).expand(3.0), 3.0, tk.panel);
painter.galley(at, g, tk.text);
}
GLYPHS.with(|g| *g.borrow_mut() = drawn);
}
/// Toolbar icons used as glyphs (H and V stay letters, which tell them apart).
fn glyph_icon(name: &str) -> Option<&'static str> {
Some(match name {
"Coincident" => "c_coincident",
"Parallel" => "c_parallel",
"Perpendicular" => "c_perpendicular",
"Collinear" => "c_collinear",
"Tangent" => "c_tangent",
"Smooth" => "c_smooth",
"Equal" => "c_equal",
"Concentric" => "c_concentric",
"MidPoint" => "c_midpoint",
"Symmetry" => "c_symmetry",
"Fix" => "c_fix",
_ => return None,
})
}
/// The curves and points a constraint ties together.
fn constraint_entities(k: &solvecraft_engine::sketch::ConstraintKind) -> (Vec<usize>, Vec<usize>) {
use solvecraft_engine::sketch::ConstraintKind::*;
match *k {
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![], vec![p, q]),
Fix { p } => (vec![], vec![p]),
Midpoint { p, l } => (vec![l], vec![p]),
PointOnCurve { p, c } => (vec![c], vec![p]),
Symmetric { p, q, l } => (vec![l], vec![p, q]),
Horizontal { l } | Vertical { l } => (vec![l], vec![]),
Parallel { a, b }
| Perpendicular { a, b }
| Collinear { a, b }
| Equal { a, b }
| Concentric { a, b }
| Tangent { a, b }
| Smooth { a, b } => (vec![a, b], vec![]),
_ => (vec![], vec![]),
}
}
fn glyph_letter(name: &str) -> &'static str {
match name {
"Coincident" => "\u{25cf}",
"Horizontal" => "H",
"Vertical" => "V",
"Parallel" => "//",
"Perpendicular" => "\u{22a5}",
"Collinear" => "\u{2261}",
"Tangent" => "T",
"Smooth" => "G2",
"Equal" => "=",
"Concentric" => "\u{25ce}",
"MidPoint" => "M",
"Symmetry" => "[]",
"Fix" => "\u{1f512}",
_ => "?",
}
}
fn glyph_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &solvecraft_engine::sketch::ConstraintKind) -> Option<Vec2> {
/// Where a constraint's glyphs go: one per entity (the middle of a curve, with its direction
/// there; a point, without).
fn glyph_anchors(sk: &solvecraft_engine::sketch::Sketch, k: &solvecraft_engine::sketch::ConstraintKind) -> Vec<(Vec2, Option<Vec2>)> {
use solvecraft_engine::sketch::ConstraintKind::*;
// Half way along the curve.
let mid = |c: usize| -> Option<Vec2> {
// Half way along the curve, and the direction there.
let mid = |c: usize| -> Option<(Vec2, Option<Vec2>)> {
let poly = sk.polyline(c);
let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum();
let mut acc = 0.0;
for w in poly.windows(2) {
let l = w[0].dist(w[1]);
if acc + l >= total * 0.5 && l > 0.0 {
return Some(w[0].lerp(w[1], (total * 0.5 - acc) / l));
return Some((w[0].lerp(w[1], (total * 0.5 - acc) / l), (w[1] - w[0]).normalized()));
}
acc += l;
}
poly.first().copied()
poly.first().map(|p| (*p, None))
};
let pt = |p: usize| sk.point(p).map(|q| (q, None));
match *k {
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => sk.point(p),
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5),
Horizontal { l } | Vertical { l } => mid(l),
Parallel { a, .. }
| Perpendicular { a, .. }
| Collinear { a, .. }
| Equal { a, .. }
| Concentric { a, .. }
| Tangent { a, .. }
| Smooth { a, .. } => mid(a),
_ => None,
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => pt(p).into_iter().collect(),
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => {
sk.point(p).zip(sk.point(q)).map(|(a, b)| ((a + b) * 0.5, None)).into_iter().collect()
}
Horizontal { l } | Vertical { l } => mid(l).into_iter().collect(),
Parallel { a, b }
| Perpendicular { a, b }
| Collinear { a, b }
| Equal { a, b }
| Concentric { a, b }
| Tangent { a, b }
| Smooth { a, b } => [mid(a), mid(b)].into_iter().flatten().collect(),
_ => Vec::new(),
}
}
@@ -1002,7 +1090,7 @@ fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box.
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift)));
glyphs(app, painter, proj);
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()));
snap_hint(app, painter, proj);
combs(app, painter, proj);
if DEFERRED.with(|d| d.replace(false)) {

Some files were not shown because too many files have changed in this diff Show More