Compare commits

...

66 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
115 changed files with 8510 additions and 880 deletions
+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
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
+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"
+128 -47
View File
@@ -1,9 +1,9 @@
# Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
#
# Push a tag `v<version>` (matching [workspace.package] version in Cargo.toml) and this builds and
# packages SolveCraft for every platform and publishes the GitHub release "SolveCraft v<version>"
# with SHA256SUMS.txt, .github/release-notes.md and GitHub's generated "What's Changed". A
# version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
# 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
@@ -11,17 +11,16 @@
# 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: Actions > Release > Run workflow (publish off) builds everything and keeps it as
# the release-assets artifact instead of releasing.
# 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:
publish:
description: "Publish the release (off: a dry run; the files become the release-assets artifact)"
draft:
description: "Create/update a draft (release branch only; off: build artifacts only)"
type: boolean
default: false
@@ -44,13 +43,19 @@ jobs:
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
env:
REQUEST_DRAFT: ${{ inputs.draft || false }}
run: |
. packaging/env.sh
if [ "$GITHUB_REF_TYPE" = tag ] && [ "${GITHUB_REF_NAME#v}" != "$VERSION" ]; then
echo "::error::tag $GITHUB_REF_NAME doesn't match the Cargo.toml version $VERSION"
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
@@ -62,16 +67,16 @@ jobs:
icons:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
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@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
@@ -93,7 +98,7 @@ jobs:
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install packaging tools
run: |
sudo apt-get update
@@ -102,7 +107,7 @@ jobs:
"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@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Build and package (AppImage, deb, rpm, tar.gz)
@@ -121,7 +126,7 @@ jobs:
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
@@ -143,8 +148,8 @@ jobs:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
@@ -156,12 +161,68 @@ jobs:
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@v4
- 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
@@ -172,7 +233,7 @@ jobs:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- 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
@@ -196,7 +257,7 @@ jobs:
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@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
@@ -210,16 +271,16 @@ jobs:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- 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
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
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@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
@@ -240,15 +301,15 @@ jobs:
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- 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@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Install WiX
@@ -261,7 +322,7 @@ jobs:
env:
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
@@ -275,11 +336,11 @@ jobs:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
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
with:
key: release-web
- name: Install trunk
@@ -288,14 +349,14 @@ jobs:
| tar -xz -C "$HOME/.cargo/bin" trunk
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: GitHub Release
name: Draft GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, freebsd, macos, windows, web]
runs-on: ubuntu-latest
permissions:
@@ -306,8 +367,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
@@ -316,28 +377,48 @@ jobs:
run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
- name: Publish the release
if: github.event_name == 'push' || inputs.publish
- 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"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); else pre=(--latest); fi
if gh release view "$tag" >/dev/null 2>&1; then
echo "::error::Release $tag already exists. Bump the version before tagging."
# 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
gh release create "$tag" --title "SolveCraft v$VERSION" --verify-tag --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
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 "Published **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
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.publish
if: github.event_name == 'workflow_dispatch' && !inputs.draft
run: cp notes.md artifacts/release-notes.md
- if: github.event_name == 'workflow_dispatch' && !inputs.publish
uses: actions/upload-artifact@v4
- if: github.event_name == 'workflow_dispatch' && !inputs.draft
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
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`.
+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`.
+6 -4
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)
```
@@ -81,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) |
@@ -102,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.2"
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.2"
version = "0.1.3"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3081,7 +3082,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"log",
"serde",
@@ -3095,7 +3096,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"log",
"serde",
@@ -3111,7 +3112,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"serde",
"serde_json",
@@ -3119,7 +3120,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3131,7 +3132,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"log",
"serde",
@@ -3146,7 +3147,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3154,7 +3155,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"image",
"serde",
@@ -3163,7 +3164,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"serde",
"serde_json",
@@ -3174,7 +3175,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"egui",
"egui-wgpu",
@@ -3188,7 +3189,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"eframe",
"egui",
@@ -4487,7 +4488,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.2"
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.2"
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"
+6 -5
View File
@@ -1,10 +1,11 @@
# 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.**
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)
+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
}
+6 -2
View File
@@ -16,6 +16,7 @@
#![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;
@@ -35,7 +36,8 @@ 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
@@ -58,6 +60,8 @@ 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") => {
@@ -239,7 +243,7 @@ fn icon_png(size: usize) -> Result<Vec<u8>, String> {
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) });
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 });
+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(()) }
+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"] }
+12
View File
@@ -132,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 }
}
+5
View File
@@ -9,6 +9,7 @@
pub mod cmd;
pub mod frames;
pub mod fuzz;
pub mod legacy_ids;
pub mod licences;
pub mod params;
@@ -433,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;
@@ -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
+82 -6
View File
@@ -181,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,
@@ -193,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 }
}
}
@@ -206,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
@@ -214,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 {
@@ -317,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 {
@@ -355,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 {
@@ -508,4 +559,29 @@ mod tests {
}
}
}
/// 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);
}
}
+1 -1
View File
@@ -7,5 +7,5 @@
mod camera;
mod raster;
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, ease_in_out};
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};
+24 -5
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);
}
}
}
@@ -248,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);
+95 -15
View File
@@ -21,36 +21,87 @@ use solvecraft_engine::geom::{Vec2, Vec3};
use crate::SolveApp;
use crate::control::ControlResponse;
/// How fast the pointer moves.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
/// 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 {
/// No animation: the command runs at once.
Times(f64),
Instant,
#[default]
Normal,
Slow,
}
impl Default for Speed {
fn default() -> Self {
Speed::Times(1.0)
}
}
impl Speed {
pub const ALL: [Speed; 3] = [Speed::Instant, Speed::Normal, Speed::Slow];
pub fn label(self) -> &'static str {
/// 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",
Speed::Normal => "Normal",
Speed::Slow => "Slow",
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::Normal => (0.45, 0.25),
Speed::Slow => (0.9, 0.45),
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)]
@@ -176,7 +227,7 @@ fn walk<'a>(v: &'a Value, key: Option<&'a str>, f: &mut dyn FnMut(Option<&'a str
/// 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 || s.speed == Speed::Instant || !matches!(method, "engine.execute" | "command") {
if !s.show || !matches!(method, "engine.execute" | "command") {
return false;
}
if params.get("animate").and_then(Value::as_bool) == Some(false) {
@@ -185,6 +236,14 @@ pub fn intercept(app: &mut SolveApp, method: &str, params: &Value, reply: &Sende
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;
}
@@ -336,4 +395,25 @@ mod tests {
// 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" {
+24 -5
View File
@@ -27,7 +27,7 @@
//! - `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?: instant|normal|slow, follow_camera?}`: the agent cursor
//! - `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`
@@ -107,9 +107,9 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
s.follow_camera = v;
}
if let Some(v) = p.get("speed") {
match serde_json::from_value(v.clone()) {
Ok(sp) => s.speed = sp,
Err(_) => return err("speed must be instant, normal or slow"),
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))
@@ -287,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])),
@@ -426,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()})),
@@ -433,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);
+66 -41
View File
@@ -151,8 +151,6 @@ pub enum Kind {
Stock {
margin: String,
},
/// Application preferences (applied as they change; kept between runs).
Preferences,
/// Physical material (and with it the appearance) of bodies.
Material {
index: usize,
@@ -502,10 +500,6 @@ impl Dialog {
Some(d)
}
pub fn preferences() -> Dialog {
Dialog::new(Kind::Preferences, vec![])
}
pub fn rename(feature: u64, name: &str) -> Dialog {
Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![])
}
@@ -775,7 +769,6 @@ fn title(k: &Kind) -> &'static str {
Kind::Mirror => "MIRROR",
Kind::Measure { .. } => "MEASURE",
Kind::Material { .. } => "PHYSICAL MATERIAL",
Kind::Preferences => "PREFERENCES",
Kind::OffsetPlane { .. } => "OFFSET PLANE",
Kind::Rib { web: false, .. } => "RIB",
Kind::Rib { web: true, .. } => "WEB",
@@ -1136,30 +1129,6 @@ fn update_measure(app: &SolveApp, d: &mut Dialog) {
}
}
/// The preferences, applied as they change.
fn preferences_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.label("Theme");
let mut theme = usize::from(!app.ui.dark);
combo(ui, "pref_theme", &["Dark", "Light"], &mut theme);
app.ui.dark = theme == 0;
ui.end_row();
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("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();
}
fn fmt_mm(v: &Value) -> String {
v.as_f64().map(|x| format!("{x:.3} mm")).unwrap_or_default()
}
@@ -1251,7 +1220,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
// OK is offered once the inputs make a feature.
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences);
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal };
let wkey = egui::Id::new(("sc_dialog_w", wide));
@@ -1453,6 +1422,37 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
row_label(ui, "Swap Ends");
extra_check(ui, &mut d.extra, "swap_ends");
ui.end_row();
// Radii between the ends: position along the edge (from the start end)
// and radius.
let mut mids = mid_points(&d.extra);
let mut remove = None;
for (k, (t, r)) in mids.iter_mut().enumerate() {
row_label(ui, "Mid Radius");
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(t).speed(0.01).range(0.01..=0.99).fixed_decimals(2))
.on_hover_text("Position along the edge, 0 at the start end, 1 at the other");
enter |= field(ui, r);
if ui.small_button("×").on_hover_text("Remove this point").clicked() {
remove = Some(k);
}
});
ui.end_row();
}
if let Some(k) = remove {
mids.remove(k);
}
row_label(ui, "");
if ui.button("Add Mid Radius").clicked() && mids.len() < 20 {
let t = if mids.is_empty() { 0.5 } else { (mids.iter().map(|m| m.0).fold(0.0, f64::max) + 1.0) * 0.5 };
mids.push((t.min(0.99), radius.clone()));
}
ui.end_row();
d.extra.insert("mid".into(), json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
if !mids.is_empty() {
row_label(ui, "Smooth");
extra_check(ui, &mut d.extra, "smooth");
ui.end_row();
}
}
row_label(ui, "Tangent Chain");
ui.checkbox(chain, "");
@@ -1490,7 +1490,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.end_row();
}
Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()),
Kind::Preferences => preferences_rows(app, ui),
Kind::OffsetPlane { offset } => {
row_label(ui, "Distance");
enter |= field(ui, offset);
@@ -1811,7 +1810,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ok = true;
}
}
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences) { "Close" } else { "Cancel" };
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
if ui.add(egui::Button::new(close).min_size(vec2(64.0, 24.0))).clicked() {
cancel = true;
}
@@ -1824,7 +1823,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let nothing_focused = ctx.memory(|m| m.focused().is_none());
let canvas_enter = enter_free && ctx.memory(|m| m.had_focus_last_frame(egui::Id::new("sc_canvas_value")));
// Tables (configurations, a study's keys) take Enter for their cells.
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. } | Kind::Preferences)
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. })
&& !matches!(&d.kind, Kind::Motion(k) if k.wide());
if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
ok = true;
@@ -1945,6 +1944,25 @@ fn extra_field(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, ke
enter
}
/// A variable fillet's middle radii as (position, radius expression).
fn mid_points(extra: &serde_json::Map<String, Value>) -> Vec<(f64, String)> {
extra
.get("mid")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|m| {
let t = m.get(0)?.as_f64()?;
let r = match m.get(1)? {
Value::String(x) => x.clone(),
Value::Number(n) => n.to_string(),
_ => return None,
};
Some((t, r))
})
.collect()
}
const CHAMFER_TYPES: [&str; 3] = ["Equal Distance", "Two Distances", "Distance and Angle"];
/// A new Fillet or Chamfer dialog's values.
@@ -2202,6 +2220,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
if let Some((a, b)) = ends {
p["start"] = pt(if swap { b } else { a });
}
let mids = mid_points(&d.extra);
if !mids.is_empty() {
p["mid"] = json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>());
if d.extra.get("smooth").and_then(Value::as_bool).unwrap_or(false) {
p["smooth"] = json!(true);
}
}
}
("solid.fillet", p)
}
@@ -2533,7 +2558,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
Kind::EditParam { name, expr } => ("parameters.change", json!({"name": name, "expression": expr})),
Kind::Rename { feature, name } => ("timeline.rename", json!({"feature": feature, "name": name})),
Kind::ConfirmDelete { feature, .. } => ("timeline.delete", json!({ "features": [feature.to_string()] })),
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences => return Ok(Vec::new()),
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } => return Ok(Vec::new()),
Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs),
Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra),
Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra),
@@ -2765,10 +2790,8 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
if let Some(inp) = d.inputs.first_mut() {
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
}
for (k, v) in [("targets", (!targets.is_empty()).then(|| json!(targets)))] {
if let Some(v) = v {
d.extra.insert(k.into(), v);
}
if !targets.is_empty() {
d.extra.insert("targets".into(), json!(targets));
}
d
}
@@ -2824,9 +2847,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
FilletStyle::Chord => {
d.extra.insert("type".into(), json!("chord"));
}
FilletStyle::Variable { radius2, start } => {
FilletStyle::Variable { radius2, start, mid, smooth } => {
d.extra.insert("type".into(), json!("variable"));
d.extra.insert("radius2".into(), json!(radius2));
d.extra.insert("mid".into(), json!(mid.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
d.extra.insert("smooth".into(), json!(smooth));
// The start was the first edge's last point: ends swapped.
let first_end = edges.first().and_then(|p| edge_sel(s, *p)).and_then(|x| match x {
Sel::Edge { body, index, .. } => {
+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![]);
+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));
+76 -62
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
@@ -321,14 +324,25 @@ fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f3
// 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, 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;
}
/// How fast the normal turns across the pixel (per mm).
@@ -474,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>,
@@ -751,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,
@@ -788,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 };
@@ -822,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
@@ -909,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 {
@@ -939,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);
@@ -1041,3 +1032,26 @@ mod tests {
}
}
}
#[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);
}
}
+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)]
+95 -6
View File
@@ -37,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;
@@ -54,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;
@@ -97,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>,
@@ -128,6 +149,7 @@ 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(),
@@ -177,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,
@@ -191,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.
@@ -238,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(),
@@ -247,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,
@@ -288,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()
@@ -339,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 {
@@ -360,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();
@@ -372,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" {
@@ -544,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) {
@@ -557,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, &[]);
}
}
@@ -564,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);
}
@@ -584,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);
@@ -606,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.
@@ -649,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);
@@ -659,6 +733,9 @@ 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);
@@ -733,13 +810,13 @@ 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;
@@ -821,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");
}
+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());
+25 -1
View File
@@ -261,6 +261,30 @@ fn variable_fillet_round_trip() {
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();
@@ -297,7 +321,7 @@ fn boundary_fill_finds_the_cells_and_fills_the_ticked_ones() {
app.dialog = Some(d);
// The dialog finds the cells on its next frame.
let ctx = egui::Context::default();
let _ = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
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.
+93 -5
View File
@@ -9,7 +9,7 @@
//! - `{"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}`: a request sent the way an
//! - `{"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
@@ -24,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
@@ -95,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};
@@ -362,9 +375,30 @@ impl Harness {
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) {
@@ -490,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();
@@ -549,7 +583,8 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
}
}
"camera" => {
// {"camera": {"yaw_deg": y, "pitch_deg": p}}: where the camera looks from (±0.5°),
// {"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() {
@@ -562,6 +597,14 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
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.
@@ -628,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"]));
@@ -788,6 +846,15 @@ 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]));
@@ -803,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)) {
+72
View File
@@ -0,0 +1,72 @@
//! Standing up a Y-up file (#3). Many CAD programs save parts with Y up, so a STEP or IGES file
//! from them opens lying on its back in a Z-up design. After such a file opens, SolveCraft
//! offers to stand it up: a quarter turn about X (Y → Z) of the bodies it brought, as one
//! Move feature that can be undone or edited. Preferences › Design › "Offer to stand up Y-up
//! files" turns the offer off; with Y up as the default orientation it isn't needed.
use serde_json::json;
use crate::SolveApp;
/// The bodies a just-opened file brought (`before`: body names before it opened, none for a
/// new design): offer to stand them up.
pub fn offer(app: &mut SolveApp, path: &str, before: &[String]) {
let io = &solvecraft_engine::io::is_step_path;
if !(io(path) || solvecraft_engine::io::is_iges_path(path)) || !app.preferences.offer_stand_up || app.preferences.up_axis != "z" {
return;
}
let bodies: Vec<String> = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|n| !before.contains(n)).collect();
if !bodies.is_empty() {
app.stand_up = Some(bodies);
}
}
/// Turn the offered bodies a quarter turn about X (Y up becomes Z up).
pub fn stand_up(app: &mut SolveApp) -> Result<(), String> {
let Some(bodies) = app.stand_up.take() else { return Err("no file is waiting to be stood up".into()) };
app.run("solid.move", json!({ "bodies": bodies, "axis": [1, 0, 0], "angle": "90 deg" })).map_err(|e| e.to_string())?;
app.animate_view("home");
Ok(())
}
/// The offer, under the toolbar.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if app.stand_up.is_none() {
return;
}
let mut act: Option<&str> = None;
crate::frame::window(ctx, "Stand this part up?", crate::frame::Width::Normal)
.id(egui::Id::new("sc_stand_up"))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 140.0))
.show(ctx, |ui| {
ui.label("Files from many CAD programs have Y up, so they open lying on their back here (Z up).");
ui.label("Stand it up: a quarter turn about X, as one Move feature you can undo.");
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("Stand it up").clicked() {
act = Some("stand");
}
if ui.button("Leave it").clicked() {
act = Some("leave");
}
if ui.button("Don't ask again").on_hover_text("Preferences › Design turns the offer back on").clicked() {
act = Some("never");
}
});
});
match act {
Some("stand") => {
if let Err(e) = stand_up(app) {
app.set_status(e, true);
}
}
Some("leave") => app.stand_up = None,
Some("never") => {
app.stand_up = None;
app.preferences.offer_stand_up = false;
}
_ => {}
}
}
+85 -20
View File
@@ -2,7 +2,6 @@
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use solvecraft_engine::command_specs;
use crate::theme::Tokens;
use crate::{SolveApp, icons};
@@ -10,7 +9,7 @@ use crate::{SolveApp, icons};
pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"];
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
fn panels(tab: &str) -> &'static [&'static str] {
pub fn panels(tab: &str) -> &'static [&'static str] {
if let Some(p) = crate::workspace::panels(tab) {
return p;
}
@@ -26,7 +25,7 @@ fn panels(tab: &str) -> &'static [&'static str] {
}
/// How many commands of a panel get a big button.
fn promoted(tab: &str, panel: &str) -> usize {
pub fn promoted(tab: &str, panel: &str) -> usize {
match (tab, panel) {
("SOLID", "CREATE") => 4,
("SOLID", "MODIFY") => 4,
@@ -85,6 +84,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let theme_tip = if app.ui.dark { "Light theme" } else { "Dark theme" };
if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
app.ui.dark = !app.ui.dark;
app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into();
}
if click(ui, "help", "Help") {
app.help.menu = !app.help.menu;
@@ -108,7 +108,18 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
}
// Document tabs.
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 60.0);
crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 90.0);
// Preferences, at the top right like Fusion's profile menu (also Ctrl+,).
let gear = Rect::from_center_size(pos2(r.right() - captions - 70.0, r.center().y), vec2(24.0, 24.0));
let gresp = ui.interact(gear, ui.id().with("sc_prefs_button"), Sense::click());
if gresp.hovered() {
ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30));
}
icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar);
crate::scenario::publish_handle("titlebar:preferences", gear.center());
if gresp.on_hover_text(if cfg!(target_os = "macos") { "Preferences (Cmd+,)" } else { "Preferences (Ctrl+,)" }).clicked() {
crate::prefs::open_at(app, 0);
}
ui.painter().text(
pos2(r.right() - 12.0 - captions, r.center().y),
Align2::RIGHT_CENTER,
@@ -241,13 +252,25 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let painter = ui.painter().clone();
// Workspace selector and tabs.
let tabs_y = r.top() + 2.0;
let ws = Rect::from_min_size(pos2(r.left() + 8.0, tabs_y + 26.0), vec2(96.0, 56.0));
painter.rect(ws, 4.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
// The workspace switcher spans the toolbar's height (the tab row and the panel row).
let ws = Rect::from_min_max(pos2(r.left() + 6.0, r.top() + 4.0), pos2(r.left() + 104.0, r.bottom() - 5.0));
let wresp = ui.interact(ws, ui.id().with("workspace_switcher"), Sense::click());
painter.rect(ws, 4.0, if wresp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(&painter, Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), "box", t.icon, t.icon_fill, t.accent);
painter.text(
pos2(ws.center().x - 5.0, ws.top() + 52.0),
Align2::CENTER_CENTER,
crate::toolbar_custom::WORKSPACE,
FontId::proportional(12.0),
t.text,
);
caret(&painter, pos2(ws.center().x + 27.0, ws.top() + 52.0), t.text);
painter.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
if wresp.on_hover_text("Change workspace").clicked() {
crate::context_menu::open_for(app, ws.left_bottom(), crate::context_menu::Target::Workspace);
}
crate::scenario::publish_rect("toolbar", r);
crate::scenario::publish_rect("workspace_switcher", ws);
painter.text(pos2(ws.center().x - 5.0, ws.top() + 14.0), Align2::CENTER_CENTER, "DESIGN", FontId::proportional(12.0), t.text);
caret(&painter, pos2(ws.center().x + 26.0, ws.top() + 14.0), t.text);
painter.text(pos2(ws.center().x, ws.top() + 34.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
let mut x = r.left() + 112.0;
let mut tabs: Vec<&str> = Vec::new();
if sketching {
@@ -281,23 +304,50 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
}
// Panels.
let tab = app.ui.tab.clone();
let specs = command_specs();
let mut px = r.left() + 112.0;
let top = r.top() + 26.0;
for panel in panels(&tab) {
let (cmds, promote) = match crate::workspace::layout(&tab, panel, &specs) {
Some(l) => l,
None => (specs.iter().filter(|c| c.tab == tab && c.panel == *panel).copied().collect(), promoted(&tab, panel)),
};
let cmds = crate::sketch_tools::also_in(&tab, panel, cmds, &specs);
let n = promote.min(cmds.len()).max(if cmds.is_empty() { 1 } else { 0 });
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
let mut menu: Option<(egui::Pos2, &'static str)> = None;
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
let panel: &&str = &panel;
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
let n = shown.len().max(1);
let width = (n as f32 * 40.0).max(64.0) + 8.0;
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
for (i, c) in cmds.iter().take(n).enumerate() {
// Dropped on the panel's free space: the button goes to its end.
let free = Rect::from_min_size(
pos2(px + 4.0 + shown.len() as f32 * 40.0, top + 2.0),
vec2((width - 8.0 - shown.len() as f32 * 40.0).max(8.0), 38.0),
);
let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), Sense::hover());
if fresp.dnd_hover_payload::<ToolbarDrag>().is_some() {
painter.line_segment([pos2(free.left(), free.top() + 2.0), pos2(free.left(), free.bottom() - 2.0)], Stroke::new(2.0, t.accent));
}
if let Some(d) = fresp.dnd_release_payload::<ToolbarDrag>() {
drop = Some((d.0, *panel, None));
}
for (i, c) in shown.iter().enumerate() {
let br = Rect::from_min_size(pos2(px + 4.0 + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0));
let info = c.info(&app.session);
let resp = ui.interact(br, ui.id().with(("cmd", c.id)), Sense::click());
let resp = ui.interact(br, ui.id().with(("cmd", c.id)), Sense::click_and_drag());
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), br.center());
if resp.drag_started() {
resp.dnd_set_drag_payload(ToolbarDrag(c.id));
}
if let Some(d) = resp.dnd_hover_payload::<ToolbarDrag>()
&& d.0 != c.id
{
painter
.line_segment([pos2(br.left() - 1.0, br.top() + 2.0), pos2(br.left() - 1.0, br.bottom() - 2.0)], Stroke::new(2.0, t.accent));
}
if let Some(d) = resp.dnd_release_payload::<ToolbarDrag>()
&& d.0 != c.id
{
drop = Some((d.0, *panel, Some(c.id)));
}
if resp.secondary_clicked() {
menu = Some((resp.interact_pointer_pos().unwrap_or(br.left_bottom()), c.id));
}
if resp.hovered() && info.enabled {
painter.rect_filled(br, 4.0, t.hover);
}
@@ -317,7 +367,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
app.start(id);
}
}
if cmds.is_empty() {
if shown.is_empty() {
let br = Rect::from_min_size(pos2(px + 4.0, top + 2.0), vec2(width - 8.0, 38.0));
painter.text(br.center(), Align2::CENTER_CENTER, "—", FontId::proportional(14.0), t.border);
}
@@ -363,6 +413,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
let b = ui.add_enabled(info.enabled, egui::Button::new(c.label).frame(false).shortcut_text(c.shortcut.unwrap_or("")));
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
if b.secondary_clicked() {
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
}
if b.clicked() {
let id = c.id;
app.start(id);
@@ -381,9 +434,20 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
px += 2.0;
}
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
if let Some((id, panel, before)) = drop {
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
}
if let Some((at, id)) = menu {
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
crate::context_menu::open_for(app, at, crate::context_menu::Target::ToolbarCommand { id: id.to_string() });
}
});
}
/// A toolbar button being dragged (its command id).
#[derive(Clone, Copy, Debug)]
struct ToolbarDrag(&'static str);
/// The SELECT panel: what a click in the view picks.
fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
@@ -436,6 +500,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
Key::Y => drop(app.run("edit.redo", json!({}))),
Key::S => save(app),
Key::N => crate::documents::new_design(app),
Key::Comma => crate::prefs::open_at(app, 0),
Key::O => {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path(&p);
+192
View File
@@ -0,0 +1,192 @@
//! Toolbar customisation: which commands each panel shows as buttons, per workspace and tab.
//! A command's menu (right-click on 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; buttons are dragged
//! to reorder them within and between panels; Reset Toolbar restores the tab. Kept with the
//! preferences and exported or imported with the keyboard shortcuts.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use solvecraft_engine::{CommandSpec, command_specs};
use crate::SolveApp;
use crate::context_menu::Item;
/// The only workspace so far.
pub const WORKSPACE: &str = "DESIGN";
#[derive(Default, Clone, Debug, PartialEq)]
pub struct ToolbarCustom {
/// `"<workspace>/<tab>/<panel>"` → the panel's buttons, in order (only panels the user
/// changed).
pub panels: BTreeMap<String, Vec<String>>,
}
impl ToolbarCustom {
pub fn prefs(&self) -> Value {
json!(self.panels)
}
pub fn load(&mut self, v: &Value) {
self.panels = v
.as_object()
.map(|o| {
o.iter()
.take(500)
.filter_map(|(k, v)| {
let ids: Vec<String> = v.as_array()?.iter().take(100).filter_map(|x| x.as_str().map(str::to_string)).collect();
Some((k.clone(), ids))
})
.collect()
})
.unwrap_or_default();
}
}
fn key(tab: &str, panel: &str) -> String {
format!("{WORKSPACE}/{tab}/{panel}")
}
/// The buttons a panel shows: the user's list, or the first `promoted` commands of the panel.
pub fn buttons(app: &SolveApp, tab: &str, panel: &str, cmds: &[&'static CommandSpec], promoted: usize) -> Vec<&'static CommandSpec> {
match app.toolbar_custom.panels.get(&key(tab, panel)) {
Some(ids) => ids.iter().filter_map(|id| solvecraft_engine::find_command(id)).collect(),
None => cmds.iter().take(promoted).copied().collect(),
}
}
/// The panels of a tab with their commands and default button counts (what the toolbar draws).
pub fn panel_lists(tab: &str) -> Vec<(&'static str, Vec<&'static CommandSpec>, usize)> {
let specs = command_specs();
crate::toolbar::panels(tab)
.iter()
.map(|panel| {
let (cmds, promote) = match crate::workspace::layout(tab, panel, &specs) {
Some(l) => l,
None => (specs.iter().filter(|c| c.tab == tab && c.panel == *panel).copied().collect(), crate::toolbar::promoted(tab, panel)),
};
let cmds = crate::sketch_tools::also_in(tab, panel, cmds, &specs);
(*panel, cmds, promote)
})
.collect()
}
/// The buttons of a panel as ids, made the user's own list (so it can change).
fn own(app: &mut SolveApp, tab: &str, panel: &str) -> Vec<String> {
let k = key(tab, panel);
if let Some(v) = app.toolbar_custom.panels.get(&k) {
return v.clone();
}
let lists = panel_lists(tab);
let ids: Vec<String> =
lists.iter().find(|(p, ..)| *p == panel).map(|(_, cmds, n)| cmds.iter().take(*n).map(|c| c.id.to_string()).collect()).unwrap_or_default();
ids
}
/// Where a command is a button on a tab now: its panel.
pub fn pinned_on(app: &SolveApp, tab: &str, id: &str) -> Option<&'static str> {
panel_lists(tab).into_iter().find(|(panel, cmds, n)| buttons(app, tab, panel, cmds, *n).iter().any(|c| c.id == id)).map(|(p, ..)| p)
}
/// The panel a command belongs to on a tab (its drop-down), or the tab's first panel.
fn home_panel(tab: &str, id: &str) -> Option<&'static str> {
let lists = panel_lists(tab);
lists.iter().find(|(_, cmds, _)| cmds.iter().any(|c| c.id == id)).or(lists.first()).map(|(p, ..)| *p)
}
/// Pin a command to the toolbar of a tab (as a button of its panel, at the end).
pub fn pin(app: &mut SolveApp, tab: &str, id: &str) -> Result<(), String> {
if solvecraft_engine::find_command(id).is_none() {
return Err(format!("no command `{id}`"));
}
if pinned_on(app, tab, id).is_some() {
return Ok(());
}
let panel = home_panel(tab, id).ok_or_else(|| format!("the {tab} tab has no panels"))?;
let mut ids = own(app, tab, panel);
ids.push(id.to_string());
app.toolbar_custom.panels.insert(key(tab, panel), ids);
Ok(())
}
/// Remove a command's button from a tab (it stays in its panel's drop-down).
pub fn remove(app: &mut SolveApp, tab: &str, id: &str) {
let Some(panel) = pinned_on(app, tab, id) else { return };
let mut ids = own(app, tab, panel);
ids.retain(|x| x != id);
app.toolbar_custom.panels.insert(key(tab, panel), ids);
}
/// Move a button before another one (`before`: None puts it at the end of `panel`).
pub fn reorder(app: &mut SolveApp, tab: &str, id: &str, panel: &str, before: Option<&str>) {
if let Some(from) = pinned_on(app, tab, id) {
let mut ids = own(app, tab, from);
ids.retain(|x| x != id);
app.toolbar_custom.panels.insert(key(tab, from), ids);
}
let mut ids = own(app, tab, panel);
ids.retain(|x| x != id);
let at = before.and_then(|b| ids.iter().position(|x| x == b)).unwrap_or(ids.len());
ids.insert(at, id.to_string());
app.toolbar_custom.panels.insert(key(tab, panel), ids);
}
/// Reset Toolbar: a tab's panels back to their defaults.
pub fn reset(app: &mut SolveApp, tab: &str) {
let prefix = format!("{WORKSPACE}/{tab}/");
app.toolbar_custom.panels.retain(|k, _| !k.starts_with(&prefix));
}
/// The menu of a command (toolbar button, drop-down row, S box row).
pub fn command_items(app: &SolveApp, id: &str) -> Vec<Item> {
let tab = app.ui.tab.clone();
let on = pinned_on(app, &tab, id).is_some();
let fav = app.sbox.pinned.iter().any(|p| p == id);
let mut v = Vec::new();
v.push(if on {
Item::action("ui.toolbarRemove", "Remove from Toolbar", "").with(json!({ "command": id }))
} else {
Item::action("ui.toolbarPin", "Pin to Toolbar", "").with(json!({ "command": id }))
});
v.push(Item::action("ui.shortcutPin", if fav { "Unpin from Shortcuts" } else { "Pin to Shortcuts" }, "").with(json!({ "command": id })));
v.push(Item::sep());
v.push(Item::action("ui.toolbarReset", "Reset Toolbar", "").with(json!({ "tab": tab })));
v
}
/// Run a toolbar item from a menu.
pub fn run(app: &mut SolveApp, action: &str, p: &Value) {
let tab = p.get("tab").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.tab.clone());
let id = p.get("command").and_then(Value::as_str).unwrap_or("");
match action {
"ui.toolbarPin" => {
if let Err(e) = pin(app, &tab, id) {
app.set_status(e, true);
}
}
"ui.toolbarRemove" => remove(app, &tab, id),
"ui.toolbarReset" => reset(app, &tab),
"ui.shortcutPin" => app.sbox.toggle_pin(id),
_ => {}
}
}
/// Shortcut keys, S box favourites and the toolbar as one file (Keyboard Shortcuts › Export).
pub fn export(app: &SolveApp) -> Value {
json!({ "format": "solvecraft-shortcuts/1", "shortcuts": app.keymap.prefs(), "favourites": app.sbox.pinned, "toolbar": app.toolbar_custom.prefs() })
}
pub fn import(app: &mut SolveApp, v: &Value) -> Result<(), String> {
if v.get("format").and_then(Value::as_str) != Some("solvecraft-shortcuts/1") {
return Err("not a SolveCraft shortcuts file".into());
}
if let Some(s) = v.get("shortcuts") {
app.keymap.load(s);
}
if let Some(f) = v.get("favourites").and_then(Value::as_array) {
app.sbox.pinned = f.iter().take(64).filter_map(|x| x.as_str().map(str::to_string)).collect();
}
if let Some(t) = v.get("toolbar") {
app.toolbar_custom.load(t);
}
Ok(())
}
-29
View File
@@ -242,38 +242,9 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec<String> {
.ok()
.map(|v| v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect())
.unwrap_or_default();
tie_snaps(app, t, &curves);
curves
}
/// A click that snapped to an existing point (the origin, a corner) but went in as plain
/// coordinates: tie the new shape's point there to it, as Fusion does.
fn tie_snaps(app: &mut SolveApp, t: &Tool, curves: &[String]) {
let mut ties = Vec::new();
{
let st = app.session.world_state();
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return };
let sk = &ss.sketch;
let new_pts: Vec<usize> =
curves.iter().filter_map(|c| sk.curve_index(c)).flat_map(|i| sk.curves.get(i).map(|c| c.kind.point_ids()).unwrap_or_default()).collect();
for (at, r) in &t.pts {
let Some(r) = r else { continue };
let Some(target) = sk.resolve_point(r) else { continue };
if new_pts.contains(&target) {
continue;
}
if let Some(q) = new_pts.iter().copied().find(|q| sk.point(*q).is_some_and(|p| p.dist(*at) < 1e-6))
&& let Some(qid) = sk.points.get(q).map(|p| p.id.clone())
{
ties.push((r.clone(), qid));
}
}
}
for (a, b) in ties {
let _ = app.run("sketch.constraint.coincident", json!({"a": a, "b": b}));
}
}
fn run_pick(app: &mut SolveApp, t: &Tool) {
let k = &t.picks;
let params = match t.cmd {
+270 -61
View File
@@ -6,7 +6,7 @@ use serde_json::json;
use solvecraft_engine::Sel;
use solvecraft_engine::geom::{Vec2, Vec3};
use solvecraft_engine::render::{Camera, Mat4, Rgb};
use solvecraft_engine::sketch::{ConstraintKind, CurveKind};
use solvecraft_engine::sketch::CurveKind;
use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines};
use std::hash::{Hash, Hasher};
@@ -125,6 +125,44 @@ impl Proj {
}
}
/// A projected 3D face (convex): an unfeathered triangle fan, outlined one edge at a time (no
/// joins between the edges). Unlike `Shape::convex_polygon`, it stays inside its outline when
/// the face turns edge-on.
pub(crate) fn projected_polygon(pts: &[Pos2], fill: Color32, stroke: Stroke) -> Vec<Shape> {
let mut mesh = egui::Mesh::default();
for (i, p) in (0u32..).zip(pts) {
mesh.colored_vertex(*p, fill);
if i >= 2 {
mesh.add_triangle(0, i - 1, i);
}
}
let mut out = vec![Shape::mesh(mesh)];
out.extend(pts.iter().zip(pts.iter().skip(1).chain(pts.first())).map(|(a, b)| Shape::line_segment([*a, *b], stroke)));
out
}
/// View directions all round, a few degrees apart, none exactly along an axis.
#[cfg(test)]
pub(crate) fn orbit_directions() -> impl Iterator<Item = Vec3> {
(0..90).flat_map(|i| {
(0..45).map(move |j| {
let (yaw, pitch) = (f64::from(i) * 4.0f64.to_radians() + 0.003, f64::from(j) * 4.0f64.to_radians() - 1.5);
Vec3::new(pitch.cos() * yaw.cos(), pitch.cos() * yaw.sin(), pitch.sin())
})
})
}
/// Every vertex egui draws for `shapes`.
#[cfg(test)]
pub(crate) fn drawn_points(shapes: Vec<Shape>) -> Vec<Pos2> {
let mut tess = egui::epaint::Tessellator::new(1.0, egui::epaint::TessellationOptions::default(), [1, 1], vec![]);
let mut mesh = egui::Mesh::default();
for shape in shapes {
tess.tessellate_shape(shape, &mut mesh);
}
mesh.vertices.iter().map(|v| v.pos).collect()
}
fn rgba(c: Rgb) -> [u8; 4] {
[c.0, c.1, c.2, 255]
}
@@ -165,6 +203,9 @@ fn scene_key(app: &SolveApp) -> u64 {
app.ui.show_grid.hash(&mut h);
app.ui.visual_style.hash(&mut h);
app.ui.ground_shadow.hash(&mut h);
// The section plane (its cut outlines are part of the model scene).
section_plane(app).map(|(o, n)| [o.x, o.y, o.z, n.x, n.y, n.z].map(f64::to_bits)).hash(&mut h);
app.cam.up.hash(&mut h);
(app.cam.pitch > 0.0).hash(&mut h);
app.ui.show_sketches.hash(&mut h);
app.ui.hidden_bodies.hash(&mut h);
@@ -204,15 +245,18 @@ fn build_scene(app: &SolveApp) -> GpuScene {
if app.ui.show_grid && !active_view(app).hide_grid {
let (minor, major) = grid_step(app.cam.half_height());
let ext = (app.cam.half_height() * 3.0 / major).ceil() * major;
let c = Vec3::new((app.cam.target.x / major).round() * major, (app.cam.target.y / major).round() * major, 0.0);
// The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame).
let tf = app.cam.to_frame(app.cam.target);
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
let w3 = |p: Vec3| app.cam.from_frame(p).to_f32();
let n = ((ext / minor) as i64).clamp(1, 300);
for i in -n..=n {
let t = i as f64 * minor;
let maj = (t / major).round() * major == t;
let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) };
let w = if maj { 1.0 } else { 0.7 };
sc.under_line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w);
sc.under_line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w);
sc.under_line(w3(c + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w);
sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w);
}
}
for b in &st.bodies {
@@ -277,6 +321,15 @@ fn build_scene(app: &SolveApp) -> GpuScene {
if app.ui.ground_shadow && app.ui.visual_style != 2 && app.cam.pitch > 0.0 {
ground_shadow(&mut sc, app, &st);
}
// Section: the outline of each body where the plane cuts it.
if let Some((o, n)) = section_plane(app) {
let col = c4(tk.text.gamma_multiply(0.9));
for b in st.bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(&b.name)) {
for (a, c) in section_segments(&b.mesh(), o, n) {
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
}
}
}
for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue };
let active = s.active_sketch == Some(sid);
@@ -310,9 +363,21 @@ fn build_scene(app: &SolveApp) -> GpuScene {
/// A soft dark ellipse on the ground under the shown bodies (darkest in the middle).
fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) {
// The bodies' box in the camera's Z-up frame (the ground is its XY plane).
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
for x in st.bodies.iter().filter(|x| !app.ui.hidden_bodies.contains(&x.name)) {
b = b.union(&x.mesh().bounds());
let bb = x.mesh().bounds();
if bb.is_empty() {
continue;
}
for i in 0..8 {
let p = Vec3::new(
if i & 1 == 0 { bb.min.x } else { bb.max.x },
if i & 2 == 0 { bb.min.y } else { bb.max.y },
if i & 4 == 0 { bb.min.z } else { bb.max.z },
);
b.add(app.cam.to_frame(p));
}
}
if b.is_empty() {
return;
@@ -330,7 +395,7 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc:
let n = 48;
let at = |r: f64, k: usize| {
let a = k as f64 / n as f64 * std::f64::consts::TAU;
Vec3::new(c.x + rx * r * a.cos(), c.y + ry * r * a.sin(), z).to_f32()
app.cam.from_frame(Vec3::new(c.x + rx * r * a.cos(), c.y + ry * r * a.sin(), z)).to_f32()
};
for w in rings.windows(2) {
let ((r0, a0), (r1, a1)) = (w[0], w[1]);
@@ -349,6 +414,28 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc:
}
}
/// Where the plane through `o` with normal `n` cuts a mesh: one segment per crossed triangle,
/// nudged a hair to the kept side (so the clip leaves them drawn).
pub(crate) fn section_segments(m: &solvecraft_engine::geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec3, Vec3)> {
let eps = 1e-4 * (1.0 + m.bounds().diagonal());
let mut out = Vec::new();
for t in &m.triangles {
let Some(p) = m.tri(t) else { continue };
let d = p.map(|q| n.dot(q - o));
let mut pts = Vec::with_capacity(2);
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
if (d[i] < 0.0) != (d[j] < 0.0) {
let s = d[i] / (d[i] - d[j]);
pts.push(p[i] + (p[j] - p[i]) * s - n * eps);
}
}
if let [a, b] = pts[..] {
out.push((a, b));
}
}
out
}
/// The drawn pieces of a polyline in a dash pattern (on, off, on, off… lengths).
pub(crate) fn dashes(pts: &[Vec3], pattern: &[f64]) -> Vec<(Vec3, Vec3)> {
let mut out = Vec::new();
@@ -390,7 +477,9 @@ pub fn origin_planes(app: &SolveApp) -> Vec<(&'static str, Vec3, [Vec3; 4])> {
if !app.origin_visible() {
return Vec::new();
}
let creating = app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch));
// While the Origin is hidden, Create Sketch may bring the planes back (empty design): then
// every plane; otherwise only those not hidden one by one.
let creating = !app.ui.show_origin && app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch));
origin_quads(origin_size(app.cam.half_height()))
.into_iter()
.filter(|(n, _, _)| creating || !app.ui.hidden_origin.iter().any(|h| h == n))
@@ -724,14 +813,15 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
if let Some(f) = bestf {
surf.push(f);
}
// Planes give way to a face or profile at the same depth (a floor on the XZ plane).
for (name, _, q) in origin_planes(app) {
if let Some(t) = ray_quad(o, d, &q) {
surf.push((t, Hit::Plane { name: name.into(), point: o + d * t }));
surf.push((t + slack, Hit::Plane { name: name.into(), point: o + d * t }));
}
}
for (name, _, q) in construction_quads(app) {
if let Some(t) = ray_quad(o, d, &q) {
surf.push((t, Hit::Plane { name, point: o + d * t }));
surf.push((t + slack, Hit::Plane { name, point: o + d * t }));
}
}
surf.sort_by(|a, b| a.0.total_cmp(&b.0));
@@ -910,12 +1000,29 @@ fn face_tris(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, face: usize,
}
}
/// A highlighted edge: a halo and a core, and where the model hides it, the core dimmed (so a
/// pick on the far side still shows).
fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f32) {
for (col, width) in [(halo, w + 3.0), (core, w)] {
for p in e.windows(2) {
sc.line(p[0].to_f32(), p[1].to_f32(), c4(col), width, false);
}
}
for p in e.windows(2) {
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(core.gamma_multiply(0.45)), (w * 0.7).max(1.0));
}
}
/// The outline of a highlighted face where the model hides it (dimmed).
fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, face: usize, col: Color32) {
for (ei, e) in m.edges.iter().enumerate() {
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face));
if on && !m.seams.get(ei).copied().unwrap_or(false) {
for p in e.windows(2) {
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4);
}
}
}
}
fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
@@ -1045,6 +1152,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
// A live preview stands in for the body: its old faces would fight with it.
if let Some(b) = st.body(body).filter(|_| !app.preview.replaced.contains(body)) {
face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false);
hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge);
}
}
Sel::Body { name } => {
@@ -1255,7 +1363,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let h = rect.height().max(1.0) as f64;
if inside && scroll != 0.0 {
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
let anchor = hover.map(|p| {
// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre).
let anchor = hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| {
let proj = projection(app, rect);
// Toward the model's surface under the cursor, else the target's depth.
if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) {
@@ -1775,7 +1884,7 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
}
}
/// Sketch points, dimension labels and constraint glyphs of the active sketch.
/// Sketch points and the status of the active sketch (constraint glyphs: `sketch_tools`).
fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let t = Tokens::get();
let st = app.session.world_state();
@@ -1803,9 +1912,6 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
}
}
for c in sk.constraints.iter().filter(|c| !c.kind.is_dimension() && !sk.view.hide_constraints) {
glyph(painter, proj, ss, &c.kind);
}
// Sketch status.
if let Some(r) = app.viewport.rect {
let msg = if !ss.report.ok() {
@@ -1820,35 +1926,6 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
}
}
fn line_mid(sk: &solvecraft_engine::sketch::Sketch, l: usize) -> Option<(Vec2, Vec2)> {
match sk.curves.get(l)?.kind {
CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)),
_ => None,
}
}
/// Small constraint markers next to the constrained geometry.
fn glyph(painter: &egui::Painter, proj: &Proj, ss: &solvecraft_engine::doc::SolvedSketch, k: &ConstraintKind) {
use ConstraintKind::*;
let sk = &ss.sketch;
let (anchor, label) = match *k {
Horizontal { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "H"),
Vertical { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "V"),
Parallel { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "//"),
Perpendicular { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "L"),
Tangent { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "T"),
Equal { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "="),
Fix { p } => (sk.point(p), "F"),
_ => (None, ""),
};
let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { return };
let r = Rect::from_center_size(a + vec2(10.0, -10.0), vec2(13.0, 13.0));
let t = Tokens::get();
painter.rect_filled(r, 2.0, t.glyph_bg);
painter.rect_stroke(r, 2.0, Stroke::new(1.0, t.glyph_edge), egui::StrokeKind::Inside);
painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(10.0), t.glyph_text);
}
fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) {
let mut h = std::collections::hash_map::DefaultHasher::new();
scene_key(app).hash(&mut h);
@@ -1873,7 +1950,7 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
/// Where a point in view-cube units (faces at ±1) is drawn.
pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 {
let (r, u, _) = app.cam.basis();
let (r, u, _) = cube_basis(&app.cam);
pos2(rect.right() - 80.0 + (p.dot(r) * CUBE_SCALE) as f32, rect.top() + 80.0 - (p.dot(u) * CUBE_SCALE) as f32)
}
@@ -1884,29 +1961,46 @@ const CUBE_SCALE: f64 = 32.0;
/// (orthographic views) and the 8 corner facets (iso views); the edges between them are not
/// targets. Hover lights the face or facet under the pointer, a click turns the view to it, the
/// house goes home.
/// The camera's basis in its up-axis frame (Z up): the cube's TOP is the world's up, X, Y or Z.
fn cube_basis(cam: &Camera) -> (Vec3, Vec3, Vec3) {
let (r, u, b) = cam.basis();
(cam.to_frame(r), cam.to_frame(u), cam.to_frame(b))
}
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get();
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
let (r, u, b) = app.cam.basis();
let (r, u, b) = cube_basis(&app.cam);
let (wr, wu, _) = app.cam.basis();
let painter = ui.painter_at(rect);
let hover = ui.input(|i| i.pointer.hover_pos());
let targets = cube_targets();
let shapes = cube_projected(&targets, (r, u, b), c);
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
// Axis triad from the cube's back corner, drawn first: the cube hides what is behind it.
// The corner is in the cube's frame, the axes are the world's.
let o = Vec3::new(-1.0, -1.0, -1.0);
let at = pos2(c.x + (o.dot(r) * CUBE_SCALE) as f32, c.y - (o.dot(u) * CUBE_SCALE) as f32);
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32);
painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
}
// Back to front: the faces and facets turned toward the camera.
for (i, pts) in shapes.iter().rev() {
let tg = &targets[*i];
let n = tg.normal();
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) };
painter.add(Shape::convex_polygon(pts.clone(), fill.to_opaque(), Stroke::new(1.0, t.cube_edge)));
painter.extend(projected_polygon(pts, fill.to_opaque(), Stroke::new(1.0, t.cube_edge)));
if hovered == Some(*i) {
painter.add(Shape::convex_polygon(pts.clone(), t.accent_soft, Stroke::new(1.0, t.accent)));
painter.extend(projected_polygon(pts, t.accent_soft, Stroke::new(1.0, t.accent)));
}
let centre = cube_centroid(pts);
crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre);
if !tg.corner && n.dot(b) > 0.35 {
painter.text(centre, Align2::CENTER_CENTER, tg.name.to_uppercase(), FontId::proportional(9.5), Color32::from_rgb(40, 44, 52));
// Fades out as the face turns edge-on.
let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32;
if !tg.corner && ink > 0.05 {
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), n, (r, u), c, Color32::from_rgb(40, 44, 52).gamma_multiply(ink)));
}
}
crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some())));
@@ -1915,12 +2009,6 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
}
// Clicked: the view from the face's or facet's direction (target → eye).
let clicked = hovered.filter(|_| ui.input(|i| i.pointer.primary_clicked())).map(|i| targets[i].dir);
// Axis triad at the cube's corner.
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
let o = Vec3::new(-1.0, -1.0, -1.0);
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
painter.line_segment([to2(o), to2(o + d * 2.6)], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
}
let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0));
let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click());
icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent);
@@ -1928,7 +2016,11 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
app.animate_view("home");
}
if let Some(dir) = clicked {
let to = app.cam.looking_from(dir);
let mut to = app.cam.looking_from(app.cam.from_frame(dir));
// TOP and BOTTOM turn to the standard plan views (front edge at the bottom).
if dir.x == 0.0 && dir.y == 0.0 {
to.yaw = 0.0;
}
app.animate_to(to);
}
}
@@ -1987,16 +2079,44 @@ pub(crate) fn cube_targets() -> Vec<CubeTarget> {
out
}
/// Where a point in cube units is drawn, for the camera's right and up `ru` and the cube's
/// centre `c`.
fn cube_to_screen((r, u): (Vec3, Vec3), c: Pos2, p: Vec3) -> Pos2 {
pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32)
}
/// A face's text directions (right, up): read from outside the cube, and upright in the view
/// clicking the face turns to (TOP and BOTTOM: the plan views, front edge at the bottom).
fn cube_label_axes(n: Vec3) -> (Vec3, Vec3) {
if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) }
}
/// A face's label printed on the face: laid out flat, then mapped through the cube's projection,
/// so it turns and foreshortens with the face.
fn cube_label(ctx: &egui::Context, text: &str, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(9.5), color, f32::INFINITY));
let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() };
let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]);
let mut mesh = egui::Mesh::default();
let at = (-galley.size() / 2.0).to_pos2();
tess.tessellate_text(&egui::epaint::TextShape::new(at, galley, color), &mut mesh);
let (right, up) = cube_label_axes(n);
for v in &mut mesh.vertices {
let p = n + right * (f64::from(v.pos.x) / CUBE_SCALE) - up * (f64::from(v.pos.y) / CUBE_SCALE);
v.pos = cube_to_screen(ru, c, p);
}
Shape::mesh(mesh)
}
/// The targets turned toward the camera (basis `rub`), front first, as screen polygons around
/// the cube's centre `c`.
pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec<Pos2>)> {
let (r, u, b) = rub;
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
let mut v: Vec<(usize, f64, Vec<Pos2>)> = targets
.iter()
.enumerate()
.filter(|(_, tg)| tg.normal().dot(b) > 1e-3)
.map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| to2(*p)).collect()))
.map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| cube_to_screen((r, u), c, *p)).collect()))
.collect();
v.sort_by(|a, b2| b2.1.total_cmp(&a.1));
v.into_iter().map(|(i, _, p)| (i, p)).collect()
@@ -2155,9 +2275,7 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
ui.add_enabled_ui(s.show, |ui| {
ui.horizontal(|ui| {
ui.label("Speed");
for sp in crate::agent_cursor::Speed::ALL {
ui.selectable_value(&mut s.speed, sp, sp.label());
}
crate::agent_cursor::speed_slider(ui, &mut s.speed);
});
ui.checkbox(&mut s.follow_camera, "Follow camera (Look At sketches)");
});
@@ -2172,6 +2290,37 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
mod tests {
use super::*;
#[test]
fn section_outlines_follow_the_cut() {
let mut s = solvecraft_engine::Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let st = s.model.state();
let m = st.bodies[0].mesh();
let (o, n) = (Vec3::new(0.0, 15.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
let segs = section_segments(&m, o, n);
assert!(segs.len() >= 4, "{segs:?}");
// On the plane (a hair to the kept side) and around the 40 x 20 cut.
let mut len = 0.0;
for (a, b) in &segs {
assert!((a.y - 15.0).abs() < 0.01 && (b.y - 15.0).abs() < 0.01);
len += a.dist(*b);
}
assert!((len - 120.0).abs() < 0.5, "outline length {len}");
// A plane that misses the body cuts nothing.
assert!(section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty());
}
#[test]
fn selection_shows_where_the_model_hides_it() {
let mut s = solvecraft_engine::Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
assert!(build_highlight(&app).hidden.is_empty());
// The back face: its outline is drawn (dimmed) where the box hides it.
app.session.selection = vec![Sel::Face { body: "Body1".into(), index: 3, point: Vec3::new(20.0, 30.0, 10.0) }];
assert!(!build_highlight(&app).hidden.is_empty());
}
/// A camera basis looking from `dir` (target → eye), as the view cube draws with.
fn basis_from(dir: Vec3) -> (Vec3, Vec3, Vec3) {
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
@@ -2196,6 +2345,66 @@ mod tests {
assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None);
}
/// From any direction, including faces turned nearly edge-on, everything drawn stays within
/// the cube.
#[test]
fn view_cube_faces_never_spike_past_the_cube() {
let targets = cube_targets();
let c = pos2(100.0, 100.0);
// The cube's circumradius (corner at √3) plus the stroke and feathering.
let limit = (3f64.sqrt() * CUBE_SCALE) as f32 + 3.0;
let mut worst = 0.0f32;
for dir in orbit_directions() {
for (_, pts) in cube_projected(&targets, basis_from(dir), c) {
for p in drawn_points(projected_polygon(&pts, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) {
worst = worst.max(p.distance(c));
}
}
}
assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})");
}
#[test]
fn view_cube_labels_read_upright_in_their_own_view() {
for tg in cube_targets().iter().filter(|t| !t.corner) {
// The view clicking the face turns to (TOP and BOTTOM with yaw 0).
let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir);
if tg.dir.z.abs() > 0.5 {
cam.yaw = 0.0;
}
let (r, u, _) = cam.basis();
let (right, up) = cube_label_axes(tg.dir);
assert!(right.dot(r) > 0.99 && up.dot(u) > 0.99, "{}: right·r {} up·u {}", tg.name, right.dot(r), up.dot(u));
}
}
/// From any direction, each label is printed within its face.
#[test]
fn view_cube_labels_stay_on_their_faces() {
let ctx = egui::Context::default();
let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let targets = cube_targets();
let c = pos2(100.0, 100.0);
for dir in orbit_directions().step_by(7) {
let (r, u, b) = basis_from(dir);
for (i, pts) in cube_projected(&targets, (r, u, b), c) {
let tg = &targets[i];
if tg.corner {
continue;
}
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), tg.normal(), (r, u), c, Color32::BLACK)]) {
let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) };
worst = worst.max(outside);
drawn += 1;
}
}
}
});
assert!(drawn > 10_000, "only {drawn} label vertices drawn");
assert!(worst <= 0.5, "a label vertex {worst} px outside its face");
}
#[test]
fn view_cube_edges_are_not_targets() {
let targets = cube_targets();
@@ -0,0 +1,27 @@
[
{
"pending": "#40: offsets of projected geometry get new ids on recompute, the lid's lip fails"
},
{
"note": "Acceptance (#27): the board grows 60 \u2192 70 mm; the lid's lip (offsets of the case's projected rim) follows."
},
{
"script": "examples/acceptance/sensor_enclosure.json"
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "board_w",
"expression": "70 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
}
]
@@ -0,0 +1,30 @@
[
{
"note": "Acceptance (#27): a lip on the case's rounded rim with the Lip command."
},
{
"script": "examples/acceptance/sensor_enclosure.json"
},
{
"call": "engine.execute",
"params": {
"command": "plastic.lip",
"params": {
"face": [
0,
29,
25
],
"width": 1,
"height": 1.5,
"body": "Case"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
}
]
@@ -0,0 +1,26 @@
[
{
"note": "Acceptance (#27): suppressing the PCB standoff takes the patterned copies with it."
},
{
"script": "examples/acceptance/sensor_enclosure.json"
},
{
"call": "engine.execute",
"params": {
"command": "timeline.suppress",
"params": {
"feature": "Standoff",
"suppressed": true
}
}
},
{
"expect": {
"body_volume": {
"body": "Case",
"volume": 24934.4
}
}
}
]
@@ -0,0 +1,145 @@
[
{
"note": "Acceptance (#27): the sensor enclosure built from examples/acceptance/sensor_enclosure.json, then edited: the USB cut-out moved, the height changed, features suppressed and back, saved and reopened, exported to STEP and read back."
},
{
"script": "examples/acceptance/sensor_enclosure.json"
},
{
"call": "ui.view",
"params": {
"view": "home"
}
},
{
"expect": {
"bodies": 2,
"errors": 0,
"body_volume": {
"body": "Case",
"volume": 25401.7
}
}
},
{
"expect": {
"body_volume": {
"body": "Lid",
"volume": 19629.7
}
}
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "usb_offset",
"expression": "15 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2,
"body_volume": {
"body": "Case",
"volume": 25401.7
}
}
},
{
"call": "engine.execute",
"params": {
"command": "timeline.suppress",
"params": {
"feature": "SnapFit1",
"suppressed": true
}
}
},
{
"expect": {
"errors": 0,
"body_volume": {
"body": "Case",
"volume": 25340.5
}
}
},
{
"call": "engine.execute",
"params": {
"command": "timeline.suppress",
"params": {
"feature": "SnapFit1",
"suppressed": false
}
}
},
{
"expect": {
"errors": 0,
"body_volume": {
"body": "Case",
"volume": 25401.7
}
}
},
{
"call": "engine.execute",
"params": {
"command": "file.save_as",
"params": {
"path": "$DIR/enclosure.solvecraft"
}
}
},
{
"call": "engine.execute",
"params": {
"command": "doc.open",
"params": {
"path": "$DIR/enclosure.solvecraft"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2,
"body_volume": {
"body": "Case",
"volume": 25401.7
}
}
},
{
"call": "engine.execute",
"params": {
"command": "file.export",
"params": {
"path": "$DIR/enclosure.step"
}
}
},
{
"call": "engine.execute",
"params": {
"command": "doc.open",
"params": {
"path": "$DIR/enclosure.step"
}
}
},
{
"expect": {
"bodies": 2,
"body_volume": {
"body": "Case",
"volume": 25401.7
}
}
}
]
@@ -0,0 +1,44 @@
[
{
"pending": "#41: a snap fit stays at its old point when its wall moves (a floating body)"
},
{
"note": "Acceptance (#27): the board grows 40 \u2192 50 mm deep; the snap fit stays on the moved front wall."
},
{
"script": "examples/acceptance/sensor_enclosure.json"
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "board_d",
"expression": "50 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
},
{
"expect": {
"bbox": {
"body": "Case",
"min": [
-40,
-35,
0
],
"max": [
40,
35,
25
]
}
}
}
]
@@ -1,17 +1,32 @@
[
{"note": "#35: with the agent cursor on, an agent's commands are acted out (the reply waits for the pointer) and give the same result as with it off"},
{"call": "ui.agent_cursor", "params": {"show": true, "speed": "normal", "follow_camera": true}},
{"note": "#35, #79: with the agent cursor on, an agent's commands are acted out (the reply waits for the pointer) at 1x and 3x; at Instant they run at once. Each speed builds the same body as the cursor being off."},
{"call": "ui.agent_cursor", "params": {"show": true, "speed": 1, "follow_camera": true}},
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "min_frames": 30},
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "min_frames": 45},
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "min_frames": 30},
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "min_frames": 30},
{"expect": {"errors": 0, "bodies": 1, "volume": [10000, 1e-6]}},
{"note": "3x: still acted out, about three times as fast"},
{"call": "ui.agent_cursor", "params": {"speed": 3}},
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "min_frames": 8, "max_frames": 45},
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "min_frames": 8},
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "min_frames": 8},
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "min_frames": 8},
{"expect": {"errors": 0, "bodies": 2, "volume": [20000, 1e-6]}},
{"note": "Instant: answered on the next frame, no animation"},
{"call": "ui.agent_cursor", "params": {"speed": "instant"}},
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "max_frames": 2},
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "max_frames": 2},
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "max_frames": 2},
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "max_frames": 2},
{"expect": {"errors": 0, "bodies": 3, "volume": [30000, 1e-6]}},
{"note": "A call with animate: false runs at once"},
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}, "animate": false}},
{"call": "ui.agent_cursor", "params": {"speed": 1}},
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}, "animate": false}, "max_frames": 2},
{"call": "ui.agent_cursor", "params": {"show": false}},
{"note": "The same steps with the cursor off: a second, identical body"},
{"note": "The same steps with the cursor off: a fourth, identical body"},
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}},
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}},
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}},
{"expect": {"errors": 0, "bodies": 2, "volume": [20000, 1e-6]}}
{"expect": {"errors": 0, "bodies": 4, "volume": [40000, 1e-6]}}
]
@@ -0,0 +1,505 @@
[
{
"note": "A design with every kind of browser folder: bodies in a group, a component, a sketch, a construction plane, a canvas and named views."
},
{
"call": "engine.execute",
"params": {
"command": "solid.box",
"params": {
"length": 10,
"width": 10,
"height": 10
}
}
},
{
"call": "engine.execute",
"params": {
"command": "solid.box",
"params": {
"length": 10,
"width": 10,
"height": 10,
"corner": [
30,
0,
0
]
}
}
},
{
"call": "engine.execute",
"params": {
"command": "solid.box",
"params": {
"length": 10,
"width": 10,
"height": 10,
"corner": [
60,
0,
0
]
}
}
},
{
"call": "engine.execute",
"params": {
"command": "component.from_bodies",
"params": {
"bodies": [
"Body3"
]
}
}
},
{
"call": "engine.execute",
"params": {
"command": "browser.group",
"params": {
"folder": "bodies",
"items": [
"Body1"
]
}
}
},
{
"call": "engine.execute",
"params": {
"command": "construct.plane.offset",
"params": {
"base": "XY",
"offset": 20
}
}
},
{
"call": "engine.execute",
"params": {
"command": "sketch.create",
"params": {
"plane": "XZ"
}
}
},
{
"call": "engine.execute",
"params": {
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
5,
5
]
}
}
},
{
"call": "engine.execute",
"params": {
"command": "sketch.finish",
"params": {}
}
},
{
"make_image": "pic.png"
},
{
"choose_file": "pic.png"
},
{
"start": "canvas.insert"
},
{
"click": {
"plane": "XY"
}
},
{
"key": "Escape"
},
{
"call": "ui.set",
"params": {
"dark": true
}
},
{
"note": "Folders that start closed open with their arrow, and close again; the click selects nothing."
},
{
"expect": {
"browser_lacks": [
"Units: mm"
]
}
},
{
"click": {
"chevron": "Document Settings"
}
},
{
"expect": {
"browser_has": [
"Units: mm"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Document Settings"
}
},
{
"expect": {
"browser_lacks": [
"Units: mm"
],
"browser_selected": false
}
},
{
"expect": {
"browser_lacks": [
"Top"
]
}
},
{
"click": {
"chevron": "Named Views"
}
},
{
"expect": {
"browser_has": [
"Top"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Named Views"
}
},
{
"expect": {
"browser_lacks": [
"Top"
],
"browser_selected": false
}
},
{
"expect": {
"browser_lacks": [
"XY"
]
}
},
{
"click": {
"chevron": "Origin"
}
},
{
"expect": {
"browser_has": [
"XY"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Origin"
}
},
{
"expect": {
"browser_lacks": [
"XY"
],
"browser_selected": false
}
},
{
"note": "Folders that start open close with their arrow, and open again."
},
{
"expect": {
"browser_has": [
"Body1"
]
}
},
{
"click": {
"chevron": "Group1 (1)"
}
},
{
"expect": {
"browser_lacks": [
"Body1"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Group1 (1)"
}
},
{
"expect": {
"browser_has": [
"Body1"
],
"browser_selected": false
}
},
{
"expect": {
"browser_has": [
"Sketch1"
]
}
},
{
"click": {
"chevron": "Sketches"
}
},
{
"expect": {
"browser_lacks": [
"Sketch1"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Sketches"
}
},
{
"expect": {
"browser_has": [
"Sketch1"
],
"browser_selected": false
}
},
{
"expect": {
"browser_has": [
"Plane1"
]
}
},
{
"click": {
"chevron": "Construction"
}
},
{
"expect": {
"browser_lacks": [
"Plane1"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Construction"
}
},
{
"expect": {
"browser_has": [
"Plane1"
],
"browser_selected": false
}
},
{
"expect": {
"browser_has": [
"Canvas1"
]
}
},
{
"click": {
"chevron": "Canvases"
}
},
{
"expect": {
"browser_lacks": [
"Canvas1"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Canvases"
}
},
{
"expect": {
"browser_has": [
"Canvas1"
],
"browser_selected": false
}
},
{
"expect": {
"browser_has": [
"Body3"
]
}
},
{
"click": {
"chevron": "Body3:1"
}
},
{
"expect": {
"browser_lacks": [
"Body3"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Body3:1"
}
},
{
"expect": {
"browser_has": [
"Body3"
],
"browser_selected": false
}
},
{
"expect": {
"browser_has": [
"Body2"
]
}
},
{
"click": {
"chevron": "Bodies"
}
},
{
"expect": {
"browser_lacks": [
"Body2"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Bodies"
}
},
{
"expect": {
"browser_has": [
"Body2"
],
"browser_selected": false
}
},
{
"note": "The design's own row closes the whole tree."
},
{
"expect": {
"browser_has": [
"Bodies"
]
}
},
{
"click": {
"chevron": "Untitled"
}
},
{
"expect": {
"browser_lacks": [
"Bodies"
],
"browser_selected": false
}
},
{
"click": {
"chevron": "Untitled"
}
},
{
"expect": {
"browser_has": [
"Bodies"
],
"browser_selected": false
}
},
{
"note": "Clicking the row of a folder (not its arrow) toggles it too; clicking a component's arrow doesn't pick it."
},
{
"click": {
"browser": "Sketches"
}
},
{
"expect": {
"browser_lacks": [
"Sketch1"
]
}
},
{
"click": {
"browser": "Sketches"
}
},
{
"expect": {
"browser_has": [
"Sketch1"
]
}
},
{
"shot": "browser_folds"
}
]
@@ -0,0 +1,14 @@
[
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}},
{"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 20], "connect": false}}},
{"call": "engine.execute", "params": {"command": "sketch.circle.center", "params": {"center": [60, 10], "radius": 5}}},
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[45, -10], [75, -10]]}}},
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[45, 30], [75, 30]]}}},
{"call": "engine.execute", "params": {"command": "sketch.constraint.parallel", "params": {"a": "l5", "b": "l6"}}},
{"call": "engine.execute", "params": {"command": "sketch.constraint.tangent", "params": {"a": "l5", "b": "c1"}}},
{"call": "engine.execute", "params": {"command": "sketch.constraint.tangent", "params": {"a": "l6", "b": "c1"}}},
{"call": "engine.execute", "params": {"command": "sketch.constraint.equal", "params": {"a": "l5", "b": "l6"}}},
{"call": "ui.view", "params": {"view": "top"}},
{"call": "ui.view", "params": {"view": "fit"}},
{"expect": {"glyphs": {"k1": 1, "k5": 2, "k6": 2, "k7": 2, "k8": 2}}}
]
@@ -0,0 +1,81 @@
[
{
"note": "#31: a face wins a tie with an origin plane through it (a shelled box's floor where the XZ plane crosses it)."
},
{
"call": "engine.execute",
"params": {
"command": "solid.box",
"params": {
"corner": [
-10,
-10,
0
],
"length": 20,
"width": 20,
"height": 6
}
}
},
{
"call": "engine.execute",
"params": {
"command": "solid.shell",
"params": {
"faces": [
[
0,
0,
6
]
],
"thickness": 2
}
}
},
{
"expect": {
"volume": [
1376,
0.001
]
}
},
{
"call": "ui.view",
"params": {
"view": "iso"
}
},
{
"start": "sketch.create"
},
{
"click": {
"world": [
0,
0,
2
]
}
},
{
"expect": {
"sketching": true
}
},
{
"expect": {
"query": {
"command": "sketch.inspect",
"path": [
"plane",
"origin",
2
],
"value": 2
}
}
}
]
@@ -0,0 +1,91 @@
[
{
"note": "#31: with the Origin hidden, its planes are not pickable in Create Sketch (a body is shown, so they don't come back)."
},
{
"call": "engine.execute",
"params": {
"command": "solid.box",
"params": {
"corner": [
6,
-15,
0
],
"length": 4,
"width": 4,
"height": 4
}
}
},
{
"call": "engine.execute",
"params": {
"command": "solid.box",
"params": {
"corner": [
-15,
10,
-5
],
"length": 3,
"width": 3,
"height": 3
}
}
},
{
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"call": "ui.view",
"params": {
"view": "iso"
}
},
{
"start": "sketch.create"
},
{
"click": {
"plane": "YZ"
}
},
{
"expect": {
"sketching": true
}
},
{
"start": "sketch.finish"
},
{
"call": "ui.set",
"params": {
"showOrigin": false
}
},
{
"start": "sketch.create"
},
{
"click": {
"world": [
0,
3,
3
]
}
},
{
"expect": {
"sketching": false
}
},
{
"key": "Escape"
}
]

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