Compare commits

...

186 Commits

Author SHA1 Message Date
bherbruck 4ac803ab52 Release SolveCraft 0.1.4 2026-10-10 18:21:13 -04:00
bherbruck 63fd838f55 Fix hover highlight flicker during viewport navigation
Resolve hover before rendering and suppress it throughout navigation gestures and animated views. Closes #92.
2026-10-10 18:14:53 -04:00
bherbruck 71a4f3061b Fix document and toolbar tab styling 2026-10-10 18:00:37 -04:00
CypherNaugh_0x 1227fab860 Extrude's start_offset says which way it goes: along the sketch normal, also with direction: negative (where a positive offset starts on the far side of the sketch from the extrusion)
The params doc listed start_offset? with no description, and an extrude in direction negative with a positive start offset surprised a user. The behaviour is unchanged (an offset start plane, as in Fusion); the params doc of solid.extrude and the Extent field doc say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x 9a2a3cef8b A sweep along a path with sharp corners follows the corners: sweeping a rim profile along three lines (an open rectangle) gave a body of volume -0.0006 mm3 with its centre of mass at x = -3.5e9, and no error
kernel::sweep swept segment by segment: a line translates the profile along it, an arc turns it. At a sharp corner the next line runs within the plane the profile has reached, so the profile slid sideways in its own plane and the walls folded into an inside-out, zero-volume shell. Paths whose direction changes at a joint (more than 1e-3 rad) are now swept as a polyline by sweep_path, which mitres the corners; arcs become points within 0.01 mm. Smooth paths (lines and tangent arcs) are swept as before. Pipe uses the same path.

Tests: sweep_round_a_sharp_corner_is_mitred sweeps a 2 x 2 square along an L of two 10 mm lines and checks the volume, 80 mm3 (fails on main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x 3086c32a9a A round hole elsewhere on a body no longer breaks a loft cut beside it: a loft cut from a pocket floor (556.68 x 99 down 4 mm to 80 x 80), with a 35 mm hole 66 mm away that it never touches, failed with a kernel panic in TroughFall, while the same cut without the hole, or with the hole made afterwards, worked
The loft's top lies in the pocket floor. truck's boolean cannot take coincident faces, and the exact polygon fallback (planar_boolean) only takes bodies whose faces are all planar, so the hole's cylinder ruled out the only path that handles this cut. planar_boolean now first tries a sliced form: when an axis-aligned slab holds the tool and none of the body's curved faces, the body is split on the slab's planes (planes clear of the body's own faces), the planar middle piece takes the exact boolean, and the pieces outside are glued back on along the cut faces (the middle piece is healed first so its edges match theirs). Pieces in several parts are refused rather than glued into one shell. split_by_plane's half-space code is now ops::half_space, which this uses.

Tests: loft_cut_beside_a_round_hole replays examples/regressions/loft_cut_beside_round_hole.json and checks the exact volume (slab - pocket - prismatoid - hole; fails on main). With the hole made after the loft, the body is the same (volume, area, 19 faces, 42 edges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x 5249ae84ef Moving a body that a boolean made no longer hangs: a boolean between a cylinder and a tilted plane (an elliptical edge) froze the app at 100 % CPU, because its retries move both bodies and the result back
Moving a body transforms its intersection-curve edges, and truck-geometry 0.5.0 scales each curve's tolerance by the operator norm of the matrix. matext4cgmath 0.1.0 computes it from eigenvalues through a quartic whose Newton refinement loops while the residual is above a tolerance and never gives up. For a translation matrix all four eigenvalues are 1, and near that quadruple root the iteration can cycle forever (about one translation in eight of the boolean retries' shifts, by a scan of sizes 0.1-2000 mm). matext4cgmath is vendored with both Newton loops stopped after 100 steps (vendor/README.md, ATTRIBUTION.md).

truck master no longer computes the norm in transform_by, so the vendored copy can go once a truck release after 0.5.0 is adopted.

Tests: moving_a_body_with_an_intersection_curve_edge_ends (kernel) cuts a cylinder with a tilted box and moves the result by one of the shifts that hung (hangs on main). examples/regressions/oblique_cut_of_cylinder_hang.json is the variant of the oblique cut that hung the app; it hangs on main, and since the previous commit it passes without reaching the shifted retries, so it is replayed by the engine test as a scenario only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x f3a689f284 A thin part trimmed by a long one is no longer refused as an implausible result: a 437 mm bar with a 3 mm concave arc in its profile, cut by a 920 mm prism with a top tilted 21 degrees, failed with "implausible result volume 3870" (it was 3694 before the cut)
The boolean checks its result by meshing both operands and the result, then sampling membership densely where the operands' boxes overlap. The mesh step came from the larger body's size (0.46 mm here), which facets the bar's 3 mm arc so coarsely that two meshes of the same arc disagree over a few percent of the thin overlap box, and a correct cut failed the 0.4 % mismatch limit. The step now also follows the smallest side of the overlap box (2e-3 of it), never finer than 1e-5 of the size, so it only changes where the bodies meet in a region much thinner than they are. truck's first result for the cut is then accepted, and it matches a split of the bar by the tilted plane.

Tests: oblique_cut_through_a_small_arc_matches_a_split replays examples/regressions/oblique_cut_of_cylinder.json and compares with solid.split_body by the same plane (fails on main). The full suite runs no slower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x f17ff913d6 A panic while healing a boolean result no longer ends the boolean: a shallow, wide loft cut from a top face (556.68 x 99 down 4 mm to 80 x 80 in a 900 x 480 x 160 slab) failed with "called Option::unwrap() on a None value"
heal_keep evaluated the result's intersection curves outside any guard. truck panics where such a curve no longer evaluates, and the panic escaped boolean_whole, so the retries and fallbacks that would have succeeded never ran. heal_keep now runs under guard and returns the solid unhealed when healing panics (as its doc already promised for failures), and the boolean builds its result body under guard too, so a result that cannot be built counts as a failed attempt.

Tests: shallow_loft_cut_from_the_top_face replays examples/regressions/shallow_loft_cut_from_top_face.json and checks the exact prismatoid volume (fails on main with the panic).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
bherbruck 6f16b31771 Fix viewport input leaking through dialogs
Use egui pointer ownership for viewport navigation and picking, view-cube
interaction, and sketch-dimension hover. Cover scrolling, model selection,
and cube interaction under floating dialogs, including after they close.
Shield constraint glyph and move-gizmo highlights from overlapping UI too.

Clear strict Clippy warnings and use the same saved path in the recent-file
scenario on Windows and Unix.

Fixes #87
2026-10-10 17:34:14 -04:00
CypherNaugh_0x ab23fc9906 A failed MCP batch goes back to where it started however long the undo history is: rollback undid as many steps as the undo history had grown, but the history is capped at 200 steps, so once it was full a failed batch reported undone: 0 and left its sketches behind (an empty Sketch1, two sketches named TapHole). New commands edit.checkpoint and edit.restore_checkpoint (forget: true drops one) remember and restore the design, the active sketch and the undo history; batch takes a checkpoint before its first step, restores it on a failure and forgets it on success. Against an app without checkpoints batch falls back to counting undo steps 2026-10-10 17:21:25 -04:00
CypherNaugh_0x 54a86dc6f5 Deleting a sketch deletes every feature built from it: a pipe whose path sketch was deleted stayed in the timeline as a broken feature ("unknown sketch"), and so did holes drilled at sketch points and patches filling sketch regions. Which sketches a feature uses now comes from one place, FeatureKind::input_sketches, a match over every kind with no wildcard arm, so a new feature kind does not compile until it says which sketches it uses; a test reads the FeatureKind definition and fails when a kind with a sketch field is listed as using none (it catches a sketch field added to an existing kind). timeline.dependents reports the same features 2026-10-10 17:21:25 -04:00
bherbruck acf4669f73 fix: select complete circular rims and curved surfaces in 3D
Group kernel pieces across circular rims and hidden surface seams for hovering, selection, dialogs, and measurement. Preserve physical references and cover full-item toggling and picking with regression tests.

Fixes #88
2026-10-10 17:13:36 -04:00
bherbruck e042a7b555 ci: cache native Linux test compilation with sccache (#93)
* ci: cache native test compilation with sccache

* ci: preserve workflow line endings
2026-10-10 17:07:18 -04:00
Dylan M. Taylor 96ee326424 ci: drop pages: write permission from Flatpak repository build job 2026-10-10 16:41:29 -04:00
bherbruck d6cdec40da Fix modelling regressions and reconcile WIP UI work 2026-10-10 15:37:00 -04:00
CypherNaugh_0x 49bd89de13 Parameter and feature edits report the features they restored from the evaluation cache: setting a parameter back (or a timeline.edit back) reused the earlier results for the same inputs, so the geometry changed back but recomputed said 0, which read as "nothing rebuilt". The evaluator now counts cache hits as last_restored, and parameters.change, timeline.edit, timeline.redefine and solid.press_pull on a fillet or chamfer face report it as restored next to recomputed (params docs, the MCP set_parameter description and docs/mcp.md say what each counts). Tests: setting a parameter back and a feature edit back report restored, in the evaluator and through the commands 2026-10-10 15:36:42 -04:00
CypherNaugh_0x 90ce5261b3 An angle dimension between two lines takes the reading nearest its value and reports degrees: without text_at the angle was measured between the lines' directions, so a riser pointing up and an outlet pointing down-left (meeting at 30°, directions 150° apart) asked for 30° swung the outlet round by 120°, which the fillets between them could not follow, and the dimension was refused as over-constraining with a degree of freedom left. Two lines make two angles, θ and 180° − θ; the dimension now takes the one nearer the value (the acute one without a value), and text_at still picks a sector. sketch.dimension reports measured in the dimension's unit (deg for angles, mm for lengths) with a unit field, instead of radians for angles 2026-10-10 14:57:56 -04:00
CypherNaugh_0x 81be4e74b8 browser.visibility hides and shows sketches, bodies, construction geometry and origin items (items: ids or names, or a whole folder; visible: bool), so agents can hide helper sketches over MCP. What is hidden moved from the egui UI state into the engine session (view state: not saved, not an undo step); the desktop eye toggles and menus use it, headless renders and fit leave hidden items out, and document.inspect, sketch.inspect and ui.inspect report visibility. ui.set still accepts hiddenBodies, hiddenOrigin, hiddenSketches and shownSketches. Tests: helper sketches hide and show in the engine (hostile input changes nothing) and through MCP only 2026-10-10 14:33:56 -04:00
CypherNaugh_0x 0321778d38 Perspective views no longer show hidden edges through solids: lines, decals and ghosts were pulled toward the eye by a fixed step in depth, and perspective depth is not linear in distance (near plane at about 0.1 % of the distance), so at the model that step spanned tens of millimetres and hole bottoms, the far side of bores and the back edges showed through the part (and through section caps). In perspective the point now moves toward the eye by a fraction of its distance (about 0.2 mm at 140 mm, like the orthographic step); orthographic is unchanged. Not covered by an automated test (no GPU in the test suite); checked in the app with before/after screenshots 2026-10-10 13:51:00 -04:00
CypherNaugh_0x b5fd55cce6 Section Analysis cut faces hide the inside of the body: the cap was drawn on the far walls' back faces at their own depth, so every edge between the cut and the far wall (hole and boss edges, corner seams) showed through it like a pane of glass. While a section is shown the solids mark in the stencil where the cap is the visible surface, then draw the cap again there at the depth where the view ray meets the section plane (orthographic and perspective), so holes stay open and the inside is hidden. The native app asks for an 8-bit stencil; without one (eframe's web painter has none) the cap draws as before. Not covered by an automated test (no GPU in the test suite); checked in the app with before/after screenshots 2026-10-10 13:51:00 -04:00
bherbruck b1ec9fc1ce Release: drop the riscv64 Linux build
The cross-built riscv64 binary can't run on the x86_64 runner, so the packaging step that runs the CLI
failed and blocked the whole release. RISC-V users can build from source; the opt-in RISC-V compile
check in ci.yml stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 17:24:10 +00:00
CypherNaugh_0x 29796762de cargo xtask test runs every test binary at once (cargo test runs them one after another): 323 s to 75 s on 12 cores. cargo xtask ci runs the quick gates (fmt, assets, licences, layers) first, then clippy, then the tests with the wasm check and doc tests alongside; the wasm check uses the ci profile to reuse clippy's host build scripts and proc macros. GitHub CI runs its tests through cargo xtask test 2026-10-10 13:24:08 -04:00
CypherNaugh_0x aa268f0f0e Faster tests: hostile_params_never_panic shares its commands across threads (154 s to 20 s), the design-file fuzz test tries its seeded cases on all cores (21 s to 7.5 s), the kernel's tangent-primitive boolean test runs its three seeds at once, and the headless scenario harness skips the software viewport render it threw away every frame (scenarios 63 s to 26 s). kill_mid_save waits for the saver to reach a varying round instead of sleeping a fixed time, so it holds on a busy machine 2026-10-10 13:24:08 -04:00
CypherNaugh_0x 4a41de72ec Autosave ids are unique within the process: an app restarted in the same second (the scenario harness crashes and restarts in one process) took the id {seconds}-{pid} of the crashed app, locked its entry again and so offered nothing to recover. The id now carries a per-process sequence number. Test: a restart in the same second still offers the crashed app's work 2026-10-10 13:24:08 -04:00
bherbruck 524ef6b8c2 The up axis preference (Preferences > Design, ui.prefs up_axis) turns the view: the camera, home view, ground grid and orbit follow it, at startup too
Fixes #3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 17:13:07 +00:00
bherbruck 74f70614cc Section Analysis: cut faces in their body's colour (tinted toward the cap colour) and hatched; snapshots show the section too (the cut part removed, hatched cut faces)
Fixes #51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 17:05:23 +00:00
bherbruck c90070c497 Agent cursor speed: a slider from 1× to 5× with Instant at the far right. Instant no longer breaks: the command runs at once (as with the cursor off) and the pointer jumps to where it acted, with a click ripple. ui.agent_cursor speed takes 1–5 or "instant"; saved "slow" and "normal" read as 1×. Scenario covers 1×, 3× and Instant against the cursor-off result
Fixes #79

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

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

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

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

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

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

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

Refs #9

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

Refs #9

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

Fixes #14

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

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

Fixes #60

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

Fixes #36

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #12

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

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

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

Fixes #11

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:03:32 +00:00
bherbruck d2bc8e9847 README: a 3x-speed demo GIF at the top (agent builds the enclosure over MCP, then edits parameters)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:54:48 +00:00
bherbruck 8601611d0c MCP demo v3 (enclosure_ui_mcp.py --story v3, default): one standoff placed from the parameters (width/2 - inset, depth/2 - inset) and patterned 2 x 2 in the Rectangular Pattern dialog with spacings width - 2*inset and depth - 2*inset, then width 80 -> 100 and wall 2 -> 3 typed into the Parameters dialog; record_v2.sh is now record_ui.sh STORY. A pattern's Direction can be a straight model edge (an edge along an origin axis points the axis's way), scenario pattern_direction_edge. Dialog rows publish their widgets as dialog:<label> handles (combo items dialog:<label>:<item>, OK), the Parameters dialog its expressions (param:<name>), title and close button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:09:29 +00:00
bherbruck 3b9a975299 Release workflow: Linux tar.gz and AppImage, Windows zip, universal macOS app, web zip, SHA256SUMS
.github/workflows/release.yml builds on a pushed v* tag (or by hand) and attaches everything to a
GitHub release, a pre-release while the version is 0.x, with .github/release-notes.md. The
packaging lives in .github/release/package-{linux,macos}.sh so it can be run locally; the
AppImage icon is rendered by solvecraft-cli from the bracket example.

Fixes #32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:41:42 +00:00
bherbruck 1d11898a2c MCP demo v2: examples/demo/enclosure_ui_mcp.py builds the enclosure through the UI like a person (toolbar clicks, real dialogs, sketches on the model's faces, Look At and back out, orbits between features) with the parameters and their dimensions over MCP; record_v2.sh and overlay.py record it with captions (UI click / MCP call) and a drawn pointer with click ripples. Toolbar buttons, drop-down items and panel labels publish handles (toolbar:<id>, panel:<tab>:<panel>); control method ui.handles lists them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:22:14 +00:00
bherbruck 78dd776152 ROADMAP refreshed: parity 328/541 in scope, oracle 76/76, 596 tests; persistent naming, fillet corners, Split Face, Fold and modelled threads no longer listed as missing
Fixes #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:39:10 +00:00
bherbruck 0d7e456bc1 Documentation site and CI workflows
An mdBook under docs/book: a user guide (getting started, UI tour, sketching, features,
parameters, components and joints, sheet metal, plastic, files, shortcuts, MCP) and a developer
guide (CONTRIBUTING, architecture, command engine, kernel boundary, persistent naming, test
harnesses, and the existing docs/*.md pages). `cargo xtask book` generates the command reference
from `solvecraft-cli commands` and renders the screenshots with `solvecraft-cli snapshot`, then
runs mdbook. .github/workflows/pages.yml publishes the book at /solvecraft/ and the web app at
/solvecraft/app/ (skipped while the repository is private). .github/workflows/ci.yml runs
`cargo xtask ci` on pull requests. README and Help → Documentation link to the book.

Fixes #28
Fixes #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:25:38 +00:00
bherbruck 2c3fec087e Drop the vendored truck-meshalgo's VTK export (unused): removes vtkio and lz4_flex 0.7.5 (Dependabot high: uninitialised memory in decompression)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:11:27 +00:00
bherbruck 025380277a MCP demo: examples/demo/enclosure_mcp.py builds an electronics enclosure in the running app over MCP, one execute call per step of enclosure_steps.json (rounded box, shell, USB port cut, four screw bosses, then width 80 -> 100 mm rebuilt), ending on an orbit; record.sh and captions.py record it with ffmpeg and burn in captions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:00:14 +00:00
bherbruck 517b7a911c Split Face cuts with the sketch's exact segments (lines and arcs, kernel::SplitTool::Curves now takes Seg2); a test fillet gets its style
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:47:56 +00:00
bherbruck 334cb5f498 face.delete help text without the word fillet (MCP list_commands filter test)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 846bb112e2 Construction axes, points and planes from model geometry (SOLID ▸ CONSTRUCT): axes through a cylinder/cone/torus, perpendicular to a face at a point, through two planes, two points or an edge; points at a vertex, through two edges, three planes, at the centre of a circle/sphere/torus, at an edge and a plane, along a path; midplane, planes through two edges or three points, tangent to a face, along a path. References (doc::construct::GeoRef) keep picked faces and edges by persistent name and resolve on each evaluation (they follow the model); results live in ModelState.construct, in the component's frame; view::construction_axes/points/planes for the viewport; kernel::face_surface for a face's analytic surface
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 68271137a5 Split Face (FusionSplitFaceCommand): a timeline feature dividing the picked faces where a plane, another body's face (its surface extended) or a sketch's curves meet them, through kernel::split_faces; the solid is unchanged and the pieces take the face's name with #k. Rebase fixes: duplicate Chamfer and Combine specs; the Delete selection's face test expects Delete Face's refusal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 3db6119cc6 Delete Face (face.delete, and faces in the Delete selection): a timeline feature that removes faces and heals the gap from the neighbouring planes through kernel::delete_faces; a picked face brings the rest of its surface (both halves of a hole's wall); face picks are stored by persistent name; a face its neighbours can't close is refused with the kernel's reason
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 9219eb5dc1 Rebase fixes: one helix re-export, a closing brace lost in tests.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 141a5fea8a Modelled threads (Thread with modeled: true): the ISO 68-1 profile cut along a helix, M3 to M20, on shafts and in bores; running out past a free end of the face, stopping square at a set length. The groove is a watertight screw-sweep mesh (kernel::sweep_helix_mesh) cut by kernel::faceted_boolean (BSP on the faceted body, tolerance ladder checked by A−B + A∩B = A); the polygon rebuild welds vertices across grid cells, splits pinched loops, finds T-junctions through a grid, and takes the group's plane for a loop a hair off flat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 1f619e49cc Extrude and Revolve To Object on curved faces follow the face: the tool runs through the target body and is cut back to its surface (the piece that starts at the profile); tests against a cylinder and a sphere (volumes by integration) and a revolve ending on a post; Shell Outside/Both on a cylinder through the exact curved offsets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 1e515547d8 Boundary Fill (SurfaceSculpt): solids from the cells tool bodies enclose (inside some tools, outside the rest), each picked by a point, as new bodies or joined/cut/intersected, tools optionally removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 0daeb0c9fc Align with snap points: from/to as {snap: vertex|edge_mid|face_center|circle_center, at}
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 3ee03ac597 Combine: new_component puts the result into a new component (keep_tools already kept the tools)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 214dcf7524 Hole To Object (drill to the face the axis meets there, or a point's depth; kept by persistent face name, copies in patterns move it too) and holes at every point of a sketch (standalone points and circle centres) when only the sketch is given
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 8e36d5d2fe Shell Outside and Both (walls grown out of the faces, or half out and half in: an offset body less a cavity) and tangent-chain face selection (open faces joined smoothly to the picked ones, by the surface normals either side of each edge)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck a46d00f361 Revolve Two Sides (angle2), Symmetric (angle split either side) and To Object (turn until a face, vertex or point, kept by persistent face name)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck ab328ae018 Extrude To Object (a face, vertex or point; parallel faces by their plane, others where the profile's middle meets them; to_offset past it) and From Object (start at a face or point); both kept by persistent face name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 9ee8ba5dd7 Imported looks outlive later features: a body's imported colour, opacity and face colours (kernel paint, dropped by operations) are carried to the next version of the body, its faces by persistent name (split faces keep the colour, new faces take the body's); assigned face appearances already follow their faces by name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-09 12:47:56 +00:00
bherbruck 2ea9955621 CONTRIBUTING.md for outside contributors; README status and a Contributing section
Refs #7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:32:41 +00:00
bherbruck f7ff78681a One more renamed id: a menu test still started Extrude by its old id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:25:46 +00:00
bherbruck d10312b7fd Command ids are SolveCraft's own: 173 ids that were another product's internal command ids become dotted, lower-case ids (<area>.<thing>[.<variant>]: solid.extrude, sketch.rectangle.two_point, sheet.flange, joint.drive, ...), in the registry, toolbar, dialogs, shortcuts, palette, control channel, MCP, examples, scenarios and docs
The old ids live only in crates/engine/src/legacy_ids.rs (the legacy migration table): find_command maps them, so old scripts, recipes and control calls keep working, and saved shortcuts, recent and pinned commands are migrated on load (designs store no command ids). Parity maps the observed catalog ids to ours through xtask/data/fusion-ids.tsv. The agent rule now reads: our own dotted id, never another product's. Cargo.toml's repository points at github.com/bherbruck/solvecraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:17:23 +00:00
bherbruck 740360d7c3 Fillet corners: a radius per edge (kernel::fillet_radii), rounds next to earlier rounds of another radius, and smooth corner patches where three rounds meet
blend_corner.rs rebuilds the planar faces at the ends of the filleted edges (three planar faces and three straight edges at each such vertex) and keeps the rest. One round at a vertex ends in the third face's exact section of its cylinder; two rounds with the third edge sharp trim each other (rolling ball: the crease from where their contact lines cross to the third edge for equal radii, else to the smaller round's contact line on the face that ends the larger one, which gets the exact section between); three rounds meet a sphere for equal radii, else a rational degree-4 patch through plane sections of the cylinders, the sections turned so that the cylinders' curvatures agree where two meet on a face and the patch fitted tangent to all three (within 0.01 degrees) and to the face at its closed corner.

kernel::fillet falls back to it when the local blends refuse: an edge that ends on an earlier round (any radius) takes the round off (delete_faces) and blends both edges together, so r3 next to r2 and a third edge into a mitred pair work. delete_faces: a gap stretch along several deleted faces keeps the corners between them (two rounds meeting in a mitre come off back to the sharp corner). splitface::fit is shared for the crease.

Tests: volumes against the strips-minus-overlap reference (exact strips, overlap integrated; checked against an independent 1D integral), the sphere-octant formula for three equal rounds, tangency along the patch, closed meshes, refusals without panics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:37:29 +00:00
bherbruck 7820ee405b Chamfer dialog: Chamfer Type (Equal Distance, Two Distances, Distance and Angle) with the second distance or the angle and Flip, sent as the engine's distance2 / angle / flip; Edit Feature restores them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:27:09 +00:00
bherbruck dffa1c79bf Provenance of sweeps and lofts: swept_origins (start cap in the profile's plane, walls by the profile segment under their start edge, end caps) and sweep_origins for sweep_path tools (walls in profile order, once per station), cross-checked in tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 22:16:41 +00:00
bherbruck c0e3e3d216 Variable-radius and chord-length fillets
New kernel file blend_variable.rs. The hooks into blend.rs are visibility
only: plane_normal, vtx, rebuild_face and unsupported are now pub(crate).
- fillet_variable(body, edge, start, r0, r1) works on the same edge
  configuration as the constant fillet (straight edge, planar faces, end
  faces square to it). The radius runs linearly from the end nearest
  `start`, and every section square to the edge is a circular arc. The
  blend is an exact degree (2, 1) rational B-spline: arc control points
  move linearly.
- fillet_chord(body, edges, chord): the radius per edge comes from its
  face angle (chord = 2 r cos(phi/2)).

Fillet feature: style constant | chord | variable {radius2, start};
FusionFilletEdgesCommand takes type, radius2 and start.

Tests:
- Variable 2 -> 5 on a box edge removes (1 - pi/4) L (r0^2 + r0 r1 + r1^2)/3.
  Starting from the other end gives the same solid; equal radii give the
  constant fillet; too large is refused.
- Chord 4 on a right angle equals radius 4/sqrt(2).
- Engine: both types by command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 21:30:58 +00:00
bherbruck c9ed3d154d Web files: designs live in the browser (OPFS), files come in by picker or drop and go out as downloads
- solvecraft_io::vfs: the engine's file access (disk natively; in the browser an in-memory set of
  files the host loads and stores). File commands and write_atomic go through it.
- Web host (apps/solvecraft-web/src/files.rs): stored designs are read from OPFS at start
  (storage.persist() asked for) and written back when a command writes one; Save/Save As keep
  designs in the browser, exports (STEP, STL, 3MF, OBJ, ...) download as files.
- File > Open shows "Files in this browser": open, insert, rename, download, delete, plus open or
  insert from the computer (showOpenFilePicker where there is one, an <input type=file>
  otherwise). Files dropped on the page are inserted or opened.
- Unsaved work is autosaved to OPFS and offered again after a reload or crash.
- Start page recents come from the stored designs, with thumbnails. In the browser the samples
  are built when opened (a picture blocks the page there, and a kernel panic can't be caught).
- Pages without OPFS (http on a network address, old browsers) keep designs in local storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 21:15:18 +00:00
bherbruck d191ae571e Pending scenario: a root-active sketch on a face of a moved component projects the face where it is (today at the body's own coordinates); harness check linked_world_range
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 20:34:37 +00:00
bherbruck c450619b0a View animations turn smoothly to the very end: the camera blends heading (the shorter way) and elevation instead of slerping orientations (whose roll-free heading spun near straight up or down, so moves to Top or Bottom snapped in their last frames); test samples every view-cube move at 60 fps for jumps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 20:24:10 +00:00
bherbruck 3f44110b59 One frame for every window (frame.rs): command dialogs dock right and shrink to their content (260 to 380 px, 560 for table dialogs), floating windows are centred and capped (460 px, 760 for tables); header and footer rows right-align without stretching the dialog, long texts wrap; all windows migrated; tests: no egui::Window outside frame.rs, every command's dialog and the floating windows measured within limits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 20:24:10 +00:00
bherbruck 74c7259825 Face provenance (kernel/src/provenance.rs): trace(result, inputs) tells per result face which input faces it lies on (surface and trimmed region, either side) and whether it was kept, modified, split, merged or generated, and per edge its faces and the input edge it lies on; extrude_origins and revolve_origins name a tool's caps and the profile segment each side sweeps, compose carries them through the boolean, blend_origins gives fillet/chamfer faces their edge and offset_origins gives shell walls the face they offset
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 20:12:13 +00:00
bherbruck 288bdf7f0f Sketch on a face: Look At centres on the picked point instead of the plane's origin (a corner of the face), which left the face filling the view off-centre like a big grey square
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 20:12:12 +00:00
bherbruck 7027382667 split_faces: exact sketch segments, a face map, and misses named
For solvecraft-params' Split Face feature:
- Sketch tools are chains of Seg2. A single line splits by its plane and a
  single arc by its cylinder, exactly (a circular edge on a planar face);
  longer chains and splines follow the curve.
- split_faces_with_map gives the source face of each face of the result.
- A listed face the tool misses is an error naming it (split_any keeps
  the lenient form for Trim).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 19:49:50 +00:00
bherbruck 998410de56 Debug builds mesh seam faces and closed edges (vendored meshalgo)
Three step_in tests and two split tests failed in debug builds only:
truck's debug_new asserts simple loops and two distinct end vertices, but
faces whose loop runs a seam edge both ways and full-circle edges on one
vertex are valid. Meshed faces and edges are now built unchecked. ci
(release) was unaffected; the kernel tests pass in debug and release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 19:35:22 +00:00
bherbruck 0a6f6c4422 Insert Part dialog (SOLID > INSERT > Insert Fastener)
The standard parts library by type (with its ISO standard), size and length, the size's table
values and a picture of the part; picking a hole's wall or rim seats it there with a rigid joint
(the live preview shows it seated), without a pick it goes at the origin. Opens on
FusionFastenersCommand (and parts.insert); OK runs FusionFastenersCommand. Its own icon.
Insert's world picks (`at`, `point`, `direction`) go through the component frames like other
picks. Tests for the library parsing and the insert parameters; checked in the app: an M3 socket
head cap screw seated in a drilled hole with its joint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 18:28:08 +00:00
bherbruck 4eff2146d2 s31 (sketch on a face projects its loops) passes on main: enabled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 18:00:42 +00:00
bherbruck acc7b83625 UI scenarios are found, not listed: build.rs makes one test per tests/scenarios/*.json, named after the file, and a scenario waiting for a fix says so in its own file with a {"pending": "why"} step (its test is ignored with that reason). No shared list to edit, no numbering required
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 18:00:42 +00:00
bherbruck 305c1c9a9b Dragging sketch geometry snaps like Fusion: a dragged point (or a line dragged by its body, by its ends) snaps onto other points, line midpoints and curves, and letting go there adds the coincident, midpoint or point-on-curve constraint in the drag's undo step; it lines up horizontally and vertically with other points (dashed guides, no constraint). Snap distances in screen pixels; Ctrl or Alt held turns snapping off, also while drawing. Ring and Pt/Mid/On label on the snap. Scenario s42 (coincident and DOF, exact H alignment without a constraint, Ctrl-drag, undo, line body drag); ui.drag hold_end; synthetic drags keep their modifiers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 18:00:42 +00:00
bherbruck b8d00aea13 Surface Extend: a planar surface's straight edges pushed out (FusionExtendCommand)
Kernel extend(body, edges, distance) moves the picked straight edges of a
planar surface outward in its plane, with neighbours meeting them. Doc
feature SurfaceExtend, engine FusionExtendCommand. Tests: 10x10 extended
by 5 past one edge gives 150, past two gives 225; the engine patch, trim,
extend and thicken gives 240.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 17:31:57 +00:00
bherbruck dd6c8e1a6b Surface bodies: Patch, Stitch, Trim and Thicken
Kernel (surfaces):
- Bodies with an open shell are surface bodies (Body::is_surface).
- patch_region: a planar patch of a sketch region. patch_edges: a planar
  patch filling a closed loop of a body's edges.
- copy_faces: faces copied out as a surface.
- stitch: sew surfaces along coinciding edges, turning faces to agree; the
  result is a solid when it closes.
- trim: cut a surface by a plane or face and keep one side.
- thicken: offset planar and circular-cylindrical faces along their
  normals (one side or symmetric) into a solid. Plane copies slide;
  cylinder copies scale about the axis, exact for NURBS circles. Free
  edges get ruled walls. Faces meeting at a sharp edge are refused.

Doc features Patch, Stitch, Thicken (with operations) and SurfaceTrim;
engine commands FusionSurfacePatchCommand, FusionSurfaceStitchCommand,
FusionSurfaceThickenCommand and FusionSurfaceTrimCommand.

Tests (volumes):
- A 10x10 patch thickened by 2 gives 200, also symmetric.
- Half a cylinder side thickened outward gives half a ring; both halves
  inward give the full ring.
- Six faces of a box stitched back give 6000; an open box with its top
  patched from the edges, stitched, gives 6000.
- Trim keeps 30 or 70 of 100.
- Engine: patch, trim, thicken gives 140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 17:31:57 +00:00
bherbruck feaaba156e Split Body by a face of a body: FusionSplitBodyCommand tool {body, point}
The Split feature takes an optional face tool (FaceAt, the face through a
point on a named body) and splits with kernel split_body, the tool's
surface extended; otherwise the plane as before. Engine test: a box split
by a cylinder's side into the core and the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 17:31:57 +00:00
bherbruck 34be218639 Kernel: split a body by a plane or another face's surface
split_body(body, tool) divides every face where the tool meets it and
closes each side with the piece of the tool surface inside the body.
- Patches have outer loops and holes.
- Bands go round a cylinder or another surface of revolution.
- Each side falls apart into its connected parts.
- A body the tool misses comes back whole.

Faces that are B-spline sheets but really planes or circular cylinders
(extruded faces) split as the whole plane or cylinder: the tool is
extended, and the field is exact and cheap.

Tests (volumes): a box split by a plane (2400/3600); a holed plate split
through its hole into two equal halves; a box split by a cylinder's side
into the core (pi*16*30) and the rest; a plane that misses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 17:31:57 +00:00
bherbruck 69d7bcfe7d split_faces: faces chosen by position stay chosen when a neighbour's split cuts their edges
A face whose edges were cut by an earlier split got a new identity and was
skipped. Chosen faces are now tracked by position through each split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 17:31:57 +00:00
bherbruck f8a4ffbc67 Kernel: split faces by a plane, another face's surface or sketch curves
split_faces(body, faces, tool) divides the chosen faces where the tool
meets them and leaves the solid unchanged. It handles:
- lines across a face;
- closed lines inside a face (a disc inside a ring);
- lines that run into a hole (the face is divided around it).
Edges the line ends on are cut in every face that uses them. The tool's
zero set is traced on a finely divided triangulation of the face, each
point is placed exactly on the surface by bisection in its parameters,
the ends are placed exactly on the boundary edges, and a cubic is fitted
through the points (a line when they are straight).

Tests: a box top split by a plane (areas 80/120, later booleans still
work); a cylinder side split by a sketch line; a cylinder top split in
ring and disc by a smaller cylinder's face; a plate top split through
its hole into two equal halves; a tool that misses is an error. Volume
and area are unchanged in every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 17:31:57 +00:00
bherbruck 0670de3f57 Shell and Offset Face on drill tips and tori: samples skip points where the normal is undefined (a cone's apex), the apex moves with its cone, tori offset their tube (the tube circle scaled and revolved again); a box with a drilled hole now shells to its analytic volume
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 17:10:06 +00:00
bherbruck 7e2d45602b kernel::delete_faces(body, faces): Delete Face with healing. The body is rebuilt from its own topology: faces away from the gap stay, planar neighbours keep their other edges, loops that ran along deleted faces (hole rims, boss footprints) go, corners on the gap move to where the neighbouring planes meet across it, and the gap closes with straight edges. Fillets, chamfers, through and blind holes and bosses come off exactly; curved neighbours and gaps the neighbours cannot close are refused naming the face
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 17:10:06 +00:00
bherbruck b8cd3d71e5 Regression test for components as frames (user bug #4, on 7a2eb05): a fillet picked in the world on a moved component with the root active, a sketch on a moved body's face, a body moved into a moved component keeping its place, and a fillet picked on a pasted instance; in its own test module
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 16:45:48 +00:00
bherbruck 7a46436158 Scenario s41: a value typed after picking an edge goes into Fillet's heads-up radius (as Extrude and Hole)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 16:07:33 +00:00
bherbruck c45f3173fa QA targets for the newest UX reports (pending until their owners land): s37 browser chevrons on every node kind (all toggle; no Joints folder yet), s38 view animations to Top/Bottom end without a jump (the last frame turns 22.5 degrees today), s39 view cube edge/corner hover lights 2/3 patches, s40 toolbar Pin/Remove/reorder survive a restart and the workspace switcher spans the toolbar; ui_layout tests: every command dialog docked right at 260-380 px (or 480-560 for tables; the exploded view is 635), other windows fit on screen. Harness: published rects and counts, browser rows/fold arrows as handles, camera and view_anim steps, ui.at cube points, settings carried over a close restart, an adhoc scenario runner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 16:07:33 +00:00
bherbruck 1e8fc25976 X and the toolbar's Construction no longer start a pick tool (Fusion has none): with a selection they convert it, with nothing selected they switch construction mode, outside a sketch they say so. Center Rectangle draws both diagonals as construction, the centre at their crossing, with only the first carrying the midpoint constraint (the second is implied), so a fresh one stays at 4 DOF. Scenarios s30 (pattern a hole) and s32 (cut in the active component) enabled now that b36b332 is on main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 16:07:33 +00:00
bherbruck 90873e51bb Construction mode like Fusion: with nothing selected, X (or the toolbar's Construction, or the Sketch Palette's Linetype) switches the drawing line type, so new curves are construction (or centerline) until switched back, the active mode highlighted in the palette and named in the status bar; with a selection they convert it as before; the line type is part of the drawing's undo step. Scenarios: s35 construction mode, s36 the Sketch Palette shows while sketching (also over the tools, back after a window closes, both themes) and goes with the sketch; harness shown/not_shown checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 16:07:33 +00:00
bherbruck 35ac9569dc Holes as deep as the material: a drilled hole whose drill point would start exactly on the exit face is cut through (QA: depth = plate thickness failed with coincident faces on curved bodies)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 15:41:02 +00:00
bherbruck c0f6aeb574 Regression test: lips, grooves and outside lips after bosses (to the rim, ribbed to the walls, against a wall, drafted and filleted) on a shelled enclosure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 15:41:02 +00:00
bherbruck 8497542a3d Chamfers with two distances or a distance and an angle (distance2 / angle, flip for which face comes first), on straight edges between planar faces; pattern copies keep them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 15:41:02 +00:00
bherbruck 38a6ae3d38 Topology counts: a horn torus pinched to a point is one face (smoothness ignores an edge's ends and points without a normal), with a degenerate loop at the pinch counted as Fusion does (two edges and a vertex); oracle 50 passes, 76/76
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 15:41:02 +00:00
bherbruck 109b8acbb5 Topology counts weld coincident vertices and edges, and merge two pieces of one surface only across an edge that just those two share: tangent holes touching along a line keep the wall cut at the pinch, the same way at every pinch (oracle 42 passes; 75/76)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 15:41:02 +00:00
bherbruck 51ffaf9945 IGES 5.3 import and export; OBJ import; STL reading hardened
- IGES import (kernel iges_in, from the published specification). The file
  is restated as STEP and read by the STEP reader, so an IGES file becomes
  the same Import base feature.
  - Manifold solid B-reps (186/514/510/508/504/502).
  - Trimmed (144), bounded (143) and bare B-spline surfaces, sewn into a
    solid by shared edges, with face orientation made consistent.
  - Curves: lines, arcs, ellipses, composite and rational B-spline curves.
  - Surfaces: planes, analytic surfaces 190-198, B-spline surfaces,
    surfaces of revolution and tabulated cylinders.
  - Transformations, units, colours, names (406 form 15 or labels).
  Open, insert and drop .igs/.iges like STEP.
- IGES export (kernel iges_out): each body as a manifold solid B-rep on the
  surfaces a STEP export uses, with name properties and colours.
  ExportCommand to .igs.
- OBJ import as mesh bodies: objects/groups, polygons fanned, negative
  indices, bad lines reported.
- STL: binary files whose header starts with "solid" and those with a zero
  triangle count read as binary; non-finite vertices dropped; facet
  normals ignored; ASCII with non-UTF-8 names. Mesh bodies get consistent
  winding (stray inverted facets turned over).
- Tests: primitives and features round-trip through IGES (volume, area,
  name, colour). Six trimmed patches sew into a closed box. Hostile IGES,
  OBJ and STL inputs never panic. STL quirks. The engine exports a design
  to IGES and opens it again. All 76 oracle parts round-trip through IGES
  with matching volume (local check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 15:17:25 +00:00
bherbruck 7a2eb059c4 Components are frames (user bug #4)
What a component holds moves with its occurrence, and picks in the world land in the
component's frame:
- Sketches and construction planes show where their component is placed (free move, joint
  drive, Capture Position); sketch.inspect reports sketch points in the world.
- frames.rs maps a command's world picks into the frame of the body they lie on (through the
  inverse of its placement: a moved component or a pasted instance), points that make new
  geometry into the active component's frame, and directions with them; instance body names
  ("Body1 (Pin:2)") become the component's body. Fillet/chamfer edges, shell/offset/draft
  faces, face extrude/revolve, sketch on face, holes, sheet metal and plastic picks, primitives,
  appearance faces, analyses. Once per command (not again for commands run by commands); the
  active-frame fallback stays for commands without a table. Nothing changes while every
  placement is the identity.
- component.move_bodies keeps a body where it is in the world (Document.body_offsets: from the
  new component's frame to the one it was made in; renames and deleted components follow).
- The UI reads the placed model (Session::world_state) for display, picking, sketch editing and
  dialogs; live previews use it once anything is placed.
Tests (engine): sketches and construction planes move (and turn) with the component; world
picks fillet, sketch, hole and make boxes in a moved component; a pick on a pasted instance;
move_bodies keeps the world place there and back; a sketch on a face follows a Move feature.
QA's s34 now runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 15:04:32 +00:00
bherbruck c0725ebc86 Dialog parity: Mirror has Object Type (Bodies / Features, a hole mirrored as a feature; Edit Feature keeps it); Thread has Full Length (a length only when off)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:56:11 +00:00
bherbruck 23d38fab97 STEP export: read the file back only when faces were merged
The read-back check (fallback to unmerged pieces) imported every export.
Now it runs only when some body actually had faces merged. Oracle 41
(42 faces, nothing merged): export no longer re-imports, which saved 7 s.
Pipe flange (merges): 1.1 s for the read-back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:34:18 +00:00
bherbruck b36b332619 Patterns and mirrors of holes drill each copy afresh (its spot moved by the instance transform and found on the surface there), so a blind hole's depth counts from the face at the copy instead of from the bottom of the original hole (QA s30: three blind holes now remove three holes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck 83a10c813a Unit-less parameters used as lengths read in the design's units (feature evaluation and parameter values; bare numbers stored in inputs stay mm); document.units checks what the geometry reads (Document::length_inputs) and writes the old unit (with it as the parameter's unit) into bare unit-less parameters that would move; cached results keyed on the design's units
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck e30309be0d Insert Fastener (FusionFastenersCommand) on SOLID > INSERT, icon "fastener": the Insert Part dialog's command (same as parts.insert)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck f7c26d8dfe Standard parts library (parts.library, parts.insert, parts.resize): ISO 4762 socket head cap screws, ISO 4014/4017 hex bolts, ISO 4032 nuts, ISO 7089 washers, ISO 4026 set screws, ISO 8734 dowel pins and 6000/6200/600-series bearing envelopes, M3–M20, built in code from the standards' dimensions as their own components with cosmetic threads; inserted at a hole's rim they get a rigid joint that puts them on the hole's axis seated on its face; resize rebuilds in place. Circle joint origins are named after the cylindrical wall
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck b2d0452656 Fold: with no fixed side the larger side stays, whichever side of the line it is on (the side areas were measured from the wrong point for one side; QA s22)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck 2ef7f908b0 ROADMAP: M5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck 3b88585e5c sheet: clippy (items after tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck 05b38e0f5f Persistent naming for sheet metal: base panel edges are named after the base sketch line they lie on, flange edges after their flange (F<feature>.<k>) and side (far / side0 / side1, top or bottom face); flanges and hems store edge names and pick by name first (a name that is gone keeps the point with a warning)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck c6cb3dd1e6 naming: clippy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck 23e50e4540 Persistent naming: shell inner faces named after the wall face they offset (F<shell>:inner:<face>), sweep and loft faces by start/end caps and the profile curve each side sweeps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck 7c611aec0c Booleans stay in their component: a join, cut or intersect without explicit targets changes only its own component's bodies (and what it made), auto join/cut looks only at the active component; any feature takes participants: [bodies] to reach other components ("*" = every body); file format 5 gives older features in designs with components participants ["*"] so they keep their shape
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
bherbruck 2886399215 Bare lengths typed into features (commands and parameter edits) mean the design's units: they are stored with the unit written in (2 in an inch design is "2 in"); lengths with units, angles and mm designs are unchanged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 14:25:27 +00:00
375 changed files with 51545 additions and 5521 deletions
+25
View File
@@ -0,0 +1,25 @@
SolveCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solvecraft/)
· [try it in the browser](https://bherbruck.github.io/solvecraft/app/) ·
[contributing](https://github.com/bherbruck/solvecraft/blob/main/CONTRIBUTING.md) ·
[issues](https://github.com/bherbruck/solvecraft/issues). AI agents can model with it over MCP:
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solvecraft/guide/mcp.html)).
**Which file?**
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
`-portable.zip` (x64, x86 and arm64).
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag SolveCraft onto Applications.
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
The builds aren't signed yet. On macOS, right-click SolveCraft in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
[ROADMAP.md](https://github.com/bherbruck/solvecraft/blob/main/ROADMAP.md#known-limitations).
+206
View File
@@ -0,0 +1,206 @@
# 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 }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
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
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Compile tests
run: cargo test --workspace --profile ci --locked --no-run
- name: Run tests
# Every test binary at once (cargo test runs them one after another).
run: cargo xtask test --locked
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
# 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
- 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
+161
View File
@@ -0,0 +1,161 @@
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
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
APP_ID: ai.storyteller.solvecraft
steps:
- name: Configure Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download and validate release bundles
shell: bash
run: |
set -euo pipefail
tag="$RELEASE_TAG"
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
exit 1
fi
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
'select(.draft == false and .published_at != null) | .tag_name')"
if [[ "$published_tag" != "$tag" ]]; then
echo "::error::$tag is not a published release in $GITHUB_REPOSITORY"
exit 1
fi
version="${tag#v}"
mkdir -p bundles
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern SHA256SUMS.txt --dir bundles
for arch in x86_64 aarch64; do
asset="solvecraft-$version-linux-$arch.flatpak"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset" --dir bundles
test -s "bundles/$asset"
(
cd bundles
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
SHA256SUMS.txt | sha256sum --check -
)
done
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
for arch in x86_64 aarch64; do
asset="bundles/solvecraft-$version-linux-$arch.flatpak"
validation_repo="$(mktemp -d)"
ostree --repo="$validation_repo" init --mode=archive-z2
flatpak build-import-bundle "$validation_repo" "$asset"
expected_ref="app/$APP_ID/$arch/stable"
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
echo "::error::$(basename "$asset") does not contain $expected_ref"
exit 1
}
rm -rf "$validation_repo"
done
- name: Build repository and site
shell: bash
env:
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: |
set -euo pipefail
site="$RUNNER_TEMP/site"
repo_dir="$site/flatpak/repo"
mkdir -p "$repo_dir"
ostree --repo="$repo_dir" init --mode=archive-z2
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
if curl --fail --silent --show-error --location \
--output /dev/null "$previous_url/summary"; then
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
ostree --repo="$repo_dir" pull --mirror previous
ostree --repo="$repo_dir" remote delete previous
fi
version="${RELEASE_TAG#v}"
for arch in x86_64 aarch64; do
flatpak build-import-bundle "$repo_dir" \
"bundles/solvecraft-$version-linux-$arch.flatpak"
done
flatpak build-update-repo \
--title="SolveCraft" \
--comment='Official SolveCraft releases' \
--description='Official Flatpak repository for SolveCraft' \
--homepage="https://github.com/$GITHUB_REPOSITORY" \
--default-branch=stable \
"$repo_dir"
for arch in x86_64 aarch64; do
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
done
test -s "$repo_dir/summary"
base_url="${PAGES_BASE_URL%/}"
repo_url="$base_url/flatpak/repo/"
cat >"$site/flatpak/solvecraft.flatpakrepo" <<META
[Flatpak Repo]
Title=SolveCraft
Comment=Official SolveCraft releases
Description=Official Flatpak repository for SolveCraft
Url=$repo_url
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft Flatpak repository</title></head><body><main><h1>SolveCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolveCraft repository</a></p><pre>flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
flatpak install solvecraft $APP_ID</pre></main></body></html>
HTML
cat >"$site/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft downloads</title></head><body><main><h1>SolveCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
HTML
touch "$site/.nojekyll"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ${{ runner.temp }}/site
include-hidden-files: true
deploy:
name: Deploy Pages
needs: build
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+53
View File
@@ -0,0 +1,53 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, the
# packaging shell and PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
# Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
name: Packaging lint
on:
push:
branches: [main]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: packaging-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: '"$RUNNER_TEMP/actionlint" -color'
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
+65
View File
@@ -0,0 +1,65 @@
# The documentation site on GitHub Pages: the book (docs/book) at /solvecraft/ and the web app
# at /solvecraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# repository is private.
name: Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
MDBOOK_VERSION: v0.5.4
TRUNK_VERSION: v0.21.14
jobs:
build:
if: ${{ !github.event.repository.private }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Install mdbook and trunk
run: |
mkdir -p "$HOME/.local/bin"
curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
curl -sSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build the book
run: cargo xtask book
- name: Build the web app
working-directory: apps/solvecraft-web
run: trunk build --release --public-url /solvecraft/app/
- name: Assemble the site
run: |
mkdir -p site
cp -r target/book/. site/
cp -r dist/web site/app
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+370
View File
@@ -0,0 +1,370 @@
# Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
#
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
# Web a .zip of the static site packaging/web
#
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
name: Release
on:
push:
branches: [release]
workflow_dispatch:
inputs:
draft:
description: "Create/update a draft (release branch only; off: build artifacts only)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
env:
REQUEST_DRAFT: ${{ inputs.draft || false }}
run: |
. packaging/env.sh
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then
echo "::error::Draft releases must be built from the release branch"
exit 1
fi
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolveCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solvecraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solvecraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends desktop-file-utils appstream zsync rpm
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
rpm -qip dist/release/*.rpm
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
ldd target/release/solvecraft
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder than 22.04 ships.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest
timeout-minutes: 180
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
prepare: |
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
# runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
macos:
name: macOS universal
needs: version
runs-on: macos-14
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- 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@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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, freebsd, macos, windows, web]
runs-on: ubuntu-latest
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums and notes
run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
- name: Create or update the draft release
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
working-directory: artifacts
run: |
tag="v$VERSION"
# Refuse existing tags: a draft must point at this build, never an older tag.
# matching-refs returns [] for an absent tag and fails on API/auth errors.
existing_refs="$(gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" \
--jq '.[].ref')"
if printf '%s\n' "$existing_refs" | grep -Fxq "refs/tags/$tag"; then
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
exit 1
fi
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != true ]; then
echo "::error::Release $tag is already published. Bump the version before releasing."
exit 1
fi
# Replace the complete draft asset set, including assets removed in this build.
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
while IFS= read -r asset; do
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
done <<<"$assets"
gh release edit "$tag" --draft --title "SolveCraft v$VERSION" \
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
gh release create "$tag" --draft --title "SolveCraft v$VERSION" \
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
- name: Keep the files (dry run)
if: github.event_name == 'workflow_dispatch' && !inputs.draft
run: cp notes.md artifacts/release-notes.md
- if: github.event_name == 'workflow_dispatch' && !inputs.draft
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: artifacts/*
+1
View File
@@ -6,3 +6,4 @@
*.swp
/.claude/worktrees/
/target-snapshot
__pycache__/
+13 -3
View File
@@ -50,7 +50,8 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = Fusion's command id where Fusion has the command, else a dotted id; label;
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
@@ -70,12 +71,21 @@ 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`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"Extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"SketchCreate"}}` then `ui.click {x, y}`
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solvecraft-cli snapshot`.
+8
View File
@@ -18,6 +18,12 @@ Generated-in-code assets are original and have no file to list:
| File | Author | Source | Licence |
|---|---|---|---|
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo.gif | SolveCraft contributors | screen recording of SolveCraft itself driven by examples/demo (3x speed) | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
## Code adapted from sibling projects
@@ -34,6 +40,7 @@ Generated-in-code assets are original and have no file to list:
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
## Vendored third-party code
@@ -42,3 +49,4 @@ Generated-in-code assets are original and have no file to list:
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
+13 -3
View File
@@ -50,7 +50,8 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = Fusion's command id where Fusion has the command, else a dotted id; label;
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
@@ -70,12 +71,21 @@ 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`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"Extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"SketchCreate"}}` then `ui.click {x, y}`
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solvecraft-cli snapshot`.
+159
View File
@@ -0,0 +1,159 @@
# Contributing to SolveCraft
SolveCraft is an open-source parametric 3D CAD app written in pure Rust, in the style of
Autodesk Fusion. You sketch on a plane, constrain the sketch, turn profiles into solids, and
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
model with.
Help is welcome, whether that's a one-line fix or owning a whole area. This page covers how to
build it, how the code is laid out, the few rules that aren't negotiable, and where help is
needed most.
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.90 or newer. For the web
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
```sh
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo xtask test # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
HTTPS or localhost, because it uses the Origin Private File System.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
## How the code is laid out
The [README](README.md#architecture) has the layer table. The short version:
- `crates/geom`: vectors, planes, meshes, measures.
- `crates/sketch`: our own constraint solver, plus profile finding.
- `crates/kernel`: the B-rep kernel boundary. It wraps [truck](https://github.com/ricosjp/truck),
and it's the only crate allowed to touch truck. Fillets, chamfers, shell/offset, Delete Face,
Split Face, STEP/IGES read and write, and face provenance are our own code here. Patched
copies of three truck crates live in `vendor/` and are excluded from our fmt/clippy rules.
- `crates/doc`: the document. It holds parameters and expressions, the feature timeline and its
incremental rebuild, components and occurrences, joints, appearances, and persistent naming.
- `crates/io`: design files and mesh formats (3MF, STL, OBJ).
- `crates/engine`: the session and the command registry. **Everything the user can do is a
command**, and the toolbar, dialogs, scripts, CLI, control channel and MCP all run the same
commands.
- `crates/ui-egui`: the desktop/web front end (egui + wgpu). It's swappable; nothing below it
depends on egui.
- `crates/mcp`, `apps/solvecraft`, `apps/solvecraft-cli`, `apps/solvecraft-web`.
`cargo xtask layers` fails the build if a lower layer depends on a higher one.
## Rules that aren't negotiable
1. **Clean-room.** We observe Fusion as a black box: its UI, how parts it builds measure, and
what it does with a given input. We never copy Autodesk code, icons, artwork, help text,
tooltips, templates or materials, and we never commit files Fusion produced. Command ids are
our own (`solid.fillet`, not Fusion's internal names).
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolveCraft is
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
3. **Every asset is original or openly licensed and has a row in
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
`cargo xtask assets` enforces this.
4. **Never crash.** People trust a CAD app with their work.
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
There's no `unsafe`, and clippy denies it.
- An unsupported case returns an error ("not supported yet: …"); it never panics.
- Every call into truck goes through `solvecraft_kernel::guard`.
- Every crash fix comes with a regression test.
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
the most complete description of the house style.
## Tests
| What | Where | Run |
|---|---|---|
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) |
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
press keys, then assert on the result.
```json
[
{"start": "sketch.create"},
{"click": {"plane": "XY"}},
{"start": "sketch.rectangle.two_point"},
{"click": {"sketch": [0, 0]}},
{"click": {"sketch": [40, 30]}},
{"expect": {"errors": 0}}
]
```
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
in the PR. `cargo xtask test` builds the tests with it and then runs every test binary at once
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
measurements; [docs/oracle.md](docs/oracle.md) has the current results. The recipes and
measurements live outside the repo (`plan/`, gitignored), because they come from a Fusion
install. If you work on geometry and want to check against it, ask in an issue.
## Sending a change
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solvecraft/issues), labelled
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
comment on the issue so it gets the `in progress` label and two people don't build the same
thing. For anything bigger than a bug fix that has no issue yet, open one first.
2. Branch from `main` and keep the PR to one topic.
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Much of SolveCraft so far was written by AI coding agents (Claude) working in parallel, each
owning one area, with a human setting direction and reporting bugs. Their working notes are in
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
## Where help is wanted most
See the [`help wanted`](https://github.com/bherbruck/solvecraft/labels/help%20wanted) and
[`good first issue`](https://github.com/bherbruck/solvecraft/labels/good%20first%20issue) labels.
In order of how much they'd help users:
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
Constant, variable-radius and chord fillets work, and so do corners where fillets of
different radii meet, including three rounds meeting at a vertex. Next are setback corners, fillets along curved edges between curved
faces, and fillets that consume neighbouring faces.
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
through without a kernel error. Each failing seed is a reproducible bug.
3. **Y-up / Z-up.** SolveCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
Fusion's default) open lying on their backs. Fusion solves this with a "default modeling
orientation" preference.
4. **Imported appearances.** Colours from STEP files are lost when a feature changes the
imported body. They should become design appearances on import.
5. **Drawings, CAM and mesh tools.** These are out of scope for now (see
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
## Licence
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
of SolveCraft. SolveCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
Generated
+22 -119
View File
@@ -422,12 +422,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -524,12 +518,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -685,7 +673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"memchr 2.8.3",
"memchr",
]
[[package]]
@@ -1762,43 +1750,15 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lz4_flex"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05304f8e67dfc93d1b4b990137fd1a7a4c6ad44b60a9c486c8c4486f9d2027ae"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "matext4cgmath"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178"
dependencies = [
"cgmath",
"katexit",
"num-complex",
]
[[package]]
name = "memchr"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "148fab2e51b4f1cfc66da2a7c32981d1d3c083a803978268bb11fe4b86925e7a"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -1927,15 +1887,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05aec50c70fd288702bcd93284a8444607f3292dbdf2a30de5ea5dcdbe72287b"
dependencies = [
"memchr 1.0.2",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -1945,17 +1896,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-derive"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2612,23 +2552,13 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b"
dependencies = [
"memchr 2.8.3",
"serde",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr 2.8.3",
"memchr",
]
[[package]]
@@ -2637,7 +2567,7 @@ version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr 2.8.3",
"memchr",
]
[[package]]
@@ -2958,7 +2888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr 2.8.3",
"memchr",
"serde",
"serde_core",
"zmij",
@@ -3127,7 +3057,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"eframe",
"egui",
@@ -3136,11 +3066,12 @@ dependencies = [
"serde_json",
"solvecraft-engine",
"solvecraft-ui-egui",
"winit",
]
[[package]]
name = "solvecraft-cli"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3149,7 +3080,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"log",
"serde",
@@ -3163,7 +3094,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"log",
"serde",
@@ -3179,7 +3110,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"serde",
"serde_json",
@@ -3187,7 +3118,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3199,7 +3130,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"log",
"serde",
@@ -3214,7 +3145,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3222,7 +3153,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"image",
"serde",
@@ -3231,7 +3162,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"serde",
"serde_json",
@@ -3242,7 +3173,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"egui",
"egui-wgpu",
@@ -3256,11 +3187,13 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"serde_json",
"solvecraft-engine",
"solvecraft-ui-egui",
"wasm-bindgen",
@@ -3302,7 +3235,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -3538,7 +3470,6 @@ dependencies = [
"truck-geometry",
"truck-polymesh",
"truck-topology",
"vtkio",
]
[[package]]
@@ -3715,25 +3646,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vtkio"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abbe89e5b97b472d57abeb02755a06d75b28d2df7d1fe3df5baf032281a65c16"
dependencies = [
"base64",
"bytemuck",
"byteorder",
"flate2",
"lz4_flex",
"nom",
"num-derive",
"num-traits",
"quick-xml 0.22.0",
"serde",
"xz2",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4500,7 +4412,7 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr 2.8.3",
"memchr",
]
[[package]]
@@ -4574,20 +4486,11 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"serde_json",
]
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "zbus"
version = "5.19.0"
@@ -4724,7 +4627,7 @@ dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr 2.8.3",
"memchr",
"zopfli",
]
+18 -2
View File
@@ -5,12 +5,12 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.1.0"
version = "0.1.4"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
authors = ["SolveCraft contributors"]
repository = "https://github.com/storytold/solvecraft"
repository = "https://github.com/bherbruck/solvecraft"
[workspace.dependencies]
# internal
@@ -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"
@@ -83,3 +98,4 @@ debug = 0
truck-shapeops = { path = "vendor/truck-shapeops" }
truck-stepio = { path = "vendor/truck-stepio" }
truck-meshalgo = { path = "vendor/truck-meshalgo" }
matext4cgmath = { path = "vendor/matext4cgmath" }
+5 -1
View File
@@ -5,7 +5,9 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
Third-party material
Software dependencies are listed in Cargo.lock. The geometry kernel builds on the truck crates
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
@@ -14,6 +16,8 @@ vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
Some code is adapted from sibling projects in the storytold family (CADCraft, GridCraft), which are
licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
list is in ATTRIBUTION.md.
+37 -19
View File
@@ -1,16 +1,25 @@
# SolveCraft
SolveCraft is an open-source parametric 3D CAD application written in Rust, in the style of
Autodesk Fusion: sketch on a plane, constrain and dimension the sketch, turn profiles into solids
with extrude and revolve, round edges, combine bodies, and change a parameter to watch the whole
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
storytold "craft" family.
**Parametric 3D CAD; an open-source, [clean-room](CLAUDE.md) reimplementation of Autodesk
Fusion 360, built in pure Rust.**
![SolveCraft with the sample plate](docs/screenshots/sample-plate.png)
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server. Part of the storytold "craft" family.
**Status: early (milestone M0 done, M1 in progress).** See [ROADMAP.md](ROADMAP.md),
[docs/parity.md](docs/parity.md) (command parity with Fusion's Design workspace) and
[docs/oracle.md](docs/oracle.md) (parts built in Fusion, rebuilt and measured in SolveCraft).
![An AI agent builds a parametric enclosure in SolveCraft over MCP, then the width is changed in the Parameters dialog and the model rebuilds (3x speed)](docs/screenshots/demo.gif)
*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).*
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the
[web app](https://bherbruck.github.io/solvecraft/app/) to try it in the browser.
**Status: early but usable for simple parts.** 76 of 76 reference parts built in Fusion
rebuild in SolveCraft with the same volume, area and topology ([docs/oracle.md](docs/oracle.md));
command parity with Fusion's Design workspace is tracked in [docs/parity.md](docs/parity.md).
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solvecraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
## What works
@@ -30,12 +39,14 @@ storytold "craft" family.
deleted.
- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`).
- **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), 3MF, STL (binary/ASCII) and OBJ;
3MF and STL import as mesh bodies (they render, measure, move and export; solid features need
B-rep bodies);
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) as a base feature
that later features build on — open a `.step`/`.stp` file, insert one into a design, or drop it
on the window ([docs/step-import.md](docs/step-import.md)).
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies);
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
control channel so agents and tests can drive it.
@@ -67,10 +78,10 @@ A script is a JSON list of commands, the same ones the UI runs:
```json
{"commands": [
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "Extrude", "params": {"distance": 20}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 10]], "radius": 3}}
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "solid.extrude", "params": {"distance": 20}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}}
]}
```
@@ -86,6 +97,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
## Architecture
<!-- ANCHOR: architecture -->
| Layer | Crates | Role |
|---|---|---|
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
@@ -95,6 +107,12 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solvecraft`, `solvecraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md): how to build and test, the code map, the clean-room
and licence rules, and where help is wanted most.
## Licence
+25 -13
View File
@@ -1,22 +1,28 @@
# SolveCraft roadmap
Status as of **2026-10-07**. SolveCraft targets Fusion-style parametric modelling (sketch →
Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
| Question | Answer |
|---|---|
| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop and browser (wasm) app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. |
| Command parity (in scope) | **139 / 541 (26%)** in-scope commands; SOLID + SKETCH **102 / 291 (35%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **72 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
| Tests | 85 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) |
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) |
| Time to a useful alpha (M0–M6) | ≈ **150 agent hours** remain |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
In scope, in priority order:
1. SOLID and SKETCH (now).
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
@@ -44,20 +50,22 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. Left: sheet metal edges, kernel-reported face history (exact through coincident surfaces), more roles (sweep, loft, shell inner faces) | in progress | 20 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, thicken (needs surfaces), split face, modelled threads | in progress | 12 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: installers, signing, docs, performance | planned | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (FusionStartDesignConfigModeCmd, FusionShowDesignConfigPanelCmd, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
## Known limitations
- Fillets and chamfers: straight edges (convex or concave) between planar faces with perpendicular
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
@@ -80,7 +88,11 @@ headline counts in-scope tabs only and lists the deferred ones separately.
product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- No persistent naming (fillet edges are re-found by position; projected sketch geometry too).
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
references resolve by name first and warn when the entity split or vanished. Kernel-reported
face history (exact through coincident surfaces) is not wired in yet.
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
and Revolve To Object on a curved face cut the tool back to the target's surface.
- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//!
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
//! is recorded like any other failure.
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use solvecraft_engine::fuzz;
use solvecraft_engine::fuzz::boolean_case;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
}
/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr).
pub fn one(args: &[String]) -> Result<(), String> {
let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one <seed> [--steps N] [--minimise]")?;
let steps = flag(args, "--steps", 8usize);
let minimise = args.iter().any(|a| a == "--minimise");
// Panics caught inside the kernel: harmless natively, fatal in the web build.
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
std::panic::set_hook(Box::new(|_| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}));
eprintln!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
let mut design = json!({
"outcome": run.outcome,
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
"unsupported": run.unsupported,
"rejected": run.rejected,
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
});
if run.outcome.is_bug() {
design["script"] = run.script();
if minimise {
eprintln!("minimise");
let m = fuzz::minimise(&run);
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
}
}
let (a, b) = solvecraft_engine::fuzz::boolean_shapes(seed);
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
let b = boolean_case(seed);
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
Ok(())
}
struct Job {
seed: u64,
child: std::process::Child,
started: Instant,
err: std::sync::mpsc::Receiver<String>,
last: String,
}
/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each;
/// write failing scripts to `--out` and a summary to `--report`.
pub fn run(args: &[String]) -> Result<(), String> {
let from = flag(args, "--from", 0u64);
let count = flag(args, "--count", 100u64);
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
let steps = flag(args, "--steps", 8usize);
let out_dir = args.iter().position(|a| a == "--out").and_then(|i| args.get(i + 1)).cloned();
let report = args.iter().position(|a| a == "--report").and_then(|i| args.get(i + 1)).cloned();
let minimise = args.iter().any(|a| a == "--minimise");
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
if let Some(d) = &out_dir {
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
}
let mut next = from;
let mut running: Vec<Job> = Vec::new();
let mut results: Vec<Value> = Vec::new();
let t0 = Instant::now();
while next < from + count || !running.is_empty() {
while running.len() < jobs && next < from + count {
let mut cmd = Command::new(&exe);
cmd.args(["fuzz-one", &next.to_string(), "--steps", &steps.to_string()]);
if minimise {
cmd.arg("--minimise");
}
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
let (tx, rx) = std::sync::mpsc::channel();
if let Some(err) = child.stderr.take() {
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
}
running.push(Job { seed: next, child, started: Instant::now(), err: rx, last: String::new() });
next += 1;
}
std::thread::sleep(Duration::from_millis(50));
let mut i = 0;
while i < running.len() {
let Some(j) = running.get_mut(i) else { break };
while let Ok(l) = j.err.try_recv() {
if l.starts_with("step") || l.starts_with("boolean") || l.starts_with("minimise") {
j.last = l;
}
}
let done = match j.child.try_wait() {
Ok(Some(status)) => {
let mut s = String::new();
if let Some(mut o) = j.child.stdout.take() {
let _ = o.read_to_string(&mut s);
}
Some(match serde_json::from_str::<Value>(s.trim()) {
Ok(v) => v,
Err(_) => json!({"seed": j.seed, "crash": format!("exit {status}"), "at": j.last}),
})
}
Ok(None) if j.started.elapsed() > timeout => {
let _ = j.child.kill();
let _ = j.child.wait();
Some(json!({"seed": j.seed, "timeout": timeout.as_secs(), "at": j.last}))
}
Ok(None) => None,
Err(e) => Some(json!({"seed": j.seed, "crash": e.to_string(), "at": j.last})),
};
match done {
Some(v) => {
record(&v, out_dir.as_deref());
results.push(v);
running.swap_remove(i);
}
None => i += 1,
}
}
}
let summary = summarise(&results, t0.elapsed());
println!("{summary}");
if let Some(r) = report {
std::fs::write(&r, &summary).map_err(|e| format!("{r}: {e}"))?;
}
Ok(())
}
/// Print a failure as it comes in and keep its script.
fn record(v: &Value, out_dir: Option<&str>) {
let seed = v["seed"].as_u64().unwrap_or(0);
let d = &v["design"];
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
}
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
if bk != "ok" {
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
}
if let Some(dir) = out_dir {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(format!("{dir}/results.jsonl")) {
let _ = writeln!(f, "{v}");
}
if bug {
let script = if d.get("minimal").is_some() { &d["minimal"]["script"] } else { &d["script"] };
let _ = std::fs::write(format!("{dir}/design-{seed}.json"), serde_json::to_string_pretty(script).unwrap_or_default());
}
if bk != "ok" {
let _ = std::fs::write(format!("{dir}/boolean-{seed}.json"), serde_json::to_string_pretty(&v["boolean"]).unwrap_or_default());
}
}
}
fn summarise(results: &[Value], took: Duration) -> String {
let n = results.len().max(1);
let mut design: BTreeMap<String, usize> = BTreeMap::new();
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
let mut steps = 0;
let (mut dp, mut bp) = (0, 0);
for v in results {
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
let k = if v.get("timeout").is_some() {
"timeout".to_string()
} else if v.get("crash").is_some() {
"crash".to_string()
} else {
v["design"]["outcome"]["kind"].as_str().unwrap_or("?").to_string()
};
if k != "ok" {
let last = v["design"]["steps"].as_array().and_then(|a| a.last()).and_then(Value::as_str).unwrap_or("?");
*at_step.entry(format!("{k} at {last}")).or_default() += 1;
}
*design.entry(k).or_default() += 1;
*boolean.entry(v["boolean"]["outcome"].as_str().unwrap_or("not run").to_string()).or_default() += 1;
steps += v["design"]["steps"].as_array().map(Vec::len).unwrap_or(0);
for u in v["design"]["unsupported"].as_array().into_iter().flatten() {
*unsupported.entry(u[0].as_str().unwrap_or("?").to_string()).or_default() += 1;
}
}
let pct = |c: usize| format!("{c} ({:.1} %)", 100.0 * c as f64 / n as f64);
let mut s = format!("{} seeds, {steps} design steps, {:.0} s\n\nDesigns:\n", results.len(), took.as_secs_f64());
for (k, c) in &design {
s += &format!("- {k}: {}\n", pct(*c));
}
s += "\nFailures by step:\n";
for (k, c) in &at_step {
s += &format!("- {k}: {c}\n");
}
s += "\nSteps refused as not supported (undone, the design went on):\n";
for (k, c) in &unsupported {
s += &format!("- {k}: {c}\n");
}
s += &format!("\nCaught panics (fatal in the web build): designs {}, boolean checks {}\n", pct(dp), pct(bp));
s += "\nBoolean identity (union, intersection, cut of two primitives):\n";
for (k, c) in &boolean {
s += &format!("- {k}: {}\n", pct(*c));
}
s
}
+88 -10
View File
@@ -10,10 +10,13 @@
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod fuzz;
mod oracle;
mod recipe;
mod seams;
@@ -33,8 +36,11 @@ const USAGE: &str = "usage:
solvecraft-cli commands
solvecraft-cli recipe <recipe.json>
solvecraft-cli oracle <case-dir>... [--json]
solvecraft-cli step-corpus <case-dir>... [--json]
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solvecraft-cli step-corpus <case-dir>... [--json] [--iges]
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
solvecraft-cli licences
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
";
fn main() -> ExitCode {
@@ -54,7 +60,16 @@ fn main() -> ExitCode {
}),
Some("oracle") => oracle::run(&args[1..]),
Some("step-corpus") => oracle::step_corpus(&args[1..]),
Some("fuzz") => fuzz::run(&args[1..]),
Some("fuzz-one") => fuzz::one(&args[1..]),
Some("mcp") => cmd_mcp(&args[1..]),
Some("icon") => cmd_icon(&args[1..]),
Some("licences" | "licenses") => {
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
Ok(())
}
// Hidden, for the kill-mid-save test: save a large design over and over.
Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md).
@@ -150,13 +165,13 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
_ => {}
}
for out in flags(args, "--out") {
s.execute("ExportCommand", &json!({"path": out})).map_err(|e| e.to_string())?;
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
}
if let Some(p) = flag(args, "--save") {
s.execute("SaveDocumentAsCommand", &json!({"path": p})).map_err(|e| e.to_string())?;
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if !args.iter().any(|a| a == "--quiet") {
let m = s.execute("MeasureCommand", &json!({})).map_err(|e| e.to_string())?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
println!("{}", pretty(&m));
}
Ok(())
@@ -165,7 +180,7 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
fn cmd_eval(args: &[String]) -> Result<(), String> {
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
let mut s = load(path)?;
let m = s.execute("MeasureCommand", &json!({})).map_err(|e| e.to_string())?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
"body_count": m["body_count"],
@@ -196,6 +211,69 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
Ok(())
}
/// The app icon: a blue block with a bore, drawn by the CPU renderer on a transparent background
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
fn cmd_icon(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
let size: usize = flag(args, "--size").and_then(|x| x.parse().ok()).unwrap_or(256).clamp(8, 2048);
let bytes = if out.to_ascii_lowercase().ends_with(".ico") {
let pngs: Vec<(usize, Vec<u8>)> =
[16, 20, 24, 32, 40, 48, 64, 128, 256].into_iter().map(|s| icon_png(s).map(|p| (s, p))).collect::<Result<_, _>>()?;
ico(&pngs)?
} else {
icon_png(size)?
};
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "bytes": bytes.len()})));
Ok(())
}
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
use solvecraft_engine::render::{Rgb, Scene, SceneLine, SceneMesh, png, render_transparent};
let mut s = Session::default();
for (cmd, p) in [
("solid.box", json!({"length": 20, "width": 20, "height": 20, "center": [0, 0, 0]})),
("solid.cylinder", json!({"diameter": 9, "height": 30, "base": [0, 0, -15], "operation": "cut"})),
] {
s.execute(cmd, &p).map_err(|e| format!("icon: {e}"))?;
}
let mut cam = solvecraft_engine::view::home_camera(&s);
cam.distance *= 0.95;
let mut scene = Scene { radius: 60.0, ..Default::default() };
let px = size as f64 / 256.0;
for body in &s.world_state().bodies {
let mesh = body.mesh();
scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240), face_colors: Vec::new() });
for (i, e) in mesh.edges.iter().enumerate() {
if !mesh.seams.get(i).copied().unwrap_or(false) {
scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false });
}
}
}
let c = render_transparent(&mut scene, &cam, size, size);
png(c.w, c.h, c.rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
}
/// A Windows icon file holding PNG images (Vista and later read these).
fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
let n = u16::try_from(images.len()).map_err(|_| "too many icon sizes")?;
let mut out = Vec::new();
out.extend_from_slice(&[0, 0, 1, 0]);
out.extend_from_slice(&n.to_le_bytes());
let mut offset = 6 + 16 * images.len();
for (size, data) in images {
let dim = if *size >= 256 { 0 } else { *size as u8 };
out.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
out.extend_from_slice(&u32::try_from(data.len()).map_err(|_| "icon too large")?.to_le_bytes());
out.extend_from_slice(&u32::try_from(offset).map_err(|_| "icon too large")?.to_le_bytes());
offset += data.len();
}
for (_, data) in images {
out.extend_from_slice(data);
}
Ok(out)
}
fn cmd_exec(args: &[String]) -> Result<(), String> {
let id = args.first().ok_or_else(|| USAGE.to_string())?;
let p: Value = match args.get(1) {
@@ -239,7 +317,7 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
s.doc_mut().params.extend(filler);
for r in 0..rounds {
s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?;
s.execute("SaveDocumentCommand", &json!({ "path": path })).map_err(|e| e.to_string())?;
s.execute("file.save", &json!({ "path": path })).map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -266,10 +344,10 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
Ok(())
};
let expr = expr.replace("{old}", &old);
time(&mut s, "edit", "ChangeParameterCommand", json!({"name": param, "expression": expr}))?;
time(&mut s, "set back", "ChangeParameterCommand", json!({"name": param, "expression": old}))?;
time(&mut s, "undo", "UndoCommand", json!({}))?;
time(&mut s, "redo", "RedoCommand", json!({}))?;
time(&mut s, "edit", "parameters.change", json!({"name": param, "expression": expr}))?;
time(&mut s, "set back", "parameters.change", json!({"name": param, "expression": old}))?;
time(&mut s, "undo", "edit.undo", json!({}))?;
time(&mut s, "redo", "edit.redo", json!({}))?;
println!("{}", pretty(&json!(out)));
Ok(())
}
+15 -6
View File
@@ -31,7 +31,7 @@ fn check_case(dir: &str) -> Value {
if let Err(e) = s.run_script(&script) {
return json!({"case": name, "pass": false, "error": e.to_string()});
}
let got = match s.execute("MeasureCommand", &json!({})) {
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
@@ -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}),
@@ -183,7 +184,7 @@ fn check_step_case(dir: &str) -> Value {
Ok(v) => v,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let got = match s.execute("MeasureCommand", &json!({})) {
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
@@ -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(()) }
+53 -53
View File
@@ -10,7 +10,7 @@ fn plane_param(pl: &Value) -> Value {
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
let std = match r.to_ascii_uppercase().as_str() {
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, -1.0])),
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
_ => None,
};
@@ -44,23 +44,23 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
if let Some(n) = f.get("name") {
p["name"] = n.clone();
}
out.push(json!({"command": "SketchCreate", "params": p}));
out.push(json!({"command": "sketch.create", "params": p}));
for e in f.get("entities").and_then(Value::as_array).into_iter().flatten() {
let id = e.get("id").cloned().unwrap_or(Value::Null);
let construction = e.get("construction").cloned().unwrap_or(json!(false));
match e.get("type").and_then(Value::as_str) {
Some("line") => out.push(json!({"command": "DrawPolyline", "params": {"points": [parg(e.get("start")), parg(e.get("end"))], "ids": [id], "construction": construction}})),
Some("circle") => out.push(json!({"command": "CircleCenterRadius", "params": {"center": parg(e.get("center")), "radius": e.get("radius"), "id": id, "construction": construction}})),
Some("arc_three_point") => out.push(json!({"command": "ArcThreePoint", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": e.get("mid").or(e.get("through")), "id": id}})),
Some("arc_center_start_sweep") => out.push(json!({"command": "ArcCenterTwoPoint", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "sweep": e.get("sweep_deg").or(e.get("sweep")), "id": id}})),
Some("line") => out.push(json!({"command": "sketch.line", "params": {"points": [parg(e.get("start")), parg(e.get("end"))], "ids": [id], "construction": construction}})),
Some("circle") => out.push(json!({"command": "sketch.circle.center", "params": {"center": parg(e.get("center")), "radius": e.get("radius"), "id": id, "construction": construction}})),
Some("arc_three_point") => out.push(json!({"command": "sketch.arc.three_point", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": e.get("mid").or(e.get("through")), "id": id}})),
Some("arc_center_start_sweep") => out.push(json!({"command": "sketch.arc.center_point", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "sweep": e.get("sweep_deg").or(e.get("sweep")), "id": id}})),
Some("arc") => {
if e.get("center").is_some() {
out.push(json!({"command": "ArcCenterTwoPoint", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "end": parg(e.get("end")), "id": id}}))
out.push(json!({"command": "sketch.arc.center_point", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "end": parg(e.get("end")), "id": id}}))
} else {
out.push(json!({"command": "ArcThreePoint", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": parg(e.get("through").or(e.get("mid"))), "id": id}}))
out.push(json!({"command": "sketch.arc.three_point", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": parg(e.get("through").or(e.get("mid"))), "id": id}}))
}
}
Some("point") => out.push(json!({"command": "DrawPoint", "params": {"point": parg(e.get("at").map(|a| json!({"at": a})).as_ref().or(Some(e))), "id": id}})),
Some("point") => out.push(json!({"command": "sketch.point", "params": {"point": parg(e.get("at").map(|a| json!({"at": a})).as_ref().or(Some(e))), "id": id}})),
// A 3D helix fit: the sweep along it becomes a coil (see `helix_path`).
Some("fitted_spline") if e.get("fit_points").and_then(Value::as_array).is_some_and(|a| a.iter().any(|p| p.get(2).is_some())) => {}
Some(other) => return Err(format!("recipe: sketch entity type `{other}` is not supported yet")),
@@ -77,9 +77,9 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
p[k] = v.clone();
}
}
out.push(json!({"command": "SketchDimension", "params": p}));
out.push(json!({"command": "sketch.dimension", "params": p}));
}
out.push(json!({"command": "SketchStop", "params": {}}));
out.push(json!({"command": "sketch.finish", "params": {}}));
Ok(())
}
@@ -138,33 +138,33 @@ fn constraint_cmd(c: &Value) -> Result<Value, String> {
let at = |i: usize| r.get(i).cloned().unwrap_or(Value::Null);
let is_point = |v: &Value| v.as_str().is_some_and(|s| s.contains('.') || s == "origin" || s.starts_with('p'));
let (cmd, p) = match ty.as_str() {
"horizontal" | "vertical" if r.len() >= 2 => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": ty})),
"horizontal" | "vertical" => ("ConstraintHorizontalVertical", json!({"line": at(0), "mode": ty})),
"horizontal_points" => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": "horizontal"})),
"vertical_points" => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": "vertical"})),
"horizontal" | "vertical" if r.len() >= 2 => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": ty})),
"horizontal" | "vertical" => ("sketch.constraint.horizontal_vertical", json!({"line": at(0), "mode": ty})),
"horizontal_points" => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": "horizontal"})),
"vertical_points" => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": "vertical"})),
"coincident" | "point_on_curve" => {
// The point goes first.
if is_point(&at(0)) {
("ConstraintCoincident", json!({"a": at(0), "b": at(1)}))
("sketch.constraint.coincident", json!({"a": at(0), "b": at(1)}))
} else {
("ConstraintCoincident", json!({"a": at(1), "b": at(0)}))
("sketch.constraint.coincident", json!({"a": at(1), "b": at(0)}))
}
}
"midpoint" => {
if is_point(&at(0)) {
("ConstraintMidPoint", json!({"point": at(0), "line": at(1)}))
("sketch.constraint.midpoint", json!({"point": at(0), "line": at(1)}))
} else {
("ConstraintMidPoint", json!({"point": at(1), "line": at(0)}))
("sketch.constraint.midpoint", json!({"point": at(1), "line": at(0)}))
}
}
"symmetric" | "symmetry" => ("ConstraintSymmetry", json!({"a": at(0), "b": at(1), "line": at(2)})),
"fix" => ("ConstraintFix", json!({"entity": at(0), "fixed": true})),
"tangent" => ("ConstraintTangent", json!({"a": at(0), "b": at(1)})),
"equal" => ("ConstraintEqual", json!({"a": at(0), "b": at(1)})),
"parallel" => ("ConstraintParallel", json!({"a": at(0), "b": at(1)})),
"perpendicular" => ("ConstraintPerpendicular", json!({"a": at(0), "b": at(1)})),
"concentric" => ("ConstraintConcentric", json!({"a": at(0), "b": at(1)})),
"collinear" => ("ConstraintCollinear", json!({"a": at(0), "b": at(1)})),
"symmetric" | "symmetry" => ("sketch.constraint.symmetry", json!({"a": at(0), "b": at(1), "line": at(2)})),
"fix" => ("sketch.constraint.fix", json!({"entity": at(0), "fixed": true})),
"tangent" => ("sketch.constraint.tangent", json!({"a": at(0), "b": at(1)})),
"equal" => ("sketch.constraint.equal", json!({"a": at(0), "b": at(1)})),
"parallel" => ("sketch.constraint.parallel", json!({"a": at(0), "b": at(1)})),
"perpendicular" => ("sketch.constraint.perpendicular", json!({"a": at(0), "b": at(1)})),
"concentric" => ("sketch.constraint.concentric", json!({"a": at(0), "b": at(1)})),
"collinear" => ("sketch.constraint.collinear", json!({"a": at(0), "b": at(1)})),
other => return Err(format!("recipe: constraint `{other}` is not supported yet")),
};
Ok(json!({"command": cmd, "params": p}))
@@ -294,7 +294,7 @@ fn circle_fit(pts: &[[f64; 3]]) -> Option<(f64, f64)> {
pub fn to_script(recipe: &Value) -> Result<Value, String> {
let mut out: Vec<Value> = Vec::new();
for p in recipe.get("parameters").and_then(Value::as_array).into_iter().flatten() {
out.push(json!({"command": "ChangeParameterCommand", "params": {"name": p.get("name"), "expression": p.get("expression").or(p.get("value")), "unit": p.get("unit").cloned().unwrap_or(json!("mm"))}}));
out.push(json!({"command": "parameters.change", "params": {"name": p.get("name"), "expression": p.get("expression").or(p.get("value")), "unit": p.get("unit").cloned().unwrap_or(json!("mm"))}}));
}
let features = recipe.get("features").and_then(Value::as_array).ok_or("recipe: missing `features`")?;
for f in features {
@@ -305,9 +305,9 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
Some("extrude") => {
let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies});
extent(f, &mut p);
out.push(json!({"command": "Extrude", "params": p}));
out.push(json!({"command": "solid.extrude", "params": p}));
}
Some("combine") => out.push(json!({"command": "FusionCombineCommand", "params": {
Some("combine") => out.push(json!({"command": "solid.combine", "params": {
"target": f.get("target_body").or(f.get("target")), "tools": f.get("tool_bodies").or(f.get("tools")),
"operation": op(f), "keep_tools": f.get("keep_tools").cloned().unwrap_or(json!(false)), "name": name}})),
Some("revolve") => {
@@ -316,7 +316,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
Some(a) => a.get("line").or(a.get("ref")).cloned().unwrap_or_else(|| a.clone()),
None => Value::Null,
};
out.push(json!({"command": "Revolve", "params": {
out.push(json!({"command": "solid.revolve", "params": {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "axis": axis,
"angle": f.get("angle").cloned().unwrap_or(json!("360 deg")), "operation": op(f), "name": name, "body_names": bodies}}));
}
@@ -329,11 +329,11 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
p["count2"] = d2.get("count").cloned().unwrap_or(Value::Null);
p["spacing2"] = d2.get("spacing").cloned().unwrap_or(Value::Null);
}
out.push(json!({"command": "PatternRectangular", "params": p}));
out.push(json!({"command": "solid.pattern.rectangular", "params": p}));
}
Some("circular_pattern") => {
let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null);
out.push(json!({"command": "PatternCircular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
out.push(json!({"command": "solid.pattern.circular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
}
Some("sm_flange") => match f.get("type").and_then(Value::as_str) {
Some("edge") => {
@@ -344,11 +344,11 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("flip").and_then(Value::as_bool) == Some(true) {
p["flip"] = json!(true);
}
out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": p}));
out.push(json!({"command": "sheet.flange", "params": p}));
}
Some("contour") => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": {
Some("contour") => out.push(json!({"command": "sheet.flange", "params": {
"type": "contour", "sketch": f.get("profile_sketch"), "curves": f.get("profile_curves"), "distance": f.get("distance"), "name": name}})),
_ => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": {
_ => out.push(json!({"command": "sheet.flange", "params": {
"type": "base", "sketch": profile_sketch(f), "profiles": profiles_of(f), "name": name, "body_names": bodies}})),
},
Some("sheet_metal_rule_edit") => {
@@ -368,7 +368,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
p["thickness"] = json!(format!("{v} {u}"));
}
}
out.push(json!({"command": "FusionSheetMetalRulesCommand", "params": p}));
out.push(json!({"command": "sheet.manage_rules", "params": p}));
}
Some("sm_hem") => {
let e = f.get("edge").and_then(|e| e.get("point")).cloned().unwrap_or(Value::Null);
@@ -376,9 +376,9 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("flipped").and_then(Value::as_bool) == Some(true) {
p["flip"] = json!(true);
}
out.push(json!({"command": "FusionSheetMetalHemFlangeCommand", "params": p}));
out.push(json!({"command": "sheet.hem", "params": p}));
}
Some("sm_unfold") => out.push(json!({"command": "FusionSheetmetalUnfoldCommand", "params": {"name": name}})),
Some("sm_unfold") => out.push(json!({"command": "sheet.unfold", "params": {"name": name}})),
Some("sm_refold") => out.push(json!({"command": "sheet.refold", "params": {"name": name}})),
Some("path_pattern") => {
let path = f.get("path").cloned().unwrap_or(Value::Null);
@@ -392,7 +392,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
} else {
p["features"] = feature_refs(f.get("features"));
}
out.push(json!({"command": "PatternOnPath", "params": p}));
out.push(json!({"command": "solid.pattern.path", "params": p}));
}
Some("loft") => {
let sections: Vec<Value> = f
@@ -405,24 +405,24 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
None => json!({"sketch": sec.get("sketch"), "profiles": [sec]}),
})
.collect();
out.push(json!({"command": "SolidLoft", "params": {"sections": sections, "operation": op(f), "name": name, "body_names": bodies}}));
out.push(json!({"command": "solid.loft", "params": {"sections": sections, "operation": op(f), "name": name, "body_names": bodies}}));
}
Some("sweep") if helix_path(features, f).is_some() => {
// A swept circle along a fitted 3D helix: a coil (the true helix).
let Some((base, diameter, pitch, turns, start, cw, size)) = helix_path(features, f) else { continue };
out.push(json!({"command": "PrimitiveCoil", "params": {
out.push(json!({"command": "solid.coil", "params": {
"base": base, "diameter": diameter, "pitch": pitch, "revolutions": turns, "section_size": size,
"start_angle": format!("{start} rad"), "clockwise": cw, "operation": op(f), "name": name, "body_names": bodies}}));
}
Some("sweep") => {
let path = f.get("path").cloned().unwrap_or(Value::Null);
out.push(json!({"command": "Sweep", "params": {
out.push(json!({"command": "solid.sweep", "params": {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
"operation": op(f), "name": name, "body_names": bodies}}));
}
Some("shell") => {
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
out.push(json!({"command": "FusionShellBodyCommand", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
}
Some("draft") => {
let faces: Vec<Value> = f.get("faces").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
@@ -433,7 +433,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("flipped").and_then(Value::as_bool).unwrap_or(false) {
angle = -angle;
}
out.push(json!({"command": "FusionDraftCommand", "params": {"faces": faces, "angle": angle, "neutral": neutral, "pull": n, "name": name}}));
out.push(json!({"command": "solid.draft", "params": {"faces": faces, "angle": angle, "neutral": neutral, "pull": n, "name": name}}));
}
Some("thread") => {
let face = f.get("face").and_then(|x| x.get("point")).cloned().unwrap_or(Value::Null);
@@ -443,7 +443,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
{
p["length"] = l.clone();
}
out.push(json!({"command": "FusionThreadCommand", "params": p}));
out.push(json!({"command": "solid.thread", "params": p}));
}
Some("hole") => {
let n = f.get("face").and_then(|fc| fc.get("plane_normal_outward").or(fc.get("normal_at_point_on_face"))).cloned();
@@ -495,20 +495,20 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
p[k] = v.clone();
}
}
out.push(json!({"command": "FusionHoleCommand", "params": p}));
out.push(json!({"command": "solid.hole", "params": p}));
}
Some("construction_plane") => match f.get("method").and_then(Value::as_str) {
Some("offset") => out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})),
Some("offset") => out.push(json!({"command": "construct.plane.offset", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})),
_ => {
// Use the resulting plane as recorded.
let (o, n) = (f.get("result_origin").cloned().unwrap_or(json!([0, 0, 0])), f.get("result_normal").cloned().unwrap_or(Value::Null));
if n.is_null() {
return Err(format!("recipe: construction plane method {:?} without result_normal", f.get("method")));
}
out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": {"origin": o, "normal": n}, "offset": 0, "name": name}}));
out.push(json!({"command": "construct.plane.offset", "params": {"base": {"origin": o, "normal": n}, "offset": 0, "name": name}}));
}
},
Some("split_body") => out.push(json!({"command": "FusionSplitBodyCommand", "params": {"body": f.get("body"), "plane": f.get("tool"), "name": name}})),
Some("split_body") => out.push(json!({"command": "solid.split_body", "params": {"body": f.get("body"), "plane": f.get("tool"), "name": name}})),
Some("mirror") => {
let pl = f.get("plane").cloned().unwrap_or(Value::Null);
let plane = match pl.get("ref").and_then(Value::as_str) {
@@ -519,13 +519,13 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("mirror_of").and_then(Value::as_str) == Some("bodies") {
p = json!({"bodies": f.get("bodies"), "combine": f.get("combine").cloned().unwrap_or(json!(false)), "plane": plane, "name": name});
}
out.push(json!({"command": "MirrorCommand", "params": p}));
out.push(json!({"command": "solid.mirror", "params": p}));
}
Some("fillet") => {
out.push(json!({"command": "FusionFilletEdgesCommand", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
out.push(json!({"command": "solid.fillet", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
}
Some("chamfer") => {
out.push(json!({"command": "FusionChamferCommand", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
out.push(json!({"command": "solid.chamfer", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
}
Some(other) => return Err(format!("recipe: feature op `{other}` is not supported yet")),
None => return Err("recipe: feature without `op`".into()),
@@ -1,15 +1,15 @@
{"commands": [
{"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
{"command": "FusionCreateNewComponentCommand", "params": {"name": "Arm"}},
{"command": "PrimitiveBox", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[25, 4, 4]], "radius": 1}},
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
{"command": "component.create", "params": {"name": "Arm"}},
{"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}},
{"command": "solid.fillet", "params": {"edges": [[25, 4, 4]], "radius": 1}},
{"command": "component.activate", "params": {"component": 0}},
{"command": "occurrence.move", "params": {"occurrence": 3, "translate": [0, 50, 0]}},
{"command": "occurrence.copy", "params": {"component": "Arm", "translate": [0, 20, 0]}},
{"command": "component.paste_new", "params": {"component": "Arm", "translate": [0, -30, 0]}},
{"command": "occurrence.ground", "params": {"occurrence": 3}},
{"command": "SketchCreate", "params": {"plane": "XY", "name": "S1"}},
{"command": "CircleCenterRadius", "params": {"center": [5, 5], "radius": 2}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 10, "operation": "cut"}}
{"command": "sketch.create", "params": {"plane": "XY", "name": "S1"}},
{"command": "sketch.circle.center", "params": {"center": [5, 5], "radius": 2}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10, "operation": "cut"}}
]}
@@ -1,14 +1,14 @@
{"commands": [
{"command": "ChangeParameterCommand", "params": {"name": "open_angle", "expression": "90 deg"}},
{"command": "FusionCreateNewComponentCommand", "params": {"name": "LeafA"}},
{"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "parameters.change", "params": {"name": "open_angle", "expression": "90 deg"}},
{"command": "component.create", "params": {"name": "LeafA"}},
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "component.activate", "params": {"component": "root"}},
{"command": "FusionCreateNewComponentCommand", "params": {"name": "LeafB"}},
{"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "component.create", "params": {"name": "LeafB"}},
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "component.activate", "params": {"component": "root"}},
{"command": "occurrence.ground", "params": {"occurrence": 2, "grounded": true}},
{"command": "JointAssembleCmdNew", "params": {"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}},
{"command": "FusionMoveJointsCommand", "params": {"joint": "Hinge", "value": "open_angle"}}
{"command": "joint.create", "params": {"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}},
{"command": "joint.drive", "params": {"joint": "Hinge", "value": "open_angle"}}
]}
@@ -1,12 +1,12 @@
{"commands": [
{"command": "ChangeParameterCommand", "params": {"name": "width", "expression": "40 mm"}},
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": "width / 2"}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 10]], "radius": 3}},
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "CircleCenterRadius", "params": {"center": [20, 15], "radius": 5}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 20, "operation": "cut"}}
{"command": "parameters.change", "params": {"name": "width", "expression": "40 mm"}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "width / 2"}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.circle.center", "params": {"center": [20, 15], "radius": 5}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 20, "operation": "cut"}}
]}
@@ -1,11 +1,11 @@
{"commands": [
{"command": "SketchCreate", "params": {"plane": "XZ"}},
{"command": "DrawPolyline", "params": {"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}},
{"command": "SketchStop", "params": {}},
{"command": "Revolve", "params": {"axis": "y"}},
{"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}},
{"command": "PrimitiveCylinder", "params": {"diameter": 4, "height": 20, "base": [35, 5, -5]}},
{"command": "FusionCombineCommand", "params": {"target": "Body2", "tools": ["Body3"], "operation": "cut"}},
{"command": "FusionChamferCommand", "params": {"edges": [[30, 5, 10]], "distance": 1}},
{"command": "sketch.create", "params": {"plane": "XZ"}},
{"command": "sketch.line", "params": {"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.revolve", "params": {"axis": "y"}},
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}},
{"command": "solid.cylinder", "params": {"diameter": 4, "height": 20, "base": [35, 5, -5]}},
{"command": "solid.combine", "params": {"target": "Body2", "tools": ["Body3"], "operation": "cut"}},
{"command": "solid.chamfer", "params": {"edges": [[30, 5, 10]], "distance": 1}},
{"command": "timeline.edit", "params": {"feature": "Revolve1", "set": {"angle": "180 deg"}}}
]}
@@ -1,14 +1,14 @@
{
"commands": [
{
"command": "SketchCreate",
"command": "sketch.create",
"params": {
"name": "Plate_sketch",
"plane": "XY"
}
},
{
"command": "DrawPolyline",
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
@@ -27,7 +27,7 @@
}
},
{
"command": "DrawPolyline",
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
@@ -43,7 +43,7 @@
}
},
{
"command": "DrawPolyline",
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
@@ -59,7 +59,7 @@
}
},
{
"command": "DrawPolyline",
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
@@ -72,11 +72,11 @@
}
},
{
"command": "SketchStop",
"command": "sketch.finish",
"params": {}
},
{
"command": "Extrude",
"command": "solid.extrude",
"params": {
"body_names": [
"Plate"
@@ -110,14 +110,14 @@
}
},
{
"command": "SketchCreate",
"command": "sketch.create",
"params": {
"name": "PathSketch",
"plane": "XY"
}
},
{
"command": "ArcThreePoint",
"command": "sketch.arc.three_point",
"params": {
"end": [
20,
@@ -135,7 +135,7 @@
}
},
{
"command": "CircleCenterRadius",
"command": "sketch.circle.center",
"params": {
"center": "a1.start",
"construction": false,
@@ -144,11 +144,11 @@
}
},
{
"command": "SketchStop",
"command": "sketch.finish",
"params": {}
},
{
"command": "Extrude",
"command": "solid.extrude",
"params": {
"body_names": [
"Plate"
@@ -179,7 +179,7 @@
}
},
{
"command": "PatternOnPath",
"command": "solid.pattern.path",
"params": {
"count": 7,
"features": [
@@ -1,10 +1,10 @@
{"commands": [
{"command": "ChangeParameterCommand", "params": {"name": "wall", "expression": "2 mm"}},
{"command": "PrimitiveBox", "params": {"length": 80, "width": 50, "height": 30}},
{"command": "FusionShellBodyCommand", "params": {"faces": [[40, 25, 30]], "thickness": "wall"}},
{"command": "FusionLipCommand", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
{"command": "FusionBossCommand", "params": {"position": [20, 20, 2], "diameter": 6, "height": 10, "hole_diameter": 2.5, "draft": "1 deg"}},
{"command": "FusionRestCommand", "params": {"position": [60, 30, 2], "width": 8, "length": 12, "height": 3, "thickness": 1}},
{"command": "FusionSnapFitCommand", "params": {"position": [40, 12, 2], "direction": [0, 0, 1], "hook": [0, 1, 0], "length": 12, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
{"command": "FusionManagePlasticRuleCommand", "params": {"name": "ABS (1.5mm)", "active": true}}
{"command": "parameters.change", "params": {"name": "wall", "expression": "2 mm"}},
{"command": "solid.box", "params": {"length": 80, "width": 50, "height": 30}},
{"command": "solid.shell", "params": {"faces": [[40, 25, 30]], "thickness": "wall"}},
{"command": "plastic.lip", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
{"command": "plastic.boss", "params": {"position": [20, 20, 2], "diameter": 6, "height": 10, "hole_diameter": 2.5, "draft": "1 deg"}},
{"command": "plastic.rest", "params": {"position": [60, 30, 2], "width": 8, "length": 12, "height": 3, "thickness": 1}},
{"command": "plastic.snap_fit", "params": {"position": [40, 12, 2], "direction": [0, 0, 1], "hook": [0, 1, 0], "length": 12, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
{"command": "plastic.manage_rules", "params": {"name": "ABS (1.5mm)", "active": true}}
]}
@@ -1,7 +1,7 @@
{
"commands": [
{
"command": "SketchCreate",
"command": "sketch.create",
"params": {
"name": "Contour",
"plane": {
@@ -24,7 +24,7 @@
}
},
{
"command": "DrawPolyline",
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
@@ -43,7 +43,7 @@
}
},
{
"command": "DrawPolyline",
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
@@ -59,7 +59,7 @@
}
},
{
"command": "DrawPolyline",
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
@@ -75,11 +75,11 @@
}
},
{
"command": "SketchStop",
"command": "sketch.finish",
"params": {}
},
{
"command": "FusionSheetMetalFlangeCommand",
"command": "sheet.flange",
"params": {
"curves": [
"l1",
+26 -11
View File
@@ -57,7 +57,7 @@ fn old_designs_open_and_evaluate_as_saved() {
}
}
assert_eq!(s.doc.format, solvecraft_engine::doc::format::format_string(solvecraft_engine::doc::format::FORMAT), "{name}: upgraded");
let got = s.execute("MeasureCommand", &json!({})).unwrap();
let got = s.execute("inspect.measure", &json!({})).unwrap();
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
}
@@ -145,7 +145,7 @@ fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
fn try_bytes(bytes: &[u8]) {
if let Ok(doc) = solvecraft_engine::io::read_design(bytes) {
let mut s = Session::new(doc);
let _ = s.execute("MeasureCommand", &json!({}));
let _ = s.execute("inspect.measure", &json!({}));
let _ = s.execute("document.inspect", &json!({}));
}
}
@@ -153,7 +153,8 @@ fn try_bytes(bytes: &[u8]) {
#[test]
fn damaged_and_hostile_design_files_never_panic() {
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
let mut failures = Vec::new();
// The cases come from one seeded sequence; trying them is independent, so threads share it.
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
for p in corpus() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let bytes = std::fs::read(&p).unwrap();
@@ -198,14 +199,28 @@ fn damaged_and_hostile_design_files_never_panic() {
cases.push(("features repeated".into(), v.to_string().into_bytes()));
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
for (what, b) in cases {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(&b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
}
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let failures: Vec<String> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut failures = Vec::new();
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
failures
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
});
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
}
+12 -2
View File
@@ -22,10 +22,20 @@ fn killed_mid_save_the_old_file_survives() {
assert_eq!(round_of(&path).as_deref(), Some("0"));
let mut kills = 0;
let mut seen = std::collections::BTreeSet::new();
let modified = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
for i in 0..25u64 {
let before = modified();
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
// Let it get into its save loop (building the design takes a moment), then kill it.
std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200));
// Wait until it is in its save loop (its first save replaced the file) and has reached a
// round that varies from kill to kill, however busy the machine is; then kill it a
// varying while later.
let target = i % 10;
let reached = || modified() != before && round_of(&path).and_then(|r| r.parse::<u64>().ok()).is_some_and(|r| r >= target);
let t0 = std::time::Instant::now();
while !reached() && t0.elapsed() < Duration::from_secs(20) && child.try_wait().unwrap().is_none() {
std::thread::sleep(Duration::from_millis(2));
}
std::thread::sleep(Duration::from_millis((i * 7) % 30));
if child.try_wait().unwrap().is_none() {
child.kill().unwrap();
kills += 1;
+8 -1
View File
@@ -23,4 +23,11 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location"] }
js-sys = "0.3"
serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget",
] }
+476
View File
@@ -0,0 +1,476 @@
//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into
//! the engine's in-memory files (`solvecraft_engine::io::vfs`) at start, written back whenever a
//! command writes one. Files come in from the computer through a file picker (the File System
//! Access API where the browser has it, an `<input type=file>` otherwise) or a drop on the page,
//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
//! in OPFS too and offered again after a reload or a crash.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
use solvecraft_engine::io::vfs;
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
/// Where OPFS files appear to the engine.
pub const OPFS: &str = "/opfs/";
/// Paths written here are handed to the user as downloads.
pub const DOWNLOADS: &str = "/downloads/";
/// Files brought in from the computer.
pub const UPLOAD: &str = "/upload/";
/// The autosave of unsaved work (in OPFS: `recovery.solvecraft` and `recovery.json`).
const RECOVERY: &str = "recovery.solvecraft";
const RECOVERY_META: &str = "recovery.json";
/// What to do with a file brought in from the computer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Intent {
/// Open it (a design, or STEP/mesh as a new design).
Open,
/// Add its bodies to the open design (STEP, 3MF, STL).
Insert,
}
/// A stored design, as the file panel lists it.
#[derive(Clone, Debug)]
pub struct Entry {
pub name: String,
pub size: u64,
/// Last written (ms since 1970; 0 when unknown).
pub modified: f64,
}
/// State shared between the async browser calls and the app's frames.
#[derive(Default)]
pub struct Files {
pub entries: RefCell<Vec<Entry>>,
/// Files read from the computer: (name, bytes, what to do).
pub incoming: RefCell<Vec<(String, Vec<u8>, Intent)>>,
/// Messages for the status line (errors and confirmations).
pub messages: RefCell<Vec<(String, bool)>>,
/// The stored designs are loaded (the app may open them).
pub ready: Cell<bool>,
/// The file panel is open.
pub panel: Cell<bool>,
/// An autosave left by an earlier session: its design's name and file.
pub recovery: RefCell<Option<(String, Option<String>)>>,
/// The app's context, to wake it when a browser call finishes.
pub ctx: RefCell<Option<egui::Context>>,
}
impl Files {
fn say(&self, text: impl Into<String>, error: bool) {
self.messages.borrow_mut().push((text.into(), error));
self.wake();
}
/// A file arrived from the computer.
fn arrive(&self, name: String, bytes: Vec<u8>, intent: Intent) {
self.incoming.borrow_mut().push((name, bytes, intent));
self.wake();
}
/// Run a frame (the app sleeps until something happens).
fn wake(&self) {
if let Some(c) = self.ctx.borrow().as_ref() {
c.request_repaint();
}
}
}
thread_local! {
/// The private file system isn't there (an insecure page: http on a network address, or an
/// old browser): designs are kept in local storage instead.
static LOCAL: Cell<bool> = const { Cell::new(false) };
}
/// Local storage keys of stored files.
const LOCAL_PREFIX: &str = "solvecraft.file.";
fn local() -> Result<web_sys::Storage, String> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).ok_or_else(|| "this browser keeps no local storage".to_string())
}
/// Text as is, anything else as hex (local storage holds strings).
fn encode(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(t) => format!("t{t}"),
Err(_) => {
let mut s = String::with_capacity(bytes.len() * 2 + 1);
s.push('x');
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
}
}
fn decode(s: &str) -> Vec<u8> {
if let Some(t) = s.strip_prefix('t') {
return t.as_bytes().to_vec();
}
let h = s.strip_prefix('x').unwrap_or("");
(0..h.len() / 2).filter_map(|i| h.get(2 * i..2 * i + 2).and_then(|x| u8::from_str_radix(x, 16).ok())).collect()
}
/// Is the browser's storage local storage (no OPFS here)?
pub fn local_only() -> bool {
LOCAL.with(Cell::get)
}
fn err(e: JsValue) -> String {
e.as_string().or_else(|| Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
}
/// The browser's storage manager: missing on insecure pages (http on a network address) and in
/// old browsers, where calling into it would throw.
fn storage_manager() -> Option<web_sys::StorageManager> {
let nav = web_sys::window()?.navigator();
let sm = Reflect::get(&nav, &"storage".into()).ok().filter(|v| v.is_object())?;
Some(sm.unchecked_into())
}
/// Does the storage manager have `method`?
fn has(sm: &web_sys::StorageManager, method: &str) -> bool {
Reflect::get(sm, &method.into()).is_ok_and(|f| f.is_function())
}
async fn root() -> Result<FileSystemDirectoryHandle, String> {
let sm = storage_manager().filter(|sm| has(sm, "getDirectory")).ok_or("this page has no private file storage (it needs https or localhost)")?;
let dir = JsFuture::from(sm.get_directory()).await.map_err(err)?;
dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
}
async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
let file: web_sys::File = JsFuture::from(h.get_file()).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())?;
blob_bytes(&file).await
}
async fn blob_bytes(b: &web_sys::Blob) -> Result<Vec<u8>, String> {
let buf = JsFuture::from(b.array_buffer()).await.map_err(err)?;
Ok(Uint8Array::new(&buf).to_vec())
}
async fn file_handle(dir: &FileSystemDirectoryHandle, name: &str, create: bool) -> Result<FileSystemFileHandle, String> {
let opts = FileSystemGetFileOptions::new();
opts.set_create(create);
JsFuture::from(dir.get_file_handle_with_options(name, &opts)).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())
}
async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
if local_only() {
return local()?
.set_item(&format!("{LOCAL_PREFIX}{name}"), &encode(bytes))
.map_err(|e| format!("local storage is full or blocked: {}", err(e)));
}
let dir = root().await?;
let h = file_handle(&dir, name, true).await?;
let w: FileSystemWritableFileStream =
JsFuture::from(h.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?;
JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?;
JsFuture::from(w.close()).await.map_err(err)?;
Ok(())
}
async fn remove_file(name: &str) -> Result<(), String> {
if local_only() {
return local()?.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
}
let dir = root().await?;
JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
Ok(())
}
/// Every stored file in local storage.
fn read_local() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
let st = local()?;
let mut out = Vec::new();
for i in 0..st.length().unwrap_or(0).min(10_000) {
let Some(k) = st.key(i).ok().flatten() else { continue };
let Some(name) = k.strip_prefix(LOCAL_PREFIX) else { continue };
if let Some(v) = st.get_item(&k).ok().flatten() {
out.push((name.to_string(), decode(&v), 0.0));
}
}
Ok(out)
}
/// Every stored file: (name, bytes). Without OPFS, local storage takes over from here on.
async fn read_all() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
let dir = match root().await {
Ok(d) => d,
Err(e) => {
log::warn!("no OPFS ({e}): designs go to local storage");
LOCAL.with(|l| l.set(true));
return read_local();
}
};
let iter: JsValue = dir.values().into();
let next: Function = Reflect::get(&iter, &"next".into()).map_err(err)?.dyn_into().map_err(|_| "no iterator".to_string())?;
let mut out = Vec::new();
for _ in 0..10_000 {
let step = JsFuture::from(js_sys::Promise::resolve(&next.call0(&iter).map_err(err)?)).await.map_err(err)?;
if Reflect::get(&step, &"done".into()).ok().and_then(|d| d.as_bool()).unwrap_or(true) {
break;
}
let Ok(h) = Reflect::get(&step, &"value".into()).map_err(err)?.dyn_into::<FileSystemFileHandle>() else { continue };
let file =
match JsFuture::from(h.get_file()).await.map_err(err).and_then(|f| f.dyn_into::<web_sys::File>().map_err(|_| "not a file".to_string())) {
Ok(f) => f,
Err(e) => {
log::warn!("{}: {e}", h.name());
continue;
}
};
match blob_bytes(&file).await {
Ok(b) => out.push((h.name(), b, file.last_modified())),
Err(e) => log::warn!("{}: {e}", h.name()),
}
}
Ok(out)
}
/// Ask the browser to keep the storage (not evict it under pressure), then load every stored
/// file into the engine's files.
pub fn start(files: &Rc<Files>) {
if let Some(sm) = storage_manager().filter(|sm| has(sm, "persist"))
&& let Ok(p) = sm.persist()
{
spawn_local(async move {
let _ = JsFuture::from(p).await;
});
}
let files = files.clone();
spawn_local(async move {
match read_all().await {
Ok(all) => {
if local_only() {
files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
}
let mut entries = Vec::new();
for (name, bytes, modified) in all {
if name == RECOVERY_META {
let meta: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
let n = meta["name"].as_str().unwrap_or("Untitled").to_string();
*files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string)));
continue;
}
if name != RECOVERY {
entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified });
}
vfs::insert(&format!("{OPFS}{name}"), bytes);
}
entries.sort_by_key(|a| a.name.to_lowercase());
*files.entries.borrow_mut() = entries;
// An autosave without its design file is nothing to recover.
if !vfs::exists(&format!("{OPFS}{RECOVERY}")) {
*files.recovery.borrow_mut() = None;
}
}
Err(e) => files.say(format!("browser storage: {e}"), true),
}
files.ready.set(true);
files.wake();
});
}
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
pub fn flush(files: &Rc<Files>) {
for (path, bytes) in vfs::take_writes() {
if let Some(name) = path.strip_prefix(OPFS) {
let name = name.to_string();
{
let mut e = files.entries.borrow_mut();
e.retain(|x| x.name != name);
if name != RECOVERY && name != RECOVERY_META {
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
e.sort_by_key(|a| a.name.to_lowercase());
}
}
let files = files.clone();
spawn_local(async move {
if let Err(e) = write_file(&name, &bytes).await {
files.say(format!("{name}: {e}"), true);
}
});
} else if let Some(name) = path.strip_prefix(DOWNLOADS)
&& let Err(e) = download(name, &bytes)
{
files.say(format!("{name}: {e}"), true);
}
}
}
/// Hand `bytes` to the user as a download named `name`.
pub fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let parts = Array::new();
parts.push(&Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?;
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(err)?.dyn_into().map_err(|_| "no anchor".to_string())?;
a.set_href(&url);
a.set_download(name);
a.click();
// Let the browser start the download before the URL goes.
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
}
Ok(())
}
/// Delete a stored design.
pub fn delete(files: &Rc<Files>, name: &str) {
let _ = vfs::remove(&format!("{OPFS}{name}"));
files.entries.borrow_mut().retain(|e| e.name != name);
let (files, name) = (files.clone(), name.to_string());
spawn_local(async move {
if let Err(e) = remove_file(&name).await {
files.say(format!("{name}: {e}"), true);
}
});
}
/// Rename a stored design (a copy under the new name, then the old one goes: not every browser
/// can move OPFS files).
pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
let to = to.trim();
if to.is_empty() || to.contains('/') || to.contains('\\') || to.len() > 200 {
return Err("give a file name without slashes".into());
}
if files.entries.borrow().iter().any(|e| e.name == to) {
return Err(format!("there is already a file named {to}"));
}
let bytes = vfs::read(&format!("{OPFS}{from}")).map_err(|e| e.to_string())?;
vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?;
delete(files, from);
flush(files);
Ok(())
}
/// Keep (or drop) the autosave of the open design.
pub fn autosave(files: &Rc<Files>, doc: Option<(&solvecraft_engine::doc::Document, Option<&str>)>) {
let files = files.clone();
match doc {
Some((d, path)) => {
let bytes = solvecraft_engine::io::write_design(d);
let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string();
spawn_local(async move {
let r = async {
write_file(RECOVERY, &bytes).await?;
write_file(RECOVERY_META, meta.as_bytes()).await
};
if let Err(e) = r.await {
files.say(format!("autosave: {e}"), true);
}
});
}
None => spawn_local(async move {
let _ = remove_file(RECOVERY).await;
let _ = remove_file(RECOVERY_META).await;
}),
}
}
/// A stored path for `name` that no stored file has: `name`, else `name (2)`, `name (3)`…
pub fn free_name(name: &str) -> String {
let (stem, ext) = name.rsplit_once('.').map(|(s, e)| (s.to_string(), format!(".{e}"))).unwrap_or((name.to_string(), String::new()));
for i in 1..1000 {
let n = if i == 1 { name.to_string() } else { format!("{stem} ({i}){ext}") };
let p = format!("{OPFS}{n}");
if !vfs::exists(&p) {
return p;
}
}
format!("{OPFS}{stem} ({}){ext}", js_sys::Date::now() as u64)
}
/// The autosaved design's path (in the engine's files) for opening it.
pub fn recovery_path() -> String {
format!("{OPFS}{RECOVERY}")
}
/// Pick files on the computer: the File System Access picker where there is one, else an
/// `<input type=file>`. Read files arrive in `files.incoming`.
pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
let Some(window) = web_sys::window() else { return };
let picker = Reflect::get(&window, &"showOpenFilePicker".into()).ok().and_then(|f| f.dyn_into::<Function>().ok());
match picker {
Some(f) => {
let opts = Object::new();
let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
let promise = f.call1(&window, &opts);
let files = files.clone();
spawn_local(async move {
let r = async {
let p: js_sys::Promise = promise.map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?;
let handles: Array = JsFuture::from(p).await.map_err(err)?.dyn_into().map_err(|_| "no files".to_string())?;
for h in handles.iter() {
let h: FileSystemFileHandle = h.dyn_into().map_err(|_| "not a file".to_string())?;
let bytes = read_handle(&h).await?;
files.arrive(h.name(), bytes, intent);
}
Ok::<(), String>(())
};
match r.await {
Ok(()) => {}
// Closing the picker is no error.
Err(e) if e.contains("abort") || e.contains("Abort") => {}
Err(e) => files.say(format!("open: {e}"), true),
}
});
}
None => input_picker(files, intent),
}
}
fn input_picker(files: &Rc<Files>, intent: Intent) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".solvecraft,.step,.stp,.3mf,.stl");
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
let _ = input.set_attribute("style", "display:none");
if let Some(body) = doc.body() {
let _ = body.append_child(&input);
}
let (files, el) = (files.clone(), input.clone());
let on_change = Closure::once_into_js(move || {
el.remove();
let Some(list) = el.files() else { return };
for i in 0..list.length() {
let Some(f) = list.get(i) else { continue };
let files = files.clone();
spawn_local(async move {
match blob_bytes(&f).await {
Ok(b) => files.arrive(f.name(), b, intent),
Err(e) => files.say(format!("{}: {e}", f.name()), true),
}
});
}
});
input.set_onchange(Some(on_change.unchecked_ref()));
input.click();
}
/// Read files dropped on the page.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
for f in std::mem::take(&mut raw.dropped_files) {
let files = files.clone();
spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(b) => files.arrive(name, b, Intent::Insert),
Err(e) => files.say(format!("{name}: {e}"), true),
}
});
}
}
+334 -9
View File
@@ -6,30 +6,324 @@
//! sample part.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod files;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use solvecraft_engine::Session;
use solvecraft_engine::io::vfs;
use solvecraft_ui_egui::{Services, SolveApp};
use wasm_bindgen::JsCast as _;
use crate::files::{self, Files, Intent};
const CANVAS_ID: &str = "solvecraft_canvas";
const LOADING_ID: &str = "solvecraft_loading";
const DEPTH_BITS: u8 = 24;
struct Shell(SolveApp);
/// Seconds between autosaves of unsaved work.
const AUTOSAVE_S: f64 = 30.0;
thread_local! {
/// An open design has unsaved changes: leaving the page asks first.
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
/// unsaved changes (the page can't show its own dialog when it is being closed).
fn ask_before_unload(window: &web_sys::Window) {
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
if UNSAVED.with(std::cell::Cell::get) {
e.prevent_default();
e.set_return_value("unsaved");
}
});
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_unload.forget();
}
struct Shell {
app: SolveApp,
files: Rc<Files>,
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
autosaved_at: f64,
autosaved: Option<u64>,
/// A stored design being renamed: (its name, the new name).
renaming: Option<(String, String)>,
/// The rename field takes the keyboard when it appears.
rename_focus: bool,
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
}
impl Shell {
/// Files from the computer, writes to store, messages and the autosave.
fn files_tick(&mut self, ctx: &egui::Context) {
if self.files.ctx.borrow().is_none() {
*self.files.ctx.borrow_mut() = Some(ctx.clone());
}
files::flush(&self.files);
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
for (name, bytes, intent) in incoming {
let design = name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
let path = if design && intent == Intent::Open {
// A design from the computer is kept in the browser (a copy), so Save works.
let path = files::free_name(&name);
if let Err(e) = vfs::write(&path, &bytes) {
self.app.set_status(e.to_string(), true);
}
files::flush(&self.files);
path
} else {
let path = format!("{}{name}", files::UPLOAD);
vfs::insert(&path, bytes);
path
};
match intent {
Intent::Open => self.app.open_path(&path),
Intent::Insert => self.app.drop_path(&path),
}
self.files.panel.set(false);
}
if self.files.ready.get() && !self.recents_synced {
self.recents_synced = true;
self.sync_recents();
}
let now = ctx.input(|i| i.time);
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
let s = &self.app.session;
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
let wait = AUTOSAVE_S - (now - self.autosaved_at);
if changed && wait <= 0.0 {
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
self.autosaved = Some(s.revision);
self.autosaved_at = now;
} else if changed {
// Come back for it even when nothing else happens.
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
} else if !s.is_dirty() && self.autosaved.is_some() {
files::autosave(&self.files, None);
self.autosaved = None;
}
}
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
/// The start page's recent designs are the stored ones: the remembered order first (those
/// still stored), then the others, newest first.
fn sync_recents(&mut self) {
let mut entries = self.files.entries.borrow().clone();
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
let recent = &mut self.app.home.recent;
recent.retain(|p| vfs::exists(p));
for e in entries.iter().filter(|e| e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT))) {
let p = format!("{}{}", files::OPFS, e.name);
if !recent.contains(&p) {
recent.push(p);
}
}
recent.truncate(solvecraft_ui_egui::home::MAX_RECENT);
}
/// The stored designs: open, insert, rename, delete, download; open from the computer.
fn file_panel(&mut self, ctx: &egui::Context) {
if !self.files.panel.get() {
return;
}
let mut open = true;
let mut act: Option<(&'static str, String)> = None;
egui::Window::new("Files in this browser")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(520.0)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("Open from computer…").clicked() {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() {
files::pick_from_disk(&self.files, Intent::Insert);
}
if ui.button("Download open design").clicked() {
act = Some(("download_open", String::new()));
}
});
ui.separator();
if !self.files.ready.get() {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Reading the browser's storage…");
});
return;
}
let entries = self.files.entries.borrow().clone();
if entries.is_empty() {
ui.label("Designs you save appear here. They stay in this browser.");
}
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
for e in &entries {
match &mut self.renaming {
Some((from, to)) if *from == e.name => {
let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
if std::mem::take(&mut self.rename_focus) {
r.request_focus();
}
if r.lost_focus() {
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
("rename", to.clone())
} else {
("rename_cancel", String::new())
});
}
}
_ => {
ui.label(&e.name);
}
}
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
ui.horizontal(|ui| {
if ui.small_button("Open").clicked() {
act = Some(("open", e.name.clone()));
}
let design = e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
act = Some(("insert", e.name.clone()));
}
if ui.small_button("Rename").clicked() {
self.renaming = Some((e.name.clone(), e.name.clone()));
self.rename_focus = true;
}
if ui.small_button("Download").clicked() {
act = Some(("download", e.name.clone()));
}
if ui.small_button("Delete").clicked() {
act = Some(("delete", e.name.clone()));
}
});
ui.end_row();
}
});
});
});
if !open {
self.files.panel.set(false);
self.renaming = None;
}
let Some((what, name)) = act else { return };
let path = format!("{}{name}", files::OPFS);
match what {
"open" => {
self.app.open_path(&path);
self.files.panel.set(false);
}
"insert" => {
self.app.insert_path(&path);
self.files.panel.set(false);
}
"download" => match vfs::read(&path) {
Ok(b) => {
if let Err(e) = files::download(&name, &b) {
self.app.set_status(e, true);
}
}
Err(e) => self.app.set_status(e.to_string(), true),
},
"download_open" => {
let d = &self.app.session.doc;
let bytes = solvecraft_engine::io::write_design(d);
if let Err(e) = files::download(&format!("{}.{}", d.name, solvecraft_engine::io::DESIGN_EXT), &bytes) {
self.app.set_status(e, true);
}
}
"delete" => {
files::delete(&self.files, &name);
self.app.home.recent.retain(|r| *r != path);
}
"rename_cancel" => self.renaming = None,
"rename" => {
if let Some((from, _)) = self.renaming.take() {
match files::rename(&self.files, &from, &name) {
Ok(()) => {
// Recent designs follow the new name.
let (old, new) = (format!("{}{from}", files::OPFS), format!("{}{name}", files::OPFS));
for r in &mut self.app.home.recent {
if *r == old {
*r = new.clone();
}
}
}
Err(e) => self.app.set_status(e, true),
}
}
}
_ => {}
}
}
/// Offer the autosave an earlier session left.
fn recovery_prompt(&mut self, ctx: &egui::Context) {
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
let mut answer = None;
egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(
ctx,
|ui| {
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
ui.horizontal(|ui| {
if ui.button("Recover").clicked() {
answer = Some(true);
}
if ui.button("Discard").clicked() {
answer = Some(false);
}
});
},
);
let Some(recover) = answer else { return };
*self.files.recovery.borrow_mut() = None;
if recover {
self.app.open_path(&files::recovery_path());
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
}
files::autosave(&self.files, None);
}
}
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
self.app.logic(ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
files::take_drops(&self.files, raw);
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.app.ui(ui);
let ctx = ui.ctx().clone();
self.file_panel(&ctx);
self.recovery_prompt(&ctx);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(PREFS_KEY, self.0.prefs());
storage.set_string(PREFS_KEY, self.app.prefs());
}
}
@@ -43,7 +337,9 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
let Some(document) = window.document() else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
@@ -59,12 +355,29 @@ mod web {
canvas,
options,
Box::new(move |cc| {
let mut app = SolveApp::new(Session::default(), Services::default());
let files = Rc::new(Files::default());
files::start(&files);
let panel = files.clone();
let services = Services {
// Open shows the browser's files (and the computer's, from there).
pick_open: Some(Box::new(move || {
panel.panel.set(true);
None
})),
// Designs are kept in the browser; everything else is downloaded.
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
let design = exts.contains(&solvecraft_engine::io::DESIGN_EXT);
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
})),
..Default::default()
};
let mut app = SolveApp::new(Session::default(), services);
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs, DEPTH_BITS, 1);
// eframe's web painter has no stencil buffer.
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
}
if query().contains("sample") {
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
@@ -73,7 +386,19 @@ mod web {
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
}
Ok(Box::new(Shell(app)))
if !query().contains("sample") {
let rev = app.session.revision;
app.home.show(rev);
}
Ok(Box::new(Shell {
app,
files,
autosaved_at: f64::NEG_INFINITY,
autosaved: None,
renaming: None,
rename_focus: false,
recents_synced: false,
}))
}),
)
.await;
+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"] }
+46 -3
View File
@@ -6,7 +6,7 @@
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"PrimitiveBox","params":{…}}}`.
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
//! See `solvecraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -21,6 +21,12 @@ struct App(SolveApp);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
// designs first; the window closes once they are answered (`quit_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solvecraft_ui_egui::documents::request_quit(&mut self.0);
}
self.0.logic(ctx);
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -47,10 +53,15 @@ fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Designs, STEP and meshes", &["solvecraft", "step", "stp", "STEP", "STP", "3mf", "3MF", "stl", "STL"])
.add_filter(
"Designs, STEP, IGES and meshes",
&["solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"],
)
.add_filter("SolveCraft design", &["solvecraft"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
@@ -59,6 +70,23 @@ fn services() -> Services {
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
});
command.arg(folder);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let _ = command.spawn();
})),
}
}
@@ -84,6 +112,8 @@ fn msaa() -> u16 {
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const DEPTH_BITS: u8 = 24;
/// The section cap marks where it shows in the stencil.
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
fn main() -> eframe::Result {
@@ -119,9 +149,22 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id("ai.storyteller.solvecraft"),
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
..Default::default()
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; SOLVECRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SOLVECRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
eframe::run_native(
"SolveCraft",
options,
@@ -133,7 +176,7 @@ fn main() -> eframe::Result {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs, DEPTH_BITS, u32::from(samples));
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples));
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
+40 -8
View File
@@ -47,6 +47,33 @@ pub struct FaceLook {
pub name: Option<String>,
}
/// The look a body was imported with, carried through later features (which build new kernel
/// bodies without it): its colour and opacity, and its faces' colours by persistent face name.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ImportedLook {
pub color: Option<[f32; 3]>,
pub opacity: Option<f32>,
pub faces: BTreeMap<String, ([f32; 3], f32)>,
}
impl ImportedLook {
/// A body's imported look: its own kernel paint, else what it carries.
pub fn of(b: &crate::ModelBody) -> Option<std::sync::Arc<ImportedLook>> {
if b.body.paint().is_none() && b.body.color().is_none() {
return b.imported.clone();
}
let names = crate::naming::face_names(b);
let faces = b
.body
.paint()
.map(|p| {
p.faces.iter().filter_map(|f| names.get(f.face).map(|n| (crate::naming::strip_piece(n).to_string(), (f.color, f.opacity)))).collect()
})
.unwrap_or_default();
Some(std::sync::Arc::new(ImportedLook { color: b.body.color(), opacity: b.body.paint().map(|p| p.opacity), faces }))
}
}
/// The design's appearances.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Appearances {
@@ -126,15 +153,20 @@ impl Document {
/// assignment to the same face wins.
pub fn face_colors(&self, b: &crate::ModelBody) -> Vec<(usize, Look)> {
// Face colours the body was imported with, under the design's own.
let mut out: Vec<(usize, Look)> = b
.body
.paint()
.map(|p| {
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect()
})
.unwrap_or_default();
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
let names = crate::naming::face_names(b);
let mut out: Vec<(usize, Look)> = match b.body.paint() {
Some(p) => p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect(),
// Carried from an earlier version of the body: by face name.
None => match &b.imported {
Some(imp) => names
.iter()
.enumerate()
.filter_map(|(i, n)| imp.faces.get(crate::naming::strip_piece(n)).map(|(c, o)| (i, Look::custom(c.map(to8), *o as f64))))
.collect(),
None => Vec::new(),
},
};
for f in self.appearances.faces.iter().filter(|f| f.body == b.name) {
let (p, look) = (f.point, f.look.clone());
// By name (every piece of a split face), else by the point.
+93 -6
View File
@@ -144,25 +144,64 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
}
// Named origin planes and construction planes are the component's own.
PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {}
PlaneRef::Tangent { face, at } => {
*at = apply_point(m, *at);
map_geo_ref(m, face);
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::AlongPath { .. }
| PlaneRef::Perpendicular { .. } => {
for r in p.refs_mut() {
map_geo_ref(m, r);
}
}
}
}
fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) {
g.map_points(&mut |p: &mut Vec3| *p = apply_point(m, *p), &mut |pl: &mut PlaneRef| map_plane_ref(m, pl));
}
impl FeatureKind {
/// Map the feature's world geometry (points, directions, explicit planes) by `m`.
pub fn transform_geometry(&mut self, m: &Mat) {
let pt = |p: &mut Vec3| *p = apply_point(m, *p);
let dir = |v: &mut Vec3| *v = apply_vector(m, *v);
match self {
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::Mirror { plane, .. } => map_plane_ref(m, plane),
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => {
map_plane_ref(m, plane)
}
FeatureKind::ConstructionAxis { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)),
FeatureKind::ConstructionPoint { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)),
FeatureKind::Split { plane, tool, .. } => {
map_plane_ref(m, plane);
if let Some(t) = tool {
pt(&mut t.point);
}
}
FeatureKind::SplitFace { faces, plane, tool, .. } => {
faces.iter_mut().for_each(pt);
map_plane_ref(m, plane);
if let Some(t) = tool {
pt(&mut t.point);
}
}
FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } => {
pt(origin);
dir(d);
}
FeatureKind::Fillet { edges, .. } | FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } => faces.iter_mut().for_each(pt),
FeatureKind::Fillet { edges, style, .. } => {
edges.iter_mut().for_each(pt);
if let crate::FilletStyle::Variable { start, .. } = style {
pt(start);
}
}
FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } | FeatureKind::DeleteFace { faces, .. } => {
faces.iter_mut().for_each(pt)
}
FeatureKind::Draft { faces, neutral, pull, .. } => {
faces.iter_mut().for_each(pt);
map_plane_ref(m, neutral);
@@ -205,6 +244,7 @@ impl FeatureKind {
dir(direction);
}
FeatureKind::Lip { face, .. } => pt(face),
FeatureKind::BoundaryFill { cells, .. } => cells.iter_mut().for_each(pt),
FeatureKind::Rest { position, direction, along, .. } => {
pt(position);
dir(direction);
@@ -252,8 +292,18 @@ impl FeatureKind {
| FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::Remove { .. }
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. } => {}
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
map_plane_ref(m, plane);
if let Some(t) = tool {
pt(&mut t.point);
}
pt(keep);
}
}
}
}
@@ -298,6 +348,43 @@ impl Document {
out
}
/// Every body as the world shows it: its world name ("Body1", or "Body1 (Occ:2)" for a
/// further instance), its component, the occurrence path, and the transform from the frame
/// its geometry is in to the world.
pub fn body_frames(&self, st: &crate::ModelState) -> Vec<(String, u64, Vec<u64>, Mat)> {
let mut out = Vec::new();
for (comp, path, m) in self.placements() {
let primary = path == self.first_path(comp);
let label = || -> String {
path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::<Vec<_>>().join("/")
};
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
let w = match self.body_offsets.get(&b.name) {
Some(o) => mat_mul(&m, o),
None => m,
};
out.push((name, comp, path.clone(), w));
}
}
out
}
/// The occurrence path of a component's first placement.
pub fn first_path(&self, component: u64) -> Vec<u64> {
let mut path = Vec::new();
let mut c = component;
for _ in 0..1000 {
if c == 0 {
break;
}
let Some(o) = self.occurrence_of(c) else { break };
path.insert(0, o.id);
c = o.parent;
}
path
}
/// Is `a` inside `b` (or `b` itself)?
pub fn component_within(&self, a: u64, b: u64) -> bool {
let mut c = a;
+22 -1
View File
@@ -34,6 +34,14 @@ pub struct Canvas {
pub flip: bool,
#[serde(default = "yes")]
pub visible: bool,
/// The component it belongs to (its plane is in that component's frame, so it moves with
/// the component's occurrence); 0 = the root.
#[serde(default, skip_serializing_if = "is_root")]
pub component: u64,
}
fn is_root(c: &u64) -> bool {
*c == 0
}
fn half() -> f64 {
@@ -57,7 +65,7 @@ impl Canvas {
let f = if self.flip { -1.0 } else { 1.0 };
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
}
/// Corners in world coordinates.
/// Corners on `plane` (in whatever frame the plane is given).
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
self.corners().map(|p| plane.to_world(p))
}
@@ -66,6 +74,19 @@ impl Canvas {
}
}
impl crate::Document {
/// A canvas's plane in the world: resolved in its component's frame, then placed where the
/// component's occurrence puts it.
pub fn canvas_plane(&self, vals: &std::collections::BTreeMap<String, crate::expr::Value>, c: &Canvas) -> crate::Result<Plane> {
let p = self.resolve_plane(vals, &c.plane, 0)?;
let m = self.component_transform(c.component);
if crate::is_identity(&m) {
return Ok(p);
}
Ok(Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)).unwrap_or(p))
}
}
/// Image kind and pixel size from the file header (PNG or JPEG).
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
+6
View File
@@ -40,6 +40,12 @@ impl FeatureKind {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => {
plane_names(plane, names)
}
FeatureKind::SplitFace { plane, sketch, .. } => {
plane_names(plane, names);
if let Some(s) = sketch {
sk(s);
}
}
FeatureKind::Extrude { sketch, .. }
| FeatureKind::Revolve { sketch, .. }
| FeatureKind::Emboss { sketch, .. }
+653
View File
@@ -0,0 +1,653 @@
//! Construction geometry from the model: references to vertices, edges, faces, planes, axes,
//! points and sketch entities, resolved on each evaluation, and the axes, points and planes
//! built from them (Fusion's CONSTRUCT panel).
//!
//! Face and edge references carry the persistent name of what was picked (see `naming`), so
//! they follow the model when earlier features change; the picked point is the fallback.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_kernel::{self as kernel, FaceSurface};
use crate::eval::{ModelBody, ModelState};
use crate::expr::{Kind, Value};
use crate::{DocError, Document, PlaneRef, Result};
/// Something picked to build construction geometry from.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GeoRef {
/// A fixed point.
Point { p: Vec3 },
/// The design origin.
Origin,
/// A body vertex (the one nearest `at`).
Vertex { at: Vec3 },
/// A body edge: the one named `name`, else the one nearest `at`.
Edge {
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// A body face: the one named `name`, else the one at `at`.
Face {
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// A plane (origin, construction, offset…).
Plane { plane: PlaneRef },
/// World X, Y or Z axis.
World { axis: String },
/// A construction axis feature, by name.
Axis { name: String },
/// A construction point feature, by name.
ConstructPoint { name: String },
/// A sketch point (by sketch feature id and point id).
SketchPoint { sketch: u64, point: String },
/// A sketch curve (by sketch feature id and curve id).
SketchCurve { sketch: u64, curve: String },
}
/// How a construction axis is defined.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AxisDef {
/// The axis of a cylinder, cone or torus face (or a circular edge).
ThroughFace { face: GeoRef },
/// Perpendicular to a face, through a point (projected onto the face).
NormalToFace { face: GeoRef, point: GeoRef },
/// Where two planes meet.
TwoPlanes { a: GeoRef, b: GeoRef },
/// Through two points.
TwoPoints { a: GeoRef, b: GeoRef },
/// Along a straight edge (or sketch line).
Edge { edge: GeoRef },
}
/// How a construction point is defined.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PointDef {
/// At a vertex, sketch point or fixed point.
At { point: GeoRef },
/// Where two edges (lines) meet, or come closest.
TwoEdges { a: GeoRef, b: GeoRef },
/// Where three planes meet.
ThreePlanes { a: GeoRef, b: GeoRef, c: GeoRef },
/// The centre of a circular edge, a sphere or a torus.
Center { of: GeoRef },
/// Where an edge (line) crosses a plane.
EdgeAndPlane { edge: GeoRef, plane: GeoRef },
/// At a fraction `t` (0…1) of the way along an edge or sketch curve.
AlongPath { path: GeoRef, t: String },
}
/// Resolved construction geometry, kept in the model state by feature.
#[derive(Clone, Debug, PartialEq)]
pub enum ConstructGeom {
Plane(Plane),
Axis { origin: Vec3, dir: Vec3 },
Point(Vec3),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Construct {
pub feature: u64,
pub name: String,
pub geom: ConstructGeom,
}
/// Everything a reference needs to be resolved.
pub(crate) struct Ctx<'a> {
pub doc: &'a Document,
pub vals: &'a BTreeMap<String, Value>,
pub st: &'a ModelState,
}
fn bad(m: impl Into<String>) -> DocError {
DocError::Invalid(m.into())
}
fn bodies(st: &ModelState) -> impl Iterator<Item = &ModelBody> {
st.bodies.iter().filter(|b| !b.body.is_mesh())
}
fn construct<'a>(st: &'a ModelState, name: &str) -> Result<&'a ConstructGeom> {
st.construct.iter().find(|c| c.name == name).map(|c| &c.geom).ok_or_else(|| DocError::Unknown(format!("construction `{name}`")))
}
/// The face picked: (body, face index, point on it).
fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&'a ModelBody, usize, Vec3)> {
let at = match name.as_deref().and_then(|n| crate::naming::point_on_face(st, n, at)) {
Some((q, _)) => q,
None => at,
};
for b in bodies(st) {
if let Some(fi) = crate::appearance::face_index_at(b, at) {
return Ok((b, fi, at));
}
}
Err(bad(format!("no face at {at:?}")))
}
/// The edge picked, as a polyline.
fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<Vec3>> {
let mut at = at;
if let Some(n) = name {
for b in bodies(st) {
if let Ok((q, _)) = crate::naming::find_edge(b, n, at) {
at = q;
break;
}
}
}
let mut best: Option<(f64, Vec<Vec3>)> = None;
for b in bodies(st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(edges) = b.body.edges(tol) else { continue };
for e in edges {
let d = e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min);
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, e.points));
}
}
}
match best {
Some((d, pts)) if pts.len() >= 2 && d <= 1e-3 + 1e-2 * pts.iter().fold(0.0, |m: f64, p| m.max(p.len())).max(1.0) => Ok(pts),
_ => Err(bad(format!("no edge at {at:?}"))),
}
}
/// A polyline that is straight: its line.
fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
let (a, b) = (*pts.first()?, *pts.last()?);
let d = (b - a).normalized()?;
let len = (b - a).len();
pts.iter().all(|p| p.dist_to_segment(a, b) <= len * 1e-6 + 1e-9).then_some((a, d))
}
/// A polyline on a circle: (centre, unit normal, radius).
fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> {
let n = pts.len();
if n < 4 {
return None;
}
let (a, b, c) = (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?);
let (ab, ac) = (b - a, c - a);
let nrm = ab.cross(ac);
let l2 = nrm.len2();
if l2 < 1e-24 {
return None;
}
// Circumcentre of abc.
let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) * (1.0 / (2.0 * l2));
let r = (a - centre).len();
let unit = nrm.normalized()?;
pts.iter()
.all(|p| ((*p - centre).len() - r).abs() <= r * 1e-3 + 1e-6 && (*p - centre).dot(unit).abs() <= r * 1e-3 + 1e-6)
.then_some((centre, unit, r))
}
fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result<Vec3> {
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
Ok(ss.plane.to_world(q))
}
fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result<Vec<Vec3>> {
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?;
let pl = ss.sketch.polyline(ci);
if pl.len() < 2 {
return Err(bad(format!("curve `{curve}` has no length")));
}
Ok(pl.into_iter().map(|q| ss.plane.to_world(q)).collect())
}
/// A reference as a point.
pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
match g {
GeoRef::Point { p } => Ok(*p),
GeoRef::Origin => Ok(Vec3::ZERO),
GeoRef::Vertex { at } => {
let mut best: Option<(f64, Vec3)> = None;
for b in bodies(ctx.st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(edges) = b.body.edges(tol) else { continue };
for v in edges.iter().flat_map(|e| [e.points.first().copied(), e.points.last().copied()]).flatten() {
let d = v.dist(*at);
if best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, v));
}
}
}
best.map(|(_, v)| v).ok_or_else(|| bad("there is no vertex"))
}
GeoRef::ConstructPoint { name } => match construct(ctx.st, name)? {
ConstructGeom::Point(p) => Ok(*p),
_ => Err(bad(format!("`{name}` is not a point"))),
},
GeoRef::SketchPoint { sketch, point } => {
let ss = ctx.st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let q =
ss.sketch.point_index(point).and_then(|i| ss.sketch.point(i)).ok_or_else(|| DocError::Unknown(format!("sketch point `{point}`")))?;
sketch_world(ctx, *sketch, q)
}
// A round edge or face: its centre.
GeoRef::Edge { .. } | GeoRef::Face { .. } | GeoRef::SketchCurve { .. } => centre(ctx, g),
_ => Err(bad("pick a point, vertex or sketch point")),
}
}
/// A reference as a line (origin, unit direction).
pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
match g {
GeoRef::Edge { at, name } => {
let pts = edge_points(ctx.st, *at, name)?;
as_line(&pts).or_else(|| as_circle(&pts).map(|(c, n, _)| (c, n))).ok_or_else(|| bad("the edge is neither straight nor circular"))
}
GeoRef::SketchCurve { sketch, curve } => {
let pts = sketch_curve_points(ctx, *sketch, curve)?;
as_line(&pts).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a line")))
}
GeoRef::World { axis } => {
let d = match axis.to_ascii_uppercase().as_str() {
"X" => Vec3::X,
"Y" => Vec3::Y,
"Z" => Vec3::Z,
_ => return Err(DocError::Unknown(format!("axis `{axis}`"))),
};
Ok((Vec3::ZERO, d))
}
GeoRef::Axis { name } => match construct(ctx.st, name)? {
ConstructGeom::Axis { origin, dir } => Ok((*origin, *dir)),
_ => Err(bad(format!("`{name}` is not an axis"))),
},
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, axis)),
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, axis)),
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, axis)),
_ => Err(bad("the face is not a cylinder, cone or torus")),
}
}
_ => Err(bad("pick an edge, line or axis")),
}
}
/// A reference as a plane.
pub(crate) fn plane(ctx: &Ctx, g: &GeoRef) -> Result<Plane> {
match g {
GeoRef::Plane { plane } => ctx.doc.resolve_plane_in(ctx.vals, plane, 0, Some(ctx.st)),
GeoRef::Face { at, name } => {
let (b, fi, q) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Plane { normal, point }) => {
let o = q - normal * (q - point).dot(normal);
Plane::from_normal(o, normal).ok_or_else(|| bad("degenerate face"))
}
_ => Err(bad("the face is not planar")),
}
}
_ => Err(bad("pick a plane or planar face")),
}
}
/// The centre of a round edge, sketch circle, sphere or torus.
fn centre(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
match g {
GeoRef::Edge { at, name } => {
let pts = edge_points(ctx.st, *at, name)?;
as_circle(&pts).map(|(c, _, _)| c).ok_or_else(|| bad("the edge is not circular"))
}
GeoRef::SketchCurve { sketch, curve } => {
let pts = sketch_curve_points(ctx, *sketch, curve)?;
as_circle(&pts).map(|(c, _, _)| c).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a circle or arc")))
}
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Sphere { center, .. }) | Some(FaceSurface::Torus { center, .. }) => Ok(center),
_ => Err(bad("the face is not a sphere or torus")),
}
}
_ => Err(bad("pick a circular edge, a sphere or a torus")),
}
}
/// Point and unit tangent at fraction `t` along a polyline.
fn along(pts: &[Vec3], t: f64) -> Option<(Vec3, Vec3)> {
let total: f64 = pts.windows(2).map(|w| (w[1] - w[0]).len()).sum();
if !(total > 0.0) {
return None;
}
let mut left = t.clamp(0.0, 1.0) * total;
for w in pts.windows(2) {
let l = (w[1] - w[0]).len();
if l <= 0.0 {
continue;
}
if left <= l {
return Some((w[0] + (w[1] - w[0]) * (left / l), (w[1] - w[0]) * (1.0 / l)));
}
left -= l;
}
let (a, b) = (pts.get(pts.len() - 2)?, pts.last()?);
Some((*b, (*b - *a).normalized()?))
}
fn path_points(ctx: &Ctx, g: &GeoRef) -> Result<Vec<Vec3>> {
match g {
GeoRef::Edge { at, name } => edge_points(ctx.st, *at, name),
GeoRef::SketchCurve { sketch, curve } => sketch_curve_points(ctx, *sketch, curve),
_ => Err(bad("pick an edge or sketch curve as the path")),
}
}
/// The surface's unit normal at (the point of the surface nearest) `p`, and that point.
fn surface_normal(s: FaceSurface, p: Vec3) -> Option<(Vec3, Vec3)> {
match s {
FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), normal)),
FaceSurface::Cylinder { origin, axis, radius } => {
let w = p - origin;
let r = (w - axis * w.dot(axis)).normalized()?;
Some((origin + axis * w.dot(axis) + r * radius, r))
}
FaceSurface::Sphere { center, radius } => {
let r = (p - center).normalized()?;
Some((center + r * radius, r))
}
FaceSurface::Cone { apex, axis, half_angle } => {
let w = p - apex;
let radial = (w - axis * w.dot(axis)).normalized()?;
// The generator through p's side, and the normal square to it in the axial plane.
let line = axis * half_angle.cos() + radial * half_angle.sin();
let n = (radial * half_angle.cos() - axis * half_angle.sin()).normalized()?;
Some((apex + line * w.dot(line), n))
}
FaceSurface::Torus { center, axis, major, minor } => {
let w = p - center;
let radial = (w - axis * w.dot(axis)).normalized()?;
let tube = center + radial * major;
let n = (p - tube).normalized()?;
Some((tube + n * minor, n))
}
}
}
fn face_surface_at(ctx: &Ctx, g: &GeoRef) -> Result<(FaceSurface, Vec3)> {
match g {
GeoRef::Face { at, name } => {
let (b, fi, q) = face_of(ctx.st, *at, name)?;
kernel::face_surface(&b.body, fi).map(|s| (s, q)).ok_or_else(|| bad("the face's surface is not a plane, cylinder, cone, sphere or torus"))
}
GeoRef::Plane { .. } => {
let pl = plane(ctx, g)?;
Ok((FaceSurface::Plane { normal: pl.normal(), point: pl.origin }, pl.origin))
}
_ => Err(bad("pick a face")),
}
}
/// Where two planes meet.
fn plane_plane(a: &Plane, b: &Plane) -> Option<(Vec3, Vec3)> {
let (n1, n2) = (a.normal(), b.normal());
let d = n1.cross(n2);
let l2 = d.len2();
if l2 < 1e-18 {
return None;
}
let (h1, h2) = (n1.dot(a.origin), n2.dot(b.origin));
let o = (n2.cross(d) * h1 + d.cross(n1) * h2) * (1.0 / l2);
Some((o, d.normalized()?))
}
/// Closest points of two lines: their midpoint (where they meet, if they do).
fn line_line(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> Option<Vec3> {
let ((p, u), (q, v)) = (a, b);
let w = p - q;
let (uu, uv, vv, uw, vw) = (u.dot(u), u.dot(v), v.dot(v), u.dot(w), v.dot(w));
let den = uu * vv - uv * uv;
if den.abs() < 1e-12 {
return None;
}
let s = (uv * vw - vv * uw) / den;
let t = (uu * vw - uv * uw) / den;
Some(((p + u * s) + (q + v * t)) * 0.5)
}
pub(crate) fn resolve_axis(ctx: &Ctx, d: &AxisDef) -> Result<(Vec3, Vec3)> {
match d {
AxisDef::ThroughFace { face } => line(ctx, face),
AxisDef::Edge { edge } => {
let (o, dir) = line(ctx, edge)?;
if let GeoRef::Edge { at, name } = edge
&& as_line(&edge_points(ctx.st, *at, name)?).is_none()
{
return Err(bad("the edge is not straight"));
}
Ok((o, dir))
}
AxisDef::NormalToFace { face, point: p } => {
let (s, _) = face_surface_at(ctx, face)?;
let q = point(ctx, p)?;
let (on, n) = surface_normal(s, q).ok_or_else(|| bad("the point is on the face's axis"))?;
Ok((on, n))
}
AxisDef::TwoPlanes { a, b } => plane_plane(&plane(ctx, a)?, &plane(ctx, b)?).ok_or_else(|| bad("the planes are parallel")),
AxisDef::TwoPoints { a, b } => {
let (p, q) = (point(ctx, a)?, point(ctx, b)?);
Ok((p, (q - p).normalized().ok_or_else(|| bad("the two points coincide"))?))
}
}
}
pub(crate) fn resolve_point(ctx: &Ctx, d: &PointDef) -> Result<Vec3> {
match d {
PointDef::At { point: p } => point(ctx, p),
PointDef::Center { of } => centre(ctx, of),
PointDef::TwoEdges { a, b } => line_line(line(ctx, a)?, line(ctx, b)?).ok_or_else(|| bad("the edges are parallel")),
PointDef::ThreePlanes { a, b, c } => {
let (pa, pb, pc) = (plane(ctx, a)?, plane(ctx, b)?, plane(ctx, c)?);
let (o, dir) = plane_plane(&pa, &pb).ok_or_else(|| bad("two of the planes are parallel"))?;
line_plane(o, dir, &pc).ok_or_else(|| bad("the planes don't meet in a point"))
}
PointDef::EdgeAndPlane { edge, plane: p } => {
let (o, dir) = line(ctx, edge)?;
line_plane(o, dir, &plane(ctx, p)?).ok_or_else(|| bad("the edge is parallel to the plane"))
}
PointDef::AlongPath { path, t } => {
let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?;
along(&path_points(ctx, path)?, t).map(|(p, _)| p).ok_or_else(|| bad("the path has no length"))
}
}
}
fn line_plane(o: Vec3, d: Vec3, p: &Plane) -> Option<Vec3> {
let n = p.normal();
let den = d.dot(n);
if den.abs() < 1e-12 {
return None;
}
Some(o + d * ((p.origin - o).dot(n) / den))
}
/// Planes built from model geometry (the `PlaneRef` kinds that need the model).
pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
let degenerate = || bad("degenerate plane");
match p {
PlaneRef::Midplane { a, b } => {
let (pa, pb) = (plane(ctx, a)?, plane(ctx, b)?);
let (na, nb) = (pa.normal(), pb.normal());
if na.cross(nb).len() < 1e-9 {
// Parallel: halfway between, facing as the first.
let half = nb * ((pb.origin - pa.origin).dot(nb) * 0.5);
return Plane::new(pa.origin + half, pa.x, pa.y).ok_or_else(degenerate);
}
// Meeting at an angle: the plane through their line that halves the angle.
let (o, dir) = plane_plane(&pa, &pb).ok_or_else(degenerate)?;
let n = (na - nb).normalized().ok_or_else(degenerate)?;
Plane::new(o, dir, n.cross(dir)).ok_or_else(degenerate)
}
PlaneRef::TwoEdges { a, b } => {
let ((o1, d1), (o2, d2)) = (line(ctx, a)?, line(ctx, b)?);
let n = match d1.cross(d2).normalized() {
Some(n) if (o2 - o1).dot(n).abs() <= 1e-6 * (1.0 + (o2 - o1).len()) => n,
Some(_) => return Err(bad("the edges are not in one plane")),
None => d1.cross(o2 - o1).normalized().ok_or_else(|| bad("the edges are on one line"))?,
};
Plane::new(o1, d1, n.cross(d1)).ok_or_else(degenerate)
}
PlaneRef::ThreePoints { a, b, c } => {
let (pa, pb, pc) = (point(ctx, a)?, point(ctx, b)?, point(ctx, c)?);
if (pb - pa).cross(pc - pa).len() < 1e-9 {
return Err(bad("the three points are on one line"));
}
Plane::new(pa, pb - pa, pc - pa).ok_or_else(degenerate)
}
PlaneRef::Tangent { face, at } => {
let (s, _) = face_surface_at(ctx, face)?;
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"))?;
Plane::from_normal(p, tan).ok_or_else(degenerate)
}
_ => ctx.doc.resolve_plane_in(ctx.vals, p, 0, Some(ctx.st)),
}
}
impl GeoRef {
/// Map the reference's world points (component frames).
pub fn map_points(&mut self, pt: &mut dyn FnMut(&mut Vec3), plane: &mut dyn FnMut(&mut PlaneRef)) {
match self {
GeoRef::Point { p } => pt(p),
GeoRef::Vertex { at } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at),
GeoRef::Plane { plane: p } => plane(p),
_ => {}
}
}
/// Picked faces and edges get their persistent names (and only those still missing one).
pub fn name_picks(&mut self, st: &ModelState) {
match self {
GeoRef::Face { at, name: name @ None } => {
*name = crate::naming::face_names_at(st, &[*at]).into_iter().next().filter(|n| !n.is_empty());
}
GeoRef::Edge { at, name: name @ None } => {
for b in bodies(st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
if b.body.nearest_edge(*at, tol).ok().flatten().is_some_and(|(_, d)| d <= tol * 10.0) {
*name = crate::naming::edge_names_at(b, &[*at]).into_iter().next().filter(|n| !n.is_empty());
break;
}
}
}
GeoRef::Plane { plane } => plane.name_picks(st),
_ => {}
}
}
}
impl GeoRef {
fn plane_ref(&self) -> Option<&PlaneRef> {
match self {
GeoRef::Plane { plane } => Some(plane),
_ => None,
}
}
}
impl AxisDef {
pub fn refs(&self) -> Vec<&GeoRef> {
match self {
AxisDef::ThroughFace { face } => vec![face],
AxisDef::Edge { edge } => vec![edge],
AxisDef::NormalToFace { face, point } => vec![face, point],
AxisDef::TwoPlanes { a, b } | AxisDef::TwoPoints { a, b } => vec![a, b],
}
}
/// Planes it refers to (whose expressions it depends on).
pub fn planes(&self) -> Vec<&PlaneRef> {
self.refs().into_iter().filter_map(GeoRef::plane_ref).collect()
}
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
AxisDef::ThroughFace { face } => vec![face],
AxisDef::Edge { edge } => vec![edge],
AxisDef::NormalToFace { face, point } => vec![face, point],
AxisDef::TwoPlanes { a, b } | AxisDef::TwoPoints { a, b } => vec![a, b],
}
}
}
impl PointDef {
pub fn refs(&self) -> Vec<&GeoRef> {
match self {
PointDef::At { point } => vec![point],
PointDef::Center { of } => vec![of],
PointDef::TwoEdges { a, b } => vec![a, b],
PointDef::ThreePlanes { a, b, c } => vec![a, b, c],
PointDef::EdgeAndPlane { edge, plane } => vec![edge, plane],
PointDef::AlongPath { path, .. } => vec![path],
}
}
pub fn planes(&self) -> Vec<&PlaneRef> {
self.refs().into_iter().filter_map(GeoRef::plane_ref).collect()
}
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
PointDef::At { point } => vec![point],
PointDef::Center { of } => vec![of],
PointDef::TwoEdges { a, b } => vec![a, b],
PointDef::ThreePlanes { a, b, c } => vec![a, b, c],
PointDef::EdgeAndPlane { edge, plane } => vec![edge, plane],
PointDef::AlongPath { path, .. } => vec![path],
}
}
}
impl PlaneRef {
/// The references of a plane built from model geometry.
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()],
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => base.refs_mut(),
_ => Vec::new(),
}
}
pub fn name_picks(&mut self, st: &ModelState) {
for r in self.refs_mut() {
r.name_picks(st);
}
}
/// Does resolving it need the model (edges, faces, construction axes and points)?
pub fn needs_model(&self) -> bool {
let mut k = self.clone();
!k.refs_mut().is_empty()
}
}
+389 -31
View File
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::Sketch;
use crate::construct::{AxisDef, GeoRef, PointDef};
use crate::expr::{self, Kind};
use crate::{DocError, Result};
@@ -43,6 +44,18 @@ pub enum PlaneRef {
AtAngle { base: Box<PlaneRef>, axis_origin: Vec3, axis_dir: Vec3, angle: String },
/// A construction plane feature (by name).
Construction { name: String },
/// Halfway between two planes (or planar faces); bisecting them when they meet at an angle.
Midplane { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through two straight edges (lines) that lie in one plane.
TwoEdges { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through three points.
ThreePoints { a: Box<GeoRef>, b: Box<GeoRef>, c: Box<GeoRef> },
/// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it.
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
@@ -112,7 +125,8 @@ pub struct Extent {
/// Optional second side distance (two-sided extrude), along the negative normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance2: Option<String>,
/// Start offset from the sketch plane (expression).
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
/// direction.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_offset: Option<String>,
/// Through all bodies in the direction(s) instead of `distance`.
@@ -121,6 +135,16 @@ pub struct Extent {
/// Taper angle (expression; positive grows the profile).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub taper: Option<String>,
/// To Object: end at the face (or vertex/point) at this point, instead of `distance`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<Vec3>,
/// Offset past the To Object face (expression; negative stops short).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_offset: Option<String>,
/// From Object: start at the face (or point) here instead of the sketch plane (plus
/// `start_offset`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<Vec3>,
}
/// Revolve axis.
@@ -165,6 +189,15 @@ pub enum FeatureKind {
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
/// Two Sides: the angle on the other side.
#[serde(default, skip_serializing_if = "Option::is_none")]
angle2: Option<String>,
/// Symmetric: `angle` split evenly either side of the profile.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
symmetric: bool,
/// To Object: turn until the face, vertex or point here.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<Vec3>,
},
Fillet {
/// Reference points on the edges to round (each re-finds the nearest edge).
@@ -172,10 +205,23 @@ pub enum FeatureKind {
radius: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
/// Constant radius (the default), chord length (`radius` is the width across), or a
/// radius varying along each edge.
#[serde(default, skip_serializing_if = "FilletStyle::is_constant")]
style: FilletStyle,
},
Chamfer {
edges: Vec<Vec3>,
distance: String,
/// Unequal chamfers: the distance along the second face…
#[serde(default, skip_serializing_if = "Option::is_none")]
distance2: Option<String>,
/// …or the angle the chamfer makes with the first face.
#[serde(default, skip_serializing_if = "Option::is_none")]
angle: Option<String>,
/// Swap which face is the first (by default the one facing up most).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
@@ -253,9 +299,13 @@ pub enum FeatureKind {
/// A cosmetic thread in the holes (e.g. "M6x1").
#[serde(default, skip_serializing_if = "Option::is_none")]
thread: Option<String>,
/// To Object: drill down to the face (or point) here instead of `depth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<Vec3>,
},
/// A cosmetic thread on a cylindrical face (no geometry change; drawn and exported as an
/// annotation). `designation` is an ISO metric size such as "M8" or "M8x1".
/// A thread on a cylindrical face. Cosmetic by default (no geometry change; drawn and
/// exported as an annotation); `modeled` cuts the real helical ISO profile into the face.
/// `designation` is an ISO metric size such as "M8" or "M8x1".
Thread {
face: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -263,6 +313,8 @@ pub enum FeatureKind {
/// Thread length from the face's end nearest `face`; `None` = full length.
#[serde(default, skip_serializing_if = "Option::is_none")]
length: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
},
/// Ruled loft through profiles of several sketches, in order.
Loft {
@@ -289,6 +341,12 @@ pub enum FeatureKind {
thickness: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
/// inside (default) | outside | both (the wall straddles the faces).
#[serde(default, skip_serializing_if = "String::is_empty")]
direction: String,
/// Open the faces smoothly joined to the picked ones too.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
tangent_chain: bool,
},
/// Tilt faces (at the given points) about a neutral plane, leaning toward `pull`.
Draft {
@@ -303,10 +361,34 @@ pub enum FeatureKind {
ConstructionPlane {
plane: PlaneRef,
},
/// Split a body with a plane into two bodies.
/// Split a body into the parts on either side of a plane, or of a face of a body (the face
/// through `point`, its surface extended) when `tool` is set.
Split {
body: String,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
},
/// A construction axis (Fusion's CONSTRUCT axes).
ConstructionAxis {
def: AxisDef,
},
/// A construction point (Fusion's CONSTRUCT points).
ConstructionPoint {
def: PointDef,
},
/// Split faces (at the given points) where a tool meets them, the solid unchanged: a plane,
/// a face of a body (`tool`, its surface extended) or a sketch's curves (`sketch`, swept
/// along its normal). The pieces are named after the face (`#k`).
SplitFace {
faces: Vec<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<u64>,
},
/// Scale bodies about a point (uniform, or per axis).
Scale {
@@ -325,6 +407,14 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Remove faces (at the given points) and heal the gap from the neighbouring planes, as when
/// a fillet, chamfer, hole or boss is taken off. A picked face brings the rest of its surface
/// (both halves of a hole's wall).
DeleteFace {
faces: Vec<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// A box around bodies, grown by a margin.
BoundingSolid {
bodies: Vec<String>,
@@ -589,10 +679,65 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Boundary Fill: solids from the cells the tool bodies enclose (a cell: inside some
/// tools and outside the rest), each picked by a point inside it.
BoundaryFill {
tools: Vec<String>,
cells: Vec<Vec3>,
#[serde(default)]
operation: Operation,
/// Remove the tool bodies afterwards.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
remove_tools: bool,
},
/// Remove bodies from the model (from here on in the timeline).
Remove {
bodies: Vec<String>,
},
/// Surface bodies: planar patches filling sketch regions (`profiles` of `sketch`), or a
/// closed loop of a body's edges (`edges`: points on them, on `body`).
Patch {
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<u64>,
#[serde(default = "all_profiles")]
profiles: ProfileSel,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
edges: Vec<Vec3>,
},
/// Surface bodies sewn along shared edges into the first one (a solid when they close).
Stitch {
bodies: Vec<String>,
#[serde(default = "zero_expr")]
tolerance: String,
},
/// Surface bodies thickened into solids along their normals (by half each way when
/// `symmetric`).
Thicken {
bodies: Vec<String>,
thickness: String,
#[serde(default)]
symmetric: bool,
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
/// A planar surface body extended outward by `distance` past the edges through `edges`.
SurfaceExtend {
body: String,
edges: Vec<Vec3>,
distance: String,
},
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
/// `keep`.
SurfaceTrim {
body: String,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
keep: Vec3,
},
Move {
bodies: Vec<String>,
translate: [String; 3],
@@ -621,6 +766,39 @@ pub enum FeatureKind {
},
}
/// How a fillet's size is given.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FilletStyle {
#[default]
Constant,
/// `radius` is the chord: the width across the blend.
Chord,
/// `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 {
pub fn is_constant(&self) -> bool {
*self == FilletStyle::Constant
}
}
/// A face picked by a point on it, on a named body.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FaceAt {
pub body: String,
pub point: Vec3,
}
/// Triangles of one imported mesh body (millimetres).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
@@ -748,6 +926,10 @@ fn plane_exprs<'a>(p: &'a PlaneRef, v: &mut Vec<&'a str>) {
v.push(angle);
p = base;
}
PlaneRef::AlongPath { t, .. } => {
v.push(t);
break;
}
_ => break,
}
}
@@ -758,6 +940,66 @@ fn z_axis() -> Vec3 {
}
impl FeatureKind {
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
/// deletes the feature. Every kind is listed, so a new one has to say what it uses.
pub fn input_sketches(&self) -> Vec<u64> {
use FeatureKind as K;
match self {
K::Extrude { sketch, .. }
| K::Revolve { sketch, .. }
| K::Emboss { sketch, .. }
| K::Rib { sketch, .. }
| K::SheetBase { sketch, .. }
| K::SheetContour { sketch, .. } => vec![*sketch],
K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
K::Sketch { .. }
| K::Pattern { .. }
| K::Fillet { .. }
| K::Chamfer { .. }
| K::Box { .. }
| K::Cylinder { .. }
| K::Sphere { .. }
| K::Torus { .. }
| K::Combine { .. }
| K::Mirror { .. }
| K::Thread { .. }
| K::Shell { .. }
| K::Draft { .. }
| K::ConstructionPlane { .. }
| K::Split { .. }
| K::ConstructionAxis { .. }
| K::ConstructionPoint { .. }
| K::Scale { .. }
| K::OffsetFace { .. }
| K::DeleteFace { .. }
| K::BoundingSolid { .. }
| K::ReplaceFace { .. }
| K::Align { .. }
| K::Coil { .. }
| K::SheetFlange { .. }
| K::SheetHem { .. }
| K::SheetUnfold { .. }
| K::SheetConvert { .. }
| K::Boss { .. }
| K::Lip { .. }
| K::SnapFit { .. }
| K::Rest { .. }
| K::BoundaryFill { .. }
| K::Remove { .. }
| K::Stitch { .. }
| K::Thicken { .. }
| K::SurfaceExtend { .. }
| K::SurfaceTrim { .. }
| K::Move { .. }
| K::Import { .. }
| K::MeshImport { .. } => Vec::new(),
}
}
/// A sheet metal feature (its result depends on the sheet metal rules).
pub fn is_plastic(&self) -> bool {
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
@@ -801,9 +1043,13 @@ impl FeatureKind {
FeatureKind::Shell { .. } => "ShellFeature",
FeatureKind::Draft { .. } => "DraftFeature",
FeatureKind::Split { .. } => "SplitBodyFeature",
FeatureKind::SplitFace { .. } => "SplitFaceFeature",
FeatureKind::ConstructionAxis { .. } => "ConstructionAxis",
FeatureKind::ConstructionPoint { .. } => "ConstructionPoint",
FeatureKind::Move { .. } => "MoveFeature",
FeatureKind::Scale { .. } => "ScaleFeature",
FeatureKind::OffsetFace { .. } => "OffsetFacesFeature",
FeatureKind::DeleteFace { .. } => "DeleteFaceFeature",
FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature",
FeatureKind::Pipe { .. } => "PipeFeature",
FeatureKind::Emboss { .. } => "EmbossFeature",
@@ -812,6 +1058,7 @@ impl FeatureKind {
FeatureKind::Lip { .. } => "GrooveFeature",
FeatureKind::SnapFit { .. } => "SnapFitFeature",
FeatureKind::Rest { .. } => "RestFeature",
FeatureKind::BoundaryFill { .. } => "BoundaryFillFeature",
FeatureKind::SheetBase { .. } => "BaseFlangeFeature",
FeatureKind::SheetContour { .. } => "ContourFlangeFeature",
FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature",
@@ -826,6 +1073,11 @@ impl FeatureKind {
FeatureKind::ReplaceFace { .. } => "ReplaceFaceFeature",
FeatureKind::Align { .. } => "AlignFeature",
FeatureKind::Remove { .. } => "RemoveFeature",
FeatureKind::Patch { .. } => "PatchFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
FeatureKind::Thicken { .. } => "ThickenFeature",
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature",
}
@@ -855,9 +1107,13 @@ impl FeatureKind {
FeatureKind::Shell { .. } => "Shell",
FeatureKind::Draft { .. } => "Draft",
FeatureKind::Split { .. } => "Split",
FeatureKind::SplitFace { .. } => "SplitFace",
FeatureKind::ConstructionAxis { .. } => "Axis",
FeatureKind::ConstructionPoint { .. } => "Point",
FeatureKind::Move { .. } => "Move",
FeatureKind::Scale { .. } => "Scale",
FeatureKind::OffsetFace { .. } => "OffsetFace",
FeatureKind::DeleteFace { .. } => "DeleteFace",
FeatureKind::BoundingSolid { .. } => "BoundingSolid",
FeatureKind::Pipe { .. } => "Pipe",
FeatureKind::Emboss { .. } => "Emboss",
@@ -866,6 +1122,7 @@ impl FeatureKind {
FeatureKind::Lip { .. } => "Groove",
FeatureKind::SnapFit { .. } => "SnapFit",
FeatureKind::Rest { .. } => "Rest",
FeatureKind::BoundaryFill { .. } => "BoundaryFill",
FeatureKind::SheetBase { .. } => "BaseFlange",
FeatureKind::SheetContour { .. } => "ContourFlange",
FeatureKind::SheetFlange { .. } => "EdgeFlange",
@@ -880,6 +1137,11 @@ impl FeatureKind {
FeatureKind::ReplaceFace { .. } => "ReplaceFace",
FeatureKind::Align { .. } => "Align",
FeatureKind::Remove { .. } => "Remove",
FeatureKind::Patch { .. } => "Patch",
FeatureKind::Stitch { .. } => "Stitch",
FeatureKind::Thicken { .. } => "Thicken",
FeatureKind::SurfaceTrim { .. } => "Trim",
FeatureKind::SurfaceExtend { .. } => "Extend",
FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh",
}
@@ -887,13 +1149,34 @@ 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, .. }
| FeatureKind::Draft { faces, .. }
| FeatureKind::OffsetFace { faces, .. }
| FeatureKind::DeleteFace { faces, .. }
| FeatureKind::SplitFace { faces, .. }
| FeatureKind::ReplaceFace { faces, .. } => faces.iter_mut().collect(),
FeatureKind::Hole { position, points: None, .. } => vec![position],
FeatureKind::Hole { position, points: None, to, .. } => std::iter::once(position).chain(to.iter_mut()).collect(),
FeatureKind::Hole { to: Some(t), .. } => vec![t],
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
FeatureKind::Thread { face, .. } => vec![face],
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
_ => Vec::new(),
}
}
@@ -906,9 +1189,17 @@ impl FeatureKind {
pub fn expressions(&self) -> Vec<&str> {
let mut v: Vec<&str> = Vec::new();
match self {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => {
plane_exprs(plane, &mut v)
FeatureKind::ConstructionAxis { def } => def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v)),
FeatureKind::ConstructionPoint { def } => {
if let PointDef::AlongPath { t, .. } = def {
v.push(t);
}
def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v));
}
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::Extrude { extent, .. } => {
v.push(&extent.distance);
if let Some(d) = &extent.distance2 {
@@ -921,9 +1212,22 @@ impl FeatureKind {
v.push(d);
}
}
FeatureKind::Revolve { angle, .. } => v.push(angle),
FeatureKind::Fillet { radius, .. } => v.push(radius),
FeatureKind::Chamfer { distance, .. } => v.push(distance),
FeatureKind::Revolve { angle, angle2, .. } => {
v.push(angle);
v.extend(angle2.iter().map(String::as_str));
}
FeatureKind::Fillet { radius, style, .. } => {
v.push(radius);
if let FilletStyle::Variable { radius2, mid, .. } = style {
v.push(radius2);
v.extend(mid.iter().map(|(_, r)| r.as_str()));
}
}
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(distance);
v.extend(distance2.iter().map(String::as_str));
v.extend(angle.iter().map(String::as_str));
}
FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]),
FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]),
FeatureKind::Sphere { radius, .. } => v.push(radius),
@@ -1013,7 +1317,12 @@ impl FeatureKind {
v.extend(depth.iter().map(String::as_str));
}
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
FeatureKind::Align { .. } | FeatureKind::Remove { .. } => {}
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
FeatureKind::Patch { .. } => {}
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
}
v
@@ -1045,6 +1354,10 @@ pub struct Feature {
/// Persistent names of the faces at the feature's face points (`FeatureKind::face_points`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub face_names: Vec<String>,
/// Bodies a join/cut/intersect may change (any component); empty = the feature's own
/// component's bodies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub participants: Vec<String>,
#[serde(flatten)]
pub kind: FeatureKind,
}
@@ -1053,6 +1366,10 @@ fn inside_str() -> String {
"inside".into()
}
fn all_profiles() -> ProfileSel {
ProfileSel::All
}
fn zero_expr() -> String {
"0".into()
}
@@ -1061,6 +1378,15 @@ fn is_root(c: &u64) -> bool {
*c == 0
}
/// A standard part (fastener, pin, bearing) inserted as a component.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StandardPart {
pub family: String,
pub size: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub length: Option<f64>,
}
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -1091,6 +1417,9 @@ pub struct Document {
/// Plastic rules.
#[serde(default, skip_serializing_if = "crate::plastic::PlasticSettings::is_empty")]
pub plastic: crate::plastic::PlasticSettings,
/// Components that are standard parts (by component id): what to rebuild them from.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub parts: std::collections::BTreeMap<u64, StandardPart>,
/// Configurations (rows of parameter values and suppressions).
#[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")]
pub configs: crate::config::ConfigTable,
@@ -1112,6 +1441,10 @@ pub struct Document {
/// Bodies moved into another component than their feature's (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_components: std::collections::BTreeMap<String, u64>,
/// Bodies moved into another component keep their place in the world: the transform from
/// the new component's frame to the frame the body was made in (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_offsets: std::collections::BTreeMap<String, crate::Mat>,
/// Browser groups (folders of bodies, sketches or construction planes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub browser_groups: Vec<BrowserGroup>,
@@ -1178,7 +1511,9 @@ impl Document {
sheet: Default::default(),
plastic: Default::default(),
configs: Default::default(),
parts: Default::default(),
body_components: Default::default(),
body_offsets: Default::default(),
materials: Default::default(),
appearances: Default::default(),
favorites: Default::default(),
@@ -1194,7 +1529,7 @@ impl Document {
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color())
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
}
/// The model with each body carrying the colour, opacity and face colours it is shown with
@@ -1209,7 +1544,11 @@ impl Document {
.into_iter()
.map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 })
.collect();
let opacity = look.map(|l| l.opacity as f32).or_else(|| b.body.paint().map(|p| p.opacity)).unwrap_or(1.0);
let opacity = look
.map(|l| l.opacity as f32)
.or_else(|| b.body.paint().map(|p| p.opacity))
.or_else(|| b.imported.as_ref().and_then(|i| i.opacity))
.unwrap_or(1.0);
let paint = solvecraft_kernel::Paint { opacity, faces };
if c != b.body.color() || b.body.paint() != Some(&paint) {
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
@@ -1324,6 +1663,7 @@ impl Document {
edge_refs: Vec::new(),
edge_names: Vec::new(),
face_names: Vec::new(),
participants: Vec::new(),
kind,
};
match self.marker {
@@ -1341,18 +1681,7 @@ impl Document {
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
let mut gone = vec![id];
for f in &self.features {
match &f.kind {
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id),
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetFold { sketch: Some(s), .. } if *s == id => gone.push(f.id),
_ => {}
}
}
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
self.features.retain(|f| !gone.contains(&f.id));
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
@@ -1493,6 +1822,18 @@ impl Document {
/// Resolve a plane reference.
pub fn resolve_plane(&self, vals: &std::collections::BTreeMap<String, expr::Value>, p: &PlaneRef, depth: usize) -> Result<Plane> {
self.resolve_plane_in(vals, p, depth, None)
}
/// Resolve a plane; planes built from model geometry (edges, faces, construction axes and
/// points) need the model state `st`, and construction planes are read from it when given.
pub fn resolve_plane_in(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
p: &PlaneRef,
depth: usize,
st: Option<&crate::ModelState>,
) -> Result<Plane> {
if depth > 32 {
return Err(DocError::Invalid("plane offsets nested too deeply".into()));
}
@@ -1502,21 +1843,38 @@ impl Document {
Plane::new(plane.origin, plane.x, plane.y).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Offset { base, distance } => {
let b = self.resolve_plane(vals, base, depth + 1)?;
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?))
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => {
let b = self.resolve_plane(vals, base, depth + 1)?;
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
let a = Self::eval_in(vals, angle, Kind::Angle)?;
let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?;
let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos()));
let o = *axis_origin + rot(b.origin - *axis_origin);
Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Construction { name } => match self.find_feature(name).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane(vals, plane, depth + 1),
_ => Err(DocError::Unknown(format!("construction plane `{name}`"))),
},
PlaneRef::Construction { name } => {
// Evaluated already: as it came out.
if let Some(c) = st.and_then(|st| st.construct.iter().find(|c| &c.name == name))
&& let crate::construct::ConstructGeom::Plane(pl) = &c.geom
{
return Ok(*pl);
}
match self.find_feature(name).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane_in(vals, plane, depth + 1, st),
_ => Err(DocError::Unknown(format!("construction plane `{name}`"))),
}
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::Tangent { .. }
| 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)
}
}
}
+785 -55
View File
File diff suppressed because it is too large Load Diff
+88 -9
View File
@@ -45,7 +45,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
apply_op(st, f, tools, Operation::Join, &[])
}
FeatureKind::ReplaceFace { faces, target, body } => {
let tp = doc.resolve_plane(vals, target, 0)?;
let tp = doc.resolve_plane_in(vals, target, 0, Some(st))?;
let tn = tp.normal();
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
@@ -288,7 +288,13 @@ fn mirror_y(r: &Region2) -> Region2 {
Region2 { outer: m(&r.outer), holes: r.holes.iter().map(|h| m(h).reversed()).collect() }
}
pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
pub(super) fn sheet_eval(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &mut ModelState,
warning: &mut Option<String>,
) -> Result<()> {
use crate::sheet;
match &f.kind {
FeatureKind::SheetBase { sketch, profiles, rule, flip } => {
@@ -303,7 +309,22 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Fea
(ss.plane, region)
};
let name = new_name(st, f, k);
let sh = sheet::base_flange(&name, &r.name, &rv, plane, region);
let mut sh = sheet::base_flange(&name, &r.name, &rv, plane, region);
// The sketch's lines name the base panel's edges.
sh.base_curves = ss
.sketch
.curves
.iter()
.enumerate()
.filter(|(_, c)| !c.construction)
.flat_map(|(ci, c)| {
ss.sketch.segs(ci).into_iter().filter_map(move |sg| match sg {
Seg2::Line { a, b } => Some((c.id.clone(), a, b)),
_ => None,
})
})
.map(|(id, a, b)| if *flip { (id, Vec2::new(a.x, -a.y), Vec2::new(b.x, -b.y)) } else { (id, a, b) })
.collect();
let solid = sh.solid()?;
st.bodies.push(ModelBody::new(name, solid, f.id));
st.sheets.push(sh);
@@ -349,7 +370,10 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Fea
if sh.flat {
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
}
sh.add_flanges(edges, h, a, rr, position, *flip)?;
let picks = sheet_picks(sh, f, edges, warning);
let n0 = sh.flanges.len();
sh.add_flanges(&picks, h, a, rr, position, *flip)?;
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
@@ -363,9 +387,12 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Fea
}
};
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
for e in edges {
let picks = sheet_picks(sh, f, edges, warning);
let n0 = sh.flanges.len();
for e in &picks {
sh.add_hem(*e, l, g, *flip)?;
}
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip, fixed, body } => {
@@ -394,7 +421,9 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Fea
if sh.flat {
return Err(DocError::Invalid("refold the sheet before folding it".into()));
}
let n0 = sh.flanges.len();
sh.add_fold(a, b, ang, rr, position, *flip, *fixed)?;
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetUnfold { body, refold } => {
@@ -584,7 +613,12 @@ fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
/// The body a placed plastic feature joins: by name, else the one under the point.
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
let i = body_at(st, body, &[p])?;
st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into()))
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
let tolerance = (b.body.size() * 1e-5).max(1e-4);
if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) {
return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into()));
}
Ok(b.name.clone())
}
/// Axes with z along `z` (and x along `x` when given).
@@ -754,11 +788,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 {
@@ -843,3 +894,31 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
_ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))),
}
}
/// Edge picks of a sheet feature, moved onto their named edges (a name that is gone keeps the
/// point, with a warning).
fn sheet_picks(sh: &crate::sheet::SheetBody, f: &Feature, edges: &[Vec3], warning: &mut Option<String>) -> Vec<Vec3> {
let mut gone = Vec::new();
let out = edges
.iter()
.enumerate()
.map(|(i, p)| match f.edge_names.get(i).filter(|n| !n.is_empty()) {
Some(n) => sh.edge_by_name(n, *p).unwrap_or_else(|| {
gone.push(n.clone());
*p
}),
None => *p,
})
.collect();
if !gone.is_empty() {
*warning = Some(format!("sheet edge(s) {} no longer exist; using the edges at the picked points", gone.join(", ")));
}
out
}
/// Tag the flanges a feature just added (`F<feature>.<k>`).
fn tag_flanges(sh: &mut crate::sheet::SheetBody, from: usize, feature: u64) {
for (k, fl) in sh.flanges.iter_mut().enumerate().skip(from) {
fl.tag = format!("F{feature}.{}", k - from);
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Evaluation of the surface features: Patch, Stitch, Thicken and Trim (kernel `surfaces`).
use super::*;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, body, edges } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
}
}
if !edges.is_empty() {
let i = body_at(st, body, edges)?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
made.push(kernel::patch_edges(&b.body, edges)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
}
for (k, b) in made.into_iter().enumerate() {
let name = new_name(st, f, k);
st.bodies.push(ModelBody::new(name, b, f.id));
}
Ok(())
}
FeatureKind::Stitch { bodies, tolerance } => {
let tol = val(vals, tolerance, Kind::Length)?;
let idx: Vec<usize> = bodies
.iter()
.map(|n| st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`"))))
.collect::<Result<_>>()?;
let Some(&first) = idx.first() else { return Err(DocError::Invalid("pick the surfaces to stitch".into())) };
let refs: Vec<&kernel::Body> = idx.iter().filter_map(|i| st.bodies.get(*i).map(|b| &b.body)).collect();
let sewn = kernel::stitch(&refs, tol)?;
let Some(mb) = st.bodies.get(first).cloned() else { return Err(DocError::Invalid("body".into())) };
if let Some(slot) = st.bodies.get_mut(first) {
*slot = ModelBody::new(mb.name, sewn, mb.feature);
}
// The others are now part of the first.
let gone: Vec<String> = idx.iter().skip(1).filter_map(|i| st.bodies.get(*i).map(|b| b.name.clone())).collect();
st.bodies.retain(|b| !gone.contains(&b.name));
Ok(())
}
FeatureKind::Thicken { bodies, thickness, symmetric, operation, targets } => {
let t = val(vals, thickness, Kind::Length)?;
let mut tools = Vec::new();
for n in bodies {
let b = st.body(n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
tools.push(kernel::thicken(&b.body, t, *symmetric)?);
}
if tools.is_empty() {
return Err(DocError::Invalid("pick the surfaces to thicken".into()));
}
apply_op(st, f, tools, *operation, targets)
}
FeatureKind::SurfaceTrim { body, plane, tool, keep } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let trimmed = match tool {
Some(t) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, *keep)?
}
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), *keep)?,
};
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, trimmed, mb.feature);
}
Ok(())
}
FeatureKind::SurfaceExtend { body, edges, distance } => {
let d = val(vals, distance, Kind::Length)?;
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let out = kernel::extend(&mb.body, edges, d)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, out, mb.feature);
}
Ok(())
}
_ => Err(DocError::Invalid("not a surface feature".into())),
}
}
+75 -2
View File
@@ -13,15 +13,20 @@
//! - 3: appearances per body, face and component with names and opacity
//! (`"appearances": {"bodies": {name: {name, color, opacity}}, "faces": […], "components": {…}}`);
//! before, `"appearances"` mapped body names to bare `[r, g, b]` colours.
//! - 5: joins, cuts and intersects change only their own component's bodies unless bodies are
//! named as `participants`; older features in designs with components get `participants:
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
//! explicit planes retaining their previous axes, including offset-plane expressions.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 4;
pub const FORMAT: u32 = 6;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -34,7 +39,49 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 3] = [|_| Ok(()), v2_to_v3, v3_to_v4];
const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
/// dimensions, profiles, extrude directions or parameter expressions in saved designs.
fn v5_to_v6(v: &mut Value) -> Result<()> {
fn preserve(v: &mut Value) {
match v {
Value::Object(o) => {
let old_xz = o.get("type").and_then(Value::as_str) == Some("origin")
&& o.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case("XZ") || n.eq_ignore_ascii_case("FRONT"));
if old_xz {
*v = serde_json::json!({"type": "custom", "plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}});
} else {
o.values_mut().for_each(preserve);
}
}
Value::Array(a) => a.iter_mut().for_each(preserve),
_ => {}
}
}
preserve(v);
Ok(())
}
/// Features in designs with components keep reaching every body.
fn v4_to_v5(v: &mut Value) -> Result<()> {
let has_components = v.get("components").and_then(Value::as_array).is_some_and(|c| !c.is_empty());
if !has_components {
return Ok(());
}
if let Some(Value::Array(fs)) = v.get_mut("features") {
for f in fs.iter_mut() {
if let Some(o) = f.as_object_mut()
&& o.get("type").and_then(Value::as_str) != Some("sketch")
&& o.get("participants").is_none()
{
o.insert("participants".into(), serde_json::json!(["*"]));
}
}
}
Ok(())
}
/// Joints placed with the old convention (aligned unless flipped) keep their placement.
fn v3_to_v4(v: &mut Value) -> Result<()> {
@@ -176,6 +223,32 @@ mod tests {
assert_eq!(back, d);
}
#[test]
fn old_xz_and_nested_offsets_keep_their_frame() {
let mut doc = Document::new("old XZ");
let id = doc
.add_feature(
crate::FeatureKind::ConstructionPlane {
plane: crate::PlaneRef::Offset { base: Box::new(crate::PlaneRef::Origin { name: "XZ".into() }), distance: "7 mm".into() },
},
None,
)
.unwrap();
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = Value::from("solvecraft/5");
let migrated = read(&json.to_string()).unwrap();
let crate::FeatureKind::ConstructionPlane { plane } = &migrated.feature(id).unwrap().kind else { panic!("plane") };
let (values, errors) = migrated.param_values();
assert!(errors.is_empty());
let p = migrated.resolve_plane(&values, plane, 0).unwrap();
assert_eq!(p.y, solvecraft_geom::Vec3::Z);
assert_eq!(p.normal(), -solvecraft_geom::Vec3::Y);
assert_eq!(p.origin, solvecraft_geom::Vec3::new(0.0, -7.0, 0.0));
assert_eq!(read(&migrated.to_json()).unwrap(), migrated);
let fresh = doc.resolve_plane(&values, &crate::PlaneRef::Origin { name: "XZ".into() }, 0).unwrap();
assert_eq!(fresh.normal(), solvecraft_geom::Vec3::Y);
}
#[test]
fn older_upgraded_newer_refused_others_rejected() {
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
+2 -1
View File
@@ -10,6 +10,7 @@ mod assembly;
pub mod canvas;
mod clipboard;
pub mod config;
pub mod construct;
mod document;
mod eval;
pub mod expr;
@@ -28,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
world_state,
state_in_frame, world_state,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solvecraft_kernel as kernel;
+88 -1
View File
@@ -34,6 +34,12 @@ impl NameCell {
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
NameCell { origin, names: Arc::new(OnceLock::new()) }
}
/// Names already known (a moved copy of a body keeps its faces' names).
pub fn known(names: Arc<Vec<String>>) -> Self {
let cell = OnceLock::new();
let _ = cell.set(names);
NameCell { origin: None, names: Arc::new(cell) }
}
}
/// Deeper chains than this are named by position only (bounded work and stack).
@@ -226,7 +232,14 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
.enumerate()
.map(|(fi, n)| {
n.unwrap_or_else(|| {
role_name(&o.feature, &o.before, pts.get(fi).map(Vec::as_slice).unwrap_or(&[]), ns.get(fi).unwrap_or(&empty), fi, fid)
let (ps, nn) = (pts.get(fi).map(Vec::as_slice).unwrap_or(&[]), ns.get(fi).unwrap_or(&empty));
// A shell's inner face: the face its wall runs back to.
if matches!(o.feature.kind, FeatureKind::Shell { .. })
&& let Some(n) = behind(ps, nn, &inputs)
{
return format!("F{fid}:inner:{}", strip_piece(&n));
}
role_name(&o.feature, &o.before, ps, nn, fi, fid)
})
})
.collect();
@@ -234,6 +247,59 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
names
}
/// The input face a face looks back at through the material (along minus its normal): the
/// face a shell wall's inner side offsets.
fn behind(ps: &[Vec3], ns: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)]) -> Option<String> {
let n = ns.first().copied()?;
let d = n * -1.0;
let mut votes: Vec<(String, usize)> = Vec::new();
for p in ps {
let mut best: Option<(f64, String)> = None;
for (_, idx, nm) in inputs {
for (fi, (_, _, tris)) in idx.faces.iter().enumerate() {
for [a, b, c] in tris {
if let Some(t) = ray_hit(*p, d, *a, *b, *c)
&& t > 1e-9
&& best.as_ref().is_none_or(|(bt, _)| t < *bt)
&& let Some(name) = nm.get(fi)
{
best = Some((t, name.clone()));
}
}
}
}
if let Some((_, n)) = best {
match votes.iter_mut().find(|(v, _)| *v == n) {
Some(v) => v.1 += 1,
None => votes.push((n, 1)),
}
}
}
votes.sort_by_key(|v| std::cmp::Reverse(v.1));
votes.first().filter(|(_, k)| *k * 2 >= ps.len().max(1)).map(|(n, _)| n.clone())
}
fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
let (e1, e2) = (b - a, c - a);
let h = d.cross(e2);
let det = e1.dot(h);
if det.abs() < 1e-14 {
return None;
}
let f = 1.0 / det;
let s = o - a;
let u = f * s.dot(h);
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = s.cross(e1);
let v = f * d.dot(q);
if v < 0.0 || u + v > 1.0 {
return None;
}
Some(f * e2.dot(q))
}
/// The input face most of a face's sample points lie on (mapped by `map` first), if it holds
/// at least half of them.
fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol: f64, map: Option<&crate::Mat>) -> Option<String> {
@@ -382,6 +448,27 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
.min_by(|a, b| a.0.total_cmp(&b.0));
best.map(|(_, id)| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
}
FeatureKind::Sweep { .. } | FeatureKind::Loft { .. } => {
let sk = match &f.kind {
FeatureKind::Sweep { sketch, .. } => Some(*sketch),
FeatureKind::Loft { sections, .. } => sections.first().map(|x| x.sketch),
_ => None,
};
let Some(ss) = sk.and_then(|k| before.sketch(k)) else { return format!("F{fid}:face:{fi}") };
let n = ss.plane.normal();
let h = |p: &Vec3| (*p - ss.plane.origin).dot(n);
let flat = !ns.is_empty() && ns.windows(2).all(|w| w[0].cross(w[1]).len() < 1e-6);
if flat && ps.iter().all(|p| h(p).abs() < 1e-6 * (1.0 + p.len())) {
return format!("F{fid}:start");
}
if flat && ns.iter().all(|m| m.cross(n).len() < 1e-6) {
return format!("F{fid}:end");
}
// A side: the profile curve under its sample nearest the start.
let Some(p0) = ps.iter().min_by(|a, b| h(a).abs().total_cmp(&h(b).abs())) else { return format!("F{fid}:face:{fi}") };
let q = ss.plane.to_local(*p0);
nearest_curve(ss, Vec2::new(q.x, q.y)).map(|id| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
}
FeatureKind::Box { .. } => {
let n = ns.first().copied().unwrap_or(Vec3::Z);
let axis = |v: f64, p: &str, m: &str| {
+92 -5
View File
@@ -21,6 +21,7 @@ fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut
v.push(("Angle", angle, Kind::Angle));
plane_inputs_mut(base, v);
}
PlaneRef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, Kind::Unitless)),
_ => {}
}
}
@@ -34,7 +35,25 @@ impl FeatureKind {
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. }
| FeatureKind::Mirror { plane, .. } => plane_inputs_mut(plane, &mut v),
FeatureKind::ConstructionAxis { def } => {
for r in def.refs_mut() {
if let crate::construct::GeoRef::Plane { plane } = r {
plane_inputs_mut(plane, &mut v);
}
}
}
FeatureKind::ConstructionPoint { def } => match def {
crate::construct::PointDef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, U)),
other => {
for r in other.refs_mut() {
if let crate::construct::GeoRef::Plane { plane } = r {
plane_inputs_mut(plane, &mut v);
}
}
}
},
FeatureKind::Extrude { extent, .. } => {
v.push(("Distance", &mut extent.distance, L));
if let Some(d) = &mut extent.distance2 {
@@ -47,9 +66,30 @@ impl FeatureKind {
v.push(("Taper angle", d, A));
}
}
FeatureKind::Revolve { angle, .. } => v.push(("Angle", angle, A)),
FeatureKind::Fillet { radius, .. } => v.push(("Radius", radius, L)),
FeatureKind::Chamfer { distance, .. } => v.push(("Distance", distance, L)),
FeatureKind::Revolve { angle, angle2, .. } => {
v.push(("Angle", angle, A));
if let Some(a) = angle2 {
v.push(("Angle 2", a, A));
}
}
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, 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, .. } => {
v.push(("Distance", distance, L));
if let Some(d) = distance2 {
v.push(("Distance 2", d, L));
}
if let Some(a) = angle {
v.push(("Angle", a, A));
}
}
FeatureKind::Box { length, width, height, .. } => {
v.push(("Length", length, L));
v.push(("Width", width, L));
@@ -215,12 +255,19 @@ impl FeatureKind {
}
}
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
FeatureKind::Patch { .. } => {}
FeatureKind::Combine { .. }
| FeatureKind::SheetBase { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. }
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. }
| FeatureKind::DeleteFace { .. }
| FeatureKind::BoundaryFill { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
| FeatureKind::Import { .. }
@@ -326,6 +373,35 @@ impl Document {
}
/// Change a parameter: a stored one, or a feature input by its name.
/// A typed length that is a bare number means the design's units: it gets that unit written
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
pub fn with_design_unit(&self, e: &str, kind: Kind) -> String {
let u = self.units.trim();
let bare = expr::is_literal(e) && e.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok();
if kind == Kind::Length && bare && !u.is_empty() && u != "mm" && expr::unit_info(u).is_some_and(|(k, _)| k == Kind::Length) {
format!("{} {u}", e.trim())
} else {
e.to_string()
}
}
/// Every length input of every feature as the geometry reads it (mm; a unit-less result is
/// in the design's units), for checking that an edit moves nothing.
pub fn length_inputs(&self) -> Vec<Option<f64>> {
let (vals, _) = self.param_values();
let scale = expr::unit_info(self.units.trim()).filter(|(k, _)| *k == Kind::Length).map(|(_, s)| s).unwrap_or(1.0);
let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")));
self.features
.iter()
.flat_map(|f| f.kind.inputs())
.filter(|(_, _, k)| *k == Kind::Length)
.map(|(_, e, _)| {
let d = if expr::is_literal(&e) { expr::Defaults::default() } else { expr::Defaults { len: scale, ..expr::Defaults::default() } };
expr::eval_with(&e, &look).and_then(|v| v.to_kind_in(Kind::Length, d)).ok()
})
.collect()
}
pub fn change_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
let hit = self.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k)));
let Some((i, k)) = hit else { return self.set_param(name, expr_s, unit, comment) };
@@ -333,10 +409,12 @@ impl Document {
return Err(DocError::Expr("expression too long".into()));
}
let mut next = self.clone();
let kind = self.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless);
let expr_s = self.with_design_unit(expr_s, kind);
if let Some(f) = next.features.get_mut(i)
&& let Some((_, e, _)) = f.kind.inputs_mut().into_iter().nth(k)
{
*e = expr_s.to_string();
*e = expr_s.clone();
}
if let Some(c) = comment {
next.param_comments.insert(name.to_string(), c.to_string());
@@ -515,7 +593,16 @@ impl Document {
for f in &self.features {
for (name, (_, e, k)) in f.param_names.iter().zip(f.kind.inputs()) {
if !name.is_empty() {
v.push(ParamDef { name: name.clone(), expr: e, unit: unit_of(k).into() });
// A bare number stored in an input is mm (the design's units are written in
// when it is typed); anything else that comes out unit-less (a unit-less
// parameter used as a length) is in the design's units.
let unit = match k {
Kind::Length if !expr::is_literal(&e) && expr::unit_info(self.units.trim()).is_some_and(|(k, _)| k == Kind::Length) => {
self.units.trim().to_string()
}
_ => unit_of(k).to_string(),
};
v.push(ParamDef { name: name.clone(), expr: e, unit });
}
}
}
+16 -3
View File
@@ -257,7 +257,7 @@ fn resolve_uncached(
LinkSource::Plane { name } => {
let pr =
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
let other = doc.resolve_plane(vals, &pr, 0).map_err(|_| lost(&format!("plane {name}")))?;
let other = doc.resolve_plane_in(vals, &pr, 0, Some(st)).map_err(|_| lost(&format!("plane {name}")))?;
plane_trace(plane, &other).into_iter().collect()
}
};
@@ -273,7 +273,20 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
let mut warn = Vec::new();
let links = sk.links.clone();
for l in &links {
match resolve(doc, vals, st, plane, l.kind, &l.source) {
let mut source = l.source.clone();
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
*at = p;
if split {
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
}
} else {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected face `{name}` no longer exists"));
continue;
}
}
match resolve(doc, vals, st, plane, l.kind, &source) {
Ok(r) => {
sk.set_link_source(&l.id, r.source);
let _ = sk.set_link_wires(&l.id, &r.wires);
@@ -472,7 +485,7 @@ fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
let (c, n, r, closed) = circle3(pts)?;
let sn = plane.normal();
let cosang = n.dot(sn).abs();
if cosang > 1.0 - 1e-12 || cosang < 1e-9 {
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
return None;
}
if closed {
+93 -3
View File
@@ -160,6 +160,9 @@ pub struct SheetFlange {
/// instead of a rectangle `leg` long.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<Vec<Vec2>>,
/// Which feature made it: `F<feature>.<k>` (persistent edge names).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tag: String,
}
/// A sheet metal body (see the module docs).
@@ -179,6 +182,9 @@ pub struct SheetBody {
pub flat: bool,
/// Gap between flanges that meet at a corner.
pub gap: f64,
/// The base sketch's lines (id, ends in flat coordinates), naming the base panel's edges.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub base_curves: Vec<(String, Vec2, Vec2)>,
}
/// A piece of the sheet: a panel (None = the base) or a flange's bend zone.
@@ -839,6 +845,7 @@ impl SheetBody {
radius,
leg: leg.max(0.0),
shape: None,
tag: String::new(),
ext: (0.0, 0.0),
});
made.push(self.flanges.len() - 1);
@@ -892,7 +899,7 @@ impl SheetBody {
return Err(DocError::Invalid("the hem is shorter than its bend".into()));
}
// A hem folds back over the face it starts from (toward the bottom unless flipped).
let up = !top == flip;
let up = top != flip;
let up = !up;
self.flanges.push(SheetFlange {
parent: panel,
@@ -905,6 +912,7 @@ impl SheetBody {
leg,
ext: (0.0, 0.0),
shape: None,
tag: String::new(),
});
Ok(self.flanges.len() - 1)
}
@@ -945,7 +953,8 @@ impl SheetBody {
let mut n = Vec2::new(-d.y, d.x);
let l0 = a2.dot(n);
// Which side moves. A flange keeps the side its own bend is on.
let side_area = |n: Vec2| signed_area(&clip(&pts, n * l0, n)).abs();
let on_line = n * l0;
let side_area = |m: Vec2| signed_area(&clip(&pts, on_line, m)).abs();
let moves_n = match (panel, fixed) {
(Some(j), _) => {
let f = self.flanges.get(j).ok_or_else(|| DocError::Invalid("flange".into()))?;
@@ -968,7 +977,7 @@ impl SheetBody {
n = n * -1.0;
}
let l = a2.dot(n);
let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None };
let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None, tag: String::new() };
let ba = self.allowance(&probe);
let s0 = match position {
"centerline" | "center" | "centre" => l - ba / 2.0,
@@ -1018,6 +1027,7 @@ impl SheetBody {
leg,
ext: (0.0, 0.0),
shape: Some(ccw(moving)),
tag: String::new(),
});
let i = self.flanges.len() - 1;
// Flanges on the moving part's edges now hang off the fold and move with it.
@@ -1040,6 +1050,69 @@ impl SheetBody {
Ok(i)
}
/// Name a panel edge (flat coordinates a→b on `panel`, top or bottom face): base edges by the
/// base sketch line they lie on, flange edges by the flange (its tag) and side.
fn edge_name_of(&self, panel: Option<usize>, a: Vec2, b: Vec2, top: bool) -> Option<String> {
let face = if top { "top" } else { "bottom" };
let d = (b - a).normalized()?;
match panel {
None => {
let on = |p: Vec2, s: Vec2, e: Vec2| {
let t = e - s;
t.len() > 1e-9 && ((p - s).cross(t) / t.len()).abs() < 1e-6
};
self.base_curves.iter().find(|(_, s, e)| on(a, *s, *e) && on(b, *s, *e)).map(|(id, _, _)| format!("base:{id}:{face}"))
}
Some(i) => {
let f = self.flanges.get(i)?;
if f.tag.is_empty() {
return None;
}
let side = if d.cross(f.d).abs() < 1e-6 {
// Along the bend: the far edge (or the bend side, never picked).
if ((a - f.p).dot(f.n)) > self.allowance(f) + 1e-6 { "far" } else { "bend" }
} else if ((a + b) * 0.5 - f.p).dot(f.d) < f.len / 2.0 {
"side0"
} else {
"side1"
};
Some(format!("{}:{side}:{face}", f.tag))
}
}
}
/// The persistent name of the sheet edge nearest a world point.
pub fn edge_name(&self, pick: Vec3) -> Option<String> {
let (panel, a, b, top, _) = self.edge_at(pick)?;
self.edge_name_of(panel, a, b, top)
}
/// A world point on the sheet edge with this name (the one nearest `near` if several).
pub fn edge_by_name(&self, name: &str, near: Vec3) -> Option<Vec3> {
let mut best: Option<(f64, Vec3)> = None;
for (pi, r) in self.panels() {
let pts = r.outer.ccw().polyline(1e-3);
let m = pts.len();
let w = self.world(pi);
for i in 0..m {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue };
if a.dist(b) < 1e-9 {
continue;
}
for (z, top) in [(0.0, false), (self.t, true)] {
if self.edge_name_of(pi, a, b, top).as_deref() == Some(name) {
let mid = (a + b) * 0.5;
let q = apply_point(&w, Vec3::new(mid.x, mid.y, z));
if best.is_none_or(|(d, _)| q.dist(near) < d) {
best = Some((q.dist(near), q));
}
}
}
}
}
best.map(|(_, q)| q)
}
/// Map a region in a world plane to flat coordinates through a panel's placement.
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Option<Region2> {
let inv = mat_inverse(&self.world(panel))?;
@@ -1239,6 +1312,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
holes: Vec::new(),
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
}
}
@@ -1289,6 +1363,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
holes: Vec::new(),
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
};
let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) };
// First segment: the base panel.
@@ -1320,6 +1395,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
leg: leg.max(0.0),
ext: (0.0, 0.0),
shape: None,
tag: String::new(),
};
let ba = sheet.allowance(&f);
sheet.flanges.push(f);
@@ -1329,6 +1405,20 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
Ok(sheet)
}
/// Persistent names of the sheet edges nearest each world point (empty when none).
pub fn sheet_edge_names(st: &crate::ModelState, pts: &[Vec3]) -> Vec<String> {
pts.iter()
.map(|p| {
st.sheets
.iter()
.filter_map(|sh| sh.edge_at(*p).map(|e| (e.4, sh)))
.min_by(|a, b| a.0.total_cmp(&b.0))
.and_then(|(_, sh)| sh.edge_name(*p))
.unwrap_or_default()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+83 -2
View File
@@ -38,6 +38,9 @@ fn plate_doc() -> (Document, u64) {
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: Operation::NewBody,
targets: vec![],
@@ -78,10 +81,31 @@ fn parametric_plate_reevaluates() {
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
}
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
/// the evaluation reports the features it restored (their geometry did change back).
#[test]
fn set_back_reports_restored_features() {
let (mut doc, _) = plate_doc();
let mut m = Model::new();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
doc.set_param("width", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
doc.set_param("width", "40 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 2));
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
// No change: nothing recomputed and nothing restored.
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
}
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None }, None).unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None, style: Default::default() }, None)
.unwrap();
let mut sk = Sketch::new();
sk.add_circle(Vec2::new(20.0, 15.0), 5.0, None, None).unwrap();
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
@@ -96,6 +120,9 @@ fn box_fillet_cut_timeline() {
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: Operation::Cut,
targets: vec![],
@@ -128,11 +155,15 @@ fn errors_are_reported_not_fatal() {
angle: "90 deg".into(),
operation: Operation::NewBody,
targets: vec![],
angle2: None,
symmetric: false,
to: None,
},
None,
)
.unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None }, None).unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None)
.unwrap();
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody },
None,
@@ -264,3 +295,53 @@ fn parameter_cycles_are_found() {
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
}
/// Features that use a sketch's points or regions go with it (not only profile and path users).
#[test]
fn input_sketches_cover_points_and_patches() {
let hole: FeatureKind = serde_json::from_value(serde_json::json!({
"type": "hole", "position": [0, 0, 0], "direction": [0, 0, -1], "diameter": "3",
"points": {"sketch": 7, "ids": ["p1"]},
}))
.unwrap();
assert_eq!(hole.input_sketches(), vec![7]);
let patch: FeatureKind = serde_json::from_value(serde_json::json!({"type": "patch", "sketch": 9})).unwrap();
assert_eq!(patch.input_sketches(), vec![9]);
let pipe: FeatureKind = serde_json::from_value(serde_json::json!({"type": "pipe", "path_sketch": 72, "path": ["l1"], "diameter": "4"})).unwrap();
assert_eq!(pipe.input_sketches(), vec![72]);
}
/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch
/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the
/// arm that returns no sketches. New variants are caught by the compiler (the match has no
/// wildcard); this catches a sketch field added to an existing variant.
#[test]
fn input_sketches_lists_every_variant_with_a_sketch_field() {
let src = include_str!("document.rs");
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
let mut with_sketch = Vec::new();
let mut variant = "";
for line in body.lines() {
let t = line.trim();
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("");
continue;
}
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints")))
|| ty.contains("SketchPoints")
|| ty.contains("LoftSection");
if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) {
with_sketch.push(variant);
}
}
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap();
let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap();
for v in with_sketch {
assert!(
!empty_arm.contains(&format!("K::{v} {{")),
"FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken"
);
}
}
@@ -0,0 +1,66 @@
//! Booleans that failed, panicked or hung, replayed from the scripts in
//! `examples/regressions/` (each script's description says what went wrong).
use serde_json::{Value, json};
use crate::Session;
fn replay(script: &str) -> Session {
let mut s = Session::default();
let script: Value = serde_json::from_str(script).unwrap();
s.run_script(&script).unwrap();
s
}
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)
}
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
fn slab_pocket_loft() -> (f64, f64) {
let slab = 900.0 * 480.0 * 160.0;
let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0);
(slab - 557.68 * 100.0 * 120.0, loft)
}
#[test]
fn loft_cut_beside_a_round_hole() {
let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json"));
let (pocketed, loft) = slab_pocket_loft();
let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0;
let want = pocketed - loft - hole;
let got = volume(&s, "Body1");
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
}
#[test]
fn shallow_loft_cut_from_the_top_face() {
let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json"));
let (_, loft) = slab_pocket_loft();
let want = 900.0 * 480.0 * 160.0 - loft;
let got = volume(&s, "Body1");
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
}
#[test]
fn oblique_cut_through_a_small_arc_matches_a_split() {
let scripts = [
include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json"),
include_str!("../../../examples/regressions/oblique_cut_of_cylinder_hang.json"),
];
// The same bar split by the prism's tilted top: the piece above it is the cut's result.
let mut split = Session::default();
let script: Value = serde_json::from_str(scripts[0]).unwrap();
let steps = script.get("commands").and_then(Value::as_array).unwrap();
split.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
let whole = volume(&split, "Tool");
split.execute("solid.split_body", &json!({"body": "Tool", "plane": {"origin": [0, -472, 853.91], "normal": [0, 15.44, 40]}})).unwrap();
let above = volume(&split, "Tool");
assert!(above < whole - 10.0, "{above} of {whole}");
for script in scripts {
let s = replay(script);
let got = volume(&s, "Tool");
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
}
}
+195 -33
View File
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
use solvecraft_doc::FeatureKind;
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::params::{bad, bool_, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
@@ -34,6 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
or folder?: bodies|sketches|construction|origin (all of it). View state: not saved, not an undo step",
),
];
const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"];
@@ -155,6 +159,83 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({}))
}
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
/// A browser item whose visibility can change.
enum Shown {
Sketch(u64),
Body(String),
/// Origin items and construction geometry, by name.
Origin(String),
}
fn is_construction(k: &FeatureKind) -> bool {
matches!(k, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
}
fn shown_item(s: &Session, key: &str) -> Option<Shown> {
if let Some(f) = s.doc.find_feature(key) {
if matches!(f.kind, FeatureKind::Sketch { .. }) {
return Some(Shown::Sketch(f.id));
}
if is_construction(&f.kind) {
return Some(Shown::Origin(f.name.clone()));
}
}
if s.world_state().bodies.iter().any(|b| b.name == key) {
return Some(Shown::Body(key.to_string()));
}
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
}
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "browser.visibility";
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin)"))),
(None, Some(_)) => {
let keys = keys(p, "items", cmd)?;
if keys.is_empty() {
return Err(bad(cmd, "`items` must list sketches, bodies or construction geometry"));
}
// Every item is found before anything changes.
keys.iter()
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body or construction item `{k}`"))))
.collect::<Result<_>>()?
}
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
};
let v = &mut s.visibility;
for it in &items {
match it {
Shown::Sketch(id) => {
v.hidden_sketches.retain(|x| x != id);
v.shown_sketches.retain(|x| x != id);
if visible { v.shown_sketches.push(*id) } else { v.hidden_sketches.push(*id) }
}
Shown::Body(n) => {
v.hidden_bodies.retain(|x| x != n);
if !visible {
v.hidden_bodies.push(n.clone());
}
}
Shown::Origin(n) => {
v.hidden_origin.retain(|x| x != n);
if !visible {
v.hidden_origin.push(n.clone());
}
}
}
}
s.revision += 1;
Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility }))
}
fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "document.units";
let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?;
@@ -165,14 +246,17 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
if old == u {
return Ok(json!({ "units": u, "kept": [] }));
}
let (before, _) = s.doc.param_values();
// What the geometry reads: every feature's length inputs.
let before = s.doc.length_inputs();
s.doc_mut().units = u.to_string();
// Unit-less user parameters holding a bare number read it in the design's units where they
// stand for lengths. If any value moved, those numbers keep the old unit, written out.
let (after, _) = s.doc.param_values();
let moved = before.iter().any(|(k, b)| after.get(k).is_none_or(|a| (a.v - b.v).abs() > 1e-9 * b.v.abs().max(1.0)));
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
let same = |a: &[Option<f64>]| {
a.iter().zip(&before).filter(|(x, y)| matches!((x, y), (Some(x), Some(y)) if (x - y).abs() <= 1e-9 * y.abs().max(1.0))).count()
};
let after = s.doc.length_inputs();
let mut kept = Vec::new();
if moved {
if same(&after) < before.len() {
let bare: Vec<(String, String)> = s
.doc
.params
@@ -181,16 +265,15 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
.map(|p| (p.name.clone(), p.expr.trim().to_string()))
.collect();
for (name, e) in bare {
let now = same(&s.doc.length_inputs());
let probe = {
let mut d = (*s.doc).clone();
let _ = d.change_param(&name, &format!("{e} {old}"), None, None);
d.param_values().0
let _ = d.change_param(&name, &format!("{e} {old}"), Some(&old), None);
same(&d.length_inputs())
};
// Only when writing the old unit puts the values back where they were.
let fixes = before.iter().filter(|(k, b)| probe.get(*k).is_some_and(|a| (a.v - b.v).abs() <= 1e-9 * b.v.abs().max(1.0))).count()
> before.iter().filter(|(k, b)| after.get(*k).is_some_and(|a| (a.v - b.v).abs() <= 1e-9 * b.v.abs().max(1.0))).count();
if fixes {
s.doc_mut().change_param(&name, &format!("{e} {old}"), None, None)?;
// Only when writing the old unit puts more inputs back where they were.
if probe > now {
s.doc_mut().change_param(&name, &format!("{e} {old}"), Some(&old), None)?;
kept.push(name);
}
}
@@ -330,10 +413,16 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
f.body = name.to_string();
}
if let Some(o) = d.body_offsets.remove(&old) {
d.body_offsets.insert(name.to_string(), o);
}
if let Some(c) = d.body_components.remove(&old) {
d.body_components.insert(name.to_string(), c);
}
rename_key(d, "bodies", &old, name);
for b in s.visibility.hidden_bodies.iter_mut().filter(|b| **b == old) {
*b = name.to_string();
}
Ok(json!({"body": old, "name": name}))
}
@@ -353,7 +442,7 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
}
let plane = super::sketch::plane_ref(s, p, cmd)?;
let (vals, _) = s.doc.param_values();
s.doc.resolve_plane(&vals, &plane, 0)?;
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
*slot = plane;
}
@@ -380,10 +469,10 @@ mod tests {
#[test]
fn renamed_body_keeps_its_users() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
run(&mut s, "FusionMoveCommand", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
run(&mut s, "solid.move", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
run(&mut s, "body.rename", json!({"body": b, "name": "Lid"}));
assert_eq!(names(&s), vec![a.clone(), "Lid".to_string()]);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "the move still finds its body");
@@ -391,22 +480,24 @@ mod tests {
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": a})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Nope", "name": "X"})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": " "})).is_err());
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s)[1], b);
}
#[test]
fn redefined_sketch_moves_its_extrude() {
let mut s = Session::default();
let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
let zmax = |s: &mut Session| run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let zmax = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
let z = zmax(&mut s);
assert!(z > 9.0, "the profile stands up on XZ: {z}");
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
assert!((ymin + 20.0).abs() < 1e-6);
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
}
@@ -414,8 +505,8 @@ mod tests {
#[test]
fn design_units_change_without_moving_geometry() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 20, "width": 10, "height": 5}));
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5}));
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let v0 = vol(&mut s);
run(&mut s, "document.units", json!({"units": "in"}));
assert_eq!(s.doc.units, "in");
@@ -425,17 +516,34 @@ mod tests {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "5", "unit": ""}));
run(&mut s, "parameters.add", json!({"name": "n", "expression": "3", "unit": ""}));
run(&mut s, "PrimitiveBox", json!({"length": "w", "width": 10, "height": 5}));
run(&mut s, "solid.box", json!({"length": "w", "width": 10, "height": 5}));
let b = s.model.state().bodies[0].name.clone();
run(&mut s, "PatternRectangular", json!({"bodies": [b], "dir1": [1, 0, 0], "count1": "n", "spacing1": 20}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": [b], "dir1": [1, 0, 0], "count1": "n", "spacing1": 20}));
let v0 = vol(&mut s);
let r = run(&mut s, "document.units", json!({"units": "cm"}));
assert!((vol(&mut s) - v0).abs() < 1e-6, "{r}");
assert!(s.doc.param("n").is_some_and(|p| p.expr == "3"), "the count stays bare");
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
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();
@@ -452,15 +560,69 @@ mod tests {
for p in [json!({}), json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -1}}), json!({"camera": "x"})] {
assert!(s.execute("view.save", &p).is_err(), "{p}");
}
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.named_views.len(), 2);
}
#[test]
fn helper_sketches_hide_and_show() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let floor = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [50, 0]], "construction": true}));
run(&mut s, "sketch.finish", json!({}));
let drain = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.point", json!({"point": [25, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Floor"}));
let body = s.model.state().bodies[0].name.clone();
let drain_name = s.doc.feature(drain).unwrap().name.clone();
let cam = solvecraft_render::Camera::default();
let lines = |s: &Session| crate::view::scene(s, &cam).lines.len();
let shown = |s: &mut Session| {
let d = run(s, "document.inspect", json!({}));
d["sketches"].as_array().unwrap().iter().filter(|x| x["visible"] == true).count()
};
let (n0, undo0, doc0) = (lines(&s), s.undo.len(), s.doc.clone());
assert_eq!(shown(&mut s), 2);
// By id and by name; the view loses their lines, the design and its history don't change.
let r = run(&mut s, "browser.visibility", json!({"items": [floor, drain_name], "visible": false}));
assert_eq!(r["changed"], 2);
assert_eq!(shown(&mut s), 0);
assert!(lines(&s) < n0);
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc0) && s.undo.len() == undo0, "view state, not a design change");
run(&mut s, "browser.visibility", json!({"folder": "sketches", "visible": true}));
assert_eq!(lines(&s), n0);
// Bodies (following a rename) and construction planes.
run(&mut s, "browser.visibility", json!({"items": [body.clone(), "Floor", "xy"], "visible": false}));
assert!(crate::view::scene(&s, &cam).meshes.is_empty());
assert_eq!(s.visibility.hidden_origin, vec!["Floor".to_string(), "XY".to_string()]);
run(&mut s, "body.rename", json!({"body": body, "name": "Tray"}));
assert_eq!(s.visibility.hidden_bodies, vec!["Tray".to_string()]);
let d = run(&mut s, "document.inspect", json!({}));
assert_eq!(d["bodies"][0]["visible"], false);
// Hostile input is refused and changes nothing.
let before = s.visibility.clone();
for p in [
json!({"items": [floor]}),
json!({"items": [floor], "visible": "no"}),
json!({"items": [floor, "Nope"], "visible": false}),
json!({"items": [], "visible": false}),
json!({"items": "Floor", "visible": false}),
json!({"folder": "sketches", "items": [floor], "visible": false}),
json!({"folder": "nope", "visible": false}),
json!({"visible": false}),
] {
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
}
assert_eq!(s.visibility, before);
}
#[test]
fn groups_hold_items_and_follow_renames() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
let g = run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}))["group"].as_u64().unwrap();
assert_eq!(s.doc.browser_groups[0].name, "Group1");
@@ -490,7 +652,7 @@ mod tests {
] {
assert!(s.execute(c, &p).is_err(), "{c} {p}");
}
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.browser_groups.len(), 1, "ungroup undoes");
}
}
+12 -12
View File
@@ -122,32 +122,32 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn copy_paste_sketch_and_extrude() {
let mut s = Session::default();
run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "S", "distance": 5}));
run(&mut s, "parameters.change", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5}));
assert!((volume(&mut s) - 500.0).abs() < 1e-6);
run(&mut s, "timeline.copy", json!({"features": ["S", "Extrude1"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [30, 0, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 2);
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 2);
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
// The copy's dimensions are its own, still driven by the user parameter.
run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "20 mm"}));
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
assert!((volume(&mut s) - 2000.0).abs() < 1e-6);
let pasted_extrude = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string();
let d = s.doc.find_feature(&pasted_extrude).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "10"}));
run(&mut s, "parameters.change", json!({"name": d, "expression": "10"}));
assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "only the copy got thicker");
assert!(s.execute("timeline.paste", &json!({"translate": "x"})).is_err());
let mut t = Session::default();
+25 -9
View File
@@ -12,11 +12,11 @@ use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionCreateNewComponentCommand", "New Component", new_component)
CommandSpec::new("component.create", "New Component", new_component)
.at("SOLID", "ASSEMBLE")
.icon("component")
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
CommandSpec::new("FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies", from_bodies)
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
.icon("component")
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
@@ -31,8 +31,8 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
.params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"),
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("SnapshotCmd", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("AsBuiltPositionsCmd", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
];
fn key(v: Option<&Value>) -> Option<String> {
@@ -58,7 +58,7 @@ fn translation(t: Vec3) -> Mat {
}
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCreateNewComponentCommand";
let cmd = "component.create";
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
@@ -70,7 +70,7 @@ fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
}
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCreateComponentsFromBodiesCommand";
let cmd = "component.from_bodies";
let bodies = string_list(p, "bodies");
if bodies.is_empty() || bodies.len() > 1000 {
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
@@ -156,7 +156,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
d.components.retain(|c| !gone.contains(&c.id));
d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent));
let moved_out: Vec<String> = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect();
d.body_components.retain(|_, c| !gone.contains(c));
d.body_offsets.retain(|b, _| !moved_out.contains(b));
if gone.contains(&s.active_component) {
s.active_component = 0;
}
@@ -174,10 +176,23 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
for b in &bodies {
let feature = st.body(b).map(|x| x.feature).unwrap_or(0);
let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0);
// The body keeps its place in the world: where it is shown now (its component's frame and
// any earlier offset), seen from the new component's frame.
let from = s.doc.body_component(b, feature);
let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY));
let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to))
.map(|inv| solvecraft_doc::mat_mul(&inv, &now))
.unwrap_or(solvecraft_doc::IDENTITY);
let doc = s.doc_mut();
if own == to {
s.doc_mut().body_components.remove(b);
doc.body_components.remove(b);
} else {
s.doc_mut().body_components.insert(b.clone(), to);
doc.body_components.insert(b.clone(), to);
}
if solvecraft_doc::is_identity(&offset) {
doc.body_offsets.remove(b);
} else {
doc.body_offsets.insert(b.clone(), offset);
}
}
Ok(json!({"bodies": bodies, "component": to}))
@@ -355,7 +370,8 @@ fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
/// The active component's frame from world coordinates (for new features' geometry).
pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
if s.active_component == 0 {
// Commands whose picks `frames` maps are already in their frames.
if s.active_component == 0 || crate::frames::mapped() {
return;
}
let m = s.doc.component_transform(s.active_component);
+14 -14
View File
@@ -9,10 +9,10 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionStartDesignConfigModeCmd", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
"params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
),
CommandSpec::new("FusionShowDesignConfigPanelCmd", "Display Configuration Table", show)
CommandSpec::new("config.table", "Display Configuration Table", show)
.at("SOLID", "CONFIGURE")
.icon("params")
.noundo()
@@ -54,7 +54,7 @@ fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> {
}
fn configure(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionStartDesignConfigModeCmd";
let cmd = "config.configure";
for n in string_list(p, "params") {
let c = column_of(s, &json!({ "param": n }), cmd)?;
add_column(s, c, cmd)?;
@@ -196,17 +196,17 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn configurations_switch_sizes_and_suppressions() {
let mut s = Session::default();
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "PrimitiveBox", json!({"length": "len", "width": 20, "height": 10}));
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[0, 0, 5]], "radius": 3}));
run(&mut s, "parameters.change", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "solid.box", json!({"length": "len", "width": 20, "height": 10}));
run(&mut s, "solid.fillet", json!({"edges": [[0, 0, 5]], "radius": 3}));
let fillet = s.doc.features.last().map(|f| f.name.clone()).unwrap_or_default();
let t = run(&mut s, "FusionStartDesignConfigModeCmd", json!({"params": ["len"], "features": [fillet]}));
let t = run(&mut s, "config.configure", json!({"params": ["len"], "features": [fillet]}));
assert_eq!(t["rows"][0]["name"], "Default");
run(&mut s, "config.row", json!({"name": "Long", "values": {"len": "80 mm"}}));
run(&mut s, "config.row", json!({"name": "Sharp", "from": "Long", "values": {fillet.clone(): true}}));
@@ -216,17 +216,17 @@ mod tests {
assert!((volume(&mut s) - (16000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 10.0)).abs() < 0.5, "{}", volume(&mut s));
run(&mut s, "config.activate", json!({"row": "Sharp"}));
assert!((volume(&mut s) - 16000.0).abs() < 1e-6);
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
let t = run(&mut s, "config.table", json!({}));
assert_eq!((t["active"].as_str(), t["matches"].as_bool()), (Some("Sharp"), Some(true)), "{t}");
// Undo goes back to the previous configuration's values.
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(volume(&mut s) > 15000.0 && volume(&mut s) < 16000.0);
// Renaming the parameter keeps the column; editing it by hand breaks the match.
run(&mut s, "parameters.rename", json!({"name": "len", "new_name": "length"}));
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
let t = run(&mut s, "config.table", json!({}));
assert_eq!(t["columns"][0], "param:length", "{t}");
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "50 mm"}));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["matches"], false);
run(&mut s, "parameters.change", json!({"name": "length", "expression": "50 mm"}));
assert_eq!(run(&mut s, "config.table", json!({}))["matches"], false);
// Bad input.
assert!(s.execute("config.row", &json!({"name": "X", "values": {"nope": 1}})).is_err());
assert!(s.execute("config.activate", &json!({"row": "Missing"})).is_err());
@@ -234,6 +234,6 @@ mod tests {
assert!(s.execute("config.activate", &json!({"row": "Broken"})).is_err());
run(&mut s, "config.row", json!({"name": "Broken", "delete": true}));
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["columns"].as_array().map(Vec::len), Some(1));
assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1));
}
}
+260
View File
@@ -0,0 +1,260 @@
//! SOLID ▸ CONSTRUCT: construction axes, points and the planes built from model geometry.
//!
//! References are given as `{face: [x,y,z]}`, `{edge: [x,y,z]}`, `{vertex: [x,y,z]}`,
//! `{point: [x,y,z]}`, `{plane: XY|name|{origin, normal}}`, `{axis: X|Y|Z|name}`,
//! `{construct_point: name}`, `{sketch, point}` or `{sketch, curve}`, or as a tagged
//! `GeoRef` (`{"type": "edge", "at": …}`). Picked faces and edges are stored with their
//! persistent names.
use serde_json::Value;
use solvecraft_doc::construct::{AxisDef, GeoRef, PointDef};
use solvecraft_doc::{FeatureKind, PlaneRef};
use super::CommandSpec;
use super::features::{add_feature, check_expr, plane_param, sketch_id};
use crate::params::{bad, req_expr, str_, vec3};
use crate::{Result, Session};
const REF: &str =
"{face|edge|vertex|point: [x,y,z]} | {plane: XY|name|{origin, normal}} | {axis: X|Y|Z|name} | {construct_point: name} | {sketch, point|curve}";
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"),
CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("face: ref; point: ref (the axis goes through it, square to the face); name?"),
CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("a, b: plane or planar face refs; name?"),
CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("a, b: point refs (vertices, sketch points, points); name?"),
CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("edge: ref (a straight edge or sketch line); name?"),
CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("point: ref (vertex, sketch point, point); name?"),
CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("a, b: edge or line refs (where they meet, or come closest); name?"),
CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("a, b, c: plane or planar face refs; name?"),
CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"),
CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("edge: edge or line ref; plane: plane or planar face ref; name?"),
CommandSpec::new("construct.point.along_path", "Point Along Path", point_along)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"),
CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"),
CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("a, b: straight edge or line refs in one plane; name?"),
CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", plane_points)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("a, b, c: point refs; name?"),
CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"),
CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along)
.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.
fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<GeoRef> {
let v = v.ok_or_else(|| bad(cmd, format!("`{what}` is required: {REF}")))?;
let p3 = |k: &str| v.get(k).and_then(vec3);
let mut g = if v.get("type").is_some() {
serde_json::from_value::<GeoRef>(v.clone()).map_err(|e| bad(cmd, format!("`{what}`: {e}")))?
} else if let Some(at) = p3("face") {
GeoRef::Face { at, name: None }
} else if let Some(at) = p3("edge") {
GeoRef::Edge { at, name: None }
} else if let Some(at) = p3("vertex") {
GeoRef::Vertex { at }
} else if let Some(sk) = v.get("sketch") {
let sketch = sketch_id(s, Some(sk), cmd, what)?;
match (str_(v, "point"), str_(v, "curve")) {
(Some(point), _) => GeoRef::SketchPoint { sketch, point: point.to_string() },
(None, Some(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() },
_ => return Err(bad(cmd, format!("`{what}`: a sketch reference needs `point` or `curve`"))),
}
} else if let Some(p) = p3("point") {
GeoRef::Point { p }
} else if v.get("plane").is_some() {
GeoRef::Plane { plane: plane_param(s, v.get("plane"), cmd)? }
} else if let Some(a) = str_(v, "axis") {
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
GeoRef::World { axis: a.to_ascii_uppercase() }
} else {
match s.doc.find_feature(a).map(|f| &f.kind) {
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.to_string() },
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
}
}
} else if let Some(n) = str_(v, "construct_point") {
match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.to_string() },
_ => return Err(bad(cmd, format!("no construction point `{n}`"))),
}
} else if v.as_str().is_some() {
// A bare string: a plane.
GeoRef::Plane { plane: plane_param(s, Some(v), cmd)? }
} else {
return Err(bad(cmd, format!("`{what}` must be {REF}")));
};
g.name_picks(&s.model.state());
Ok(g)
}
fn t_param(s: &Session, p: &Value, cmd: &str) -> Result<String> {
let t = if p.get("t").is_some() { req_expr(cmd, p, "t")? } else { "0.5".to_string() };
check_expr(s, &t, solvecraft_doc::expr::Kind::Unitless, cmd, "t")?;
Ok(t)
}
fn axis(s: &mut Session, p: &Value, def: AxisDef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionAxis { def })
}
fn point(s: &mut Session, p: &Value, def: PointDef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionPoint { def })
}
fn plane(s: &mut Session, p: &Value, plane: PlaneRef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionPlane { plane })
}
fn axis_face(s: &mut Session, p: &Value) -> Result<Value> {
let face = geo(s, p.get("face"), "construct.axis.through_cylinder", "face")?;
axis(s, p, AxisDef::ThroughFace { face })
}
fn axis_normal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.axis.normal_to_face";
let face = geo(s, p.get("face"), cmd, "face")?;
let point = geo(s, p.get("point"), cmd, "point")?;
axis(s, p, AxisDef::NormalToFace { face, point })
}
fn axis_planes(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.axis.two_planes";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
axis(s, p, AxisDef::TwoPlanes { a, b })
}
fn axis_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.axis.two_points";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
axis(s, p, AxisDef::TwoPoints { a, b })
}
fn axis_edge(s: &mut Session, p: &Value) -> Result<Value> {
let edge = geo(s, p.get("edge"), "construct.axis.edge", "edge")?;
axis(s, p, AxisDef::Edge { edge })
}
fn point_at(s: &mut Session, p: &Value) -> Result<Value> {
let at = geo(s, p.get("point"), "construct.point.vertex", "point")?;
point(s, p, PointDef::At { point: at })
}
fn point_edges(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.two_edges";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
point(s, p, PointDef::TwoEdges { a, b })
}
fn point_planes(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.three_planes";
let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?);
point(s, p, PointDef::ThreePlanes { a, b, c })
}
fn point_center(s: &mut Session, p: &Value) -> Result<Value> {
let of = geo(s, p.get("of"), "construct.point.center", "of")?;
point(s, p, PointDef::Center { of })
}
fn point_edge_plane(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.edge_plane";
let (edge, pl) = (geo(s, p.get("edge"), cmd, "edge")?, geo(s, p.get("plane"), cmd, "plane")?);
point(s, p, PointDef::EdgeAndPlane { edge, plane: pl })
}
fn point_along(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.along_path";
let path = geo(s, p.get("path"), cmd, "path")?;
let t = t_param(s, p, cmd)?;
point(s, p, PointDef::AlongPath { path, t })
}
fn plane_mid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.midplane";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
plane(s, p, PlaneRef::Midplane { a: Box::new(a), b: Box::new(b) })
}
fn plane_edges(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.two_edges";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
plane(s, p, PlaneRef::TwoEdges { a: Box::new(a), b: Box::new(b) })
}
fn plane_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.three_points";
let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?);
plane(s, p, PlaneRef::ThreePoints { a: Box::new(a), b: Box::new(b), c: Box::new(c) })
}
fn plane_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.tangent";
let face = geo(s, p.get("face"), cmd, "face")?;
let at = match (p.get("at").and_then(vec3), &face) {
(Some(a), _) => a,
(None, GeoRef::Face { at, .. }) => *at,
_ => return Err(bad(cmd, "`at` is required: where the plane touches the face")),
};
plane(s, p, PlaneRef::Tangent { face: Box::new(face), at })
}
fn plane_along(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.along_path";
let path = geo(s, p.get("path"), cmd, "path")?;
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) })
}
+26 -15
View File
@@ -6,6 +6,7 @@
use serde_json::{Value, json};
use solvecraft_doc::FeatureKind;
use solvecraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, bool_};
@@ -63,6 +64,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mut entities = Vec::new();
let mut features: Vec<u64> = Vec::new();
let mut bodies: Vec<String> = Vec::new();
let mut faces: Vec<(String, Vec<Vec3>)> = Vec::new();
for x in &items {
match x {
Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()),
@@ -78,7 +80,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
},
Sel::Axis { .. } => skip("the origin axes can't be deleted".into()),
Sel::Body { name } => bodies.push(name.clone()),
Sel::Face { .. } => skip("Delete Face: not available yet".into()),
Sel::Face { body, point, .. } => match faces.iter_mut().find(|(b, _)| b == body) {
Some((_, ps)) => ps.push(*point),
None => faces.push((body.clone(), vec![*point])),
},
Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()),
Sel::Profile { .. } => skip("a profile goes with its sketch".into()),
}
@@ -93,9 +98,14 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
done = true;
}
}
// Faces of a body that is going anyway are left to it; the rest become Delete Face features.
for (body, points) in faces.iter().filter(|(b, _)| !bodies.contains(b)) {
sub(s, "face.delete", json!({ "faces": points, "body": body }))?;
done = true;
}
if !features.is_empty() {
let ids: Vec<String> = features.iter().map(u64::to_string).collect();
sub(s, "FusionDeleteCommand", json!({ "features": ids }))?;
sub(s, "timeline.delete", json!({ "features": ids }))?;
done = true;
}
// Bodies still there (not made by a feature deleted above) get a Remove feature.
@@ -103,7 +113,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
bodies.retain(|b| st.body(b).is_some());
bodies.dedup();
if !bodies.is_empty() {
sub(s, "SoftDeleteCommand", json!({ "bodies": bodies }))?;
sub(s, "solid.remove", json!({ "bodies": bodies }))?;
done = true;
}
// An occurrence goes alone while its component has others; the last one takes the component.
@@ -174,12 +184,12 @@ mod tests {
/// A sketch with a rectangle extruded into a body, a second box, and an offset plane.
fn part() -> (Session, u64) {
let mut s = Session::default();
let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]}));
run(&mut s, "ConstructionPlaneOffsetFromPlaneCommand", json!({"base": "XY", "offset": 10}));
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 10}));
(s, sk)
}
@@ -192,7 +202,7 @@ mod tests {
assert!(dry["deleted"].as_array().unwrap().len() >= 2, "the extrude goes with its sketch: {dry}");
run(&mut s, "selection.delete", json!({"items": [{"type": "feature", "id": sk}]}));
assert_eq!(bodies(&s), 1);
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.features.len(), n);
assert_eq!(bodies(&s), 2);
}
@@ -212,7 +222,7 @@ mod tests {
assert!(s.doc.find_feature("Plane1").is_none());
assert_eq!(r["skipped"][0], "the origin planes can't be deleted");
assert_eq!(s.undo.len(), depth + 1);
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!((bodies(&s), s.doc.features.len()), (2, n));
}
@@ -221,7 +231,8 @@ mod tests {
let (mut s, _) = part();
let b = s.model.state().bodies[0].name.clone();
let e = s.execute("selection.delete", &json!({"items": [{"type": "face", "body": b, "index": 0, "point": [0, 0, 0]}]})).unwrap_err();
assert!(e.to_string().contains("not available yet"), "{e}");
// A side of the part its neighbours can't close: Delete Face refuses it.
assert!(e.to_string().contains("Delete Face"), "{e}");
for p in [
json!({}),
json!({"items": "x"}),
@@ -237,7 +248,7 @@ mod tests {
fn occurrences_components_and_sketch_entities() {
let (mut s, _) = part();
let b = s.model.state().bodies[1].name.clone();
run(&mut s, "FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]}));
run(&mut s, "component.from_bodies", json!({"bodies": [b]}));
let c = s.doc.components[0].id;
let o1 = s.doc.occurrence_of(c).unwrap().id;
run(&mut s, "occurrence.copy", json!({"component": c, "translate": [0, 30, 0]}));
@@ -249,8 +260,8 @@ mod tests {
run(&mut s, "selection.delete", json!({"occurrences": [o2]}));
assert!(s.doc.components.is_empty());
// Sketch entities while sketching.
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
let sk = s.active_sketch.unwrap();
let id = s.model.state().sketch(sk).unwrap().sketch.curves[0].id.clone();
run(&mut s, "selection.delete", json!({"items": [{"type": "sketch_curve", "id": id}]}));
+37 -14
View File
@@ -8,17 +8,23 @@ use crate::params::{bad, bool_, num, str_, string_list};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("UndoCommand", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("RedoCommand", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("FusionComputeAllCommand", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("FusionDeleteCommand", "Delete", delete)
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
.noundo()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
.at("SOLID", "MODIFY")
.icon("delete")
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it)"),
CommandSpec::new("FusionRenameTimelineEntryCommand", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.rollTo", "Roll History Marker", rollback)
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
@@ -28,8 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("select.set", "Select", select_set)
.noundo()
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
@@ -58,6 +66,21 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"redone": label}))
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"checkpoint": s.checkpoint()}))
}
fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.restore_checkpoint";
let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?;
if bool_(p, "forget") == Some(true) {
s.drop_checkpoint(id);
return Ok(json!({"checkpoint": id, "restored": false}));
}
s.restore_checkpoint(id)?;
Ok(json!({"checkpoint": id, "restored": true}))
}
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
s.model = solvecraft_doc::Model::new();
s.refresh();
@@ -75,7 +98,7 @@ fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionDeleteCommand";
let cmd = "timeline.delete";
let mut keys = string_list(p, "features");
if let Some(Value::Array(a)) = p.get("features") {
keys.extend(a.iter().filter_map(Value::as_u64).map(|n| n.to_string()));
@@ -108,7 +131,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionRenameTimelineEntryCommand";
let cmd = "timeline.rename";
let id = feature_id(s, p.get("feature"), cmd)?;
let name =
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
@@ -125,7 +148,7 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.doc.features.len();
let pos = match p.get("feature") {
Some(f) => {
let id = feature_id(s, Some(f), "timeline.rollTo")?;
let id = feature_id(s, Some(f), "timeline.roll_to")?;
s.doc.feature_index(id).map(|i| i + 1)
}
None => num(p, "position").map(|x| x.max(0.0) as usize),
@@ -204,7 +227,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
}
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
@@ -278,7 +301,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
let id = feature_id(s, p.get("feature"), cmd)?;
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
if cid.starts_with("timeline.") || cid.starts_with("select.") || cid.starts_with("Sketch") {
if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") {
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
@@ -307,7 +330,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
}
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
+4 -4
View File
@@ -118,7 +118,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?;
(spec.run)(s, &p)
};
let r = run(s, "SketchCreate", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
let r = run(s, "sketch.create", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
s.refresh();
let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
@@ -142,10 +142,10 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
None
};
match circle {
Some((o, r)) if closed => run(s, "CircleCenterRadius", json!({"center": xy(o), "radius": r}))?,
Some((o, r)) if closed => run(s, "sketch.circle.center", json!({"center": xy(o), "radius": r}))?,
Some(_) => {
let mid = pts.get(pts.len() / 2).copied().unwrap_or(first);
run(s, "ArcThreePoint", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))?
run(s, "sketch.arc.three_point", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))?
}
None => {
let mut list: Vec<Value> = Vec::new();
@@ -159,7 +159,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
if closed_poly {
list.pop();
}
run(s, "DrawPolyline", json!({"points": list, "closed": closed_poly}))?
run(s, "sketch.line", json!({"points": list, "closed": closed_poly}))?
}
};
}
+319 -79
View File
@@ -9,92 +9,105 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("Extrude", "Extrude", extrude)
CommandSpec::new("solid.extrude", "Extrude", extrude)
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("Revolve", "Revolve", revolve)
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
CommandSpec::new("Sweep", "Sweep", sweep)
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
CommandSpec::new("SolidLoft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("PrimitiveBox", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveCylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveSphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveTorus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
CommandSpec::new("FusionFilletEdgesCommand", "Fillet", fillet)
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?"),
CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; body?"),
CommandSpec::new("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"),
CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect)
.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)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names] | components: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"),
CommandSpec::new("PatternCircular", "Circular Pattern", pattern_circ)
CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ)
.at("SOLID", "CREATE")
.icon("pattern_circ")
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"),
CommandSpec::new("PatternOnPath", "Pattern on Path", pattern_path)
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("MirrorCommand", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("FusionHoleCommand", "Hole", hole)
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("solid.hole", "Hole", hole)
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("PrimitivePipe", "Pipe", pipe)
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch) | sketch alone (every sketch point: standalone points and circle centres); direction?: [x,y,z] (default: into the face); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("solid.pipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
.params("path_sketch: sketch; path: [curve ids in order]; diameter: expr; wall?: expr (hollow); operation?, targets?, name?, body_name?"),
CommandSpec::new("StockModelCommand", "Bounding Solid", bounding_solid)
CommandSpec::new("solid.bounding_solid", "Bounding Solid", bounding_solid)
.at("SOLID", "CREATE")
.icon("box")
.params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"),
CommandSpec::new("ModifyScale", "Scale", scale)
CommandSpec::new("solid.scale", "Scale", scale)
.at("SOLID", "MODIFY")
.icon("scale")
.params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"),
CommandSpec::new("FusionOffsetFacesCommand", "Offset Face", offset_face)
CommandSpec::new("face.delete", "Delete Face", delete_face)
.at("SOLID", "MODIFY")
.icon("delete")
.params("faces: [[x,y,z] points on the faces]; body?. The neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off"),
CommandSpec::new("solid.offset_face", "Offset Face", offset_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; distance: expr (positive: outward); body?"),
CommandSpec::new("FusionThreadCommand", "Thread", thread)
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face). Cosmetic: the model is unchanged, model.threads lists it"),
CommandSpec::new("FusionShellBodyCommand", "Shell", shell)
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr (inside); body?"),
CommandSpec::new("FusionDraftCommand", "Draft", draft)
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"),
CommandSpec::new("solid.draft", "Draft", draft)
.at("SOLID", "MODIFY")
.icon("draft")
.params("faces: [[x,y,z]]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
CommandSpec::new("ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane", plane_offset)
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; offset: expr; name?"),
CommandSpec::new("ConstructionPlaneAtAngleCommand", "Plane at Angle", plane_angle)
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"),
CommandSpec::new("FusionSplitBodyCommand", "Split Body", split_body).at("SOLID", "MODIFY").icon("split").params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal}"),
CommandSpec::new("FusionPressPullCommand", "Press Pull", press_pull)
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
.icon("split")
.params(
"faces: [[x,y,z] points on the faces to split]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
),
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
.icon("split")
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended)"),
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
];
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
@@ -139,7 +152,14 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
if st.bodies.is_empty() || !d.is_finite() {
return Some("new");
}
let inside = |q: Vec3| st.bodies.iter().any(|b| b.mesh().contains(q));
// Only the active component's bodies take part (or the ones named as participants).
let parts = string_list(p, "participants");
let inside = |q: Vec3| {
st.bodies
.iter()
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
.any(|b| b.mesh().contains(q))
};
let eps = (d.abs() * 1e-3).max(1e-3);
let sign = if d < 0.0 { -1.0 } else { 1.0 };
if symmetric {
@@ -189,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 {
@@ -243,20 +294,39 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
// Commands speak world coordinates; the feature is authored in the active component.
let mut kind = kind;
super::component::to_active_frame(s, &mut kind);
// Bare lengths typed in an inch (or cm…) design mean that unit.
if s.doc.units.trim() != "mm" {
let doc = s.doc.clone();
for (_, e, k) in kind.inputs_mut() {
*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, .. } => {
let st = s.model.state();
(solvecraft_doc::edge_refs(&st, body, edges), solvecraft_doc::edge_names_for(&st, body, edges))
}
// Sheet metal edges are named by the sheet model.
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => {
(Vec::new(), solvecraft_doc::sheet::sheet_edge_names(&s.model.state(), edges))
}
_ => (Vec::new(), Vec::new()),
};
let face_names = {
let pts = kind.face_points();
if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) }
};
let participants = string_list(p, "participants");
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
return Err(EngineError::Other("`participants` must list existing bodies".into()));
}
let id = s.doc_mut().add_feature(kind, name)?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.participants = participants;
f.edge_refs = refs;
f.edge_names = names;
f.face_names = face_names;
@@ -290,7 +360,7 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
}
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "Extrude";
let cmd = "solid.extrude";
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
@@ -300,7 +370,14 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
None => (feature_sketch(s, p, cmd)?, None),
};
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
let distance = if through_all { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
let to_object = p.get("to").is_some();
if to_object && p.get("to").and_then(vec3).is_none() {
return Err(bad(cmd, "`to` must be a point [x, y, z] on the face, vertex or point to extrude to"));
}
if p.get("from").is_some() && p.get("from").and_then(vec3).is_none() {
return Err(bad(cmd, "`from` must be a point [x, y, z] on the face or point to start from"));
}
let distance = if through_all || to_object { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let taper = expr(p, "taper");
if let Some(t) = &taper {
@@ -326,7 +403,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => f,
None => profiles(p, cmd)?,
},
extent: Extent { distance, direction, distance2, start_offset, through_all, taper },
extent: Extent {
distance,
direction,
distance2,
start_offset,
through_all,
taper,
to: p.get("to").and_then(vec3),
to_offset: expr(p, "to_offset"),
from: p.get("from").and_then(vec3),
},
operation: match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
@@ -337,7 +424,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
}
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "Revolve";
let cmd = "solid.revolve";
// A planar body face revolves like a profile (its edges projected into a sketch on it).
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
@@ -365,7 +452,27 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => f,
None => profiles(p, cmd)?,
};
let kind = FeatureKind::Revolve { sketch, profiles, axis, angle, operation: operation(p, cmd)?, targets: string_list(p, "targets") };
let angle2 = expr(p, "angle2");
if let Some(a) = &angle2 {
check_expr(s, a, Kind::Angle, cmd, "angle2")?;
}
let to = match p.get("to") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] to turn to"))?),
None => None,
};
let symmetric =
matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false);
let kind = FeatureKind::Revolve {
sketch,
profiles,
axis,
angle,
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
angle2,
symmetric,
to,
};
add_feature(s, p, kind)
}
@@ -378,7 +485,7 @@ fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
}
fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveBox";
let cmd = "solid.box";
let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?);
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?;
@@ -395,7 +502,7 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveCylinder";
let cmd = "solid.cylinder";
let radius = radius_expr(p, cmd)?;
let height = req_expr(cmd, p, "height")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
@@ -406,7 +513,7 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveSphere";
let cmd = "solid.sphere";
let radius = radius_expr(p, cmd)?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO);
@@ -414,7 +521,7 @@ fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_torus(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveTorus";
let cmd = "solid.torus";
let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?);
check_expr(s, &major, Kind::Length, cmd, "major")?;
check_expr(s, &minor, Kind::Length, cmd, "minor")?;
@@ -448,7 +555,7 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
/// edge is rounded (a fillet).
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionPressPullCommand";
let cmd = "solid.press_pull";
let distance = req_expr(cmd, p, "distance")?;
if p.get("edges").is_some() {
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
@@ -474,10 +581,10 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let new = format!("({old}) + ({distance})");
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
}
// Another curved face moves along its normal.
let r = super::find_command("FusionOffsetFacesCommand").ok_or_else(|| bad(cmd, "FusionOffsetFacesCommand"))?;
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
return (r.run)(s, &json!({"faces": [face], "distance": distance}));
}
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") };
@@ -485,23 +592,63 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionFilletEdgesCommand";
let cmd = "solid.fillet";
let edges = edge_points(s, p, cmd)?;
let radius = req_expr(cmd, p, "radius")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string) })
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("constant") | Some("rolling_ball") => solvecraft_doc::FilletStyle::Constant,
Some("chord") | Some("chord_length") => solvecraft_doc::FilletStyle::Chord,
Some("variable") => {
let radius2 = req_expr(cmd, p, "radius2")?;
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`"))?;
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)"))),
};
add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style })
}
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionChamferCommand";
let cmd = "solid.chamfer";
let edges = edge_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::Chamfer { edges, distance, body: str_(p, "body").map(str::to_string) })
let (distance2, angle) = (expr(p, "distance2"), expr(p, "angle"));
if let Some(d) = &distance2 {
check_expr(s, d, Kind::Length, cmd, "distance2")?;
}
if let Some(a) = &angle {
check_expr(s, a, Kind::Angle, cmd, "angle")?;
}
if distance2.is_some() && angle.is_some() {
return Err(bad(cmd, "give `distance2` or `angle`, not both"));
}
let flip = bool_(p, "flip").unwrap_or(false);
add_feature(s, p, FeatureKind::Chamfer { edges, distance, distance2, angle, flip, body: str_(p, "body").map(str::to_string) })
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCombineCommand";
let cmd = "solid.combine";
let target = str_(p, "target").ok_or_else(|| bad(cmd, "`target` must be a body name"))?.to_string();
let tools = string_list(p, "tools");
if tools.is_empty() {
@@ -511,11 +658,23 @@ fn combine(s: &mut Session, p: &Value) -> Result<Value> {
None => Operation::Join,
Some(o) => Operation::parse(o).filter(|o| *o != Operation::NewBody).ok_or_else(|| bad(cmd, "operation must be join, cut or intersect"))?,
};
add_feature(s, p, FeatureKind::Combine { target, tools, operation: op, keep_tools: bool_(p, "keep_tools").unwrap_or(false) })
let mut out = add_feature(
s,
p,
FeatureKind::Combine { target: target.clone(), tools, operation: op, keep_tools: bool_(p, "keep_tools").unwrap_or(false) },
)?;
// New Component: the result goes into a component of its own.
if bool_(p, "new_component").unwrap_or(false) {
let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined"));
let c = s.execute("component.create", &json!({"name": name, "activate": false}))?;
s.execute("component.move_bodies", &json!({"bodies": [target], "component": c["component"]}))?;
out["component"] = c["component"].clone();
}
Ok(out)
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMoveCommand";
let cmd = "solid.move";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -609,7 +768,7 @@ fn pattern_sources(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<String>, Ve
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PatternRectangular";
let cmd = "solid.pattern.rectangular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let dir1 = p.get("dir1").and_then(vec3).ok_or_else(|| bad(cmd, "`dir1` must be [x, y, z]"))?;
let count1 = req_expr(cmd, p, "count1")?;
@@ -629,7 +788,7 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PatternCircular";
let cmd = "solid.pattern.circular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let (origin, axis) = axis_line(p, cmd)?;
let count = req_expr(cmd, p, "count")?;
@@ -641,7 +800,7 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PatternOnPath";
let cmd = "solid.pattern.path";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch").or_else(|| p.get("sketch")), cmd, "path_sketch")?;
let path = string_list(p, "path");
@@ -667,7 +826,7 @@ fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "MirrorCommand";
let cmd = "solid.mirror";
let bodies = string_list(p, "bodies");
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = match p.get("plane") {
@@ -689,7 +848,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
/// A plane reference from a parameter: an origin plane, a construction plane name, or an
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`.
pub fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
use solvecraft_doc::PlaneRef;
match v {
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => Ok(PlaneRef::Origin { name: n.to_ascii_uppercase() }),
@@ -711,7 +870,7 @@ pub fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecra
}
fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstructionPlaneOffsetFromPlaneCommand";
let cmd = "construct.plane.offset";
let base = plane_param(s, p.get("base"), cmd)?;
let d = req_expr(cmd, p, "offset")?;
check_expr(s, &d, Kind::Length, cmd, "offset")?;
@@ -719,7 +878,7 @@ fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
}
fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstructionPlaneAtAngleCommand";
let cmd = "construct.plane.at_angle";
let base = plane_param(s, p.get("base"), cmd)?;
let (axis_origin, axis_dir) = axis_line(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
@@ -731,14 +890,52 @@ fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
)
}
fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.split_face";
let faces = face_points(p, cmd)?;
let body = str_(p, "body").map(str::to_string);
let xy = || solvecraft_doc::PlaneRef::Origin { name: "XY".into() };
let kind = if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
FeatureKind::SplitFace { faces, body, plane: xy(), tool: Some(solvecraft_doc::FaceAt { body: tb, point }), sketch: None }
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
FeatureKind::SplitFace { faces, body, plane: xy(), tool: None, sketch: Some(sketch) }
} else {
let plane = plane_param(s, p.get("plane"), cmd)?;
FeatureKind::SplitFace { faces, body, plane, tool: None, sketch: None }
};
add_feature(s, p, kind)
}
fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSplitBodyCommand";
let cmd = "solid.split_body";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
// A face as the tool: `tool: {body, point}` (its surface extended); else a plane.
if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
let st = s.model.state();
let tbody = st.body(&tb).ok_or_else(|| bad(cmd, format!("no body `{tb}`")))?;
if solvecraft_doc::appearance::face_index_at(tbody, point).is_none() {
return Err(bad(cmd, "`tool.point` is not on a face of the tool body"));
}
return add_feature(
s,
p,
FeatureKind::Split {
body,
plane: solvecraft_doc::PlaneRef::Origin { name: "XY".into() },
tool: Some(solvecraft_doc::FaceAt { body: tb, point }),
},
);
}
let plane = plane_param(s, p.get("plane"), cmd)?;
add_feature(s, p, FeatureKind::Split { body, plane })
add_feature(s, p, FeatureKind::Split { body, plane, tool: None })
}
/// ISO 273 clearance hole diameters (close, normal, loose fit) for metric bolts.
@@ -778,12 +975,14 @@ fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
}
fn hole(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionHoleCommand";
let cmd = "solid.hole";
// At sketch points: the holes go perpendicular to the sketch and follow its points.
let points = match (p.get("sketch"), p.get("points")) {
(Some(_), Some(_)) => {
let points = match (p.get("sketch"), p.get("points"), p.get("position")) {
(Some(_), Some(_), _) | (Some(_), None, None) => {
let sketch = feature_sketch(s, p, cmd)?;
let ids = string_list(p, "points");
// No points named: every sketch point (standalone points and circle centres, not
// line or arc ends, not the origin).
let ids = if p.get("points").is_some() { string_list(p, "points") } else { sketch_hole_points(s, sketch) };
if ids.is_empty() || ids.len() > 10_000 {
return Err(bad(cmd, "`points` must list 1…10000 sketch point ids"));
}
@@ -871,7 +1070,28 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
},
other => return Err(bad(cmd, format!("unknown hole type `{other}`"))),
};
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread })
let to = match p.get("to") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
None => None,
};
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
}
/// The points of a sketch a hole drills at when only the sketch is given.
fn sketch_hole_points(s: &Session, sketch: u64) -> Vec<String> {
let st = s.model.state();
let Some(ss) = st.sketch(sketch) else { return Vec::new() };
let mut ends: Vec<usize> = Vec::new();
for c in &ss.sketch.curves {
match &c.kind {
solvecraft_sketch::CurveKind::Circle { .. } => {}
solvecraft_sketch::CurveKind::Arc { a, b, .. } | solvecraft_sketch::CurveKind::Line { a, b } => ends.extend([*a, *b]),
solvecraft_sketch::CurveKind::Ellipse { m, .. } => ends.push(*m),
solvecraft_sketch::CurveKind::Spline { pts, .. } => ends.extend(pts.iter().copied()),
solvecraft_sketch::CurveKind::Conic { a, b, apex, .. } => ends.extend([*a, *b, *apex]),
}
}
ss.sketch.points.iter().enumerate().filter(|(i, p)| p.id != "origin" && !ends.contains(i)).map(|(_, p)| p.id.clone()).collect()
}
fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
@@ -883,21 +1103,35 @@ fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
}
fn shell(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionShellBodyCommand";
let cmd = "solid.shell";
let faces = face_points(p, cmd)?;
let thickness = req_expr(cmd, p, "thickness")?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
add_feature(s, p, FeatureKind::Shell { faces, thickness, body: str_(p, "body").map(str::to_string) })
let direction = str_(p, "direction").map(str::to_ascii_lowercase).unwrap_or_default();
if !matches!(direction.as_str(), "" | "inside" | "outside" | "both") {
return Err(bad(cmd, "`direction` must be inside, outside or both"));
}
add_feature(
s,
p,
FeatureKind::Shell {
faces,
thickness,
body: str_(p, "body").map(str::to_string),
direction: if direction == "inside" { String::new() } else { direction },
tangent_chain: bool_(p, "tangent_chain").unwrap_or(false),
},
)
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionDraftCommand";
let cmd = "solid.draft";
let faces = face_points(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let neutral = plane_param(s, p.get("neutral"), cmd)?;
let (vals, _) = s.doc.param_values();
let pl = s.doc.resolve_plane(&vals, &neutral, 0)?;
let pl = s.doc.resolve_plane_in(&vals, &neutral, 0, Some(&s.model.state()))?;
let pull = p.get("pull").and_then(vec3).unwrap_or_else(|| pl.normal());
add_feature(s, p, FeatureKind::Draft { faces, angle, neutral, pull, body: str_(p, "body").map(str::to_string) })
}
@@ -915,7 +1149,7 @@ pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -
}
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "Sweep";
let cmd = "solid.sweep";
let sketch = feature_sketch(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(p, "path");
@@ -937,7 +1171,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SolidLoft";
let cmd = "solid.loft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
if list.len() < 2 || list.len() > 50 {
return Err(bad(cmd, "a loft needs 2…50 sections"));
@@ -960,7 +1194,7 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionThreadCommand";
let cmd = "solid.thread";
let face = p
.get("face")
.and_then(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)))
@@ -975,14 +1209,15 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = &length {
check_expr(s, l, Kind::Length, cmd, "length")?;
}
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length })?;
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length, modeled })?;
let st = s.model.state();
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
}
fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitivePipe";
let cmd = "solid.pipe";
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(p, "path");
if path.is_empty() {
@@ -998,14 +1233,14 @@ fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
}
fn bounding_solid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "StockModelCommand";
let cmd = "solid.bounding_solid";
let margin = expr(p, "margin").unwrap_or_else(|| "0".into());
check_expr(s, &margin, Kind::Length, cmd, "margin")?;
add_feature(s, p, FeatureKind::BoundingSolid { bodies: string_list(p, "bodies"), margin })
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ModifyScale";
let cmd = "solid.scale";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -1031,8 +1266,13 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::Scale { bodies, origin, factor, factors })
}
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(p, "face.delete")?;
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string) })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionOffsetFacesCommand";
let cmd = "solid.offset_face";
let faces = face_points(p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
+74 -15
View File
@@ -11,35 +11,38 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("PrimitiveCoil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
CommandSpec::new("SurfaceSculpt", "Boundary Fill", boundary_fill).at("SOLID", "CREATE").icon("boundary_fill").params(
"tools: [bodies]; cells: [[x,y,z] a point inside each cell to fill (a cell: inside some tools, outside the rest)]; operation?: new|join|cut|intersect; remove_tools?: bool",
),
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
"diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \
base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
),
CommandSpec::new("FusionRibCommand", "Rib", rib)
CommandSpec::new("solid.rib", "Rib", rib)
.at("SOLID", "CREATE")
.icon("rib")
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"),
CommandSpec::new("FusionWebCommand", "Web", web)
CommandSpec::new("solid.web", "Web", web)
.at("SOLID", "CREATE")
.icon("rib")
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"),
CommandSpec::new("EmbossCmd", "Emboss", emboss)
CommandSpec::new("solid.emboss", "Emboss", emboss)
.at("SOLID", "CREATE")
.icon("emboss")
.params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"),
CommandSpec::new("FusionReplaceFaceCommand", "Replace Face", replace_face)
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("AlignCmd", "Align", align)
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("move")
.params("bodies: [names]; from: [x,y,z] | from_face: [x,y,z]; to: [x,y,z] | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
CommandSpec::new("SoftDeleteCommand", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
.params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
];
fn coil(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveCoil";
let cmd = "solid.coil";
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
let axis = match p.get("axis") {
None => Vec3::Z,
@@ -131,15 +134,15 @@ fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result<Value> {
}
fn rib(s: &mut Session, p: &Value) -> Result<Value> {
rib_like(s, p, "FusionRibCommand", false)
rib_like(s, p, "solid.rib", false)
}
fn web(s: &mut Session, p: &Value) -> Result<Value> {
rib_like(s, p, "FusionWebCommand", true)
rib_like(s, p, "solid.web", true)
}
fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "EmbossCmd";
let cmd = "solid.emboss";
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
let depth = req_expr(cmd, p, "depth")?;
@@ -166,7 +169,7 @@ fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
}
fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionReplaceFaceCommand";
let cmd = "solid.replace_face";
let faces = face_points(p, "faces", cmd)?;
for f in &faces {
if super::face::face_normal(s, *f).is_none() {
@@ -186,7 +189,7 @@ fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "AlignCmd";
let cmd = "solid.align";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list the bodies to move"));
@@ -202,6 +205,12 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(s, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk).and_then(vec3).map(|v| (v, None)).ok_or_else(|| bad(cmd, format!("give `{pk}` or `{fk}` as [x, y, z]")))
};
let (from, from_normal) = pick(s, "from", "from_face")?;
@@ -215,7 +224,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SoftDeleteCommand";
let cmd = "solid.remove";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -230,3 +239,53 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "features_more_tests.rs"]
mod tests;
/// A snap point near `at` on the model: the nearest vertex, edge midpoint, planar face's centre
/// or circular edge's centre (the end of a cylindrical face).
pub(super) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option<Vec3> {
let st = s.world_state();
let near = |pts: Vec<Vec3>| pts.into_iter().min_by(|a, b| a.dist(at).total_cmp(&b.dist(at)));
match kind {
"vertex" => near(
st.bodies
.iter()
.flat_map(|b| b.mesh().edges.iter().flat_map(|e| [e.first().copied(), e.last().copied()]).flatten().collect::<Vec<_>>())
.collect(),
),
"edge_mid" => {
let d = |e: &solvecraft_doc::kernel::EdgeInfo| e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min);
st.bodies
.iter()
.filter_map(|b| b.body.edges((b.body.size() * 2e-3).max(1e-3)).ok())
.flatten()
.min_by(|a, b| d(a).total_cmp(&d(b)))
.map(|e| e.mid)
}
"face_center" => st.bodies.iter().find_map(|b| {
let i = solvecraft_doc::appearance::face_index_at(b, at)?;
b.body.faces((b.body.size() * 2e-3).max(1e-3)).ok()?.into_iter().find(|f| f.index == i).map(|f| f.centroid)
}),
"circle_center" => st.bodies.iter().find_map(|b| {
let c = solvecraft_doc::kernel::cylinder_face_at(&b.body, at)?;
let along = (at - c.axis_point).dot(c.axis);
let end = if (along - c.start).abs() <= (c.end - along).abs() { c.start } else { c.end };
Some(c.axis_point + c.axis * end)
}),
_ => None,
}
}
fn boundary_fill(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SurfaceSculpt";
let tools = string_list(p, "tools");
let st = s.model.state();
if tools.is_empty() || tools.len() > 64 || tools.iter().any(|t| st.body(t).is_none()) {
return Err(bad(cmd, "`tools` must list 1…64 bodies"));
}
let cells: Vec<Vec3> = p.get("cells").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if cells.is_empty() || cells.len() > 1000 {
return Err(bad(cmd, "`cells` must list 1…1000 points [x, y, z], one inside each cell"));
}
let operation = super::features::operation(p, cmd)?;
add_feature(s, p, FeatureKind::BoundaryFill { tools, cells, operation, remove_tools: bool_(p, "remove_tools").unwrap_or(false) })
}
+43 -43
View File
@@ -10,7 +10,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn measure(s: &mut Session) -> Value {
run(s, "MeasureCommand", json!({}))
run(s, "inspect.measure", json!({}))
}
fn volume(s: &mut Session) -> f64 {
@@ -24,76 +24,76 @@ fn rel(a: f64, b: f64) -> f64 {
/// An L bracket: a 40 x 20 x 5 base and a 5 x 20 x 40 wall at x = 0..5.
fn bracket() -> Session {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"}));
run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"}));
assert!(rel(volume(&mut s), 4000.0 + 3500.0) < 1e-9);
// Mid-plane of the bracket (y = 10): sketch x = X, sketch y = Z.
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"}));
run(&mut s, "sketch.line", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
s
}
#[test]
fn rib_to_the_next_face_fills_the_corner() {
let mut s = bracket();
run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
// The triangle (5,5)-(5,25)-(25,5) of area 200, 4 thick.
assert!(rel(volume(&mut s), 7500.0 + 800.0) < 1e-6, "{}", volume(&mut s));
assert_eq!(measure(&mut s)["body_count"], 1);
// The thickness is a parameter.
let t = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": t, "expression": "2"}));
run(&mut s, "parameters.change", json!({"name": t, "expression": "2"}));
assert!(rel(volume(&mut s), 7500.0 + 400.0) < 1e-6);
}
#[test]
fn rib_with_a_depth_and_web() {
let mut s = bracket();
run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2}));
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2}));
let v = volume(&mut s);
assert!(v > 7500.0 + 100.0 && v < 7500.0 + 800.0, "{v}");
let mut s = bracket();
run(&mut s, "FusionWebCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
run(&mut s, "solid.web", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
assert!(rel(volume(&mut s), 8300.0) < 1e-6);
// Curves that face no body.
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Far"}));
run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]}));
run(&mut s, "SketchStop", json!({}));
let e = s.execute("FusionRibCommand", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Far"}));
run(&mut s, "sketch.line", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.rib", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1}));
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
}
#[test]
fn emboss_and_deboss_on_a_face() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 2}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 2}));
assert!(rel(volume(&mut s), 12000.0 + 200.0) < 1e-6, "{}", volume(&mut s));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"}));
assert!(rel(volume(&mut s), 12000.0 - 300.0) < 1e-6);
assert!(s.execute("EmbossCmd", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
}
#[test]
fn replace_face_align_remove() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "FusionReplaceFaceCommand", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.replace_face", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}}));
assert!(rel(volume(&mut s), 1500.0) < 1e-6);
let e = s.execute("FusionReplaceFaceCommand", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}}));
let e = s.execute("solid.replace_face", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}}));
assert!(e.is_err() || s.model.results.last().is_some_and(|r| r.error.as_deref().unwrap_or("").contains("parallel")));
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"}));
// B's top face onto A's top face: B turns over and sits on A.
run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]}));
run(&mut s, "solid.align", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone());
@@ -101,11 +101,11 @@ fn replace_face_align_remove() {
assert!((got.as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{b}");
}
// Points only: a plain move.
run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]}));
run(&mut s, "solid.align", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
assert!((b["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "{b}");
run(&mut s, "SoftDeleteCommand", json!({"bodies": ["B"]}));
run(&mut s, "solid.remove", json!({"bodies": ["B"]}));
assert_eq!(measure(&mut s)["body_count"], 1);
}
@@ -113,27 +113,27 @@ fn replace_face_align_remove() {
fn coil_and_loft_to_a_point() {
use std::f64::consts::{PI, TAU};
let mut s = Session::default();
run(&mut s, "PrimitiveCoil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
run(&mut s, "solid.coil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
assert!(rel(volume(&mut s), PI * 4.0 * TAU * 20.0 * 2.0) < 1e-3, "{}", volume(&mut s));
// Height and revolutions give the pitch; a square section outside the diameter.
let mut s = Session::default();
run(
&mut s,
"PrimitiveCoil",
"solid.coil",
json!({"diameter": 40, "revolutions": 1.5, "height": 15, "section": "square", "section_size": 2, "section_position": "outside", "axis": "X"}),
);
assert!(rel(volume(&mut s), 4.0 * TAU * 21.0 * 1.5) < 1e-3, "{}", volume(&mut s));
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
assert!(s.execute("solid.coil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
assert!(s.execute("solid.coil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
// Pyramid: a square lofted to a point.
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
run(&mut s, "DrawPoint", json!({"point": [0, 0], "id": "apex"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SolidLoft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
run(&mut s, "sketch.point", json!({"point": [0, 0], "id": "apex"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
assert!(s.execute("SolidLoft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
}
+23 -23
View File
@@ -9,22 +9,22 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("NewDocumentCommand", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("file.new", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("doc.open", "Open", open)
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solvecraft design, or a .step/.stp file (opens as a new design)"),
CommandSpec::new("FusionImportCommandFromToolbar", "Insert STEP", insert_step)
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?"),
CommandSpec::new("ParaMeshInsertAlignCommand", "Insert Mesh", insert_mesh)
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
CommandSpec::new("SaveDocumentCommand", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("SaveDocumentAsCommand", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("file.save", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("file.save_as", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
@@ -32,11 +32,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).noundo().params("id, or all: true; dir?"),
CommandSpec::new("ExportCommand", "Export", export)
CommandSpec::new("file.export", "Export", export)
.icon("export")
.noundo()
.params("path; format?: stl|stla|obj|step|3mf (default from extension); bodies?: [names]"),
CommandSpec::new("FusionSaveAsSTLCommand", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
];
fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
@@ -51,11 +51,11 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
/// Read a STEP file as an Import feature.
fn read_step(path: &str) -> Result<solvecraft_io::StepFeature> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > solvecraft_io::MAX_STEP_BYTES as u64 {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > solvecraft_io::MAX_STEP_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
solvecraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -80,11 +80,11 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
/// Add a MeshImport feature for a 3MF or STL file.
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > solvecraft_io::MAX_3MF_BYTES as u64 {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > solvecraft_io::MAX_3MF_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mf = solvecraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?;
@@ -103,7 +103,7 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
}
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ParaMeshInsertAlignCommand";
let cmd = "file.insert_mesh";
let path = path_arg(p, cmd)?;
if !solvecraft_io::is_mesh_path(path) {
return Err(bad(cmd, "only 3MF and STL files (.3mf, .stl) can be inserted as meshes"));
@@ -112,7 +112,7 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
}
fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionImportCommandFromToolbar";
let cmd = "file.insert_step";
let path = path_arg(p, cmd)?;
if !solvecraft_io::is_step_path(path) {
return Err(bad(cmd, "only STEP files (.step, .stp) can be inserted"));
@@ -132,11 +132,11 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
}
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() as usize > solvecraft_io::MAX_DESIGN_BYTES {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > solvecraft_io::MAX_DESIGN_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = solvecraft_io::read_design(&bytes)?;
*s = Session::new(doc);
s.path = Some(path.to_string());
@@ -156,13 +156,13 @@ fn write(s: &mut Session, path: &str) -> Result<Value> {
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = match str_(p, "path") {
Some(x) if !x.trim().is_empty() => x.to_string(),
_ => s.path.clone().ok_or_else(|| bad("SaveDocumentCommand", "the design has no file yet: give a `path`"))?,
_ => s.path.clone().ok_or_else(|| bad("file.save", "the design has no file yet: give a `path`"))?,
};
write(s, &path)
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "SaveDocumentAsCommand")?.to_string();
let path = path_arg(p, "file.save_as")?.to_string();
write(s, &path)
}
@@ -175,18 +175,18 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
// Bodies carry the colour they are shown in (appearance, material or imported).
solvecraft_io::export(&s.doc.painted(&s.world_state()), bodies, format, &name)?
};
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
solvecraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "ExportCommand")?;
let path = path_arg(p, "file.export")?;
let format = Format::from_name(str_(p, "format").unwrap_or(path))?;
export_to(s, path, format, &string_list(p, "bodies"))
}
fn save_stl(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "FusionSaveAsSTLCommand")?;
let path = path_arg(p, "file.save_mesh")?;
let f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary };
export_to(s, path, f, &string_list(p, "bodies"))
}
+24 -11
View File
@@ -8,7 +8,7 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("MeasureCommand", "Measure", measure)
CommandSpec::new("inspect.measure", "Measure", measure)
.at("SOLID", "INSPECT")
.icon("measure")
.key("I")
@@ -19,11 +19,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"),
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
CommandSpec::new("PhysicalMaterialCommand", "Physical Material", physical_material)
CommandSpec::new("material.assign", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
CommandSpec::new("AppearanceCommand", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
neither (or color: null): clear, so the face follows its body, the body its component, the component its material",
@@ -74,7 +74,7 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if !want.is_empty() && bodies.len() != want.len() {
return Err(bad("MeasureCommand", "unknown body name"));
return Err(bad("inspect.measure", "unknown body name"));
}
// Sheet metal keeps the edges where bends meet flat faces (as Fusion does): count its
// B-rep faces as they are, without merging coplanar neighbours.
@@ -102,6 +102,9 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
fn sketch_json(s: &Session, id: u64) -> Option<Value> {
let state = s.model.state();
let ss = state.sketch(id)?;
// Where the sketch is in the world (its component's placement).
let world = s.world_state();
let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane);
let sk = &ss.sketch;
let curves: Vec<Value> = sk
.curves
@@ -129,7 +132,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
.points
.iter()
.enumerate()
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.collect();
let constraints: Vec<Value> = sk
.constraints
@@ -167,6 +170,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
"profiles": profiles,
"links": sk.links,
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::<Vec<_>>(),
"visible": crate::view::sketch_shown(s, id),
}))
}
@@ -204,18 +208,27 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let with_measure = bool_(p, "measure").unwrap_or(false);
let bodies: Vec<Value> =
st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect();
let bodies: Vec<Value> = st
.bodies
.iter()
.map(|b| {
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
if let Some(o) = v.as_object_mut() {
o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name)));
}
v
})
.collect();
let sketches: Vec<Value> = st
.sketches
.iter()
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len()}))
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)}))
.collect();
Ok(json!({
"name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
"active_sketch": s.active_sketch, "marker": marker,
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(),
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
}))
}
@@ -262,7 +275,7 @@ fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PhysicalMaterialCommand";
let cmd = "material.assign";
let bodies = string_list(p, "bodies");
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
let canonical = solvecraft_doc::MATERIALS
@@ -301,7 +314,7 @@ fn color_param(v: &Value) -> Option<[u8; 3]> {
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
use solvecraft_doc::appearance::{FaceLook, Look, library};
let cmd = "AppearanceCommand";
let cmd = "appearance.assign";
let look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
// A colour with a name: that colour, called that (a name not in the library is fine).
(Some(n), Some(v)) if !v.is_null() => {
+32 -17
View File
@@ -13,21 +13,21 @@ use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("JointOrigin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
"name?, occurrence?: id|name (default: from the pick), face: [x,y,z] (centre of a planar face) | circle: [x,y,z] (centre of a round end) | point: [x,y,z], z?: [x,y,z]",
),
CommandSpec::new("JointAssembleCmdNew", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|point: [x,y,z] in world} | origin name); \
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool; offset?; angle?; name?",
),
CommandSpec::new("JointAsBuiltCmd", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame",
),
CommandSpec::new("RigidGroupCmd", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("FusionMoveJointsCommand", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
"joint: id|name; value | values: [angles in deg or expressions, distances] (clamped to the limits)",
),
CommandSpec::new("FusionMotionRelationshipCommand", "Motion Link", motion_link)
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
.at("SOLID", "ASSEMBLE")
.icon("motion_link")
.params("a, b: joints; ratio (b = ratio · a + offset); offset?; ia?, ib?: value indices (default 0)"),
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("InterferenceCheckCommand", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
),
];
@@ -81,7 +81,7 @@ pub(crate) fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result
}
/// The occurrence whose bodies are nearest a world point (0 = root bodies).
fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
let st = s.model.state();
let mut best: Option<(f64, u64)> = None;
for b in &st.bodies {
@@ -133,7 +133,13 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
let snap = match what {
"face" | "circle" => {
let pick = local(world);
let face = solvecraft_doc::naming::face_names_at(&s.model.state(), &[pick]).into_iter().next().filter(|n| !n.is_empty());
// A circle's name is its cylindrical wall's (a rim pick also touches the end face).
let named_at = if what == "circle" {
s.model.state().bodies.iter().filter_map(|b| cylinder_wall_point(&b.body, pick)).next().unwrap_or(pick)
} else {
pick
};
let face = solvecraft_doc::naming::face_names_at(&s.model.state(), &[named_at]).into_iter().next().filter(|n| !n.is_empty());
if what == "face" { Snap::FaceCenter { pick, face } } else { Snap::CircleCenter { pick, face } }
}
"point" => Snap::Point { point: local(world), z: v.get("z").and_then(vec3).map(local_dir) },
@@ -215,7 +221,7 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
return Err(EngineError::Other("too many joints".into()));
}
if j.a.occurrence == j.b.occurrence {
return Err(bad("JointAssembleCmdNew", "a joint needs two different occurrences"));
return Err(bad("joint.create", "a joint needs two different occurrences"));
}
let id = j.id;
let name = j.name.clone();
@@ -228,7 +234,7 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
}
fn joint_origin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "JointOrigin";
let cmd = "joint.origin";
let origin = origin_param(s, Some(p), cmd)?;
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
@@ -248,7 +254,7 @@ fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
}
fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "JointAssembleCmdNew";
let cmd = "joint.create";
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
@@ -279,7 +285,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
}
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "JointAsBuiltCmd";
let cmd = "joint.as_built";
let kind = kind_param(p, cmd)?;
let (oa, ob) = (occurrence_id(s, p.get("a"), cmd)?, occurrence_id(s, p.get("b"), cmd)?);
// The joint's frame in the world: the given origin, or B's component origin.
@@ -312,7 +318,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "RigidGroupCmd";
let cmd = "joint.rigid_group";
let list = p.get("occurrences").and_then(Value::as_array).cloned().unwrap_or_default();
if list.len() < 2 || list.len() > 1000 {
return Err(bad(cmd, "`occurrences` must list 2…1000 occurrences"));
@@ -328,7 +334,7 @@ fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
}
fn drive(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMoveJointsCommand";
let cmd = "joint.drive";
let id = joint_id(s, p.get("joint"), cmd)?;
let kind = s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind).ok_or_else(|| bad(cmd, "joint"))?;
if kind == JointKind::Rigid {
@@ -358,7 +364,7 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
}
fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMotionRelationshipCommand";
let cmd = "joint.motion_link";
let (a, b) = (joint_id(s, p.get("a"), cmd)?, joint_id(s, p.get("b"), cmd)?);
if a == b {
return Err(bad(cmd, "link two different joints"));
@@ -517,7 +523,7 @@ fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "InterferenceCheckCommand";
let cmd = "inspect.interference";
let want = string_list(p, "bodies");
let st = s.world_state();
let bodies: Vec<&solvecraft_doc::ModelBody> =
@@ -551,3 +557,12 @@ fn interference(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "joints_tests.rs"]
mod tests;
/// A point in the middle of the cylindrical face at `pick` (on its wall, halfway along).
fn cylinder_wall_point(b: &solvecraft_doc::kernel::Body, pick: Vec3) -> Option<Vec3> {
let c = solvecraft_doc::kernel::cylinder_face_at(b, pick)?;
let v = pick - c.axis_point;
let along = v.dot(c.axis);
let radial = (v - c.axis * along).normalized()?;
Some(c.axis_point + c.axis * ((c.start + c.end) / 2.0) + radial * c.radius)
}
+32 -32
View File
@@ -29,9 +29,9 @@ fn near(a: Vec3, b: Vec3) -> bool {
/// at y = 33, z = 4.
fn leaf(s: &mut Session, name: &str) {
run(s, "component.activate", json!({"component": "root"}));
run(s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
run(s, "PrimitiveCylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
run(s, "component.create", json!({"name": name}));
run(s, "solid.box", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
}
#[test]
@@ -43,7 +43,7 @@ fn revolute_hinge_turns_about_the_knuckle() {
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
let r = run(
&mut s,
"JointAssembleCmdNew",
"joint.create",
json!({
"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "flip": true, "name": "Hinge"}),
);
@@ -54,7 +54,7 @@ fn revolute_hinge_turns_about_the_knuckle() {
let dof = l["dof"].as_array().unwrap().iter().find(|d| d["occurrence"] == occ(&s, "LeafB")).unwrap()["dof"].clone();
assert_eq!(dof, 1, "{l}");
// Drive 90°: B turns about the knuckle axis (through (0, 33, 4), along the frame's z = −X).
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": 90}));
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 90}));
let want = solvecraft_doc::rigid(Vec3::ZERO, Vec3::new(0.0, 33.0, 4.0), Vec3::new(-1.0, 0.0, 0.0), FRAC_PI_2);
let p = Vec3::new(20.0, 0.0, 0.0);
assert!(near(place(&s, "LeafB", p), solvecraft_doc::apply_point(&want, p)), "{:?}", place(&s, "LeafB", p));
@@ -63,9 +63,9 @@ fn revolute_hinge_turns_about_the_knuckle() {
assert!(near(place(&s, "LeafA", p), p));
assert!(near(place(&s, "LeafB", Vec3::new(10.0, 33.0, 4.0)), Vec3::new(10.0, 33.0, 4.0)));
// Expressions drive too, and undo puts it back.
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": "pi rad"}));
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": "pi rad"}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 66.0, 8.0)));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
}
@@ -74,8 +74,8 @@ fn two_cubes() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
let o = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
@@ -88,19 +88,19 @@ fn slider_limits_clamp() {
// B's bottom face onto A's top face (faces meet), sliding along their common normal.
let r = run(
&mut s,
"JointAssembleCmdNew",
"joint.create",
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "limits": [[0, 10]], "name": "Rail"}),
);
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
// B now sits on A.
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 25}));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 25}));
assert_eq!(r["values"], json!([10.0]), "{r}");
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 20.0)));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
// Changing the limits clamps the current value.
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 8}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 8}));
run(&mut s, "joint.limits", json!({"joint": "Rail", "min": 0, "max": 4}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 14.0)));
// Joints follow edits: A gets taller, B rides on its new top.
@@ -111,7 +111,7 @@ fn slider_limits_clamp() {
.find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. }))
.and_then(|f| f.param_names.get(2).cloned())
.unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": h, "expression": "15"}));
run(&mut s, "parameters.change", json!({"name": h, "expression": "15"}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 19.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
}
@@ -119,12 +119,12 @@ fn slider_limits_clamp() {
fn rigid_cycles_solve_and_conflicts_report() {
let mut s = two_cubes();
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "C"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
run(&mut s, "component.create", json!({"name": "C"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
// A rigid group and a closing as-built joint: a consistent loop.
run(&mut s, "RigidGroupCmd", json!({"occurrences": ["A", "B", "C"]}));
let r = run(&mut s, "JointAsBuiltCmd", json!({"type": "rigid", "a": "B", "b": "C"}));
run(&mut s, "joint.rigid_group", json!({"occurrences": ["A", "B", "C"]}));
let r = run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "B", "b": "C"}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
assert!(near(place(&s, "C", Vec3::new(40.0, 0.0, 0.0)), Vec3::new(40.0, 0.0, 0.0)));
let l = run(&mut s, "joint.list", json!({}));
@@ -132,7 +132,7 @@ fn rigid_cycles_solve_and_conflicts_report() {
// A joint that wants C elsewhere conflicts and says so.
let r = run(
&mut s,
"JointAssembleCmdNew",
"joint.create",
json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "C", "point": [40, 0, 0]}, "name": "Wrong"}),
);
let c = r["conflicts"].as_array().cloned().unwrap_or_default();
@@ -143,37 +143,37 @@ fn rigid_cycles_solve_and_conflicts_report() {
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0));
assert!(
s.execute(
"JointAssembleCmdNew",
"joint.create",
&json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "A", "point": [1, 0, 0]}})
)
.is_err()
);
assert!(s.execute("JointAssembleCmdNew", &json!({"type": "hinge"})).is_err());
assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err());
}
#[test]
fn motion_links_and_interference() {
let mut s = two_cubes();
run(&mut s, "JointAssembleCmdNew", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Screw"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 0]}));
run(&mut s, "joint.create", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Screw"}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, 0]}));
// No overlap while B sits on A; then push B 4 mm into A.
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["count"], 0, "{i}");
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, -4]}));
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, -4]}));
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["count"], 1, "{i}");
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 400.0).abs() < 1e-3, "{i}");
// A motion link: a second joint's value follows.
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "D"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]}));
run(&mut s, "component.create", json!({"name": "D"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]}));
run(
&mut s,
"JointAssembleCmdNew",
"joint.create",
json!({"type": "slider", "a": {"occurrence": "A", "face": [5, 5, 10]}, "b": {"occurrence": "D", "face": [65, 5, 0]}, "name": "Follower"}),
);
run(&mut s, "FusionMotionRelationshipCommand", json!({"a": "Screw", "ia": 1, "b": "Follower", "ratio": 2}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 3]}));
run(&mut s, "joint.motion_link", json!({"a": "Screw", "ia": 1, "b": "Follower", "ratio": 2}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, 3]}));
assert!(near(place(&s, "D", Vec3::new(65.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 16.0)), "{:?}", place(&s, "D", Vec3::new(65.0, 5.0, 0.0)));
// Saved and reopened, joints come back.
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
@@ -188,7 +188,7 @@ fn motion_links_and_interference() {
#[test]
fn faces_mate_by_default() {
let mut s = two_cubes();
run(&mut s, "JointAssembleCmdNew", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Stack"}));
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Stack"}));
// B's bottom centre (25, 5, 0) at A's top centre, B above it.
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 20.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
@@ -0,0 +1,65 @@
//! Delete Face and shells/offsets on cones and spheres, through the engine.
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: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
/// A box with a drilled hole (cone tip) shells: the cavity is the box shrunk by the wall and the
/// hole grown by it (its cone slid down its axis), measured against that analytic volume.
#[test]
fn shell_a_box_with_a_drilled_hole() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "diameter": 8, "depth": 10, "type": "drilled", "tip_angle": 118}));
let v_body = volume(&mut s);
let (r, t) = (4.0, 2.0);
let a = (59.0f64).to_radians();
// Cylinder depth from the measured body: 24000 − π r² h − π r² (r / tan α) / 3.
let h = (24000.0 - v_body - PI * r * r * (r / a.tan()) / 3.0) / (PI * r * r);
assert!(h > 9.0 && h < 11.0, "{h}");
run(&mut s, "solid.shell", json!({"faces": [[5, 5, 20]], "thickness": t}));
let v_shell = volume(&mut s);
let apex = 20.0 - h - r / a.tan() - t / a.sin();
let big_r = r + t;
let joint = apex + big_r / a.tan();
let grown = PI * big_r * big_r * (20.0 - joint) + PI * big_r * big_r * (joint - apex) / 3.0;
let cavity = 36.0 * 26.0 * 18.0 - grown;
let want = v_body - cavity;
assert!((v_shell - want).abs() < 2e-4 * want, "{v_shell} vs {want}");
}
/// Offset Face on a cone and a sphere move them exactly.
#[test]
fn offset_cone_and_sphere_faces() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 10}));
let r = run(&mut s, "solid.offset_face", json!({"faces": [[10, 0, 0]], "distance": 2}));
let v = volume(&mut s);
assert!((v - 4.0 / 3.0 * PI * 1728.0).abs() < 1e-3 * v, "{v} {r}");
}
/// Offset Face on a torus changes its tube radius and keeps the major radius.
#[test]
fn offset_torus_face() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.torus", json!({"major": 20, "minor": 5}));
let v0 = volume(&mut s);
assert!((v0 - 2.0 * PI * PI * 20.0 * 25.0).abs() < 1e-3 * v0, "{v0}");
run(&mut s, "solid.offset_face", json!({"faces": [[25, 0, 0]], "distance": 1}));
let v = volume(&mut s);
assert!((v - 2.0 * PI * PI * 20.0 * 36.0).abs() < 1e-3 * v, "{v}");
}
+52 -13
View File
@@ -1,4 +1,4 @@
//! Measuring picked geometry (`MeasureCommand` with `items`): one item gives its length, area,
//! Measuring picked geometry (`inspect.measure` with `items`): one item gives its length, area,
//! volume or position; two give the minimum distance between them (with the two closest
//! points) and, where both have a direction (planar faces, straight edges), the angle.
@@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec<Vec3> {
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
/// the nearest.
fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option<Vec<Vec3>> {
fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option<usize> {
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) {
return Some(e.clone());
if m.edges.get(index).is_some_and(|e| near(e) < tol) {
return Some(index);
}
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned()
m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i)
}
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
@@ -75,15 +75,23 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
Sel::Edge { body, index, point } => {
let m = st.body(body)?.mesh();
let pts = edge_points(&m, *index, *point)?;
let index = edge_index(&m, *index, *point)?;
let pts = m.edges.get(index)?;
let rim = m.circular_rim(index);
if rim.len() > 1 {
let pieces: Vec<&Vec<Vec3>> = rim.iter().filter_map(|&i| m.edges.get(i)).collect();
let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum();
let points = pieces.iter().flat_map(|p| densify(p)).collect();
return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len})));
}
let (a, b) = (*pts.first()?, *pts.last()?);
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
let len = polyline_len(&pts);
(Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len}))
let len = polyline_len(pts);
(Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len}))
}
Sel::Face { body, index, .. } => {
let m = st.body(body)?.mesh();
let tris = face_tris(&m, *index);
let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::<Vec<_>>();
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
@@ -146,14 +154,45 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
}
}
/// `MeasureCommand` with `items` (one or two selections).
/// `inspect.measure` with `items` (one or two selections).
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
let cmd = "MeasureCommand";
let cmd = "inspect.measure";
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
if list.is_empty() || list.len() > 2 {
if list.is_empty() || list.len() > 1000 {
return Err(bad(cmd, "measure one or two items"));
}
let sels: Vec<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
let st = s.world_state();
let mut sels = Vec::new();
for value in list {
let mut sel = serde_json::from_value::<Sel>(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
// Two kernel halves of one visible item are one measurement target.
match &mut sel {
Sel::Edge { body, index, point } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let resolved = edge_index(&m, *index, *point).unwrap_or(*index);
*index = m.circular_rim(resolved).first().copied().unwrap_or(resolved);
}
}
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
*index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index);
}
}
_ => {}
}
let duplicate = sels.iter().any(|x| match (x, &sel) {
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
_ => false,
});
if !duplicate {
sels.push(sel);
}
if sels.len() > 2 {
return Err(bad(cmd, "measure one or two items"));
}
}
let mut described = Vec::new();
for x in &sels {
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
+13 -3
View File
@@ -1,10 +1,11 @@
//! The command registry. Ids follow Fusion's command ids where Fusion has the command; the
//! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles
//! as the parity metric (`cargo xtask parity`).
//! The command registry. Ids are SolveCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`).
mod browser;
mod clipboard;
mod component;
mod construct;
mod delete;
mod edit;
mod face;
@@ -17,8 +18,11 @@ mod inspect;
pub(crate) mod joints;
mod measure_sel;
pub(crate) mod motion;
mod parts;
mod section;
pub(crate) use measure_sel::measure_items;
#[cfg(test)]
mod kernel_heal_tests;
mod parameters;
mod plastic;
mod sheet;
@@ -32,6 +36,7 @@ mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
mod surface;
mod surface_analysis;
use serde::Serialize;
@@ -136,16 +141,19 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
v.extend(surface::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
@@ -159,5 +167,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
// Ids from before the rename still find their command.
let id = crate::legacy_ids::current_id(id);
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
+10 -8
View File
@@ -13,22 +13,22 @@ use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("EnableContactSetsCmd", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
),
CommandSpec::new("EnableAllContactCmd", "Enable All Contact", enable_all)
CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("every occurrence takes part in contact (one set of all)"),
CommandSpec::new("DisableAllContactCmd", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("ContactSetCmd", "New Contact Set", new_set)
CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("contact.create", "New Contact Set", new_set)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("occurrences: [ids or names] (two or more); name?"),
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"),
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
CommandSpec::new("FusionMotionStudyCommand", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
(values: degrees for angles, mm for distances, or expressions)",
),
@@ -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 {
@@ -285,7 +287,7 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value
Ok(json!({"values": values, "stopped_by_contact": stopped, "conflicts": sol.conflicts.len()}))
}
/// Drive one joint (FusionMoveJointsCommand), honouring contact sets.
/// Drive one joint (joint.drive), honouring contact sets.
pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>) -> Result<Value> {
drive_with_contact(s, &[(id, q)])
}
@@ -311,7 +313,7 @@ fn disable_all(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ContactSetCmd";
let cmd = "contact.create";
let occ = occurrences(s, p, cmd)?;
if occ.len() < 2 {
return Err(bad(cmd, "a contact set needs two or more occurrences"));
@@ -387,7 +389,7 @@ fn study_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
}
fn study(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMotionStudyCommand";
let cmd = "motion.study";
let existing = if p.get("study").is_some() { Some(study_index(s, p, cmd)?) } else { None };
let steps = match p.get("steps") {
Some(v) => {
+23 -24
View File
@@ -26,14 +26,14 @@ fn rail() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
run(&mut s, "component.activate", json!({"component": "root"}));
let a = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(&mut s, "JointAssembleCmdNew", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
assert!((b_x(&s) - 20.0).abs() < 1e-6, "{}", b_x(&s));
s
}
@@ -42,45 +42,44 @@ fn rail() -> Session {
fn contact_sets_stop_a_driven_joint_at_touching() {
let mut s = rail();
// No contact: B passes into A.
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!(r["stopped_by_contact"].is_object(), "{r}");
// Stopped where the faces touch (to the search's resolution, 15 mm / 2^14).
assert!((b_x(&s) - 10.0).abs() < 2e-3, "{}", b_x(&s));
assert!(b_x(&s) >= 10.0 - 2e-3, "a touch, not a pass");
assert_eq!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().map(Vec::len), Some(0));
// Away is free; contact off lets it through again.
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 3}));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 3}));
assert!(r["stopped_by_contact"].is_null() && (b_x(&s) - 13.0).abs() < 1e-6, "{r}");
run(&mut s, "DisableAllContactCmd", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "contact.disable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
// Suppressed sets don't count; Enable All Contact puts every occurrence in one.
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "EnableContactSetsCmd", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_sets", json!({}));
run(&mut s, "contact.edit", json!({"set": "Contact Set1", "suppressed": true}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "EnableAllContactCmd", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 10.0).abs() < 2e-3);
assert!(s.execute("ContactSetCmd", &json!({"occurrences": ["A"]})).is_err());
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
}
#[test]
fn motion_study_plays_and_exports() {
let mut s = rail();
let r =
run(&mut s, "FusionMotionStudyCommand", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
let r = run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
assert_eq!(r["keys"], 1);
run(&mut s, "motion.play", json!({"study": "Push", "step": 50}));
assert!((b_x(&s) - 12.5).abs() < 1e-6, "{}", b_x(&s));
// With contact, the end of the study stops at touching.
run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]}));
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "motion.play", json!({"study": "Push", "step": 100}));
assert!(r["stopped_by_contact"].is_object() && (b_x(&s) - 10.0).abs() < 2e-3, "{r}");
// Export: positions per sample; the design keeps its joint value.
@@ -104,7 +103,7 @@ fn motion_study_plays_and_exports() {
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.starts_with("step,occurrence,x,y,z") && text.lines().count() == 1 + 3 * 2, "{text}");
let _ = std::fs::remove_dir_all(&dir);
assert!(s.execute("FusionMotionStudyCommand", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
assert!(s.execute("motion.study", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
run(&mut s, "motion.delete", json!({"study": "Push"}));
assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0));
}
@@ -117,7 +116,7 @@ fn exploded_views_show_without_changing_the_design() {
assert_eq!(r["moves"].as_array().map(Vec::len), Some(2), "{r}");
run(&mut s, "explode.show", json!({"view": "Apart"}));
// Centres at x 5 and 25 → middle 15: each moves 10 mm further out.
let bb = |s: &mut Session, b: &str| run(s, "MeasureCommand", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN);
let bb = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN);
let names: Vec<String> = s.world_state().bodies.iter().map(|b| b.name.clone()).collect();
assert!((bb(&mut s, &names[0]) + 10.0).abs() < 1e-6, "{names:?}");
assert!((bb(&mut s, &names[1]) - 30.0).abs() < 1e-6);
@@ -127,7 +126,7 @@ fn exploded_views_show_without_changing_the_design() {
run(&mut s, "explode.create", json!({"name": "Hand", "moves": [{"occurrence": "B", "translate": [0, 0, 50]}]}));
run(&mut s, "explode.show", json!({"view": "Hand"}));
assert!(
(run(&mut s, "MeasureCommand", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6
(run(&mut s, "inspect.measure", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6
);
assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2));
}
+9 -7
View File
@@ -10,9 +10,10 @@ use crate::params::{bad, bool_, str_};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("ChangeParameterCommand", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
favorite?: bool, new_name?: rename (references follow); or delete: name",
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
),
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
@@ -65,7 +66,7 @@ fn known(s: &Session, name: &str) -> bool {
}
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ChangeParameterCommand";
let cmd = "parameters.change";
if let Some(n) = str_(p, "delete") {
s.doc_mut().remove_param(n.trim())?;
return Ok(json!({"deleted": n.trim()}));
@@ -107,6 +108,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
out["value"] = json!(vals.get(&name).map(|v| v.v));
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
out["recomputed"] = json!(s.model.last_recomputed);
out["restored"] = json!(s.model.last_restored);
out["errors"] = json!(failing(s));
Ok(out)
}
@@ -219,7 +221,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
other => return Err(bad(cmd, format!("unknown format `{other}` (csv or json)"))),
};
if let Some(path) = path {
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
}
Ok(json!({"format": format, "path": path, "text": text}))
}
@@ -229,11 +231,11 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let text = match (str_(p, "text"), str_(p, "path")) {
(Some(t), _) => t.to_string(),
(None, Some(path)) => {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > 16 << 20 {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > 16 << 20 {
return Err(bad(cmd, "file too large"));
}
std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
solvecraft_io::vfs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
}
_ => return Err(bad(cmd, "give `text` or `path`")),
};
+40 -21
View File
@@ -17,7 +17,7 @@ fn err(s: &mut Session, id: &str, p: Value) -> String {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -36,13 +36,13 @@ fn expr_of(s: &mut Session, name: &str) -> String {
/// A sketched block 40 x (len / 2) x 10 whose width is an expression of a user parameter.
fn block(s: &mut Session) {
run(s, "parameters.add", json!({"name": "len", "expression": "60 mm", "comment": "overall length", "favorite": true}));
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"}));
run(s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
run(s, "SketchDimension", json!({"entities": ["l2"], "value": "len/2"}));
run(s, "SketchStop", json!({}));
run(s, "Extrude", json!({"distance": 10, "body_name": "Block"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "len/2"}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 10, "body_name": "Block"}));
}
#[test]
@@ -50,23 +50,42 @@ fn width_follows_an_expression() {
let mut s = Session::default();
block(&mut s);
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "100 mm"}));
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
// Units convert: 4 in = 101.6 mm.
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "4 in"}));
run(&mut s, "parameters.change", json!({"name": "len", "expression": "4 in"}));
assert!(rel(volume(&mut s), 40.0 * 50.8 * 10.0) < 1e-9);
// A parameter in inches reads bare numbers as inches.
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5", "unit": "in"}));
run(&mut s, "parameters.change", json!({"name": "len", "expression": "5", "unit": "in"}));
assert!(rel(volume(&mut s), 40.0 * 63.5 * 10.0) < 1e-9);
let l = run(&mut s, "parameters.list", json!({}));
assert_eq!(row(&l, "len")["value"], 5.0, "{l}");
assert_eq!(row(&l, "len")["unit"], "in");
// A failing parameter fails the features using it, with its error.
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5 deg"}));
let e = err(&mut s, "parameters.change", json!({"name": "len", "expression": "5 deg"}));
assert!(e.contains("expected a length, got an angle"), "{e}");
}
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
/// for "nothing changed". timeline.edit reports the same.
#[test]
fn setting_a_parameter_back_reports_restored_features() {
let mut s = Session::default();
block(&mut s);
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
}
#[test]
fn feature_inputs_are_named_and_editable() {
let mut s = Session::default();
@@ -75,14 +94,14 @@ fn feature_inputs_are_named_and_editable() {
let dist = l["parameters"].as_array().unwrap().iter().find(|r| r["input"] == "Distance").unwrap().clone();
assert_eq!(dist["feature"], "Extrude1", "{l}");
let d = dist["name"].as_str().unwrap().to_string();
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "len / 3"}));
run(&mut s, "parameters.change", json!({"name": d, "expression": "len / 3"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9);
// Editing the feature keeps its parameter name.
run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "12 mm"}}}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0) < 1e-9);
assert_eq!(expr_of(&mut s, &d), "12 mm");
// Another feature can use it.
run(&mut s, "PrimitiveBox", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
run(&mut s, "solid.box", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0 + 12.0 * 25.0) < 1e-9);
let u = run(&mut s, "parameters.users", json!({"name": d}));
assert_eq!(u["users"].as_array().map(Vec::len), Some(1), "{u}");
@@ -101,7 +120,7 @@ fn unit_errors_and_cycles_are_refused() {
assert!(e.contains("expected an angle, got a length"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "x", "expression": "1", "unit": "furlong"})).contains("unknown unit"));
run(&mut s, "parameters.add", json!({"name": "b", "expression": "a * 2"}));
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "a", "expression": "b + 1"}));
let e = err(&mut s, "parameters.change", json!({"name": "a", "expression": "b + 1"}));
assert!(e.contains("circular reference: a → b → a"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "selfref", "expression": "selfref"})).contains("circular"));
assert!(err(&mut s, "parameters.add", json!({"name": "a", "expression": "1"})).contains("already"));
@@ -132,7 +151,7 @@ fn rename_updates_every_reference() {
let l = run(&mut s, "parameters.list", json!({"favorites": true}));
assert_eq!(l["count"], 1, "the favourite follows the rename");
assert_eq!(row(&l, "length")["comment"], "overall length");
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "80"}));
run(&mut s, "parameters.change", json!({"name": "length", "expression": "80"}));
assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9);
// Model and feature parameters can be renamed too: dimensions and features follow.
let dim = s.doc.features.first().and_then(|f| match &f.kind {
@@ -140,17 +159,17 @@ fn rename_updates_every_reference() {
_ => None,
});
let dim = dim.unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
run(&mut s, "parameters.change", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 10.0) < 1e-9);
let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.rename", json!({"name": ext, "new_name": "thick"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "thick", "expression": "base_w / 10"}));
run(&mut s, "parameters.change", json!({"name": "thick", "expression": "base_w / 10"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 5.0) < 1e-9);
assert!(err(&mut s, "parameters.rename", json!({"name": "twice", "new_name": "length"})).contains("already"));
assert!(err(&mut s, "parameters.rename", json!({"name": "nope", "new_name": "x"})).contains("unknown"));
// Undo puts the old name back.
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.features.get(1).is_some_and(|f| f.param_names.first() == Some(&ext)));
}
@@ -165,7 +184,7 @@ fn deleting_a_used_parameter_lists_its_users() {
assert_eq!(u["users"].as_array().map(Vec::len), Some(2), "{u}");
assert_eq!(u["drives"][0]["name"], "Base", "{u}");
let dim = u["users"].as_array().unwrap().iter().find(|x| x["name"] != "spare").unwrap()["name"].as_str().unwrap().to_string();
let e = err(&mut s, "ChangeParameterCommand", json!({"delete": dim}));
let e = err(&mut s, "parameters.change", json!({"delete": dim}));
assert!(e.contains("Base dimension"), "{e}");
run(&mut s, "parameters.delete", json!({"name": "spare"}));
assert!(s.doc.param("spare").is_none());
+518
View File
@@ -0,0 +1,518 @@
//! Standard parts: ISO metric fasteners, dowel pins and ball bearings, generated in code from
//! the dimensions in the public standards tables (no vendor geometry). Each part is inserted as
//! its own component, built by ordinary features (so it stays parametric and editable), with a
//! cosmetic thread where it has one; at a hole it gets a rigid joint that puts its axis on the
//! hole's and seats it on the face.
//!
//! Part frame: the seating plane is z = 0 (the underside of a head, the bottom of a nut or
//! washer, the top of a set screw or pin); the shank runs down −z.
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, num, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parts.library", "Standard Parts", library)
.noundo()
.params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"),
CommandSpec::new("parts.fastener", "Insert Fastener", insert).at("SOLID", "INSERT").icon("fastener").params(
"the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?",
),
CommandSpec::new("parts.insert", "Insert Part", insert).icon("fastener").params(
"family (socket_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \
length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?",
),
CommandSpec::new("parts.resize", "Change Part Size", resize)
.params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"),
];
/// A family: (id, name, standard).
const FAMILIES: [(&str, &str, &str); 7] = [
("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"),
("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"),
("hex_nut", "Hex nut", "ISO 4032"),
("washer", "Plain washer", "ISO 7089"),
("set_screw", "Hex socket set screw, flat point", "ISO 4026"),
("dowel_pin", "Parallel pin (dowel)", "ISO 8734"),
("bearing", "Deep groove ball bearing (envelope)", "ISO 15 (6000/6200/600 series)"),
];
/// ISO metric coarse: (size, d, pitch).
const METRIC: [(&str, f64, f64); 9] = [
("M3", 3.0, 0.5),
("M4", 4.0, 0.7),
("M5", 5.0, 0.8),
("M6", 6.0, 1.0),
("M8", 8.0, 1.25),
("M10", 10.0, 1.5),
("M12", 12.0, 1.75),
("M16", 16.0, 2.0),
("M20", 20.0, 2.5),
];
/// ISO 4762: (size, head diameter dk, head height k, socket s, socket depth t).
const SHCS: [(&str, f64, f64, f64, f64); 9] = [
("M3", 5.5, 3.0, 2.5, 1.3),
("M4", 7.0, 4.0, 3.0, 2.0),
("M5", 8.5, 5.0, 4.0, 2.5),
("M6", 10.0, 6.0, 5.0, 3.0),
("M8", 13.0, 8.0, 6.0, 4.0),
("M10", 16.0, 10.0, 8.0, 5.0),
("M12", 18.0, 12.0, 10.0, 6.0),
("M16", 24.0, 16.0, 14.0, 8.0),
("M20", 30.0, 20.0, 17.0, 10.0),
];
/// ISO 4014/4017 hex head: (size, across flats s, head height k).
const HEX_BOLT: [(&str, f64, f64); 9] = [
("M3", 5.5, 2.0),
("M4", 7.0, 2.8),
("M5", 8.0, 3.5),
("M6", 10.0, 4.0),
("M8", 13.0, 5.3),
("M10", 16.0, 6.4),
("M12", 18.0, 7.5),
("M16", 24.0, 10.0),
("M20", 30.0, 12.5),
];
/// ISO 4032: (size, across flats s, height m).
const HEX_NUT: [(&str, f64, f64); 9] = [
("M3", 5.5, 2.4),
("M4", 7.0, 3.2),
("M5", 8.0, 4.7),
("M6", 10.0, 5.2),
("M8", 13.0, 6.8),
("M10", 16.0, 8.4),
("M12", 18.0, 10.8),
("M16", 24.0, 14.8),
("M20", 30.0, 18.0),
];
/// ISO 7089: (size, inner d1, outer d2, thickness h).
const WASHER: [(&str, f64, f64, f64); 9] = [
("M3", 3.2, 7.0, 0.5),
("M4", 4.3, 9.0, 0.8),
("M5", 5.3, 10.0, 1.0),
("M6", 6.4, 12.0, 1.6),
("M8", 8.4, 16.0, 1.6),
("M10", 10.5, 20.0, 2.0),
("M12", 13.0, 24.0, 2.5),
("M16", 17.0, 30.0, 3.0),
("M20", 21.0, 37.0, 3.0),
];
/// ISO 4026: (size, socket s, socket depth t).
const SET_SCREW: [(&str, f64, f64); 7] =
[("M3", 1.5, 1.2), ("M4", 2.0, 1.5), ("M5", 2.5, 2.0), ("M6", 3.0, 2.0), ("M8", 4.0, 3.0), ("M10", 5.0, 4.0), ("M12", 6.0, 4.8)];
/// ISO 8734 diameters.
const DOWEL: [f64; 9] = [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0];
/// Bearings: (designation, bore, outside diameter, width).
const BEARINGS: [(&str, f64, f64, f64); 13] = [
("625", 5.0, 16.0, 5.0),
("626", 6.0, 19.0, 6.0),
("608", 8.0, 22.0, 7.0),
("6000", 10.0, 26.0, 8.0),
("6001", 12.0, 28.0, 8.0),
("6002", 15.0, 32.0, 9.0),
("6003", 17.0, 35.0, 10.0),
("6004", 20.0, 42.0, 12.0),
("6200", 10.0, 30.0, 9.0),
("6201", 12.0, 32.0, 10.0),
("6202", 15.0, 35.0, 11.0),
("6203", 17.0, 40.0, 12.0),
("6204", 20.0, 47.0, 14.0),
];
/// Standard lengths for screws, bolts and pins (mm).
const LENGTHS: [f64; 22] =
[4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 80.0, 90.0, 100.0, 120.0];
fn metric(size: &str) -> Option<(f64, f64)> {
METRIC.iter().find(|(s, _, _)| s.eq_ignore_ascii_case(size)).map(|(_, d, p)| (*d, *p))
}
fn default_length(d: f64) -> f64 {
LENGTHS.iter().copied().find(|l| *l >= 2.5 * d).unwrap_or(100.0)
}
/// One part's shape, in its own frame.
#[derive(Clone, Debug, PartialEq)]
enum Shape {
/// A head (round or hex), a shank down −z, an optional hex socket in the head's top.
Screw { d: f64, pitch: f64, len: f64, head: Head, socket: Option<(f64, f64)> },
/// A hex prism with a through hole (nut), z 0…m.
Nut { d: f64, pitch: f64, s: f64, m: f64 },
/// A ring (washer, bearing envelope), z 0…h.
Ring { d_in: f64, d_out: f64, h: f64 },
/// A cylinder down −z with a socket in its top (set screw), or plain (pin).
Rod { d: f64, pitch: Option<f64>, len: f64, socket: Option<(f64, f64)> },
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Head {
Round { dk: f64, k: f64 },
Hex { s: f64, k: f64 },
}
fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<Shape, String> {
let need = |what: &str| format!("no {what} size `{size}` (parts.library lists them)");
let len = |d: f64| length.unwrap_or_else(|| default_length(d));
Ok(match family {
"socket_head_cap_screw" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, dk, k, s, t) = SHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4762"))?;
Shape::Screw { d, pitch, len: len(d), head: Head::Round { dk, k }, socket: Some((s, t)) }
}
"hex_bolt" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, k) = HEX_BOLT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4014"))?;
Shape::Screw { d, pitch, len: len(d), head: Head::Hex { s, k }, socket: None }
}
"hex_nut" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, m) = HEX_NUT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4032"))?;
Shape::Nut { d, pitch, s, m }
}
"washer" => {
let (_, d1, d2, h) = WASHER.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7089"))?;
Shape::Ring { d_in: d1, d_out: d2, h }
}
"set_screw" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, t) = SET_SCREW.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4026"))?;
Shape::Rod {
d,
pitch: Some(pitch),
len: length.unwrap_or_else(|| LENGTHS.iter().copied().find(|l| *l >= d).unwrap_or(d)),
socket: Some((s, t)),
}
}
"dowel_pin" => {
let d: f64 = size.trim_start_matches(['d', 'D', 'Ø']).trim().parse().map_err(|_| need("dowel pin"))?;
if !DOWEL.contains(&d) {
return Err(need("ISO 8734"));
}
Shape::Rod { d, pitch: None, len: len(d * 1.6), socket: None }
}
"bearing" => {
let (_, bore, od, w) = BEARINGS.iter().find(|r| r.0 == size.trim()).copied().ok_or_else(|| need("bearing"))?;
Shape::Ring { d_in: bore, d_out: od, h: w }
}
o => return Err(format!("unknown part family `{o}` (parts.library lists them)")),
})
}
fn run(s: &mut Session, id: &str, p: Value) -> Result<Value> {
s.execute(id, &p)
}
/// A hex prism (across flats `af`) from z0 to z1, joined or cut.
fn hex(s: &mut Session, af: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XY", "offset": z0}))?;
run(s, "sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": af / 2.0, "sides": 6}))?;
run(s, "sketch.finish", json!({}))?;
let (dist, dir) = if z1 >= z0 { (z1 - z0, "positive") } else { (z0 - z1, "negative") };
run(s, "solid.extrude", json!({"distance": dist, "direction": dir, "operation": op}))?;
Ok(())
}
fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
run(s, "solid.cylinder", json!({"diameter": d, "height": z1 - z0, "base": [0, 0, z0], "axis": [0, 0, 1], "operation": op}))?;
Ok(())
}
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XZ"}))?;
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
run(s, "sketch.finish", json!({}))?;
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
Ok(())
}
fn thread(s: &mut Session, d: f64, pitch: f64, at: Vec3) {
let _ = run(s, "solid.thread", json!({"face": at, "designation": format!("M{}x{}", d, pitch)}));
let _ = d;
}
/// Build a part in the active component.
fn build(s: &mut Session, sh: &Shape) -> Result<()> {
match *sh {
Shape::Screw { d, pitch, len, head, socket } => {
let k = match head {
// One revolved outline: shank and round head.
Head::Round { dk, k } => {
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (dk / 2.0, 0.0), (dk / 2.0, k), (0.0, k)], "new")?;
k
}
// A hex head joined over a shank that runs up into it.
Head::Hex { s: af, k } => {
hex(s, af, 0.0, k, "new")?;
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, k / 2.0), (0.0, k / 2.0)], "join")?;
k
}
};
if let Some((af, t)) = socket {
hex(s, af, k, k - t, "cut")?;
}
thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, -len / 2.0));
}
Shape::Nut { d, pitch, s: af, m } => {
hex(s, af, 0.0, m, "new")?;
cyl(s, d, -1.0, m + 1.0, "cut")?;
thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, m / 2.0));
}
Shape::Ring { d_in, d_out, h } => {
revolve(s, &[(d_in / 2.0, 0.0), (d_out / 2.0, 0.0), (d_out / 2.0, h), (d_in / 2.0, h)], "new")?;
}
Shape::Rod { d, pitch, len, socket } => {
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (0.0, 0.0)], "new")?;
if let Some((af, t)) = socket {
hex(s, af, 0.0, -t, "cut")?;
}
if let Some(p) = pitch {
thread(s, d, p, Vec3::new(d / 2.0, 0.0, -len / 2.0));
}
}
}
Ok(())
}
fn library(_s: &mut Session, p: &Value) -> Result<Value> {
let fam = |id: &str| -> Value {
let sizes: Vec<Value> = match id {
"socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"hex_bolt" => HEX_BOLT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "head_height": r.2})).collect(),
"hex_nut" => HEX_NUT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "height": r.2})).collect(),
"washer" => WASHER.iter().map(|r| json!({"size": r.0, "inner": r.1, "outer": r.2, "thickness": r.3})).collect(),
"set_screw" => SET_SCREW.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "socket": r.1, "socket_depth": r.2})).collect(),
"dowel_pin" => DOWEL.iter().map(|d| json!({"size": format!("{d}"), "d": d})).collect(),
"bearing" => BEARINGS.iter().map(|r| json!({"size": r.0, "bore": r.1, "outer": r.2, "width": r.3})).collect(),
_ => Vec::new(),
};
let (_, name, standard) = FAMILIES.iter().find(|f| f.0 == id).copied().unwrap_or(("", "", ""));
let lengths = matches!(id, "socket_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin").then_some(LENGTHS.to_vec());
json!({"family": id, "name": name, "standard": standard, "sizes": sizes, "lengths": lengths})
};
match str_(p, "family") {
Some(f) if FAMILIES.iter().any(|x| x.0 == f) => Ok(fam(f)),
Some(f) => Err(bad("parts.library", format!("unknown part family `{f}`"))),
None => Ok(json!({"families": FAMILIES.iter().map(|f| fam(f.0)).collect::<Vec<_>>()})),
}
}
fn part_name(family: &str, size: &str, sh: &Shape) -> String {
match sh {
Shape::Screw { len, .. } | Shape::Rod { len, .. } => format!("{} {size}x{len}", label(family)),
_ => format!("{} {size}", label(family)),
}
}
fn label(family: &str) -> &'static str {
match family {
"socket_head_cap_screw" => "SHCS",
"hex_bolt" => "Hex Bolt",
"hex_nut" => "Hex Nut",
"washer" => "Washer",
"set_screw" => "Set Screw",
"dowel_pin" => "Dowel Pin",
_ => "Bearing",
}
}
fn length_param(p: &Value, cmd: &str) -> Result<Option<f64>> {
match p.get("length") {
None | Some(Value::Null) => Ok(None),
Some(_) => {
let l = num(p, "length").filter(|l| l.is_finite() && *l > 0.0 && *l <= 2000.0).ok_or_else(|| bad(cmd, "`length` must be 0…2000 mm"))?;
Ok(Some(l))
}
}
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parts.insert (Insert Fastener)";
let family = str_(p, "family").ok_or_else(|| bad(cmd, "`family` is required (parts.library)"))?.to_string();
let size = str_(p, "size").ok_or_else(|| bad(cmd, "`size` is required"))?.to_string();
let sh = shape(&family, &size, length_param(p, cmd)?).map_err(|e| bad(cmd, e))?;
// A pick on a hole's rim also touches its end face: step onto the wall just inside the rim.
let at = p.get("at").and_then(vec3).map(|a| hole_wall(s, a).unwrap_or(a));
// The occurrence the hole belongs to, found before the part exists.
let host = match at {
Some(a) => Some(super::joints::occurrence_at(s, a).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?),
None => None,
};
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| part_name(&family, &size, &sh));
let active = s.active_component;
let c = run(s, "component.create", json!({"name": name}))?;
let (comp, occ) = (c["component"].as_u64().unwrap_or(0), c["occurrence"].as_u64().unwrap_or(0));
build(s, &sh)?;
s.doc_mut().parts.insert(comp, solvecraft_doc::StandardPart { family: family.clone(), size: size.clone(), length: shape_len(&sh) });
run(s, "component.activate", json!({"component": active}))?;
let mut out = json!({"component": comp, "occurrence": occ, "name": name});
if let (Some(a), Some(h)) = (at, host) {
// Seat it: its axis on the hole's, its seating plane on the hole's face, the shank going
// into the hole (the joint mates the part's −z with the face's outward z).
let j = run(
s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": occ, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": format!("{name} seat")}),
)?;
out["joint"] = j["joint"].clone();
} else if let Some(pt) = p.get("point").and_then(vec3) {
// Placed at a point, its +z along `direction`.
let dir = p.get("direction").and_then(vec3).and_then(|d| d.normalized()).unwrap_or(Vec3::Z);
let m = solvecraft_doc::rigid(
Vec3::ZERO,
Vec3::ZERO,
Vec3::Z.cross(dir).normalized().unwrap_or(Vec3::X),
Vec3::Z.dot(dir).clamp(-1.0, 1.0).acos(),
);
let mut m = m;
m[3] = [pt.x, pt.y, pt.z, 1.0];
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
o.transform = m;
}
}
Ok(out)
}
/// A point on the cylindrical wall of the hole at `a`, a little in from the picked rim.
fn hole_wall(s: &Session, a: Vec3) -> Option<Vec3> {
let st = s.model.state();
let dirs = [Vec3::Z * -1.0, Vec3::Z, Vec3::X, Vec3::X * -1.0, Vec3::Y, Vec3::Y * -1.0];
for step in [0.2, 0.5, 1.0, 2.0] {
for b in &st.bodies {
for d in dirs {
let q = a + d * step;
if let Some(c) = solvecraft_doc::kernel::cylinder_face_at(&b.body, q) {
let v = q - c.axis_point;
let along = v.dot(c.axis);
let on_wall = ((v - c.axis * along).len() - c.radius).abs() < 1e-3 && along > c.start && along < c.end;
if on_wall {
return Some(q);
}
}
}
}
}
None
}
fn shape_len(sh: &Shape) -> Option<f64> {
match sh {
Shape::Screw { len, .. } | Shape::Rod { len, .. } => Some(*len),
_ => None,
}
}
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parts.resize";
let key = match p.get("component") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, "`component` must be an id or a name")),
};
let comp = s.doc.find_component(&key).ok_or_else(|| bad(cmd, format!("no component `{key}`")))?;
let part = s.doc.parts.get(&comp).cloned().ok_or_else(|| bad(cmd, "that component is not a standard part"))?;
let size = str_(p, "size").map(str::to_string).unwrap_or(part.size.clone());
let length = length_param(p, cmd)?.or(if size == part.size { part.length } else { None });
let sh = shape(&part.family, &size, length).map_err(|e| bad(cmd, e))?;
// Rebuild: the component's features go, the new shape is built in their place.
let ids: Vec<u64> = s.doc.features.iter().filter(|f| f.component == comp).map(|f| f.id).collect();
for id in ids.iter().rev() {
let _ = s.doc_mut().delete_feature(*id);
}
let active = s.active_component;
run(s, "component.activate", json!({"component": comp}))?;
build(s, &sh)?;
let name = part_name(&part.family, &size, &sh);
s.doc_mut().parts.insert(comp, solvecraft_doc::StandardPart { family: part.family.clone(), size: size.clone(), length: shape_len(&sh) });
if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == comp) {
c.name = name.clone();
}
run(s, "component.activate", json!({"component": active}))?;
s.refresh();
if let Some(e) = s.model.results.iter().find_map(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"component": comp, "name": name, "size": size, "length": shape_len(&sh)}))
}
#[cfg(test)]
mod tests {
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 bbox(s: &Session, prefix: &str) -> (solvecraft_geom::Vec3, solvecraft_geom::Vec3) {
let st = s.world_state();
let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0);
let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN));
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) {
let m = b.mesh().bounds();
lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z));
hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z));
}
(lo, hi)
}
#[test]
fn dimensions_follow_the_standard_tables() {
let l = run(&mut Session::default(), "parts.library", json!({"family": "socket_head_cap_screw"}));
let m6 = l["sizes"].as_array().and_then(|a| a.iter().find(|x| x["size"] == "M6")).cloned().unwrap_or_default();
assert_eq!((m6["head_diameter"].as_f64(), m6["head_height"].as_f64(), m6["socket"].as_f64()), (Some(10.0), Some(6.0), Some(5.0)));
// An M6 x 20 cap screw: head Ø10 x 6, shank 20 long.
let mut s = Session::default();
run(&mut s, "parts.insert", json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20}));
let (lo, hi) = bbox(&s, "SHCS M6x20");
assert!((hi.x - lo.x - 10.0).abs() < 1e-6 && (lo.z + 20.0).abs() < 1e-6 && (hi.z - 6.0).abs() < 1e-6, "{lo:?} {hi:?}");
assert!(s.model.state().threads.iter().any(|t| t.designation.starts_with("M6")), "{:?}", s.model.state().threads);
// An M8 nut: 13 across flats (14.43 across corners), 6.8 high.
let mut n = Session::default();
run(&mut n, "parts.insert", json!({"family": "hex_nut", "size": "M8"}));
let (lo, hi) = bbox(&n, "Hex Nut M8");
let (w, h) = ((hi.x - lo.x).max(hi.y - lo.y), hi.z - lo.z);
assert!((w - 13.0 / (std::f64::consts::PI / 6.0).cos()).abs() < 1e-6 && (h - 6.8).abs() < 1e-6, "{w} {h}");
// A 608 bearing: 8 x 22 x 7.
let mut b = Session::default();
run(&mut b, "parts.insert", json!({"family": "bearing", "size": "608"}));
let (lo, hi) = bbox(&b, "Bearing 608");
assert!((hi.x - lo.x - 22.0).abs() < 1e-6 && (hi.z - lo.z - 7.0).abs() < 1e-6);
assert!(b.execute("parts.insert", &json!({"family": "hex_nut", "size": "M7"})).is_err());
}
#[test]
fn a_screw_seats_in_a_hole_and_resizes() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 10], "diameter": 6.4}));
// Pick the hole's rim at the top face.
let r = run(&mut s, "parts.insert", json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20, "at": [23.2, 15, 10]}));
assert!(r["joint"].is_number(), "{r}");
let (lo, hi) = bbox(&s, "SHCS M6x20");
// The head sits flush on the top face, the shank down the hole's axis.
assert!((hi.z - 16.0).abs() < 1e-6 && (lo.z + 10.0).abs() < 1e-6, "{lo:?} {hi:?}");
assert!(((lo.x + hi.x) / 2.0 - 20.0).abs() < 1e-6 && ((lo.y + hi.y) / 2.0 - 15.0).abs() < 1e-6);
// M8 x 25: rebuilt in place, still seated.
let comp = r["component"].as_u64().unwrap_or(0);
run(&mut s, "parts.resize", json!({"component": comp, "size": "M8", "length": 25}));
let (lo, hi) = bbox(&s, "SHCS M8x25");
assert!((hi.z - 18.0).abs() < 1e-6 && (lo.z + 15.0).abs() < 1e-6, "{lo:?} {hi:?}");
}
}
+56 -28
View File
@@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -22,12 +22,12 @@ fn rel(a: f64, b: f64) -> f64 {
/// A 100 x 50 x 5 plate with a hole at the start of a straight path (10,10) → (90,10).
fn plate_with_hole() -> Session {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 50, "height": 5, "corner": [0, 0, 0]}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [90, 10]], "ids": ["l1"]}));
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 2, "id": "c1"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Path", "through_all": true, "operation": "cut", "name": "Hole1"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 50, "height": 5, "corner": [0, 0, 0]}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [90, 10]], "ids": ["l1"]}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 2, "id": "c1"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Path", "through_all": true, "operation": "cut", "name": "Hole1"}));
s
}
@@ -38,25 +38,25 @@ const HOLE: f64 = PI * 4.0 * 5.0;
fn pattern_on_path_spacing_extent_and_flip() {
let mut s = plate_with_hole();
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 5, "spacing": 20}));
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 5, "spacing": 20}));
assert!(rel(volume(&mut s), PLATE - 5.0 * HOLE) < 1e-4);
// The count is a parameter like any other input.
let n = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "3"}));
run(&mut s, "parameters.change", json!({"name": n, "expression": "3"}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Instances past the end of the path are an error.
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "6"}));
run(&mut s, "parameters.change", json!({"name": n, "expression": "6"}));
let err = s.model.results.last().and_then(|r| r.error.clone()).unwrap_or_default();
assert!(err.contains("past the end of the path"), "{err}");
// Distance = first to last instance.
let mut s = plate_with_hole();
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 3, "distance": 80}));
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 3, "distance": 80}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Flipped, the path runs from (90,10) back toward (10,10).
let mut s = plate_with_hole();
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 2, "spacing": 30, "flip": true}));
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 2, "spacing": 30, "flip": true}));
// The copy moves by path(30) − path(0) = (60,10) − (90,10) = −30: off the plate side → only 1 hole.
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
}
@@ -64,47 +64,47 @@ fn pattern_on_path_spacing_extent_and_flip() {
#[test]
fn patterns_of_bodies() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cube"}));
run(&mut s, "PatternCircular", json!({"bodies": ["Cube"], "axis": "Z", "count": 4}));
let m = run(&mut s, "MeasureCommand", json!({}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cube"}));
run(&mut s, "solid.pattern.circular", json!({"bodies": ["Cube"], "axis": "Z", "count": 4}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 4, "{m}");
assert!(rel(volume(&mut s), 4000.0) < 1e-4);
run(&mut s, "PatternRectangular", json!({"bodies": ["Cube"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Cube"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
assert!(rel(volume(&mut s), 6000.0) < 1e-4);
assert!(s.execute("PatternRectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
assert!(s.execute("solid.pattern.rectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
}
#[test]
fn patterns_of_components_place_occurrences() {
let mut s = Session::default();
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Peg"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "component.create", json!({"name": "Peg"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = run(&mut s, "PatternRectangular", json!({"components": ["Peg"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
let r = run(&mut s, "solid.pattern.rectangular", json!({"components": ["Peg"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(2), "{r}");
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 3, "{m}");
let zs: Vec<f64> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["bbox"]["min"][2].as_f64()).collect();
for z in [0.0, 20.0, 40.0] {
assert!(zs.iter().any(|x| (x - z).abs() < 1e-6), "{zs:?}");
}
assert!(s.execute("PatternCircular", &json!({"components": ["Nope"], "axis": "Z", "count": 2})).is_err());
assert!(s.execute("solid.pattern.circular", &json!({"components": ["Nope"], "axis": "Z", "count": 2})).is_err());
}
#[test]
fn path_pattern_orientation_follows_the_path() {
let width_of_copy = |orient: &str| {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 2, "width": 4, "height": 2, "corner": [19, -2, 0], "body_name": "Tab"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Arc"}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "a1"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "solid.box", json!({"length": 2, "width": 4, "height": 2, "corner": [19, -2, 0], "body_name": "Tab"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Arc"}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "a1"}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"PatternOnPath",
"solid.pattern.path",
json!({"bodies": ["Tab"], "path_sketch": "Arc", "path": ["a1"], "count": 2, "distance": "pi * 10", "orientation": orient}),
);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] != "Tab").unwrap().clone();
let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone());
// The copy sits at the arc's end (0, 20).
@@ -114,3 +114,31 @@ fn path_pattern_orientation_follows_the_path() {
assert!((width_of_copy("identical") - 2.0).abs() < 1e-6);
assert!((width_of_copy("path") - 4.0).abs() < 1e-6);
}
/// Patterns of a blind hole drill every copy to the full depth from the face at the copy
/// (QA s30): n copies remove n times one hole.
#[test]
fn patterns_of_a_blind_hole_drill_every_copy() {
let one = {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 15}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
v0 - volume(&mut s)
};
let patterned = |p: (&str, Value)| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 15}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
run(&mut s, p.0, p.1);
v0 - volume(&mut s)
};
let r = patterned(("solid.pattern.rectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "15 mm"})));
assert!((r - 3.0 * one).abs() < 1e-3 * one, "{r} vs 3 × {one}");
let c = patterned((
"solid.pattern.circular",
json!({"features": ["Hole1"], "axis": {"origin": [25, 12, 0], "dir": [0, 0, 1]}, "count": 2, "angle": 360}),
));
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
}
+50 -50
View File
@@ -12,26 +12,26 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionBossCommand", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
CommandSpec::new("plastic.boss", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
"position: [x,y,z] on a face; direction? (default: out of the face); diameter, height; hole_diameter?, hole_depth? (default: to the face); \
draft? (angle), fillet? (root radius); ribs? (count), rib_thickness?, rib_length? (out from the wall), rib_offset? (below the top); body?",
),
CommandSpec::new("FusionLipCommand", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
CommandSpec::new("plastic.lip", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
"face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?",
),
CommandSpec::new("FusionSnapFitCommand", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params(
CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params(
"position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; body?",
),
CommandSpec::new("FusionRestCommand", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
CommandSpec::new("plastic.rest", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
"position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \
height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?",
),
CommandSpec::new("FusionManagePlasticRuleCommand", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("plastic_rule").params(
CommandSpec::new("plastic.manage_rules", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("plastic_rule").params(
"name (library or new rule; a new one starts from `from`, default ABS (1.5mm)); from?; material?; thickness?, nominal_radius?, clearance?, knife_edge?, \
reveal_height?, thickness_variation?, draft?, max_thickness?, min_thickness?, min_draft? (expressions; `Thickness` is the rule's); active?: bool (rule for bodies without one); \
delete?: bool (a design rule; a library rule goes back to the library values)",
),
CommandSpec::new("FusionAssignPlasticRuleCommand", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params(
CommandSpec::new("plastic.assign_rule", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params(
"bodies: [names]; rule: name (empty: no rule) — plastic features on these bodies take its draft and clearance as defaults",
),
CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"),
@@ -53,7 +53,7 @@ fn rule_json(s: &Session, r: &PlasticRule) -> Value {
}
fn manage_rules(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionManagePlasticRuleCommand";
let cmd = "plastic.manage_rules";
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
if bool_(p, "delete").unwrap_or(false) {
let doc = s.doc_mut();
@@ -116,7 +116,7 @@ fn manage_rules(s: &mut Session, p: &Value) -> Result<Value> {
}
fn assign_rule(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionAssignPlasticRuleCommand";
let cmd = "plastic.assign_rule";
let bodies = string_list(p, "bodies");
if bodies.is_empty() || bodies.len() > 10_000 {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -147,7 +147,7 @@ fn list_rules(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn rest(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionRestCommand";
let cmd = "plastic.rest";
let (position, direction) = placed(s, p, cmd)?;
let along = match p.get("along") {
Some(v) => Some(
@@ -199,7 +199,7 @@ fn placed(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
}
fn boss(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionBossCommand";
let cmd = "plastic.boss";
let (position, direction) = placed(s, p, cmd)?;
let kind = FeatureKind::Boss {
position,
@@ -220,7 +220,7 @@ fn boss(s: &mut Session, p: &Value) -> Result<Value> {
}
fn lip(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionLipCommand";
let cmd = "plastic.lip";
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the rim face"))?;
let groove = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("lip") => false,
@@ -245,7 +245,7 @@ fn lip(s: &mut Session, p: &Value) -> Result<Value> {
}
fn snap_fit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSnapFitCommand";
let cmd = "plastic.snap_fit";
let (position, direction) = placed(s, p, cmd)?;
let hook = p.get("hook").and_then(vec3).and_then(|h| h.normalized()).ok_or_else(|| bad(cmd, "`hook` must be the catch's direction [x, y, z]"))?;
if hook.cross(direction).len() < 1e-6 {
@@ -281,7 +281,7 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -291,17 +291,17 @@ mod tests {
#[test]
fn boss_with_hole_fillet_and_ribs() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3, "body_name": "Plate"}));
run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 4}));
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3, "body_name": "Plate"}));
run(&mut s, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 4}));
let want = 10800.0 + PI * 25.0 * 12.0 - PI * 4.0 * 12.0;
assert!(rel(volume(&mut s), want) < 1e-3, "{} {want}", volume(&mut s));
assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 1);
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 1);
// Ribs add t·l·h each.
let mut t = Session::default();
run(&mut t, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(&mut t, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(
&mut t,
"FusionBossCommand",
"plastic.boss",
json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6, "rib_offset": 2}),
);
let ribs = 4.0 * 1.5 * 6.0 * 10.0;
@@ -309,52 +309,52 @@ mod tests {
let v = volume(&mut t) - 10800.0 - PI * 25.0 * 12.0;
assert!(v > ribs && v < ribs * 1.05, "{v} {ribs}");
let mut u = Session::default();
run(&mut u, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(&mut u, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "fillet": 2, "draft": 1}));
run(&mut u, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(&mut u, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "fillet": 2, "draft": 1}));
assert!(volume(&mut u) > 10800.0 + PI * 25.0 * 10.0);
assert!(u.execute("FusionBossCommand", &json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 20})).is_err());
assert!(u.execute("plastic.boss", &json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 20})).is_err());
}
#[test]
fn lip_and_groove_on_a_shelled_box() {
let shelled = || {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
s
};
let mut s = shelled();
let v0 = volume(&mut s);
run(&mut s, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
// Inside band 1 wide along the inner edge (76 x 46): (76+2)(46+2) − 76·46 = 248, 2 high.
assert!(rel(volume(&mut s) - v0, 248.0 * 2.0) < 1e-6, "{}", volume(&mut s) - v0);
let mut g = shelled();
run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
run(&mut g, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
// Outside band along the outer edge (80 x 50): 80·50 − 78·48 = 256, 2 deep.
assert!(rel(v0 - volume(&mut g), 256.0 * 2.0) < 1e-6, "{}", v0 - volume(&mut g));
assert!(g.execute("FusionLipCommand", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
assert!(g.execute("plastic.lip", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
}
#[test]
fn snap_fit_arm_and_catch() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 40, "height": 4}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 4}));
let v0 = volume(&mut s);
run(
&mut s,
"FusionSnapFitCommand",
"plastic.snap_fit",
json!({"position": [20, 20, 4], "hook": [1, 0, 0], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3}),
);
let want = 1.5 * 15.0 * 6.0 + 0.5 * 1.0 * 3.0 * 6.0;
assert!(rel(volume(&mut s) - v0, want) < 1e-6, "{}", volume(&mut s) - v0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap_or(0.0) - 40.0).abs() < 1e-9);
assert!(s.execute("FusionSnapFitCommand", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
assert!(s.execute("plastic.snap_fit", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
}
fn plate() -> (Session, f64) {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
let v0 = volume(&mut s);
(s, v0)
}
@@ -363,27 +363,27 @@ mod tests {
fn rest_pads() {
// Rectangle 20 x 10, 4 high: 800.
let (mut s, v0) = plate();
run(&mut s, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "along": [1, 0, 0], "height": 4}));
run(&mut s, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "along": [1, 0, 0], "height": 4}));
assert!(rel(volume(&mut s) - v0, 800.0) < 1e-6, "{}", volume(&mut s) - v0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 7.0).abs() < 1e-9);
// Round, diameter 10: π·25·4.
let (mut r, v0) = plate();
run(&mut r, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "height": 4}));
run(&mut r, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "height": 4}));
assert!(rel(volume(&mut r) - v0, PI * 25.0 * 4.0) < 1e-3, "{}", volume(&mut r) - v0);
// Hollow, wall 1: (20·10 − 18·8)·4 = 224.
let (mut h, v0) = plate();
run(&mut h, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "thickness": 1}));
run(&mut h, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "thickness": 1}));
assert!(rel(volume(&mut h) - v0, 224.0) < 1e-6, "{}", volume(&mut h) - v0);
// Drafted 2°: the section shrinks by 2·s·tan 2° each way at height s.
let (mut d, v0) = plate();
run(&mut d, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "draft": "2 deg"}));
run(&mut d, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "draft": "2 deg"}));
let a = 2f64.to_radians().tan();
let want = 800.0 - a * 30.0 * 16.0 + 4.0 / 3.0 * a * a * 64.0;
assert!(rel(volume(&mut d) - v0, want) < 1e-6, "{} {want}", volume(&mut d) - v0);
// A wall thicker than half the width, or a draft that closes the top, is refused.
assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 4, "thickness": 2})).is_err());
assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 40, "draft": 10})).is_err());
assert!(d.execute("plastic.rest", &json!({"position": [10, 10, 3], "width": 4, "height": 4, "thickness": 2})).is_err());
assert!(d.execute("plastic.rest", &json!({"position": [10, 10, 3], "width": 4, "height": 40, "draft": 10})).is_err());
}
#[test]
@@ -395,31 +395,31 @@ mod tests {
assert_eq!(list["rules"][0]["values"]["nominal_radius"], 0.75);
assert_eq!(list["rules"][3]["values"]["thickness"].as_f64().map(|t| (t - 2.54).abs() < 1e-9), Some(true));
// A new rule from ABS: Thickness-based values follow its thickness.
let r = run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "thickness": 3, "draft": "1.5 deg"}));
let r = run(&mut s, "plastic.manage_rules", json!({"name": "Thick ABS", "thickness": 3, "draft": "1.5 deg"}));
assert_eq!(r["rule"]["values"]["nominal_radius"], 1.5);
assert!(s.execute("FusionManagePlasticRuleCommand", &json!({"name": "Bad", "draft": "3 mm"})).is_err());
assert!(s.execute("plastic.manage_rules", &json!({"name": "Bad", "draft": "3 mm"})).is_err());
// Assigned to the plate: a boss without a draft takes the rule's 1.5°.
let body = run(&mut s, "MeasureCommand", json!({}))["bodies"][0]["name"].as_str().unwrap_or_default().to_string();
run(&mut s, "FusionAssignPlasticRuleCommand", json!({"bodies": [body], "rule": "Thick ABS"}));
run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12}));
let body = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["name"].as_str().unwrap_or_default().to_string();
run(&mut s, "plastic.assign_rule", json!({"bodies": [body], "rule": "Thick ABS"}));
run(&mut s, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12}));
let rt = 5.0 - 12.0 * 1.5f64.to_radians().tan();
let want = PI * 12.0 / 3.0 * (25.0 + 5.0 * rt + rt * rt);
assert!(rel(volume(&mut s) - v0, want) < 1e-3, "{} {want}", volume(&mut s) - v0);
// Editing the rule rebuilds the boss.
run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "draft": "0 deg"}));
run(&mut s, "plastic.manage_rules", json!({"name": "Thick ABS", "draft": "0 deg"}));
assert!(rel(volume(&mut s) - v0, PI * 25.0 * 12.0) < 1e-3, "{}", volume(&mut s) - v0);
assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": [body], "rule": "Nope"})).is_err());
assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err());
assert!(s.execute("plastic.assign_rule", &json!({"bodies": [body], "rule": "Nope"})).is_err());
assert!(s.execute("plastic.assign_rule", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err());
}
#[test]
fn groove_takes_the_rule_clearance() {
let mut g = Session::default();
run(&mut g, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30}));
run(&mut g, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut g, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut g, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
let v0 = volume(&mut g);
run(&mut g, "FusionManagePlasticRuleCommand", json!({"name": "ABS (1.5mm)", "active": true}));
run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
run(&mut g, "plastic.manage_rules", json!({"name": "ABS (1.5mm)", "active": true}));
run(&mut g, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
// Width and depth grow by the 0.1 clearance: (80·50 − 77.8·47.8)·2.1.
let want = (4000.0 - 77.8 * 47.8) * 2.1;
assert!(rel(v0 - volume(&mut g), want) < 1e-6, "{} {want}", v0 - volume(&mut g));
+40 -13
View File
@@ -8,34 +8,48 @@ use super::CommandSpec;
use crate::params::{bad, bool_, expr, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("FusionHalfSectionViewCommand", "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 = "FusionHalfSectionViewCommand";
let cmd = "inspect.section";
if bool_(p, "clear").unwrap_or(false) {
s.section = None;
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,
}
}
+15 -15
View File
@@ -13,31 +13,31 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionSheetMetalFlangeCommand", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs)",
),
CommandSpec::new("FusionSheetMetalHemFlangeCommand", "Hem", hem)
CommandSpec::new("sheet.hem", "Hem", hem)
.at("SHEET METAL", "CREATE")
.icon("hem")
.params("edges: [[x,y,z] on sheet edges]; length (to the outer face); gap? (inner radius, default: rule); flip?; body?"),
CommandSpec::new("SheetMetalFoldCmd", "Fold", fold).at("SHEET METAL", "CREATE").icon("fold").params(
CommandSpec::new("sheet.fold", "Fold", fold).at("SHEET METAL", "CREATE").icon("fold").params(
"line: sketch + curve (a sketch line on the sheet's base face; followed when the sketch changes) or points: [[x,y,z], [x,y,z]]; \
angle? (default 90 deg); radius? (default: rule); position?: centerline|start|end|mould (where the line sits in the bend, default centerline); \
fixed?: [x,y,z] on the side that stays (default: the larger side); flip? (fold the other way); body?",
),
CommandSpec::new("FusionSheetmetalUnfoldCommand", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"),
CommandSpec::new("sheet.unfold", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"),
CommandSpec::new("sheet.refold", "Refold", refold).at("SHEET METAL", "MODIFY").icon("refold").params("body? (default: the last unfolded sheet)"),
CommandSpec::new("ConvertToSheetMetalCmd", "Convert to Sheet Metal", convert)
CommandSpec::new("sheet.convert", "Convert to Sheet Metal", convert)
.at("SHEET METAL", "CREATE")
.icon("convert_sheet")
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
CommandSpec::new("FusionSheetMetalRulesCommand", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("FusionSheetMetalFlatPatternCmd", "Create Flat Pattern", flat_pattern)
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
.noundo()
@@ -60,13 +60,13 @@ fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
match str_(p, "rule") {
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (FusionSheetMetalRulesCommand makes one)"))),
Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (sheet.manage_rules makes one)"))),
None => Ok(None),
}
}
fn flange(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSheetMetalFlangeCommand";
let cmd = "sheet.flange";
let ty = match str_(p, "type") {
Some(t) => t.to_ascii_lowercase(),
None if p.get("edges").is_some() => "edge".into(),
@@ -123,7 +123,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
}
fn hem(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSheetMetalHemFlangeCommand";
let cmd = "sheet.hem";
let edges = points(p, "edges", cmd)?;
let length = req_expr(cmd, p, "length")?;
check_expr(s, &length, Kind::Length, cmd, "length")?;
@@ -139,7 +139,7 @@ fn hem(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fold(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SheetMetalFoldCmd";
let cmd = "sheet.fold";
let (sketch, curve, a, b) = match p.get("points") {
Some(v) => {
let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::<Vec<Vec3>>()).unwrap_or_default();
@@ -189,7 +189,7 @@ fn refold(s: &mut Session, p: &Value) -> Result<Value> {
}
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConvertToSheetMetalCmd";
let cmd = "sheet.convert";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
@@ -200,7 +200,7 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSheetMetalRulesCommand";
let cmd = "sheet.manage_rules";
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
if bool_(p, "delete").unwrap_or(false) {
let doc = s.doc_mut();
@@ -289,7 +289,7 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
}
fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
let sh = sheet_of(s, p, "FusionSheetMetalFlatPatternCmd")?;
let sh = sheet_of(s, p, "sheet.flat_pattern")?;
flat_json(&sh)
}
@@ -369,7 +369,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let sh = sheet_of(s, p, cmd)?;
let text = flat_dxf(&sh)?;
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "bends": sh.bends().len()}));
}
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
+107 -61
View File
@@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -20,15 +20,15 @@ fn rel(a: f64, b: f64) -> f64 {
}
fn flat(s: &mut Session) -> Value {
run(s, "FusionSheetMetalFlatPatternCmd", json!({}))
run(s, "sheet.flat_pattern", json!({}))
}
/// A w x h base flange on XY.
fn base(s: &mut Session, w: f64, h: f64) {
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [w, h]}));
run(s, "SketchStop", json!({}));
run(s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Base"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"sketch": "Base"}));
}
#[test]
@@ -36,70 +36,70 @@ fn base_and_edge_flange_follow_the_k_factor() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
assert!(rel(volume(&mut s), 15000.0) < 1e-9);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
// Oracle 68: flat = 60 − 5 + (π/2)(2.5 + 0.44·2.5) + 20 − 5.
let want = 60.0 - 5.0 + FRAC_PI_2 * (2.5 + 0.44 * 2.5) + 15.0;
let f = flat(&mut s);
assert!((f["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6, "{f}");
assert_eq!(f["bends"][0]["direction"], "up");
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["total"]["faces"], 14, "{m}");
// The angle is a parameter: 45° (oracle 70 uses height 15).
let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": names[0], "expression": "15"}));
run(&mut s, "ChangeParameterCommand", json!({"name": names[1], "expression": "45 deg"}));
run(&mut s, "parameters.change", json!({"name": names[0], "expression": "15"}));
run(&mut s, "parameters.change", json!({"name": names[1], "expression": "45 deg"}));
assert!(rel(volume(&mut s), 18450.777) < 1e-4, "{}", volume(&mut s));
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - 73.685298).abs() < 1e-5);
// DXF: outline and an up bend line.
let d = run(&mut s, "sheet.export_dxf", json!({}));
let text = d["dxf"].as_str().unwrap_or_default();
assert!(text.contains("BEND_UP") && text.contains("OUTLINE"), "{text}");
assert!(s.execute("FusionSheetMetalFlangeCommand", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge");
assert!(s.execute("sheet.flange", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge");
}
#[test]
fn rules_are_parameters() {
let mut s = Session::default();
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "1.5 mm"}));
run(&mut s, "FusionSheetMetalRulesCommand", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"}));
run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "1.5 mm"}));
run(&mut s, "sheet.manage_rules", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"}));
base(&mut s, 80.0, 50.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20}));
run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20}));
// Oracle 71: 50 − 9 + 2·(π/2)(3 + 0.495) + 2·15.5 = 82.980.
let want = 50.0 - 2.0 * 4.5 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 1.5) + 2.0 * 15.5;
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
assert!(rel(volume(&mut s), 10053.717) < 1e-4, "{}", volume(&mut s));
// Thicker: everything follows.
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "2 mm"}));
run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "2 mm"}));
let want = 50.0 - 2.0 * 5.0 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 2.0) + 2.0 * 15.0;
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
let l = run(&mut s, "sheet.rules", json!({}));
assert_eq!(l["active"], "Custom", "{l}");
assert!(s.execute("FusionSheetMetalRulesCommand", &json!({"name": "Bad", "k_factor": 3})).is_err());
assert!(s.execute("sheet.manage_rules", &json!({"name": "Bad", "k_factor": 3})).is_err());
}
#[test]
fn contour_flange_hem_and_tray() {
// Oracle 72.
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
assert!(rel(volume(&mut s), 12097.622) < 1e-4, "{}", volume(&mut s));
assert!((flat(&mut s)["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 96.309734).abs() < 1e-5);
// Oracle 73: flange, then a flat hem of 8.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalHemFlangeCommand", json!({"edges": [[50, 0, 20]], "length": 8}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "sheet.hem", json!({"edges": [[50, 0, 20]], "length": 8}));
assert!(rel(volume(&mut s), 21334.723) < 1e-4, "{}", volume(&mut s));
let b = flat(&mut s)["bends"].clone();
assert!(b.as_array().is_some_and(|a| a.iter().any(|x| (x["angle_deg"].as_f64().unwrap_or(0.0) - 180.0).abs() < 1e-9)), "{b}");
// A tray: four flanges at once stop at each other's bends.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25}));
let f = flat(&mut s);
// Oracle 69: 100 − 10 + 2·5.655 + 2·20 = 141.310 across.
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - (90.0 + 2.0 * FRAC_PI_2 * 3.6 + 40.0)).abs() < 1e-6, "{f}");
@@ -117,15 +117,15 @@ fn unfold_cut_refold_and_convert() {
// Oracle 74: two flanges, unfold, a slot across the bend, refold.
let mut s = Session::default();
base(&mut s, 80.0, 50.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20}));
run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20}));
let folded = volume(&mut s);
run(&mut s, "FusionSheetmetalUnfoldCommand", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
run(&mut s, "sheet.unfold", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 2.5).abs() < 1e-6, "{m}");
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [35, -12], "p1": [45, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [35, -12], "p1": [45, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "sheet.refold", json!({}));
let v = volume(&mut s);
assert!(v < folded - 300.0, "{v} {folded}");
@@ -133,24 +133,24 @@ fn unfold_cut_refold_and_convert() {
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
// Convert a plate.
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"}));
run(&mut s, "ConvertToSheetMetalCmd", json!({"body": "Plate", "face": [50, 30, 2.5]}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"}));
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2.5]}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
let mut t = Session::default();
run(&mut t, "PrimitiveCylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("ConvertToSheetMetalCmd", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
run(&mut t, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("sheet.convert", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
}
/// A sketch line across a 100 x 60 plate's top face at x = 40.
fn fold_line(s: &mut Session) {
run(s, "SketchCreate", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
run(s, "DrawPolyline", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]}));
run(s, "SketchStop", json!({}));
run(s, "sketch.create", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
run(s, "sketch.line", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]}));
run(s, "sketch.finish", json!({}));
}
fn bbox(s: &mut Session) -> Value {
run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"].clone()
run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone()
}
#[test]
@@ -160,7 +160,7 @@ fn fold_takes_the_bend_out_of_the_flat_sheet() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
fold_line(&mut s);
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
// The flat pattern keeps its size; the bend's centre line is the sketch line.
let f = flat(&mut s);
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-6, "{f}");
@@ -189,7 +189,7 @@ fn fold_positions_fixed_side_and_flip() {
for (k, v) in p.as_object().into_iter().flatten() {
q[k] = v.clone();
}
run(&mut s, "SheetMetalFoldCmd", q);
run(&mut s, "sheet.fold", q);
bbox(&mut s)
};
let x0 = |b: &Value| b["min"][0].as_f64().unwrap_or(f64::NAN);
@@ -215,10 +215,10 @@ fn fold_carries_flanges_on_the_moving_side() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// A flange on the left edge, then a fold between it and the rest.
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[0, 30, 2.5]], "height": 20}));
run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2.5]], "height": 20}));
let v0 = volume(&mut s);
fold_line(&mut s);
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]}));
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]}));
// The flange rode along: it is above the plate now, nothing left at z < 0.
let b = bbox(&mut s);
assert!(b["min"][2].as_f64().unwrap_or(-1.0) > -1e-6, "{b}");
@@ -229,9 +229,9 @@ fn fold_carries_flanges_on_the_moving_side() {
// Errors: off the sheet, through the flange's bend.
let mut e = Session::default();
base(&mut e, 100.0, 60.0);
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend");
assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err());
assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err());
assert!(e.execute("sheet.fold", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend");
}
#[test]
@@ -240,12 +240,12 @@ fn fold_on_a_flange_panel() {
let ba = FRAC_PI_2 * (r + k * t);
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 30}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 30}));
let flat0 = flat(&mut s)["flat_size_mm"].clone();
let v0 = volume(&mut s);
// The flange stands up at the plate's y = 0 edge: its outer face is the body's min y.
let y = bbox(&mut s)["min"][1].as_f64().unwrap_or(f64::NAN);
run(&mut s, "SheetMetalFoldCmd", json!({"points": [[10, y, 18], [90, y, 18]]}));
run(&mut s, "sheet.fold", json!({"points": [[10, y, 18], [90, y, 18]]}));
let f = flat(&mut s);
for i in 0..2 {
assert!(
@@ -262,7 +262,7 @@ fn fold_on_a_flange_panel() {
let b = bbox(&mut s);
assert!(b["max"][2].as_f64().unwrap_or(99.0) < 25.0, "{b}");
// Folding across the flange's own bend is refused.
assert!(s.execute("SheetMetalFoldCmd", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
assert!(s.execute("sheet.fold", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
}
#[test]
@@ -270,31 +270,77 @@ fn fold_through_cut_outs() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// A slot across the fold line (edges along and across the bend).
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [30, 20], "p1": [50, 40]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 20], "p1": [50, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
let mut plain = Session::default();
base(&mut plain, 100.0, 60.0);
fold_line(&mut plain);
run(&mut plain, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut plain, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
fold_line(&mut s);
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
let f = flat(&mut s);
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
let (v, vp) = (volume(&mut s), volume(&mut plain));
// The slot takes 20 x 20 of the flat sheet; across the bend it removes the bend's share.
assert!(v < vp - 900.0 && v > vp - 1100.0, "{v} {vp}");
run(&mut s, "FusionSheetmetalUnfoldCommand", json!({}));
run(&mut s, "sheet.unfold", json!({}));
assert!((volume(&mut s) - (15000.0 - 1000.0)).abs() < 1e-3, "{}", volume(&mut s));
// A round hole across the bend is not supported yet, and says so.
let mut c = Session::default();
base(&mut c, 100.0, 60.0);
run(&mut c, "SketchCreate", json!({"plane": "XY", "name": "Hole"}));
run(&mut c, "CircleCenterRadius", json!({"center": [40, 30], "radius": 5}));
run(&mut c, "SketchStop", json!({}));
run(&mut c, "Extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut c, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
run(&mut c, "sketch.circle.center", json!({"center": [40, 30], "radius": 5}));
run(&mut c, "sketch.finish", json!({}));
run(&mut c, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
fold_line(&mut c);
let e = c.execute("SheetMetalFoldCmd", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
let e = c.execute("sheet.fold", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
}
#[test]
fn flange_edges_are_named_and_follow_the_base_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 100}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sheet.flange", json!({"edges": [[100, 30, 2.5]], "height": 20, "name": "Side"}));
let side = s.doc.find_feature("Side").cloned().unwrap();
assert_eq!(side.edge_names, vec!["base:l2:top".to_string()]);
let v0 = volume(&mut s);
// Narrower base: the flange stays on the right edge (now at x = 40).
let name = d["param"].as_str().or_else(|| d["name"].as_str()).map(str::to_string).unwrap_or_else(|| "d1".into());
run(&mut s, "parameters.change", json!({"name": name, "expression": "40"}));
let b = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone();
let w = b["max"][0].as_f64().unwrap_or(0.0) - b["min"][0].as_f64().unwrap_or(0.0);
assert!((w - 40.0).abs() < 1e-3 && b["max"][2].as_f64().unwrap_or(0.0) > 19.0, "{b}");
assert!(volume(&mut s) < v0);
let id = side.id;
assert_eq!(s.model.result(id).and_then(|r| r.warning.clone()), None);
// A hem on the flange's far edge is named after the flange.
let top = b["max"][2].as_f64().unwrap_or(0.0);
let x = b["max"][0].as_f64().unwrap_or(0.0);
run(&mut s, "sheet.hem", json!({"edges": [[x, 30, top]], "length": 6, "name": "H"}));
let h = s.doc.find_feature("H").cloned().unwrap();
assert!(h.edge_names[0].starts_with(&format!("F{id}.0:far")), "{:?}", h.edge_names);
}
/// With no fixed side, the larger side stays whichever side of the line it is on (QA s22).
#[test]
fn fold_keeps_the_larger_side_either_way() {
for (w, line, moving_right) in [(60.0, 40.0, true), (100.0, 40.0, false)] {
let mut s = Session::default();
base(&mut s, w, 40.0);
run(&mut s, "sheet.fold", json!({"points": [[line, 0, 2.5], [line, 40, 2.5]]}));
let b = bbox(&mut s);
let (x0, x1) = (b["min"][0].as_f64().unwrap_or(0.0), b["max"][0].as_f64().unwrap_or(0.0));
if moving_right {
assert!(x0.abs() < 1e-6 && x1 < line + 5.0, "the left (larger) part stays: {b}");
} else {
assert!((x1 - w).abs() < 1e-6 && x0 > line - 5.0, "the right (larger) part stays: {b}");
}
}
}
+231 -110
View File
@@ -1,7 +1,7 @@
//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions.
use serde_json::{Value, json};
use solvecraft_doc::{FeatureKind, PlaneRef};
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
@@ -9,137 +9,144 @@ 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("SketchCreate", "Create Sketch", create_sketch)
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
.icon("sketch")
.params("plane: XY|XZ|YZ | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
CommandSpec::new("SketchActivate", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("SketchStop", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("DrawPolyline", "Line", draw_line)
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line)
.at("SKETCH", "CREATE")
.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)"),
CommandSpec::new("ShapeRectangleTwoPoint", "2-Point Rectangle", rect_two_point)
.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?"),
CommandSpec::new("ShapeRectangleThreePoint", "3-Point Rectangle", rect_three_point)
.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"),
CommandSpec::new("ShapeRectangleCenter", "Center Rectangle", rect_center)
.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"),
CommandSpec::new("CircleCenterRadius", "Center Diameter Circle", circle_center)
.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"),
CommandSpec::new("CircleTwoPoint", "2-Point Circle", circle_two)
.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"),
CommandSpec::new("CircleThreePoint", "3-Point Circle", circle_three)
.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("ArcThreePoint", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"),
CommandSpec::new("ArcCenterTwoPoint", "Center Point Arc", arc_center)
.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"),
CommandSpec::new("ShapePolygonInscribed", "Inscribed Polygon", polygon_inscribed)
.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"),
CommandSpec::new("ShapePolygonCircumscribed", "Circumscribed Polygon", polygon_circumscribed)
.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"),
CommandSpec::new("ShapePolygonEdge", "Edge Polygon", polygon_edge)
.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"),
CommandSpec::new("ShapeSlotCenterToCenter", "Center to Center Slot", slot_c2c)
.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"),
CommandSpec::new("ShapeSlotOverall", "Overall Slot", slot_overall)
.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("DrawPoint", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"),
CommandSpec::new("SketchDimension", "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)",
.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); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
),
CommandSpec::new("ConstraintHorizontalVertical", "Horizontal/Vertical", c_horizontal_vertical)
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
.icon("c_hv")
.enabled(in_sketch)
.params("line: id | points: [a, b]; mode?: horizontal|vertical"),
CommandSpec::new("ConstraintCoincident", "Coincident", c_coincident)
CommandSpec::new("sketch.constraint.coincident", "Coincident", c_coincident)
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.params("a: point, b: point or curve"),
CommandSpec::new("ConstraintTangent", "Tangent", c_tangent)
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
.enabled(in_sketch)
.params("a, b: curves"),
CommandSpec::new("ConstraintEqual", "Equal", c_equal)
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
.at("SKETCH", "CONSTRAINTS")
.icon("c_equal")
.enabled(in_sketch)
.params("a, b: lines or circles/arcs"),
CommandSpec::new("ConstraintParallel", "Parallel", c_parallel)
CommandSpec::new("sketch.constraint.parallel", "Parallel", c_parallel)
.at("SKETCH", "CONSTRAINTS")
.icon("c_parallel")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("ConstraintPerpendicular", "Perpendicular", c_perpendicular)
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
.at("SKETCH", "CONSTRAINTS")
.icon("c_perpendicular")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("ConstraintFix", "Fix/UnFix", c_fix)
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
.at("SKETCH", "CONSTRAINTS")
.icon("c_fix")
.enabled(in_sketch)
.params("entity: point or curve | entities: [points and curves]; fixed?: bool (default: fix them all unless all are fixed, then unfix)"),
CommandSpec::new("ConstraintMidPoint", "MidPoint", c_midpoint)
CommandSpec::new("sketch.constraint.midpoint", "MidPoint", c_midpoint)
.at("SKETCH", "CONSTRAINTS")
.icon("c_midpoint")
.enabled(in_sketch)
.params("point, line"),
CommandSpec::new("ConstraintConcentric", "Concentric", c_concentric)
CommandSpec::new("sketch.constraint.concentric", "Concentric", c_concentric)
.at("SKETCH", "CONSTRAINTS")
.icon("c_concentric")
.enabled(in_sketch)
.params("a, b: circles/arcs"),
CommandSpec::new("ConstraintCollinear", "Collinear", c_collinear)
CommandSpec::new("sketch.constraint.collinear", "Collinear", c_collinear)
.at("SKETCH", "CONSTRAINTS")
.icon("c_collinear")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("ConstraintSymmetry", "Symmetry", c_symmetry)
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
.at("SKETCH", "CONSTRAINTS")
.icon("c_symmetry")
.enabled(in_sketch)
@@ -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 {
@@ -311,8 +359,10 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
}
Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) {
// A planar face of a body at this point.
let st = s.model.state();
// A planar face of a body at this point, as the active component sees the model
// (another component's face where it is placed).
let model = s.model.state();
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
let mut found = None;
let mut face_name = None;
for b in &st.bodies {
@@ -358,16 +408,19 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
let s1 = (b - a).cross(p - a).dot(n);
let s2 = (c - b).cross(p - b).dot(n);
let s3 = (a - c).cross(p - c).dot(n);
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0)
|| (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
// Exact curved boundaries can sit just outside their tessellated chords.
|| p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a)) <= tol * 0.1
})
}
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchCreate";
let cmd = "sketch.create";
let plane = plane_ref(s, p, cmd)?;
// Validate the plane resolves.
let (vals, _) = s.doc.param_values();
s.doc.resolve_plane(&vals, &plane, 0)?;
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
let name = str_(p, "name");
let mut kind = FeatureKind::Sketch { plane, sketch: Sketch::new() };
super::component::to_active_frame(s, &mut kind);
@@ -393,11 +446,11 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let key = match p.get("sketch") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad("SketchActivate", "`sketch` must be an id or a name")),
_ => return Err(bad("sketch.edit", "`sketch` must be an id or a name")),
};
let f = s.doc.find_feature(&key).ok_or_else(|| bad("SketchActivate", format!("no sketch `{key}`")))?;
let f = s.doc.find_feature(&key).ok_or_else(|| bad("sketch.edit", format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad("SketchActivate", format!("`{key}` is not a sketch")));
return Err(bad("sketch.edit", format!("`{key}` is not a sketch")));
}
let (id, name) = (f.id, f.name.clone());
s.active_sketch = Some(id);
@@ -416,7 +469,7 @@ fn finish_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
// Drawing
fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "DrawPolyline";
let cmd = "sketch.line";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let list = p.get("points").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`points` must be a list"))?;
if list.len() < 2 || list.len() > 10_000 {
@@ -425,7 +478,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,48 +579,79 @@ 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 = "ShapeRectangleTwoPoint";
let cmd = "sketch.rectangle.two_point";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
if (a.x - b.x).abs() < 1e-9 || (a.y - b.y).abs() < 1e-9 {
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> {
let cmd = "ShapeRectangleCenter";
let cmd = "sketch.rectangle.center";
let (c, k) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "corner")?);
let h = Vec2::new((k.x - c.x).abs(), (k.y - c.y).abs());
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)?;
if let (Some(p0), Some(p2)) = (pts.first(), pts.get(2)) {
// Both diagonals (construction), the centre at their crossing. Only the first carries
// the midpoint constraint: diagonals of a parallelogram bisect each other, so a second
// one would be redundant (over-constrained).
if let (Some(p0), Some(p1), Some(p2), Some(p3)) = (pts.first(), pts.get(1), pts.get(2), pts.get(3)) {
let diag = sk.add_line_pts(*p0, *p2, None)?;
if let Some(cu) = sk.curves.get_mut(diag) {
cu.construction = true;
let diag2 = sk.add_line_pts(*p1, *p3, None)?;
for d in [diag, diag2] {
if let Some(cu) = sk.curves.get_mut(d) {
cu.construction = true;
}
}
let cp = sk.add_point(c, None)?;
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> {
let cmd = "ShapeRectangleThreePoint";
let cmd = "sketch.rectangle.three_point";
let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?);
let d = (b - a).normalized().ok_or_else(|| bad(cmd, "p0 and p1 must differ"))?;
let n = d.perp();
@@ -575,7 +659,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[..] {
@@ -599,11 +683,11 @@ pub(super) fn radius_arg(p: &Value, cmd: &str) -> Result<f64> {
}
fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleCenterRadius";
let cmd = "sketch.circle.center";
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);
@@ -613,13 +697,13 @@ fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
}
fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleTwoPoint";
let cmd = "sketch.circle.two_point";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let r = a.dist(b) / 2.0;
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()))
})?;
@@ -641,10 +725,10 @@ pub(super) fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> {
}
fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleThreePoint";
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()))
})?;
@@ -652,10 +736,10 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ArcThreePoint";
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"))?;
@@ -674,11 +758,11 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ArcCenterTwoPoint";
let cmd = "sketch.arc.center_point";
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 {
@@ -715,7 +799,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();
@@ -753,7 +837,7 @@ fn sides(p: &Value, cmd: &str) -> Result<usize> {
}
fn polygon_inscribed(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapePolygonInscribed";
let cmd = "sketch.polygon.inscribed";
let c = req_vec2(cmd, p, "center")?;
let r = radius_arg(p, cmd)?;
let n = sides(p, cmd)?;
@@ -762,7 +846,7 @@ fn polygon_inscribed(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon_circumscribed(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapePolygonCircumscribed";
let cmd = "sketch.polygon.circumscribed";
let c = req_vec2(cmd, p, "center")?;
let r = radius_arg(p, cmd)?;
let n = sides(p, cmd)?;
@@ -771,7 +855,7 @@ fn polygon_circumscribed(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon_edge(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapePolygonEdge";
let cmd = "sketch.polygon.edge";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let n = sides(p, cmd)?;
let e = a.dist(b);
@@ -791,7 +875,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]);
@@ -810,14 +894,14 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w:
}
fn slot_c2c(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeSlotCenterToCenter";
let cmd = "sketch.slot.center_to_center";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?;
slot(s, p, cmd, a, b, w)
}
fn slot_overall(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeSlotOverall";
let cmd = "sketch.slot.overall";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?;
let d = (b - a).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?;
@@ -828,14 +912,15 @@ fn slot_overall(s: &mut Session, p: &Value) -> Result<Value> {
}
fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "DrawPoint";
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}))
}
// ---------------------------------------------------------------------------------------------
@@ -1012,7 +1097,7 @@ pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -
}
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintHorizontalVertical";
let cmd = "sketch.constraint.horizontal_vertical";
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
constrain(s, p, cmd, |sk| {
if let Some(pts) = p.get("points").and_then(Value::as_array) {
@@ -1042,7 +1127,16 @@ fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
}
fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintCoincident";
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"))?;
@@ -1064,31 +1158,39 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
}
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintTangent", |sk| two_curves(sk, p, "ConstraintTangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintEqual", |sk| two_curves(sk, p, "ConstraintEqual").map(|(a, b)| ConstraintKind::Equal { a, b }))
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
}
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintParallel", |sk| two_curves(sk, p, "ConstraintParallel").map(|(a, b)| ConstraintKind::Parallel { a, b }))
constrain(s, p, "sketch.constraint.parallel", |sk| {
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
})
}
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintPerpendicular", |sk| two_curves(sk, p, "ConstraintPerpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b }))
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
})
}
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintConcentric", |sk| two_curves(sk, p, "ConstraintConcentric").map(|(a, b)| ConstraintKind::Concentric { a, b }))
constrain(s, p, "sketch.constraint.concentric", |sk| {
two_curves(sk, p, "sketch.constraint.concentric").map(|(a, b)| ConstraintKind::Concentric { a, b })
})
}
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintCollinear", |sk| two_curves(sk, p, "ConstraintCollinear").map(|(a, b)| ConstraintKind::Collinear { a, b }))
constrain(s, p, "sketch.constraint.collinear", |sk| {
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
})
}
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintMidPoint";
let cmd = "sketch.constraint.midpoint";
constrain(s, p, cmd, |sk| {
Ok(ConstraintKind::Midpoint { p: point_ref(sk, p.get("point"), cmd, "point")?, l: curve_ref(sk, p.get("line"), cmd, "line")? })
})
}
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintSymmetry";
let cmd = "sketch.constraint.symmetry";
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?;
@@ -1135,7 +1237,7 @@ fn round_center(sk: &Sketch, c: usize) -> Option<usize> {
}
fn c_fix(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintFix";
let cmd = "sketch.constraint.fix";
let want = bool_(p, "fixed");
let mut refs = string_list(p, "entities");
if let Some(e) = str_(p, "entity") {
@@ -1201,7 +1303,7 @@ pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> {
}
fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchDimension";
let cmd = "sketch.dimension";
let ents = string_list(p, "entities");
if ents.is_empty() || ents.len() > 2 {
return Err(bad(cmd, "`entities` must list one or two sketch entities"));
@@ -1235,7 +1337,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
};
let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }));
let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. }));
let (k, cur): (ConstraintKind, f64) = match (e0, e1) {
// The other reading of an angle between two lines (see below).
let mut alt: Option<(ConstraintKind, f64)> = None;
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
((None, Some(c)), None) if is_line(c) => {
let (a, b) = line_pts(sk, c).unwrap_or_default();
match ty.as_str() {
@@ -1312,7 +1416,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
ang = -ang;
std::mem::swap(&mut ka, &mut kb);
}
(ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang)
// Two lines make two angles, θ and 180° − θ, whichever way they were
// drawn: the acute one unless the value asks for the other.
let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang);
let supplement =
(ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang);
let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) };
alt = Some(obtuse);
acute
}
}
}
@@ -1329,7 +1440,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
c.driven = true;
}
before = None;
return Ok((String::new(), name, cur));
return Ok((String::new(), name, measured(is_angle, cur)));
}
let (unit, default_expr) =
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
@@ -1340,20 +1451,30 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(err) = errs.get(&pname) {
return Err(bad(cmd, format!("value: {err}")));
}
let _ = vals;
// An angle between two lines dimensions whichever of its two readings is nearer the value.
if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok()))
&& (want - ca).abs() < (want - cur).abs()
{
(k, cur) = (ka, ca);
}
let name = k.name();
let id = sk.add_constraint(k, Some(pname.clone()))?;
place_text(sk, &id, text_at);
Ok((pname, name, cur))
Ok((pname, name, measured(is_angle, cur)))
})?;
if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info}));
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
}
if reject_redundant(before, &info, cmd).is_err() {
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
}
/// A dimension's measured value as reported, in the unit its expression is written in.
fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") }
}
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
+16 -13
View File
@@ -9,11 +9,11 @@ use crate::params::{bad, bool_, num, str_, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionAddCanvasCommand", "Canvas", add_canvas)
CommandSpec::new("canvas.insert", "Canvas", add_canvas)
.at("SKETCH", "INSERT")
.icon("canvas")
.params("path | data (base64 PNG or JPEG); plane?: XY|XZ|YZ|{face: [x,y,z]}|… (default XY); center?: [x,y] on the plane, or at?: [x,y,z]; width?: mm (default 100); angle?: deg; opacity?: 0…1 (default 0.5); flip?; name?"),
CommandSpec::new("FusionAddEditDecalCommand", "Decal", add_decal)
CommandSpec::new("canvas.decal", "Decal", add_decal)
.at("SKETCH", "INSERT")
.icon("decal")
.params("path | data; face: [x,y,z] on a planar face (the image is centred there); width?: mm; angle?: deg; opacity?: (default 1)"),
@@ -31,11 +31,11 @@ fn image_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
base64_decode(d).ok_or_else(|| bad(cmd, "`data` is not base64"))?
} else {
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `data`"))?;
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() as usize > MAX_CANVAS_BYTES {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
}
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
};
if b.len() > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
@@ -48,7 +48,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
let (format, pixels) = image_info(&bytes).ok_or_else(|| bad(cmd, "only PNG and JPEG images can be inserted"))?;
let plane = super::sketch::plane_ref(s, p, cmd)?;
let (vals, _) = s.doc.param_values();
let pl = s.doc.resolve_plane(&vals, &plane, 0)?;
let pl = s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
// A decal sits where the face was picked.
let at = p.get("at").or_else(|| p.get("plane").and_then(|v| v.get("face"))).and_then(crate::params::vec3).map(|q| pl.to_local(q));
let center = p.get("center").and_then(vec2).or(at).unwrap_or(Vec2::ZERO);
@@ -57,6 +57,8 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
return Err(bad(cmd, "`width` must be positive"));
}
let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0);
// It belongs to the active component (its plane is in that frame) and moves with it.
let component = s.active_component;
let doc = s.doc_mut();
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
let base = if decal { "Decal" } else { "Canvas" };
@@ -74,16 +76,17 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
opacity,
flip: bool_(p, "flip").unwrap_or(false),
visible: true,
component,
});
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
}
fn add_canvas(s: &mut Session, p: &Value) -> Result<Value> {
make(s, p, "FusionAddCanvasCommand", false)
make(s, p, "canvas.insert", false)
}
fn add_decal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionAddEditDecalCommand";
let cmd = "canvas.decal";
let face = p.get("face").cloned().ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on a planar face"))?;
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
@@ -174,7 +177,7 @@ mod tests {
#[test]
fn canvas_insert_edit_calibrate_save() {
let mut s = Session::default();
let r = s.execute("FusionAddCanvasCommand", &json!({"data": png(400, 200), "plane": "XZ", "width": 80})).unwrap();
let r = s.execute("canvas.insert", &json!({"data": png(400, 200), "plane": "XZ", "width": 80})).unwrap();
let id = r["canvas"].as_u64().unwrap();
let c = s.doc.canvases[0].clone();
assert_eq!(c.pixels, [400, 200]);
@@ -188,12 +191,12 @@ mod tests {
assert_eq!(back.canvases, s.doc.canvases);
s.execute("canvas.delete", &json!({"canvas": id})).unwrap();
assert!(s.doc.canvases.is_empty());
s.execute("UndoCommand", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.canvases.len(), 1);
assert!(s.execute("FusionAddCanvasCommand", &json!({"data": "bm9wZQ=="})).is_err());
assert!(s.execute("canvas.insert", &json!({"data": "bm9wZQ=="})).is_err());
// A decal on the top face of a box.
s.execute("PrimitiveBox", &json!({"length": 30, "width": 30, "height": 10})).unwrap();
let r = s.execute("FusionAddEditDecalCommand", &json!({"data": png(10, 10), "face": [15, 15, 10], "width": 20})).unwrap();
s.execute("solid.box", &json!({"length": 30, "width": 30, "height": 10})).unwrap();
let r = s.execute("canvas.decal", &json!({"data": png(10, 10), "face": [15, 15, 10], "width": 20})).unwrap();
assert!(r["canvas"].is_u64());
}
}
+12 -12
View File
@@ -11,32 +11,32 @@ use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("ConstraintSmooth", "Curvature", c_smooth)
CommandSpec::new("sketch.constraint.curvature", "Curvature", c_smooth)
.at("SKETCH", "CONSTRAINTS")
.icon("c_smooth")
.enabled(in_sketch)
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
CommandSpec::new("SketchPolygonConstraintCmd", "Polygon", c_polygon)
CommandSpec::new("sketch.constraint.polygon", "Polygon", c_polygon)
.at("SKETCH", "CONSTRAINTS")
.icon("c_polygon")
.enabled(in_sketch)
.params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"),
CommandSpec::new("SketchAutoConstraintAndDimCmd", "AutoConstrain", auto_constrain)
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", auto_constrain)
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.params("tolerance?: mm (default 0.01), angle_tolerance?: deg (default 1), dimensions?: bool (default true): infer constraints, then dimension until fully constrained"),
CommandSpec::new("SketchAutoConstraintAndDimFromDatumCmd", "AutoConstrain from datum", auto_constrain_datum)
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from datum", auto_constrain_datum)
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.params("datum: point ref (default origin); like AutoConstrain with positions measured from the datum"),
CommandSpec::new("SketchAutoConstrainAndFinish", "Finish with AutoConstrain", auto_constrain_finish)
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", auto_constrain_finish)
.at("SKETCH", "FINISH SKETCH")
.icon("finish")
.enabled(in_sketch)
.params("like AutoConstrain, then Finish Sketch"),
CommandSpec::new("SketchConstrainer", "SketchConstrainer", smart_constrain)
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", smart_constrain)
.at("SKETCH", "CREATE")
.icon("constrainer")
.enabled(in_sketch)
@@ -55,7 +55,7 @@ pub static COMMANDS: &[CommandSpec] = &[
];
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintSmooth";
let cmd = "sketch.constraint.curvature";
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let a = curve(sk, p.get("a"), cmd)?;
@@ -73,7 +73,7 @@ fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
}
fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchPolygonConstraintCmd";
let cmd = "sketch.constraint.polygon";
let ids = string_list(p, "lines");
if ids.len() < 3 || ids.len() > 200 {
return Err(bad(cmd, "`lines` must list 3 to 200 lines of a closed chain"));
@@ -258,13 +258,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}
fn auto_constrain(s: &mut Session, p: &Value) -> Result<Value> {
auto(s, p, "SketchAutoConstraintAndDimCmd")
auto(s, p, "sketch.auto_constrain")
}
fn auto_constrain_datum(s: &mut Session, p: &Value) -> Result<Value> {
auto(s, p, "SketchAutoConstraintAndDimFromDatumCmd")
auto(s, p, "sketch.auto_constrain_from_datum")
}
fn auto_constrain_finish(s: &mut Session, p: &Value) -> Result<Value> {
let r = auto(s, p, "SketchAutoConstrainAndFinish")?;
let r = auto(s, p, "sketch.finish_auto_constrain")?;
s.active_sketch = None;
s.revision += 1;
Ok(r)
@@ -343,7 +343,7 @@ fn guess(sk: &Sketch, ents: &[String], cmd: &str) -> Result<ConstraintKind> {
}
fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchConstrainer";
let cmd = "sketch.constrainer";
let ents = string_list(p, "entities");
let mut before = None;
let mut name = "";
+105 -42
View File
@@ -16,7 +16,7 @@ fn ids(v: &Value) -> Vec<String> {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
@@ -27,21 +27,21 @@ fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
#[test]
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
// Already horizontal (inferred).
let e = s.execute("ConstraintHorizontalVertical", &json!({"line": l})).unwrap_err().to_string();
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l})).unwrap_err().to_string();
assert!(e.contains("over-constrain"), "{e}");
run(&mut s, "ConstraintCoincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "SketchDimension", json!({"entities": [l], "value": 10}));
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
// A second length on it over-constrains: refused, unless driven.
assert!(s.execute("SketchDimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "SketchDimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert!(s.execute("sketch.dimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "sketch.dimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert_eq!(d["driven"], true);
// The driven dimension follows the driving one.
let pname = si["constraints"].as_array().unwrap().iter().find_map(|c| c["param"].as_str().map(str::to_string)).unwrap();
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "25 mm"}));
run(&mut s, "parameters.change", json!({"name": pname, "expression": "25 mm"}));
let g = run(&mut s, "sketch.glyphs", json!({}));
let driven = g["glyphs"].as_array().unwrap().iter().find(|x| x["driven"] == true).unwrap().clone();
assert!((driven["value"].as_f64().unwrap() - 25.0).abs() < 1e-7, "{driven}");
@@ -53,9 +53,10 @@ fn curvature_polygon_arc_length_linear_diameter() {
let mut s = new_sketch();
// A horizontal line ending where a control-point spline starts: G2 keeps the spline's
// start straight (zero curvature) and tangent.
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
let sp = ids(&run(&mut s, "DrawCVMSpline3D", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
run(&mut s, "ConstraintSmooth", json!({"a": l, "b": sp}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
let sp =
ids(&run(&mut s, "sketch.spline.control_point", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
run(&mut s, "sketch.constraint.curvature", json!({"a": l, "b": sp}));
let sk = sketch(&s);
let pts: Vec<Vec2> = sk.curves[sk.curve_index(&sp).unwrap()].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
// Tangent: first leg along +x; zero curvature: the second leg too (collinear first three).
@@ -63,16 +64,16 @@ fn curvature_polygon_arc_length_linear_diameter() {
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3, "{pts:?}");
// Polygon constraint on a rough quadrilateral: becomes a square-ish regular polygon.
let q = run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
let q = run(&mut s, "sketch.line", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
let ql = ids(&q["curves"]);
run(&mut s, "SketchPolygonConstraintCmd", json!({"lines": ql}));
run(&mut s, "sketch.constraint.polygon", json!({"lines": ql}));
let sk = sketch(&s);
let lens: Vec<f64> = ql.iter().map(|i| sk.polyline(sk.curve_index(i).unwrap())).map(|p| p[0].dist(p[p.len() - 1])).collect();
assert!(lens.iter().all(|x| (x - lens[0]).abs() < 1e-6), "{lens:?}");
// Arc length.
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
let a = ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
let sk = sketch(&s);
let ai = sk.curve_index(&a).unwrap();
let len = match sk.segs(ai)[0] {
@@ -82,10 +83,10 @@ fn curvature_polygon_arc_length_linear_diameter() {
assert!((len - 20.0).abs() < 1e-6, "{len}");
// Linear diameter about a centerline.
let cl = ids(&run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
let cl = ids(&run(&mut s, "sketch.line", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
run(&mut s, "sketch.centerline", json!({"curves": [cl]}));
run(&mut s, "DrawPoint", json!({"point": [207, 10], "id": "rim"}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["rim", cl], "value": 30}));
run(&mut s, "sketch.point", json!({"point": [207, 10], "id": "rim"}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["rim", cl], "value": 30}));
assert_eq!(d["type"], "LinearDiameterDimension", "{d}");
let sk = sketch(&s);
let rim = sk.point(sk.point_index("rim").unwrap()).unwrap();
@@ -98,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, "ShapeRectangleTwoPoint", 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);
@@ -116,9 +117,9 @@ fn glyphs_and_snaps() {
fn auto_constrain_fully_constrains_a_rough_sketch() {
let mut s = new_sketch();
// A slightly crooked closed rectangle and a circle inside.
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0.004], [40.003, 30], [0, 30.002]], "closed": true}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 15], "radius": 5}));
let r = run(&mut s, "SketchAutoConstraintAndDimCmd", json!({}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0.004], [40.003, 30], [0, 30.002]], "closed": true}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 15], "radius": 5}));
let r = run(&mut s, "sketch.auto_constrain", json!({}));
assert_eq!(r["dof"], 0, "{r}");
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["fully_constrained"], true, "{si}");
@@ -127,9 +128,9 @@ fn auto_constrain_fully_constrains_a_rough_sketch() {
assert!(names.iter().any(|n| n == "DiameterDimension"), "{names:?}");
// From a datum, and finishing.
let mut s = new_sketch();
run(&mut s, "DrawPoint", json!({"point": [5, 5], "id": "d"}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 10]]}));
let r = run(&mut s, "SketchAutoConstrainAndFinish", json!({"datum": "d"}));
run(&mut s, "sketch.point", json!({"point": [5, 5], "id": "d"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [20, 10]]}));
let r = run(&mut s, "sketch.finish_auto_constrain", json!({"datum": "d"}));
assert!(s.active_sketch.is_none());
assert!(r["added"].as_array().unwrap().len() >= 3, "{r}");
}
@@ -137,11 +138,11 @@ fn auto_constrain_fully_constrains_a_rough_sketch() {
#[test]
fn drawing_infers_tangent_and_perpendicular() {
let mut s = new_sketch();
let l1 = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 10]], "infer": true}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("{l1}.end"), [20, 0]], "infer": true}));
let l1 = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 10]], "infer": true}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("{l1}.end"), [20, 0]], "infer": true}));
assert_eq!(r["constraints"].as_array().unwrap().len(), 1, "{r}");
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("{a}.end"), [30, 10]], "infer": true}));
let a = ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("{a}.end"), [30, 10]], "infer": true}));
let si = run(&mut s, "sketch.inspect", json!({}));
let names: Vec<&str> = si["constraints"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
assert!(names.contains(&"Perpendicular") && names.contains(&"Tangent"), "{names:?} {r}");
@@ -150,18 +151,18 @@ fn drawing_infers_tangent_and_perpendicular() {
#[test]
fn smart_constrain_and_driven_toggle() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "SketchConstrainer", json!({"entities": [a]}));
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a]}));
assert_eq!(r["type"], "Horizontal");
let r = run(&mut s, "SketchConstrainer", json!({"entities": [a, b]}));
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a, b]}));
assert_eq!(r["type"], "Parallel");
let c1 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone();
let c2 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50.3, 0.2], "radius": 3}))["curves"])[0].clone();
let r = run(&mut s, "SketchConstrainer", json!({"entities": [c1, c2]}));
let c1 = ids(&run(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone();
let c2 = ids(&run(&mut s, "sketch.circle.center", json!({"center": [50.3, 0.2], "radius": 3}))["curves"])[0].clone();
let r = run(&mut s, "sketch.constrainer", json!({"entities": [c1, c2]}));
assert_eq!(r["type"], "Concentric");
// Driven toggle.
let d = run(&mut s, "SketchDimension", json!({"entities": [c1], "value": 10}));
let d = run(&mut s, "sketch.dimension", json!({"entities": [c1], "value": 10}));
let g = run(&mut s, "sketch.glyphs", json!({}));
let did = g["glyphs"].as_array().unwrap().iter().find(|x| x["param"] == d["param"]).unwrap()["id"].as_str().unwrap().to_string();
let r = run(&mut s, "sketch.toggle_driven", json!({"constraint": did}));
@@ -174,8 +175,8 @@ fn smart_constrain_and_driven_toggle() {
#[test]
fn drawing_from_a_line_midpoint_keeps_it_there() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("mid:{l}"), [10, 15]]}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("mid:{l}"), [10, 15]]}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["name"] == "MidPoint"), "{si}");
// Lengthen the first line: the second one's start follows the middle.
@@ -191,12 +192,74 @@ fn drawing_from_a_line_midpoint_keeps_it_there() {
#[test]
fn drawing_onto_a_curve_keeps_the_point_on_it() {
let mut s = new_sketch();
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("on:{c}:7.2,7.1"), [30, 30]]}));
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("on:{c}:7.2,7.1"), [30, 30]]}));
let n = ids(&r["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [c], "value": 16}));
run(&mut s, "sketch.dimension", json!({"entities": [c], "value": 16}));
let sk = sketch(&s);
let st = sk.resolve_point(&format!("{n}.start")).and_then(|i| sk.point(i)).unwrap();
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}");
}
/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined
/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart
/// while the lines meet at 30°.
fn spout() -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50}));
run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068}));
run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020}));
assert_eq!(d["sketch"]["dof"], 1, "{d}");
s
}
/// The angle (degrees, 0..=90) between two lines of the active sketch.
fn line_angle(s: &Session, a: &str, b: &str) -> f64 {
let sk = sketch(s);
let dir = |l: &str| {
let c = sk.curve_index(l).unwrap();
let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap();
q - p
};
let (u, v) = (dir(a), dir(b));
let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
t.min(180.0 - t)
}
#[test]
fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() {
// The riser and the outlet are not adjacent and meet at 30°; their directions are 150°
// apart. Asking for 30° used to swing the outlet round and was refused as over-constraining.
for ents in [["l1", "l3"], ["l3", "l1"]] {
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert_eq!(d["unit"], "deg", "{d}");
assert!((d["measured"].as_f64().unwrap() - 30.3).abs() < 0.1, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
}
// The other reading still works, and the adjacent top line measures 60° the same way.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
assert!((d["measured"].as_f64().unwrap() - 59.74).abs() < 0.01, "{d}");
// Without a value the acute angle is kept as it is.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
let e = d["expression"].as_str().unwrap();
assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::<f64>().unwrap() - 30.3).abs() < 0.1, "{d}");
// Lengths report millimetres.
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
assert_eq!(d["unit"], "mm", "{d}");
}
@@ -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));
}
+28 -28
View File
@@ -12,65 +12,65 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[
CommandSpec::new("SketchMidpointLine", "Midpoint Line", midpoint_line)
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
.icon("line_mid")
.enabled(in_sketch)
.params("mid: [x,y] or point ref, end: [x,y]; construction?"),
CommandSpec::new("ArcTangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(
"start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)",
),
CommandSpec::new("CircleTanTanRadius", "2-Tangent Circle", circle_tan_tan)
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
.at("SKETCH", "CREATE")
.icon("circle_tt")
.enabled(in_sketch)
.params("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)"),
CommandSpec::new("CircleThreeTangent", "3-Tangent Circle", circle_three_tan)
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
.at("SKETCH", "CREATE")
.icon("circle_ttt")
.enabled(in_sketch)
.params("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)"),
CommandSpec::new("ShapeSlotCenterPoint", "Center Point Slot", slot_center_point)
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("center: slot centre, end: one arc centre, width"),
CommandSpec::new("ShapeArcSlotThreePoint", "Three Point Arc Slot", arc_slot_three)
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params("start, through, end: points on the centre arc, width"),
CommandSpec::new("ShapeArcSlotCenterTwoPoint", "Center Point Arc Slot", arc_slot_center)
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width"),
CommandSpec::new("CircleElipse", "Ellipse", ellipse)
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse)
.at("SKETCH", "CREATE")
.icon("ellipse")
.enabled(in_sketch)
.params("center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius"),
CommandSpec::new("DrawSpline", "Fit Point Spline", spline_fit)
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
.at("SKETCH", "CREATE")
.icon("spline")
.enabled(in_sketch)
.params("points: [[x,y] or point refs…] (2…500): the spline passes through them"),
CommandSpec::new("DrawCVMSpline3D", "Control Point Spline", spline_cv3)
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), cubic"),
CommandSpec::new("DrawCVMSpline5D", "Control Point Spline (Degree 5)", spline_cv5)
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), degree 5"),
CommandSpec::new("ConicCurveCmd", "Conic Curve", conic)
CommandSpec::new("sketch.conic", "Conic Curve", conic)
.at("SKETCH", "CREATE")
.icon("conic")
.enabled(in_sketch)
.params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"),
CommandSpec::new("BlendG1CurveSketchCmd", "Blend Curve", blend)
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
@@ -78,7 +78,7 @@ pub static COMMANDS: &[CommandSpec] =
CommandSpec::new("sketch.centerline", "Centerline", centerline)
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("MTextCmd", "Text", text)
CommandSpec::new("sketch.text", "Text", text)
.at("SKETCH", "CREATE")
.icon("text")
.enabled(in_sketch)
@@ -107,7 +107,7 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "MTextCmd";
let cmd = "sketch.text";
if p.get("at").is_none() {
return Err(bad(cmd, "`at` must be the baseline start [x, y]"));
}
@@ -153,7 +153,7 @@ fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> {
}
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "BlendG1CurveSketchCmd";
let cmd = "sketch.blend_curve";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?;
let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?;
@@ -200,7 +200,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleElipse";
let cmd = "sketch.ellipse";
let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?);
let u = (m - c).normalized().ok_or_else(|| bad(cmd, "the major axis end must differ from the centre"))?;
let a = c.dist(m);
@@ -263,17 +263,17 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
}
fn spline_fit(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "DrawSpline", false, 3)
spline(s, p, "sketch.spline.fit_point", false, 3)
}
fn spline_cv3(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "DrawCVMSpline3D", true, 3)
spline(s, p, "sketch.spline.control_point", true, 3)
}
fn spline_cv5(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "DrawCVMSpline5D", true, 5)
spline(s, p, "sketch.spline.control_point_5", true, 5)
}
fn conic(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConicCurveCmd";
let cmd = "sketch.conic";
let rho = num(p, "rho").unwrap_or(0.5);
if !(rho > 1e-6 && rho < 1.0 - 1e-6) {
return Err(bad(cmd, "`rho` must be between 0 and 1"));
@@ -300,7 +300,7 @@ fn conic(s: &mut Session, p: &Value) -> Result<Value> {
}
fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchMidpointLine";
let cmd = "sketch.line.midpoint";
let end = req_vec2(cmd, p, "end")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let m = req_parg(sk, p, "mid", cmd)?;
@@ -369,7 +369,7 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option<Vec2> {
}
fn arc_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ArcTangent";
let cmd = "sketch.arc.tangent";
let end = req_vec2(cmd, p, "end")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let st = req_parg(sk, p, "start", cmd)?;
@@ -458,7 +458,7 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
}
fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleTanTanRadius";
let cmd = "sketch.circle.two_tangent";
let r = match (crate::params::expr(p, "radius"), num(p, "diameter")) {
(Some(r), _) => r,
(None, Some(d)) => format!("{}", d / 2.0),
@@ -468,7 +468,7 @@ fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
}
fn circle_three_tan(s: &mut Session, p: &Value) -> Result<Value> {
tangent_circle(s, p, "CircleThreeTangent", 3, None)
tangent_circle(s, p, "sketch.circle.three_tangent", 3, None)
}
fn width(p: &Value, cmd: &str) -> Result<f64> {
@@ -476,7 +476,7 @@ fn width(p: &Value, cmd: &str) -> Result<f64> {
}
fn slot_center_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeSlotCenterPoint";
let cmd = "sketch.slot.center_point";
let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
slot(s, p, cmd, c * 2.0 - e, e, w)
@@ -518,7 +518,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe
}
fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeArcSlotThreePoint";
let cmd = "sketch.slot.arc_three_point";
let (a, t, b) = (req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "through")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
let (o, r) = circumcircle(a, t, b).ok_or_else(|| bad(cmd, "the points are collinear"))?;
@@ -530,7 +530,7 @@ fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_slot_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeArcSlotCenterTwoPoint";
let cmd = "sketch.slot.arc_center";
let (o, a, b) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
let r = o.dist(a);
+33 -33
View File
@@ -44,53 +44,53 @@ fn random_sketch_editing_never_breaks() {
for seed in 1..=2u64 {
let mut r = Rng(0x9E37_79B9_7F4A_7C15 ^ seed);
let mut s = Session::default();
let _ = s.execute("PrimitiveBox", &json!({"length": 30, "width": 20, "height": 10}));
let _ = s.execute("SketchCreate", &json!({"plane": {"face": [15, 10, 10]}}));
let _ = s.execute("solid.box", &json!({"length": 30, "width": 20, "height": 10}));
let _ = s.execute("sketch.create", &json!({"plane": {"face": [15, 10, 10]}}));
for step in 0..120 {
let (curves, points, cons) = entities(&s);
let c = |r: &mut Rng| r.pick(&curves).cloned().unwrap_or_default();
let p = |r: &mut Rng| r.pick(&points).cloned().unwrap_or_default();
let (cmd, params) = match r.next() % 34 {
0 => ("DrawPolyline", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})),
1 => ("CircleCenterRadius", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})),
2 => ("ArcCenterTwoPoint", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})),
3 => ("ShapeRectangleTwoPoint", json!({"p0": r.pt(), "p1": r.pt()})),
4 => ("DrawSpline", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})),
5 => ("CircleElipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})),
6 => ("ConicCurveCmd", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})),
7 => ("TrimSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
8 => ("ExtendSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
9 => ("BreakSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
10 => ("FilletSketchCmd", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})),
11 => ("ChamferSketchEqualDistance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})),
12 => ("Offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})),
13 => ("MirrorSketchCommand", json!({"entities": [c(&mut r)], "line": c(&mut r)})),
14 => ("CircularSketchPatternCommand", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})),
15 => ("ConstraintCoincident", json!({"a": p(&mut r), "b": p(&mut r)})),
16 => ("ConstraintTangent", json!({"a": c(&mut r), "b": c(&mut r)})),
17 => ("ConstraintParallel", json!({"a": c(&mut r), "b": c(&mut r)})),
18 => ("ConstraintSmooth", json!({"a": c(&mut r), "b": c(&mut r)})),
19 => ("SketchDimension", json!({"entities": [c(&mut r)]})),
20 => ("SketchDimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})),
0 => ("sketch.line", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})),
1 => ("sketch.circle.center", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})),
2 => ("sketch.arc.center_point", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})),
3 => ("sketch.rectangle.two_point", json!({"p0": r.pt(), "p1": r.pt()})),
4 => ("sketch.spline.fit_point", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})),
5 => ("sketch.ellipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})),
6 => ("sketch.conic", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})),
7 => ("sketch.trim", json!({"curve": c(&mut r), "at": r.pt()})),
8 => ("sketch.extend", json!({"curve": c(&mut r), "at": r.pt()})),
9 => ("sketch.break", json!({"curve": c(&mut r), "at": r.pt()})),
10 => ("sketch.fillet", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})),
11 => ("sketch.chamfer.equal_distance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})),
12 => ("sketch.offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})),
13 => ("sketch.mirror", json!({"entities": [c(&mut r)], "line": c(&mut r)})),
14 => ("sketch.pattern.circular", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})),
15 => ("sketch.constraint.coincident", json!({"a": p(&mut r), "b": p(&mut r)})),
16 => ("sketch.constraint.tangent", json!({"a": c(&mut r), "b": c(&mut r)})),
17 => ("sketch.constraint.parallel", json!({"a": c(&mut r), "b": c(&mut r)})),
18 => ("sketch.constraint.curvature", json!({"a": c(&mut r), "b": c(&mut r)})),
19 => ("sketch.dimension", json!({"entities": [c(&mut r)]})),
20 => ("sketch.dimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})),
21 => ("sketch.delete", json!({"entities": [r.pick(&cons).cloned().unwrap_or_else(|| c(&mut r))]})),
22 => ("sketch.move_point", json!({"point": p(&mut r), "to": r.pt()})),
23 => ("ProjectNewCmd", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})),
23 => ("sketch.project", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})),
24 => ("sketch.break_link", json!({"entities": [c(&mut r)]})),
25 => ("SketchConstrainer", json!({"entities": [c(&mut r), c(&mut r)]})),
26 => ("SketchAutoConstraintAndDimCmd", json!({})),
27 => ("MTextCmd", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})),
25 => ("sketch.constrainer", json!({"entities": [c(&mut r), c(&mut r)]})),
26 => ("sketch.auto_constrain", json!({})),
27 => ("sketch.text", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})),
28 => ("sketch.toggle_driven", json!({"constraint": r.pick(&cons).cloned().unwrap_or_default()})),
29 => ("ArcTangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})),
30 => ("SketchScaleCmd", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})),
31 => ("UndoCommand", json!({})),
32 => ("ShapeArcSlotThreePoint", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})),
_ => ("RedoCommand", json!({})),
29 => ("sketch.arc.tangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})),
30 => ("sketch.scale", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})),
31 => ("edit.undo", json!({})),
32 => ("sketch.slot.arc_three_point", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})),
_ => ("edit.redo", json!({})),
};
if let Err(EngineError::Internal(id, msg)) = s.execute(cmd, &params) {
panic!("seed {seed} step {step}: {id} {params}: {msg}");
}
if s.active_sketch.is_none() {
let _ = s.execute("SketchCreate", &json!({"plane": "XY"}));
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
}
}
// The design round-trips.
+20 -20
View File
@@ -13,11 +13,11 @@ use crate::params::{bad, num, str_, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("ImportDxfFileCommand", "Insert DXF", insert_dxf)
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
.at("SKETCH", "INSERT")
.icon("dxf")
.params("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
CommandSpec::new("SketchImportSVG", "Insert SVG", insert_svg)
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
.at("SKETCH", "INSERT")
.icon("svg")
.params("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
@@ -34,11 +34,11 @@ fn source_text(p: &Value, cmd: &str) -> Result<String> {
return Ok(t.to_string());
}
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `text`"))?;
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() as usize > MAX_DRAWING_BYTES {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > MAX_DRAWING_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(String::from_utf8_lossy(&bytes).to_string())
}
@@ -123,7 +123,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if let Some(n) = str_(p, "name") {
cp["name"] = json!(n);
}
let r = (super::find_command("SketchCreate").ok_or_else(|| bad(cmd, "SketchCreate"))?.run)(s, &cp)?;
let r = (super::find_command("sketch.create").ok_or_else(|| bad(cmd, "sketch.create"))?.run)(s, &cp)?;
created = r.get("sketch").and_then(Value::as_u64);
}
let tol = num(p, "tolerance").filter(|t| t.is_finite() && *t > 0.0).unwrap_or(1e-4).clamp(1e-9, 10.0);
@@ -150,11 +150,11 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
}
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "ImportDxfFileCommand", false)
insert(s, p, "sketch.insert_dxf", false)
}
fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "SketchImportSVG", true)
insert(s, p, "sketch.insert_svg", true)
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
@@ -186,7 +186,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
}
let text = solvecraft_io::write_dxf(&geom);
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "entities": geom.len()}));
}
Ok(json!({"dxf": text, "entities": geom.len()}))
@@ -201,14 +201,14 @@ mod tests {
#[test]
fn dxf_and_svg_round_trip_into_sketches() {
let mut s = Session::default();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
s.execute("ShapeRectangleTwoPoint", &json!({"p0": [0, 0], "p1": [30, 20]})).unwrap();
s.execute("CircleCenterRadius", &json!({"center": [10, 10], "radius": 4})).unwrap();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [30, 20]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [10, 10], "radius": 4})).unwrap();
let out = s.execute("sketch.export_dxf", &json!({})).unwrap();
let dxf = out["dxf"].as_str().unwrap().to_string();
s.execute("SketchStop", &json!({})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
// Insert it again as a new sketch, offset: same profiles.
let r = s.execute("ImportDxfFileCommand", &json!({"text": dxf, "at": [100, 0]})).unwrap();
let r = s.execute("sketch.insert_dxf", &json!({"text": dxf, "at": [100, 0]})).unwrap();
assert_eq!(r["curves"], 5, "{r}");
let id = r["sketch"].as_u64().unwrap();
let ss = s.model.state().sketch(id).unwrap().clone();
@@ -219,19 +219,19 @@ mod tests {
// SVG with a path and text, into the active sketch.
let svg =
r#"<svg width="40mm" height="40mm" viewBox="0 0 40 40"><path d="M0 0 H40 V40 H0 Z"/><text x="5" y="20" font-size="10">Hi</text></svg>"#;
let r = s.execute("SketchImportSVG", &json!({"text": svg})).unwrap();
let r = s.execute("sketch.insert_svg", &json!({"text": svg})).unwrap();
assert_eq!(r["curves"], 4, "{r}");
assert_eq!(r["texts"], 1, "{r}");
assert!(s.execute("SketchImportSVG", &json!({"text": "<svg></svg>"})).is_err());
assert!(s.execute("ImportDxfFileCommand", &json!({"path": "/nonexistent.dxf"})).is_err());
s.execute("SketchStop", &json!({})).unwrap();
assert!(s.execute("sketch.insert_svg", &json!({"text": "<svg></svg>"})).is_err());
assert!(s.execute("sketch.insert_dxf", &json!({"path": "/nonexistent.dxf"})).is_err());
s.execute("sketch.finish", &json!({})).unwrap();
// Ends that miss by 0.00005 still close the square; with a tighter tolerance they don't.
let gappy = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n10\n21\n0\n0\nLINE\n10\n10.00005\n20\n0\n11\n10\n21\n10\n0\nLINE\n10\n10\n20\n10\n11\n0\n21\n10\n0\nLINE\n10\n0\n20\n10.00003\n11\n0\n21\n0.00004\n0\nENDSEC\n0\nEOF\n";
for (tol, n) in [(1e-4, 1), (1e-6, 0)] {
let r = s.execute("ImportDxfFileCommand", &json!({"text": gappy, "tolerance": tol})).unwrap();
let r = s.execute("sketch.insert_dxf", &json!({"text": gappy, "tolerance": tol})).unwrap();
let ss = s.model.state().sketch(r["sketch"].as_u64().unwrap()).unwrap().clone();
assert_eq!(ss.profiles.len(), n, "tolerance {tol}");
s.execute("SketchStop", &json!({})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
}
}
}
+28 -28
View File
@@ -9,22 +9,22 @@ use crate::params::{bad, num, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionCurvatureCombAnalysisCommand", "Curvature Comb Analysis", curvature_comb)
CommandSpec::new("inspect.curvature_comb", "Curvature Comb Analysis", curvature_comb)
.at("SKETCH", "INSPECT")
.icon("curvature_comb")
.noundo()
.params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges]; density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"),
CommandSpec::new("FusionMinimumRadiusAnalysisCommand", "Minimum Radius Analysis", minimum_radius)
CommandSpec::new("inspect.minimum_radius", "Minimum Radius Analysis", minimum_radius)
.at("SKETCH", "INSPECT")
.icon("min_radius")
.noundo()
.params("curves?: [sketch curve ids] (sketch?) | edges?: [[x,y,z]…] | faces?: [[x,y,z]…] (default: every face of every body); the smallest radius of curvature of each and where it is"),
CommandSpec::new("FusionIsoCurveAnalysisCommand", "Isocurve Analysis", isocurves)
CommandSpec::new("inspect.isocurve", "Isocurve Analysis", isocurves)
.at("SKETCH", "INSPECT")
.icon("iso_analysis")
.noundo()
.params("faces: [[x,y,z]…] points on faces; count?: curves each way (default 8): the faces' isoparametric curves"),
CommandSpec::new("FusionCenterOfMassCommand", "Center of Mass", center_of_mass)
CommandSpec::new("inspect.center_of_mass", "Center of Mass", center_of_mass)
.at("SKETCH", "INSPECT")
.icon("center_of_mass")
.noundo()
@@ -73,7 +73,7 @@ fn teeth_3d(pts: &[Vec3]) -> Vec<(Vec3, f64, Vec3)> {
}
fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCurvatureCombAnalysisCommand";
let cmd = "inspect.curvature_comb";
let density = num(p, "density").map(|d| d.clamp(2.0, 2000.0) as usize).unwrap_or(40);
let st = s.model.state();
let mut combs: Vec<(String, Vec<(Vec3, f64, Vec3)>)> = Vec::new();
@@ -210,7 +210,7 @@ fn face_min_radius(m: &solvecraft_geom::Mesh, face: u32) -> Option<(f64, Vec3)>
}
fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMinimumRadiusAnalysisCommand";
let cmd = "inspect.minimum_radius";
let st = s.model.state();
let mut items: Vec<(String, Option<(f64, Vec3)>)> = Vec::new();
let curves = string_list(p, "curves");
@@ -282,7 +282,7 @@ fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
}
fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionIsoCurveAnalysisCommand";
let cmd = "inspect.isocurve";
let count = num(p, "count").map(|c| c.clamp(1.0, 100.0) as usize).unwrap_or(8);
let faces = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
if faces.is_empty() {
@@ -302,7 +302,7 @@ fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
}
fn center_of_mass(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCenterOfMassCommand";
let cmd = "inspect.center_of_mass";
let names = string_list(p, "bodies");
let st = s.model.state();
let mut out = Vec::new();
@@ -332,10 +332,10 @@ mod tests {
#[test]
fn curvature_combs() {
let mut s = Session::default();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let c = s.execute("CircleCenterRadius", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
let l = s.execute("DrawPolyline", &json!({"points": [[0, 20], [10, 20]]})).unwrap()["curves"][0].clone();
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"curves": [c, l], "density": 20})).unwrap();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let c = s.execute("sketch.circle.center", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
let l = s.execute("sketch.line", &json!({"points": [[0, 20], [10, 20]]})).unwrap()["curves"][0].clone();
let r = s.execute("inspect.curvature_comb", &json!({"curves": [c, l], "density": 20})).unwrap();
let circle = &r["combs"][0];
assert!((circle["min_radius"].as_f64().unwrap() - 5.0).abs() < 1e-3, "{circle}");
assert!(r["combs"][1]["min_radius"].is_null(), "a line is straight");
@@ -343,39 +343,39 @@ mod tests {
let t = &circle["teeth"][3];
let (a, b) = ((t[0][0].as_f64().unwrap(), t[0][1].as_f64().unwrap()), (t[1][0].as_f64().unwrap(), t[1][1].as_f64().unwrap()));
assert!(b.0 * b.0 + b.1 * b.1 > a.0 * a.0 + a.1 * a.1);
s.execute("SketchStop", &json!({})).unwrap();
s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"edges": [[58, 0, 10]]})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
let r = s.execute("inspect.curvature_comb", &json!({"edges": [[58, 0, 10]]})).unwrap();
assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}");
assert!(s.execute("FusionCurvatureCombAnalysisCommand", &json!({})).is_err());
assert!(s.execute("inspect.curvature_comb", &json!({})).is_err());
}
#[test]
fn minimum_radius_and_center_of_mass() {
let mut s = Session::default();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let e = s.execute("CircleElipse", &json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})).unwrap()["curves"][0].clone();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let e = s.execute("sketch.ellipse", &json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})).unwrap()["curves"][0].clone();
// An ellipse's tightest bend is at the major axis ends: b²/a.
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"curves": [e]})).unwrap();
let r = s.execute("inspect.minimum_radius", &json!({"curves": [e]})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 1.6).abs() < 1e-3, "{r}");
assert!((r["at"][0].as_f64().unwrap().abs() - 10.0).abs() < 0.1, "{r}");
s.execute("SketchStop", &json!({})).unwrap();
s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
// The cylinder's side face: radius 8 (the mesh estimate is close).
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"faces": [[58, 0, 5]]})).unwrap();
let r = s.execute("inspect.minimum_radius", &json!({"faces": [[58, 0, 5]]})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}");
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({})).unwrap();
let r = s.execute("inspect.minimum_radius", &json!({})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}");
// Centre of mass: a box beside the cylinder.
s.execute("PrimitiveBox", &json!({"corner": [0, 0, 0], "length": 10, "width": 10, "height": 10})).unwrap();
let r = s.execute("FusionCenterOfMassCommand", &json!({})).unwrap();
s.execute("solid.box", &json!({"corner": [0, 0, 0], "length": 10, "width": 10, "height": 10})).unwrap();
let r = s.execute("inspect.center_of_mass", &json!({})).unwrap();
let (vb, vc) = (1000.0, std::f64::consts::PI * 640.0);
let want = (5.0 * vb + 50.0 * vc) / (vb + vc);
assert!((r["center"][0].as_f64().unwrap() - want).abs() < 0.1, "{r}");
assert!((r["center"][2].as_f64().unwrap() - 5.0).abs() < 0.05, "{r}");
assert!(s.execute("FusionCenterOfMassCommand", &json!({"bodies": ["nope"]})).is_err());
assert!(s.execute("inspect.center_of_mass", &json!({"bodies": ["nope"]})).is_err());
// Isocurves on the cylinder's side: rings around it and lines along it.
let r = s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap();
let r = s.execute("inspect.isocurve", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap();
let curves = r["curves"].as_array().unwrap();
let ring =
|c: &Value| c.as_array().unwrap().iter().all(|q| ((q[0].as_f64().unwrap() - 50.0).hypot(q[1].as_f64().unwrap()) - 8.0).abs() < 0.2);
@@ -386,6 +386,6 @@ mod tests {
z.iter().cloned().fold(f64::NEG_INFINITY, f64::max) - z.iter().cloned().fold(f64::INFINITY, f64::min) < 1e-6
};
assert!(curves.iter().filter(|c| flat(c)).count() >= 4, "rings at constant height");
assert!(s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": []})).is_err());
assert!(s.execute("inspect.isocurve", &json!({"faces": []})).is_err());
}
}
+116 -63
View File
@@ -13,49 +13,49 @@ use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FilletSketchCmd", "Fillet", fillet)
CommandSpec::new("sketch.fillet", "Fillet", fillet)
.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"),
CommandSpec::new("ChamferSketchEqualDistance", "Equal Distance Chamfer", chamfer_equal)
.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")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance"),
CommandSpec::new("ChamferSketchDistanceAngle", "Distance and Angle Chamfer", chamfer_dist_angle)
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance: along a, angle: deg from a"),
CommandSpec::new("ChamferSketchDistanceDistance", "Two Distance Chamfer", chamfer_two)
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance: along a, distance2: along b"),
CommandSpec::new("Offset", "Offset", offset)
CommandSpec::new("sketch.offset", "Offset", offset)
.at("SKETCH", "MODIFY")
.icon("offset")
.key("O")
.enabled(in_sketch)
.params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"),
CommandSpec::new("TrimSketchCmd", "Trim", trim)
CommandSpec::new("sketch.trim", "Trim", trim)
.at("SKETCH", "MODIFY")
.icon("trim")
.key("T")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)"),
CommandSpec::new("ExtendSketchCmd", "Extend", extend)
CommandSpec::new("sketch.extend", "Extend", extend)
.at("SKETCH", "MODIFY")
.icon("extend")
.enabled(in_sketch)
.params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"),
CommandSpec::new("BreakSketchCmd", "Break", break_cmd)
CommandSpec::new("sketch.break", "Break", break_cmd)
.at("SKETCH", "MODIFY")
.icon("break")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"),
CommandSpec::new("SketchScaleCmd", "Sketch Scale", scale)
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("scale")
.enabled(in_sketch)
@@ -63,17 +63,17 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
.enabled(in_sketch)
.params("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool"),
CommandSpec::new("MirrorSketchCommand", "Mirror", mirror)
CommandSpec::new("sketch.mirror", "Mirror", mirror)
.at("SKETCH", "CREATE")
.icon("sketch_mirror")
.enabled(in_sketch)
.params("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)"),
CommandSpec::new("CircularSketchPatternCommand", "Circular Pattern", pattern_circular)
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
.at("SKETCH", "CREATE")
.icon("sketch_circ_pattern")
.enabled(in_sketch)
.params("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)"),
CommandSpec::new("RectangularSketchPatternCommand", "Rectangular Pattern", pattern_rect)
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SKETCH", "CREATE")
.icon("sketch_rect_pattern")
.enabled(in_sketch)
@@ -253,7 +253,7 @@ fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "TrimSketchCmd";
let cmd = "sketch.trim";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
@@ -313,7 +313,7 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
}
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "BreakSketchCmd";
let cmd = "sketch.break";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = curve_arg(sk, p, cmd)?;
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
@@ -349,7 +349,7 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ExtendSketchCmd";
let cmd = "sketch.extend";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = curve_arg(sk, p, cmd)?;
@@ -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 } => {
@@ -476,50 +478,69 @@ 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 = "FilletSketchCmd";
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 !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) {
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.
@@ -610,20 +631,20 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
}
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ChamferSketchEqualDistance";
let cmd = "sketch.chamfer.equal_distance";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
chamfer(s, p, cmd, |_| Ok((d, d)))
}
fn chamfer_two(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ChamferSketchDistanceDistance";
let cmd = "sketch.chamfer.two_distance";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
let d2 = num(p, "distance2").ok_or_else(|| bad(cmd, "`distance2` must be a number"))?;
chamfer(s, p, cmd, |_| Ok((d, d2)))
}
fn chamfer_dist_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ChamferSketchDistanceAngle";
let cmd = "sketch.chamfer.distance_angle";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
let a = num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be a number (degrees)"))?.to_radians();
chamfer(s, p, cmd, |phi| {
@@ -766,7 +787,7 @@ fn piece_dist(p: &Piece, q: Vec2) -> f64 {
}
fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "Offset";
let cmd = "sketch.offset";
let ids = string_list(p, "curves");
if ids.is_empty() {
return Err(bad(cmd, "`curves` must list curves"));
@@ -792,6 +813,12 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
let ci = sk.add_circle(cc, nr, Some(c), None)?;
made.push(ci);
sk.offsets.push(solvecraft_sketch::OffsetChain {
sources: vec![(ids[0].clone(), true)],
outputs: ids_of(sk, &made),
distance: r - nr,
closed: true,
});
return Ok((ids_of(sk, &made), cons));
}
let pool: Vec<usize> = if use_chain {
@@ -847,6 +874,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] });
@@ -883,7 +922,13 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok((ids_of(sk, &made), cons))
let outputs = ids_of(sk, &made);
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
sk.refresh_offsets()?;
}
Ok((outputs, cons))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
}
@@ -988,7 +1033,7 @@ fn copy_through(
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "MirrorSketchCommand";
let cmd = "sketch.mirror";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
let lid = str_(p, "line").ok_or_else(|| bad(cmd, "`line` must be the mirror line"))?;
@@ -1043,7 +1088,7 @@ fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 {
}
fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircularSketchPatternCommand";
let cmd = "sketch.pattern.circular";
let n = count(p, "count", cmd)?;
let total = num(p, "angle").unwrap_or(360.0);
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
@@ -1066,7 +1111,7 @@ fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "RectangularSketchPatternCommand";
let cmd = "sketch.pattern.rectangular";
let n1 = count(p, "count", cmd)?;
let s1 = num(p, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be a number"))?;
let n2 = if p.get("count2").is_some() { count(p, "count2", cmd)? } else { 1 };
@@ -1134,7 +1179,7 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchScaleCmd";
let cmd = "sketch.scale";
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
@@ -1149,3 +1194,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;
+108 -105
View File
@@ -22,7 +22,7 @@ fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
@@ -41,10 +41,10 @@ fn close(a: f64, b: f64) -> bool {
fn trim_extend_break() {
let mut s = new_sketch();
// A cross: horizontal line crossed by a vertical one.
let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let v = ids(&run(&mut s, "DrawPolyline", json!({"points": [[10, -5], [10, 5]]}))["curves"])[0].clone();
let h = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let v = ids(&run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]}))["curves"])[0].clone();
// Trim the right half of the horizontal line.
run(&mut s, "TrimSketchCmd", json!({"curve": h, "at": [15, 0]}));
run(&mut s, "sketch.trim", json!({"curve": h, "at": [15, 0]}));
let sk = sketch(&s);
let sh = sk.shape(sk.curve_index(&h).unwrap()).unwrap();
assert!(
@@ -52,32 +52,32 @@ fn trim_extend_break() {
"{sh:?}"
);
// Trim the lower half of the vertical line: the original id survives on the upper piece.
run(&mut s, "TrimSketchCmd", json!({"curve": v, "at": [10, -3]}));
run(&mut s, "sketch.trim", json!({"curve": v, "at": [10, -3]}));
let sk = sketch(&s);
assert_eq!(sk.curves.len(), 2);
// Extend: a short line reaching toward the vertical one.
let e = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 3], [4, 3]]}))["curves"])[0].clone();
run(&mut s, "ExtendSketchCmd", json!({"curve": e, "at": [4, 3]}));
let e = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 3], [4, 3]]}))["curves"])[0].clone();
run(&mut s, "sketch.extend", json!({"curve": e, "at": [4, 3]}));
let sk = sketch(&s);
let end = sk.resolve_point(&format!("{e}.end")).and_then(|i| sk.point(i)).unwrap();
assert!(end.dist(Vec2::new(10.0, 3.0)) < 1e-7, "{end:?}");
// Break the horizontal line where... nothing crosses it inside: error.
assert!(s.execute("BreakSketchCmd", &json!({"curve": h, "at": [5, 0]})).is_err());
assert!(s.execute("sketch.break", &json!({"curve": h, "at": [5, 0]})).is_err());
// Break the vertical at the extended line: two pieces.
let r = run(&mut s, "BreakSketchCmd", json!({"curve": v, "at": [10, 1]}));
let r = run(&mut s, "sketch.break", json!({"curve": v, "at": [10, 1]}));
assert_eq!(ids(&r["curves"]).len(), 2, "{r}");
// A lone curve trims away entirely.
let lone = ids(&run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 50]]}))["curves"])[0].clone();
run(&mut s, "TrimSketchCmd", json!({"curve": lone, "at": [55, 50]}));
let lone = ids(&run(&mut s, "sketch.line", json!({"points": [[50, 50], [60, 50]]}))["curves"])[0].clone();
run(&mut s, "sketch.trim", json!({"curve": lone, "at": [55, 50]}));
assert!(sketch(&s).curve_index(&lone).is_none());
}
#[test]
fn trim_circle_makes_an_arc_and_a_profile() {
let mut s = new_sketch();
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [[-20, 0], [20, 0]]}));
run(&mut s, "TrimSketchCmd", json!({"curve": c, "at": [0, -10]}));
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}));
run(&mut s, "sketch.trim", json!({"curve": c, "at": [0, -10]}));
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. }));
@@ -90,65 +90,65 @@ fn trim_circle_makes_an_arc_and_a_profile() {
#[test]
fn fillet_and_chamfers() {
let mut s = new_sketch();
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
// Pin the rectangle so a dimension change only moves the fillet.
run(&mut s, "ConstraintFix", json!({"entity": l[2]}));
run(&mut s, "ConstraintFix", json!({"entity": l[3]}));
let f = run(&mut s, "FilletSketchCmd", json!({"a": l[0], "b": l[1], "radius": 5}));
run(&mut s, "sketch.constraint.fix", json!({"entity": l[2]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": l[3]}));
let f = run(&mut s, "sketch.fillet", json!({"a": l[0], "b": l[1], "radius": 5}));
assert!(f["param"].is_string());
let a = profiles(&s);
assert!(close(a[0], 1200.0 - (25.0 - std::f64::consts::PI * 25.0 / 4.0)), "{a:?}");
// The fillet radius is a dimension: change it.
let pname = f["param"].as_str().unwrap().to_string();
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "8 mm"}));
run(&mut s, "parameters.change", json!({"name": pname, "expression": "8 mm"}));
let a = profiles(&s);
assert!(close(a[0], 1200.0 - (64.0 - std::f64::consts::PI * 64.0 / 4.0)), "{a:?}");
run(&mut s, "ChamferSketchEqualDistance", json!({"a": l[2], "b": l[3], "distance": 4}));
run(&mut s, "sketch.chamfer.equal_distance", json!({"a": l[2], "b": l[3], "distance": 4}));
let a2 = profiles(&s)[0];
assert!(close(a[0] - a2, 8.0), "{a2}");
run(&mut s, "ChamferSketchDistanceDistance", json!({"a": l[1], "b": l[2], "distance": 2, "distance2": 6}));
run(&mut s, "sketch.chamfer.two_distance", json!({"a": l[1], "b": l[2], "distance": 2, "distance2": 6}));
let a3 = profiles(&s)[0];
assert!(close(a2 - a3, 6.0), "{a3}");
// Distance-angle at the last corner (origin, 90 degrees): 45 deg gives an isosceles cut.
run(&mut s, "ChamferSketchDistanceAngle", json!({"a": l[3], "b": l[0], "distance": 3, "angle": 45}));
run(&mut s, "sketch.chamfer.distance_angle", json!({"a": l[3], "b": l[0], "distance": 3, "angle": 45}));
let a4 = profiles(&s)[0];
assert!(close(a3 - a4, 4.5), "{a4}");
assert!(s.execute("FilletSketchCmd", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner");
assert!(s.execute("sketch.fillet", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner");
}
#[test]
fn offset_chain_mirror_patterns_move_scale() {
let mut s = new_sketch();
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
// Outward offset by 2 (toward a point outside).
let o = run(&mut s, "Offset", json!({"curves": [l[0]], "distance": 2, "side": [20, -10]}));
let o = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": 2, "side": [20, -10]}));
assert_eq!(ids(&o["curves"]).len(), 4, "{o}");
let a = profiles(&s);
assert!(close(a[1], 44.0 * 34.0 - 1200.0) || close(a.iter().sum::<f64>(), 44.0 * 34.0), "{a:?}");
// Circle offset inward.
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [100, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "Offset", json!({"curves": [c], "distance": 3, "side": [100, 0]}));
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [100, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "sketch.offset", json!({"curves": [c], "distance": 3, "side": [100, 0]}));
// Mirror a circle about a vertical line.
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[120, -50], [120, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [c], "line": m}));
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[120, -50], [120, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.mirror", json!({"entities": [c], "line": m}));
let mc = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
assert!(sk.center(sk.curve_index(&mc).unwrap()).unwrap().dist(Vec2::new(140.0, 0.0)) < 1e-7);
// Circular pattern of the mirrored circle: 4 around (120, 0).
let r = run(&mut s, "CircularSketchPatternCommand", json!({"entities": [mc], "center": [120, 0], "count": 4}));
let r = run(&mut s, "sketch.pattern.circular", json!({"entities": [mc], "center": [120, 0], "count": 4}));
assert_eq!(ids(&r["curves"]).len(), 3);
let sk = sketch(&s);
let c2 = sk.center(sk.curve_index(&ids(&r["curves"])[0]).unwrap()).unwrap();
assert!(c2.dist(Vec2::new(120.0, 20.0)) < 1e-7, "{c2:?}");
// Rectangular pattern 3 x 2.
let small = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 100], "radius": 1}))["curves"])[0].clone();
let r = run(&mut s, "RectangularSketchPatternCommand", json!({"entities": [small], "count": 3, "spacing": 5, "count2": 2, "spacing2": 4}));
let small = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 100], "radius": 1}))["curves"])[0].clone();
let r = run(&mut s, "sketch.pattern.rectangular", json!({"entities": [small], "count": 3, "spacing": 5, "count2": 2, "spacing2": 4}));
assert_eq!(ids(&r["curves"]).len(), 5);
// Move and scale.
run(&mut s, "sketch.move", json!({"entities": [small], "translate": [0, 10]}));
run(&mut s, "SketchScaleCmd", json!({"entities": [small], "base": [0, 110], "factor": 3}));
run(&mut s, "sketch.scale", json!({"entities": [small], "base": [0, 110], "factor": 3}));
let sk = sketch(&s);
let ci = sk.curve_index(&small).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(0.0, 110.0)) < 1e-7);
@@ -162,31 +162,31 @@ fn offset_chain_mirror_patterns_move_scale() {
#[test]
fn new_creation_tools() {
let mut s = new_sketch();
let r = run(&mut s, "SketchMidpointLine", json!({"mid": [0, 0], "end": [10, 5]}));
let r = run(&mut s, "sketch.line.midpoint", json!({"mid": [0, 0], "end": [10, 5]}));
let l = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let start = sk.resolve_point(&format!("{l}.start")).and_then(|i| sk.point(i)).unwrap();
assert!(start.dist(Vec2::new(-10.0, -5.0)) < 1e-9);
// Tangent arc off the end of a horizontal line.
let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 20], [10, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "ArcTangent", json!({"start": format!("{h}.end"), "end": [20, 30]}));
let h = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.arc.tangent", json!({"start": format!("{h}.end"), "end": [20, 30]}));
let a = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ai = sk.curve_index(&a).unwrap();
assert!(sk.center(ai).unwrap().dist(Vec2::new(10.0, 30.0)) < 1e-7, "{:?}", sk.center(ai));
assert!(close(sk.radius(ai).unwrap(), 10.0));
// 2-tangent circle in the corner of two lines.
let x = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [150, 0]]}))["curves"])[0].clone();
let y = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [100, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "CircleTanTanRadius", json!({"curves": [x, y], "radius": 5, "near": [106, 104 - 100]}));
let x = ids(&run(&mut s, "sketch.line", json!({"points": [[100, 0], [150, 0]]}))["curves"])[0].clone();
let y = ids(&run(&mut s, "sketch.line", json!({"points": [[100, 0], [100, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [x, y], "radius": 5, "near": [106, 104 - 100]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(105.0, 5.0)) < 1e-6, "{:?}", sk.center(ci));
// 3-tangent circle inside a triangle: the incircle of the 3-4-5 triangle (r = 1).
let t = run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [203, 0], [200, 4]], "closed": true}));
let t = run(&mut s, "sketch.line", json!({"points": [[200, 0], [203, 0], [200, 4]], "closed": true}));
let tl = ids(&t["curves"]);
let r = run(&mut s, "CircleThreeTangent", json!({"curves": tl, "near": [201, 1.2]}));
let r = run(&mut s, "sketch.circle.three_tangent", json!({"curves": tl, "near": [201, 1.2]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
@@ -194,9 +194,9 @@ fn new_creation_tools() {
assert!(sk.center(ci).unwrap().dist(Vec2::new(201.0, 1.0)) < 1e-6);
// Slots.
let before = profiles(&s).len();
run(&mut s, "ShapeSlotCenterPoint", json!({"center": [0, -100], "end": [10, -100], "width": 4}));
run(&mut s, "ShapeArcSlotThreePoint", json!({"start": [100, -100], "through": [110, -90], "end": [120, -100], "width": 2}));
run(&mut s, "ShapeArcSlotCenterTwoPoint", json!({"center": [200, -100], "start": [210, -100], "end": [200, -90], "width": 2}));
run(&mut s, "sketch.slot.center_point", json!({"center": [0, -100], "end": [10, -100], "width": 4}));
run(&mut s, "sketch.slot.arc_three_point", json!({"start": [100, -100], "through": [110, -90], "end": [120, -100], "width": 2}));
run(&mut s, "sketch.slot.arc_center", json!({"center": [200, -100], "start": [210, -100], "end": [200, -90], "width": 2}));
let a = profiles(&s);
assert_eq!(a.len(), before + 3, "{a:?}");
let pi = std::f64::consts::PI;
@@ -210,13 +210,13 @@ fn new_creation_tools() {
fn ellipse_splines_conic() {
let pi = std::f64::consts::PI;
let mut s = new_sketch();
let e = run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]}));
let e = run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]}));
let eid = ids(&e["curves"])[0].clone();
let a = profiles(&s);
assert!((a[0] - pi * 40.0).abs() / (pi * 40.0) < 2e-3, "{a:?}");
// A point constrained onto the ellipse lands on it.
run(&mut s, "DrawPoint", json!({"point": [3, 5], "id": "q"}));
run(&mut s, "ConstraintCoincident", json!({"a": "q", "b": eid}));
run(&mut s, "sketch.point", json!({"point": [3, 5], "id": "q"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "q", "b": eid}));
let sk = sketch(&s);
let q = sk.point(sk.point_index("q").unwrap()).unwrap();
let poly = sk.polyline(sk.curve_index(&eid).unwrap());
@@ -227,9 +227,9 @@ fn ellipse_splines_conic() {
});
assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}");
// Fit spline closed by a line: one profile.
let sp = run(&mut s, "DrawSpline", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sid = ids(&sp["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]}));
run(&mut s, "sketch.line", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]}));
assert_eq!(profiles(&s).len(), 2);
// Moving a fit point reshapes the spline (it is a sketch point).
let sk = sketch(&s);
@@ -240,31 +240,31 @@ fn ellipse_splines_conic() {
let sk = sketch(&s);
assert!(sk.polyline(si).iter().any(|q| q.dist(Vec2::new(110.0, 12.0)) < 1e-9));
// Control point splines (degree 3 and 5) and a conic.
run(&mut s, "DrawCVMSpline3D", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]}));
run(&mut s, "DrawCVMSpline5D", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]}));
run(&mut s, "sketch.spline.control_point", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]}));
run(&mut s, "sketch.spline.control_point_5", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]}));
let w = std::f64::consts::FRAC_1_SQRT_2;
let k = run(&mut s, "ConicCurveCmd", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)}));
let k = run(&mut s, "sketch.conic", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)}));
let kid = ids(&k["curves"])[0].clone();
// A line tangent to the conic at its end: the tangent arc tool continues it.
let r = run(&mut s, "ArcTangent", json!({"start": format!("{kid}.end"), "end": [190, 0]}));
let r = run(&mut s, "sketch.arc.tangent", json!({"start": format!("{kid}.end"), "end": [190, 0]}));
let aid = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
// The conic is a quarter circle about (200, 0) leaving (200, 10) heading -x: the tangent
// arc from there to (190, 0) continues the same circle.
let ai = sk.curve_index(&aid).unwrap();
assert!(sk.center(ai).unwrap().dist(Vec2::new(200.0, 0.0)) < 1e-6, "{:?}", sk.center(ai));
assert!(s.execute("DrawSpline", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("ConicCurveCmd", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err());
assert!(s.execute("sketch.spline.fit_point", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("sketch.conic", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err());
// Mirror and pattern copy free-form curves too.
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [eid, sid], "line": m}));
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.mirror", json!({"entities": [eid, sid], "line": m}));
assert_eq!(ids(&r["curves"]).len(), 2);
}
#[test]
fn text_makes_profiles_that_extrude_and_edit() {
let mut s = new_sketch();
let r = run(&mut s, "MTextCmd", json!({"text": "SO", "at": [0, 0], "height": 10}));
let r = run(&mut s, "sketch.text", json!({"text": "SO", "at": [0, 0], "height": 10}));
let link = r["link"].as_str().unwrap().to_string();
let a = profiles(&s);
// S (one region) + O (ring = outer with a hole, plus the counter as its own region).
@@ -276,21 +276,21 @@ fn text_makes_profiles_that_extrude_and_edit() {
assert_eq!(r2["link"], json!(link));
let a = profiles(&s);
assert_eq!(a.len(), 1, "{a:?}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2}));
let m = run(&mut s, "MeasureCommand", json!({}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
let m = run(&mut s, "inspect.measure", json!({}));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
// An "I" 20 mm tall is a bar a couple of mm wide.
assert!(v > 40.0 && v < 200.0, "{v}");
assert!(s.execute("MTextCmd", &json!({"text": "", "at": [0, 0]})).is_err());
assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err());
}
#[test]
fn blend_curve_joins_tangentially() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[20, 10], [20, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "BlendG1CurveSketchCmd", json!({"a": format!("{a}.end"), "b": format!("{b}.start")}));
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.blend_curve", json!({"a": format!("{a}.end"), "b": format!("{b}.start")}));
assert_eq!(r["sketch"]["solved"], true, "{r}");
let sp = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
@@ -305,9 +305,9 @@ fn blend_curve_joins_tangentially() {
#[test]
fn centerline_is_left_out_of_profiles() {
let mut s = new_sketch();
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
let l = ids(&r["curves"]);
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[5, -5], [5, 15]]}))["curves"])[0].clone();
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[5, -5], [5, 15]]}))["curves"])[0].clone();
assert_eq!(profiles(&s).len(), 2);
run(&mut s, "sketch.centerline", json!({"curves": [m]}));
assert_eq!(profiles(&s).len(), 1);
@@ -318,57 +318,59 @@ fn centerline_is_left_out_of_profiles() {
#[test]
fn free_form_profiles_extrude_exactly() {
let pi = std::f64::consts::PI;
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
// Ellipse a = 10, b = 4: π a b h.
let mut s = new_sketch();
run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}));
run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}));
let a = profiles(&s)[0];
assert!((a - pi * 40.0).abs() < 1e-9, "{a}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let v = vol(&mut s);
assert!((v - pi * 40.0 * 5.0).abs() / (pi * 200.0) < 2e-4, "{v}");
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["bodies"][0]["faces"], 3, "an exact ellipse: top, bottom and one side ({m})");
// A region closed by a fit spline and a line.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 8], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let a = profiles(&s)[0];
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
let v = vol(&mut s);
assert!((v - a * 2.0).abs() / (a * 2.0) < 2e-4, "{v} vs {}", a * 2.0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!(m["bodies"][0]["faces"].as_u64().unwrap() <= 5, "{m}");
}
#[test]
fn degree_five_and_cut_free_form_profiles_extrude_exactly() {
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
// A degree-5 control spline closed by a line: one B-spline side face.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawCVMSpline5D", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let sp =
ids(&run(&mut s, "sketch.spline.control_point_5", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0]
.clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let a = profiles(&s)[0];
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 3}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 3}));
let v = vol(&mut s);
assert!((v - a * 3.0).abs() / (a * 3.0) < 2e-4, "{v} vs {}", a * 3.0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["bodies"][0]["faces"], 4, "{m}");
// A fit spline arch cut by a line: both sides extrude with exact curved faces.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [6, 9], [14, 9], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, -5], [10, 20]]}));
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [6, 9], [14, 9], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 20]]}));
let areas = profiles(&s);
assert_eq!(areas.len(), 2, "{areas:?}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2, "profiles": [0]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [0]}));
let v = vol(&mut s);
assert!((v - areas[0] * 2.0).abs() / (areas[0] * 2.0) < 2e-4, "{v} vs {}", areas[0] * 2.0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!(m["bodies"][0]["faces"].as_u64().unwrap() <= 7, "no polyline facets: {m}");
}
@@ -378,9 +380,9 @@ fn trim_and_break_free_form_curves_exactly() {
let mut s = new_sketch();
// An ellipse cut by a line through its centre: trimming the lower half leaves the upper
// half, which closes with the line into half the ellipse.
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-15, 0], [15, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "TrimSketchCmd", json!({"curve": e, "at": [0, -4]}));
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-15, 0], [15, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.trim", json!({"curve": e, "at": [0, -4]}));
assert!(!r["result"]["pieces"].as_array().unwrap().is_empty(), "{r}");
let a = profiles(&s);
assert_eq!(a.len(), 1, "{a:?}");
@@ -388,10 +390,10 @@ fn trim_and_break_free_form_curves_exactly() {
let _ = l;
// A fit spline broken where a line crosses it: two pieces that still trace the curve.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 10], [20, 0], [30, 5]]}))["curves"])[0].clone();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 10], [20, 0], [30, 5]]}))["curves"])[0].clone();
let before = sketch(&s).polyline(sketch(&s).curve_index(&sp).unwrap());
run(&mut s, "DrawPolyline", json!({"points": [[15, -10], [15, 20]]}));
let r = run(&mut s, "BreakSketchCmd", json!({"curve": sp, "at": [15, 5]}));
run(&mut s, "sketch.line", json!({"points": [[15, -10], [15, 20]]}));
let r = run(&mut s, "sketch.break", json!({"curve": sp, "at": [15, 5]}));
let pieces = ids(&r["curves"]);
assert_eq!(pieces.len(), 4, "three Béziers, the middle one split: {r}");
let sk = sketch(&s);
@@ -415,9 +417,9 @@ fn trim_and_break_free_form_curves_exactly() {
#[test]
fn ellipse_radii_dimensions() {
let mut s = new_sketch();
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [e], "type": "major", "value": 12}));
run(&mut s, "SketchDimension", json!({"entities": [e], "value": 5}));
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [e], "type": "major", "value": 12}));
run(&mut s, "sketch.dimension", json!({"entities": [e], "value": 5}));
let a = profiles(&s)[0];
assert!((a - std::f64::consts::PI * 60.0).abs() < 1e-6, "{a}");
}
@@ -426,10 +428,11 @@ fn ellipse_radii_dimensions() {
fn fillet_a_line_and_an_arc() {
let mut s = new_sketch();
// A line into a quarter arc meeting at a sharp corner.
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let a =
ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
let _ = a;
let f = run(&mut s, "FilletSketchCmd", json!({"point": format!("{l}.end"), "radius": 2}));
let f = run(&mut s, "sketch.fillet", json!({"point": format!("{l}.end"), "radius": 2}));
let arc = ids(&f["curves"])[0].clone();
let sk = sketch(&s);
let ai = sk.curve_index(&arc).unwrap();
@@ -444,8 +447,8 @@ fn fillet_a_line_and_an_arc() {
#[test]
fn offset_free_form_curves() {
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [sp], "distance": 2, "side": [10, 20]}));
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.offset", json!({"curves": [sp], "distance": 2, "side": [10, 20]}));
let made = ids(&r["curves"]);
assert_eq!(made.len(), 1, "{r}");
let sk = sketch(&s);
@@ -464,7 +467,7 @@ fn offset_free_form_curves() {
assert!((dist(*q) - 2.0).abs() < 0.08, "{q:?} {}", dist(*q));
}
// An ellipse grows by the offset all round.
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [100, 0], "major": [110, 0], "minor_radius": 5}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [100, 0], "major": [110, 0], "minor_radius": 5}))["curves"])[0].clone();
let r = run(&mut s, "sketch.offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
assert_eq!(ids(&r["curves"]).len(), 1, "{r}");
}
+2 -2
View File
@@ -15,7 +15,7 @@ fn ms(t: Instant) -> f64 {
/// them, built straight into the document.
fn big(n: usize) -> Session {
let mut s = Session::default();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let id = s.active_sketch.unwrap();
let mut doc = (*s.doc).clone();
let sk = doc.sketch_mut(id).unwrap();
@@ -67,7 +67,7 @@ fn sketch_perf() {
s.execute("sketch.snap", &json!({"at": [10.0, 0.2], "from": [0, 0], "radius": 1})).unwrap();
let snap = ms(t);
let t = Instant::now();
s.execute("DrawPolyline", &json!({"points": [[-50, -50], [-40, -45]]})).unwrap();
s.execute("sketch.line", &json!({"points": [[-50, -50], [-40, -45]]})).unwrap();
let add = ms(t);
let t = Instant::now();
s.execute("sketch.move_point", &json!({"point": "p1", "to": [1, 1]})).unwrap();
+29 -17
View File
@@ -14,36 +14,36 @@ use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("ProjectNewCmd", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
CommandSpec::new("IntersectCmd", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
"refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked",
),
CommandSpec::new("Include3DGeometry", "Include 3D Geometry", include_cmd)
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
.at("SKETCH", "CREATE")
.icon("include")
.enabled(in_sketch)
.params("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it"),
CommandSpec::new("ProjectToSurface", "Project To Surface", project_to_surface)
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
.at("SKETCH", "CREATE")
.icon("project_surface")
.enabled(in_sketch)
.params("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves"),
CommandSpec::new("IntersectionCurve", "Intersection Curve", intersection_curve)
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
.at("SKETCH", "CREATE")
.icon("intersection_curve")
.enabled(in_sketch)
.params("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves"),
CommandSpec::new("SpunProfileCmd", "Spun Profile", spun_profile)
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
.at("SKETCH", "CREATE")
.icon("spun")
.enabled(in_sketch)
.params("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane"),
CommandSpec::new("SketchIsoparametricCurve", "Isoparametric Curve", iso_curve)
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
.at("SKETCH", "CREATE")
.icon("iso_curve")
.enabled(in_sketch)
.params("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve"),
CommandSpec::new("FitCurvesToSectionCommand", "Fit Curves to Mesh Section", fit_section)
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
.at("SKETCH", "CREATE")
.icon("fit_section")
.enabled(in_sketch)
@@ -140,8 +140,20 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
let plane = sketch_plane(doc, id)?;
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
// The model as the sketch's component sees it.
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?)
let name = if let LinkSource::Face { at, .. } = &r.source {
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
} else {
None
};
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
l.face_name = name;
}
Ok(link)
}
fn link_json(sk: &Sketch, link: &str) -> Value {
@@ -176,15 +188,15 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Va
}
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "ProjectNewCmd", LinkKind::Project)
run_links(s, p, "sketch.project", LinkKind::Project)
}
fn intersect_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "IntersectCmd", LinkKind::Intersect)
run_links(s, p, "sketch.intersect", LinkKind::Intersect)
}
fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "Include3DGeometry", LinkKind::Include)
run_links(s, p, "sketch.include_3d", LinkKind::Include)
}
/// Add one link from `src` to the active sketch and report it.
@@ -204,7 +216,7 @@ fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Resu
}
fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ProjectToSurface";
let cmd = "sketch.project_to_surface";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the target face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let list = p.get("curves").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`curves` must list {sketch, curve}"))?;
@@ -222,7 +234,7 @@ fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
}
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "IntersectionCurve";
let cmd = "sketch.intersection_curve";
let side = |k: &str| -> Result<LinkSource> {
let v = p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
match source(s, v, cmd)? {
@@ -235,7 +247,7 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SpunProfileCmd";
let cmd = "sketch.spun_profile";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
let axis = str_(p, "axis").ok_or_else(|| bad(cmd, "`axis` must be a sketch line or X|Y|Z"))?;
let (origin, dir) = match axis.to_ascii_uppercase().as_str() {
@@ -256,7 +268,7 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
}
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchIsoparametricCurve";
let cmd = "sketch.isoparametric_curve";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let dir = match p.get("direction") {
@@ -271,7 +283,7 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FitCurvesToSectionCommand";
let cmd = "sketch.fit_curves_to_section";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
+131 -89
View File
@@ -15,20 +15,20 @@ fn ids(v: &Value) -> Vec<String> {
/// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`.
fn box_part(s: &mut Session) {
run(s, "ChangeParameterCommand", json!({"name": "width", "expression": "40 mm"}));
run(s, "SketchCreate", json!({"plane": "XY"}));
let r = run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "parameters.change", json!({"name": "width", "expression": "40 mm"}));
run(s, "sketch.create", json!({"plane": "XY"}));
let r = run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
let start = match sk.curves[0].kind {
solvecraft_sketch::CurveKind::Line { a, .. } => sk.points[a].id.clone(),
_ => panic!(),
};
run(s, "ConstraintCoincident", json!({"a": start, "b": "origin"}));
run(s, "SketchDimension", json!({"entities": [l[0]], "value": "width"}));
run(s, "SketchDimension", json!({"entities": [l[1]], "value": 30}));
run(s, "SketchStop", json!({}));
run(s, "Extrude", json!({"distance": 20}));
run(s, "sketch.constraint.coincident", json!({"a": start, "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": [l[0]], "value": "width"}));
run(s, "sketch.dimension", json!({"entities": [l[1]], "value": 30}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 20}));
}
fn inspect(s: &mut Session) -> Value {
@@ -43,7 +43,7 @@ fn linked_curves(si: &Value) -> Vec<Value> {
fn sketch_on_face_projects_its_edges_and_follows_edits() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
assert!(r["projected"].is_string(), "{r}");
let si = inspect(&mut s);
let lc = linked_curves(&si);
@@ -55,16 +55,16 @@ fn sketch_on_face_projects_its_edges_and_follows_edits() {
// Projected geometry is fixed: the sketch has no degrees of freedom.
assert_eq!(si["dof"], 0, "{si}");
// A circle on the face, dimensioned to a projected edge.
let c = run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 3}));
let c = run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 3}));
let cid = ids(&c["curves"])[0].clone();
let e = lc.iter().find(|c| c["start_at"][1].as_f64().unwrap().abs() < 1e-9 && c["end_at"][1].as_f64().unwrap().abs() < 1e-9).unwrap();
run(&mut s, "SketchDimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
run(&mut s, "sketch.dimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
let si = inspect(&mut s);
let center = si["points"].as_array().unwrap().iter().find(|p| p["id"] == format!("{cid}.center")).unwrap();
assert!((center["at"][1].as_f64().unwrap() - 8.0).abs() < 1e-7, "{center}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// Widen the box: the projected loop follows.
run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "50 mm"}));
run(&mut s, "parameters.change", json!({"name": "width", "expression": "50 mm"}));
let st = s.model.state();
let ss = st.sketches.last().unwrap();
assert_eq!(ss.profiles.len(), 2);
@@ -78,11 +78,11 @@ fn sketch_on_face_projects_its_edges_and_follows_edits() {
fn auto_project_can_be_turned_off() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
assert!(r["projected"].is_null());
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.auto_project", json!({"value": false}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
assert!(r["projected"].is_null());
}
@@ -90,10 +90,10 @@ fn auto_project_can_be_turned_off() {
fn project_edges_bodies_sketches_and_origin() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "PrimitiveCylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "solid.cylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
// An edge on the top face projects to a line on XY.
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [20, 30, 20]}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 30, 20]}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "line");
@@ -101,7 +101,7 @@ fn project_edges_bodies_sketches_and_origin() {
assert!(ys.iter().all(|y| (y - 30.0).abs() < 1e-9), "{c}");
// The cylinder's silhouette seen from above is its circle, exactly.
let name = s.model.state().bodies[1].name.clone();
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": name}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"body": name}]}));
let cid = r["links"][0]["curves"][0].clone();
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == cid).unwrap().clone();
@@ -109,34 +109,34 @@ fn project_edges_bodies_sketches_and_origin() {
assert!((c["radius"].as_f64().unwrap() - 6.0).abs() < 1e-6, "{c}");
assert!((c["center_at"][0].as_f64().unwrap() - 80.0).abs() < 1e-6, "{c}");
// Origin, the Y axis and the YZ plane.
run(&mut s, "ProjectNewCmd", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
run(&mut s, "sketch.project", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
// The box silhouette from above is its 40 x 30 outline.
let b0 = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": b0}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"body": b0}]}));
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
let first = s.active_sketch;
assert!(first.is_none());
let sk1 = s.doc.features.last().unwrap().id;
// Another sketch projects a curve of the first one.
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
let c2 = r["links"][0]["curves"][0].clone();
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == c2).unwrap().clone();
assert_eq!(c["type"], "circle");
// Bad references fail cleanly.
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
assert!(s.execute("sketch.project", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
assert!(s.execute("sketch.project", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
}
#[test]
fn intersect_cuts_a_body_with_the_sketch_plane() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 12}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 12}));
let name = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "IntersectCmd", json!({"refs": [{"body": name}]}));
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": name}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "circle", "{c}");
@@ -148,7 +148,7 @@ fn intersect_cuts_a_body_with_the_sketch_plane() {
fn break_link_makes_normal_geometry() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let link = r["projected"].as_str().unwrap().to_string();
run(&mut s, "sketch.break_link", json!({"link": link}));
let si = inspect(&mut s);
@@ -162,11 +162,11 @@ fn break_link_makes_normal_geometry() {
#[test]
fn lost_reference_keeps_geometry_with_a_warning() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let boxf = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [5, 0, 10]}]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [5, 0, 10]}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.suppress", json!({"feature": boxf, "suppressed": true}));
let st = s.model.state();
let ss = st.sketches.last().unwrap();
@@ -180,8 +180,8 @@ fn lost_reference_keeps_geometry_with_a_warning() {
fn drawing_on_a_model_vertex_projects_it() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "DrawPolyline", json!({"points": ["vertex:40,30,20", [60, 45]]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.line", json!({"points": ["vertex:40,30,20", [60, 45]]}));
let l = ids(&r["curves"])[0].clone();
let si = inspect(&mut s);
let start = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == l).unwrap()["start"].clone();
@@ -190,7 +190,7 @@ fn drawing_on_a_model_vertex_projects_it() {
assert_eq!(p["fixed"], true);
assert!((p["at"][0].as_f64().unwrap() - 40.0).abs() < 1e-9);
// Snapping onto an edge projects it and keeps the point on it.
let r = run(&mut s, "DrawPolyline", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
let r = run(&mut s, "sketch.line", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
assert_eq!(ids(&r["curves"]).len(), 1);
let si = inspect(&mut s);
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["type"] == "point_on_curve"), "{si}");
@@ -199,41 +199,41 @@ fn drawing_on_a_model_vertex_projects_it() {
#[test]
fn fit_curves_to_a_mesh_section() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 10}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 10}));
let name = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "FitCurvesToSectionCommand", json!({"body": name}));
let r = run(&mut s, "sketch.fit_curves_to_section", json!({"body": name}));
assert_eq!(r["curves"].as_array().unwrap().len(), 1, "{r}");
let si = inspect(&mut s);
assert!(si["links"].as_array().unwrap().is_empty());
let c = &si["curves"][0];
assert_eq!(c["type"], "circle");
assert!(c.get("link").is_none());
assert!(s.execute("FitCurvesToSectionCommand", &json!({"body": "nope"})).is_err());
assert!(s.execute("sketch.fit_curves_to_section", &json!({"body": "nope"})).is_err());
}
#[test]
fn three_d_sketch_curves() {
let mut s = Session::default();
// Include keeps an out-of-plane edge 3D.
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "Include3DGeometry", json!({"refs": [{"edge": [10, 10, 5]}]}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [10, 10, 5]}]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
assert_eq!(sk.wires.len(), 1, "{r}");
assert!((sk.wires[0].pts.iter().map(|p| p.z).fold(0.0, f64::max) - 10.0).abs() < 1e-9);
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// Project a line onto a sphere along the sketch normal.
let mut s = Session::default();
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [10, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [10, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.finish", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project_to_surface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
@@ -246,16 +246,16 @@ fn three_d_sketch_curves() {
// Delete the wire.
run(&mut s, "sketch.delete", json!({"entities": [wid]}));
assert!(s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.wires.is_empty());
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// Two crossing cylinders meet in 3D curves.
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40}));
run(&mut s, "PrimitiveCylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40}));
run(&mut s, "solid.cylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 2, "{names:?}");
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "IntersectionCurve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.intersection_curve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}}));
assert!(!r["wires"].as_array().unwrap().is_empty(), "{r}");
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
@@ -265,14 +265,14 @@ fn three_d_sketch_curves() {
assert!(((p.y * p.y + p.z * p.z).sqrt() - 4.0).abs() < 0.1, "{p:?}");
}
}
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// Spun profile of a cylinder about Z, on the XZ plane: a straight outline 6 from the axis.
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
let name = s.model.state().bodies[0].name.clone();
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
let r = run(&mut s, "SpunProfileCmd", json!({"body": name, "axis": "Z"}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
let r = run(&mut s, "sketch.spun_profile", json!({"body": name, "axis": "Z"}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "line", "{c}");
@@ -282,30 +282,31 @@ fn three_d_sketch_curves() {
#[test]
fn isoparametric_curves_of_a_cylinder() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
// u: around (a circle at z = 10); v: along (a line from z 0 to 20).
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10]}));
let r = run(&mut s, "sketch.isoparametric_curve", json!({"face": [6, 0, 10]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
assert!(w.pts.iter().all(|p| (p.z - 10.0).abs() < 1e-6 && ((p.x * p.x + p.y * p.y).sqrt() - 6.0).abs() < 1e-3), "{:?}", &w.pts[..3]);
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10], "direction": "v"}));
let r = run(&mut s, "sketch.isoparametric_curve", json!({"face": [6, 0, 10], "direction": "v"}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
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]
fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "axis": [1, 0, 1], "radius": 5, "height": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": -10}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [1, 0, 1], "radius": 5, "height": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": -10}));
// The base circle (centred at the origin) seen from above.
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, 5, 0]}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [0, 5, 0]}]}));
// One seam half: exact conics (rational quadratic arcs).
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
@@ -320,7 +321,7 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
}
}
// The whole face (both halves of its rim, plus its seam) gives the full ellipse.
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"face": [0, 0, 0]}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"face": [0, 0, 0]}]}));
let si = inspect(&mut s);
let types: Vec<String> = r["links"][0]["curves"]
.as_array()
@@ -329,7 +330,7 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
.map(|id| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == *id).unwrap()["type"].as_str().unwrap().to_string())
.collect();
assert!(types.iter().all(|t| t == "conic" || t == "ellipse"), "{types:?}");
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, -5, 0]}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [0, -5, 0]}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
if c["type"] == "ellipse" {
@@ -337,14 +338,14 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
let major = &c["major_at"];
assert!(((major[0].as_f64().unwrap().powi(2) + major[1].as_f64().unwrap().powi(2)).sqrt() - 5.0).abs() < 1e-6, "{c}");
}
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// A spline of a parallel sketch projects as the same spline.
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 30}));
let sp = run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 5], [20, 0]]}))["curves"][0].clone();
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 30}));
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 5], [20, 0]]}))["curves"][0].clone();
run(&mut s, "sketch.finish", json!({}));
let sk1 = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": sp}]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": sk1, "curve": sp}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "spline", "{c}");
@@ -355,13 +356,13 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
fn sweeps_and_pipes_follow_3d_sketch_curves() {
// A line projected onto a sphere: a 3D arc on its top.
let mut s = Session::default();
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-12, 0], [12, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-12, 0], [12, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.finish", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project_to_surface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let path_sketch = s.active_sketch.unwrap();
let pts = {
@@ -369,11 +370,11 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
let sk = &st.sketch(path_sketch).unwrap().sketch;
sk.wires[sk.wire_index(&wid).unwrap()].pts.clone()
};
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
// A pipe along it: volume ≈ π r² × length.
let before = s.model.state().bodies.len();
run(&mut s, "PrimitivePipe", json!({"path_sketch": path_sketch, "path": [wid.clone()], "diameter": "2 mm", "operation": "new"}));
run(&mut s, "solid.pipe", json!({"path_sketch": path_sketch, "path": [wid.clone()], "diameter": "2 mm", "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 1);
let v = st.bodies.last().unwrap().mesh().measure().volume;
@@ -385,13 +386,54 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
let t = (b - a).normalized().unwrap();
let x = t.cross(solvecraft_geom::Vec3::Z).normalized().unwrap();
let y = t.cross(x);
run(&mut s, "SketchCreate", json!({"plane": {"origin": [a.x, a.y, a.z], "x_dir": [x.x, x.y, x.z], "y_dir": [y.x, y.y, y.z]}}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-1, -1], "p1": [1, 1]}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [a.x, a.y, a.z], "x_dir": [x.x, x.y, x.z], "y_dir": [y.x, y.y, y.z]}}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-1, -1], "p1": [1, 1]}));
let prof = s.active_sketch.unwrap();
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Sweep", json!({"sketch": prof, "path_sketch": path_sketch, "path": [wid], "operation": "new"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.sweep", json!({"sketch": prof, "path_sketch": path_sketch, "path": [wid], "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 2);
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}");
}
+25 -9
View File
@@ -19,11 +19,11 @@ fn ids(v: &Value) -> Vec<String> {
/// A new sketch with one rectangle of the given kind; returns its four side ids.
fn rect(kind: &str) -> (Session, Vec<String>) {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = match kind {
"center" => run(&mut s, "ShapeRectangleCenter", json!({"center": [3, 2], "corner": [13, 9]})),
"two" => run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [1, 1], "p1": [21, 15]})),
_ => run(&mut s, "ShapeRectangleThreePoint", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
"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], "connect": false})),
};
let sides = ids(&r["curves"]);
assert_eq!(sides.len(), 4, "{r}");
@@ -54,9 +54,9 @@ fn fresh_rectangles_have_the_expected_freedom() {
fn rectangles_take_dimensions_without_conflict() {
for k in KINDS {
let (mut s, l) = rect(k);
run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 30}));
healthy(&mut s, &format!("{k} width"));
run(&mut s, "SketchDimension", json!({"entities": [l[1]], "value": 12}));
run(&mut s, "sketch.dimension", json!({"entities": [l[1]], "value": 12}));
let dof = healthy(&mut s, &format!("{k} width+height"));
assert_eq!(dof, if k == "three" { 3 } else { 2 }, "{k}");
}
@@ -72,12 +72,12 @@ fn rectangles_can_be_dragged_pinned_and_made_square() {
run(&mut s, "sketch.move_point", json!({"point": format!("{}.end", l[2]), "to": [25, 22]}));
healthy(&mut s, &format!("{k} drag other corner"));
// Pin a corner to the origin.
run(&mut s, "ConstraintCoincident", json!({"a": format!("{}.start", l[0]), "b": "origin"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", l[0]), "b": "origin"}));
healthy(&mut s, &format!("{k} corner on origin"));
// Square: equal sides.
run(&mut s, "ConstraintEqual", json!({"a": l[0], "b": l[1]}));
run(&mut s, "sketch.constraint.equal", json!({"a": l[0], "b": l[1]}));
healthy(&mut s, &format!("{k} square"));
run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 10}));
let dof = healthy(&mut s, &format!("{k} square sized"));
assert_eq!(dof, if k == "three" { 1 } else { 0 }, "{k}");
}
@@ -101,3 +101,19 @@ fn dragging_corners_never_collapses_a_side() {
}
}
}
#[test]
fn center_rectangle_draws_both_diagonals_like_fusion() {
let (mut s, _) = rect("center");
let si = run(&mut s, "sketch.inspect", json!({}));
let curves = si["curves"].as_array().cloned().unwrap_or_default();
// Four sides plus two construction diagonals; the other rectangles have no diagonals.
assert_eq!(curves.len(), 6, "{si}");
assert_eq!(curves.iter().filter(|c| c["construction"] == true).count(), 2, "{si}");
assert_eq!(healthy(&mut s, "center diagonals"), 4);
for k in ["two", "three"] {
let (mut s, _) = rect(k);
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["curves"].as_array().map(Vec::len), Some(4), "{k}: {si}");
}
}
@@ -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);
}
}
+102 -102
View File
@@ -20,7 +20,7 @@ fn sketch(s: &Session) -> Sketch {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
@@ -30,11 +30,11 @@ fn new_sketch() -> Session {
#[test]
fn every_dimension_kind_has_a_drawable_frame() {
let mut s = new_sketch();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 20], "radius": 8}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]}));
run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-10, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-20, 25]]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 20], "radius": 8}));
run(&mut s, "sketch.arc.center_point", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-10, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-20, 25]]}));
for (ents, ty) in [
(json!(["l1"]), "auto"),
(json!(["l2"]), "vertical"),
@@ -44,9 +44,9 @@ fn every_dimension_kind_has_a_drawable_frame() {
(json!(["a1"]), "arc_length"),
(json!(["l5", "l6"]), "angle"),
] {
run(&mut s, "SketchDimension", json!({"entities": ents, "type": ty}));
run(&mut s, "sketch.dimension", json!({"entities": ents, "type": ty}));
}
run(&mut s, "SketchDimension", json!({"entities": ["c1.center", "l2"], "driven": true}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1.center", "l2"], "driven": true}));
let sk = sketch(&s);
let dims: Vec<_> = sk.constraints.iter().filter(|c| c.kind.is_dimension()).collect();
assert_eq!(dims.len(), 8);
@@ -71,8 +71,8 @@ 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, "DrawPolyline", json!({"points": [[0, 0], [40, 0]]}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
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| {
let sk = sketch(s);
@@ -102,8 +102,8 @@ fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
#[test]
fn deleting_a_dimension_frees_its_parameter() {
let mut s = new_sketch();
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["c1"]}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["c1"]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let id = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == Some(param.as_str())).map(|c| c.id.clone()).expect("dim");
run(&mut s, "sketch.delete", json!({"entities": [id]}));
@@ -126,22 +126,22 @@ 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, "DrawPolyline", 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, "ConstraintFix", json!({"entity": "l1.end"}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], true);
assert_eq!(dof(&mut s), free - 2);
let corner = pt(&s, "l1.end");
// Dragging the fixed point does nothing; dragging its neighbours never moves it.
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [50, 50]}));
assert_eq!(pt(&s, "l1.end"), corner);
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l2", "mode": "vertical"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "vertical"}));
run(&mut s, "sketch.move_point", json!({"point": "l2.end", "to": [40, 25]}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 12}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 12}));
assert!(pt(&s, "l1.end").dist(corner) < 1e-12, "{:?}", pt(&s, "l1.end"));
assert!((pt(&s, "l1.start").dist(corner) - 12.0).abs() < 1e-6);
// Unfix: the point moves again.
let r = run(&mut s, "ConstraintFix", json!({"entity": "l1.end"}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], false);
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [30, -5]}));
assert!(pt(&s, "l1.end").dist(corner) > 1.0);
@@ -150,13 +150,13 @@ fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
#[test]
fn fixing_curves_holds_their_points_and_radius() {
let mut s = new_sketch();
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 5}));
run(&mut s, "DrawPolyline", json!({"points": [[30, 0], [50, 0]]}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]}));
run(&mut s, "CircleElipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 5}));
run(&mut s, "sketch.line", json!({"points": [[30, 0], [50, 0]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]}));
run(&mut s, "sketch.ellipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4}));
let free = dof(&mut s);
// Multi-select fixes them all (circle 3, line 4, arc 5, ellipse 5 degrees of freedom).
let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["changed"], 4);
assert_eq!(dof(&mut s), free - 17);
let si = run(&mut s, "sketch.inspect", json!({}));
@@ -164,18 +164,18 @@ fn fixing_curves_holds_their_points_and_radius() {
// A fixed circle keeps centre and radius; a dimension on it conflicts.
run(&mut s, "sketch.move_point", json!({"point": "c1.center", "to": [0, 0]}));
assert_eq!(pt(&s, "c1.center"), solvecraft_geom::Vec2::new(10.0, 10.0));
assert!(s.execute("SketchDimension", &json!({"entities": ["c1"], "value": 20})).is_err());
assert!(s.execute("sketch.dimension", &json!({"entities": ["c1"], "value": 20})).is_err());
let r0 = sketch(&s).radius(0);
assert_eq!(r0, Some(5.0));
// Toggle without `fixed`: all fixed, so all are unfixed.
let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["fixed"], false);
assert_eq!(dof(&mut s), free);
run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 7}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 7}));
assert!((sketch(&s).radius(0).unwrap_or(0.0) - 7.0).abs() < 1e-9);
// Mixed selection: one fixed, one free → both end up fixed.
run(&mut s, "ConstraintFix", json!({"entity": "l1"}));
let r = run(&mut s, "ConstraintFix", json!({"entities": ["l1", "l1.start", "a1"]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1", "l1.start", "a1"]}));
assert_eq!(r["result"]["fixed"], true);
assert!(sketch(&s).curves.iter().filter(|c| c.id == "l1" || c.id == "a1").all(|c| c.fixed));
}
@@ -183,17 +183,17 @@ fn fixing_curves_holds_their_points_and_radius() {
#[test]
fn projected_geometry_counts_as_fixed() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
let sk = sketch(&s);
let ci = sk.curves.iter().position(|c| c.link.is_some()).unwrap_or_else(|| panic!("projected: {r}"));
let id = sk.curves[ci].id.clone();
assert!(sk.curve_locked(ci));
let r = run(&mut s, "ConstraintFix", json!({"entity": id}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": id}));
// Already fixed: the toggle unfixes, which projected geometry ignores.
assert_eq!(r["result"]["changed"], 0);
assert!(sketch(&s).curve_locked(ci));
@@ -213,11 +213,11 @@ fn curve_ids(v: &Value) -> Vec<String> {
#[test]
fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
let mut s = new_sketch();
let rect = curve_ids(&run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]})));
let slot = curve_ids(&run(&mut s, "ShapeSlotCenterToCenter", json!({"p0": [40, 5], "p1": [60, 5], "width": 6})));
let poly = curve_ids(&run(&mut s, "ShapePolygonInscribed", json!({"center": [0, 40], "radius": 8, "sides": 6})));
let circ = curve_ids(&run(&mut s, "CircleCenterRadius", json!({"center": [40, 40], "radius": 5})));
let ell = curve_ids(&run(&mut s, "CircleElipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4})));
let rect = curve_ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})));
let slot = curve_ids(&run(&mut s, "sketch.slot.center_to_center", json!({"p0": [40, 5], "p1": [60, 5], "width": 6})));
let poly = curve_ids(&run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 40], "radius": 8, "sides": 6})));
let circ = curve_ids(&run(&mut s, "sketch.circle.center", json!({"center": [40, 40], "radius": 5})));
let ell = curve_ids(&run(&mut s, "sketch.ellipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4})));
assert_eq!(profiles(&mut s), 5);
for (shape, left) in [(&rect, 4), (&slot, 3), (&poly, 2), (&circ, 1), (&ell, 0)] {
let r = run(&mut s, "sketch.construction", json!({ "curves": shape }));
@@ -240,12 +240,12 @@ fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
#[test]
fn projected_geometry_can_become_construction() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
let id = sketch(&s).curves.iter().find(|c| c.link.is_some()).map(|c| c.id.clone()).expect("projected");
let r = run(&mut s, "sketch.construction", json!({ "curves": [id.clone()] }));
assert_eq!(r["changed"], 1);
@@ -273,8 +273,8 @@ fn same(a: &(Vec<solvecraft_geom::Vec2>, Option<f64>), b: &(Vec<solvecraft_geom:
/// A free line l1 (5,3)–(45,3) and a circle c1 at (20,18) with radius 5.
fn line_and_circle() -> Session {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 18], "radius": 5}));
run(&mut s, "sketch.line", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 18], "radius": 5}));
s
}
@@ -291,40 +291,40 @@ fn tangent_moves_only_the_second_pick() {
// Line then circle: only the circle moves (and keeps its size).
let mut s = line_and_circle();
let l0 = snap(&s, "l1");
run(&mut s, "ConstraintTangent", json!({"a": "l1", "b": "c1"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "l1", "b": "c1"}));
assert!(same(&snap(&s, "l1"), &l0), "line moved: {:?}", snap(&s, "l1"));
let c = snap(&s, "c1");
assert!((c.1.unwrap_or(0.0) - 5.0).abs() < 1e-9 && (c.0[0].y - 8.0).abs() < 1e-6, "{c:?}");
// Circle then line: only the line moves.
check_order(line_and_circle(), "ConstraintTangent", "c1", "l1");
check_order(line_and_circle(), "sketch.constraint.tangent", "c1", "l1");
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();
run(&mut s, "ConstraintFix", json!({"entity": "l1"}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
let l0 = snap(&s, "l1");
let c0 = snap(&s, "c1");
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "l1"), &l0));
assert!(!same(&snap(&s, "c1"), &c0));
// Fixed first: the second moves as usual.
let mut s = line_and_circle();
run(&mut s, "ConstraintFix", json!({"entity": "c1"}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "c1"}));
let c0 = snap(&s, "c1");
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "c1"), &c0));
}
#[test]
fn two_fully_constrained_entities_conflict_and_nothing_moves() {
let mut s = line_and_circle();
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["curves"].as_array().is_some_and(|c| c.iter().all(|c| c["fully_constrained"] == true)), "{si}");
let before = sketch(&s);
let e = s.execute("ConstraintTangent", &json!({"a": "l1", "b": "c1"}));
let e = s.execute("sketch.constraint.tangent", &json!({"a": "l1", "b": "c1"}));
assert!(e.is_err(), "{e:?}");
assert_eq!(sketch(&s), before);
}
@@ -333,22 +333,22 @@ 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, "DrawPolyline", json!({"points": [[0, 0], [30, 2]]}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 20], [25, 35]]}));
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
};
let len = |s: &Session, l: &str| {
let p = snap(s, l).0;
p[0].dist(p[1])
};
for cmd in ["ConstraintParallel", "ConstraintPerpendicular", "ConstraintEqual", "ConstraintCollinear"] {
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.equal", "sketch.constraint.collinear"] {
check_order(lines(), cmd, "l1", "l2");
check_order(lines(), cmd, "l2", "l1");
// The second line turns and slides; only Equal changes its length.
let mut s = lines();
let l0 = len(&s, "l2");
run(&mut s, cmd, json!({"a": "l1", "b": "l2"}));
if cmd != "ConstraintEqual" {
if cmd != "sketch.constraint.equal" {
assert!((len(&s, "l2") - l0).abs() < 1e-6, "{cmd}: {l0} -> {}", len(&s, "l2"));
} else {
assert!((len(&s, "l2") - len(&s, "l1")).abs() < 1e-6);
@@ -360,17 +360,17 @@ fn line_pairs_keep_the_first_line() {
fn circle_pairs_keep_the_first_circle() {
let circles = || {
let mut s = new_sketch();
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 10], "radius": 8}));
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
};
for cmd in ["ConstraintConcentric", "ConstraintEqual", "ConstraintTangent"] {
for cmd in ["sketch.constraint.concentric", "sketch.constraint.equal", "sketch.constraint.tangent"] {
check_order(circles(), cmd, "c1", "c2");
check_order(circles(), cmd, "c2", "c1");
}
// Equal resizes the second to the first.
let mut s = circles();
run(&mut s, "ConstraintEqual", json!({"a": "c2", "b": "c1"}));
run(&mut s, "sketch.constraint.equal", json!({"a": "c2", "b": "c1"}));
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
}
@@ -378,25 +378,25 @@ fn circle_pairs_keep_the_first_circle() {
fn point_constraints_keep_the_first_pick() {
let pts = || {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 25]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [20, 25]]}));
s
};
check_order(pts(), "ConstraintCoincident", "l2.start", "l1.end");
check_order(pts(), "ConstraintCoincident", "l1.end", "l2.start");
check_order(pts(), "sketch.constraint.coincident", "l2.start", "l1.end");
check_order(pts(), "sketch.constraint.coincident", "l1.end", "l2.start");
// Curve picked first, point second: the point goes onto the line.
check_order(pts(), "ConstraintCoincident", "l1", "l2.start");
check_order(pts(), "sketch.constraint.coincident", "l1", "l2.start");
// Midpoint: the point stays, the line moves.
let mut s = pts();
let p0 = snap(&s, "l2.end");
run(&mut s, "ConstraintMidPoint", json!({"point": "l2.end", "line": "l1"}));
run(&mut s, "sketch.constraint.midpoint", json!({"point": "l2.end", "line": "l1"}));
assert!(same(&snap(&s, "l2.end"), &p0));
// Symmetry: the first point and the line stay, the second mirrors.
let mut s = pts();
run(&mut s, "DrawPoint", json!({"point": [3, 7]}));
run(&mut s, "DrawPoint", json!({"point": [4, -9]}));
run(&mut s, "sketch.point", json!({"point": [3, 7]}));
run(&mut s, "sketch.point", json!({"point": [4, -9]}));
let (a0, l0) = (snap(&s, "p1"), snap(&s, "l1"));
run(&mut s, "ConstraintSymmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
assert!(same(&snap(&s, "p1"), &a0) && same(&snap(&s, "l1"), &l0));
assert!(pt(&s, "p2").dist(solvecraft_geom::Vec2::new(3.0, -7.0)) < 1e-6, "{:?}", pt(&s, "p2"));
}
@@ -418,8 +418,8 @@ fn palette_options_are_kept_with_each_sketch() {
assert!(v.hide_dimensions && v.no_snap && v.slice && v.three_d && !v.hide_points);
// Another sketch starts with the defaults; the first keeps its options after finishing.
let first = s.active_sketch.expect("sketch");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
assert_eq!(sketch(&s).view, solvecraft_sketch::SketchView::default());
let o = run(&mut s, "sketch.options", json!({"sketch": first}));
assert_eq!(o["options"]["show_dimensions"], false);
@@ -430,7 +430,7 @@ fn palette_options_are_kept_with_each_sketch() {
assert!(s.execute("sketch.options", &json!({"bogus": true})).is_err());
// Undo restores the option.
run(&mut s, "sketch.options", json!({"show_points": false}));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(!sketch(&s).view.hide_points);
}
@@ -448,7 +448,7 @@ fn v(x: f64, y: f64) -> solvecraft_geom::Vec2 {
#[test]
fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 20]]}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
drag(&mut s, "l1", [25.0, 15.0], [30.0, 25.0]);
assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9 && pt(&s, "l1.end").dist(v(45.0, 30.0)) < 1e-9);
drag(&mut s, "l1.end", [45.0, 30.0], [50.0, 0.0]);
@@ -459,7 +459,7 @@ fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
#[test]
fn dragging_a_rectangle_side_moves_that_side_only() {
let mut s = new_sketch();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [40, 30]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [40, 30]}));
// l2 is the right side (x = 40): dragged right, it stays vertical, the left side stays.
let left = snap(&s, "l4");
drag(&mut s, "l2", [40.0, 20.0], [50.0, 20.0]);
@@ -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, "CircleCenterRadius", 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);
@@ -479,7 +479,7 @@ fn dragging_circles_and_arcs() {
drag(&mut s, "c1.center", [0.0, 0.0], [20.0, 5.0]);
assert!(pt(&s, "c1.center").dist(v(20.0, 5.0)) < 1e-9);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
drag(&mut s, "a1", [57.07, 7.07], [50.0 + 200f64.sqrt(), 200f64.sqrt()]);
assert!(pt(&s, "a1.center").dist(v(50.0, 0.0)) < 1e-9);
assert!((snap(&s, "a1").1.unwrap_or(0.0) - 20.0).abs() < 1e-9, "{:?}", snap(&s, "a1"));
@@ -489,16 +489,16 @@ fn dragging_circles_and_arcs() {
#[test]
fn constrained_geometry_resists_drags() {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end"]}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end"]}));
let l0 = snap(&s, "l1");
drag(&mut s, "l1", [25.0, 10.0], [25.0, 30.0]);
assert!(same(&snap(&s, "l1"), &l0));
// A horizontal line dragged at its end stays horizontal; its length dimension holds.
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
drag(&mut s, "l1.end", [40.0, 10.0], [45.0, 20.0]);
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
@@ -507,12 +507,12 @@ fn constrained_geometry_resists_drags() {
#[test]
fn fully_constrained_feedback_is_per_entity() {
let mut s = new_sketch();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [50, 35]}));
run(&mut s, "CircleCenterRadius", json!({"center": [80, 20], "radius": 8}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 25}));
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [50, 35]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 20], "radius": 8}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 25}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 3);
for c in si["curves"].as_array().expect("curves") {
@@ -533,10 +533,10 @@ fn fully_constrained_feedback_is_per_entity() {
#[test]
fn chain_follows_shared_and_coincident_ends() {
let mut s = new_sketch();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "DrawPolyline", json!({"points": [[40, 0], [50, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[50.5, 0], [60, 5]]}));
run(&mut s, "CircleCenterRadius", json!({"center": [80, 0], "radius": 3}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.line", json!({"points": [[40, 0], [50, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[50.5, 0], [60, 5]]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 0], "radius": 3}));
let chain = |s: &mut Session, c: &str| -> Vec<String> {
let v = run(s, "sketch.chain", json!({"curve": c}));
v["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
@@ -545,7 +545,7 @@ fn chain_follows_shared_and_coincident_ends() {
assert_eq!(chain(&mut s, "l5"), ["l5"]);
assert_eq!(chain(&mut s, "c1"), ["c1"]);
// Joined by a coincident constraint, the two lines are one chain.
run(&mut s, "ConstraintCoincident", json!({"a": "l5.end", "b": "l6.start"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "l5.end", "b": "l6.start"}));
assert_eq!(chain(&mut s, "l6"), ["l6", "l5"]);
assert!(s.execute("sketch.chain", &json!({"curve": "nope"})).is_err());
}
@@ -553,9 +553,9 @@ 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, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30}));
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();
assert_eq!(ids.len(), 2);
let items: Vec<Value> = ids.iter().map(|id| json!({"type": "sketch_constraint", "id": id})).collect();
@@ -603,14 +603,14 @@ fn three_d_curves_need_the_option_and_move_joined() {
#[test]
fn pipe_and_sweep_follow_3d_sketch_curves() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
let r = run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 30], [20, 0, 30]]}));
let mut path = curve_ids(&r);
let sp = run(&mut s, "sketch.spline3d", json!({"points": [[20, 0, 30], [35, 10, 35], [50, 0, 50]]}));
path.extend(curve_ids(&sp));
run(&mut s, "SketchStop", json!({}));
let p = run(&mut s, "PrimitivePipe", json!({"path_sketch": "Path", "path": path, "diameter": 4}));
run(&mut s, "sketch.finish", json!({}));
let p = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": path, "diameter": 4}));
let si = run(&mut s, "document.inspect", json!({"measure": true}));
let body = si["bodies"].as_array().and_then(|b| b.first()).cloned().unwrap_or_default();
// The tube runs from the first line's start to the spline's end (radius 2 around them; the
+154
View File
@@ -0,0 +1,154 @@
//! Surface commands: Patch, Stitch, Thicken and Trim (surface bodies and turning them into
//! solids).
use serde_json::Value;
use solvecraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind};
use super::CommandSpec;
use super::features::{add_feature, check_expr, feature_sketch, operation, plane_param, profiles};
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges) and body?; name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
.params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"),
CommandSpec::new("surface.thicken", "Thicken", thicken)
.at("SURFACE", "CREATE")
.icon("thicken")
.params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"),
CommandSpec::new("surface.extend", "Extend", extend)
.at("SURFACE", "MODIFY")
.icon("extend")
.params("body: planar surface name; edges: [[x,y,z]] (points on straight edges to push out); distance: expr"),
CommandSpec::new("surface.trim", "Trim", trim)
.at("SURFACE", "MODIFY")
.icon("trim")
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
];
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.patch";
let edges: Vec<solvecraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if !edges.is_empty() {
if edges.len() > 10_000 {
return Err(bad(cmd, "too many edges"));
}
let body = str_(p, "body").map(str::to_string);
return add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges });
}
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new() })
}
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
}
let st = s.model.state();
if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) {
return Err(bad(cmd, format!("no body `{b}`")));
}
Ok(bodies)
}
fn stitch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.stitch";
let bodies = bodies_param(s, p, cmd)?;
let tolerance = expr(p, "tolerance").unwrap_or_else(|| "0".into());
check_expr(s, &tolerance, Kind::Length, cmd, "tolerance")?;
add_feature(s, p, FeatureKind::Stitch { bodies, tolerance })
}
fn thicken(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.thicken";
let bodies = bodies_param(s, p, cmd)?;
let thickness = expr(p, "thickness").ok_or_else(|| bad(cmd, "`thickness` is required"))?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let kind = FeatureKind::Thicken {
bodies,
thickness,
symmetric: bool_(p, "symmetric").unwrap_or(false),
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
};
add_feature(s, p, kind)
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.extend";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let edges: Vec<solvecraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if edges.is_empty() || edges.len() > 10_000 {
return Err(bad(cmd, "`edges` must list points on the edges to extend"));
}
let distance = expr(p, "distance").ok_or_else(|| bad(cmd, "`distance` is required"))?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::SurfaceExtend { body, edges, distance })
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.trim";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let keep = p.get("keep").and_then(vec3).ok_or_else(|| bad(cmd, "`keep` must be [x, y, z] on the side to keep"))?;
let (plane, tool) = match p.get("tool").filter(|t| !t.is_null()) {
Some(t) => {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
(PlaneRef::Origin { name: "XY".into() }, Some(FaceAt { body: tb, point }))
}
None => (plane_param(s, p.get("plane"), cmd)?, None),
};
add_feature(s, p, FeatureKind::SurfaceTrim { body, plane, tool, keep })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use serde_json::json;
fn run(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
fn volume(s: &Session, name: &str) -> f64 {
let st = s.world_state();
solvecraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
}
/// A rectangle patched, trimmed by a plane and thickened; a box's faces stitched back.
#[test]
fn patch_trim_thicken_and_stitch() {
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]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.patch", json!({"sketch": "S", "body_name": "Sheet"}));
let st = s.world_state();
assert!(st.body("Sheet").unwrap().body.is_surface());
run(&mut s, "surface.trim", json!({"body": "Sheet", "plane": {"origin": [3, 0, 0], "normal": [1, 0, 0]}, "keep": [8, 5, 0]}));
run(&mut s, "surface.extend", json!({"body": "Sheet", "edges": [[10, 5, 0]], "distance": 5}));
run(&mut s, "surface.thicken", json!({"bodies": ["Sheet"], "thickness": "2 mm", "body_name": "Slab"}));
let v = volume(&s, "Slab");
assert!((v - 240.0).abs() < 1e-9, "{v}");
// Stitch the patch of a box's open top back on.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
run(&mut s, "surface.patch", json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}));
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
}
}
+18 -20
View File
@@ -9,27 +9,27 @@ use crate::params::{bad, bool_, num, vec3};
use crate::{Result, Session, SurfaceAnalysis};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionZebraAnalysisCommand", "Zebra Analysis", zebra)
CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra)
.at("SKETCH", "INSPECT")
.icon("zebra")
.noundo()
.params("stripes?: number across the view (default 12); clear?: true turns the analysis off"),
CommandSpec::new("FusionDraftAnalysisCommand", "Draft Analysis", draft)
CommandSpec::new("inspect.draft", "Draft Analysis", draft)
.at("SKETCH", "INSPECT")
.icon("draft_analysis")
.noundo()
.params("pull?: [x,y,z] direction (default Z); angle?: degrees (default 1); clear?: true turns the analysis off"),
CommandSpec::new("FusionEnvironmentMapAnalysisCommand", "Environment Map Analysis", |s, p| set(s, p, SurfaceAnalysis::Environment))
CommandSpec::new("inspect.environment_map", "Environment Map Analysis", |s, p| set(s, p, SurfaceAnalysis::Environment))
.at("SKETCH", "INSPECT")
.icon("environment_map")
.noundo()
.params("clear?: true turns the analysis off"),
CommandSpec::new("FusionAccessibilityAnalysisCommand", "Accessibility Analysis", accessibility)
CommandSpec::new("inspect.accessibility", "Accessibility Analysis", accessibility)
.at("SKETCH", "INSPECT")
.icon("accessibility")
.noundo()
.params("direction?: [x,y,z] the tool comes from (default Z, from above); clear?: true turns the analysis off"),
CommandSpec::new("FusionCurvatureMapAnalysisCommand", "Curvature Map Analysis", curvature_map)
CommandSpec::new("inspect.curvature_map", "Curvature Map Analysis", curvature_map)
.at("SKETCH", "INSPECT")
.icon("curvature_map")
.noundo()
@@ -49,13 +49,13 @@ fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
fn zebra(s: &mut Session, p: &Value) -> Result<Value> {
let stripes = num(p, "stripes").unwrap_or(12.0);
if !(stripes.is_finite() && (1.0..=200.0).contains(&stripes)) {
return Err(bad("FusionZebraAnalysisCommand", "`stripes` must be 1…200"));
return Err(bad("inspect.zebra", "`stripes` must be 1…200"));
}
set(s, p, SurfaceAnalysis::Zebra { stripes })
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionDraftAnalysisCommand";
let cmd = "inspect.draft";
let pull = match p.get("pull") {
Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad(cmd, "`pull` must be a non-zero [x,y,z]"))?,
None => Vec3::Z,
@@ -69,16 +69,14 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
fn accessibility(s: &mut Session, p: &Value) -> Result<Value> {
let dir = match p.get("direction") {
Some(v) => {
vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("FusionAccessibilityAnalysisCommand", "`direction` must be a non-zero [x,y,z]"))?
}
Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("inspect.accessibility", "`direction` must be a non-zero [x,y,z]"))?,
None => Vec3::Z,
};
set(s, p, SurfaceAnalysis::Access { dir })
}
fn curvature_map(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCurvatureMapAnalysisCommand";
let cmd = "inspect.curvature_map";
let radius = match num(p, "radius") {
Some(r) if r.is_finite() && r > 1e-6 && r < 1e9 => r,
Some(_) => return Err(bad(cmd, "`radius` must be positive")),
@@ -100,22 +98,22 @@ mod tests {
#[test]
fn surface_analyses_are_view_state() {
let mut s = Session::default();
s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
let undo = s.undo.len();
s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 20})).unwrap();
s.execute("inspect.zebra", &json!({"stripes": 20})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Zebra { stripes: 20.0 }));
s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 2], "angle": 3})).unwrap();
s.execute("inspect.draft", &json!({"pull": [0, 0, 2], "angle": 3})).unwrap();
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Draft { angle, .. }) if angle == 3.0));
s.execute("FusionCurvatureMapAnalysisCommand", &json!({})).unwrap();
s.execute("inspect.curvature_map", &json!({})).unwrap();
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Curvature { radius }) if (radius - 300f64.sqrt() / 10.0).abs() < 1e-6));
s.execute("FusionEnvironmentMapAnalysisCommand", &json!({})).unwrap();
s.execute("inspect.environment_map", &json!({})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Environment));
s.execute("FusionAccessibilityAnalysisCommand", &json!({"direction": [0, 0, -3]})).unwrap();
s.execute("inspect.accessibility", &json!({"direction": [0, 0, -3]})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Access { dir: solvecraft_geom::Vec3::new(0.0, 0.0, -1.0) }));
s.execute("FusionCurvatureMapAnalysisCommand", &json!({"clear": true})).unwrap();
s.execute("inspect.curvature_map", &json!({"clear": true})).unwrap();
assert_eq!(s.analysis, None);
assert_eq!(s.undo.len(), undo, "no undo steps");
assert!(s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 0})).is_err());
assert!(s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 0]})).is_err());
assert!(s.execute("inspect.zebra", &json!({"stripes": 0})).is_err());
assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 0]})).is_err());
}
}
+274
View File
@@ -0,0 +1,274 @@
//! Construction axes, points and planes (SOLID ▸ CONSTRUCT), one test per type, against a
//! 40 × 30 × 20 box at the origin, a cylinder, a sphere and a torus.
use serde_json::{Value, json};
use solvecraft_geom::{Plane, Vec3};
use crate::Session;
use crate::view::{construction_axes, construction_planes, construction_points};
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 part() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Box"}));
run(&mut s, "solid.cylinder", json!({"base": [60, 0, 0], "radius": 5, "height": 10, "body_name": "Cyl"}));
run(&mut s, "solid.sphere", json!({"center": [0, 0, 60], "radius": 5, "body_name": "Ball"}));
run(&mut s, "solid.torus", json!({"center": [0, 60, 0], "major": 10, "minor": 2, "body_name": "Ring"}));
s
}
fn near(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-3
}
fn parallel(a: Vec3, b: Vec3) -> bool {
a.normalized().zip(b.normalized()).is_some_and(|(a, b)| a.cross(b).len() < 1e-6)
}
fn axis(s: &Session, name: &str) -> (Vec3, Vec3) {
construction_axes(s).into_iter().find(|a| a.1 == name).map(|a| (a.2, a.3)).unwrap_or_else(|| panic!("no axis {name}"))
}
fn point(s: &Session, name: &str) -> Vec3 {
construction_points(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no point {name}"))
}
fn plane(s: &Session, name: &str) -> Plane {
construction_planes(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no plane {name}"))
}
/// The line through `o` along `d` contains `p`.
fn on_line(o: Vec3, d: Vec3, p: Vec3) -> bool {
let d = d.normalized().unwrap_or(Vec3::Z);
(p - o - d * (p - o).dot(d)).len() < 1e-3
}
fn on_plane(pl: &Plane, p: Vec3) -> bool {
(p - pl.origin).dot(pl.normal()).abs() < 1e-3
}
#[test]
fn axis_through_cylinder_cone_or_torus() {
let mut s = part();
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [65, 0, 5]}, "name": "A"}));
let (o, d) = axis(&s, "A");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(60.0, 0.0, 0.0)), "{o:?} {d:?}");
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [12, 60, 0]}, "name": "T"}));
let (o, d) = axis(&s, "T");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(0.0, 60.0, 0.0)), "{o:?} {d:?}");
// A flat face has no axis.
assert!(s.execute("construct.axis.through_cylinder", &json!({"face": {"face": [20, 15, 20]}})).is_err());
}
#[test]
fn axis_perpendicular_to_a_face_at_a_point() {
let mut s = part();
run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [10, 10, 20]}, "point": {"point": [10, 10, 25]}, "name": "N"}));
let (o, d) = axis(&s, "N");
assert!(near(o, Vec3::new(10.0, 10.0, 20.0)) && parallel(d, Vec3::Z), "{o:?} {d:?}");
// On the cylinder: radial through the point.
run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [65, 0, 5]}, "point": {"point": [70, 0, 5]}, "name": "R"}));
let (o, d) = axis(&s, "R");
assert!(near(o, Vec3::new(65.0, 0.0, 5.0)) && parallel(d, Vec3::X), "{o:?} {d:?}");
}
#[test]
fn axis_through_two_planes() {
let mut s = part();
run(&mut s, "construct.axis.two_planes", json!({"a": {"plane": "XZ"}, "b": {"face": [40, 15, 10]}, "name": "L"}));
let (o, d) = axis(&s, "L");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(40.0, 0.0, 0.0)), "{o:?} {d:?}");
assert!(s.execute("construct.axis.two_planes", &json!({"a": "XY", "b": {"face": [20, 15, 20]}})).is_err(), "parallel");
}
#[test]
fn axis_through_two_points() {
let mut s = part();
run(&mut s, "construct.axis.two_points", json!({"a": {"vertex": [0.5, 0.5, 0.5]}, "b": {"vertex": [39, 29, 19]}, "name": "D"}));
let (o, d) = axis(&s, "D");
assert!(on_line(o, d, Vec3::ZERO) && parallel(d, Vec3::new(40.0, 30.0, 20.0)), "{o:?} {d:?}");
}
#[test]
fn axis_through_an_edge() {
let mut s = part();
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 0, 0]}, "name": "E"}));
let (o, d) = axis(&s, "E");
assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::ZERO), "{o:?} {d:?}");
// A circular edge is not straight.
assert!(s.execute("construct.axis.edge", &json!({"edge": {"edge": [65, 0, 10]}})).is_err());
}
#[test]
fn point_at_a_vertex() {
let mut s = part();
run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [39, 29, 19]}, "name": "P"}));
assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0)));
}
#[test]
fn point_through_two_edges() {
let mut s = part();
run(&mut s, "construct.point.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 0]}, "name": "P"}));
assert!(near(point(&s, "P"), Vec3::new(40.0, 0.0, 0.0)), "{:?}", point(&s, "P"));
}
#[test]
fn point_through_three_planes() {
let mut s = part();
run(
&mut s,
"construct.point.three_planes",
json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "P"}),
);
assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0)), "{:?}", point(&s, "P"));
}
#[test]
fn point_at_the_centre_of_a_circle_sphere_or_torus() {
let mut s = part();
run(&mut s, "construct.point.center", json!({"of": {"edge": [65, 0, 10]}, "name": "C"}));
assert!(near(point(&s, "C"), Vec3::new(60.0, 0.0, 10.0)), "{:?}", point(&s, "C"));
run(&mut s, "construct.point.center", json!({"of": {"face": [5, 0, 60]}, "name": "S"}));
assert!(near(point(&s, "S"), Vec3::new(0.0, 0.0, 60.0)), "{:?}", point(&s, "S"));
run(&mut s, "construct.point.center", json!({"of": {"face": [12, 60, 0]}, "name": "T"}));
assert!(near(point(&s, "T"), Vec3::new(0.0, 60.0, 0.0)), "{:?}", point(&s, "T"));
}
#[test]
fn point_at_edge_and_plane() {
let mut s = part();
run(
&mut s,
"construct.point.edge_plane",
json!({"edge": {"edge": [20, 0, 0]}, "plane": {"plane": {"origin": [12, 0, 0], "normal": [1, 0, 0]}}, "name": "P"}),
);
assert!(near(point(&s, "P"), Vec3::new(12.0, 0.0, 0.0)), "{:?}", point(&s, "P"));
}
#[test]
fn point_along_a_path() {
let mut s = part();
run(&mut s, "construct.point.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.25, "name": "P"}));
let p = point(&s, "P");
assert!(near(p, Vec3::new(10.0, 0.0, 0.0)) || near(p, Vec3::new(30.0, 0.0, 0.0)), "{p:?}");
}
#[test]
fn midplane_between_parallel_and_meeting_faces() {
let mut s = part();
run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "M"}));
let m = plane(&s, "M");
assert!(parallel(m.normal(), Vec3::Z) && on_plane(&m, Vec3::new(5.0, 5.0, 10.0)), "{m:?}");
// Two sides meeting at the corner (40, 30): the diagonal plane through it.
run(&mut s, "construct.plane.midplane", json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "name": "Q"}));
let q = plane(&s, "Q");
assert!(on_plane(&q, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&q, Vec3::new(41.0, 31.0, 5.0)), "{q:?}");
}
#[test]
fn plane_through_two_edges() {
let mut s = part();
run(&mut s, "construct.plane.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [20, 30, 20]}, "name": "E"}));
let e = plane(&s, "E");
// The front-bottom and back-top edges: the slanted plane through both.
assert!(on_plane(&e, Vec3::new(5.0, 0.0, 0.0)) && on_plane(&e, Vec3::new(5.0, 30.0, 20.0)), "{e:?}");
assert!(s.execute("construct.plane.two_edges", &json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 20]}})).is_err(), "skew");
}
#[test]
fn plane_through_three_points() {
let mut s = part();
run(
&mut s,
"construct.plane.three_points",
json!({"a": {"vertex": [0, 0, 20]}, "b": {"vertex": [40, 0, 20]}, "c": {"vertex": [40, 30, 0]}, "name": "T"}),
);
let t = plane(&s, "T");
assert!(on_plane(&t, Vec3::new(0.0, 0.0, 20.0)) && on_plane(&t, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&t, Vec3::new(0.0, 30.0, 0.0)), "{t:?}");
}
#[test]
fn plane_tangent_to_a_cylinder() {
let mut s = part();
run(&mut s, "construct.plane.tangent", json!({"face": {"face": [65, 0, 5]}, "name": "T"}));
let t = plane(&s, "T");
assert!(parallel(t.normal(), Vec3::X) && on_plane(&t, Vec3::new(65.0, 3.0, 9.0)), "{t:?}");
// And to the sphere, where the pick lies over it.
run(&mut s, "construct.plane.tangent", json!({"face": {"face": [0, 0, 65]}, "name": "U"}));
let u = plane(&s, "U");
assert!(parallel(u.normal(), Vec3::Z) && on_plane(&u, Vec3::new(3.0, 3.0, 65.0)), "{u:?}");
}
#[test]
fn plane_along_a_path() {
let mut s = part();
run(&mut s, "construct.plane.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.5, "name": "P"}));
let p = plane(&s, "P");
assert!(parallel(p.normal(), Vec3::X) && on_plane(&p, Vec3::new(20.0, 7.0, 3.0)), "{p:?}");
// A sketch can be placed on it.
run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"}));
}
/// Built from model geometry, they follow when the model changes: the box gets taller.
#[test]
fn construction_follows_the_model() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "h", "expression": "20 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": "h", "body_name": "Box"}));
run(
&mut s,
"construct.point.three_planes",
json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "Corner"}),
);
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 30, 20]}, "name": "TopBack"}));
run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "Mid"}));
run(&mut s, "parameters.change", json!({"name": "h", "expression": "50 mm"}));
assert!(near(point(&s, "Corner"), Vec3::new(40.0, 30.0, 50.0)), "{:?}", point(&s, "Corner"));
let (o, d) = axis(&s, "TopBack");
assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::new(0.0, 30.0, 50.0)), "{o:?} {d:?}");
assert!(on_plane(&plane(&s, "Mid"), Vec3::new(1.0, 1.0, 25.0)));
// Construction from construction: an axis through two construction points.
run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [0, 0, 0]}, "name": "Origin"}));
run(&mut s, "construct.axis.two_points", json!({"a": {"construct_point": "Origin"}, "b": {"construct_point": "Corner"}, "name": "Diag"}));
let (o, d) = axis(&s, "Diag");
assert!(on_line(o, d, Vec3::new(40.0, 30.0, 50.0)) && on_line(o, d, Vec3::ZERO));
}
/// In a component moved 50 mm along X, picks given in the world land on its geometry, and the
/// axis and point move with the occurrence.
#[test]
fn construction_lives_in_the_component_frame() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Part"}));
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "body_name": "Pin"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]}));
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [55, 0, 5]}, "name": "PinAxis"}));
run(&mut s, "construct.point.center", json!({"of": {"edge": [55, 0, 10]}, "name": "PinTop"}));
let (o, d) = axis(&s, "PinAxis");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(50.0, 0.0, 0.0)), "{o:?} {d:?}");
assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 0.0, 10.0)), "{:?}", point(&s, "PinTop"));
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());
}

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