Compare commits

...

164 Commits

Author SHA1 Message Date
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck c36a6f9f90 Release 0.12.0 (#1414) 2026-10-15 09:31:54 -04:00
bherbruck 6eaf7458ab Fillet/chamfer: point the drag arrow into the corner on inside edges (#1394)
* Fillet/chamfer: point the drag arrow into the corner on inside edges (#1384)

The radius/distance arrow always pointed against the faces' outward
bisector, which is into the body on an outside edge but away from the
corner on an inside one, where the blend adds material. The arrow and
the spline radius handles now take each edge's convexity into account,
so dragging out along the arrow grows the value on both kinds of edge,
and each edge of a mixed chain follows its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: no unreachable! in the blend arrow helper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:58:51 -04:00
_ryu15_ ad30a2350e Navigation: choose the orbit centre: view centre, origin or point under the cursor (#1377)
Preferences ▸ Navigation ▸ Orbit around now offers View centre (the default, as before), Origin and Point under the cursor. A saved orbit_cursor preference migrates to the cursor option, and the control channel still accepts the old key.

Thanks to @kuraneko1 for this.

Co-authored-by: kuraneko1 <98832587+kuraneko1@users.noreply.github.com>
2026-10-15 08:57:20 -04:00
bherbruck 4b3b48388c Modelled threads: shuffle the faceted boolean's polygons before the BSP (#1250) (#1395)
The groove's facets came in sweep order, and the BSP takes each list's first
polygon as its splitter, so the tree was as deep as the list: quadratic time and
memory. An M8 x 60 mm thread ran out of tree depth after 12.9 GB. A fixed shuffle
gives a shallow tree and the same result (volume and faces equal to 1e-9).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:53:15 -04:00
rlartot 0d99d33c38 French: catch up with main (#1331)
fr.json covers the messages main added since #1224 (render workspace, design properties, display quality, surface untrim, joint drive stops, Save as Mesh), with the same terminology conventions.
2026-10-15 08:50:57 -04:00
bherbruck bbfad166b0 Sketch: a dimension's extension lines reach the nearest part of the lines it measures (#1361) (#1388)
A dimension to a line, or between two parallel lines, drew its extension lines from the
measured end point and its foot on the other line, so they ran the whole length of the
lines to wherever that end happened to be. They now start from the nearest point of each
line to the dimension line, and disappear where the dimension line already crosses it.

Fixes #1361

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:38:38 -04:00
NOWN-DEV e95e32d72a Sheet metal: a cut names the pieces it parts from the base (#1338)
A sheet-metal cut that parts pieces from the base names them, and the guide explains it.
2026-10-15 08:34:01 -04:00
NOWN-DEV 6fa9e0d2dd Convert to Sheet Metal finishes corners and bend ends as the rule says (#1340)
Converted sheets take the rule's corner relief between walls and its bend relief where a bend ends beside more of the fixed face, and a wall on part of an edge bends only where it stands. Noted under format 21; nothing new is saved.
2026-10-15 08:33:53 -04:00
Karl Von Richter 8a6332be83 Fix face Extrude Join targets in assemblies (#1375)
A new face-based Extrude Join (and outward Press Pull) joins the picked face's body by default and stores that scope, so an edge-touching neighbour is no longer tried first or absorbed. Explicit targets and participants still win. Related to #226.
2026-10-15 08:33:48 -04:00
Mateusz Sych 4674f0a61c Joint dialog: say why picks on one component make no joint (#1367)
The Joint and As-built Joint dialogs explain why picks make no joint and offer Create Components from Bodies when every pick is on one component; joint.create and joint.as_built suggest component.from_bodies. Labels in all catalogs. Fixes #1239.
2026-10-15 08:22:54 -04:00
CamWight 6fbb1c5d91 The offscreen GPU tests start one backend, as the app does (#1342)
Offscreen GPU test devices start the backend the app would choose (WGPU_BACKEND, else one hardware backend on Windows) instead of every backend at once. Test-only. Fixes #1330.
2026-10-15 08:22:50 -04:00
DuBearte d05fe3d4de Navigation: a Blender preset (#1356)
Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone. Opt-in preset; the default is unchanged. Fixes #1090.
2026-10-15 08:22:46 -04:00
DuBearte 56622e4be8 Sketch: preview the whole midpoint line while drawing it (#1371)
The Midpoint Line tool previews the line from the cursor's mirror image through the middle to the cursor, which is what the second click commits. Fixes #1358.
2026-10-15 08:22:42 -04:00
DuBearte 4dc638109b Parameters: move user parameters up and down (#1323)
Fixes #1187. New parameters.move {name, before|after} reorders user parameters (the design's own params order, so it is saved with no format change, and undoable); the Parameters dialog's user rows get up/down buttons. Label in every catalog. I merged main in. Checked on main + this branch: cargo check --workspace, the engine parameters tests, the UI parameter_order test, and docs/features.md regenerates unchanged.
2026-10-15 06:34:16 -04:00
NOWN-DEV d7a4a29ce3 Flat-pattern DXF for a laser shop: layers by role, dashed bend lines, bend notes, a file per body (#1324)
Closes #1284. sheet.export_dxf writes layers by role (OUTLINE, CUT, TEXT or ETCH, dashed BEND_UP/BEND_DOWN, optional BEND_NOTES, POCKET_<side>_Z…_MM and PERFORATION), inner contours before the outline; text: cut|etch, bend_notes and note_height, per_body files. Letters are derived from the timeline, nothing new is saved (format unchanged). I merged main in (the export's parameter list keeps main's pocket layer names). Checked on main + this branch: cargo check --workspace, the engine cmd::sheet tests (160) and the io DXF tests.
2026-10-15 06:34:06 -04:00
Lasse Lian 9fbbf8dc3a Edit Sketch no longer shows bodies a later feature deletes (#1329)
Fixes #1167. While a sketch is edited, a body that a rolled-off, unsuppressed Remove deletes is not shown (unless the sketch is built on it); the history stays rolled back, only the view changes. Drawing, picking, screenshots, thumbnails and the browser's Bodies folder agree; document.inspect reports removed_later. Checked on main + this branch: cargo check --workspace, the engine sketch_edit_removed/occurrences tests and the UI viewport/browser tests.
2026-10-15 06:34:01 -04:00
Irakli Kourtis 5fa8e7d60a Sweep: orientation, twist and end scale (#1269)
Part of #771 and #772. solid.sweep and surface.sweep take orientation (perpendicular, parallel or a direction), twist (up to 4 turns) and end scale (0.01 to 100), applied about the profile's normal through the path's start; default options run the old sweep, so existing sweeps are unchanged. New fields are optional, left out when unset and noted under format 21; twist and scale are model parameters. Both sweep dialogs get Orientation, Twist and End Scale rows; labels in every catalog. Checked on main + this branch: cargo check --workspace, kernel/engine/UI sweep tests, the doc suite and the robustness goldens.
2026-10-15 06:24:31 -04:00
froglemonade2 0872eb959a Save as mesh: one dialog for STL and 3MF, with Send to slicer (#1272)
One docked Save as mesh dialog replaces Export STL / 3MF / 3MF with face tags, with an optional Send to slicer that lists the slicers installed on this machine. Engine: file.save_mesh and file.slicers; the Convert Mesh dialog goes (mesh.convert and the MeshConvert feature stay, so existing designs open). Strings in all catalogs; screenshots in the PR. Checked on main + this branch: cargo check --workspace, the engine slicer/mesh/file tests, the UI catalog and menu tests and scenario s14 (the one local colour-token failure is epaint's TexturesDelta panic, which main shows on this machine too).
2026-10-15 03:51:28 -04:00
DuBearte 3e13eb6392 Sketch: symmetry about the origin point or an origin axis (#1318)
Fixes #1075. sketch.constraint.symmetry takes a centre point (the origin or any sketch point) as well as a line; the Symmetry tool's third pick may be the origin point or the sketch's own X/Y origin axis. Checked on main + this branch: cargo check --workspace, the engine cmd::sketch tests (257) and the UI symmetry pick tests.
2026-10-15 03:47:37 -04:00
bherbruck 6f115ad740 Optional supersampling (SSAA) for the viewport, adaptive and capped to the texture limit (#1288) (#1321)
* Optional supersampling (SSAA) for the viewport, adaptive and within the texture limit (#1288)

Preferences ▸ Graphics ▸ Supersampling: Off (default), 1.5× or 2×. The frame is drawn into
textures of the viewport's own kind (format, depth/stencil, MSAA) at that multiple of its pixels,
over transparent black, then filtered down into the viewport with four bilinear taps (the exact
2 × 2 box at 2×) and composited premultiplied over the background. Lines keep their on-screen
width; silhouettes and edges come out smoother.

Adaptive (on by default): the view is drawn at full speed while the camera moves and
supersampled once it has held still for a quarter of a second.

The supersampled size stays within the device's largest texture side (#869) and a 384 MiB
budget for colour and depth (MSAA samples included); under 1.25× is not worth it and the frame
is drawn as usual.

Labels in en/de/ja/ko/zh; `ui.prefs` `supersampling` and `supersampling_adaptive`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Supersampling: factor and adaptive on one row; Off reads Off (1×)

The Graphics page grew past the colour vision menu in the preferences scenario, and a second
Off on the page took its click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 03:21:55 -04:00
NOWN-DEV b3df2c382b snapshot --camera and saved views; grid: false / axes: false for headless renders (#1313)
solidcraft-cli snapshot takes --camera JSON and --view with a saved view name (through view.apply, as the MCP screenshot does), plus --no-grid and --no-axes; the MCP screenshot and ui.render take grid/axes, the headless render skips a hidden origin axis, and the app's ui.render follows the Grid and Origin switches. Closes #1245.
2026-10-15 02:59:56 -04:00
NOWN-DEV d824d36821 Convert to Sheet Metal takes bent parts as modelled: radius and chamfer bends, holes (#1314)
A Convert to Sheet Metal bend point can now lie on a bend modelled with its radius (a cylinder tangent to two flat faces) or in the middle of a chamfer; either becomes one bend at the faces' virtual sharp, with the rule's radius. Holes and engravings carry over. Bend points on edges convert as before. Recorded under format 21. Tests in cmd::sheet::convert_tests; the sheet-metal guide is updated.
2026-10-15 02:54:09 -04:00
Mateusz Sych 849426e866 Drive Joints opens on the picked joint; ui.start takes params (#1297)
Drive Joints opens on the joint picked in the browser, else the first joint that moves, and won't start on a rigid joint. ui.start takes params: joint.drive opens on a joint (id or name) at given values, driving nothing until OK; a command or param it doesn't take is refused.
2026-10-15 02:54:02 -04:00
NOWN-DEV 0ee5e97db5 STEP import takes up for Y-up files and names for the bodies' names (#1312)
file.insert_step and doc.open on a STEP file take `up` (turn a Y-up file so its up is +Z) and `names` (names for the imported bodies). The MCP tool and the app's control requests pass them through; an import without them works as before. Tests in the kernel's STEP reader, the engine, MCP and the file-load UI; docs/step-import.md and docs/mcp.md describe both.
2026-10-15 02:18:55 -04:00
Mateusz Sych 81d9b7e04a Agent skill for mechanisms: joints by coordinates, loops, links, motion (#1315)
A solidcraft-mechanisms agent skill with a worked example that a test replays through the MCP server (the actuator follows the hinge with the stated stroke and the drive stops at the rod's limit); the skills index and docs/mcp.md list it.
2026-10-15 02:13:48 -04:00
DuBearte 6ef0b22118 Viewport: a legend for the Draft, Accessibility and Curvature Map analyses (#1299)
While Draft, Accessibility or Curvature Map analysis is on, a legend at the bottom left of the viewport says what each colour means and which values it stands for, with the theme's analysis colours and stripes. Labels translated in en/de/ja/ko/zh. Fixes #973.
2026-10-15 02:13:44 -04:00
DuBearte 873a65fd4d SpaceMouse: on Linux, default to the driver's object-mode axes (#1292)
Without a 3DxWare profile, Linux now uses the driver's object-mode mapping (physical Ry and Rz swapped and reversed), so a tilt rolls and a twist spins. Windows and macOS are unchanged; a profile still takes precedence. Fixes #1172.
2026-10-15 02:13:22 -04:00
Mateusz Sych 1473893b08 MCP: a bridged server starts without the app and connects once it runs (#1298)
The bridged MCP server starts when no app is listening, says so in its instructions and in each tool error, and connects on the first call after the app starts. A batch that could not be sent says the app is not running. docs/mcp.md describes it.
2026-10-15 02:13:18 -04:00
Mateusz Sych 96356dc544 CLI snapshot takes --in; an unknown command suggests close ids (#1301)
snapshot starts from --in (a design or STEP file) with the script on top, as run does, and refuses options it doesn't take; an unknown command id names the closest registered ids.
2026-10-15 02:13:14 -04:00
Mateusz Sych 50c5c13f72 ui.camera: read, set and replay the viewport camera (#1302)
A control-channel method that reads the orbit camera, sets any of target/yaw/pitch/roll/distance, and fits listed occurrences (or everything) keeping the direction. Its reply passed back sets the same view again; a refused field changes nothing.
2026-10-15 02:13:09 -04:00
Mateusz Sych 7195e323bf Joint drives say when they move nothing; drive values in degrees; quiet replies (#1304)
joint.drive answers moved, and a reason (with unconnected) when the joint can move nothing; motion.play answers moved and unmoved; joint.list answers floating chains. joint.drive values come back in joint.list's shape (deg/mm). quiet: true trims joint answers and motion.play poses. A single value keeps a joint's other values. Drive Joints shows the floating case (drive.unconnected, all locales).
2026-10-15 02:12:58 -04:00
Mateusz Sych fd19efc6fc component.list: a compact form for large assemblies (#1305)
component.list {compact: true} gives counts of bodies, features, sketches and construction planes instead of their names, lists occurrences without identity transforms and children as occurrence ids; bodies: true keeps body names. The default form is unchanged.
2026-10-15 02:12:48 -04:00
bherbruck 9eddb1cbd3 Preferences ▸ Graphics ▸ Display quality (Coarse/Normal/Fine/Ultra) (#1287) (#1319)
* Preferences ▸ Graphics ▸ Display quality: Coarse, Normal, Fine, Ultra (#1287)

A viewing preference that sets how finely the viewport tessellates curved faces and edges:
chord tolerance relative to each body's size (3e-3, 1e-3, 3e-4, 1e-4) and the most a facet may
turn (30, 20, 12, 7 degrees). Normal is the tolerance the viewport has always used.

Bodies keep a display plan per level, so switching is a re-plan the first time and instant
after; the reference mesh the model reads (picks, holes, measuring) and exports are unchanged.
Away from Normal the model scene builds edges, and faces on the CPU path, from the plan at that
level, so silhouettes and edges get finer with the faces and a highlight still lies on the
displayed triangles.

The preference is saved with the others, set over the control channel (`ui.prefs`
`display_quality`), labelled in en/de/ja/ko/zh. `solidcraft-cli bench-open --display LEVEL
[--edit NAME EXPR]` times the plans at a level and after a parameter edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Highlighted edges follow the display quality too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:06:49 -04:00
NOWN-DEV 47517578a8 Test: an inside flange on a turned plate closes at every whole-degree turn (#1311)
A regression test: an inside flange on a 100 x 60 plate turned by every whole degree from 0 to 89 builds and keeps the analytic volume. At 62 degrees the trimmed corner used to land on a border of the sew's vertex grid and the flange failed to close.
2026-10-15 02:05:18 -04:00
Karl Von Richter d6f167a88a Fix boss-bottom blends and merged arc-corner surfaces (#1294)
A fillet or chamfer on a cylindrical boss's bottom arc now builds with a closed display mesh: the seam-extension containment ray counts a shared curved-boundary endpoint once. Merging the split faces of a three-edge arc corner rebuilds the face on the original input face's full surface instead of a piece's restricted patch, and runs under the kernel's panic guard. Fixes #1262 and #1263.

Co-authored-by: Karl-VonRichter <20079846+Karl-VonRichter@users.noreply.github.com>
2026-10-15 02:03:31 -04:00
Raymond Thurman af2f09fac2 A new empty design takes Units of new designs (#1283)
The empty design shown at launch, New on a design that is still empty, file.new and the blank design left when the last tab closes all take the Units of new designs preference. Opened files and the sample part keep their own units. Fixes #1205.

Co-authored-by: raythurman2386 <raythurman2386@users.noreply.github.com>
2026-10-15 01:20:02 -04:00
DuBearte 7391e8ed75 Section: leave the origin and construction planes and axes whole (#1286)
Section Analysis clipped the origin and construction planes and axes along with the model. They now draw from their own unclipped lists, with the same depth and blending; bodies, edges, sketches and highlights are clipped as before. Fixes #1229.

Co-authored-by: DuBearte <DuBearte@users.noreply.github.com>
2026-10-15 01:19:59 -04:00
Karl Von Richter 1ec33fcdc7 Extrude: a symmetric distance is the total thickness (#1260)
A symmetric solid extrude of 10 mm is now 10 mm thick, 5 mm each side of the sketch plane, and switching from One Side to Symmetric keeps the thickness entered. Under format 21 the extent records its convention; designs saved without it keep the per-side reading, so their geometry and parameters don't change. Fixes #1236.

Co-authored-by: Karl-VonRichter <Karl-VonRichter@users.noreply.github.com>
2026-10-15 01:19:55 -04:00
Giovanni faeeff1c64 Blends: a curved seam where the edge chain runs on holds the contact on itself (#1291)
A round or chamfer whose edge chain runs on across a curved seam (the end circle of a lofted tube whose axis bends) is now built: the section solve keeps its contact on the seam through a plane taken afresh at the seam's nearest point at each step. Straight seams are unchanged. Robustness seed 41 is re-recorded: its second fillet, refused before, now builds. Fixes #1213.

Co-authored-by: gvocale <gvocale@users.noreply.github.com>
2026-10-15 01:19:51 -04:00
Karl Von Richter ee9b4b9fb1 Kernel: refuse arc blends that collapse a side face (#1281)
A fillet or chamfer that consumes both seams of a side, leaving its old boundary and the fitted contact on the same curve, now returns TooLarge ("the blend would consume a neighbouring face") instead of a zero-area face that doesn't mesh. Genuine two-edge faces are kept. Fixes #1113.

Co-authored-by: Karl-VonRichter <Karl-VonRichter@users.noreply.github.com>
2026-10-15 01:19:48 -04:00
bherbruck 8535d229ff Display helical-trimmed faces from imported threads correctly (#1289) (#1300)
A modelled thread as other CAD systems write it to STEP has cylinder faces trimmed by helices
that turn several times around the shaft, and cylinder faces bounded by two circles with no
seam edge. The display planner refused any face whose boundary winds around its surface, so
the whole body fell back to the reference mesher, which drew those faces with triangles across
the solid.

The planner now cuts such a face along a parameter line into one period: every loop and its
copies a period apart are clipped to the strip, and the parts of the cut line inside the face
join the pieces into closed loops, with the seam nodes made once for both sides. The cut face
is meshed by Delaunay with sag refinement, and the sag check runs once more after shaping.
Bodies with a face trimmed by more than one turn take their reference mesh (picking, measuring,
the CPU viewport) from the plan too.

Tests: an ISO M10x1.5 and M12x1.75 thread built from helices, as made and after a STEP round
trip: the display mesh stays within the tolerance of its surfaces and the thread's volume, and
has no degenerate, sliver or flipped triangles.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:51:30 -04:00
bherbruck 5c2fa3e9ca Action matrix regressions of #1255: knuckle rounds, thin holes, a 45° flange hole, an axis after an edit (#1280)
* Action matrix regressions of #1255: knuckle rounds, thin holes, a 45° flange hole, an axis after an edit

- Curve blends: past the end of a curved edge the run of sections along the end
  tangent wrapped round the ring of centres; it is shortened while it still
  crosses the end face (the hinge knuckle's cap rounds at 0.728 mm).
- Boolean retry guard: each body's volume is measured finer than its smallest
  radius, and a volume out of bounds is measured again ten times finer before
  it refuses (a 0.133 mm drill measured -30272 mm³; a plastic part's coarse
  mesh was off by more than the slack).
- Sheet metal: a drill cap's certificate allows the evaluation noise its fitted
  tilt shows (a hole square to a 45° flange).
- Construction geometry on a named face uses the face found by name, not a
  second pick at its nearest point (an axis through a corner round after a
  parameter moved it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Retry guard: measure the target, tool and result at one tolerance

Measured at different tolerances, the chords' error on the faces they share
didn't cancel: a blind hole in a long D-profile bar measured 20 mm³ more than
the bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Action matrix baseline from this branch's full run: 37 actions pass again, none fewer

Blessed with cargo xtask action-matrix --from DIR --bless from the 16 shards of
run 38673859774.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:24:27 -04:00
JIJINBEI 464a6dfad4 Feature walks: generate them from field roles with #[derive(Walk)] (#1251)
The three walks over a feature (expressions, references, geometry) are generated from FeatureKind by #[derive(Walk)] in a new proc-macro crate, solidcraft-derive, from #[walk(...)] attributes written next to each field. The hand-written walks in params.rs, feature_refs.rs and feature_geometry.rs are gone; Ruled Surface and Extend have their attributes, and the three golden walk records are unchanged.

Co-authored-by: jijinbei <jijinbei@users.noreply.github.com>
2026-10-15 00:13:53 -04:00
DuBearte a026a69182 Sketch: fillet and chamfer a corner of curves drawn to meet at a point (#1282)
A corner whose two curves end on separate points held together by a Coincident constraint (a line drawn to where an arc starts) is welded into one shared point before Fillet or a Chamfer rounds or cuts it, so it no longer reports that the corner must join two lines. Only a partner point held by nothing but that constraint is welded. Fixes #1208.

Co-authored-by: DuBearte <DuBearte@users.noreply.github.com>
2026-10-15 00:02:00 -04:00
bherbruck 406293fd53 Title bar: inactive tabs without outlines, File menu, Help on the right (#1277)
* Title bar: inactive tabs without outlines, File menu, Help on the right

Inactive tabs go back to the plain bar colour (the Home tab's readable
hover stays). The separate Home button and theme toggle are gone: Home is
a tab and the theme is in Preferences. The folder icon becomes a File text
menu, Save moves into it (Ctrl+S unchanged), and Help sits on the right
next to Report a bug. Tabs start right after File, Undo and Redo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Title bar: File button hover uses the app bar hover token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:18:48 -04:00
Emil Petersson bd8db8ea1c Add Touchpad navigation: Shift to pan, Alt/Option to orbit (#1220)
An opt-in Touchpad navigation preset: Shift + pointer motion pans, Alt/Option + motion orbits, vertical scroll zooms, all without a mouse button. Existing presets are unchanged; view.navigate gains continuous motion for the control channel and scenarios.
2026-10-14 22:46:33 -04:00
Irakli Kourtis b4edf05373 Extend any surface: natural, tangent and curvature, up to a target (#760) (#1166)
Extend works on any surface: natural (continuing the face's own surface), tangent (G1 run-off) or curvature (G2 run-off), by a distance, up to a plane or face, or back to the surface's bounds, with neighbouring edges lengthened to meet. Untrim and Extend share face parameter-loop helpers. The new SurfaceExtend fields are noted under format 21. Closes #760.
2026-10-14 22:46:29 -04:00
William Dunn 41f0c3d0c6 docs: browser runtime design (craft-libs, workers, #1221) (#1258)
A design note for SolidCraft in the browser: tiered storage, a SharedWorker job broker, GPU dispatch and in-browser agent control through the same engine commands, with the generic pieces in craft-libs. Linked from the MCP and web getting-started docs.
2026-10-14 22:34:48 -04:00
DuBearte 784648af2a Loft: refuse a section with a hole instead of filling it (#1184) (#1254)
A loft section with a hole is refused with a clear message instead of being lofted as if filled, in both section-to-section and section-to-point lofts. Fixes #1184.
2026-10-14 22:34:43 -04:00
DuBearte 38bd16713f Header: size the Report a bug button to its translated label (#1145) (#1274)
The labelled Report a bug button takes its translated label's width, so longer translations no longer run over the Preferences gear; narrow bars still fall back to the compact icon. Fixes #1145.
2026-10-14 22:34:37 -04:00
DuBearte b1695873f5 Browser: show given names as typed, not translated as labels (#1228) (#1264)
Browser rows that are names someone gave (bodies, sketches, components, joints, views...) show as typed instead of being translated as UI labels; default names like Sketch1 are still translated. Fixes #1228.
2026-10-14 21:45:13 -04:00
DuBearte c008addae2 Parameters: outline the Delete button only on hover (#1178) (#1241)
The Delete button in each parameter row shows its outline on hover, like the fields beside it; the redundant tooltip is gone. Fixes #1178.
2026-10-14 21:45:09 -04:00
DuBearte 9dc12b4d0f Parameters: reset the new row's expression after Add (#1170) (#1235)
After Add in Change Parameters, the new-parameter row's expression goes back to the default a new row starts with. Fixes #1170.
2026-10-14 21:45:04 -04:00
Irakli Kourtis 2a4907884b Ruled Surface: strips off an edge chain, exact where possible (#762) (#1151)
Ruled Surface: strips off an edge chain in normal, tangent, angle or direction mode, exact planes/cylinders/cones where possible, mitred or round corners, stitched into one surface body. SurfaceRuled is noted under format 21. Closes #762.
2026-10-14 21:44:56 -04:00
bherbruck ed22d663e4 Smart Extrude: direction from the picked side, and a Cut stays a Cut (#1257) (#1261)
* Smart Extrude: direction from the picked side, Cut into a body stays a Cut (#1257)

- A sketch profile (or a thin extrude's curve) extrudes toward the side it was
  picked from: from the bottom view it goes down. Body faces still go outward.
- The automatic operation is latched per side of the profile: a drag that starts
  into a body is a Cut and stays one past the far side; a Join does not become a
  Cut when it reaches another body. Passing through zero decides afresh for the
  new side.
- An explicit operation, Direction or Reverse ends the automatic choices.
- auto_operation also looks just past the profile, so a long cut through a body
  reads as a Cut from the start (the engine's operation: "auto" too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Scenario s07: the XZ profile seen from behind now extrudes toward the camera

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:31:10 -04:00
bherbruck d73cd8a177 Robustness goldens: record seed 13 after #1164 (#1268)
#1164 joins a body that sits flush on a rounded rim into it. In seed 13 the
rectangular pattern's second box (x 5..7.5) sits flush on the half-ring's top
face with a 27 mm2 contact patch. It used to stay a separate shell (one body,
three shells); now it joins the ring (area -54 mm2, twice the patch). The
fillet edge and hole position the generator picks next come from the new
geometry, so the end state differs: 3 bodies, 2647.95 mm3 (was 4, 2300.91).
Every step stays a sound solid, and no other seed moves.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:54 -04:00
bherbruck c159a3e277 Action matrix: nightly shards cut by measured cost, a fresh baseline, and a determinism check (#1256)
* Action matrix: nightly shards cut by measured cost, heavy designs in slices, and a determinism check

The full matrix ran as four fixed quarters of the corpus and two of them hit the 60-minute
job limit. Now action_matrix_shard runs shard k of n (ACTION_MATRIX_SHARD=k/n), planned from
tests/action_matrix/costs.json: designs that take more than five minutes are split into slices,
and the pieces go largest first to the least loaded shard. The nightly runs 12 shards and uploads
each one's outcomes and timings; cargo xtask action-matrix --from DIR --bless records them in the
baseline and costs.json without running the matrix again. action_matrix_determinism runs a sample
twice and lists any action whose outcome changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Action matrix: costs measured on runners, 10 shards, determinism on the lighter designs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Action matrix: baseline and costs from a full run on main, 16 shards, timeouts listed

The baseline is regenerated from the 10 shards' results of a full run on 01b1b705
(cargo xtask action-matrix --from DIR --bless): 8 new designs, 302 actions that pass now,
names that changed, and 40 actions that passed before and fail now, each listed in #1255
(22 fillets refused since #949, 4 hinge blends since #1101, 9 on the loft cut, 5 single).
costs.json now holds every design's measured time, and the nightly runs 16 shards of about
17 minutes. An action that passes in the baseline and times out is listed but not judged,
and stays passing when blessed: two runs of the same build differed only on 10 actions that
took 50-84 s against the 90 s limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Action matrix: slice heavy designs by a hash of each action's name

Slicing by index gave some slices only fillets on oracle 41 (28 slices, a multiple of the
actions per edge), three times the planned time; two shards ran past their limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:11 -04:00
Irakli Kourtis 16c49c3e86 Untrim: holes removed and a face restored to its natural bounds (#1150)
Adds surface.untrim (#761); the SurfaceUntrim feature is first saved in format 21.
2026-10-14 20:29:25 -04:00
DuBearte 734f69ad72 Parameters: arrow keys move the cursor while renaming (#1248)
Fixes #1165.
2026-10-14 20:20:21 -04:00
NOWN-DEV 2e7f41710b run and eval can write their JSON to a file (--json-out) (#1242) 2026-10-14 20:18:17 -04:00
NOWN-DEV 5c6f70cc00 Faces by name in the face commands; model.faces gives a point on each face (#1243) 2026-10-14 20:18:13 -04:00
NOWN-DEV 2cab09f65b Join keeps its tool exact where the tool ends in the part's faces (#1244) 2026-10-14 20:18:09 -04:00
Igor Bronovskyi 861ab027b6 Add Ukrainian localization (uk) (#1196)
Ukrainian UI catalog; untranslated keys fall back to English.
2026-10-14 20:10:45 -04:00
phanoob 9935133b56 Live preview: the tool alone, the boolean only on OK, computed off the window thread (#1201)
The preview builds only the tool; the boolean runs once on OK, off the window thread, with a Computing label while it takes a while. Follow-up to #1020.
2026-10-14 19:31:29 -04:00
selune a261aaa841 Render: integrated workspace, preview image jobs and opaque PBR (#1046)
Lands the Render workspace (R3), bounded preview-image jobs with cancellation and atomic PNG export (R4a), and opaque PBR looks as one change.

- Render workspace: an overlay-free product preview on the installed GPU device. Returning to Design restores its camera. render.scene and render.settings.apply are added.
- Image jobs: render.preflight/start/status/cancel/export run bounded CPU preview jobs. Export captures the job, overwrite choice and document, and checks the caller's file scope before writing.
- Opaque PBR: looks can carry metal fraction, roughness and dielectric IOR. The product viewport and preview PNG use them; geometry and mass are unchanged. The v1 catalog export refuses PBR rather than dropping it.
- Render setups and PBR looks are saved under design format 21. Labels are in en/de/ja/ko/zh/ru.

Supersedes #706 and #800. Refs #620, #673, #756, #1004.

Contributed by @0xlillith.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-14 19:24:36 -04:00
rlartot e4ef6cfe29 French (fr) UI localization (#1224)
Adds a French catalog as a partial locale (missing strings fall back to English, like Russian), registered in the language list, with tests and attribution.
2026-10-14 19:05:17 -04:00
Que 1cc65cbc91 fix: a lip swept flush around a rounded rim joins the body it sits on (#1164)
fix: a lip swept flush around a rounded rim joins the body it sits on.
2026-10-14 18:49:45 -04:00
Mateusz Sych 099d7e298a Degrees of freedom count closed loops (#1206)
Degrees of freedom count closed loops.
2026-10-14 18:46:46 -04:00
Tobias Breckle a629236650 Join a floor into the hole it fills: grow the boss into the wall around it (#1212) (#1217)
Join a floor into the hole it fills: grow the boss into the wall around it (#1212).
2026-10-14 18:28:17 -04:00
Lasse Lian a4a8b48567 Design properties: project, author and licence in the design, its exports and inspect_design (#1015) (#1189)
A design carries metadata (format 21): project, part number, revision, author, organization, description and license, plus other tools' data under reverse-DNS keys, kept as written. Edited in the Design Properties window (Document Settings), set by document.metadata.set, reported by inspect_design, and written into STEP, IGES and 3MF where the format has a place for them. Translated, with tests.
2026-10-14 18:25:35 -04:00
devrosx 78079587ad Build bodies on the CPU on AMD FirePro under Metal (GPU hang on Mac Pro 2013) (#1171)
The AMD FirePro D300/D500/D700 Metal driver hangs on the tessellation compute shader and macOS resets the GPU, freezing the desktop. Bodies are built on the CPU on that device, and SOLIDCRAFT_CPU_TESS=1 forces the CPU path on any GPU. The check is a tested predicate, and the troubleshooting guide documents the variable.
2026-10-14 18:25:31 -04:00
Mateusz Sych 01b1b70586 Drive Joints says where and why a drive stopped short (#1169)
Drive Joints says where and why a drive stopped short.
2026-10-14 17:56:21 -04:00
Erikas 5744b1f327 Test: a pattern of touching cuts builds without fallback (#400) (#1144)
Test: a pattern of touching cuts builds without fallback (#400).
2026-10-14 17:56:17 -04:00
HiroKenEz 1a67390a53 body.rename: renaming a counted body keeps later counted names (#1109) (#1199)
body.rename: renaming a counted body keeps later counted names (#1109).
2026-10-14 17:56:12 -04:00
HiroKenEz 1337404c06 Sketch fillet and chamfer: a construction line at the corner is not a side (#1026) (#1176)
Sketch fillet and chamfer: a construction line at the corner is not a side (#1026).
2026-10-14 17:56:07 -04:00
Lasse Lian 8f081679bc Equal cylinders crossing each other no longer refuse a cut (#788) (#1179)
Equal cylinders crossing each other no longer refuse a cut (#788).
2026-10-14 17:56:02 -04:00
phanoob beb3ea01e7 Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept) (#1204)
Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept).
2026-10-14 17:55:58 -04:00
Mateusz Sych c715986a2a Motion links carry a value through a chain stored in any order (#1203)
Motion links carry a value through a chain stored in any order.
2026-10-14 17:55:54 -04:00
bherbruck d20b0fab65 Native crash capture on Windows, and startup steps in crash reports (#1215)
A crash inside a graphics driver left only "an unclean exit between
commands": no backtrace, no module, no hint that it was at startup.

- The crash journal now keeps the startup step the app is in (window,
  graphics instance, surface, adapter request, adapter chosen, device
  creation, renderer and viewport shader compiles, surface configure,
  first frame and its present, running) and the renderer as soon as the
  adapter is picked. egui-wgpu tells the app about its steps through a
  small hook. An unclean exit before the first frame names the step.
- Windows: an unhandled-exception filter (src/native_crash.rs, the one
  Windows-only module allowed unsafe) writes the exception code, the
  faulting module and offset, and a minidump next to the journal; the
  next start puts them into the report.
- The Windows CI job runs a child that faults on purpose and checks the
  report.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:53:52 -04:00
Tobias Breckle 0ddd660089 Copy a feature's error or warning message from the history menu (#1226)
The feature menu in the timeline and Feature History gets a Copy Message item for a feature's error or warning, translated, with a test.
2026-10-14 17:50:04 -04:00
CamWight b6e87f0c44 Two tests pass on a Windows checkout: a CRLF parse and an 8.3 TEMP path (#1186) (#1211)
Make two tests pass on a Windows checkout: a CRLF parse and an 8.3 TEMP path.
2026-10-14 17:36:07 -04:00
CamWight 731629a422 A failed feature has a mark of its own in the timeline and the Feature History (#972) (#1180)
A failed feature gets its own mark in the timeline and Feature History, distinct from warnings, with tests.
2026-10-14 17:36:02 -04:00
CamWight f6a47a49d7 Colour vision modes: Protan, Deutan and Tritan in Preferences › Graphics (#778) (#1007)
Colour vision modes (Protan, Deutan, Tritan) in Preferences > Graphics, with palettes, shader and icon colours adapted per mode, translations and tests.
2026-10-14 17:35:56 -04:00
selune 289b521a65 Render R2: persist named render setups without re-evaluating geometry (#667)
render.settings.set saves validated named render setups (camera, output size, exposure, environment) as one undo step, and render.settings.get reads them back without applying a camera. When validated render settings are the only change, the engine keeps the evaluated model instead of rebuilding it. Setups are saved under design format 21; format-20 designs read as having none. Fixes #658; refs #620.

Contributed by @0xlillith.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-14 17:11:39 -04:00
bherbruck 771a11feab Submit published releases to WinGet (#1222)
Publishing a non-prerelease release runs winget.yml, which opens the
winget-pkgs pull request for bherbruck.SolidCraft with Komac (pinned
2.16.0 binary, SHA-256 checked) from the three versioned MSIs. It skips
cleanly without the WINGET_TOKEN secret or before the package's first
version is in winget-pkgs. docs/releasing.md has the one-time token
setup; the README's Download section gains the winget install line.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:07:06 -04:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: a test filter YAML parses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: the startup smoke test writes graphics.json where the app reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 95ea05e7c8 Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: a test filter YAML parses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: the startup smoke test writes graphics.json where the app reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:41:27 -04:00
selune 241dac1542 Render: bounded PNG/JPEG texture input admission (#909)
Bounded in-memory PNG/JPEG texture decoding with explicit base-colour/data-map roles; RGBA8 pixels are published only after a complete decode. Input only: no material assignment, GPU shading or persistence. Refs #900.

Contributed by @0xlillith.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-14 16:35:52 -04:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
selune cccbe1255d Fix the web build: the Appearance dialog's file helper exists on wasm (#1173) 2026-10-14 15:51:03 -04:00
bherbruck 4e74fea3e5 Release 0.11.0 (#1182) 2026-10-14 15:26:45 -04:00
bherbruck 229de1484c Hide and show joints in the browser, like other items (#1168)
Fixes #1160.
2026-10-14 15:04:57 -04:00
Irakli Kourtis e3459edea8 Display tessellation: trimmed faces as four-sided patches (#303) (#1163)
Trimmed faces are cut into four-sided patches (squares around holes, butterflies for round faces, motorcycle traces from concave corners) and each is meshed as a Coons grid, with a cut grid as the fallback; cells are at most 2:1 along straight directions. Folded blends are refused rather than overlapped. The README lists display meshes and the gpu crate.
2026-10-14 15:01:05 -04:00
Erikas e519fbd14d Meshing: start a face's boundary walk at a point the surface search finds (#882) (#1157)
When projecting a face's boundary loop onto its surface fails from an unhinted first point, the walk is retried once from the first point an unhinted search does find; a walk that succeeds is unchanged. A fillet strip on an extended surface now meshes, so the rim cut in the reported case builds in one boolean.
2026-10-14 14:56:33 -04:00
Karl Von Richter 575c037f39 Bug report: clear submission steps, and no dialog growth at fractional scale (#242, #996) (#1085)
The screenshot controls, the submission steps and the Open GitHub draft button stay outside the scrolling review; the clipboard fallback is explained before a long draft opens; text fields leave a pixel of room so the window no longer widens while idle at 140% scale. Instructions in en/de/ja/ko/zh.
2026-10-14 14:56:19 -04:00
bherbruck a6fbc63746 Opening a design shows the model built so far (#1158)
A large design showed nothing but a progress bar until it had rebuilt completely. While
it rebuilds, the evaluation now offers the model built so far (placed as the design
places it) every 1.5 s to a window that asks, and the open card draws it as a small
picture on a thread of its own, the way it shows a saved preview. The bodies' meshes
made for it are the ones the finished model shows, so little is done twice. A design
that opens within the first interval shows none.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:45:14 -04:00
HiroKenEz fd12325d90 body.rename: follow the body in later features' participants (a primitive's targets) (#1108) (#1159)
A primitive's `targets` live in the feature's participants beside its kind; renaming a body now renames them too, so a box or cylinder cutting the renamed body no longer fails with unknown body.
2026-10-14 14:43:15 -04:00
bherbruck 39bc87a8cc Viewport: keep the model scene in blocks and upload only what changed (#1147) (#1154)
* Viewport: keep the model scene in blocks and upload only what changed

Every change rebuilt the whole model scene (every body's triangles and edges) and
uploaded all of it again, so a design an agent builds one body at a time paid for the
whole model on every command. The scene is now built in blocks of a few bodies, cut
where a body's own key says, and each block's bytes and GPU buffers are kept while the
block comes back unchanged. Body boxes (fit, picking, ground shadow) are kept per mesh.

Rendering is unchanged: the bicycle from bench-agent opens to a byte-identical
screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Browser: find an occurrence's placed bodies by name, not by search per row

Each body row of a placed component searched the placement's bodies for its own, every
frame: quadratic in the component's bodies (720 for the bench-agent bicycle's hardware).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:32:32 -04:00
bherbruck 9b0f028f13 STEP assemblies: mesh a repeated part once and place it for its instances (#1152)
The reader converts each item once, but every instance made its own display mesh
(tessellation, seam and tangent flags), the most expensive step of opening a large
assembly. A later instance of an item now shows its first instance's mesh, placed by
the difference of the two placements. Only rigid placements share; split lines and
any operation drop the link. Measurements and export read each instance's own
geometry, so eval output is unchanged.

Generated assemblies: mesh phase 2,215 -> 139 ms (901 bodies) and 1,028 -> 85 ms
(5,401 bodies), with the same triangle counts.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:23:33 -04:00
NOWN-DEV 764fcaa5a8 Sheet metal: the flat-pattern DXF writes ellipses and splines exactly (#1096)
sheet.export_dxf writes an AutoCAD 2000 drawing with ELLIPSE and SPLINE entities for ellipses, splines and text outlines instead of chords, with curves: arcs for machines that read only lines and arcs. Perforation circles go through the same writer. Closes #1089.
2026-10-14 14:09:46 -04:00
Karl Von Richter 9a4e8fbd19 Sketch: associative, editable sketch patterns (#1103)
Rectangular and circular sketch patterns stay linked to their source: editing the pattern's count, spacing or the source geometry updates the copies, and the copies keep their point and radius relations. Patterns are saved under format 20; older designs keep their independent copies.
2026-10-14 14:09:37 -04:00
Lasse Lian 4cfa9c5d34 MCP: a path into a missing folder inside an allowed one says the folder is missing (#1082)
PathScope::check reports a missing folder inside the scope as NotFound with the nearest existing folder, instead of calling it outside the allowed folders. Paths outside the scope and dangling links are still refused. Fixes #1077.
2026-10-14 14:09:05 -04:00
Giovanni e489e56f3d Loft: surfaces follow sections by distance along them (#1084)
Loft faces between sections are spaced by chord length instead of by parameter, so uneven sections no longer twist the surface. Fixes #968.
2026-10-14 13:59:11 -04:00
NOWN-DEV c3aae64a63 Sheet metal: a cut-out with a corner a hair beside a bend line refolds (#1095)
Edges shorter than the model tolerance are dropped from the flat cells, and the refold sews touching corners, so a cut-out across a bend line no longer fails with a degenerate edge.
2026-10-14 13:59:07 -04:00
Mateusz Sych c469adf456 MCP: an agent's folders hold for file loads through the app and for a design's font files (#1112)
doc.open and the STEP/mesh imports run on the file loader's worker with the request's path scope, and a font named by file path in a design is checked against the scope before it is read.
2026-10-14 13:58:56 -04:00
bherbruck 6de8f6db95 Agent-built designs: stop redoing whole-design work on every command (#1148)
A design built over MCP is many small commands, and each one did work over the
whole design, so building or showing a 1,300-body design took minutes:

- moved components were transformed and meshed again after every change; world_state
  now reuses each placed body whose definition and placement are unchanged
- next_d_name searched the design once per candidate number
- Document::eval evaluated every parameter for every expression; now only when the
  expression names one
- param_rows' used_by was quadratic; the desktop no longer gathers completions on every
  change while no text box has focus

solidcraft-cli bench-agent builds a generated ~1,300-body bicycle command by command
and times it (241 s -> 16 s here). Eval output is unchanged apart from timings.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:48:39 -04:00
Karl Von Richter 37547f8eba Keep Open and Save asynchronous to prevent macOS resize crashes (#1127)
Open and Save use parented AsyncFileDialog sheets; their answers run on the UI thread in a later frame, so resizing the window while a picker is open no longer re-enters winit and aborts. Editors, tab changes, Close/Quit and model-changing control requests wait for the answer; the local library Open and Export in the Appearance dialog use the same flow. Fixes #1088.
2026-10-14 13:41:29 -04:00
Yuu f425089983 io: hostile-input tests for the SVG and DXF readers (#469) (#1083)
Adds tests that feed truncated, malformed and deeply nested SVG and DXF input to the readers and check that none of it panics.
2026-10-14 13:40:25 -04:00
Mateusz Sych 94faf9ce1b Close kinematic loops: driven joints move four-bars and sliders (#1141)
Driven joints now solve closed kinematic loops, so a four-bar or slider-crank moves as a whole when one joint is driven.
2026-10-14 13:40:16 -04:00
Erikas 2e6951135a STEP export: extruded arcs, straight rounds and countersinks as cylinders and cones (#612) (#1139)
Ruled B-spline sheets that are circular cylinders or cones are written as CYLINDRICAL_SURFACE and CONICAL_SURFACE, so other readers no longer bound the part by sheets that reach past its faces.
2026-10-14 13:40:10 -04:00
bherbruck 2c754c430b Startup hardening: safe mode, a software-renderer retry, and bad settings files set aside (#1138)
Startup no longer stops at a bad settings file or a missing GPU: a settings file that fails to load is set aside, a failed GPU start retries with the software renderer, and a safe mode starts with defaults. Adds a troubleshooting page to the guide.
2026-10-14 13:40:04 -04:00
CamWight 8f48544021 ui.render and the CPU viewport draw in the window's theme (#974) (#1142)
The engine's headless scene takes a palette; ui.render and the CPU viewport build it from the window's theme tokens, so a dark window renders a dark picture. CLI snapshots, MCP screenshots and Home thumbnails keep the default colours.
2026-10-14 13:39:58 -04:00
JIJINBEI 1a456d2d1f One walk over the geometry a feature holds (3/3, #876) (#1028)
Moves the per-feature geometry handling (transforms, face points, profile selections, point expressions) into one geometry walk in feature_geometry.rs, with a golden record of what each feature kind moves. sheet.perforate holds no world geometry and joins the no-op arm.
2026-10-14 13:29:31 -04:00
NOWN-DEV f79dfe41b6 Paste New copies an import's bodies under the names its features use when the names are numbered (#1117)
Where an Import's stored labels pair one for one with its numbered built bodies (X, X (2), X (3)), component.paste_new stores the copy under the bodies' names, so the copied features after it find their bodies. No format change.
2026-10-14 13:24:27 -04:00
phanoob 6ebfcd16b6 A text's path becomes construction (#1119)
sketch.text and sketch.edit_text make the curve a text follows construction in the same undo step, so it no longer cuts the letters into pieces; the reply names the curve as guide.
2026-10-14 13:24:23 -04:00
Tobias Breckle ed943a74e6 A sketch line end on a curve within the axis snap lands where the preview shows (#1122)
A line end snapped onto a curve within the horizontal/vertical snap slides along the curve to where the axis crosses it; the preview, value boxes and click share one placement (#1121).
2026-10-14 13:24:19 -04:00
JIJINBEI aa2ace81af Right-clicking a construction plane in the browser opens its menu (#1130)
Construction plane rows get a menu: Edit Feature, Delete, Rename, Show/Hide, Find in Timeline, and Group Selected for several planes (#1036).
2026-10-14 13:24:10 -04:00
JIJINBEI 3dbdb71e59 Copy and Paste in the timeline's feature menu (#1131)
The feature menu of the bottom timeline and the side history gets Copy (the selection, or the right-clicked feature) and Paste (at the marker). Ctrl+click in the bottom timeline toggles a feature in the selection (#1034).
2026-10-14 13:24:04 -04:00
JIJINBEI c26da73f5f A pasted cylinder Emboss keeps its face and badges the pasted cylinder (#1132)
timeline.paste keeps a cylinder Emboss's persistent face name, remapped onto the pasted features and bodies, so the copy embosses the pasted cylinder instead of failing (#1035).
2026-10-14 13:24:01 -04:00
Tobias Breckle 805ea45be6 Title bar: outline inactive tabs and keep the Home tab readable on hover (#1116)
Inactive document tabs get a faint outline so neighbouring tabs stay apart, and the Home tab uses the same hover tint as the design tabs so its label stays readable in the light theme. Fixes #1115.
2026-10-14 13:06:23 -04:00
Jonas Wrede 54a8c807bf Sheet metal: sheet.perforate, a perforation field exported to the flat pattern and DXF (#732) (#1041)
Adds sheet.perforate: round holes on a staggered or square grid over the flat sheet, kept clear of edges, cut-outs and bend zones. The holes go to the flat pattern, the DXF and the measured volume rather than the B-rep, so a panel can carry many thousands of them.
2026-10-14 12:43:31 -04:00
selune d399effbf7 Local appearance and material libraries (#863)
Adds local scalar appearance and material libraries: a catalog in the io crate with safe writes, engine commands to list and apply library entries, and a library dialog in the UI, with docs.
2026-10-14 12:21:35 -04:00
Alex Pro 8ea1fd643f feat: add Russian (ru) UI localization (#1043)
Adds a Russian (ru) UI locale. It's partial: anything not yet translated falls back to English.
2026-10-14 12:11:25 -04:00
bherbruck 563bcaff5b Refuse fillets and chamfers that run past their faces or cross each other (#1080) (#1124)
* Refuse fillets and chamfers that run past their faces or cross each other (#1080)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Blend fit: chamfers keep their old acceptance of crossed loops; skip the volume bound along tangent chains

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Blend fit: one selection per check, a coarse volume pass first; record the refused oversize blends in the action matrix baseline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:46:44 -04:00
Lasse Lian 6df69e9def Thin Extrude: walls of a set thickness along sketch curves (#721) (#991)
Thin Extrude builds walls of a set thickness along sketch curves, open chains and closed loops, with the wall centred, inside, outside or on both sides at two thicknesses. Saved under format 20 (thin on an Extrude); an extrude without thin saves as before. Closes #721.
2026-10-14 11:46:16 -04:00
JIJINBEI 86ea018d05 One walk over a feature's references (2/3, #876) (#988)
Part 2 of #876: one walk over the references a feature holds (the sketches it's built from, the features it names, the bodies it works on) replaces three hand-written matches, with a golden record covering every variant.
2026-10-14 11:14:25 -04:00
Thodoris Tsagkogeorgas 110d00d07e A fillet of a vanished edge says the name is gone (#775) (#1066)
A fillet or chamfer whose picked edge no longer exists now says the edge's name is gone instead of failing on an unrelated edge (#775).
2026-10-14 11:06:11 -04:00
bherbruck d4a7b3abf3 Update now: download, verify and install a release in one click (#1118)
Where SolidCraft can replace itself safely, the Update window gets Update now:
- AppImage: the new AppImage is downloaded next to the old one, verified and renamed over
  it; Restart now starts it as SolidCraft exits.
- Windows MSI: the verified .msi starts (msiexec /i … /passive) once SolidCraft has closed,
  after the usual questions about unsaved designs.
- Windows portable zip / standalone exe: the running solidcraft.exe is renamed to .old, the
  new one takes its name (solidcraft-cli.exe too), Restart now; .old goes at the next start.
- macOS: the verified .dmg is opened (the app is ad-hoc signed and not notarized, so it isn't
  replaced behind the user's back).
- Flatpak, Snap, deb, rpm, tar.gz: never replaced; the window shows the command.

Every package's SHA-256 must match the release's SHA256SUMS.txt (HTTPS, same release); a
mismatch deletes the download and changes nothing. With PUBLIC_KEY set in update/install.rs,
SHA256SUMS.txt.minisig must also verify (minisign-verify, MIT) with a trusted comment naming
the release. release.yml signs the list when the UPDATE_SIGNING_KEY secret exists and fails
when the app expects signatures but the secret is missing; docs/releasing.md has the steps.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:05:40 -04:00
Jasper van Loenen a8258eb63a fix: update input field text selection on changing value during action (#1079)
Changing the value in an input field updates its text selection, so typing over a selected value replaces what's shown.
2026-10-14 11:01:29 -04:00
Yuu 1e1722b7e1 doc: keep a level piece's number when its centre's low bits move (#1073)
Pieces that share a name are numbered by where their centres lie. Two pieces lying almost level along the sort axis no longer swap numbers between runs because of noise in their centres' last bits, so a saved name keeps pointing at the same piece. Closes #569.
2026-10-14 10:58:04 -04:00
Lasse Lian fbd8ada754 Test that joining a patterned lobed disc is not refused (#1029) (#1051)
Adds a regression test: joining a patterned lobed disc whose copies each share the whole disc with the target is not refused for contact work (#1029).
2026-10-14 10:55:16 -04:00
bherbruck d019f2d5bf Straight edges stay one edge after cuts (#1049) (#1101)
* Straight edges stay one edge after cuts (#1049)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Robustness goldens: cuts join straight edges along untouched walls (#1049)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Robustness goldens note: what each changed seed shows when replayed on both builds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:51:07 -04:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck ec64564f21 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:40:24 -04:00
bherbruck a6928abf48 Tell people when a newer release is out (#1100)
At start, and at most once a day, a background thread asks GitHub for the latest stable
release (one HTTPS GET to releases/latest, nothing else sent; 15 s timeout; silent when
offline; GitHub's rate limit respected). When it is newer than this build, an Update button
appears next to Report a bug. It opens a window with the version, the release notes and
Download (the asset that fits this installation), Skip this version and Remind me later.

- Versions compare as semver; a nightly counts as the release it was built after, so it is
  offered only a newer stable release.
- The installation is detected (AppImage, Flatpak, Snap from the store or a file, deb, rpm,
  tar.gz, Windows MSI/portable/exe, macOS app); Download picks the fixed-name asset.
  Development builds and the Flatpak (no network permission) never check.
- Preferences > General > Updates: Check for updates automatically (on) and Check now.
- HTTP via ureq + rustls with the operating system's certificates (desktop app only).
- docs: Updates and privacy page.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:36:56 -04:00
selune 88432d226a Render: bounded opaque surface contract and numerical BRDF reference (#1019)
A renderer-independent OpaqueSurface (colour, metallic fraction, roughness, dielectric IOR) with bounded inputs, an original GGX/Smith/Schlick reference response with Lambert diffuse, and exact piecewise sRGB8 decoding. Numerical contract only: no document format, command or UI change.
2026-10-14 10:00:03 -04:00
Lasse Lian 1ac8bf4aa8 Repair next_id and reject duplicate ids on load (#1050)
Loading a design raises next_id above the largest feature, component and occurrence id, so a missing or stale value can't hand a new item an id already in use, and duplicate ids are rejected as a damaged design. Id allocation uses checked_add, so an exhausted id space is an error instead of a wrap or a panic.

Part of #1016 (points 1, 2 and 4). nekomario28's #1061 reached the same fix independently; thanks to both.

Co-authored-by: nekomario28 <nekomario28@users.noreply.github.com>
2026-10-14 09:55:53 -04:00
NOWN-DEV dde6d48dd0 fix: renaming a body keeps the names of its duplicates (#1056)
Bodies imported with the same label come in as X, X (2), X (3). Renaming one of them no longer renumbers the others: body.rename now pins the current names of the feature's other bodies when it stores the new one, so later renames, materials, appearances and features still find them.
2026-10-14 09:51:12 -04:00
phanoob 4dbc0efff9 Taper any profile, sketch text included (#1014)
A tapered extrude now offsets spline and conic curves as well as lines and arcs, so sketch text and any other profile can take a taper, and the join of tapered letters onto a face finds its boolean result.
2026-10-14 09:47:44 -04:00
Thodoris Tsagkogeorgas d114c58540 A chamfer of a hole corner is one bevel (#814) (#1067)
A chamfer of a hole corner, where the mitre extension runs into the wall, now takes the local blend and comes out as one bevel instead of a notched cut; where that blend can't build the edge, the cutters that used to succeed are kept (#814).
2026-10-14 09:37:12 -04:00
Noly b15180bdff Fonts: order CJK fallbacks by UI language so a Chinese word keeps one baseline (#1070)
With a Chinese UI on Windows and macOS, the first CJK fallback was a Japanese font without the simplified characters, so one word mixed glyphs from two faces with different line heights and they sat on different baselines. For zh the Chinese face now joins the fallback chain before the Japanese one, and the chain is rebuilt on the frame the UI language changes; other languages keep the previous order.
2026-10-14 09:36:32 -04:00
NOWN-DEV 37113c0c13 fix: sketch.text refuses inputs it doesn't understand (#1057)
sketch.text and sketch.edit_text took at, height, angle and text with a silent default when a value had the wrong kind, so a height of "H" made 5 mm text and at: ["H", 25] put it at the origin. An input that is given (not null) now has to be what it names, or the command is refused with the input's name, and a null at no longer passes as the origin.
2026-10-14 09:24:30 -04:00
phanoob f2d7b220f7 Live preview: show a tool at once, and OK takes the preview's result (#1020)
The live preview of a join, cut or intersect now sends the tool body's scene as soon as the tool is built, then runs the feature for its error while the dialog keeps its spinner. OK pressed while the preview is still computing the same commands waits for it instead of computing the feature a second time on the UI thread; the session shares its evaluation cache with the preview's scratch copy.
2026-10-14 09:19:15 -04:00
Igor Churmeev 14d928b2c8 fix(macos): open files double-clicked in Finder (#1031)
macOS hands files opened from Finder, the Dock or open -a to the application delegate's application:openURLs:, which winit's delegate lacks, so AppKit showed "SolidCraft cannot open files in the SolidCraft design format". open_documents adds that method to the delegate's class before launch finishes and queues the paths for the UI, which opens them like files named on the command line.

This is the one place SolidCraft's own code uses unsafe: the app crate denies unsafe_code instead of forbidding it, and only the two macOS-only functions in open_documents.rs allow it, with the reason documented there and in AGENTS.md. Adapted from VectorCraft (see ATTRIBUTION.md).

Closes #997.
2026-10-14 09:19:08 -04:00
NOWN-DEV 435ad0fd8c fix: a planar union leaves no sliver where an edge crosses its neighbour by their shared corner (#1058)
A planar union no longer leaves a sliver where an edge crosses its neighbour, so a sheet whose outline runs on past a flange's edge with a slight bend unfolds again.
2026-10-14 09:14:41 -04:00
Thodoris Tsagkogeorgas 7879875d49 Command search asks the field for focus once (#724) (#1065)
The command search asked its text field for focus on every frame. On Wayland that restarted the text edit's held key, so the key kept repeating in other windows after the palette closed. Focus is now requested once, when the box opens, with a test that the field doesn't take focus back while the box stays open.

Fixes #724.
2026-10-14 09:14:03 -04:00
Lasse Lian ec8e739aad Export a design's timeline as a replayable command script (#1045)
timeline.export_script (and solidcraft-cli script) writes a design's history as a command script that solidcraft-cli eval replays into the same design, so a bug that only shows on one design becomes a one-file repro. timeline.add restores one stored feature with its id, names and references, through the same checks a design file gets when it opens.

Closes #886. SychPL's #1078 approached the same issue independently; thanks to both.

Co-authored-by: SychPL <SychPL@users.noreply.github.com>
2026-10-14 09:06:24 -04:00
NOWN-DEV 08b6a8fe0f fix: the server's own folders under a short-named temp path are expanded, not refused (#1055)
On Windows the temporary folder often has an 8.3 short name, which the path scope refuses. vfs::long_names spells folders SolidCraft derives from the environment with long names, and the MCP scratch folder and the recovery folder use it, so screenshot without a path and the recovery commands work there again.
2026-10-14 09:05:43 -04:00
NOWN-DEV cf93869c4d fix: pasted features act on the copies of an import's bodies when their names are numbered (#1059)
Pasting an Import together with later features that name its bodies now points those features at the pasted copies, including bodies whose names carry a number such as X (2).
2026-10-14 09:03:09 -04:00
bherbruck 75f1cdd4c9 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:56:35 -04:00
bherbruck b5efde94c8 docs: make the release notes template version-neutral (#1074)
The template still carried the 0.9 notes, and nightlies publish it as is. Keep the introduction, the upgrade notes that hold for every version and the install instructions; the release-specific part is written for each release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:36:03 -04:00
bherbruck 0897ee3781 docs: clear the release notes carried into 0.10.0 (#1054) 2026-10-14 08:17:48 -04:00
selune 52fb5ac0e6 feat: query directional elastic modulus from validated material cards (#998)
* feat: validate physical material cards with sources and conditions

* feat: query directional elastic modulus from physical material cards

* fix: register physical material validation labels in complete locales

* fix: register directional elasticity labels in complete locales

* Add German and Korean labels for the material property commands

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-14 08:17:00 -04:00
Mateusz Sych 377e12a059 A modelled thread that would cut nothing is refused (#995) (#1018)
* fix: a modelled thread that would cut nothing is refused (#995)

An M5 modelled thread in a 5 mm hole cut nothing into the nut: the
internal groove runs from the minor diameter out to the major one, so
with the hole as wide as the thread it lay entirely in the hole, and the
faceted cut only left the wall faceted with slivers ("the thread goes the
wrong way"). The fit check takes a 5 mm hole as an M5 hole.

- thread_groove measures how deep the groove cuts into the material
  (hole wall out to the major radius, or shaft surface in to the root);
  under a quarter of the basic depth 5H/8 the modelled thread is refused
  with the measured depth, the tap drill (as solid.hole drills it) or
  shaft size to use, and the size that fits the face, e.g. "this 5.00 mm
  hole is too wide for a modelled M5 thread: it would cut 0.00 mm of the
  thread's 0.43 mm depth; drill 4.2 mm for M5, or thread this hole M6".
- Cosmetic threads and their fit check are unchanged.

* test: pin the cut-nothing limit for coarse and fine pitch; show the hole size with show_mm

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-14 08:16:55 -04:00
Philipp Bischoff 3256fffe35 test(ui): find command dialogs by their published rect (#1011)
The dialog_docked scenario check and the ui_layout width test looked up
the dialog window by the id "sc_dialog", but dialogs are now registered
under ("sc_dialog", <title>). Every lookup failed with "no command
dialog", so the test had to stay ignored and could no longer catch an
oversized dialog.

Read the rect the dialog publishes each frame instead (viewport
dialog_rect in the check, "dialog:outer" in the test), and re-enable
every_dialog_opens_docked_within_its_width_limits. The exploded view
dialog it was ignored for now opens 365 px wide, inside its limits.


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

Co-authored-by: PhilippBischoff0 <PhilippBischoff0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-14 08:16:49 -04:00
NOWN-DEV 0f4cda0510 Convert to Sheet Metal takes a long thin plate (#1040)
Convert to Sheet Metal without bend edges refused every plate whose bounding-box diagonal reached a thousand times its thickness ("no outline on that face"), and just under that limit a hole smaller than the mesh's sag fell out of the outline and the DXF. The outline's tolerance is now at most a tenth of the thickness: a thin plate is meshed again at that tolerance. Nothing new is stored. Tests: long thin strips, sheets thousands of times longer than thick, and a strip keeping holes down to 1 mm.
2026-10-14 08:12:41 -04:00
JIJINBEI 3341e2d60b One walk over a feature's expressions (1/3, #876) (#902)
inputs_mut, inputs and expressions are now built on one exhaustive walk (walk_exprs / walk_exprs_mut) instead of two hand-written matches over FeatureKind. inputs() no longer clones the feature, plane chains are walked with the same nesting cap for inputs as for expressions, and a new variant without a walk arm does not compile. A golden record of every variant's expressions guards the walk.
2026-10-14 08:11:03 -04:00
NOWN-DEV 35e58d18ed docs: the camera of screenshots and renders, and a close-up recipe (#1022)
docs/mcp.md gains a "Camera and close-up recipe" section: the orbit camera's conventions (radians, eye direction for yaw and pitch, orthographic half-height, which side inspect.section keeps) and a seven-step recipe for a close-up of one region of a large STEP model. Documentation only.
2026-10-14 07:43:57 -04:00
Tobias Breckle 32d3cf6877 Fillets and chamfers meeting at corners on curved faces: what #935 leaves (#854)
* Fillets and chamfers meeting at corners on curved faces, on top of #935

What #935 and #926 leave of #851 and the matrix:

- Corner build for blends meeting on faces of any surface (curvecorner.rs):
  the knife-edge tip's round with the arc's or the back edge's, edges
  picked in any order, a round ending in an earlier round of its radius
  (as a feature of its own), and chamfers meeting at the arc's corner.
  Its end curves are fitted by chord length: a uniform parameter
  through points clustered at the ends doubled back on itself there,
  and the display planner refused the face.
- A straight edge ending at a curved face (the back edge into the
  knife-edge tip): rolled, or cut by the face in the planar corner
  build (blend_corner.rs).
- A rolling ball turning about a rounded corner of its own radius
  (a pivot: a piece of sphere or cone).
- Seams that are an earlier blend's straight contact (fitted curves)
  count as straight.
- No spline across a joint where the strip's curvature jumps (a line
  into a small round): each strip ends on the joint's section.
- Sections at a chain's vertices on the faces of the edge from it
  alone.
- Every new fillet path passes #949's check for openings.

Tests measure volumes through the display plan, as #935's do.
Action matrix: 51 more fillets and chamfers pass (sweep, tangent arc
chain, sheet-metal hem, bracket holes), none fewer. Robustness seed 33
recorded (its last fillet builds differently; display and measured
volumes now agree).

Co-Authored-By: bherbruck <brennen.herbruck@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi

* Corner blend tests: scale, seed 33's volumes, tighter engine bounds

From the review of #854:

- seed_33_display_plan_and_measure_agree: seed 33's bodies measure
  the same through the display plan and the measure (2e-5), which the
  golden's re-record rests on.
- the_arc_rounded_after_the_back_edge_matches_both_together: the order
  that still leaves 0.05 mm³ at the tip, as an ignored test.
- corner_blends_hold_at_any_scale: the cases this branch adds on the
  #851 part, at 0.01x and 100x, take off what they take at full size
  times k³ (within 5e-6, checked to 1e-4).
- The engine tests on the part as drawn measure through the display
  plan; bounds are about twice what each reads (2e-4 to 3e-3), and the
  front corner's sets are checked against the kernel's CSG grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:40:23 -04:00
bherbruck 27374ca289 test: hold the enclosure lid's press/pull to CPU and memory, not wall time (#1027)
press_pull_on_the_enclosure_lid_is_prompt took 61-87 s of wall time on loaded runners against a 60 s bound, for about 9 s of CPU. It now checks the operation's CPU time (under 300 s) and how far it grows the peak memory (under 1 GB; the runaway it guards against passed 24 GB), from /proc/self where the system has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:38:53 -04:00
bherbruck d506382aca ci: run the action-matrix sample on its own; name a hang in an edit round (#1037)
The sampled pi-case hole on Body1/F13:side:l7@F14.1 became a real hole with #952 (7.5 s instead of 170 ms); beside the heavy tests on main's push runs it passed the matrix's 90 s action limit in its edit round, reported as the design not loading. The sample now takes every CPU in nextest so nothing runs beside it, and the edit round writes a start line so a hang there is reported as <action>+edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:19:25 -04:00
426 changed files with 67935 additions and 5804 deletions
+8
View File
@@ -28,3 +28,11 @@ slow-timeout = { period = "60s" }
[profile.ci.junit]
path = "junit.xml"
# The action matrix sample runs its designs in parallel worker processes, one per CPU. Run with
# other tests beside it (the heavy ones on main), a pi-case hole that takes 8 s alone (#952 makes
# it a real hole) went past the matrix's 90 s action limit. It runs on its own.
[[profile.ci.overrides]]
filter = "binary(action_matrix) & test(=action_matrix_sample)"
threads-required = "num-cpus"
+2
View File
@@ -14,6 +14,8 @@ What did you expect, and what happened instead?
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
## Screenshots of the behavior (when observable)
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
+3 -85
View File
@@ -5,91 +5,9 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
**Why 0.9:** this is a minor bump because the design file format moved to version 17, and
because it adds many new features.
**What's new**
- **Face actions work on about 90% of flat faces.** Sketches, offset planes and press-pull now
attach to sheet metal rims and holes, plastic features (Boss, Lip, Rest, Snap Fit), lofts,
revolved faces, pieces Offset Face moved and walls a Join merged
([#677](https://github.com/bherbruck/solidcraft/pull/677), fixing
[#681](https://github.com/bherbruck/solidcraft/issues/681)). Across the test models: sketch
75% → 90%, offset plane 75% → 90%, press-pull 64% → 85%.
- **Joins:** a logo-style part with pieces extruded up and down from one sketch now joins
([#679](https://github.com/bherbruck/solidcraft/issues/679)), faces that only partly overlap
fuse correctly ([#704](https://github.com/bherbruck/solidcraft/pull/704), fixing
[#674](https://github.com/bherbruck/solidcraft/issues/674)), and booleans build the same body
every time ([#748](https://github.com/bherbruck/solidcraft/pull/748)), so a design rebuilds
identically on every run.
- **Sketch:** type a dimension right after picking (D, click, type;
[#695](https://github.com/bherbruck/solidcraft/pull/695)); redundant but consistent
constraints are accepted, such as a rectangle clover
([#699](https://github.com/bherbruck/solidcraft/pull/699)); tool windows for Scale, Offset,
Mirror, Move/Copy, Fillet and Chamfer and the rectangular pattern (thanks @wdunn001), shown
next to the Sketch Palette, which now lists the running tool's options; a typed Rho for the
conic; a Construction Geometries row in the Sketch Palette that hides construction geometry
([#804](https://github.com/bherbruck/solidcraft/pull/804)); sketch tool shortcuts open a sketch on the selected plane; Edit Sketch from the viewport
menu.
- **Features:** feature history on the left side with folders (opt in under Preferences;
[#449](https://github.com/bherbruck/solidcraft/pull/449)); Convert to Sheet Metal from a solid
or shelled body by its bend edges ([#725](https://github.com/bherbruck/solidcraft/pull/725));
a tear corner relief ([#809](https://github.com/bherbruck/solidcraft/pull/809)); tapered and
variable fillet radii ([#675](https://github.com/bherbruck/solidcraft/pull/675),
[#749](https://github.com/bherbruck/solidcraft/pull/749)) with a radius spline you drag on the
edge ([#752](https://github.com/bherbruck/solidcraft/pull/752)); free-form cut-outs stay exact
in sheet-metal flat patterns ([#692](https://github.com/bherbruck/solidcraft/pull/692)) and edge
flanges run along what cut-outs leave of an edge
([#702](https://github.com/bherbruck/solidcraft/pull/702)); and Surface Extrude, sheets from
open curves and profile walls ([#815](https://github.com/bherbruck/solidcraft/pull/815)).
- **View:** Home and Fit frame only what is shown, and a middle double-click goes Home
([#701](https://github.com/bherbruck/solidcraft/pull/701)); a cube navigator option
(thanks @Karl-VonRichter, [#664](https://github.com/bherbruck/solidcraft/pull/664)); the window
stays responsive and draggable while a design opens, with a Cancel button that stays put
([#643](https://github.com/bherbruck/solidcraft/pull/643),
[#686](https://github.com/bherbruck/solidcraft/pull/686)).
- **Quality:** cuts no longer split walls they don't touch
([#670](https://github.com/bherbruck/solidcraft/pull/670)); booleans are faster and use less
memory ([#714](https://github.com/bherbruck/solidcraft/pull/714)); STL export welds shared
vertices ([#816](https://github.com/bherbruck/solidcraft/pull/816)); complete Japanese
translation ([#753](https://github.com/bherbruck/solidcraft/pull/753)).
- **Fixes:** pasted features act on the pasted bodies (#613); a refused OK's error clears when
you change the inputs (#641); dialogs show the units of their sizes (#630); the wheel over a
dialog's list scrolls only the list (#678); a Single hole picked at a circle's centre lands on
the centre (#657); axis or tangent plane on a drill tip works (#567); sketch text edits from
letter interiors and moves as a whole; a projected edge stays the edge you picked; a size from
a bare diameter keeps its unit (#716); Convert Mesh says how big a mesh is and what converts;
the Save changes prompt takes arrow keys and Enter.
- **MCP:** agent skills for modeling and editing over MCP
([#728](https://github.com/bherbruck/solidcraft/pull/728)).
- A new README demo and the SELENE rover showcase.
**File format 17.** Designs are now format 17 (`solidcraft/17`). SolidCraft 0.8 and older can't
open designs saved by 0.9, as a container or as JSON; they ask you to update instead. Keep a
copy of a file you may still open in 0.8. Files from every earlier version still open in 0.9.
**Upgrading from 0.8.** Nothing to do: open your designs and save (they're saved as format 17
from then on). Feature-history folders on
the left are off by default; turn them on in Preferences.
**Known limitations**
- Some through-all holes along a wall or beside curved faces still fail
([#559](https://github.com/bherbruck/solidcraft/issues/559)), as does a hole crossing an
equal-radius hole ([#788](https://github.com/bherbruck/solidcraft/issues/788)).
- Some fillets are still refused, such as along an outline of lines and arcs
([#431](https://github.com/bherbruck/solidcraft/issues/431)).
- Some faces still can't take an attached sketch: a revolve's flat faces before a later feature
([#726](https://github.com/bherbruck/solidcraft/issues/726)) and faces after Split Body or
Combine Intersect ([#565](https://github.com/bherbruck/solidcraft/issues/565)).
- Fillet and chamfer accept an edge pick far from any edge and change the nearest one
([#783](https://github.com/bherbruck/solidcraft/issues/783)); Press Pull fails on faces beside
rounds ([#562](https://github.com/bherbruck/solidcraft/issues/562)).
- `sheet.fold` refuses a fixed point exactly on the sheet face
([#685](https://github.com/bherbruck/solidcraft/issues/685)), and some tabs on L-shaped or
collinear blanks can't be folded ([#638](https://github.com/bherbruck/solidcraft/issues/638),
[#639](https://github.com/bherbruck/solidcraft/issues/639)); an edge flange with different
per-edge angles leaves gaps at the corners
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
**What's new** in SolidCraft {version} is listed below, and the
[ROADMAP](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations) lists
the known limitations. Designs from every earlier version open in this one.
**Upgrading from 0.7 or earlier.** 0.8 changed the Linux app ID to `ai.storyteller.solidcraft`:
`flatpak update` offers to replace the old app, and the `.deb`, `.rpm` and AppImage remove the old
+98 -8
View File
@@ -205,16 +205,17 @@ jobs:
action-matrix:
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
# the whole matrix runs nightly (or by hand), a quarter per job.
name: Action matrix (${{ matrix.part }}/4)
# the whole matrix runs nightly (or by hand) in shards of about equal cost, cut by
# tests/action_matrix/costs.json. Keep SHARDS in step with the matrix below.
name: Action matrix (${{ matrix.shard }}/16)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 60
timeout-minutes: 55
strategy:
fail-fast: false
matrix:
part: [1, 2, 3, 4]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
@@ -223,12 +224,48 @@ jobs:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run a quarter of the matrix
- name: Run one shard of the matrix
# A step limit below the job's, so the outcomes so far are still uploaded.
timeout-minutes: 45
env:
PART: ${{ matrix.part }}
SHARDS: 16
SHARD: ${{ matrix.shard }}
OUT: ${{ github.workspace }}/action-matrix
run: |
mkdir -p "$OUT"
export ACTION_MATRIX_SHARD="$SHARD/$SHARDS" ACTION_MATRIX_RESULTS="$OUT/results-$SHARD.json" \
ACTION_MATRIX_TIMINGS="$OUT/timings-$SHARD.jsonl" ACTION_MATRIX_WORKDIR="$OUT/raw-$SHARD"
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_shard)"
- name: Upload the outcomes and timings
# `cargo xtask action-matrix --from DIR --bless` (with every shard's files in DIR)
# records them in the baseline and costs.json.
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: action-matrix-${{ matrix.shard }}
path: action-matrix
retention-days: 14
action-matrix-determinism:
# A sample of the matrix run twice on one runner: every action must come out the same.
name: Action matrix determinism
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run a sample twice
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --run-ignored only -E "binary(action_matrix) & test(=action_matrix_full_$PART)"
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_determinism)"
doc-tests:
name: Doc tests
@@ -286,9 +323,58 @@ jobs:
name: oracle-report
path: docs/oracle.md
# D3D12's older shader compiler (FXC, used where dxcompiler.dll is absent) is stricter than
# DXC, Vulkan and Metal: a shader it cannot compile once crashed the app at startup. Build the
# GPU tessellation pipeline with FXC on the runner's WARP software adapter and compare its
# results with the CPU.
windows-fxc:
name: Windows D3D12 FXC shaders
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: windows-latest
timeout-minutes: 45
env:
WGPU_BACKEND: dx12
WGPU_DX12_COMPILER: fxc
SOLIDCRAFT_REQUIRE_GPU: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-fxc
# One test at a time: WARP's shader JIT crashes now and then (a null read in
# d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute
# pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not
# in SolidCraft or FXC; serial runs never hit it.
- run: cargo test -p solidcraft-gpu --profile ci --locked -- --test-threads=1
# The Windows app starts D3D12 first and only the backend it uses (a Vulkan driver crashed the
# app at startup on Intel). Start the real app on the runner (its D3D12 adapter is WARP) with
# several saved settings and safe mode, and check each draws a frame and quits cleanly.
windows-startup:
name: Windows app startup
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-startup
- name: Backend order and probing on Windows
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests
- name: Build the app
run: cargo build -p solidcraft --profile ci --locked
- name: Start the app under different settings
shell: pwsh
run: tools/windows-startup-smoke.ps1 -Exe target/ci/solidcraft.exe
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle]
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always()
runs-on: ubuntu-24.04
steps:
@@ -302,6 +388,8 @@ jobs:
TESTS: ${{ needs.tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
WINDOWS_STARTUP: ${{ needs.windows-startup.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -311,6 +399,8 @@ jobs:
test "$TESTS" = success
test "$DOCS" = success
test "$ORACLE" = success
test "$WINDOWS_FXC" = success
test "$WINDOWS_STARTUP" = success
fi
wasm:
+26
View File
@@ -429,6 +429,32 @@ jobs:
run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
- name: Sign the checksum list for in-app updates
# Optional until the UPDATE_SIGNING_KEY secret exists (docs/releasing.md, "Update
# signing"). The trusted comment names the release, which the app checks.
env:
UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }}
run: |
# The key the app checks against (crates/ui-egui/src/update/install.rs, PUBLIC_KEY).
public_key=$(sed -n 's/^pub const PUBLIC_KEY: Option<&str> = Some("\([^"]*\)");/\1/p' crates/ui-egui/src/update/install.rs)
if [ -z "$UPDATE_SIGNING_KEY" ]; then
if [ -n "$public_key" ]; then
echo "::error::the app expects signed updates (PUBLIC_KEY is set) but the UPDATE_SIGNING_KEY secret is missing"
exit 1
fi
echo "UPDATE_SIGNING_KEY isn't set: SHA256SUMS.txt stays unsigned"
exit 0
fi
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends minisign
umask 077
printf '%s\n' "$UPDATE_SIGNING_KEY" >"$RUNNER_TEMP/update.key"
minisign -S -s "$RUNNER_TEMP/update.key" -m artifacts/SHA256SUMS.txt \
-x artifacts/SHA256SUMS.txt.minisig -t "SolidCraft v$VERSION" </dev/null
rm -f "$RUNNER_TEMP/update.key"
if [ -n "$public_key" ]; then
minisign -V -P "$public_key" -m artifacts/SHA256SUMS.txt -x artifacts/SHA256SUMS.txt.minisig
fi
- name: Publish nightly pre-release
if: github.event_name == 'schedule'
working-directory: artifacts
+77
View File
@@ -0,0 +1,77 @@
# Publishing a GitHub release (not a prerelease) opens a pull request at microsoft/winget-pkgs
# that adds the new version of bherbruck.SolidCraft, so `winget install bherbruck.SolidCraft`
# and `winget upgrade` get it. Komac (https://github.com/russellbanks/Komac) downloads the three
# versioned MSIs, reads their product codes and copies the rest from the previous manifest.
# Skipped until the owner adds the WINGET_TOKEN secret, and while the package isn't in
# winget-pkgs yet; see docs/releasing.md.
name: WinGet
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: Published release tag to submit (e.g. v0.11.1)
required: true
permissions:
contents: read
concurrency:
group: winget
cancel-in-progress: false
env:
PACKAGE_ID: bherbruck.SolidCraft
KOMAC_VERSION: 2.16.0
KOMAC_SHA256: 7d2707fa6210f2789a3702de49fbd150b736dbf426ee0b9bc8e098736f9fd82d
jobs:
submit:
runs-on: ubuntu-latest
steps:
- id: check
name: Check for the token and the package
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
if [ -z "$WINGET_TOKEN" ]; then
echo "No WINGET_TOKEN secret: nothing to submit."
exit 0
fi
case "$TAG" in
v[0-9]*.[0-9]*.[0-9]*-*|*nightly*) echo "$TAG is a prerelease: not submitted."; exit 0 ;;
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::Unexpected tag '$TAG'"; exit 1 ;;
esac
if ! gh api "repos/microsoft/winget-pkgs/contents/manifests/b/bherbruck/SolidCraft" --silent; then
echo "$PACKAGE_ID isn't in winget-pkgs yet: nothing to update."
exit 0
fi
echo "version=${TAG#v}" >>"$GITHUB_OUTPUT"
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Install Komac
if: steps.check.outputs.version != ''
run: |
archive="komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSLo "$RUNNER_TEMP/$archive" \
"https://github.com/russellbanks/Komac/releases/download/v${KOMAC_VERSION}/$archive"
echo "$KOMAC_SHA256 $RUNNER_TEMP/$archive" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" komac
- name: Open the winget-pkgs pull request
if: steps.check.outputs.version != ''
env:
GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
VERSION: ${{ steps.check.outputs.version }}
TAG: ${{ steps.check.outputs.tag }}
run: |
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/$TAG"
"$RUNNER_TEMP/komac" update "$PACKAGE_ID" --version "$VERSION" \
--urls "$base/solidcraft-$VERSION-windows-x64.msi" \
"$base/solidcraft-$VERSION-windows-x86.msi" \
"$base/solidcraft-$VERSION-windows-arm64.msi" \
--release-notes-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/$TAG" \
--submit
+5 -1
View File
@@ -27,7 +27,11 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
+8 -1
View File
@@ -14,6 +14,9 @@ Editable drawing code, native projects and shipped exports are collected under `
| File | Author | Source | Licence |
|---|---|---|---|
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
@@ -89,8 +92,11 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
@@ -119,6 +125,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solidcraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
| apps/solidcraft/src/open_documents.rs (macOS: files opened from Finder and the Dock, its test) | VectorCraft `apps/vectorcraft/src/open_documents.rs` (https://github.com/storytold/vectorcraft) | 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 |
@@ -144,7 +151,7 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (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; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
## Vendored renderer compatibility fix
+5 -1
View File
@@ -27,7 +27,11 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
+3 -1
View File
@@ -107,7 +107,9 @@ The [README](README.md#architecture) has the layer table. The short version:
`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.
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events)
and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler
denies it.
- An unsupported case returns an error ("not supported yet: …"); it never panics.
- Every call into truck goes through `solidcraft_kernel::guard`.
- Every crash fix comes with a regression test.
Generated
+268 -17
View File
@@ -422,6 +422,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -710,6 +716,16 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -723,7 +739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics-types",
"foreign-types",
"libc",
@@ -736,7 +752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"libc",
]
@@ -1578,6 +1594,22 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -1987,6 +2019,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2449,6 +2487,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -2949,6 +2993,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "robust"
version = "1.2.0"
@@ -3016,6 +3074,80 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -3031,6 +3163,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -3043,6 +3184,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -3281,41 +3445,61 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"block2 0.6.2",
"eframe",
"egui",
"embed-resource",
"getrandom 0.3.4",
"hidapi",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pollster 0.4.0",
"quick-xml",
"rfd",
"ron",
"rustls",
"serde_json",
"solidcraft-engine",
"solidcraft-ui-egui",
"ureq",
"windows-sys 0.61.2",
"winit",
]
[[package]]
name = "solidcraft-cli"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"serde_json",
"solidcraft-engine",
"solidcraft-mcp",
]
[[package]]
name = "solidcraft-derive"
version = "0.12.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "solidcraft-doc"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"log",
"serde",
"serde_json",
"sha2",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-render",
"solidcraft-sketch",
"thiserror 2.0.21",
"web-time",
@@ -3323,7 +3507,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"log",
"serde",
@@ -3339,7 +3523,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -3348,9 +3532,10 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"log",
"naga",
"pollster 0.4.0",
"solidcraft-geom",
"solidcraft-kernel",
@@ -3360,7 +3545,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"quick-xml",
"serde",
@@ -3375,7 +3560,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"log",
"rayon",
@@ -3393,7 +3578,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3401,9 +3586,10 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"image",
"png",
"serde",
"serde_json",
"solidcraft-geom",
@@ -3411,7 +3597,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -3422,24 +3608,28 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"egui",
"egui-wgpu",
"image",
"log",
"minisign-verify",
"pollster 0.4.0",
"semver",
"serde",
"serde_json",
"sha2",
"solidcraft-engine",
"solidcraft-gpu",
"ttf-parser",
"web-time",
"zip",
]
[[package]]
name = "solidcraft-web"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -3486,6 +3676,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
@@ -3908,6 +4104,40 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -3920,6 +4150,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4242,6 +4478,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -4730,7 +4975,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -4862,7 +5107,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"serde_json",
"sha2",
@@ -5038,6 +5283,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
version = "0.2.5"
+9 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.10.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -24,13 +24,19 @@ solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" }
# foundation
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
# derive macros (crates/derive); already in the tree through serde_derive
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
sha2 = "0.10"
semver = "1"
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
truck-modeling = "0.6"
@@ -52,6 +58,7 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
# images
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
png = "0.18.1"
# ui
egui = "0.36"
@@ -61,6 +68,7 @@ web-time = "1"
egui-wgpu = "0.36"
# the wgpu egui-wgpu uses, for compute (crates/gpu); backends come from the apps' features
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
naga = { version = "30", default-features = false }
pollster = "0.4"
rfd = "0.17"
+19 -2
View File
@@ -21,6 +21,8 @@ model through the built-in MCP server.
- **Windows:** download and run the [installer][windows-msi].
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
[portable zip][windows-portable] that runs without installing.
With [WinGet](https://learn.microsoft.com/windows/package-manager/winget/):
`winget install bherbruck.SolidCraft`.
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
(Apple silicon and Intel).
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
@@ -128,12 +130,26 @@ rebuild with no errors, replayed here at 2× speed.
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
assemblies with joints as URDF for robot simulators (docs/urdf.md);
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
See [opaque surfaces](docs/render-opaque-pbr.md).
- **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v)
points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight.
Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1
along straight directions. Box-bounded and four-cornered faces mesh as structured grids.
Trimmed faces are cut into four-sided patches: squares around holes, butterflies in round
faces, motorcycle traces from concave corners (after Lyon et al. 2019). Each patch then meshes
as a Coons grid. Intersection edges are evaluated by Newton on both surfaces. A compute shader
evaluates the plans for the whole scene in one dispatch, with a CPU fallback when the GPU
can't build it. Planning and evaluating the 78 reference bodies takes about 65 ms, 50–65×
faster than the previous mesher.
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
control channel so agents and tests can drive it.
@@ -151,6 +167,7 @@ cargo run --release -p solidcraft -- --sample # desktop app with a samp
cargo run --release -p solidcraft-cli -- commands # the command registry
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.png
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
@@ -215,7 +232,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
+90 -8
View File
@@ -1,6 +1,6 @@
# SolidCraft roadmap
Status as of **2026-10-11**. SolidCraft supports parametric modelling (sketch →
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
@@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR. |
| Reference tests | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) or FreeCAD-computed (21) expected values, run by CI on every PR. |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
@@ -21,6 +21,8 @@ offsets and plastic face placements, XZ (+Y normal) with a v6 migration preservi
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. See [WIP reconciliation](docs/wip-reconciliation.md).
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
so edge-touching assembly neighbours stay separate regardless of body order (#226).
In scope, in priority order:
1. SOLID and SKETCH (now).
@@ -50,15 +52,20 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), 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; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
## Physical material data
- Physical material card validation (#923/#922) provides typed SI mechanical and
@@ -67,8 +74,18 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
persist properties, provide a solver or enable rendering; see
[the contract and staged follow-ups](docs/physical-material-cards.md).
- Directional elasticity (#994) queries the validated card in material-local axes
through read-only `material.properties.directional`, with source/condition review.
It does not assign body frames, predict structural behavior or enable a solver.
## Library foundations (#785, #786, #787)
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
selected appearance/material revisions into existing design definitions in one undo step.
Appearance offers local loading/search/export; physical materials use existing commands.
This slice is independent of the render stack and does not change the document format.
See [the contract and limits](docs/book/src/dev/local-libraries.md).
- Proposed phases cover offline bundled/project/user packs, physical materials and
appearances, editable wood mapping, and parameterized component/assembly families.
Stable identity/revision, copied design definitions, provenance and resource limits
@@ -79,14 +96,51 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Render foundation
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
stay unchanged. HDRI, transmission and textures remain separate gates.
See [the consumer and limitations](docs/render-opaque-pbr.md).
- Combined integration is tracked in #842, with independent scalar libraries in
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
Render/library consumers use the merged asynchronous picker (#1127). Material-card
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
results stay outside persistence/undo; native workers and cooperative browser tasks
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
- R3 adds an overlay-free product scene and the integrated Render workspace, with
runtime GPU host capabilities, per-document drafts and named camera/background
controls. Design keeps its camera and drawing policy. Raster transparency remains
approximate; PBR and environments are later slices. See
[workspace and scene contracts](docs/render-workspace.md).
- R2 adds bounded named render setups in document format 21 and the read/save
`render.settings.get/set` commands. Proven render-only changes preserve geometry
and prepared world data through undo/redo and checkpoint restore. This slice does
not add a Render workspace, PBR, environments or image jobs; see
[settings and refresh policy](docs/render-settings.md).
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
- The initial opaque surface numerical contract for #1004 provides bounded linear
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
The numerical module alone advertises no capabilities; the installed consumer above
provides rendering. See
[the model, evidence and remaining consumer gates](docs/render-surface.md).
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
it does not yet enable HDRI/PBR rendering or a document import command. See
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
bounded image/scratch admission checks, exposed by the read-only `render.backends`
command. It describes the current CPU raster snapshot path without evaluating the model.
Existing snapshot and viewport routes remain unchanged. The Render workspace, HDRI/PBR,
render jobs and optional engines remain future work; see [the contract](docs/render-backends.md)
The legacy Design snapshot route remains unchanged. Later slices above provide the
workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md)
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
## Dimensioning and error reporting
@@ -155,6 +209,13 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
Crash Reports…) with a reviewed issue draft; agents read them through
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
anywhere. See [crash reports](docs/crash-reports.md).
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
writes a design's history as a script that `eval` and `run` replay into the same design —
user parameters, units, sheet metal and plastic rules and materials as their own commands,
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
script after a step gives the design at that step. Appearances, folders, views and
configurations are listed as left out; components and joints are not supported yet.
## 0.2.0 release batch
@@ -242,7 +303,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
other, except a round after a round of the edge between the wall and a flat face that it
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
piece of sphere.
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
curves is refused as too large rather than returning an unmeshable face (#1113).
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
@@ -276,6 +338,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
and conics; analytic trim/break also sees free-form cutters. 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).
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
walls through denser stations where the path has corners or the profile has holes.
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
certificate and refine within bounded node limits while retaining existing IDs.
@@ -421,6 +486,15 @@ editing. Construction planes and points resolve drawn 3D wires without applying
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
and reference-object placement remain separate work, as do loft rails and centerlines.
## Design properties (#1015)
- Designs carry project, part number, revision, author, organization, description and licence
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
Per-component properties (a part number per component) can follow with the same type.
## Document-opening performance
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
@@ -466,6 +540,14 @@ and reference-object placement remain separate work, as do loft rails and center
events by strategy and reports any bounded-log truncation. Existing geometry
acceptance checks remain in force.
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
on such a pair, both faces are split along the line of their cylinder through the touching
points (the solids are unchanged), the boolean is taken of those, and its history is composed
back over the operands as given (#788). The result keeps the split lines as edges of the bore
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
in a 45 mm block with a tool of 80 mm) is still refused.
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
polylines without panicking. Direct constructor regressions retain valid open endpoints
and closed spherical intersections; the original interactive trigger remains unidentified.
+1294 -36
View File
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
//! `bench-agent`: a large design built the way an agent builds one over MCP, one small command
//! at a time, timed as it grows and then opened again.
//!
//! The design is a bicycle of about 1,300 bodies: frame tubes joined at root and then moved into
//! a component, two wheels whose spokes and nipples are placed one by one, a chainring with
//! patterned teeth, a cassette, a chain of individual links, cockpit, seat and a few hundred
//! fasteners. Nothing is read from a file; the script is generated here, so the benchmark needs
//! no fixture.
//!
//! ```text
//! solidcraft-cli bench-agent [--scale F] [--script-out script.json] [--save design.solidcraft]
//! ```
//! prints one JSON line per hundred commands (mean and max milliseconds, bodies so far), then the
//! build total, then the phases `bench-open` prints for the saved design.
use serde_json::{Value, json};
use solidcraft_engine::Session;
struct Script {
commands: Vec<Value>,
}
impl Script {
fn c(&mut self, command: &str, params: Value) {
self.commands.push(json!({"command": command, "params": params}));
}
/// A round tube from `a` to `b` (an agent's frame tube, spoke or post).
fn tube(&mut self, a: [f64; 3], b: [f64; 3], radius: f64, body: &str) {
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let axis = [d[0] / len, d[1] / len, d[2] / len];
self.c(
"solid.cylinder",
json!({"radius": radius, "height": round3(len), "base": a.map(round3), "axis": axis.map(round6), "body_name": body}),
);
}
fn component(&mut self, name: &str) {
self.c("component.create", json!({"name": name}));
}
fn root(&mut self) {
self.c("component.activate", json!({"component": "root"}));
}
/// A wheel built at the origin in its own component, then placed where it goes (the
/// component's occurrence moved), as an agent builds one part and positions it.
fn wheel(&mut self, label: &str, at: f64, spokes: usize) {
let cx = 0.0;
self.component(&format!("{label} wheel"));
self.c("solid.torus", json!({"major": 311, "minor": 12, "center": [cx, 0, 340], "body_name": format!("{label} rim")}));
self.c("solid.torus", json!({"major": 330, "minor": 18, "center": [cx, 0, 340], "body_name": format!("{label} tyre")}));
self.c("solid.cylinder", json!({"radius": 18, "height": 110, "base": [cx, -55, 340], "axis": "Y", "body_name": format!("{label} hub")}));
for k in 0..spokes {
let a = std::f64::consts::TAU * k as f64 / spokes as f64;
let side = if k % 2 == 1 { 1.0 } else { -1.0 };
let hub = [cx + 30.0 * a.cos(), side * 40.0, 340.0 + 30.0 * a.sin()];
let (ca, sa) = ((a + 0.12).cos(), (a + 0.12).sin());
let rim = [cx + 300.0 * ca, side * 4.0, 340.0 + 300.0 * sa];
self.tube(hub, rim, 1.0, &format!("{label} spoke {k}"));
let nipple = json!({"radius": 2, "height": 12, "base": rim.map(round3), "axis": [round6(ca), 0, round6(sa)], "body_name": format!("{label} nipple {k}")});
self.c("solid.cylinder", nipple);
}
self.root();
self.c("occurrence.move", json!({"occurrence": format!("{label} wheel:1"), "translate": [at, 0, 0]}));
}
}
fn round3(v: f64) -> f64 {
(v * 1e3).round() / 1e3
}
fn round6(v: f64) -> f64 {
(v * 1e6).round() / 1e6
}
/// The bicycle's command script. `scale` multiplies the counts of repeated parts (spokes,
/// chain links, fasteners); 1.0 gives about 1,300 bodies.
pub fn bicycle(scale: f64) -> Value {
let n = |k: f64| ((k * scale).round() as usize).max(1);
let mut s = Script { commands: Vec::new() };
// Frame: tubes at root, the main triangle joined, then everything moved into its component.
let (bb, head_top, head_bot, seat_top) = ([0.0, 0.0, 300.0], [600.0, 0.0, 860.0], [580.0, 0.0, 740.0], [-150.0, 0.0, 820.0]);
s.tube(bb, head_bot, 19.0, "Down tube");
s.tube(seat_top, head_top, 16.0, "Top tube");
s.tube(bb, seat_top, 16.0, "Seat tube");
s.tube(head_bot, head_top, 22.0, "Head tube");
for (side, y) in [("L", 60.0), ("R", -60.0)] {
s.tube([0.0, y * 0.3, 300.0], [-420.0, y, 340.0], 10.0, &format!("Chainstay {side}"));
s.tube([-140.0, y * 0.3, 800.0], [-420.0, y, 340.0], 9.0, &format!("Seatstay {side}"));
s.c("solid.box", json!({"length": 20, "width": 6, "height": 40, "corner": [-430.0, y - 3.0, 320], "body_name": format!("Dropout {side}")}));
}
s.c("solid.cylinder", json!({"radius": 24, "height": 80, "base": [0, -40, 300], "axis": "Y", "body_name": "BB shell"}));
s.c("solid.combine", json!({"target": "Down tube", "tools": ["Top tube", "Seat tube", "Head tube", "BB shell"]}));
s.component("Frame");
s.root();
let frame = ["Down tube", "Chainstay L", "Seatstay L", "Dropout L", "Chainstay R", "Seatstay R", "Dropout R"];
s.c("component.move_bodies", json!({"bodies": frame, "component": "Frame"}));
// Wheels: every spoke and nipple a body of its own.
s.wheel("Front", 640.0, n(36.0));
s.wheel("Rear", -420.0, n(36.0));
// Drivetrain: patterned chainring teeth, a cassette and a chain of separate links.
s.component("Drivetrain");
s.c("solid.cylinder", json!({"radius": 95, "height": 4, "base": [0, 50, 300], "axis": "Y", "body_name": "Chainring"}));
s.c("solid.box", json!({"length": 6, "width": 4, "height": 8, "corner": [-3, 50, 395], "name": "Tooth", "body_name": "Tooth"}));
s.c("solid.pattern.circular", json!({"bodies": ["Tooth"], "axis": {"origin": [0, 50, 300], "dir": [0, 1, 0]}, "count": 44}));
for g in 0..11 {
let r = 22.0 + 3.5 * g as f64;
s.c(
"solid.cylinder",
json!({"radius": r, "height": 2, "base": [-420.0, round3(30.0 + 2.6 * g as f64), 340.0], "axis": "Y", "body_name": format!("Cog {g}")}),
);
}
let links = n(112.0);
for k in 0..links {
let a = std::f64::consts::TAU * k as f64 / links as f64;
let (x, z) = (round3(-210.0 + 230.0 * a.cos()), round3(320.0 + 80.0 * a.sin()));
for (side, y) in [("in", 46), ("out", 56)] {
s.c("solid.box", json!({"length": 12, "width": 1, "height": 8, "corner": [round3(x - 6.0), y, round3(z - 4.0)], "body_name": format!("Link {k} {side}")}));
}
s.c("solid.cylinder", json!({"radius": 3.5, "height": 10, "base": [x, 46, z], "axis": "Y", "body_name": format!("Roller {k}")}));
}
for (side, y) in [("L", -90.0), ("R", 90.0)] {
let x0 = if side == "L" { -15.0 } else { 0.0 };
s.c("solid.box", json!({"length": 170, "width": 12, "height": 30, "corner": [x0, y - 6.0, 285], "body_name": format!("Crank {side}")}));
let py = if y > 0.0 { y + 8.0 } else { y - 68.0 };
s.c("solid.box", json!({"length": 90, "width": 60, "height": 15, "corner": [150.0, py, 280], "body_name": format!("Pedal {side}")}));
}
s.root();
// Cockpit and seat.
s.component("Cockpit");
s.tube([600.0, 0.0, 860.0], [620.0, 0.0, 940.0], 14.0, "Steerer");
s.tube([620.0, 0.0, 940.0], [700.0, 0.0, 950.0], 16.0, "Stem");
s.tube([700.0, -380.0, 950.0], [700.0, 380.0, 950.0], 11.0, "Handlebar");
s.c("solid.cylinder", json!({"radius": 16, "height": 120, "base": [700, 260, 950], "axis": "Y", "body_name": "Grip R"}));
s.c("solid.mirror", json!({"bodies": ["Grip R"], "plane": "XZ"}));
s.root();
s.component("Seat");
s.tube([-150.0, 0.0, 820.0], [-170.0, 0.0, 960.0], 13.0, "Seatpost");
s.c("solid.box", json!({"length": 260, "width": 140, "height": 40, "center": [-180, 0, 980], "body_name": "Saddle"}));
s.root();
// Fasteners: head, shank and washer, each its own body.
s.component("Hardware");
let bolts = n(240.0);
for k in 0..bolts {
let a = std::f64::consts::TAU * k as f64 / bolts as f64;
let p = [round3(-300.0 + 2.5 * k as f64), round3(120.0 + 10.0 * a.sin()), round3(400.0 + 10.0 * a.cos())];
s.c("solid.cylinder", json!({"radius": 5, "height": 4, "base": p, "axis": "Z", "body_name": format!("Bolt {k} head")}));
s.c(
"solid.cylinder",
json!({"radius": 2.5, "height": 16, "base": [p[0], p[1], round3(p[2] - 16.0)], "axis": "Z", "body_name": format!("Bolt {k} shank")}),
);
s.c(
"solid.cylinder",
json!({"radius": 4.5, "height": 1, "base": [p[0], p[1], round3(p[2] - 1.0)], "axis": "Z", "body_name": format!("Bolt {k} washer")}),
);
}
s.root();
json!({"commands": s.commands})
}
pub fn run(args: &[String]) -> Result<(), String> {
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1));
let scale = match flag("--scale") {
Some(v) => v.parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 20.0).ok_or("--scale takes a number from 0 to 20")?,
None => 1.0,
};
let script = bicycle(scale);
if let Some(path) = flag("--script-out") {
let text = serde_json::to_string_pretty(&script).map_err(|e| e.to_string())?;
return solidcraft_engine::io::vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}"));
}
let commands = script["commands"].as_array().cloned().unwrap_or_default();
let mut s = Session::default();
let began = std::time::Instant::now();
// Per command: the command itself, then what a window needs to draw its result (the
// bodies placed in the world and their display meshes).
let (mut run, mut shown): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
for (i, c) in commands.iter().enumerate() {
let id = c["command"].as_str().unwrap_or_default();
let t = std::time::Instant::now();
s.execute(id, &c["params"]).map_err(|e| format!("command {i} ({id}): {e}"))?;
run.push(t.elapsed().as_secs_f64() * 1000.0);
let t = std::time::Instant::now();
s.world_state().warm_meshes(|| true);
shown.push(t.elapsed().as_secs_f64() * 1000.0);
if run.len() == 100 || i + 1 == commands.len() {
let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len().max(1) as f64;
let bodies = s.model.state().bodies.len();
println!(
"{}",
json!({"step": "build", "commands": i + 1, "bodies": bodies, "command_ms": mean(&run), "display_ms": mean(&shown),
"max_ms": run.iter().zip(&shown).map(|(a, b)| a + b).fold(0.0, f64::max)})
);
run.clear();
shown.clear();
}
}
let world = s.world_state();
println!(
"{}",
json!({"step": "built", "ms": began.elapsed().as_secs_f64() * 1000.0, "commands": commands.len(), "features": s.doc.features.len(), "bodies": world.bodies.len()})
);
let path = match flag("--save") {
Some(p) => p.clone(),
None => std::env::temp_dir().join(format!("bench-agent-{}.solidcraft", std::process::id())).to_string_lossy().into_owned(),
};
s.execute("file.save", &json!({"path": path})).map_err(|e| e.to_string())?;
let r = crate::cmd_bench_open(std::slice::from_ref(&path));
if flag("--save").is_none() {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{path}.bak"));
}
r
}
#[cfg(test)]
mod tests {
use super::*;
/// The generated commands stay valid as the command set changes: a small bicycle builds
/// without a failing command or feature.
#[test]
fn a_small_bicycle_builds() {
let script = bicycle(0.03);
let mut s = Session::default();
for c in script["commands"].as_array().into_iter().flatten() {
let id = c["command"].as_str().unwrap_or_default();
if let Err(e) = s.execute(id, &c["params"]) {
panic!("{id}: {e}");
}
}
assert!(s.model.results.iter().all(|r| r.error.is_none()));
let world = s.world_state();
assert!(world.bodies.len() > 40, "{} bodies", world.bodies.len());
assert!(world.bodies.iter().any(|b| b.name.contains("spoke")));
}
}
+197 -47
View File
@@ -1,10 +1,12 @@
//! SolidCraft headless command line.
//!
//! ```text
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] loading timings as JSON lines
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
@@ -17,10 +19,12 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod bench_agent;
mod crashes;
mod fuzz;
mod oracle;
mod recipe;
mod render;
mod spatial;
use std::process::ExitCode;
@@ -30,15 +34,27 @@ use serde_json::{Value, json};
mod app_icon;
use solidcraft_engine::Session;
use solidcraft_engine::render::{StandardView, render_png};
use solidcraft_engine::render::render_png;
use solidcraft_engine::view::Guides;
const USAGE: &str = "usage:
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--allow-errors]
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect; or --in FILE)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)
(--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture)
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
solidcraft-cli recipe <recipe.json>
@@ -55,7 +71,9 @@ fn main() -> ExitCode {
let r = match args.first().map(String::as_str) {
Some("run") => cmd_run(&args[1..]),
Some("eval" | "inspect") => cmd_eval(&args[1..]),
Some("script") => cmd_script(&args[1..]),
Some("snapshot") => cmd_snapshot(&args[1..]),
Some("render") => render::run(&args[1..]),
Some("exec") => cmd_exec(&args[1..]),
Some("commands") => {
use std::io::Write as _;
@@ -91,6 +109,8 @@ fn main() -> ExitCode {
// Hidden: time recomputes after parameter edits (docs/perf.md).
Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("bench-open") => cmd_bench_open(&args[1..]),
// A ~1,300-body design built command by command, as an agent builds one over MCP.
Some("bench-agent") => bench_agent::run(&args[1..]),
Some("--version" | "-V") => {
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
@@ -156,7 +176,7 @@ fn positional(args: &[String]) -> Option<&String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if a.starts_with("--") {
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors") { 1 } else { 2 };
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors" | "--no-grid" | "--no-axes") { 1 } else { 2 };
continue;
}
return Some(a);
@@ -164,32 +184,52 @@ fn positional(args: &[String]) -> Option<&String> {
None
}
/// `--json-out FILE`: where the JSON a command prints goes instead of stdout.
fn json_out(args: &[String]) -> Result<Option<&str>, String> {
if !args.iter().any(|a| a == "--json-out") {
return Ok(None);
}
match flag(args, "--json-out") {
Some(file) if !file.starts_with("--") => Ok(Some(file)),
_ => Err("--json-out needs a FILE".to_string()),
}
}
/// `v` as indented JSON with a final newline: on stdout, or in `file`. The file is plain UTF-8
/// with no byte-order mark, whatever the platform's shell does to redirected output (Windows
/// PowerShell's `>` writes UTF-16, or UTF-8 with a BOM), so strict JSON readers take it.
fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
match file {
Some(file) => {
let mut text = pretty(v);
text.push('\n');
std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
}
None => {
println!("{}", pretty(v));
Ok(())
}
}
}
fn cmd_run(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
return Err(USAGE.to_string());
}
// `--in` starts from a design or STEP file; the positional script then runs on top of it.
let mut s = match base {
Some(b) => load(b)?,
None => Session::default(),
};
let results = if args.iter().any(|a| a == "--results") {
let (mut s, results) = if args.iter().any(|a| a == "--results") {
// Collect every command's result (scripts only), then process requested files too.
let mut s = match base {
Some(b) => load(b)?,
None => Session::default(),
};
let v = read_json(path.ok_or("--results needs a script")?)?;
let r = s.run_script(&v).map_err(|e| e.to_string())?;
Some(Value::Array(r))
(s, Some(Value::Array(r)))
} else {
match (base, path) {
(Some(_), Some(p)) => {
let v = read_json(p)?;
s.run_script(&v).map_err(|e| e.to_string())?;
}
(None, Some(p)) => s = load(p)?,
_ => {}
}
None
(open(base, path)?, None)
};
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
@@ -204,10 +244,45 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if let Some(results) = results {
println!("{}", pretty(&results));
} else if !args.iter().any(|a| a == "--quiet") {
print_json(&results, json_out)?;
} else if json_out.is_some() || !args.iter().any(|a| a == "--quiet") {
// `--quiet` silences stdout; a file that was asked for is still written.
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
println!("{}", pretty(&m));
print_json(&m, json_out)?;
}
Ok(())
}
/// The session `run` and `snapshot` start from: `--in` (a design or STEP file) with the
/// positional script run on top of it, or either one alone.
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
match (base, path) {
(Some(b), Some(p)) => {
let mut s = load(b)?;
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
Ok(s)
}
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(None, None) => Err(USAGE.to_string()),
}
}
/// Refuses an option `cmd` doesn't take, and one missing its value: a typo, or another
/// command's option, would otherwise be ignored without a word.
fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -> Result<(), String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if valued.contains(&a.as_str()) {
match args.get(i + 1) {
Some(v) if !v.starts_with("--") => i += 2,
_ => return Err(format!("{cmd}: {a} needs a value")),
}
} else if a.starts_with("--") && !switches.contains(&a.as_str()) {
return Err(format!("{cmd} doesn't take {a} (it takes {})", [valued, switches].concat().join(", ")));
} else {
i += 1;
}
}
Ok(())
}
@@ -226,6 +301,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
}
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
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("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
@@ -238,21 +314,56 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
"params": d["params"],
"sketches": d["sketches"],
});
println!("{}", pretty(&out));
print_json(&out, json_out)
}
/// A design's history as a command script (`timeline.export_script`): printed, or written to
/// `--out`; what the script leaves out goes to stderr.
fn cmd_script(args: &[String]) -> Result<(), String> {
let path = positional(args).ok_or_else(|| USAGE.to_string())?;
let s = &mut load(path)?;
let out = flag(args, "--out");
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
for what in r["omitted"].as_array().into_iter().flatten().filter_map(Value::as_str) {
eprintln!("note: the script leaves out {what}");
}
match out {
Some(out) => eprintln!("{out}: {} steps", r["steps"]),
None => println!("{}", pretty(&r["script"])),
}
Ok(())
}
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or_else(|| USAGE.to_string())?;
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
let s = load(path)?;
let mut cam = solidcraft_engine::view::home_camera(&s);
if let Some(v) = flag(args, "--view") {
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
if positional(args).is_none() && flag(args, "--in").is_none() {
return Err(USAGE.to_string());
}
let scene = solidcraft_engine::view::scene(&s, &cam);
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let size = |name: &str, default: usize| match flag(args, name) {
None => Ok(default),
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let mut s = open(flag(args, "--in"), positional(args))?;
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
// standard view, a view saved in the design, or an explicit camera. A script that applies a
// view itself sets the camera too; without any, the iso view framing the model.
match (flag(args, "--view"), flag(args, "--camera")) {
(Some(_), Some(_)) => return Err("snapshot: give --view or --camera, not both".into()),
(Some(v), None) => {
let hint = "standard views: iso, front, back, top, bottom, left, right";
s.execute("view.apply", &json!({"view": v})).map_err(|e| format!("--view {v}: {e} ({hint}; or a view saved with view.save)"))?;
}
(None, Some(c)) => {
let camera: Value = serde_json::from_str(c).map_err(|e| format!("--camera: not JSON: {e}"))?;
s.execute("view.apply", &json!({"camera": camera})).map_err(|e| format!("--camera: {e}"))?;
}
(None, None) => {}
}
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
@@ -333,13 +444,20 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
}
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => Box::new(solidcraft_mcp::Remote::connect_with_token(addr, flag(args, "--token")).map_err(|e| {
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
format!(
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
`solidcraft --control {port}` (or SOLIDCRAFT_CONTROL_PORT={port}) and retry"
)
})?),
(Some(addr), None) => {
let Some(port) = addr.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok()).filter(|p| *p != 0) else {
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
};
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
if let Err(e) = remote.try_connect() {
eprintln!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
(or SOLIDCRAFT_CONTROL_PORT={port}), tool calls connect once it runs"
);
}
Box::new(remote)
}
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
};
@@ -401,7 +519,7 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
}
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
let t = std::time::Instant::now();
let step = solidcraft_engine::io::is_step_path(path);
@@ -439,5 +557,37 @@ fn cmd_bench_open(args: &[String]) -> Result<(), String> {
st.warm_meshes(|| true);
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
// evaluation and the plans of the bodies it changed.
let flag = |name: &str| args.iter().position(|a| a == name);
let Some(level) = flag("--display").and_then(|i| args.get(i + 1)) else { return Ok(()) };
let q = solidcraft_engine::doc::display_quality::DisplayQuality::parse(level).ok_or("--display: coarse, normal, fine or ultra")?;
let plans = |st: &solidcraft_engine::doc::ModelState| {
let t = std::time::Instant::now();
solidcraft_engine::kernel::for_each_on_threads(&st.bodies, 16, |b| {
let _ = b.plan_mesh_at(q);
});
let triangles: usize = st.bodies.iter().filter_map(|b| b.plan_mesh_at(q)).map(|m| m.triangles.len()).sum();
(t.elapsed().as_secs_f64() * 1000.0, triangles)
};
let (ms, triangles) = plans(&st);
println!("{}", json!({"step": "display", "quality": q.id(), "ms": ms, "triangles": triangles}));
if let Some(i) = flag("--edit") {
let (Some(name), Some(expr)) = (args.get(i + 1), args.get(i + 2)) else { return Err("--edit NAME EXPR".into()) };
let mut s = s;
let t = std::time::Instant::now();
s.execute("parameters.change", &json!({"name": name, "expression": expr})).map_err(|e| e.to_string())?;
let eval_ms = t.elapsed().as_secs_f64() * 1000.0;
let st = s.world_state();
let t = std::time::Instant::now();
st.warm_meshes(|| true);
let mesh_ms = t.elapsed().as_secs_f64() * 1000.0;
let (ms, triangles) = plans(&st);
println!(
"{}",
json!({"step": "edit", "quality": q.id(), "evaluate_ms": eval_ms, "mesh_ms": mesh_ms, "display_ms": ms, "triangles": triangles})
);
}
Ok(())
}
+12 -1
View File
@@ -92,7 +92,7 @@ fn extent(f: &Value, p: &mut Value) {
"symmetric" => {
let full = e.get("is_full_length").and_then(Value::as_bool).unwrap_or(false);
p["direction"] = json!("symmetric");
p["distance"] = if full { json!(format!("({}) / 2", expr_text(&dist))) } else { dist };
p["distance"] = if full { dist } else { json!(format!("({}) * 2", expr_text(&dist))) };
}
"two_sides" => {
p["distance"] = e.get("side1").cloned().unwrap_or(Value::Null);
@@ -296,6 +296,17 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
Some("sketch") => sketch_cmds(f, &mut out)?,
Some("extrude") => {
let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies});
// Thin: {sketch, curves, thickness, side?, thickness2?} instead of profiles.
if let (Some(thin), Some(params)) = (f.get("thin"), p.as_object_mut()) {
params.remove("profiles");
params.insert("thin".into(), json!(true));
params.insert("sketch".into(), thin.get("sketch").cloned().unwrap_or(Value::Null));
for key in ["curves", "thickness", "side", "thickness2"] {
if let Some(v) = thin.get(key) {
params.insert(key.into(), v.clone());
}
}
}
extent(f, &mut p);
out.push(json!({"command": "solid.extrude", "params": p}));
}
+79
View File
@@ -0,0 +1,79 @@
//! Complete headless image-job workflow through the same engine commands as the UI/MCP.
use serde_json::json;
use solidcraft_engine::{
doc::render_settings::RenderSetup,
render::job::{CpuHost, JobId, Phase},
};
pub fn run(args: &[String]) -> Result<(), String> {
let input = args.first().filter(|p| !p.starts_with('-')).ok_or("render requires an input design or script")?;
let mut out = None;
let mut setup = None;
let mut width = None;
let mut height = None;
let mut transparent = false;
let mut overwrite = false;
let mut timeout = 120u64;
let mut i = 1;
while let Some(arg) = args.get(i) {
match arg.as_str() {
"--transparent" => transparent = true,
"--overwrite" => overwrite = true,
"--out" | "--setup" | "--width" | "--height" | "--timeout" => {
i += 1;
let value = args.get(i).ok_or_else(|| format!("missing value for {arg}"))?;
match arg.as_str() {
"--out" => out = Some(value.clone()),
"--setup" => setup = Some(value.clone()),
"--width" => width = Some(value.parse::<u32>().map_err(|_| "invalid width")?),
"--height" => height = Some(value.parse::<u32>().map_err(|_| "invalid height")?),
_ => timeout = value.parse::<u64>().map_err(|_| "invalid timeout")?,
}
}
_ => return Err(format!("unknown render option: {arg}")),
}
i += 1;
}
if !(1..=3600).contains(&timeout) {
return Err("render timeout must be 1..3600 seconds".into());
}
let out = out.ok_or("render requires --out FILE.png")?;
let mut session = crate::load(input)?;
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
let mut settings = if let Some(name) = setup {
session.doc.render.setups.get(&name).cloned().ok_or("no saved render setup with that name")?
} else {
let mut settings = RenderSetup::default();
if let Some(camera) = session.camera {
settings.camera = camera;
} else {
settings.camera.fit(&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds);
}
settings
};
if let Some(width) = width {
settings.width = width;
}
if let Some(height) = height {
settings.height = height;
}
let params = json!({"settings":settings,"transparent_background":transparent});
let preflight = session.execute("render.preflight", &params).map_err(|e| e.to_string())?;
let started = session.execute("render.start", &params).map_err(|e| e.to_string())?;
let id: JobId = serde_json::from_value(started["job"].clone()).map_err(|e| e.to_string())?;
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
let status = match handle.wait_timeout(std::time::Duration::from_secs(timeout)) {
Ok(status) => status,
Err(e) => {
let _ = session.execute("render.cancel", &json!({"job":id}));
return Err(e.to_string());
}
};
if status.progress.phase != Phase::Completed {
return Err(status.error.map_or_else(|| "render cancelled".into(), |e| e.to_string()));
}
let exported = session.execute("render.export", &json!({"job":id,"path":out,"overwrite":overwrite})).map_err(|e| e.to_string())?;
println!("{}", crate::pretty(&json!({"preflight":preflight,"export":exported})));
Ok(())
}
+413 -52
View File
@@ -13,8 +13,10 @@
//! fails the test (a regression). Newly passing actions are reported; record them with
//! `cargo xtask action-matrix --bless` (or `ACTION_MATRIX_BLESS=1` on [`action_matrix_all`]).
//! Every CI run checks a fixed sample ([`action_matrix_sample`], under a minute); the full matrix
//! is four ignored tests (`action_matrix_full_N`) that the nightly "Action matrix" job runs side by
//! side. See docs/action-matrix.md.
//! is the ignored [`action_matrix_shard`], which the nightly "Action matrix" jobs run one shard
//! each (`ACTION_MATRIX_SHARD=k/n`), cut by what each design costs (`tests/action_matrix/costs.json`).
//! [`action_matrix_determinism`] runs a sample twice and lists any action whose outcome changed.
//! See docs/action-matrix.md.
//!
//! Each design runs in a child process (this test binary, [`action_matrix_worker`]) so an action
//! that hangs or aborts is recorded as such and the rest of the design still runs.
@@ -33,6 +35,10 @@ use solidcraft_engine::geom::Vec3;
/// One in this many actions runs on pull requests (by a stable hash of its key).
const PR_SAMPLE: u64 = 40;
/// One in this many actions runs twice in the nightly determinism check, on the designs whose
/// whole matrix takes at most [`DETERMINISM_MAX_SECS`] on a runner (most of the corpus).
const DETERMINISM_SAMPLE: u64 = 8;
const DETERMINISM_MAX_SECS: f64 = 300.0;
/// Designs that take seconds to build: the full matrix only.
const SLOW: [&str; 7] = [
"oracle/41-tube-radial-holes",
@@ -623,17 +629,25 @@ fn action_matrix_worker() {
out.flush().unwrap();
};
let Some((_, path)) = designs().into_iter().find(|(n, _)| *n == design) else { panic!("no design {design}") };
// A slice of a heavy design (`j/n`): the actions whose name hashes to `j` modulo `n` (by
// index, slices would take whole kinds of action when n shares a factor with the number of
// actions per entity, and a slice of only fillets runs three times as long); the first
// slice records the load.
let slice: Option<(usize, usize)> = std::env::var("ACTION_MATRIX_SLICE").ok().and_then(|v| parse_part(&v));
let first = slice.is_none_or(|(j, _)| j == 0);
let t0 = Instant::now();
let s = match load(&path) {
Ok(s) => s,
Err(e) => {
emit(outcome(&design, "design", "load", &Err(("load".into(), e)), t0.elapsed().as_millis()));
if first {
emit(outcome(&design, "design", "load", &Err(("load".into(), e)), t0.elapsed().as_millis()));
}
emit(json!({"done": true}));
return;
}
};
let before = errors(&s);
if from == 0 {
if from == 0 && first {
let r = if before.is_empty() { Ok(()) } else { Err(("timeline_error".into(), before.values().cloned().collect::<Vec<_>>().join("; "))) };
emit(outcome(&design, "design", "load", &r, t0.elapsed().as_millis()));
}
@@ -647,6 +661,9 @@ fn action_matrix_worker() {
if only.as_ref().is_some_and(|o| !o.iter().any(|x| x == a.action)) {
continue;
}
if slice.is_some_and(|(j, n)| stable_hash(&format!("{} {}", a.entity, a.action)) % n as u64 != j as u64) {
continue;
}
// Whole families of pieces are sampled together (see [`family`]).
let k = key(&design, &family(&a.entity), a.action);
if !stable_hash(&k).is_multiple_of(sample) {
@@ -693,6 +710,8 @@ fn action_matrix_worker() {
&& let Some((name, expr)) = &edit
&& stable_hash(&format!("{k} edit")).is_multiple_of(EDIT_SAMPLE)
{
// Marked like an action, so a hang here is put down to the edit round (not to loading).
emit(json!({"start": i, "edit": true}));
let t = Instant::now();
let start = s.doc.features.len();
let r = scratch
@@ -706,46 +725,155 @@ fn action_matrix_worker() {
emit(json!({"done": true, "actions": list.len()}));
}
/// Run the actions (one in `sample`) of every design in part `part` of `parts`, in child
/// processes, `jobs` at a time.
fn run_matrix(sample: u64, part: usize, parts: usize) -> Vec<Value> {
/// `"k/n"` (1-based `k`) as `(k - 1, n)`.
fn parse_part(v: &str) -> Option<(usize, usize)> {
let (k, n) = v.split_once('/')?;
let (k, n): (usize, usize) = (k.trim().parse().ok()?, n.trim().parse().ok()?);
(k >= 1 && k <= n).then_some((k - 1, n))
}
/// What one design costs on a CI runner (`tests/action_matrix/costs.json`, measured by the
/// nightly shards): the whole run and the load before its first action, in seconds.
#[derive(Clone, Copy, Debug)]
struct Cost {
secs: f64,
load: f64,
}
fn costs_path() -> PathBuf {
root().join("tests/action_matrix/costs.json")
}
fn read_costs() -> BTreeMap<String, Cost> {
let Some(v) = std::fs::read(costs_path()).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) else { return BTreeMap::new() };
v["designs"]
.as_object()
.into_iter()
.flatten()
.map(|(d, c)| (d.clone(), Cost { secs: c["secs"].as_f64().unwrap_or(0.0), load: c["load"].as_f64().unwrap_or(0.0) }))
.collect()
}
/// A design new to the corpus (not in costs.json yet) is planned as this.
const UNKNOWN_COST: Cost = Cost { secs: 120.0, load: 5.0 };
/// A design that takes longer than this on a runner is split into slices of about this much, or
/// five loads if that is more (each slice loads the design again), so no shard waits on one design.
const SLICE_SECS: f64 = 600.0;
/// Designs that take longer than this to load on a runner stay out of the pull request sample.
const SAMPLE_MAX_LOAD_SECS: f64 = 8.0;
/// A piece of work: one design, or a slice `(j, n)` of a heavy one, and what it should cost.
#[derive(Clone, Debug)]
struct Unit {
design: String,
slice: Option<(usize, usize)>,
cost: f64,
}
impl Unit {
fn name(&self) -> String {
match self.slice {
Some((j, n)) => format!("{} [{}/{n}]", self.design, j + 1),
None => self.design.clone(),
}
}
}
/// The whole matrix split into `parts` shards of about equal cost: heavy designs in slices, then
/// the largest pieces first, each to the shard with the least so far. The same corpus and
/// costs.json always give the same shards.
fn plan(designs: &[String], costs: &BTreeMap<String, Cost>, parts: usize) -> Vec<Vec<Unit>> {
let mut units = Vec::new();
for d in designs {
let c = costs.get(d).copied().unwrap_or(UNKNOWN_COST);
let n = ((c.secs / SLICE_SECS.max(5.0 * c.load)).ceil() as usize).max(1);
if n == 1 {
units.push(Unit { design: d.clone(), slice: None, cost: c.secs });
} else {
let each = c.load + (c.secs - c.load).max(0.0) / n as f64;
units.extend((0..n).map(|j| Unit { design: d.clone(), slice: Some((j, n)), cost: each }));
}
}
units.sort_by(|a, b| b.cost.total_cmp(&a.cost).then_with(|| a.name().cmp(&b.name())));
let mut shards: Vec<(f64, Vec<Unit>)> = vec![(0.0, Vec::new()); parts];
for u in units {
let i = (0..parts).min_by(|&a, &b| shards[a].0.total_cmp(&shards[b].0)).unwrap_or(0);
shards[i].0 += u.cost;
shards[i].1.push(u);
}
shards.into_iter().map(|(_, v)| v).collect()
}
/// Every design's name, with `ACTION_MATRIX_FILTER` applied.
fn design_names() -> Vec<String> {
let designs = designs();
assert!(designs.len() > 80, "only {} designs", designs.len());
let dir = std::env::temp_dir().join(format!("action-matrix-{}-{part}", std::process::id()));
let filter = std::env::var("ACTION_MATRIX_FILTER").ok();
designs.into_iter().map(|(n, _)| n).filter(|n| filter.as_deref().is_none_or(|f| n.contains(f))).collect()
}
/// Run the actions (one in `sample`) of these units in child processes, `jobs` at a time.
///
/// `ACTION_MATRIX_WORKDIR` keeps each design's raw outcomes there (one JSON line each, written as
/// they come, so a run cut short still shows what it did) and `ACTION_MATRIX_TIMINGS` appends
/// each unit's time to that file (JSON lines).
fn run_units(sample: u64, queue: &[Unit]) -> Vec<Value> {
let keep = std::env::var_os("ACTION_MATRIX_WORKDIR").map(PathBuf::from);
static RUN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("action-matrix-{}-{run}", std::process::id())));
std::fs::create_dir_all(&dir).unwrap();
let timings = std::env::var_os("ACTION_MATRIX_TIMINGS").map(PathBuf::from);
let jobs: usize = std::env::var("ACTION_MATRIX_JOBS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| std::thread::available_parallelism().map_or(2, |n| n.get()))
.max(1);
let filter = std::env::var("ACTION_MATRIX_FILTER").ok();
let queue: Vec<(String, PathBuf)> = designs
.into_iter()
.enumerate()
.filter(|(i, (n, _))| i % parts == part && filter.as_deref().is_none_or(|f| n.contains(f)) && (sample == 1 || !SLOW.contains(&n.as_str())))
.map(|(_, d)| d)
.collect();
let next = std::sync::atomic::AtomicUsize::new(0);
let results = std::sync::Mutex::new(Vec::new());
std::thread::scope(|scope| {
for _ in 0..jobs {
scope.spawn(|| {
while let Some((name, _)) = queue.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let out = dir.join(format!("{}.jsonl", name.replace('/', "__")));
let lines = run_design(name, &out, sample);
while let Some(u) = queue.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let file = u.name().replace('/', "__").replace([' ', '[', ']'], "_");
let out = dir.join(format!("{file}.r{run}.jsonl"));
let t = Instant::now();
let lines = run_design(&u.design, &out, sample, u.slice);
let secs = t.elapsed().as_secs_f64();
let load = lines.iter().find(|o| o["entity"] == "design" && o["action"] == "load").and_then(|o| o["ms"].as_f64()).unwrap_or(0.0) / 1000.0;
if let Some(p) = &timings {
let line = json!({"design": u.design, "slice": u.slice.map(|(j, n)| format!("{}/{n}", j + 1)), "secs": round3(secs), "load": round3(load), "actions": lines.len()});
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(p).unwrap();
writeln!(f, "{line}").unwrap();
}
eprintln!("action matrix: {} took {secs:.1} s ({} outcomes, load {load:.1} s)", u.name(), lines.len());
results.lock().unwrap().extend(lines);
}
});
}
});
let _ = std::fs::remove_dir_all(&dir);
if keep.is_none() {
let _ = std::fs::remove_dir_all(&dir);
}
let mut v = results.into_inner().unwrap();
v.sort_by_key(|o| key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or("")));
v
}
/// Run the actions (one in `sample`) of every design (the pull request sample leaves out the
/// slow-loading ones).
fn run_matrix(sample: u64) -> Vec<Value> {
let costs = read_costs();
let queue: Vec<Unit> = design_names()
.into_iter()
.filter(|n| sample == 1 || !(SLOW.contains(&n.as_str()) || costs.get(n).is_some_and(|c| c.load > SAMPLE_MAX_LOAD_SECS)))
.map(|design| Unit { design, slice: None, cost: 0.0 })
.collect();
run_units(sample, &queue)
}
/// One design in child processes, restarting after an action that hangs or aborts.
fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
fn run_design(design: &str, out: &Path, sample: u64, slice: Option<(usize, usize)>) -> Vec<Value> {
let _ = std::fs::remove_file(out);
let mut from = 0usize;
let mut results = Vec::new();
@@ -757,6 +885,7 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
.env("ACTION_MATRIX_OUT", out)
.env("ACTION_MATRIX_FROM", from.to_string())
.env("ACTION_MATRIX_SAMPLE", sample.to_string())
.env("ACTION_MATRIX_SLICE", slice.map(|(j, n)| format!("{}/{n}", j + 1)).unwrap_or_default())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
@@ -785,13 +914,18 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
.map(|f| std::io::BufReader::new(f).lines().map_while(Result::ok).filter_map(|l| serde_json::from_str(&l).ok()).collect())
.unwrap_or_default();
let done = lines.iter().any(|l| l.get("done").is_some());
let last_start = lines.iter().rev().take_while(|l| l.get("start").is_some() || l.get("design").is_none()).find_map(|l| l["start"].as_u64());
let last = lines.iter().rev().take_while(|l| l.get("start").is_some() || l.get("design").is_none()).find(|l| l.get("start").is_some());
let last_start = last.and_then(|l| l["start"].as_u64());
let in_edit = last.is_some_and(|l| l["edit"] == true);
results = lines.iter().filter(|l| l.get("design").is_some()).cloned().collect();
if done {
break;
}
// It stopped inside action `last_start`: record it and go on after it.
let Some(i) = last_start else {
if slice.is_some_and(|(j, _)| j > 0) {
break;
}
extra.push(
json!({"design": design, "entity": "design", "action": "load", "ok": false, "kind": if timed_out { "timeout" } else { "crash" },
"message": format!("the design did not load ({status:?})")}),
@@ -800,7 +934,7 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
};
// Which action was it? The child enumerates them; ask it by running from there with a
// marker: simplest is to record the index and let the next run name it.
extra.push(json!({"design": design, "index": i, "kind": if timed_out { "timeout" } else { "crash" },
extra.push(json!({"design": design, "index": i, "edit": in_edit, "kind": if timed_out { "timeout" } else { "crash" },
"message": if timed_out { format!("no result after {} s", ACTION_TIMEOUT.as_secs()) } else { format!("the process ended: {status:?}") }}));
from = i as usize + 1;
}
@@ -809,7 +943,10 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
let names = action_names(design);
for e in &mut extra {
if let Some(i) = e.get("index").and_then(Value::as_u64) {
let (entity, action) = names.get(i as usize).cloned().unwrap_or_else(|| (format!("action{i}"), "unknown".into()));
let (entity, mut action) = names.get(i as usize).cloned().unwrap_or_else(|| (format!("action{i}"), "unknown".into()));
if e["edit"] == true {
action += "+edit";
}
*e = json!({"design": design, "entity": entity, "action": action, "ok": false, "kind": e["kind"], "message": e["message"]});
}
}
@@ -856,9 +993,12 @@ fn write_baseline(results: &[Value]) {
m.entry(design).or_default().entry(entity).or_default().extend(actions.as_str().unwrap_or("").split_whitespace().map(str::to_string));
}
}
// A timeout of an action that passed keeps it passing (see [`ratchet`]).
let base = read_baseline();
for o in results {
let (design, entity, action) = (o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
let mark = if o["ok"].as_bool() == Some(true) { "" } else { "!" };
let kept = o["kind"] == "timeout" && base.get(&key(design, entity, action)) == Some(&true);
let mark = if o["ok"].as_bool() == Some(true) || kept { "" } else { "!" };
m.entry(design).or_default().entry(entity).or_default().push(format!("{mark}{action}"));
}
// One entity per line, sorted: small diffs when a few actions change.
@@ -887,10 +1027,18 @@ fn ratchet(results: &[Value], full: bool) {
// baseline, over the same names this run tried, and in this run.
let mut fam: BTreeMap<String, (usize, usize, Vec<String>, Vec<String>)> = BTreeMap::new();
let mut fresh = 0usize;
// An action that passed in the baseline and now runs past ACTION_TIMEOUT is listed but not
// judged: on a busy runner the slowest actions (40-80 s on oracle 41's tube) land either side
// of the limit from one night to the next. An error or a crash is judged as usual.
let mut slow = Vec::new();
for o in results {
let (design, entity, action) = (o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
let k = key(design, entity, action);
let ok = o["ok"].as_bool() == Some(true);
if o["kind"] == "timeout" && base.get(&k) == Some(&true) {
slow.push(k);
continue;
}
let f = fam.entry(key(design, &family(entity), action)).or_default();
match base.get(&k) {
Some(was) => {
@@ -927,6 +1075,14 @@ fn ratchet(results: &[Value], full: bool) {
eprintln!("baseline written: {}", baseline_path().display());
return;
}
if !slow.is_empty() {
eprintln!(
"{} actions that pass in the baseline ran past the {} s limit this time (not judged):\n{}",
slow.len(),
ACTION_TIMEOUT.as_secs(),
slow.join("\n")
);
}
if !fixed.is_empty() || fresh > 0 {
eprintln!(
"{} actions pass now that failed in the baseline, and {fresh} are not in it: record them with `cargo xtask action-matrix --bless`:\n{}",
@@ -945,51 +1101,156 @@ fn ratchet(results: &[Value], full: bool) {
/// Pull requests: a fixed sample of the matrix (one action in [`PR_SAMPLE`]) against the ratchet.
#[test]
fn action_matrix_sample() {
let results = run_matrix(PR_SAMPLE, 0, 1);
let results = run_matrix(PR_SAMPLE);
assert!(results.len() > 300, "only {} actions ran", results.len());
ratchet(&results, false);
}
/// The whole matrix against the ratchet, in four parts that the nightly CI job runs side by side
/// (`--run-ignored only`).
fn full_part(part: usize) {
let results = run_matrix(1, part, 4);
/// One shard of the whole matrix against the ratchet: `ACTION_MATRIX_SHARD=k/n` (the nightly
/// "Action matrix" CI jobs run the n shards side by side, `--run-ignored only`). Shards are cut by
/// what each design costs (tests/action_matrix/costs.json), see [`plan`].
/// `ACTION_MATRIX_SHARD_LIST=1` only prints the plan.
#[test]
#[ignore = "heavy: the nightly Action matrix CI jobs run it, one shard each"]
fn action_matrix_shard() {
let Some((k, n)) = std::env::var("ACTION_MATRIX_SHARD").ok().and_then(|v| parse_part(&v)) else { panic!("set ACTION_MATRIX_SHARD=k/n") };
let shards = plan(&design_names(), &read_costs(), n);
for (i, sh) in shards.iter().enumerate() {
let total: f64 = sh.iter().map(|u| u.cost).sum();
eprintln!("shard {}/{n}: {} pieces, about {:.0} s of work{}", i + 1, sh.len(), total, if i == k { " (this one)" } else { "" });
}
eprintln!("this shard: {}", shards[k].iter().map(Unit::name).collect::<Vec<_>>().join(", "));
if std::env::var_os("ACTION_MATRIX_SHARD_LIST").is_some() {
return;
}
let results = run_units(1, &shards[k]);
ratchet(&results, false);
}
/// Determinism: a sample of the matrix (one family in [`DETERMINISM_SAMPLE`], the heaviest
/// designs left out) run twice must give the same outcome for every action. Lists each action
/// whose outcome changed between the runs.
#[test]
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
fn action_matrix_full_1() {
full_part(0);
}
#[test]
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
fn action_matrix_full_2() {
full_part(1);
}
#[test]
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
fn action_matrix_full_3() {
full_part(2);
}
#[test]
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
fn action_matrix_full_4() {
full_part(3);
#[ignore = "heavy: the nightly Action matrix determinism CI job runs it"]
fn action_matrix_determinism() {
let outcomes = |v: Vec<Value>| -> BTreeMap<String, String> {
v.into_iter()
.map(|o| {
let k = key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
let r = if o["ok"].as_bool() == Some(true) {
"pass".to_string()
} else {
format!("{}: {}", o["kind"].as_str().unwrap_or(""), o["message"].as_str().unwrap_or(""))
};
(k, r)
})
.collect()
};
let costs = read_costs();
let queue: Vec<Unit> = design_names()
.into_iter()
.filter(|n| costs.get(n).copied().unwrap_or(UNKNOWN_COST).secs <= DETERMINISM_MAX_SECS)
.map(|design| Unit { design, slice: None, cost: 0.0 })
.collect();
let a = outcomes(run_units(DETERMINISM_SAMPLE, &queue));
let b = outcomes(run_units(DETERMINISM_SAMPLE, &queue));
let keys: BTreeSet<&String> = a.keys().chain(b.keys()).collect();
let mut changed = Vec::new();
// Pieces that share a name can swap numbers between runs (#569): compare passes per family
// too, and report a family whose pass count differs.
let mut fam: BTreeMap<String, (usize, usize)> = BTreeMap::new();
for k in &keys {
let (x, y) = (a.get(*k).map_or("missing", String::as_str), b.get(*k).map_or("missing", String::as_str));
let mut parts = k.splitn(3, " :: ");
let (d, e, act) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""));
let f = fam.entry(key(d, &family(e), act)).or_default();
f.0 += usize::from(x == "pass");
f.1 += usize::from(y == "pass");
if x != y {
changed.push(format!("{k}\n first: {x}\n second: {y}"));
}
}
let families: Vec<String> = fam.into_iter().filter(|(_, (x, y))| x != y).map(|(k, (x, y))| format!("{k}: {x} then {y} pass")).collect();
eprintln!(
"action matrix determinism: {} actions, {} changed between two runs, {} families pass a different number of times",
keys.len(),
changed.len(),
families.len()
);
for c in &changed {
eprintln!(" {c}");
}
assert!(
families.is_empty(),
"{} families of actions pass a different number of times in two runs of the same build:\n{}",
families.len(),
families.join("\n")
);
}
/// The whole matrix at once (`cargo xtask action-matrix`): `ACTION_MATRIX_BLESS=1` rewrites the
/// baseline; `ACTION_MATRIX_RESULTS=file.json` keeps every outcome with its message.
/// `ACTION_MATRIX_MERGE=DIR` runs nothing and takes the outcomes from the `results-*.json` files
/// in DIR instead (the nightly shards' artifacts), and the timings from its `timings-*.jsonl`
/// files, which `ACTION_MATRIX_BLESS` then also writes to tests/action_matrix/costs.json.
#[test]
#[ignore = "cargo xtask action-matrix runs it (CI runs it as the four action_matrix_full parts)"]
#[ignore = "cargo xtask action-matrix runs it (CI runs it as the action_matrix_shard jobs)"]
fn action_matrix_all() {
let results = run_matrix(1, 0, 1);
let Some(dir) = std::env::var_os("ACTION_MATRIX_MERGE").map(PathBuf::from) else {
let results = run_matrix(1);
ratchet(&results, true);
return;
};
let mut results: Vec<Value> = Vec::new();
let mut timings: Vec<Value> = Vec::new();
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).collect();
files.sort();
for f in files {
let name = f.file_name().unwrap_or_default().to_string_lossy().to_string();
let text = std::fs::read_to_string(&f).unwrap_or_default();
if name.starts_with("results-") && name.ends_with(".json") {
results.extend(serde_json::from_str::<Vec<Value>>(&text).unwrap_or_else(|e| panic!("{name}: {e}")));
} else if name.starts_with("timings-") {
timings.extend(text.lines().filter_map(|l| serde_json::from_str(l).ok()));
}
}
let ran: BTreeSet<&str> = results.iter().filter_map(|o| o["design"].as_str()).collect();
let missing: Vec<String> = design_names().into_iter().filter(|d| !ran.contains(d.as_str())).collect();
assert!(missing.is_empty(), "no results for {}", missing.join(", "));
results.sort_by_key(|o| key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or("")));
if std::env::var_os("ACTION_MATRIX_BLESS").is_some() && !timings.is_empty() {
write_costs(&timings);
}
ratchet(&results, true);
}
/// tests/action_matrix/costs.json from the shards' timings: per design, its whole time (a sliced
/// design's slices added up, less the loads all but one of them repeat) and its load.
fn write_costs(timings: &[Value]) {
let mut m: BTreeMap<String, (f64, f64, usize)> = BTreeMap::new();
for t in timings {
let e = m.entry(t["design"].as_str().unwrap_or("").to_string()).or_default();
e.0 += t["secs"].as_f64().unwrap_or(0.0);
e.1 = e.1.max(t["load"].as_f64().unwrap_or(0.0));
e.2 += 1;
}
// Designs these timings don't cover keep their old costs.
for (d, c) in read_costs() {
m.entry(d).or_insert((c.secs, c.load, 1));
}
let mut s = String::from(
"{\n \"about\": \"What each action matrix design costs on a CI runner, in seconds (secs: the whole design, load: before its first action), for cutting the nightly shards (apps/solidcraft-cli/tests/action_matrix.rs, plan). Written with the baseline by cargo xtask action-matrix --bless --from DIR.\",\n \"designs\": {\n",
);
let n = m.len();
for (i, (d, (secs, load, pieces))) in m.into_iter().enumerate() {
let secs = (secs - load * pieces.saturating_sub(1) as f64).max(load);
s += &format!(" {}: {{\"secs\": {:.1}, \"load\": {:.1}}}{}\n", json!(d), secs, load, if i + 1 < n { "," } else { "" });
}
s += " }\n}\n";
std::fs::write(costs_path(), s).unwrap();
eprintln!("costs written: {}", costs_path().display());
}
// ---------------------------------------------------------------------------------------------
// The matrix's causes: one small reproduction per open issue, ignored until it is fixed (the
// issue says how many matrix actions it accounts for). Each runs commands as the desktop sends
@@ -1031,6 +1292,106 @@ fn a_fillet_or_chamfer_on_one_edge_ending_at_a_cylinder() {
}
}
// #1255: a round on a knuckle's cap circle (oracle/63's hinge), 0.73 wide on a ring of radius
// 4. The run past each end of the circle followed the end tangent so far that its sections
// wrapped round the ring and the blend was refused; it is shortened now while it still
// crosses the end face. Before #1101 a 0.13 mm station on the leaf's top edge kept the
// matrix's radius at 0.013, which never got that far.
#[test]
fn a_round_on_a_knuckle_cap_circle() {
for (cmd, size, removed) in [("solid.fillet", "radius", (2.4, 2.8)), ("solid.chamfer", "distance", (5.6, 6.3))] {
let mut s = load(&root().join("tests/oracle/63-hinge-knuckle/recipe.json")).unwrap();
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate(0.001).unwrap().measure().volume).sum::<f64>();
let before = volume(&s);
s.execute(cmd, &json!({"edges": [[40.0, 29.837_722, 6.449_490]], size: 0.728})).unwrap_or_else(|e| panic!("{cmd}: {e}"));
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{cmd}: {kind}: {e}"));
let st = s.model.state();
let body = &st.bodies.first().unwrap().body;
assert!(body.validity().is_empty(), "{cmd}: {:?}", body.validity());
let gone = before - volume(&s);
assert!(gone > removed.0 && gone < removed.1, "{cmd} took {gone} mm³");
}
}
// #1255: a hole 0.133 mm across on the 0.5 mm ledge a loft cut leaves in a pocket floor of a
// 900 mm slab. The Boolean needed a retry, and its guard meshed the drill as coarsely as the
// slab's size allows (0.4 mm, three times the drill's width): the mesh had no inside, the drill
// measured −30272 mm³ and the right cut was refused. Volumes are measured finer than each
// body's smallest radius now.
#[test]
fn a_hair_thin_hole_on_a_ledge_beside_a_loft_cut() {
let mut s = load(&root().join("examples/regressions/loft_cut_beside_round_hole.json")).unwrap();
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.005, true).unwrap().measure().volume).sum::<f64>();
let before = volume(&s);
s.execute("solid.hole", &json!({"diameter": 0.133, "position": [357.22, -150.993_333, 730.0]})).unwrap();
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
// The drill goes through the 4 mm ledge and on through the slab below: 690 to 730.
let gone = before - volume(&s);
let expected = std::f64::consts::PI * 0.0665 * 0.0665 * 40.0;
assert!((gone - expected).abs() < 0.2, "the hole took {gone} mm³, not {expected}");
}
// #1255: a 0.8 mm hole through the side wall of the plastic part's rest. Meshed at the
// tolerance the 200 mm drill sets, the part with its rounds, bosses and draft measured 6.8 mm³
// more after the cut than before; ten times finer it is 1.44 mm³ less, the drill's volume
// through the 2.9 mm wall. A volume out of bounds is measured again finer before it refuses.
#[test]
fn a_small_hole_through_the_wall_of_a_plastic_rest() {
let mut s = load(&root().join("apps/solidcraft-cli/tests/corpus/modern-plastic.solidcraft")).unwrap();
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.002, true).unwrap().measure().volume).sum::<f64>();
let before = volume(&s);
s.execute("solid.hole", &json!({"diameter": 0.8, "position": [63.069_841_5, 28.333_333, 4.0]})).unwrap();
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
let gone = before - volume(&s);
assert!(gone > 1.2 && gone < 1.7, "the hole took {gone} mm³");
}
// #1255: a hole square to a flange bent at 45°. The drill's cap, evaluated at that angle, lay
// 2.9e-11 mm off the plane it was made on against a bound of 2.2e-11, so the sheet didn't take
// the hole as a cut-out of its flat pattern and the cut was refused. The sheet keeps the hole
// and still unfolds and folds back.
#[test]
fn a_hole_square_to_a_45_degree_flange() {
let mut s = load(&root().join("tests/oracle/70-sm-flange-45/recipe.json")).unwrap();
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.002, true).unwrap().measure().volume).sum::<f64>();
let before = volume(&s);
s.execute("solid.hole", &json!({"diameter": 3.448, "position": [66.666_667, -4.511_845, 4.511_845]})).unwrap();
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
// Through the 2.5 mm flange.
let gone = before - volume(&s);
let expected = std::f64::consts::PI * 1.724 * 1.724 * 2.5;
assert!((gone - expected).abs() < 0.1, "the hole took {gone} mm³, not {expected}");
let sheets: Vec<String> = s.model.state().sheets.iter().map(|sh| sh.body.clone()).collect();
assert!(!sheets.is_empty());
for body in sheets {
for id in ["sheet.unfold", "sheet.refold"] {
s.execute(id, &json!({"body": body})).unwrap_or_else(|e| panic!("{id}: {e}"));
}
}
}
// #1255: an axis through a corner round of the sensor enclosure's lid, then a parameter that
// widens the lid. The round is found again by name, but its point nearest the old pick is the
// end of its arc, on the edge it shares with the flat side, and picking again by that point
// took the side: "the face is not a cylinder". The named face is used as found now.
#[test]
fn an_axis_through_a_corner_round_that_a_parameter_moves() {
let mut s = load(&root().join("examples/acceptance/sensor_enclosure.json")).unwrap();
s.execute("construct.axis.through_cylinder", &json!({"face": {"face": [36.242_617, 29.977_740, 27.0]}})).unwrap();
let start = s.doc.features.len();
s.execute("parameters.change", &json!({"name": "board_w", "expression": "(60 mm) * 1.1"})).unwrap();
check(&s, start, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
let st = s.model.state();
let axis = st.construct.iter().find_map(|c| match &c.geom {
solidcraft_engine::doc::construct::ConstructGeom::Axis { origin, dir } => Some((*origin, *dir)),
_ => None,
});
let (origin, dir) = axis.expect("the axis");
// The round's axis moved with the lid's corner, from x = 36 to 39.
let off = origin - Vec3::new(39.0, 26.0, 0.0);
assert!((off - dir * off.dot(dir)).len() < 1e-6, "the axis passes {origin:?} along {dir:?}");
}
#[test]
fn a_sketch_on_a_patterned_boss_top() {
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 20.]).into();
+168
View File
@@ -0,0 +1,168 @@
//! `--json-out FILE` writes the JSON `run` and `eval` would print to a file the CLI makes
//! itself. A shell's `>` can re-encode stdout (Windows PowerShell writes UTF-16, or UTF-8 with a
//! byte-order mark), which strict JSON readers refuse; the file is plain UTF-8.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-json-out-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A box, and a parameter whose comment has a character outside ASCII.
fn script(&self) -> PathBuf {
let path = self.0.join("box.json");
let script = json!({"commands": [
{"command": "parameters.add", "params": {"name": "bore", "expression": "5 mm", "comment": "\u{d8} of the bore"}},
{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
]});
std::fs::write(&path, script.to_string()).unwrap();
path
}
/// A filleted box whose fillet a later parameter change makes impossible: every command
/// succeeds, the timeline ends with a failing feature.
fn broken_script(&self) -> PathBuf {
let path = self.0.join("broken.json");
let script = json!({"commands": [
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
]});
std::fs::write(&path, script.to_string()).unwrap();
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
}
fn stdout_of(output: Output) -> Vec<u8> {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
output.stdout
}
/// Timings differ from one run to the next.
fn without_timings(v: &mut Value) {
match v {
Value::Object(map) => {
map.remove("ms");
map.values_mut().for_each(without_timings);
}
Value::Array(items) => items.iter_mut().for_each(without_timings),
_ => {}
}
}
/// The file holds what stdout carried in the same command without `--json-out`: the same JSON,
/// laid out the same way, in UTF-8 with no byte-order mark (UTF-8's or UTF-16's), so a strict
/// reader accepts it.
fn assert_plain_utf8_json(file: &Path, stdout: &[u8]) -> Value {
let bytes = std::fs::read(file).unwrap();
assert!(matches!(bytes.first(), Some(b'{' | b'[')), "the file starts with the JSON itself, not a mark");
assert_eq!(bytes.last(), Some(&b'\n'), "like stdout, the file ends with a newline");
let lines = |b: &[u8]| b.iter().filter(|c| **c == b'\n').count();
assert_eq!(lines(&bytes), lines(stdout), "laid out like stdout");
let mut value: Value = serde_json::from_str(std::str::from_utf8(&bytes).unwrap()).unwrap();
let mut printed: Value = serde_json::from_slice(stdout).unwrap();
without_timings(&mut value);
without_timings(&mut printed);
assert_eq!(value, printed, "the file is what stdout would have carried");
value
}
#[test]
fn run_results_json_out_holds_what_stdout_would() {
let dir = Scratch::new();
let script = dir.script();
let file = dir.0.join("results.json");
let on_stdout = stdout_of(cli().arg("run").arg(&script).arg("--results").output().unwrap());
let output = cli().arg("run").arg(&script).arg("--results").arg("--json-out").arg(&file).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "the JSON goes to the file, not to stdout");
let results = assert_plain_utf8_json(&file, &on_stdout);
assert_eq!(results.as_array().map(Vec::len), Some(2));
}
#[test]
fn run_json_out_without_results_holds_the_measurement_even_when_quiet() {
let dir = Scratch::new();
let script = dir.script();
let on_stdout = stdout_of(cli().arg("run").arg(&script).output().unwrap());
for (name, extra) in [("plain", &[][..]), ("quiet", &["--quiet"][..])] {
let file = dir.0.join(format!("measure-{name}.json"));
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "{name}");
let measure = assert_plain_utf8_json(&file, &on_stdout);
assert_eq!(measure["body_count"], 1, "{name}");
}
}
#[test]
fn eval_json_out_holds_what_stdout_would() {
let dir = Scratch::new();
let script = dir.script();
let on_stdout = stdout_of(cli().arg("eval").arg(&script).output().unwrap());
// The option may come before the file to evaluate, and `inspect` is the same command.
for name in ["eval", "inspect"] {
let file = dir.0.join(format!("{name}.json"));
let output = cli().arg(name).arg("--json-out").arg(&file).arg(&script).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "{name}");
let measured = assert_plain_utf8_json(&file, &on_stdout);
assert_eq!(measured["body_count"], 1, "{name}");
// A character outside ASCII is written as it is, in UTF-8.
assert!(std::fs::read_to_string(&file).unwrap().contains("\u{d8} of the bore"), "{name}");
}
}
#[test]
fn json_out_needs_a_file_and_fails_when_it_cannot_write() {
let dir = Scratch::new();
let script = dir.script();
for cmd in ["run", "eval"] {
let output = cli().arg(cmd).arg(&script).arg("--json-out").output().unwrap();
assert_eq!(output.status.code(), Some(1), "{cmd}");
assert!(String::from_utf8_lossy(&output.stderr).contains("--json-out needs a FILE"), "{cmd}");
assert!(output.stdout.is_empty(), "{cmd}");
let missing = dir.0.join("missing").join("out.json");
let output = cli().arg(cmd).arg(&script).arg("--json-out").arg(&missing).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{cmd}");
assert!(!output.stderr.is_empty(), "{cmd}");
assert!(output.stdout.is_empty(), "{cmd}");
assert!(!missing.exists(), "{cmd}");
}
}
#[test]
fn run_json_out_is_not_written_when_a_feature_fails() {
let dir = Scratch::new();
let script = dir.broken_script();
for (name, extra) in [("plain", &[][..]), ("results", &["--results"][..])] {
let file = dir.0.join(format!("failed-{name}.json"));
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{name}");
assert!(String::from_utf8_lossy(&output.stderr).contains("nothing was written"), "{name}");
assert!(!file.exists(), "{name}");
}
}
+74 -9
View File
@@ -1,18 +1,83 @@
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
//! `mcp --connect` to a port where no app listens yet (#270): the server still starts, its tools
//! say how to start the app, and once the app runs they reach it with that launch's token.
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
#[test]
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() {
// A port that was free a moment ago: nothing listens on it.
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
.stdin(Stdio::null())
.output()
let addr = format!("127.0.0.1:{port}");
let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-connect-{}-{port}", std::process::id()));
std::fs::create_dir_all(&control_dir).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--connect", &addr, "--allow-any-path"])
.env("SOLIDCRAFT_CONTROL_DIR", &control_dir)
.env_remove("SOLIDCRAFT_CONTROL_TOKEN")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{stderr}");
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut ask = |id: u64, method: &str, params: Value| -> Value {
writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
let mut line = String::new();
stdout.read_line(&mut line).unwrap();
serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e}: {line:?}"))
};
let measure = json!({"name": "measure", "arguments": {}});
let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
// The server answers without the app, and its instructions say the app isn't running.
let init = ask(1, "initialize", json!({"protocolVersion": "2025-06-18"}));
let instructions = init["result"]["instructions"].as_str().unwrap_or_default();
assert!(instructions.contains("not running") && instructions.contains(&format!("--control {port}")), "{instructions}");
let tools = ask(2, "tools/list", json!({}));
assert!(tools["result"]["tools"].as_array().is_some_and(|t| !t.is_empty()), "{tools}");
// A tool call says the app is not running and how to start it.
let r = ask(3, "tools/call", measure.clone());
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(text(&r).contains("not running") && text(&r).contains(&format!("solidcraft --control {port}")), "{r}");
// The app starts: a token file for this launch, and its control port.
let token = "0123456789abcdef".repeat(4);
std::fs::write(control_dir.join(format!("{port}.token")), &token).unwrap();
let listener = TcpListener::bind(&addr).unwrap();
let app = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap();
let reply = if req["token"] == json!(token) {
json!({"id": req["id"], "ok": true, "result": {"bodies": [], "method": req["method"]}})
} else {
json!({"id": req["id"], "ok": false, "error": format!("unauthorized: token {}", req["token"])})
};
writeln!(&stream, "{reply}").unwrap();
});
let r = ask(4, "tools/call", measure);
assert_ne!(r["result"]["isError"], true, "{r}");
assert!(text(&r).contains("engine.execute"), "{r}");
app.join().unwrap();
drop(stdin);
assert!(child.wait().unwrap().success());
std::fs::remove_dir_all(&control_dir).ok();
}
#[test]
fn mcp_connect_without_a_port_is_refused_at_start() {
for addr in ["localhost", "127.0.0.1:0"] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--connect", addr]).stdin(Stdio::null()).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{addr}: {stderr}");
assert!(stderr.contains("HOST:PORT"), "{addr}: {stderr}");
}
}
+38
View File
@@ -132,3 +132,41 @@ fn legacy_extension_and_format_tag_still_open() {
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
assert!(reopened.to_json().contains("\"solidcraft/"));
}
/// `snapshot` starts from `--in` as `run` does (its script runs on that design), and refuses an
/// option it doesn't take rather than ignoring it.
#[test]
fn snapshot_runs_its_script_on_the_in_design() {
let dir = Scratch::new();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let shifted = dir.0.join("shifted.json");
std::fs::write(
&shifted,
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "corner": [10, 0, 0]}}]}).to_string(),
)
.unwrap();
let shot = |name: &str, args: &[&std::ffi::OsStr]| {
let png = dir.0.join(name);
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
std::fs::read(&png).unwrap()
};
let alone = shot("alone.png", &[shifted.as_os_str()]);
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains(says), "{}", String::from_utf8_lossy(&output.stderr));
}
}
/// An unknown command's error suggests the id that was meant.
#[test]
fn unknown_command_suggests_the_closest_id() {
let output = cli().args(["exec", "file.open"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
}
@@ -0,0 +1,86 @@
//! A design's command script (`timeline.export_script`, #886) replays into the same design:
//! every corpus design that the script supports comes back with the same timeline, parameters,
//! settings and bodies.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_engine::Session;
fn corpus() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
let mut v: Vec<PathBuf> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
v.sort();
assert!(v.len() >= 7, "corpus files in {}", dir.display());
v
}
/// Designs with components or joints, which scripts do not support yet.
const ASSEMBLIES: [&str; 2] = ["components-arm", "joints-hinge"];
fn bodies(s: &mut Session) -> Vec<(String, f64, f64, Value)> {
let m = s.execute("inspect.measure", &json!({})).unwrap();
let mut v: Vec<_> = m["bodies"]
.as_array()
.into_iter()
.flatten()
.map(|b| {
(
b["name"].as_str().unwrap_or_default().to_string(),
b["volume_mm3"].as_f64().unwrap(),
b["area_mm2"].as_f64().unwrap(),
b["faces"].clone(),
)
})
.collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
fn errors(s: &Session) -> Vec<(u64, Option<String>)> {
s.model.results.iter().map(|r| (r.id, r.error.clone())).collect()
}
fn params(s: &Session) -> Vec<solidcraft_engine::doc::Param> {
let mut v = s.doc.params.clone();
v.sort_by(|a, b| a.name.cmp(&b.name));
v
}
#[test]
fn corpus_designs_replay_from_their_scripts() {
let mut replayed = 0;
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let mut s = Session::default();
s.execute("doc.open", &json!({"path": p.to_string_lossy()})).unwrap();
let r = match s.execute("timeline.export_script", &json!({})) {
Ok(r) => r,
Err(e) => {
assert!(ASSEMBLIES.contains(&name.as_str()), "{name}: {e}");
assert!(e.to_string().contains("not supported yet"), "{name}: {e}");
continue;
}
};
assert!(!ASSEMBLIES.contains(&name.as_str()), "{name} has components: the script should refuse it");
// Through text, as a script file would carry it.
let script: Value = serde_json::from_str(&r["script"].to_string()).unwrap();
let mut t = Session::default();
t.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(t.doc.features, s.doc.features, "{name}: timeline");
assert_eq!(params(&t), params(&s), "{name}: parameters");
assert_eq!(t.doc.marker, s.doc.marker, "{name}: marker");
assert_eq!(t.doc.units, s.doc.units, "{name}: units");
assert_eq!(t.doc.sheet, s.doc.sheet, "{name}: sheet metal rules");
assert_eq!(t.doc.plastic, s.doc.plastic, "{name}: plastic rules");
assert_eq!(t.doc.materials, s.doc.materials, "{name}: materials");
assert_eq!(t.doc.user_materials, s.doc.user_materials, "{name}: user materials");
assert_eq!(t.doc.favorites, s.doc.favorites, "{name}: favourites");
assert_eq!(t.doc.param_comments, s.doc.param_comments, "{name}: comments");
assert_eq!(errors(&t), errors(&s), "{name}: feature results");
assert_eq!(bodies(&mut t), bodies(&mut s), "{name}: bodies");
replayed += 1;
}
assert!(replayed >= 7, "replayed {replayed} corpus designs");
}
@@ -0,0 +1,217 @@
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
//! design or one the script applies itself, and without the ground grid and the origin's axes
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it).
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_mcp::{Headless, Server};
static NEXT: AtomicUsize = AtomicUsize::new(0);
const SIZE: (usize, usize) = (160, 100);
fn camera() -> Value {
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
}
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-snapshot-camera-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A box that straddles Z 0 and keeps clear of the origin (its axes show beside it), with a view
/// saved in the design, plus `more` steps.
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
let mut steps = vec![
json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30, "corner": [10, -20, -15]}}),
json!({"command": "view.save", "params": {"name": "Detail", "camera": camera()}}),
];
steps.extend(more.iter().cloned());
let path = self.0.join(name);
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
path
}
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.arg("snapshot")
.arg(script)
.arg("--out")
.arg(self.0.join(out))
.args(["--width", &w, "--height", &h])
.args(extra)
.output()
.unwrap()
}
/// The PNG `snapshot` writes.
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
let output = self.run(script, out, extra);
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
let png = std::fs::read(self.0.join(out)).unwrap();
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
png
}
/// The PNG the MCP `screenshot` tool writes for the same design.
fn mcp(&self, script: &Path, out: &str, arguments: Value) -> Vec<u8> {
let mut session = Session::default();
session.run_script(&serde_json::from_slice(&std::fs::read(script).unwrap()).unwrap()).unwrap();
let mut server = Server::new(Box::new(Headless::new(session)));
let mut arguments = arguments;
arguments["path"] = json!(self.0.join(out).to_string_lossy());
arguments["width"] = json!(SIZE.0);
arguments["height"] = json!(SIZE.1);
let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": arguments}});
let reply: Value = serde_json::from_str(&server.handle_line(&call.to_string()).unwrap()).unwrap();
assert!(reply.get("error").is_none() && reply["result"]["isError"] != json!(true), "{reply}");
std::fs::read(self.0.join(out)).unwrap()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The picture of `--camera` is the picture of the MCP `screenshot` with the same camera, and
/// not the home view the option used to be ignored for.
#[test]
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let home = dir.snapshot(&script, "home.png", &[]);
let json = camera().to_string();
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
assert_ne!(shot, home, "--camera was ignored");
assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})));
// A perspective camera too, and a different eye gives a different picture.
let mut near = camera();
near["fov"] = json!(0.6);
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
assert_ne!(perspective, shot);
assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})));
}
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
/// standard view still looks as it did.
#[test]
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
assert_eq!(saved, by_camera);
assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})));
// A standard view frames the model from its direction, as before: the home camera turned.
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
let mut s = Session::default();
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
let mut cam = solidcraft_engine::view::home_camera(&s);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam);
assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap());
assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"})));
assert_ne!(top, saved);
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
assert_eq!(iso, dir.snapshot(&script, "home.png", &[]));
assert_ne!(iso, top);
}
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
/// of its snapshot; `--view` and `--camera` still decide when given.
#[test]
fn a_script_can_carry_its_camera() {
let dir = Scratch::new();
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
let plain = dir.script("plain.json", &[]);
let carried = dir.snapshot(&script, "carried.png", &[]);
assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]));
assert_ne!(carried, dir.snapshot(&plain, "home.png", &[]));
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"]));
}
/// The grid and the axes each go when asked, and a render without both from any side shows the
/// model alone: an empty design is then the same blank picture from every camera.
#[test]
fn the_grid_and_the_axes_can_be_left_out() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
for view in [&["--view", "front"][..], &["--camera", &json][..]] {
let with = |extra: &[&str], out: &str| dir.snapshot(&script, out, &[view, extra].concat());
let (all, no_grid, no_axes, bare) = (
with(&[], "all.png"),
with(&["--no-grid"], "no-grid.png"),
with(&["--no-axes"], "no-axes.png"),
with(&["--no-grid", "--no-axes"], "bare.png"),
);
let different = [&all, &no_grid, &no_axes, &bare];
for (i, a) in different.iter().enumerate() {
for b in &different[i + 1..] {
assert_ne!(a, b, "{view:?}: two pictures are the same");
}
}
// The same as the MCP's `grid` and `axes`.
let mcp = |extra: Value, out: &str| {
let mut a = match view[0] {
"--view" => json!({"view": "front"}),
_ => json!({"camera": camera()}),
};
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
dir.mcp(&script, out, a)
};
assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png"));
assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png"));
assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"));
}
let empty = dir.0.join("empty.json");
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png"));
assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"]));
}
#[test]
fn a_view_or_a_camera_that_cannot_be_used_is_an_error_and_writes_nothing() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let cases: [(&[&str], &str); 7] = [
(&["--view", "Nowhere"], "Nowhere"),
(&["--view", "iso", "--camera", "{}"], "not both"),
(&["--camera", "not json"], "--camera"),
(&["--camera", "{\"target\": [0, 0, 0]}"], "yaw"),
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 0}"], "distance"),
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 50, \"fov\": -1}"], "camera"),
(&["--camera", "[1, 2, 3]"], "camera"),
];
for (extra, expect) in cases {
let output = dir.run(&script, "never.png", extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
}
}
/// The usage text says what the new options do.
#[test]
fn the_usage_names_the_camera_and_the_guides() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
for word in ["--camera", "--view", "view.save", "--no-grid", "--no-axes"] {
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
}
}
@@ -0,0 +1,88 @@
//! Straight edges stay one edge after many cuts (#1049). The owner's design
//! (`tests/action_matrix/designs/crazy-geom-cuts.solidcraft`, saved by 0.10.0): five extrudes,
//! joins and cuts, over four sketches, three of them on faces of the body with the faces'
//! edges projected. Cuts used to leave the straight edges of the walls they sliced split at
//! vertices nothing else used, so an edge that is one straight line was drawn (and lit on hover)
//! as many pieces, and a sketch on such a face projected every piece.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde_json::json;
use solidcraft_engine::Session;
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
use solidcraft_engine::geom::Vec3;
fn design() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/action_matrix/designs/crazy-geom-cuts.solidcraft")
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
/// The design built from scratch: saved without cached geometry and opened again.
fn rebuilt() -> Session {
let s = open(&design());
let dir = std::env::temp_dir().join(format!("solidcraft-straight-edges-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("crazy-geom-cuts.json");
let mut t = s.scratch();
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
let out = open(&path);
let _ = std::fs::remove_dir_all(&dir);
out
}
fn errors(s: &Session) -> Vec<(u64, String)> {
s.model.results.iter().filter_map(|r| r.error.clone().map(|e| (r.id, e))).collect()
}
fn key(v: Vec3) -> [i64; 3] {
[v.x, v.y, v.z].map(|c| (c * 1e5).round() as i64)
}
#[test]
fn straight_edges_are_one_edge_after_many_cuts() {
for (what, s) in [("as saved", open(&design())), ("built from scratch", rebuilt())] {
assert_eq!(errors(&s), vec![], "{what}: every feature evaluates");
let st = s.world_state();
let bodies: Vec<_> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect();
assert_eq!(bodies.len(), 1, "{what}: one body");
let body = &bodies[0].body;
let mesh = body.tessellate(0.01).unwrap();
// Every edge is drawn as one polyline; a straight one as one segment.
let mut at: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
for (i, e) in mesh.edges.iter().enumerate() {
let (Some(a), Some(z)) = (e.first(), e.last()) else { panic!("{what}: edge {i} is empty") };
at.entry(key(*a)).or_default().push(i);
at.entry(key(*z)).or_default().push(i);
}
let straight = |i: usize| mesh.edges.get(i).filter(|e| e.len() == 2).and_then(|e| (e[1] - e[0]).normalized());
let split: Vec<_> = at
.iter()
.filter(|(_, es)| match es.as_slice() {
[a, b] => straight(*a).zip(straight(*b)).is_some_and(|(u, w)| u.dot(w).abs() > 1. - 1e-9),
_ => false,
})
.map(|(k, _)| k.map(|c| c as f64 / 1e5))
.collect();
assert_eq!(split, Vec::<[f64; 3]>::new(), "{what}: straight edges split where nothing else meets them");
// No two faces side by side in one plane.
let plane = |f: usize| match face_surface(body, f) {
Some(FaceSurface::Plane { normal, point }) => Some((normal, normal.dot(point))),
_ => None,
};
for (i, faces) in mesh.edge_faces.iter().enumerate() {
if let [a, b] = faces.as_slice()
&& a != b
&& let (Some((na, da)), Some((nb, db))) = (plane(*a as usize), plane(*b as usize))
{
assert!(na.dot(nb) < 1. - 1e-9 || (da - db).abs() > 1e-6, "{what}: faces {a} and {b} meet at edge {i} in one plane");
}
}
assert_eq!((body.face_count(), body.edge_count(), body.vertex_count()), (35, 107, 71), "{what}: faces, edges and vertices");
}
}
+11 -2
View File
@@ -73,6 +73,12 @@ mod web {
if self.files.ctx.borrow().is_none() {
*self.files.ctx.borrow_mut() = Some(ctx.clone());
}
// A ready picker answer is consumed in app.logic later in this frame. Keep
// incoming files queued until then so they cannot replace its target design.
if self.app.file_picker_busy() {
ctx.request_repaint();
return;
}
files::flush(&self.files);
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
@@ -434,12 +440,12 @@ mod web {
// Open shows the browser's files (and the computer's, from there).
pick_open: Some(Box::new(move || {
panel.panel.set(true);
None
Box::pin(std::future::ready(None))
})),
// Designs are kept in the browser; everything else is downloaded.
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
Box::pin(std::future::ready(Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))))
})),
..Default::default()
};
@@ -459,6 +465,9 @@ mod web {
app.session.mark_saved();
}
if !query().contains("sample") {
// The empty design takes Units of new designs (#1205). The sample
// part is built in millimetres and is left that way.
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
let rev = app.session.revision;
app.home.show(rev);
}
+38 -2
View File
@@ -7,8 +7,18 @@ license.workspace = true
publish = false
default-run = "solidcraft"
[lints]
workspace = true
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs
# (macOS file-open events) and src/native_crash.rs (Windows exception filter) can allow it: neither
# has a safe API. Keep the rest in sync with [workspace.lints].
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
neg_cmp_op_on_partial_ord = "allow"
[dependencies]
solidcraft-engine = { workspace = true }
@@ -18,13 +28,39 @@ egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
# Checks eframe's settings file (app.ron) before eframe reads it (src/startup.rs).
ron = "0.12"
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
pollster = { workspace = true }
getrandom = "0.3"
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
quick-xml = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_Debug",
"Win32_System_IO",
"Win32_System_Kernel",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Threading",
] }
[target.'cfg(target_os = "macos")'.dependencies]
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+273 -21
View File
@@ -1,6 +1,9 @@
//! SolidCraft desktop app.
//!
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [design.solidcraft | part.step]`
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]`
//!
//! `--safe-mode` (or `SOLIDCRAFT_SAFE_MODE=1`) starts with default settings, no recovery offer and
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
//!
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
@@ -10,26 +13,46 @@
//! See `solidcraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#![deny(unsafe_code)]
#[cfg(not(target_os = "macos"))]
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(target_os = "windows")]
mod native_crash;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod space_mouse;
mod startup;
use solidcraft_engine::{Session, io};
use solidcraft_ui_egui::{Services, SolveApp};
struct App {
solve: SolveApp,
/// The settings folder whose start marker the first frame clears (`startup::started`).
starting: Option<std::path::PathBuf>,
/// Frames begun; the second one means the first was drawn (startup is over).
frames: u32,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse: space_mouse::SpaceMouse,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.frames < 2 {
self.frames += 1;
// A second frame right away, which means the first was presented: the app is up.
ctx.request_repaint();
if self.frames == 2 {
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
if let Some(dir) = self.starting.take() {
startup::started(&dir);
}
}
}
// The design on screen is the one whose commands a crash report replays.
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
@@ -38,9 +61,15 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
}
#[cfg(target_os = "macos")]
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
self.solve.open_path_async(&f);
}
self.solve.logic(ctx);
#[cfg(any(target_os = "windows", target_os = "linux"))]
self.space_mouse.tick(&mut self.solve, ctx);
if !self.solve.file_picker_busy() {
self.space_mouse.tick(&mut self.solve, ctx);
}
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
@@ -57,13 +86,23 @@ impl eframe::App for App {
fn on_exit(&mut self) {
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_engine::crash::end_session();
// An update asked to restart into the new version or to run its installer.
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve)
&& let Err(e) = std::process::Command::new(&program).args(&args).spawn()
{
log::error!("update: starting {}: {e}", program.display());
}
}
}
fn services() -> Services {
fn services(cc: &eframe::CreationContext<'_>) -> Services {
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
let open_parent = parent.clone();
let program_parent = parent.clone();
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
pick_open: Some(Box::new(move || {
let future = open_parent
.clone()
.add_filter(
"Designs, STEP, IGES and meshes",
&[
@@ -90,11 +129,17 @@ fn services() -> Services {
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
.pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
let dialog = rfd::FileDialog::new().set_file_name(name);
pick_program: Some(Box::new(move || {
let dialog = program_parent.clone().set_title("Choose your slicer");
let dialog = if cfg!(target_os = "windows") { dialog.add_filter("Programs", &["exe"]) } else { dialog };
let future = dialog.pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
let dialog = parent.clone().set_file_name(name);
let dialog = if exts == [io::DESIGN_EXT, "json"] {
if name.ends_with(".json") {
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
@@ -104,9 +149,12 @@ fn services() -> Services {
} else {
dialog.add_filter("File", exts)
};
dialog.save_file().map(|p| p.to_string_lossy().to_string())
let future = dialog.save_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
http_get: Some(std::sync::Arc::new(http_get)),
http_download: Some(std::sync::Arc::new(http_download)),
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") {
@@ -127,6 +175,79 @@ fn services() -> Services {
}
}
/// HTTPS GET for the update check (docs/updates.md): certificates from the operating system, a
/// 15 s limit, at most 1 MB, and no headers beyond what GitHub requires (a User-Agent without
/// the version or anything about this machine, and the API's media type).
fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
let mut response = http_call(url, std::time::Duration::from_secs(15))?;
response.body_mut().with_config().limit(1 << 20).read_to_vec().map_err(|e| FetchError::Failed(e.to_string()))
}
/// HTTPS download of an update package to `to` (docs/updates.md), reporting progress; stops when
/// `progress` returns false. The caller verifies the file.
fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<u64>) -> bool) -> Result<(), solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
use std::io::{Read, Write};
let fail = |e: String| FetchError::Failed(e);
let mut response = http_call(url, std::time::Duration::from_secs(30 * 60))?;
let total = response.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
let mut body = response.body_mut().with_config().limit(solidcraft_ui_egui::update::install::MAX_PACKAGE).reader();
let mut file = std::fs::File::create(to).map_err(|e| fail(format!("{}: {e}", to.display())))?;
let mut buf = vec![0u8; 1 << 16];
let mut done = 0u64;
loop {
let n = body.read(&mut buf).map_err(|e| fail(e.to_string()))?;
if n == 0 {
break;
}
file.write_all(buf.get(..n).unwrap_or_default()).map_err(|e| fail(format!("{}: {e}", to.display())))?;
done += n as u64;
if !progress(done, total) {
return Err(fail("cancelled".into()));
}
}
file.sync_all().map_err(|e| fail(format!("{}: {e}", to.display())))?;
Ok(())
}
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
use std::time::Duration;
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls)
.https_only(true)
.http_status_as_error(false)
.timeout_global(Some(timeout))
.timeout_connect(Some(Duration::from_secs(5)))
.user_agent("SolidCraft-update-check")
.max_redirects(3)
.build()
.into();
let response = agent
.get(url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.call()
.map_err(|e| FetchError::Failed(e.to_string()))?;
let status = response.status().as_u16();
let header = |k: &str| response.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());
if status == 429 || (status == 403 && (header("x-ratelimit-remaining") == Some(0) || header("retry-after").is_some())) {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let retry_after_s = header("retry-after").or_else(|| header("x-ratelimit-reset").map(|reset| reset.saturating_sub(now))).unwrap_or(3600);
return Err(FetchError::RateLimited { retry_after_s });
}
if status != 200 {
return Err(FetchError::Failed(format!("GitHub answered HTTP {status}")));
}
Ok(response)
}
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.
fn settings_dir() -> Option<std::path::PathBuf> {
eframe::storage_dir("SolidCraft")
@@ -174,11 +295,19 @@ fn adopt_settings(
Ok(())
}
/// `graphics.json`, read before the window opens.
fn graphics() -> Option<serde_json::Value> {
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
}
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
fn backend_preference() -> Option<String> {
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
}
/// Anti-aliasing samples from Preferences (4 when unset).
fn msaa() -> u16 {
graphics_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
graphics()
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
.and_then(|m| u16::try_from(m).ok())
.filter(|m| [1, 2, 4, 8].contains(m))
@@ -219,7 +348,7 @@ const APP_ID: &str = solidcraft_engine::io::APP_ID;
fn main() -> eframe::Result {
adopt_legacy_settings();
let samples = msaa();
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
@@ -228,6 +357,7 @@ fn main() -> eframe::Result {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--safe-mode" => safe_mode = true,
"--version" | "-V" => {
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
@@ -246,12 +376,30 @@ fn main() -> eframe::Result {
return Ok(());
}
"--help" | "-h" => {
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [design.solidcraft | part.step]");
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]");
return Ok(());
}
_ => files.push(a),
}
}
// Settings that don't read are moved aside; launches that never got a window are counted,
// and after a few in a row this one starts in safe mode (docs/book/src/guide/troubleshooting.md).
let settings = settings_dir();
let mut notes: Vec<String> = Vec::new();
let mut failed_starts = 0;
if let Some(dir) = &settings {
notes.extend(startup::check_settings(dir, &[solidcraft_ui_egui::prefs::STORAGE_KEY, solidcraft_ui_egui::prefs::LEGACY_STORAGE_KEY]));
failed_starts = startup::begin(dir);
}
if failed_starts >= startup::FAILED_STARTS_FOR_SAFE_MODE && !safe_mode {
safe_mode = true;
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
}
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let samples = if safe_mode { MSAA } else { msaa() };
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
let crashed = solidcraft_engine::crash::default_dir()
.map(|dir| {
@@ -263,13 +411,28 @@ fn main() -> eframe::Result {
solidcraft_engine::crash::install(dir, build)
})
.unwrap_or_default();
// A crash in native code (a graphics driver) says where it happened: the exception and the
// module on Windows, and on every system the startup step the app was in.
#[cfg(target_os = "windows")]
native_crash::install();
eframe::egui_wgpu::set_startup_hook(startup_step);
solidcraft_engine::crash::phase("window");
let options = eframe::NativeOptions {
viewport: app_viewport()?,
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
// Kept with the other settings, not in a folder named after the app id.
persistence_path: settings_dir().map(|d| d.join("app.ron")),
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
// and writes a scratch copy, leaving the user's settings as they were.
persistence_path: if safe_mode {
let scratch = std::env::temp_dir().join(format!("solidcraft-safe-mode-{}.ron", std::process::id()));
let _ = std::fs::remove_file(&scratch);
Some(scratch)
} else {
settings.as_ref().map(|d| d.join("app.ron"))
},
persist_window: !safe_mode,
wgpu_options: wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref()),
..Default::default()
};
#[cfg(all(unix, not(target_os = "macos")))]
@@ -286,20 +449,35 @@ fn main() -> eframe::Result {
b.with_x11();
}));
}
// Files opened from Finder and the Dock arrive as events, not arguments.
#[cfg(target_os = "macos")]
open_documents::install();
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_app = created.clone();
let starting = settings.clone();
let result = eframe::run_native(
"SolidCraft",
options,
Box::new(move |cc| {
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
solidcraft_engine::crash::phase("app setup");
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let mut app = SolveApp::new(Session::default(), services());
let mut app = SolveApp::new(Session::default(), services(cc));
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.cpu_meshing = safe_mode;
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
// The graphics driver is loaded now; it may have put its own exception filter
// in front of ours.
#[cfg(target_os = "windows")]
native_crash::reinstall();
// A saved anti-aliasing the device can't do: back to the default, with a notice.
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
let info = rs.adapter.get_info();
@@ -320,9 +498,20 @@ fn main() -> eframe::Result {
app.session.mark_saved();
app.session.undo.clear();
}
// A launch from Finder: its files arrived before the window.
#[cfg(target_os = "macos")]
let files = {
open_documents::set_ui(&cc.egui_ctx);
[files, open_documents::take()].concat()
};
for f in &files {
app.open_path_async(f);
}
// No file and no sample: the empty design takes Units of new designs (#1205).
// An opened file and the sample part keep the units they were saved or built with.
if files.is_empty() && !sample {
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
}
// No design given: the start page (not when driven over the control channel, whose
// clients expect the design view; `ui.home` shows it there).
if files.is_empty() && !sample && control_port.is_none() {
@@ -333,20 +522,83 @@ fn main() -> eframe::Result {
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
// Safe mode keeps autosaving but offers nothing: the entries wait for a normal start.
if safe_mode {
app.recoverable.clear();
}
}
// Tell the user about crashes since the last start (after the recovery offer is set up).
solidcraft_ui_egui::crash_ui::announce(&mut app, &crashed);
if !notes.is_empty() {
app.set_status(notes.join(" "), true);
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
solidcraft_engine::crash::phase("first frame");
Ok(Box::new(App {
solve: app,
starting,
frames: 0,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse,
}))
}),
);
control_server::remove_token_file();
result
match result {
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
r => r,
}
}
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface up:
/// the crash journal keeps it, and the adapter once it is picked.
fn startup_step(step: &'static str, detail: &str) {
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}"));
}
solidcraft_engine::crash::phase(step);
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
/// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend
/// in use is started); `preference` is `graphics.json`'s `backend`.
fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuConfiguration {
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
let env_set = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
eprintln!("SolidCraft graphics: starting {backends:?}");
setup.instance_descriptor.backends = backends;
}
if cpu {
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
}
}
options
}
/// The window never opened (no graphics adapter, a device that wouldn't start): once more on
/// the software renderer, else a message box saying what failed instead of a silent exit.
fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path::Path>) -> eframe::Result {
log::error!("the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
if !tried_cpu && let Ok(exe) = std::env::current_exe() {
eprintln!("SolidCraft: trying again with the software renderer ({}=cpu)", startup::RENDERER_ENV);
match std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(startup::RENDERER_ENV, "cpu").status() {
Ok(status) if status.success() => return Ok(()),
// The second try showed its own message.
Ok(_) => return Err(error),
Err(e) => log::error!("could not start again with the software renderer: {e}"),
}
}
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("SolidCraft could not start")
.set_description(startup::no_window_message(&error.to_string(), settings))
.set_buttons(rfd::MessageButtons::Ok)
.show();
Err(error)
}
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
+285
View File
@@ -0,0 +1,285 @@
//! Windows: what a crash in native code leaves behind (docs/crash-reports.md).
//!
//! A crash inside a graphics driver or another DLL (an access violation, a stack overflow) is no
//! Rust panic: the panic hook never runs and the next start only found "an unclean exit". An
//! unhandled-exception filter now writes, before the process goes, a small JSON file with the
//! exception code, the faulting module and the offset into it, and a minidump, next to the run's
//! crash journal (`solidcraft_engine::crash::native_paths`). The next start puts both into the
//! unclean exit's report, with the startup step the journal says the app was in.
//!
//! Installing an exception filter and asking the system which module holds an address have no
//! safe API, hence the `unsafe_code` allowance on this module (the app crate denies it; see
//! `open_documents.rs` for the other one). The filter runs on a thread that may have a broken
//! heap or almost no stack left: it doesn't allocate, formats into a buffer on the stack, and
//! skips the minidump after a stack overflow.
#![allow(unsafe_code)]
use std::fmt::Write as _;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, HMODULE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, WriteFile};
use windows_sys::Win32::System::Diagnostics::Debug::{
EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpNormal, MiniDumpWithThreadInfo,
MiniDumpWithUnloadedModules, MiniDumpWriteDump, SetUnhandledExceptionFilter,
};
use windows_sys::Win32::System::LibraryLoader::{
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId};
/// Where the filter writes (NUL-terminated UTF-16), set once by [`install`].
struct Paths {
info: Vec<u16>,
dump: Vec<u16>,
}
static PATHS: OnceLock<Paths> = OnceLock::new();
/// The filter that was installed before ours (called after ours), as an address.
static PREVIOUS: AtomicUsize = AtomicUsize::new(0);
/// The filter has started writing (a second exception while it does is left alone).
static BUSY: AtomicBool = AtomicBool::new(false);
const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005;
const STATUS_IN_PAGE_ERROR: u32 = 0xC000_0006;
const STATUS_STACK_OVERFLOW: u32 = 0xC000_00FD;
/// Catch native crashes from now on (after `solidcraft_engine::crash::install`; nothing without it).
pub fn install() {
let Some((info, dump)) = solidcraft_engine::crash::native_paths() else { return };
let wide = |p: &Path| p.as_os_str().encode_wide().chain([0]).collect::<Vec<u16>>();
if PATHS.set(Paths { info: wide(&info), dump: wide(&dump) }).is_ok() {
set_filter();
}
}
/// Put the filter back on top: a DLL loaded since (a graphics driver) may have set its own,
/// which is then called after ours.
pub fn reinstall() {
if PATHS.get().is_some() {
set_filter();
}
}
fn set_filter() {
// SAFETY: `filter` has the signature the system calls it with and stays valid for the
// process's life.
let previous = unsafe { SetUnhandledExceptionFilter(Some(filter)) };
if let Some(p) = previous
&& p as usize != filter as *const () as usize
{
PREVIOUS.store(p as usize, Ordering::SeqCst);
}
}
/// The unhandled-exception filter: record, then let the previous filter (or the system) go on.
unsafe extern "system" fn filter(pointers: *const EXCEPTION_POINTERS) -> i32 {
if !BUSY.swap(true, Ordering::SeqCst) {
// SAFETY: the system passes valid exception pointers for the duration of the call.
unsafe { record(pointers) };
}
let previous = PREVIOUS.load(Ordering::SeqCst);
if previous != 0 && previous != filter as *const () as usize {
// SAFETY: the address came from `SetUnhandledExceptionFilter` as a filter of this type.
let previous: unsafe extern "system" fn(*const EXCEPTION_POINTERS) -> i32 = unsafe { std::mem::transmute(previous) };
// SAFETY: as for this filter.
return unsafe { previous(pointers) };
}
EXCEPTION_CONTINUE_SEARCH
}
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
struct Buf {
bytes: [u8; 2048],
len: usize,
}
impl std::fmt::Write for Buf {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let room = self.bytes.len().saturating_sub(self.len);
let n = s.len().min(room);
if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) {
to.copy_from_slice(from);
self.len += n;
}
Ok(())
}
}
/// The usual names of the exception codes a crash shows.
fn code_name(code: u32) -> &'static str {
match code {
STATUS_ACCESS_VIOLATION => "EXCEPTION_ACCESS_VIOLATION",
STATUS_IN_PAGE_ERROR => "EXCEPTION_IN_PAGE_ERROR",
STATUS_STACK_OVERFLOW => "EXCEPTION_STACK_OVERFLOW",
0xC000_001D => "EXCEPTION_ILLEGAL_INSTRUCTION",
0xC000_0094 => "EXCEPTION_INT_DIVIDE_BY_ZERO",
0xC000_0096 => "EXCEPTION_PRIV_INSTRUCTION",
0xC000_008C => "EXCEPTION_ARRAY_BOUNDS_EXCEEDED",
0xC000_0374 => "STATUS_HEAP_CORRUPTION",
0xC000_0409 => "STATUS_STACK_BUFFER_OVERRUN",
0xC000_0602 => "STATUS_FAIL_FAST_EXCEPTION",
0x8000_0003 => "EXCEPTION_BREAKPOINT",
0xE06D_7363 => "C++ exception",
_ => "exception",
}
}
/// Write what `pointers` says into the info file, then the minidump.
unsafe fn record(pointers: *const EXCEPTION_POINTERS) {
let Some(paths) = PATHS.get() else { return };
if pointers.is_null() {
return;
}
// SAFETY: checked non-null; the system keeps it valid during the filter.
let rec = unsafe { (*pointers).ExceptionRecord };
if rec.is_null() {
return;
}
// SAFETY: as above.
let rec = unsafe { &*rec };
let code = rec.ExceptionCode as u32;
let address = rec.ExceptionAddress as usize;
let mut module: HMODULE = std::ptr::null_mut();
let mut name = [0u16; 512];
// SAFETY: FROM_ADDRESS takes the address in place of a name; UNCHANGED_REFCOUNT means no
// handle to release; `name` is as long as we say.
let len = unsafe {
if GetModuleHandleExW(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
address as *const u16,
&mut module,
) != 0
{
GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) as usize
} else {
0
}
};
// SAFETY: no preconditions.
let thread = unsafe { GetCurrentThreadId() };
let mut b = Buf { bytes: [0; 2048], len: 0 };
let _ = write!(b, "{{\"code\":\"0x{code:08X}\",\"name\":\"{}\",\"address\":\"0x{address:016X}\",\"thread\":{thread}", code_name(code));
if len > 0 {
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", address.wrapping_sub(module as usize));
for c in char::decode_utf16(name.iter().take(len).copied()) {
let _ = match c.unwrap_or(char::REPLACEMENT_CHARACTER) {
'"' => b.write_str("\\\""),
'\\' => b.write_str("\\\\"),
c if (c as u32) < 0x20 => b.write_str("?"),
c => b.write_char(c),
};
}
let _ = b.write_str("\"");
}
if matches!(code, STATUS_ACCESS_VIOLATION | STATUS_IN_PAGE_ERROR) && rec.NumberParameters >= 2 {
let what = match rec.ExceptionInformation[0] {
0 => "read of",
1 => "write to",
8 => "execution at",
_ => "access to",
};
let _ = write!(b, ",\"detail\":\"{what} 0x{:016X}\"", rec.ExceptionInformation[1]);
}
let _ = b.write_str("}");
let bytes = b.bytes.get(..b.len).unwrap_or_default();
// SAFETY: NUL-terminated path; the handle is closed below.
unsafe {
let file = create(&paths.info);
if !file.is_null() {
let mut written = 0u32;
WriteFile(file, bytes.as_ptr(), bytes.len() as u32, &mut written, std::ptr::null_mut());
CloseHandle(file);
}
}
// After a stack overflow there is no stack left for writing a minidump.
if code == STATUS_STACK_OVERFLOW {
return;
}
// SAFETY: as above; the exception information points at this thread's exception.
unsafe {
let file = create(&paths.dump);
if !file.is_null() {
let exception = MINIDUMP_EXCEPTION_INFORMATION { ThreadId: thread, ExceptionPointers: pointers as *mut _, ClientPointers: 0 };
MiniDumpWriteDump(
GetCurrentProcess(),
GetCurrentProcessId(),
file,
MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules,
&exception,
std::ptr::null(),
std::ptr::null(),
);
CloseHandle(file);
}
}
}
/// A new file at `path` (NUL-terminated) for writing, or null.
unsafe fn create(path: &[u16]) -> HANDLE {
// SAFETY: the caller passes a NUL-terminated path.
let h = unsafe { CreateFileW(path.as_ptr(), GENERIC_WRITE, 0, std::ptr::null(), CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut()) };
if h == INVALID_HANDLE_VALUE { std::ptr::null_mut() } else { h }
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
/// The child half: install crash reporting and the filter, then fault in native code.
#[test]
fn child() {
let Some(dir) = std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_CHILD") else { return };
let build = solidcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234", built: "2026-10-14" };
let fresh = solidcraft_engine::crash::install(PathBuf::from(dir), build);
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
if std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_FAULT").is_none() {
solidcraft_engine::crash::end_session();
return;
}
super::install();
solidcraft_engine::crash::phase("device creation");
// SAFETY: none; this is the crash under test (a read of address 16, as a null struct
// pointer's field would be).
let v = unsafe { std::ptr::read_volatile(16 as *const u64) };
println!("unreachable {v}");
}
fn run(dir: &std::path::Path, fault: bool) -> std::process::Output {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "native_crash::tests::child", "--test-threads=1", "--nocapture"]).env("SOLIDCRAFT_NATIVE_CRASH_CHILD", dir);
if fault {
c.env("SOLIDCRAFT_NATIVE_CRASH_FAULT", "1");
}
c.output().unwrap()
}
#[test]
fn an_access_violation_is_reported_with_its_module_step_and_minidump() {
let dir = std::env::temp_dir().join(format!("solidcraft-native-crash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = run(&dir, true);
assert_eq!(out.status.code().map(|c| c as u32), Some(super::STATUS_ACCESS_VIOLATION), "{out:?}");
let sessions: Vec<PathBuf> = std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).collect();
let info = sessions.iter().find(|p| p.to_string_lossy().ends_with(".native.json")).expect("the filter wrote what it caught");
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
assert_eq!(v["code"], "0xC0000005", "{v}");
assert_eq!(v["detail"], "read of 0x0000000000000010", "{v}");
assert!(v["module"].as_str().unwrap().to_lowercase().contains("solidcraft"), "the fault is in the test binary: {v}");
let dump = sessions.iter().find(|p| p.extension().is_some_and(|e| e == "dmp")).expect("a minidump");
assert_eq!(std::fs::read(dump).unwrap().get(..4), Some(&b"MDMP"[..]));
// The next start reports it.
let out = run(&dir, false);
assert!(out.status.success(), "{out:?}");
let reports = solidcraft_engine::crash::list(&dir);
assert_eq!(reports.len(), 1, "{reports:?}");
let r = solidcraft_engine::crash::read(&dir, &reports[0].id).unwrap();
assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in solidcraft"), "{r}");
assert!(r["message"].as_str().unwrap().contains("during startup (device creation"), "{r}");
assert!(r.get("minidump_path").is_some(), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+120
View File
@@ -0,0 +1,120 @@
//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with
//! `open -a SolidCraft file` (#997).
//!
//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's
//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`,
//! which fails with "SolidCraft cannot open files in the … format" because SolidCraft isn't an
//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to
//! the delegate's class when the app is about to finish launching: after winit set the delegate and
//! before AppKit delivers the files the app was launched with. The handler queues the paths and
//! wakes the UI, which opens them with [`take`] like files named on the command line.
//!
//! Adding a method to another crate's Objective-C class has no safe API, hence the `unsafe_code`
//! allowance on [`install`] and [`add_open_urls_method`] (winit overrides `sendEvent:` on
//! `NSApplication` similarly).
use std::ptr::NonNull;
use std::sync::{Mutex, OnceLock, PoisonError};
use block2::RcBlock;
use objc2::MainThreadMarker;
use objc2::runtime::{AnyObject, Imp, Sel};
use objc2::sel;
use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification};
use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL};
/// Paths that arrived and haven't been opened yet.
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
static UI: OnceLock<egui::Context> = OnceLock::new();
/// Handle open-document events from now on. Call before the event loop runs.
#[allow(unsafe_code)]
pub fn install() {
let block = RcBlock::new(|_: NonNull<NSNotification>| add_open_urls_method());
// SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block
// runs on the posting thread (AppKit posts it on the main thread). The block captures nothing,
// so it is sendable. The notification center keeps the registration (and the block) for the
// life of the app.
unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
Some(NSApplicationWillFinishLaunchingNotification),
None,
None,
&block,
);
}
}
/// Wake `ctx` when files arrive while the app runs.
pub fn set_ui(ctx: &egui::Context) {
// Set once; a second call would hand over the same context.
let _ = UI.set(ctx.clone());
}
/// The paths that arrived since the last call.
pub fn take() -> Vec<String> {
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
}
/// `application:openURLs:` on the application delegate's class.
#[allow(unsafe_code)]
fn add_open_urls_method() {
let Some(mtm) = MainThreadMarker::new() else { return };
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
log::warn!("no application delegate; files opened from Finder won't open");
return;
};
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = open_urls;
// SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls
// it with the arguments the type encoding declares, which match `open_urls`: no return value
// (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`).
let added = unsafe {
let imp = std::mem::transmute::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), Imp>(handler);
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
};
if !added.as_bool() {
log::warn!("the application delegate already handles open-document events");
}
}
/// AppKit calls this; a panic stops here instead of unwinding through AppKit's frames.
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| queue(urls))).is_err() {
log::warn!("files opened from Finder were dropped after an internal error");
}
}
/// Queue the files among `urls` and wake the UI.
fn queue(urls: &NSArray<NSURL>) {
// Only file URLs: macOS 27 turns an https URL's path into a file path too.
let paths = urls.iter().filter(|u| u.isFileURL()).filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
if let Some(ctx) = UI.get() {
ctx.request_repaint();
}
}
#[cfg(test)]
mod tests {
use objc2::runtime::NSObject;
use objc2_foundation::NSString;
use super::*;
/// Files queue until the UI takes them; URLs that aren't files are left out.
#[test]
fn opened_files_wait_for_the_ui() {
let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.solidcraft")).unwrap();
let urls = NSArray::from_retained_slice(&[
NSURL::from_file_path("/tmp/a b.solidcraft").unwrap(),
web,
NSURL::from_file_path("/tmp/c.step").unwrap(),
]);
let any = NSObject::new();
open_urls(&any, sel!(application:openURLs:), &any, &urls);
assert_eq!(take(), ["/tmp/a b.solidcraft", "/tmp/c.step"]);
assert!(take().is_empty());
}
}
+67 -2
View File
@@ -48,6 +48,21 @@ impl Default for DriverMapping {
}
}
impl DriverMapping {
/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is
/// what the driver applies by default in object mode (#1172): the physical Ry and Rz swapped
/// and reversed, so a tilt left/right rolls and a twist spins. Elsewhere the driver writes a
/// profile, and the raw axes are used until it does.
fn without_profile() -> Self {
let mut mapping = Self::default();
if cfg!(target_os = "linux") {
mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 };
mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 };
}
mapping
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Motion {
translation: [f64; 3],
@@ -377,7 +392,12 @@ impl SpaceMouse {
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}");
}
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
Self {
shared,
last_frame: now,
driver_mapping: load_driver_mapping().unwrap_or_else(DriverMapping::without_profile),
mapping_checked_at: now,
}
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
@@ -385,7 +405,7 @@ impl SpaceMouse {
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now;
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
let updated = load_driver_mapping().unwrap_or_default();
let updated = load_driver_mapping().unwrap_or_else(DriverMapping::without_profile);
if updated != self.driver_mapping {
log::info!("3D mouse: applying updated 3DxWare axis profile");
self.driver_mapping = updated;
@@ -580,6 +600,51 @@ mod tests {
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
}
#[test]
fn without_a_profile_linux_uses_the_drivers_object_mode_axes() {
// 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact).
let object_mode = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>true</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile");
if cfg!(target_os = "linux") {
assert_eq!(DriverMapping::without_profile(), profile);
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
let before = Camera::default();
let mut tilt = before;
navigate(
&mut tilt,
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }),
0.02,
600.0,
false,
false,
);
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
assert_ne!(tilt.roll, before.roll);
let mut twist = before;
navigate(
&mut twist,
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }),
0.02,
600.0,
false,
false,
);
assert_ne!(twist.yaw, before.yaw);
assert_eq!(twist.roll, before.roll);
} else {
assert_eq!(DriverMapping::without_profile(), DriverMapping::default());
}
}
#[test]
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
let profile = br#"
+325
View File
@@ -0,0 +1,325 @@
//! Getting the window open, whatever state the settings folder or the graphics device is in
//! (docs/book/src/guide/troubleshooting.md).
//!
//! - **Settings that don't read** (`app.ron`, `graphics.json`): moved aside as
//! `<name>.bad-<time>` and the app starts with defaults, instead of a newer or damaged file
//! being dropped silently or stopping the start.
//! - **Launches that die before the window is up**: a marker file counts them; after two in a
//! row the next launch starts in safe mode by itself.
//! - **Safe mode** (`--safe-mode` or `SOLIDCRAFT_SAFE_MODE=1`): default settings (the saved ones
//! are left untouched), no recovery offer, the software renderer and CPU meshing.
//! - **No usable graphics device**: one retry on the software renderer
//! (`SOLIDCRAFT_RENDERER=cpu`), then a message box saying what failed and what to try.
use std::path::{Path, PathBuf};
/// The environment variable that starts safe mode (any value but `0` or empty).
pub const SAFE_MODE_ENV: &str = "SOLIDCRAFT_SAFE_MODE";
/// `SOLIDCRAFT_RENDERER=cpu` picks the software renderer (WARP, lavapipe, llvmpipe).
pub const RENDERER_ENV: &str = "SOLIDCRAFT_RENDERER";
/// Consecutive launches that died before the window was up, after which safe mode starts.
pub const FAILED_STARTS_FOR_SAFE_MODE: u32 = 2;
const MARKER: &str = "starting";
/// Whether an environment value switches something on.
pub fn env_on(v: Option<&str>) -> bool {
v.map(str::trim).is_some_and(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
}
/// Whether `SOLIDCRAFT_RENDERER` asks for the software renderer.
pub fn cpu_renderer_env(v: Option<&str>) -> bool {
v.map(str::trim).is_some_and(|v| v.eq_ignore_ascii_case("cpu") || v.eq_ignore_ascii_case("software"))
}
fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Move `path` aside as `<name>.bad-<time>` (kept for a bug report); returns where it went.
pub fn back_up(path: &Path) -> std::io::Result<PathBuf> {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "settings".into());
let mut to = path.with_file_name(format!("{name}.bad-{}", unix_now()));
let mut n = 1;
while to.exists() && n < 100 {
to = path.with_file_name(format!("{name}.bad-{}-{n}", unix_now()));
n += 1;
}
std::fs::rename(path, &to)?;
Ok(to)
}
/// Whether eframe's `app.ron` reads, with the preferences inside it.
fn app_ron_reads(text: &str, prefs_keys: &[&str]) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
prefs_keys.iter().filter_map(|k| kv.get(*k)).all(|p| serde_json::from_str::<serde_json::Value>(p).is_ok_and(|v| v.is_object()))
}
/// Check the settings files in `dir` before anything reads them: one that doesn't read is moved
/// aside (the app then starts with defaults for it). Returns a line per file moved.
pub fn check_settings(dir: &Path, prefs_keys: &[&str]) -> Vec<String> {
let mut notes = Vec::new();
let mut check = |name: &str, reads: &dyn Fn(&str) -> bool| {
let path = dir.join(name);
let Ok(bytes) = std::fs::read(&path) else { return };
let ok = std::str::from_utf8(&bytes).is_ok_and(|t| t.trim().is_empty() || reads(t));
if ok {
return;
}
match back_up(&path) {
Ok(to) => {
log::warn!("settings: {} did not read; moved to {}", path.display(), to.display());
notes.push(format!("{name} could not be read and was moved to {}; defaults are used.", to.display()));
}
Err(e) => log::warn!("settings: {} did not read and could not be moved: {e}", path.display()),
}
};
check("app.ron", &|t| app_ron_reads(t, prefs_keys));
check("graphics.json", &|t| serde_json::from_str::<serde_json::Value>(t).is_ok_and(|v| v.is_object()));
notes
}
/// A launch begins: count it in the marker in `dir`. Returns how many launches before this one
/// died before their window was up.
pub fn begin(dir: &Path) -> u32 {
let marker = dir.join(MARKER);
let before = std::fs::read_to_string(&marker).ok().and_then(|s| s.trim().parse::<u32>().ok()).unwrap_or(0).min(1000);
if std::fs::create_dir_all(dir).is_ok() {
let _ = std::fs::write(&marker, (before + 1).to_string());
}
before
}
/// The window is up: this launch did not fail to start.
pub fn started(dir: &Path) {
let _ = std::fs::remove_file(dir.join(MARKER));
}
/// The software adapter among `adapters` (WARP on Windows, lavapipe or llvmpipe elsewhere), else
/// the first one that can draw to `surface`.
pub fn pick_cpu_adapter(adapters: &[eframe::wgpu::Adapter], surface: Option<&eframe::wgpu::Surface<'_>>) -> Result<eframe::wgpu::Adapter, String> {
let fits = |a: &&eframe::wgpu::Adapter| surface.is_none_or(|s| a.is_surface_supported(s));
adapters
.iter()
.filter(fits)
.find(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu)
.or_else(|| adapters.iter().find(fits))
.cloned()
.ok_or_else(|| {
let names: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().backend)).collect();
format!("no graphics adapter can draw the window (found: {})", if names.is_empty() { "none".into() } else { names.join(", ") })
})
}
/// `WGPU_BACKEND`: when set, wgpu picks the backends from it and the order below is not used.
pub const WGPU_BACKEND_ENV: &str = "WGPU_BACKEND";
/// The graphics backends to start with, or `None` to leave it to wgpu (`WGPU_BACKEND`, or all
/// the backends it was built with).
///
/// On Windows, D3D12 comes first, then Vulkan, then OpenGL, and only the first one with a
/// hardware adapter is started at all. wgpu would otherwise start Vulkan, D3D12 and OpenGL
/// together and prefer Vulkan, and starting Vulkan loads every installed Vulkan driver: Intel's
/// (`igvk64.dll`) crashed SolidCraft at startup that way, inside the driver, before anything
/// could be reported. D3D12 is Windows' own graphics API, the one its drivers are tested with.
/// `has_hardware(backends)` says whether those backends have an adapter that isn't software
/// (it is only asked about the backends being tried, one at a time).
///
/// - `WGPU_BACKEND` set (`env_set`): `None`, whatever it says wins.
/// - The software renderer (`cpu`, safe mode): D3D12 alone on Windows (WARP); `None` elsewhere.
/// - `preference` (`graphics.json`'s `backend`: `dx12`, `vulkan`, `gl` or a comma list; `auto`
/// or empty for the order above): those backends, on any system.
/// - Elsewhere than Windows: `None`.
/// - No hardware adapter at all: D3D12 (WARP, Windows' software renderer).
pub fn choose_backends(
windows: bool,
env_set: bool,
cpu: bool,
preference: Option<&str>,
mut has_hardware: impl FnMut(eframe::wgpu::Backends) -> bool,
) -> Option<eframe::wgpu::Backends> {
use eframe::wgpu::Backends;
if env_set {
return None;
}
if cpu {
return windows.then_some(Backends::DX12);
}
if let Some(p) = preference.map(str::trim).filter(|p| !p.is_empty() && !p.eq_ignore_ascii_case("auto")) {
let chosen = Backends::from_comma_list(p);
if !chosen.is_empty() {
return Some(chosen);
}
log::warn!("graphics: unknown backend preference `{p}`; using the default order");
}
if !windows {
return None;
}
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
}
/// The order backends are tried in on Windows.
pub const WINDOWS_ORDER: [eframe::wgpu::Backends; 3] = [eframe::wgpu::Backends::DX12, eframe::wgpu::Backends::VULKAN, eframe::wgpu::Backends::GL];
/// Whether `backends` (started on their own, nothing else) have a hardware adapter.
pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = backends;
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
log::info!("graphics: {backends:?} adapters: {found:?}");
adapters.iter().any(|a| a.get_info().device_type != eframe::wgpu::DeviceType::Cpu)
}
/// The message shown when no window could be opened at all.
pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
let folder = settings.map(|d| d.display().to_string()).unwrap_or_else(|| "the SolidCraft settings folder".into());
format!(
"SolidCraft could not open its window.\n\n{error}\n\n\
The software renderer was tried too. Things to try:\n\
- Install or update the graphics driver (on Windows, from the GPU maker's site rather than Windows Update).\n\
- Over Remote Desktop or in a virtual machine, enable 3D acceleration or run it on the machine itself.\n\
- Start it once with `solidcraft --safe-mode`.\n\
- Rename the settings folder: {folder}\n\n\
More: https://bherbruck.github.io/solidcraft/guide/troubleshooting.html"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-startup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn switches_read_from_the_environment() {
assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false")));
assert!(cpu_renderer_env(Some("cpu")) && cpu_renderer_env(Some("Software")) && !cpu_renderer_env(Some("gpu")) && !cpu_renderer_env(None));
}
#[test]
fn settings_that_do_not_read_are_moved_aside_and_good_ones_stay() {
let keys = ["solidcraft.prefs", "solvecraft.prefs"];
let d = scratch("check");
// Good files (and an empty one) stay where they are.
std::fs::write(d.join("app.ron"), r#"{"solidcraft.prefs": "{\"grid\":true}", "egui": "(anything)"}"#).unwrap();
std::fs::write(d.join("graphics.json"), r#"{"msaa":4}"#).unwrap();
assert!(check_settings(&d, &keys).is_empty());
assert!(d.join("app.ron").is_file() && d.join("graphics.json").is_file());
// Damaged RON, preferences that aren't JSON, a truncated graphics.json, non-UTF-8 bytes.
for (name, text) in [
("app.ron", b"{\"solidcraft.prefs\": \"{\\\"gr".as_slice()),
("app.ron", br#"{"solvecraft.prefs": "not json"}"#.as_slice()),
("graphics.json", br#"{"msaa":"#.as_slice()),
("graphics.json", b"\xff\xfe\x00".as_slice()),
] {
std::fs::write(d.join(name), text).unwrap();
let notes = check_settings(&d, &keys);
assert_eq!(notes.len(), 1, "{name}: {notes:?}");
assert!(!d.join(name).exists(), "{name} moved");
let kept: Vec<_> =
std::fs::read_dir(&d).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().starts_with(&format!("{name}.bad-"))).collect();
assert!(kept.iter().any(|e| std::fs::read(e.path()).unwrap() == text), "{name}: the bad file is kept");
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn launches_that_never_got_a_window_are_counted_until_one_does() {
let d = scratch("marker");
assert_eq!(begin(&d), 0);
assert_eq!(begin(&d), 1);
assert_eq!(begin(&d), FAILED_STARTS_FOR_SAFE_MODE);
started(&d);
assert_eq!(begin(&d), 0);
// A damaged marker counts as none.
std::fs::write(d.join(MARKER), "zz").unwrap();
assert_eq!(begin(&d), 0);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn windows_starts_d3d12_first_and_vulkan_only_when_d3d12_has_no_gpu() {
use eframe::wgpu::Backends;
let probe = |with: &'static [Backends]| {
move |b: Backends| {
assert!(b == Backends::DX12 || b == Backends::VULKAN || b == Backends::GL, "one backend at a time: {b:?}");
with.contains(&b)
}
};
// D3D12 works: Vulkan and OpenGL are never even asked about (starting Vulkan loads
// its drivers, which is what crashed).
let mut asked = Vec::new();
let chosen = choose_backends(true, false, false, None, |b| {
asked.push(b);
b == Backends::DX12
});
assert_eq!(chosen, Some(Backends::DX12));
assert_eq!(asked, [Backends::DX12]);
// Everything works: still D3D12 alone.
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::DX12, Backends::VULKAN, Backends::GL])), Some(Backends::DX12));
// No D3D12 hardware adapter: Vulkan, else OpenGL, else D3D12's software adapter (WARP).
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::VULKAN, Backends::GL])), Some(Backends::VULKAN));
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::GL])), Some(Backends::GL));
assert_eq!(choose_backends(true, false, false, None, probe(&[])), Some(Backends::DX12));
// `auto` and blanks are the default order.
assert_eq!(choose_backends(true, false, false, Some(" auto "), probe(&[Backends::DX12])), Some(Backends::DX12));
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
}
#[test]
fn wgpu_backend_the_preference_and_the_software_renderer_override_the_order() {
use eframe::wgpu::Backends;
let never = |b: Backends| -> bool { panic!("no probing expected, asked about {b:?}") };
// WGPU_BACKEND: wgpu reads it itself, nothing is probed.
assert_eq!(choose_backends(true, true, false, Some("gl"), never), None);
assert_eq!(choose_backends(true, true, true, None, never), None);
// The software renderer (safe mode, the retry): WARP through D3D12 alone, never Vulkan.
assert_eq!(choose_backends(true, false, true, Some("vulkan"), never), Some(Backends::DX12));
assert_eq!(choose_backends(false, false, true, None, never), None);
// The preference, on any system, without probing.
assert_eq!(choose_backends(true, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
assert_eq!(choose_backends(true, false, false, Some("GL"), never), Some(Backends::GL));
assert_eq!(choose_backends(true, false, false, Some("dx12,vulkan"), never), Some(Backends::DX12 | Backends::VULKAN));
assert_eq!(choose_backends(false, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
// An unknown preference is the default order.
assert_eq!(choose_backends(true, false, false, Some("glide"), |b| b == Backends::DX12), Some(Backends::DX12));
// Elsewhere: wgpu's own choice.
assert_eq!(choose_backends(false, false, false, None, never), None);
assert_eq!(choose_backends(false, false, false, Some("auto"), never), None);
}
#[test]
fn probing_a_backend_alone_never_panics() {
// Whatever this machine has (often nothing): an answer, not a crash.
for b in WINDOWS_ORDER {
let _ = has_hardware_adapter(b);
}
}
#[test]
fn the_message_names_the_settings_folder_and_safe_mode() {
let m = no_window_message("no adapter", Some(Path::new("/x/SolidCraft")));
assert!(m.contains("no adapter") && m.contains("/x/SolidCraft") && m.contains("--safe-mode"));
}
#[test]
fn the_software_adapter_is_preferred_and_its_absence_is_an_error_not_a_panic() {
assert!(pick_cpu_adapter(&[], None).unwrap_err().contains("none"));
let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle());
let adapters = pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all()));
if adapters.is_empty() {
return;
}
let picked = pick_cpu_adapter(&adapters, None).unwrap();
if adapters.iter().any(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu) {
assert_eq!(picked.get_info().device_type, eframe::wgpu::DeviceType::Cpu);
}
}
}
+2 -2
View File
@@ -100,7 +100,7 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
let face = |col: Color32, pts: &[(f32, f32)]| {
p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
};
let (light, mid, dark) = (Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197));
let [light, mid, dark, edge_col] = crate::theme::Tokens::get().logo;
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
@@ -111,7 +111,7 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
face(dark, &[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)]);
face(dark, &[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)]);
// White edge lines.
let edge = Stroke::new(10.0 * s, Color32::WHITE);
let edge = Stroke::new(10.0 * s, edge_col);
let line = |pts: &[(f32, f32)]| {
p.add(egui::Shape::line(m(pts), edge));
};
+81 -73
View File
@@ -3,13 +3,6 @@
use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet (an opening, a blend or
/// round strip), and the iso lines and comb teeth drawn on sheets.
const SHEET: Color32 = Color32::from_rgb(170, 200, 240);
const SHEET_2: Color32 = Color32::from_rgb(150, 185, 235);
const SHEET_LIGHT: Color32 = Color32::from_rgb(214, 229, 248);
const SHEET_LINE: Color32 = Color32::from_rgb(127, 166, 218);
/// `n + 1` points along the cubic Bézier curve with control points `c`.
fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> {
(0..=n)
@@ -127,6 +120,12 @@ fn line_type_dot_radius(r: Rect) -> f32 {
/// Paint the icon `name` into `r`. `accent` highlights the "action" part.
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) {
paint_with(p, r, name, ink, fill, accent, &crate::theme::Tokens::get().icons);
}
/// [`paint`] with the colours an icon draws of its own given (`theme::IconColors`).
pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32, ic: &crate::theme::IconColors) {
let ic = *ic;
let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
let a = pen.accent;
match name {
@@ -164,7 +163,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"sphere" => {
pen.circle(12.0, 12.0, 8.5, pen.fill, ink);
pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5));
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), Color32::from_white_alpha(160));
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), ic.glint);
}
"torus" => {
let pts: Vec<Pos2> =
@@ -172,7 +171,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink)));
let pts: Vec<Pos2> =
(0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect();
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
}
"fillet" => {
pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT);
@@ -214,7 +213,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
let pts: Vec<Pos2> =
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect();
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, a)));
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, a)));
pen.line(&[(7.0, 8.0), (7.0, 16.0)], a);
pen.line(&[(17.0, 8.0), (17.0, 16.0)], a);
}
@@ -231,7 +230,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"shell" => {
pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0);
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], Color32::WHITE, a);
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], ic.highlight, a);
}
"draft" => {
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink);
@@ -266,6 +265,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.line(&[(17.0, y), (21.0, y)], ink);
}
}
// Update: an arrow down onto a tray (a newer release can be downloaded).
"update" => {
pen.line(&[(12.0, 3.5), (12.0, 14.5)], ink);
pen.line(&[(7.5, 10.0), (12.0, 14.5), (16.5, 10.0)], ink);
pen.line(&[(4.0, 15.0), (4.0, 20.0), (20.0, 20.0), (20.0, 15.0)], ink);
}
// Help: a question mark in a ring.
"help" => {
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
@@ -472,7 +477,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"c_coincident" => {
pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink);
pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink);
pen.circle(12.0, 12.0, 2.6, Color32::WHITE, a);
pen.circle(12.0, 12.0, 2.6, ic.highlight, a);
}
"c_tangent" => {
pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink);
@@ -519,7 +524,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink);
}
"new" => {
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], Color32::WHITE, ink);
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], ic.highlight, ink);
pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink);
}
"open" => {
@@ -527,7 +532,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"save" => {
pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink);
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], Color32::WHITE, ink);
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], ic.highlight, ink);
}
"export" => {
pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink);
@@ -566,7 +571,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
}
"zoom" => {
pen.circle(10.0, 10.0, 6.0, Color32::WHITE, ink);
pen.circle(10.0, 10.0, 6.0, ic.highlight, ink);
pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink);
}
"perspective" => {
@@ -584,13 +589,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0);
pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0);
}
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink),
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], ic.folder, ink),
"origin" => {
pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60));
pen.arrow((6.0, 18.0), (6.0, 4.0), Color32::from_rgb(60, 100, 220));
pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60));
pen.arrow((6.0, 18.0), (20.0, 18.0), ic.axes[0]);
pen.arrow((6.0, 18.0), (6.0, 4.0), ic.axes[2]);
pen.arrow((6.0, 18.0), (15.0, 9.0), ic.axes[1]);
}
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink),
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], ic.plane, ink),
"axis" => {
pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink);
pen.circle(4.0, 20.0, 2.0, ink, ink);
@@ -819,8 +824,8 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
}
"accessibility" => {
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], Color32::from_rgb(70, 180, 90), ink);
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], Color32::from_rgb(220, 70, 60), Color32::TRANSPARENT);
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], ic.access[0], ink);
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], ic.access[1], Color32::TRANSPARENT);
pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink);
pen.arrow((4.0, 2.0), (4.0, 13.0), a);
pen.arrow((20.0, 2.0), (20.0, 13.0), a);
@@ -828,10 +833,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"environment_map" => {
let pts: Vec<Pos2> =
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
p.add(Shape::convex_polygon(pts, Color32::from_rgb(190, 205, 230), Stroke::new(pen.w, ink)));
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], Color32::from_rgb(60, 62, 70), Color32::TRANSPARENT);
pen.line(&[(3.2, 12.6), (20.8, 12.6)], Color32::WHITE);
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), Color32::WHITE);
p.add(Shape::convex_polygon(pts, ic.sky, Stroke::new(pen.w, ink)));
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], ic.floor, Color32::TRANSPARENT);
pen.line(&[(3.2, 12.6), (20.8, 12.6)], ic.highlight);
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), ic.highlight);
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
}
"zebra" | "draft_analysis" | "curvature_map" => {
@@ -839,27 +844,23 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
match name {
"zebra" => {
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
for k in 0..3 {
let y = 6.5 + k as f32 * 4.5;
let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt();
pen.poly(
&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)],
Color32::from_gray(30),
Color32::TRANSPARENT,
);
pen.poly(&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)], ic.stripe, Color32::TRANSPARENT);
}
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
}
"draft_analysis" => {
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], Color32::from_rgb(70, 180, 90), ink);
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], Color32::from_rgb(235, 200, 50), Color32::TRANSPARENT);
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], ic.draft[0], ink);
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], ic.draft[1], Color32::TRANSPARENT);
pen.arrow((12.0, 16.0), (12.0, 1.5), a);
}
_ => {
p.add(Shape::convex_polygon(pts, Color32::from_rgb(60, 110, 230), Stroke::new(pen.w, ink)));
pen.circle(14.0, 10.0, 5.0, Color32::from_rgb(70, 190, 90), Color32::TRANSPARENT);
pen.circle(15.5, 8.5, 2.2, Color32::from_rgb(225, 60, 45), Color32::TRANSPARENT);
p.add(Shape::convex_polygon(pts, ic.curvature[0], Stroke::new(pen.w, ink)));
pen.circle(14.0, 10.0, 5.0, ic.curvature[1], Color32::TRANSPARENT);
pen.circle(15.5, 8.5, 2.2, ic.curvature[2], Color32::TRANSPARENT);
}
}
}
@@ -977,13 +978,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"patch" => {
// A wire loop with a surface spanned across it.
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], SHEET, ink);
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], ic.sheet, ink);
pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
}
"stitch" => {
// Two surface patches sewn along their shared edge.
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], SHEET, ink);
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], SHEET_2, ink);
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], ic.sheet, ink);
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], ic.sheet_2, ink);
for y in [6.0, 9.0, 12.0] {
pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
}
@@ -993,7 +994,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)];
let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)];
let d = f.map(|(x, y)| (x, y + 6.0));
pen.band(&bezier(b, 16), &bezier(f, 16), SHEET);
pen.band(&bezier(b, 16), &bezier(f, 16), ic.sheet);
pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85));
pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink);
pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink);
@@ -1036,7 +1037,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// An open profile (accent) pulled up into a sheet.
let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)];
let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)];
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink);
pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink);
pen.curve(top, ink);
@@ -1047,13 +1048,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// An open profile (accent) turned about the axis into a hollow band.
let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)];
let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)];
pen.band(&bezier(left, 16), &bezier(right, 16), SHEET);
pen.band(&bezier(left, 16), &bezier(right, 16), ic.sheet);
let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16);
pen.poly(&front, SHEET, Color32::TRANSPARENT);
pen.poly(&front, ic.sheet, Color32::TRANSPARENT);
// A closed fill takes each point once: a repeated end point spikes `poly`'s edge.
let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32);
opening.pop();
pen.poly(&opening, SHEET_LIGHT, ink);
pen.poly(&opening, ic.sheet_light, ink);
pen.line(&front, ink);
pen.curve(left, ink);
pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink);
@@ -1064,7 +1065,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// A straight profile (accent) carried along a curved path: a ribbon.
let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)];
let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)];
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET);
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet);
pen.curve(near, ink);
pen.curve(far, ink);
pen.line(&[(4.0, 13.0), (4.0, 20.0)], a);
@@ -1074,7 +1075,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// Two open sections (accent) joined by a sheet.
let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)];
let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)];
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink);
pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink);
pen.curve(top, a);
@@ -1084,7 +1085,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// A wavy sheet and its offset copy (dashed), the distance shown by the arrow.
let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)];
let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)];
pen.band(&bezier(back, 16), &bezier(front, 16), SHEET);
pen.band(&bezier(back, 16), &bezier(front, 16), ic.sheet);
pen.curve(back, ink);
pen.curve(front, ink);
pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink);
@@ -1098,7 +1099,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"ruled" => {
// A strip hanging off a block's edge (accent), ruled by straight lines.
pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink);
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], SHEET, ink);
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], ic.sheet, ink);
pen.line(&[(15.0, 9.3), (15.0, 19.3)], a);
pen.line(&[(18.0, 6.7), (18.0, 16.7)], a);
pen.line(&[(12.0, 12.0), (12.0, 22.0)], a);
@@ -1134,13 +1135,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
})
.collect();
for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) {
pen.band(lo, hi, SHEET);
pen.band(lo, hi, ic.sheet);
}
for j in [n / 3, 2 * n / 3] {
if let Some(r) = rows.get(j) {
pen.line(r, SHEET_LINE);
pen.line(r, ic.sheet_line);
}
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), SHEET_LINE);
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), ic.sheet_line);
}
for c in [left, top, right, bottom] {
pen.curve(c, a);
@@ -1154,29 +1155,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// leaves both edges tangent.
let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)];
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], SHEET, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], ic.sheet, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
pen.curve(far, a);
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], SHEET, ink);
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], ic.sheet, ink);
pen.curve(near, a);
}
"untrim" => {
// A trimmed sheet whose hole (dashed, accent) is filled back in.
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], SHEET, ink);
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], ic.sheet, ink);
let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32);
pen.poly(hole.get(1..).unwrap_or_default(), SHEET_LIGHT, Color32::TRANSPARENT);
pen.poly(hole.get(1..).unwrap_or_default(), ic.sheet_light, Color32::TRANSPARENT);
pen.dashed(&hole, a);
}
"unstitch" => {
// Two sheets pulled apart from their shared edge.
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], SHEET, ink);
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], SHEET_2, ink);
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], ic.sheet, ink);
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], ic.sheet_2, ink);
pen.arrow((10.8, 11.0), (7.0, 11.0), a);
pen.arrow((13.2, 11.0), (17.0, 11.0), a);
}
"reverse_normal" => {
// A sheet with its old normal (faded) and the flipped one (accent).
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], SHEET, ink);
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], ic.sheet, ink);
pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5));
pen.arrow((12.0, 14.0), (12.0, 22.5), a);
}
@@ -1184,10 +1185,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// A wall sheet and a floor sheet joined by a round strip (accent edges).
let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)];
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], SHEET_2, ink);
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], SHEET, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
pen.line(&[(4.5, 19.5), (8.9, 15.9)], SHEET_LINE);
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], ic.sheet_2, ink);
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], ic.sheet, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
pen.line(&[(4.5, 19.5), (8.9, 15.9)], ic.sheet_line);
pen.curve(far, a);
pen.curve(near, a);
}
@@ -1203,11 +1204,11 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
let h = 0.8 + 4.2 * i as f32 / 10.0;
let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h);
if i % 2 == 0 {
pen.line(&[(*x, *y), tip], SHEET_LINE);
pen.line(&[(*x, *y), tip], ic.sheet_line);
}
tips.push(tip);
}
pen.line(&tips, SHEET_LINE);
pen.line(&tips, ic.sheet_line);
pen.curve(c, a);
pen.dot(12.0, 13.0, a);
}
@@ -1250,7 +1251,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"interference" => {
pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink);
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], Color32::from_rgb(225, 70, 60), Color32::from_rgb(225, 70, 60));
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference);
pen.closed(&[(9.0, 10.0), (21.0, 10.0), (21.0, 20.0), (9.0, 20.0)], ink);
}
// Sheet metal: thin plates (fill), the made or changed part in the accent.
@@ -1277,7 +1278,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"fold" => {
pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink);
pen.line(&[(12.0, 18.0), (19.0, 6.0)], a);
pen.line(&[(9.0, 21.0), (15.0, 15.0)], Color32::from_rgb(130, 140, 160));
pen.line(&[(9.0, 21.0), (15.0, 15.0)], ic.fold);
pen.arc(12.0, 18.0, 6.0, 0.2, 1.0, a);
pen.arrow((18.0, 16.0), (17.0, 12.5), a);
}
@@ -1352,16 +1353,23 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// Appearance: a shaded ball with three paint dabs.
"appearance" => {
pen.circle(10.0, 13.0, 7.5, pen.fill, ink);
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, Color32::WHITE);
pen.circle(18.0, 5.0, 2.6, Color32::from_rgb(220, 70, 60), ink);
pen.circle(20.5, 11.0, 2.6, Color32::from_rgb(70, 150, 230), ink);
pen.circle(19.0, 17.5, 2.6, Color32::from_rgb(240, 200, 60), ink);
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, ic.highlight);
pen.circle(18.0, 5.0, 2.6, ic.paint[0], ink);
pen.circle(20.5, 11.0, 2.6, ic.paint[1], ink);
pen.circle(19.0, 17.5, 2.6, ic.paint[2], ink);
}
"warning" => {
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], Color32::from_rgb(250, 200, 60), ink);
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], ic.warning, ink);
pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink);
pen.dot(12.0, 17.0, ink);
}
// A failed feature: a disc with a cross, a different shape from the warning's triangle, so
// it reads without colour (#972). Drawn in the caller's colours only.
"error" => {
pen.circle(12.0, 12.0, 10.0, pen.fill, ink);
pen.line(&[(8.0, 8.0), (16.0, 16.0)], ink);
pen.line(&[(16.0, 8.0), (8.0, 16.0)], ink);
}
_ => {
pen.poly(&[(5.0, 5.0), (19.0, 5.0), (19.0, 19.0), (5.0, 19.0)], pen.fill, ink);
}
+6 -5
View File
@@ -5,8 +5,9 @@ use egui::{Color32, Pos2, Stroke, vec2};
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
let white = Stroke::new(1.5, Color32::WHITE);
let ic = Tokens::get().icons;
let halo = Stroke::new(4.5, ic.marker_halo);
let white = Stroke::new(1.5, ic.highlight);
if let Some(tip) = tip {
let d = (tip - c).normalized();
let n = vec2(-d.y, d.x);
@@ -30,7 +31,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
}
}
};
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
painter.add(shape(ic.marker_halo, Stroke::NONE, 4.0));
painter.add(shape(col, Stroke::NONE, 0.0));
match kind {
SnapKind::FaceCenter => {
@@ -38,8 +39,8 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
}
SnapKind::CircleCenter => {
painter.circle_stroke(c, 7.0, white);
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, ic.highlight));
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, ic.highlight));
}
SnapKind::EdgeMid | SnapKind::Vertex => {
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
+3 -4
View File
@@ -151,13 +151,12 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
pub(crate) const AXIS_RADIUS: f32 = 11.0;
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
let col = if d.x != 0.0 {
solidcraft_engine::view::colors::AXIS_X
t.triad[0]
} else if d.y != 0.0 {
solidcraft_engine::view::colors::AXIS_Y
t.triad[1]
} else {
solidcraft_engine::view::colors::AXIS_Z
t.triad[2]
};
let col = Color32::from_rgb(col.0, col.1, col.2);
let positive = !name.starts_with('-');
let ink = if depth < -0.01 { col.gamma_multiply(0.55) } else { col };
painter.line_segment([c, at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink));
+10 -6
View File
@@ -1,14 +1,18 @@
//! Original agent pointer and label. Animation stays with the UI controller.
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
pub(crate) const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
pub(crate) fn agent() -> Color32 {
crate::theme::Tokens::get().icons.agent
}
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
let ic = crate::theme::Tokens::get().icons;
// An arrow pointer, tip at `p`.
let pts: Vec<Pos2> =
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), ic.highlight);
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
painter.rect_filled(r, 4.0, AGENT);
painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
painter.rect_filled(r, 4.0, ic.agent);
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
}
+210 -1
View File
@@ -1,10 +1,22 @@
{
"Send your report on GitHub": "Bericht auf GitHub senden",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Unten den GitHub-Entwurf öffnen und den kopierten Bericht aus der Zwischenablage einfügen.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Unten den GitHub-Entwurf öffnen. Titel und Bericht werden vorausgefüllt.",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Screenshot anhängen: kopieren und einfügen oder als PNG speichern und hochladen.",
"3. Click Submit new issue on GitHub to send your report.": "3. Auf GitHub auf Submit new issue klicken, um den Bericht zu senden.",
"nav.touchpad.label": "Touchpad",
"nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.",
"nav.touchpad.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)",
"nav.touchpad.pan": "Verschieben (Umschalt halten und bewegen; ohne Maustaste)",
"nav.touchpad.zoom": "Zoom (vertikal scrollen oder Finger spreizen/zusammenziehen)",
"nav.touchpad.scroll": "Touchpad verwendet vertikales Scrollen zum Zoomen. Die Zwei-Finger-Verschiebung bleibt für andere Vorgaben gespeichert.",
"browser.body_name": "Körper {number}",
"browser.component_name": "Komponente {number}",
"browser.parameters": "Parameter ({count})",
"browser.precision": "Genauigkeit: {precision}",
"browser.sketch_name": "Skizze {number}",
"browser.units": "Einheiten: {units}",
"dialog.computing": "Wird berechnet… {seconds} s",
"dialog.custom_axis": "↖ Eigene Achse",
"dialog.edit_title": "{title} bearbeiten",
"dialog.select": "↖ Auswählen",
@@ -37,6 +49,16 @@
"ui.animation": "Animation",
"ui.animation_speed": "Animationsgeschwindigkeit",
"ui.anti_aliasing": "Kantenglättung",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_off": "Aus (1×)",
"ui.supersampling_adaptive": "Nur bei ruhender Ansicht",
"settings.supersampling_note": "Zeichnet die Ansicht in höherer Auflösung und rechnet sie herunter: glattere Kanten, Linien und Raster auf Bildschirmen unter 4K. Kostet GPU-Zeit: etwa 2,25-mal so viele Pixel bei 1,5× und 4-mal bei 2×.",
"ui.display_quality": "Darstellungsqualität",
"ui.display_quality_coarse": "Grob",
"ui.display_quality_normal": "Normal",
"ui.display_quality_fine": "Fein",
"ui.display_quality_ultra": "Ultra",
"settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.",
"ui.apply": "Anwenden",
"ui.arc": "Kreisbogen",
"ui.assemble": "Zusammenbau",
@@ -67,6 +89,21 @@
"ui.coincident": "Koinzident",
"ui.collapse": "Einklappen",
"ui.collinear": "Kollinear",
"ui.colour_vision": "Farbsehen",
"ui.colour_vision_deutan": "Deutan",
"ui.colour_vision_deutan_tip": "Bei schwachem oder fehlendem Grünsinn",
"ui.colour_vision_failed": "Fehler",
"ui.colour_vision_fixed": "Fixiert",
"ui.colour_vision_mode": "Modus",
"ui.colour_vision_note": "Ändert die Farben, die eine Bedeutung tragen: Skizzenstatus, Achsen, Biegungen, Analysen und Symbole. Wenn Sie die Farbfilter von Windows verwenden, lassen Sie dies auf „Aus“.",
"ui.colour_vision_off": "Aus",
"ui.colour_vision_off_tip": "Die eigenen Farben des Designs",
"ui.colour_vision_projected": "Projiziert",
"ui.colour_vision_protan": "Protan",
"ui.colour_vision_protan_tip": "Bei schwachem oder fehlendem Rotsinn",
"ui.colour_vision_sample": "Beispiel",
"ui.colour_vision_tritan": "Tritan",
"ui.colour_vision_tritan_tip": "Bei schwachem oder fehlendem Blausinn",
"ui.combine": "Kombinieren",
"ui.coming_later": "bald verfügbar",
"ui.concentric": "Konzentrisch",
@@ -260,6 +297,10 @@
"ui.quantity": "Anzahl",
"ui.quit": "Beenden",
"ui.radius": "Radius",
"ui.pull_direction": "Entformungsrichtung",
"ui.tool_direction": "Werkzeugrichtung",
"ui.reachable": "Erreichbar",
"ui.blocked": "Verdeckt",
"ui.radius_type": "Radius-Typ",
"ui.rectangle": "Rechteck",
"ui.rectangular_pattern": "Rechteckmuster",
@@ -271,9 +312,33 @@
"ui.revolve": "Drehen",
"ui.right": "Rechts",
"ui.save": "Speichern",
"ui.heading_save_mesh": "ALS NETZ SPEICHERN",
"ui.format": "Format",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF mit Flächenmarken",
"ui.send_to_slicer": "An Slicer senden",
"ui.slicer": "Slicer",
"ui.custom_program": "Benutzerdefiniert…",
"ui.no_slicer_found": "Kein Slicer gefunden",
"ui.send": "Senden",
"ui.custom_program_tip": "Ein Slicer-Programm zur Liste hinzufügen",
"ui.slicer_delete_tip": "{program}\nMit Entf aus der Liste entfernen",
"ui.save_mesh_pick_bodies": "Wähle die Körper zum Speichern aus",
"ui.save_mesh_no_slicer": "Kein Slicer gefunden: „Benutzerdefiniert…“ wählen und das Slicer-Programm angeben, oder „An Slicer senden“ ausschalten",
"ui.not_a_program": "{path} ist kein startbares Programm",
"ui.sent_to_slicer": "An {slicer} gesendet",
"ui.saved_to": "Gespeichert: {path}",
"ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}",
"ui.save_as": "Speichern unter",
"ui.save_as_ellipsis": "Speichern unter...",
"ui.scale": "Skalieren",
"ui.orientation": "Ausrichtung",
"ui.twist": "Verdrehung",
"ui.end_scale": "Endskalierung",
"ui.sweep_turn_with_path": "Mit Pfad drehen",
"ui.sweep_keep_as_drawn": "Wie gezeichnet",
"ui.sweep_follow_direction": "Richtung folgen",
"ui.section_analysis": "Querschnittsanalyse",
"ui.select": "Auswählen",
"ui.selection": "Auswahl",
@@ -361,6 +426,17 @@
"ui.pinToShortcuts": "An Verknüpfungen anheften",
"ui.pinToToolbar": "An Symbolleiste anheften",
"ui.properties": "Eigenschaften",
"ui.design_properties.project": "Projekt",
"ui.design_properties.part_number": "Teilenummer",
"ui.design_properties.revision": "Revision",
"ui.design_properties.author": "Autor",
"ui.design_properties.organization": "Organisation",
"ui.design_properties.description": "Beschreibung",
"ui.design_properties.license": "Lizenz",
"ui.design_properties.not_text": "Nicht als Text gespeichert; eine Eingabe hier ersetzt den Wert.",
"ui.design_properties.exports": "STEP-, IGES- und 3MF-Exporte übernehmen diese Angaben, wo das Format ein Feld dafür hat.",
"ui.design_properties.others": "Daten anderer Werkzeuge",
"ui.design_properties.others_note": "Bleiben unverändert in der Konstruktion und werden nie exportiert.",
"ui.removeSection": "Schnitt entfernen",
"ui.removeFromToolbar": "Aus der Symbolleiste entfernen",
"ui.repeat": "Wiederholen",
@@ -384,6 +460,9 @@
"ui.updateToCurrentView": "Auf aktuelle Ansicht aktualisieren",
"ui.zoom": "Zoomen",
"ui.command_done": "{command} fertig",
"drive.stopped_by_loop": "Angehalten bei {value}: die Schleife über {joint} lässt sich nicht weiter schließen",
"drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen",
"drive.unconnected": "Bewegt nichts: {part} ist mit nichts Festgesetztem verbunden",
"cmd.appearance_assign": "Erscheinungsbild",
"cmd.appearance_library": "Erscheinungsbild-Bibliothek",
"cmd.appearance_list": "Erscheinungsbilder auflisten",
@@ -456,6 +535,9 @@
"cmd.doc_recovery_list": "Wiederherstellbare Konstruktionen",
"cmd.document_inspect": "Konstruktion prüfen",
"cmd.document_units": "Einheiten der Konstruktion ändern",
"cmd.document_metadata": "Konstruktionseigenschaften",
"cmd.document_metadata_set": "Konstruktionseigenschaften ändern",
"cmd.document_metadata_remove": "Konstruktionseigenschaft entfernen",
"cmd.edit_checkpoint": "Prüfpunkt",
"cmd.edit_restore_checkpoint": "Prüfpunkt wiederherstellen",
"cmd.engine_commands": "Befehle auflisten",
@@ -469,6 +551,7 @@
"cmd.file_insert_mesh": "Netz einfügen",
"cmd.file_insert_step": "STEP einfügen",
"cmd.file_save_mesh": "Als Netz speichern",
"cmd.file_slicers": "Installierte Slicer",
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
@@ -495,6 +578,8 @@
"cmd.material_delete": "Physisches Material löschen",
"cmd.material_edit": "Physisches Material bearbeiten",
"cmd.material_list": "Materialien auflisten",
"cmd.material_properties_directional": "Richtungsabhängiger Elastizitätsmodul",
"cmd.material_properties_validate": "Physikalische Materialdaten prüfen",
"cmd.mesh_convert": "Netz konvertieren",
"cmd.model_edges": "Kanten auflisten",
"cmd.model_faces": "Flächen auflisten",
@@ -515,6 +600,7 @@
"cmd.parameters_delete": "Parameter löschen",
"cmd.parameters_export": "Parameter exportieren",
"cmd.parameters_favorite": "Favorisierter Parameter",
"cmd.parameters_move": "Parameter verschieben",
"cmd.parameters_graph": "Parametergraph",
"cmd.parameters_import": "Parameter importieren",
"cmd.parameters_list": "Parameter auflisten",
@@ -540,6 +626,7 @@
"cmd.sheet_fold": "Falten",
"cmd.sheet_hem": "Umkantung",
"cmd.sheet_manage_rules": "Blechregeln",
"cmd.sheet_perforate": "Perforieren",
"cmd.sheet_refold": "Wieder falten",
"cmd.sheet_rules": "Blechregeln auflisten",
"cmd.sheet_unfold": "Entfalten",
@@ -637,9 +724,11 @@
"cmd.surface_unstitch": "Verbund aufheben",
"cmd.surface_reverse_normal": "Normale umkehren",
"cmd.inspect_continuity": "Kontinuitätsprüfung",
"cmd.timeline_add": "Aufgezeichnetes Merkmal hinzufügen",
"cmd.timeline_compute_all": "Alles berechnen",
"cmd.timeline_copy": "Merkmale kopieren",
"cmd.timeline_dependents": "Merkmalsabhängigkeiten",
"cmd.timeline_export_script": "Befehlsskript exportieren",
"cmd.timeline_inspect": "Merkmal prüfen",
"cmd.timeline_paste": "Merkmale einfügen",
"cmd.timeline_redefine": "Merkmal bearbeiten",
@@ -658,9 +747,12 @@
"ui.heading_constraints": "RANDBEDINGUNGEN",
"ui.heading_utilities": "WERKZEUGE",
"BROWSER": "BROWSER",
"Bends": "Biegungen",
"Drive Joint": "Gelenk antreiben",
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
"Find in Browser": "Im Browser suchen",
"Copy Error Message": "Fehlermeldung kopieren",
"Copy Warning Message": "Warnmeldung kopieren",
"Report a bug — review a screenshot and open a GitHub draft": "Fehler melden — Screenshot prüfen und einen GitHub-Entwurf öffnen",
"Reset Toolbar": "Symbolleiste zurücksetzen",
"Select bodies (a face picks its body)": "Körper auswählen (eine Fläche wählt ihren Körper)",
@@ -697,5 +789,122 @@
"sketch_text.align_right": "Rechts",
"sketch_text.path_hint": "Eine Skizzenkurve, der der Text folgt: hier klicken, dann die Kurve",
"sketch_text.path_clear": "Text von der Kurve lösen",
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage"
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage",
"cmd.library_inspect": "Lokale Bibliothek prüfen",
"cmd.library_export": "Designbibliothek exportieren",
"cmd.library_apply": "Lokalen Bibliothekseintrag anwenden",
"Thin": "Dünnwandig",
"Thin Extrude": "Dünnwandig extrudieren",
"Wall Location": "Wandlage",
"Wall Thickness": "Wandstärke",
"Thickness Outside": "Dicke außen",
"Inside": "Innen",
"Outside": "Außen",
"Profiles/Curves": "Profile/Kurven",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} ist verfügbar",
"update.title": "Update verfügbar",
"update.available": "SolidCraft {version} ist verfügbar",
"update.you_have": "Installiert ist {version}.",
"update.no_notes": "Zu diesem Release gibt es keine Hinweise.",
"update.system_hint": "Diese Installation ist ein {kind}. Aktualisieren mit:",
"update.package_hint": "Diese Installation stammt aus einem {kind}. Neues Paket herunterladen und installieren mit:",
"update.copy": "Kopieren",
"update.file": "Herunterladen holt {file}.",
"update.download": "Herunterladen",
"update.release_page": "Release-Seite öffnen",
"update.skip": "Diese Version überspringen",
"update.later": "Später erinnern",
"update.prefs_label": "Updates",
"update.check_automatically": "Automatisch nach Updates suchen",
"update.check_now": "Jetzt suchen",
"update.checking": "Suche läuft…",
"update.failed": "Suche fehlgeschlagen: {error}",
"update.up_to_date": "SolidCraft ist aktuell.",
"update.prefs_note": "Dies ist SolidCraft {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.",
"update.prefs_dev": "Dies ist ein Entwicklungs-Build ({version}): er sucht nicht nach Updates.",
"update.show": "Update auf {version} anzeigen",
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. SolidCraft sucht nicht selbst.",
"update.update_now": "Jetzt aktualisieren",
"update.install_msi": "Installieren (schließt SolidCraft)",
"update.download_open": "Herunterladen und öffnen",
"update.downloading": "Herunterladen und prüfen… {done} von {total} MB",
"update.installed": "SolidCraft {version} ist installiert. Zum Verwenden neu starten.",
"update.restart": "Jetzt neu starten",
"update.restart_later": "Später",
"update.installer_started": "Das Installationsprogramm startet, wenn SolidCraft geschlossen wird.",
"update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: SolidCraft in „Programme“ ziehen, die alte Version ersetzen und SolidCraft neu starten.",
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket.",
"cmd.render_settings_get": "Rendereinstellungen",
"cmd.render_settings_set": "Rendereinstellungen speichern",
"cmd.render_scene": "Renderszene prüfen",
"cmd.render_settings_apply": "Renderkamera anwenden",
"toolbar.render": "RENDERN",
"toolbar.render_scene": "SZENE",
"render.preview": "Integrierte Vorschau",
"render.preview_help": "Rasterdarstellung mit gemischter Transparenz. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.",
"render.setup_name": "Name des Setups",
"render.background": "Szenenhintergrund",
"render.perspective": "Perspektivische Kamera",
"render.fit": "Produkt einpassen",
"render.restore_camera": "Gespeicherte Kamera laden",
"render.save_setup": "Setup speichern",
"render.unsaved": "Ungespeicherte Vorschauänderungen",
"render.reload_settings": "Gespeicherte Einstellungen laden",
"render.unavailable_preview": "Dieses Setup verlangt ein nicht verfügbares Vorschauprofil.",
"render.use_preview": "Integrierte Vorschau verwenden",
"render.navigation_help": "Ziehen zum Drehen; mit mittlerer Taste ziehen zum Verschieben; scrollen zum Zoomen. Setup speichern übernimmt die aktuelle Kamera.",
"render.return_design": "Zurück zu Design",
"render.image_title": "Bild rendern",
"render.image_help": "CPU-Vorschaubild · PNG · angenäherte Transparenz",
"render.image_width": "Breite",
"render.image_height": "Höhe",
"render.image_transparent": "Transparenter Hintergrund",
"render.image_check": "Renderanfrage prüfen",
"render.image_supported": "Renderanfrage unterstützt; Warteschlange und Speicher werden beim Start erneut geprüft.",
"render.image_queued": "Warten auf Rendering",
"render.image_preparing": "Bild wird vorbereitet",
"render.image_running": "Bild wird gerendert",
"render.image_encoding": "PNG wird kodiert",
"render.image_cancelling": "Wird abgebrochen",
"render.image_cancelled": "Rendering abgebrochen",
"render.image_completed": "Bild bereit zum Export",
"render.image_failed": "Rendering fehlgeschlagen",
"render.image_unknown": "Renderstatus nicht verfügbar",
"render.image_reduce": "Kleineres Bild oder einfachere Szene versuchen.",
"render.image_cancel": "Rendering abbrechen",
"render.image_stale": "Dieses Bild verwendet die Szene vor den nachfolgenden Änderungen.",
"render.image_start": "Rendering starten",
"render.image_overwrite": "Vorhandene Datei ersetzen",
"render.image_export": "PNG exportieren",
"cmd.render_preflight": "Renderanfrage prüfen",
"cmd.render_start": "Bild rendern",
"cmd.render_status": "Renderfortschritt",
"cmd.render_cancel": "Rendering abbrechen",
"cmd.render_export": "PNG exportieren",
"toolbar.image": "BILD",
"appearance.surface_response": "Oberflächenverhalten",
"appearance.pbr_opaque": "Opakes PBR",
"appearance.metallic": "Metallanteil",
"appearance.roughness": "Rauheit",
"appearance.dielectric_ior": "Brechungsindex des Dielektrikums",
"appearance.pbr_help": "Diese Oberfläche in Render oder einem Produkt-PNG ansehen. Keine Brechung oder Transmission.",
"render.opaque_preview_help": "Opakes PBR und einfache Farben; Transparenz durch Blending. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.",
"render.opaque_image_summary": "Vorschau-PNG · opakes PBR · Transparenz durch Blending",
"UNTRIM": "TRIMMEN AUFHEBEN",
"Face": "Fläche",
"Holes": "Löcher",
"Holes (edges on them)": "Löcher (ihre Kanten)",
"In place (surfaces)": "An Ort und Stelle (Flächen)",
"None": "Keine",
"Picked": "Ausgewählte",
"Restore outer boundary": "Äußere Begrenzung wiederherstellen",
"RULED SURFACE": "REGELFLÄCHE",
"Mitre": "Gehrung",
"Reference face": "Referenzfläche",
"Round": "Rund",
"Type": "Typ",
"joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.",
"joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.",
"joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist."
}
+207 -1
View File
@@ -1,4 +1,15 @@
{
"Send your report on GitHub": "Send your report on GitHub",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Open GitHub draft below, then paste the report copied to your clipboard.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Open GitHub draft below. Your title and report will be filled in.",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Attach your screenshot: copy and paste it, or save and upload the PNG.",
"3. Click Submit new issue on GitHub to send your report.": "3. Click Submit new issue on GitHub to send your report.",
"nav.touchpad.label": "Touchpad",
"nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
"nav.touchpad.orbit": "Orbit (hold Alt/Option and move; no mouse button)",
"nav.touchpad.pan": "Pan (hold Shift and move; no mouse button)",
"nav.touchpad.zoom": "Zoom (vertical scroll or pinch)",
"nav.touchpad.scroll": "Touchpad uses vertical scroll to zoom. Your two-finger pan setting is kept for other presets.",
"browser.body_name": "Body{number}",
"browser.component_name": "Component{number}",
"browser.parameters": "Parameters ({count})",
@@ -77,6 +88,9 @@
"cmd.doc_recovery_list": "Recoverable Designs",
"cmd.document_inspect": "Inspect Design",
"cmd.document_units": "Change Design Units",
"cmd.document_metadata": "Design Properties",
"cmd.document_metadata_set": "Change Design Properties",
"cmd.document_metadata_remove": "Remove Design Property",
"cmd.edit_checkpoint": "Checkpoint",
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
"cmd.engine_commands": "List Commands",
@@ -90,6 +104,7 @@
"cmd.file_insert_mesh": "Insert Mesh",
"cmd.file_insert_step": "Insert STEP",
"cmd.file_save_mesh": "Save As Mesh",
"cmd.file_slicers": "Installed Slicers",
"cmd.inspect_accessibility": "Accessibility Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
@@ -136,6 +151,7 @@
"cmd.parameters_delete": "Delete Parameter",
"cmd.parameters_export": "Export Parameters",
"cmd.parameters_favorite": "Favourite Parameter",
"cmd.parameters_move": "Move Parameter",
"cmd.parameters_graph": "Parameter Graph",
"cmd.parameters_import": "Import Parameters",
"cmd.parameters_list": "List Parameters",
@@ -161,6 +177,7 @@
"cmd.sheet_fold": "Fold",
"cmd.sheet_hem": "Hem",
"cmd.sheet_manage_rules": "Sheet Metal Rules",
"cmd.sheet_perforate": "Perforate",
"cmd.sheet_refold": "Refold",
"cmd.sheet_rules": "List Sheet Metal Rules",
"cmd.sheet_unfold": "Unfold",
@@ -259,9 +276,11 @@
"cmd.surface_unstitch": "Unstitch",
"cmd.surface_reverse_normal": "Reverse Normal",
"cmd.inspect_continuity": "Continuity Analysis",
"cmd.timeline_add": "Add Recorded Feature",
"cmd.timeline_compute_all": "Compute All",
"cmd.timeline_copy": "Copy Features",
"cmd.timeline_dependents": "Feature Dependents",
"cmd.timeline_export_script": "Export Command Script",
"cmd.timeline_inspect": "Inspect Feature",
"cmd.timeline_paste": "Paste Features",
"cmd.timeline_redefine": "Edit Feature",
@@ -275,6 +294,7 @@
"cmd.view_list": "List Named Views",
"cmd.view_rename": "Rename Named View",
"cmd.view_save": "New Named View",
"dialog.computing": "Computing… {seconds} s",
"dialog.custom_axis": "↖ Custom Axis",
"dialog.edit_title": "EDIT {title}",
"dialog.select": "↖ Select",
@@ -309,6 +329,16 @@
"ui.animation": "Animation",
"ui.animation_speed": "Animation speed",
"ui.anti_aliasing": "Anti-aliasing",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_off": "Off (1×)",
"ui.supersampling_adaptive": "Only when the view is still",
"settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.",
"ui.display_quality": "Display quality",
"ui.display_quality_coarse": "Coarse",
"ui.display_quality_normal": "Normal",
"ui.display_quality_fine": "Fine",
"ui.display_quality_ultra": "Ultra",
"settings.display_quality_note": "Finer levels draw curved faces and edges smoother and take longer to mesh. Only the viewport changes, never the design or exports.",
"ui.apply": "Apply",
"ui.arc": "Arc",
"ui.assemble": "Assemble",
@@ -342,6 +372,21 @@
"ui.coincident": "Coincident",
"ui.collapse": "Collapse",
"ui.collinear": "Collinear",
"ui.colour_vision": "Colour vision",
"ui.colour_vision_deutan": "Deutan",
"ui.colour_vision_deutan_tip": "For a weak or missing green sense",
"ui.colour_vision_failed": "Failed",
"ui.colour_vision_fixed": "Fixed",
"ui.colour_vision_mode": "Mode",
"ui.colour_vision_note": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows colour filters, keep this Off.",
"ui.colour_vision_off": "Off",
"ui.colour_vision_off_tip": "The theme's own colours",
"ui.colour_vision_projected": "Projected",
"ui.colour_vision_protan": "Protan",
"ui.colour_vision_protan_tip": "For a weak or missing red sense",
"ui.colour_vision_sample": "Sample",
"ui.colour_vision_tritan": "Tritan",
"ui.colour_vision_tritan_tip": "For a weak or missing blue sense",
"ui.combine": "Combine",
"ui.coming_later": "coming later",
"ui.concentric": "Concentric",
@@ -553,6 +598,10 @@
"ui.quit": "Quit",
"ui.radial_context_menu": "Radial context menu",
"ui.radius": "Radius",
"ui.pull_direction": "Pull direction",
"ui.tool_direction": "Tool direction",
"ui.reachable": "Reachable",
"ui.blocked": "Blocked",
"ui.radius_type": "Radius Type",
"ui.rectangle": "Rectangle",
"ui.rectangular_pattern": "Rectangular Pattern",
@@ -567,9 +616,33 @@
"ui.revolve": "Revolve",
"ui.right": "Right",
"ui.save": "Save",
"ui.heading_save_mesh": "SAVE AS MESH",
"ui.format": "Format",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF with face tags",
"ui.send_to_slicer": "Send to slicer",
"ui.slicer": "Slicer",
"ui.custom_program": "Custom…",
"ui.no_slicer_found": "No slicer found",
"ui.send": "Send",
"ui.custom_program_tip": "Choose a slicer program to add to the list",
"ui.slicer_delete_tip": "{program}\nPress Delete to remove it from the list",
"ui.save_mesh_pick_bodies": "Select the bodies to save",
"ui.save_mesh_no_slicer": "No slicer found: choose Custom… and pick your slicer program, or turn off Send to slicer",
"ui.not_a_program": "{path} is not a program that can be started",
"ui.sent_to_slicer": "Sent to {slicer}",
"ui.saved_to": "Saved {path}",
"ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}",
"ui.save_as": "Save As",
"ui.save_as_ellipsis": "Save As...",
"ui.scale": "Scale",
"ui.orientation": "Orientation",
"ui.twist": "Twist",
"ui.end_scale": "End Scale",
"ui.sweep_turn_with_path": "Turn with Path",
"ui.sweep_keep_as_drawn": "Keep as Drawn",
"ui.sweep_follow_direction": "Follow Direction",
"ui.section_analysis": "Section Analysis",
"ui.select": "Select",
"ui.select_other_hint": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
@@ -721,6 +794,8 @@
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Files from many CAD programs have Y up, so they open lying on their back here (Z up).",
"Filter": "Filter",
"Find in Browser": "Find in Browser",
"Copy Error Message": "Copy Error Message",
"Copy Warning Message": "Copy Warning Message",
"Flat size": "Flat size",
"Follow camera (Look At sketches)": "Follow camera (Look At sketches)",
"Font": "Font",
@@ -909,6 +984,17 @@
"ui.pinToShortcuts": "Pin to Shortcuts",
"ui.pinToToolbar": "Pin to Toolbar",
"ui.properties": "Properties",
"ui.design_properties.project": "Project",
"ui.design_properties.part_number": "Part number",
"ui.design_properties.revision": "Revision",
"ui.design_properties.author": "Author",
"ui.design_properties.organization": "Organization",
"ui.design_properties.description": "Description",
"ui.design_properties.license": "License",
"ui.design_properties.not_text": "Saved as something other than text; typing here replaces it.",
"ui.design_properties.exports": "STEP, IGES and 3MF exports carry these where the format has a place for them.",
"ui.design_properties.others": "Other tools' data",
"ui.design_properties.others_note": "Kept in the design as it is and never exported.",
"ui.removeSection": "Remove Section",
"ui.removeFromToolbar": "Remove from Toolbar",
"ui.repeat": "Repeat",
@@ -932,6 +1018,9 @@
"ui.updateToCurrentView": "Update to Current View",
"ui.zoom": "Zoom",
"ui.command_done": "{command} done",
"drive.stopped_by_loop": "Stopped at {value}: the loop through {joint} can't close any further",
"drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}",
"drive.unconnected": "Moves nothing: {part} is not connected to anything grounded",
"timeline.group": "New Feature Folder",
"timeline.ungroup": "Remove Feature Folders",
"timeline.rename_group": "Rename Feature Folder",
@@ -957,5 +1046,122 @@
"Line Spacing": "Line Spacing",
"Path": "Path",
"sketch_text.on_path": "The text follows its path: the curve places it",
"cmd.material_properties_validate":"Validate Physical Material Data"
"cmd.library_inspect": "Inspect Local Library",
"cmd.library_export": "Export Design Library",
"cmd.library_apply": "Apply Local Library Entry",
"Thin": "Thin",
"Thin Extrude": "Thin Extrude",
"Wall Location": "Wall Location",
"Wall Thickness": "Wall Thickness",
"Thickness Outside": "Thickness Outside",
"Inside": "Inside",
"Outside": "Outside",
"Profiles/Curves": "Profiles/Curves",
"cmd.material_properties_validate": "Validate Physical Material Data",
"cmd.material_properties_directional": "Directional Elastic Modulus",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} is available",
"update.title": "Update available",
"update.available": "SolidCraft {version} is available",
"update.you_have": "You have {version}.",
"update.no_notes": "This release has no notes.",
"update.system_hint": "This copy is a {kind}. Update it with:",
"update.package_hint": "This copy came from a {kind}. Download the new package, then install it with:",
"update.copy": "Copy",
"update.file": "Download gets {file}.",
"update.download": "Download",
"update.release_page": "Open release page",
"update.skip": "Skip this version",
"update.later": "Remind me later",
"update.prefs_label": "Updates",
"update.check_automatically": "Check for updates automatically",
"update.check_now": "Check now",
"update.checking": "Checking…",
"update.failed": "Couldn't check: {error}",
"update.up_to_date": "SolidCraft is up to date.",
"update.prefs_note": "This is SolidCraft {version} ({kind}). A check asks GitHub for the latest release and sends nothing else.",
"update.prefs_dev": "This is a development build ({version}): it doesn't check for updates.",
"update.show": "Show the update to {version}",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. SolidCraft doesn't check.",
"update.update_now": "Update now",
"update.install_msi": "Install (closes SolidCraft)",
"update.download_open": "Download and open",
"update.downloading": "Downloading and verifying… {done} of {total} MB",
"update.installed": "SolidCraft {version} is installed. Restart to use it.",
"update.restart": "Restart now",
"update.restart_later": "Later",
"update.installer_started": "The installer starts when SolidCraft closes.",
"update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.",
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead.",
"cmd.render_settings_get": "Render Settings",
"cmd.render_settings_set": "Save Render Settings",
"cmd.render_scene": "Inspect Render Scene",
"cmd.render_settings_apply": "Apply Render Camera",
"toolbar.render": "RENDER",
"toolbar.render_scene": "SCENE",
"render.preview": "Integrated preview",
"render.preview_help": "Raster shading and blended transparency. Refraction, HDRI and final rendering are not available yet.",
"render.setup_name": "Setup name",
"render.background": "Scene background",
"render.perspective": "Perspective camera",
"render.fit": "Fit Product",
"render.restore_camera": "Restore Saved Camera",
"render.save_setup": "Save Setup",
"render.unsaved": "Unsaved preview changes",
"render.reload_settings": "Reload Saved Settings",
"render.unavailable_preview": "This setup requests an unavailable preview profile.",
"render.use_preview": "Use Integrated Preview",
"render.navigation_help": "Drag to orbit; middle-drag to pan; scroll to zoom. Save Setup records the current camera.",
"render.return_design": "Return to Design",
"render.image_title": "Render Image",
"render.image_help": "CPU preview image · PNG · approximate transparency",
"render.image_width": "Width",
"render.image_height": "Height",
"render.image_transparent": "Transparent background",
"render.image_check": "Check Render Request",
"render.image_supported": "Render request is supported; the queue and memory pool are checked again at start.",
"render.image_queued": "Waiting to render",
"render.image_preparing": "Preparing image",
"render.image_running": "Rendering image",
"render.image_encoding": "Encoding PNG",
"render.image_cancelling": "Cancelling",
"render.image_cancelled": "Render cancelled",
"render.image_completed": "Image ready to export",
"render.image_failed": "Render failed",
"render.image_unknown": "Render status unavailable",
"render.image_reduce": "Try a smaller image or simpler scene.",
"render.image_cancel": "Cancel Render",
"render.image_stale": "This image uses the scene captured before subsequent changes.",
"render.image_start": "Start Render",
"render.image_overwrite": "Replace existing file",
"render.image_export": "Export PNG",
"cmd.render_preflight": "Check Render Request",
"cmd.render_start": "Render Image",
"cmd.render_status": "Render Progress",
"cmd.render_cancel": "Cancel Render",
"cmd.render_export": "Export Render Image",
"toolbar.image": "IMAGE",
"appearance.surface_response": "Surface response",
"appearance.pbr_opaque": "Opaque PBR",
"appearance.metallic": "Metallic",
"appearance.roughness": "Roughness",
"appearance.dielectric_ior": "Dielectric IOR",
"appearance.pbr_help": "View this surface in Render or a product PNG. No refraction or transmission.",
"render.opaque_preview_help": "Opaque PBR and simple colors; blended transparency. Refraction, HDRI and final rendering are not available yet.",
"render.opaque_image_summary": "Preview PNG · opaque PBR · blended transparency",
"UNTRIM": "UNTRIM",
"Face": "Face",
"Holes": "Holes",
"Holes (edges on them)": "Holes (edges on them)",
"In place (surfaces)": "In place (surfaces)",
"None": "None",
"Picked": "Picked",
"Restore outer boundary": "Restore outer boundary",
"RULED SURFACE": "RULED SURFACE",
"Mitre": "Mitre",
"Reference face": "Reference face",
"Round": "Round",
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is."
}
File diff suppressed because it is too large Load Diff
+207 -1
View File
@@ -1,4 +1,15 @@
{
"Send your report on GitHub": "GitHubでレポートを送信",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 下のボタンでGitHubの下書きを開き、クリップボードにコピーされたレポートを貼り付けます。",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 下のボタンでGitHubの下書きを開きます。タイトルとレポートは入力済みです。",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. スクリーンショットを添付します。コピーして貼り付けるか、PNGを保存してアップロードします。",
"3. Click Submit new issue on GitHub to send your report.": "3. GitHubでSubmit new issueをクリックしてレポートを送信します。",
"nav.touchpad.label": "タッチパッド",
"nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
"nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)",
"nav.touchpad.pan": "パン(Shift を押しながら移動、マウスボタン不要)",
"nav.touchpad.zoom": "ズーム(縦スクロールまたはピンチ)",
"nav.touchpad.scroll": "タッチパッドでは縦スクロールでズームします。2 本指パンの設定は他のプリセット用に保持されます。",
"browser.body_name": "ボディ{number}",
"browser.component_name": "コンポーネント{number}",
"browser.parameters": "パラメータ ({count})",
@@ -77,6 +88,9 @@
"cmd.doc_recovery_list": "復元可能なデザイン",
"cmd.document_inspect": "デザインを検査",
"cmd.document_units": "デザインの単位を変更",
"cmd.document_metadata": "デザインのプロパティ",
"cmd.document_metadata_set": "デザインのプロパティを変更",
"cmd.document_metadata_remove": "デザインのプロパティを削除",
"cmd.edit_checkpoint": "チェックポイント",
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
"cmd.engine_commands": "コマンドを一覧表示",
@@ -90,6 +104,7 @@
"cmd.file_insert_mesh": "メッシュを挿入",
"cmd.file_insert_step": "STEP を挿入",
"cmd.file_save_mesh": "メッシュとして保存",
"cmd.file_slicers": "インストール済みのスライサー",
"cmd.inspect_accessibility": "アクセシビリティ解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_curvature_comb": "曲率コーム解析",
@@ -136,6 +151,7 @@
"cmd.parameters_delete": "パラメータを削除",
"cmd.parameters_export": "パラメータをエクスポート",
"cmd.parameters_favorite": "お気に入りパラメータ",
"cmd.parameters_move": "パラメータを移動",
"cmd.parameters_graph": "パラメータ グラフ",
"cmd.parameters_import": "パラメータをインポート",
"cmd.parameters_list": "パラメータを一覧表示",
@@ -161,6 +177,7 @@
"cmd.sheet_fold": "折り曲げ",
"cmd.sheet_hem": "ヘミング",
"cmd.sheet_manage_rules": "板金ルール",
"cmd.sheet_perforate": "パンチング",
"cmd.sheet_refold": "折り戻し",
"cmd.sheet_rules": "板金ルールを一覧表示",
"cmd.sheet_unfold": "展開",
@@ -259,9 +276,11 @@
"cmd.surface_unstitch": "縫い合わせ解除",
"cmd.surface_reverse_normal": "法線を反転",
"cmd.inspect_continuity": "連続性解析",
"cmd.timeline_add": "記録したフィーチャーを追加",
"cmd.timeline_compute_all": "すべて再計算",
"cmd.timeline_copy": "フィーチャーをコピー",
"cmd.timeline_dependents": "従属フィーチャー",
"cmd.timeline_export_script": "コマンド スクリプトをエクスポート",
"cmd.timeline_inspect": "フィーチャーを検査",
"cmd.timeline_paste": "フィーチャーを貼り付け",
"cmd.timeline_redefine": "フィーチャーを編集",
@@ -275,6 +294,7 @@
"cmd.view_list": "名前付きビューを一覧表示",
"cmd.view_rename": "名前付きビューの名前を変更",
"cmd.view_save": "新規名前付きビュー",
"dialog.computing": "計算中… {seconds} 秒",
"dialog.custom_axis": "↖ カスタム軸",
"dialog.edit_title": "{title}を編集",
"dialog.select": "↖ 選択",
@@ -309,6 +329,16 @@
"ui.animation": "アニメーション",
"ui.animation_speed": "アニメーション速度",
"ui.anti_aliasing": "アンチエイリアス",
"ui.supersampling": "スーパーサンプリング (SSAA)",
"ui.supersampling_off": "オフ (1×)",
"ui.supersampling_adaptive": "ビューが静止しているときのみ",
"settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。",
"ui.display_quality": "表示品質",
"ui.display_quality_coarse": "粗い",
"ui.display_quality_normal": "標準",
"ui.display_quality_fine": "細かい",
"ui.display_quality_ultra": "最高",
"settings.display_quality_note": "細かいほど曲面とエッジが滑らかに表示されますが、メッシュの生成に時間がかかります。変わるのはビューポートだけで、設計やエクスポートは変わりません。",
"ui.apply": "適用",
"ui.arc": "円弧",
"ui.assemble": "アセンブリ",
@@ -342,6 +372,21 @@
"ui.coincident": "一致",
"ui.collapse": "折りたたむ",
"ui.collinear": "同一直線上",
"ui.colour_vision": "色覚",
"ui.colour_vision_deutan": "2型色覚(D型)",
"ui.colour_vision_deutan_tip": "緑を感じにくい、または感じない場合に",
"ui.colour_vision_failed": "エラー",
"ui.colour_vision_fixed": "固定",
"ui.colour_vision_mode": "モード",
"ui.colour_vision_note": "意味を持つ色を変更します(スケッチの状態、軸、曲げ、解析、アイコン)。Windows のカラー フィルターを使う場合は「オフ」のままにしてください。",
"ui.colour_vision_off": "オフ",
"ui.colour_vision_off_tip": "テーマ本来の色",
"ui.colour_vision_projected": "投影",
"ui.colour_vision_protan": "1型色覚(P型)",
"ui.colour_vision_protan_tip": "赤を感じにくい、または感じない場合に",
"ui.colour_vision_sample": "サンプル",
"ui.colour_vision_tritan": "3型色覚(T型)",
"ui.colour_vision_tritan_tip": "青を感じにくい、または感じない場合に",
"ui.combine": "結合",
"ui.coming_later": "準備中",
"ui.concentric": "同心",
@@ -553,6 +598,10 @@
"ui.quit": "終了",
"ui.radial_context_menu": "パイ メニュー",
"ui.radius": "半径",
"ui.pull_direction": "抜き方向",
"ui.tool_direction": "工具方向",
"ui.reachable": "到達可能",
"ui.blocked": "到達不可",
"ui.radius_type": "半径タイプ",
"ui.rectangle": "長方形",
"ui.rectangular_pattern": "矩形パターン",
@@ -567,9 +616,33 @@
"ui.revolve": "回転",
"ui.right": "右側面",
"ui.save": "保存",
"ui.heading_save_mesh": "メッシュとして保存",
"ui.format": "形式",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "面タグ付き 3MF",
"ui.send_to_slicer": "スライサーに送る",
"ui.slicer": "スライサー",
"ui.custom_program": "カスタム…",
"ui.no_slicer_found": "スライサーが見つかりません",
"ui.send": "送信",
"ui.custom_program_tip": "リストに追加するスライサーのプログラムを選択",
"ui.slicer_delete_tip": "{program}\nDelete キーでリストから削除",
"ui.save_mesh_pick_bodies": "保存するボディを選択してください",
"ui.save_mesh_no_slicer": "スライサーが見つかりません。「カスタム…」でスライサーのプログラムを選ぶか、「スライサーに送る」をオフにしてください",
"ui.not_a_program": "{path} は起動できるプログラムではありません",
"ui.sent_to_slicer": "{slicer} に送信しました",
"ui.saved_to": "保存しました: {path}",
"ui.save_mesh_temp_failed": "{folder} を作成できません: {error}",
"ui.save_as": "名前を付けて保存",
"ui.save_as_ellipsis": "名前を付けて保存…",
"ui.scale": "尺度",
"ui.orientation": "向き",
"ui.twist": "ねじれ",
"ui.end_scale": "終端スケール",
"ui.sweep_turn_with_path": "パスに沿って回転",
"ui.sweep_keep_as_drawn": "描いた向きを保持",
"ui.sweep_follow_direction": "方向に従う",
"ui.section_analysis": "断面解析",
"ui.select": "選択",
"ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。",
@@ -721,6 +794,8 @@
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "多くのCADではY軸を上向きとしているため、Z軸を上向きとするSolidCraftでは、読み込んだモデルが横倒しの向きで表示されることがあります。",
"Filter": "フィルター",
"Find in Browser": "ブラウザで検索",
"Copy Error Message": "エラーメッセージをコピー",
"Copy Warning Message": "警告メッセージをコピー",
"Flat size": "展開サイズ",
"Follow camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
"Font": "フォント",
@@ -909,6 +984,17 @@
"ui.pinToShortcuts": "ショートカットに固定",
"ui.pinToToolbar": "ツールバーに固定",
"ui.properties": "プロパティ",
"ui.design_properties.project": "プロジェクト",
"ui.design_properties.part_number": "部品番号",
"ui.design_properties.revision": "リビジョン",
"ui.design_properties.author": "作成者",
"ui.design_properties.organization": "組織",
"ui.design_properties.description": "説明",
"ui.design_properties.license": "ライセンス",
"ui.design_properties.not_text": "テキスト以外の値で保存されています。ここに入力すると置き換わります。",
"ui.design_properties.exports": "STEP、IGES、3MF のエクスポートでは、形式に対応する項目があればこれらが書き込まれます。",
"ui.design_properties.others": "他のツールのデータ",
"ui.design_properties.others_note": "デザイン内にそのまま保持され、エクスポートされることはありません。",
"ui.removeSection": "セクションを解除",
"ui.removeFromToolbar": "ツールバーから削除",
"ui.repeat": "繰り返し",
@@ -932,6 +1018,9 @@
"ui.updateToCurrentView": "現在のビューに更新",
"ui.zoom": "ズーム",
"ui.command_done": "{command} 完了",
"drive.stopped_by_loop": "{value} で停止: {joint} を通るループはこれ以上閉じられません",
"drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます",
"drive.unconnected": "何も動きません: {part} は空間固定されたものに接続されていません",
"timeline.group": "フィーチャーフォルダを新規作成",
"timeline.ungroup": "フィーチャーフォルダを削除",
"timeline.rename_group": "フィーチャーフォルダの名前を変更",
@@ -957,5 +1046,122 @@
"Line Spacing": "行間",
"Path": "パス",
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります",
"cmd.material_properties_validate":"物理材料データを検証"
"cmd.library_inspect": "ローカルライブラリを確認",
"cmd.library_export": "デザインライブラリを書き出す",
"cmd.library_apply": "ローカルライブラリ項目を適用",
"Thin": "薄肉",
"Thin Extrude": "薄肉押し出し",
"Wall Location": "壁の位置",
"Wall Thickness": "壁の厚さ",
"Thickness Outside": "外側の厚さ",
"Inside": "内側",
"Outside": "外側",
"Profiles/Curves": "プロファイル/曲線",
"cmd.material_properties_validate": "物理材料データを検証",
"cmd.material_properties_directional": "方向別弾性率",
"update.button": "アップデート",
"update.button_tip": "SolidCraft {version} が利用できます",
"update.title": "アップデートがあります",
"update.available": "SolidCraft {version} が利用できます",
"update.you_have": "現在のバージョンは {version} です。",
"update.no_notes": "このリリースにはリリースノートがありません。",
"update.system_hint": "このコピーは {kind} です。次のコマンドで更新します:",
"update.package_hint": "このコピーは {kind} からインストールされました。新しいパッケージをダウンロードし、次のコマンドでインストールします:",
"update.copy": "コピー",
"update.file": "ダウンロードするファイル: {file}",
"update.download": "ダウンロード",
"update.release_page": "リリースページを開く",
"update.skip": "このバージョンをスキップ",
"update.later": "後で通知",
"update.prefs_label": "アップデート",
"update.check_automatically": "アップデートを自動的に確認",
"update.check_now": "今すぐ確認",
"update.checking": "確認中…",
"update.failed": "確認できませんでした: {error}",
"update.up_to_date": "SolidCraft は最新です。",
"update.prefs_note": "これは SolidCraft {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
"update.prefs_dev": "これは開発ビルド ({version}) のため、アップデートを確認しません。",
"update.show": "{version} へのアップデートを表示",
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。SolidCraft 自身は確認しません。",
"update.update_now": "今すぐ更新",
"update.install_msi": "インストール (SolidCraft を終了)",
"update.download_open": "ダウンロードして開く",
"update.downloading": "ダウンロードと検証中… {done} / {total} MB",
"update.installed": "SolidCraft {version} をインストールしました。再起動すると使えます。",
"update.restart": "今すぐ再起動",
"update.restart_later": "後で",
"update.installer_started": "SolidCraft の終了後にインストーラーが起動します。",
"update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。",
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。",
"cmd.render_settings_get": "レンダリング設定",
"cmd.render_settings_set": "レンダリング設定を保存",
"cmd.render_scene": "レンダリングシーンを確認",
"cmd.render_settings_apply": "レンダリングカメラを適用",
"toolbar.render": "レンダリング",
"toolbar.render_scene": "シーン",
"render.preview": "統合プレビュー",
"render.preview_help": "ラスタシェーディングとブレンド透明度を使用します。屈折、HDRI、最終レンダリングはまだ利用できません。",
"render.setup_name": "セットアップ名",
"render.background": "シーンの背景",
"render.perspective": "透視投影カメラ",
"render.fit": "製品を画面に合わせる",
"render.restore_camera": "保存したカメラを復元",
"render.save_setup": "セットアップを保存",
"render.unsaved": "未保存のプレビュー変更",
"render.reload_settings": "保存した設定を再読み込み",
"render.unavailable_preview": "このセットアップは利用できないプレビュープロファイルを要求しています。",
"render.use_preview": "統合プレビューを使用",
"render.navigation_help": "ドラッグで回転、中央ボタンのドラッグで移動、スクロールでズームします。「セットアップを保存」で現在のカメラを記録します。",
"render.return_design": "設計に戻る",
"render.image_title": "画像をレンダリング",
"render.image_help": "CPUプレビュー画像 · PNG · 近似透過",
"render.image_width": "幅",
"render.image_height": "高さ",
"render.image_transparent": "透明な背景",
"render.image_check": "レンダリング要求を確認",
"render.image_supported": "レンダリング要求に対応しています。開始時にキューとメモリを再確認します。",
"render.image_queued": "レンダリング待機中",
"render.image_preparing": "画像を準備中",
"render.image_running": "画像をレンダリング中",
"render.image_encoding": "PNGをエンコード中",
"render.image_cancelling": "キャンセル中",
"render.image_cancelled": "レンダリングをキャンセルしました",
"render.image_completed": "画像をエクスポートできます",
"render.image_failed": "レンダリングに失敗しました",
"render.image_unknown": "レンダリング状態を取得できません",
"render.image_reduce": "画像を小さくするかシーンを簡略化してください。",
"render.image_cancel": "レンダリングをキャンセル",
"render.image_stale": "この画像は後続の変更前に取得したシーンを使用しています。",
"render.image_start": "レンダリングを開始",
"render.image_overwrite": "既存のファイルを置換",
"render.image_export": "PNGをエクスポート",
"cmd.render_preflight": "レンダリング要求を確認",
"cmd.render_start": "画像をレンダリング",
"cmd.render_status": "レンダリングの進行状況",
"cmd.render_cancel": "レンダリングをキャンセル",
"cmd.render_export": "PNGをエクスポート",
"toolbar.image": "画像",
"appearance.surface_response": "表面の応答",
"appearance.pbr_opaque": "不透明PBR",
"appearance.metallic": "金属度",
"appearance.roughness": "粗さ",
"appearance.dielectric_ior": "誘電体の屈折率",
"appearance.pbr_help": "この表面はレンダリング作業空間または製品PNGで確認できます。屈折や透過には対応していません。",
"render.opaque_preview_help": "不透明PBRと単色表示、アルファ合成による透明表示に対応します。屈折、HDRI、最終レンダリングにはまだ対応していません。",
"render.opaque_image_summary": "プレビューPNG · 不透明PBR · アルファ合成の透明表示",
"UNTRIM": "トリム解除",
"Face": "面",
"Holes": "穴",
"Holes (edges on them)": "穴(そのエッジ)",
"In place (surfaces)": "その場で(サーフェス)",
"None": "なし",
"Picked": "選択したもの",
"Restore outer boundary": "外側の境界を復元",
"RULED SURFACE": "ルールドサーフェス",
"Mitre": "留め継ぎ",
"Reference face": "参照面",
"Round": "丸め",
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。"
}
+210 -1
View File
@@ -1,10 +1,22 @@
{
"Send your report on GitHub": "GitHub에서 보고서 보내기",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 아래에서 GitHub 초안을 열고 클립보드에 복사된 보고서를 붙여넣으세요.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 아래에서 GitHub 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 스크린샷을 첨부하세요. 복사하여 붙여넣거나 PNG로 저장한 뒤 업로드하세요.",
"3. Click Submit new issue on GitHub to send your report.": "3. GitHub에서 Submit new issue를 클릭하여 보고서를 보내세요.",
"nav.touchpad.label": "터치패드",
"nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
"nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)",
"nav.touchpad.pan": "화면 이동 (Shift를 누르고 이동, 마우스 버튼 불필요)",
"nav.touchpad.zoom": "확대/축소 (세로 스크롤 또는 핀치)",
"nav.touchpad.scroll": "터치패드는 세로 스크롤로 확대/축소합니다. 두 손가락 이동 설정은 다른 프리셋을 위해 유지됩니다.",
"browser.body_name": "바디{number}",
"browser.component_name": "컴포넌트{number}",
"browser.parameters": "매개변수 ({count})",
"browser.precision": "정밀도: {precision}",
"browser.sketch_name": "스케치{number}",
"browser.units": "단위: {units}",
"dialog.computing": "계산 중… {seconds}초",
"dialog.custom_axis": "↖ 사용자 정의 축",
"dialog.edit_title": "{title} 편집",
"dialog.select": "↖ 선택",
@@ -37,6 +49,16 @@
"ui.animation": "애니메이션",
"ui.animation_speed": "애니메이션 속도",
"ui.anti_aliasing": "안티앨리어싱",
"ui.supersampling": "슈퍼샘플링 (SSAA)",
"ui.supersampling_off": "끔 (1×)",
"ui.supersampling_adaptive": "뷰가 멈춰 있을 때만",
"settings.supersampling_note": "뷰를 더 높은 해상도로 그린 뒤 축소합니다. 4K 미만 화면에서 모서리, 선, 그리드가 더 매끄러워집니다. GPU 시간이 듭니다: 1.5×에서 약 2.25배, 2×에서 4배의 픽셀.",
"ui.display_quality": "표시 품질",
"ui.display_quality_coarse": "거침",
"ui.display_quality_normal": "보통",
"ui.display_quality_fine": "세밀",
"ui.display_quality_ultra": "최고",
"settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.",
"ui.apply": "적용",
"ui.arc": "호",
"ui.assemble": "조립",
@@ -67,6 +89,21 @@
"ui.coincident": "일치",
"ui.collapse": "접기",
"ui.collinear": "동일 선상",
"ui.colour_vision": "색각",
"ui.colour_vision_deutan": "녹색각이상 (Deutan)",
"ui.colour_vision_deutan_tip": "녹색 감각이 약하거나 없는 경우",
"ui.colour_vision_failed": "실패",
"ui.colour_vision_fixed": "고정",
"ui.colour_vision_mode": "모드",
"ui.colour_vision_note": "의미를 나타내는 색을 바꿉니다: 스케치 상태, 축, 벤딩, 분석, 아이콘. Windows 색 필터를 사용한다면 '끄기'로 두세요.",
"ui.colour_vision_off": "끄기",
"ui.colour_vision_off_tip": "테마 고유의 색상",
"ui.colour_vision_projected": "투영",
"ui.colour_vision_protan": "적색각이상 (Protan)",
"ui.colour_vision_protan_tip": "빨간색 감각이 약하거나 없는 경우",
"ui.colour_vision_sample": "예시",
"ui.colour_vision_tritan": "청색각이상 (Tritan)",
"ui.colour_vision_tritan_tip": "파란색 감각이 약하거나 없는 경우",
"ui.combine": "결합",
"ui.coming_later": "추후 지원 예정",
"ui.concentric": "동심",
@@ -260,6 +297,10 @@
"ui.quantity": "수량",
"ui.quit": "종료",
"ui.radius": "반지름",
"ui.pull_direction": "빼기 방향",
"ui.tool_direction": "공구 방향",
"ui.reachable": "도달 가능",
"ui.blocked": "막힘",
"ui.radius_type": "반지름 유형",
"ui.rectangle": "직사각형",
"ui.rectangular_pattern": "직사각형 패턴",
@@ -271,9 +312,33 @@
"ui.revolve": "회전",
"ui.right": "우측면",
"ui.save": "저장",
"ui.heading_save_mesh": "메쉬로 저장",
"ui.format": "형식",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "면 태그가 있는 3MF",
"ui.send_to_slicer": "슬라이서로 보내기",
"ui.slicer": "슬라이서",
"ui.custom_program": "사용자 지정…",
"ui.no_slicer_found": "슬라이서를 찾을 수 없음",
"ui.send": "보내기",
"ui.custom_program_tip": "목록에 추가할 슬라이서 프로그램 선택",
"ui.slicer_delete_tip": "{program}\nDelete 키로 목록에서 제거",
"ui.save_mesh_pick_bodies": "저장할 바디를 선택하세요",
"ui.save_mesh_no_slicer": "슬라이서를 찾을 수 없습니다. '사용자 지정…'에서 슬라이서 프로그램을 선택하거나 '슬라이서로 보내기'를 끄세요",
"ui.not_a_program": "{path}은(는) 실행할 수 있는 프로그램이 아닙니다",
"ui.sent_to_slicer": "{slicer}(으)로 보냈습니다",
"ui.saved_to": "저장됨: {path}",
"ui.save_mesh_temp_failed": "{folder}을(를) 만들 수 없습니다: {error}",
"ui.save_as": "다른 이름으로 저장",
"ui.save_as_ellipsis": "다른 이름으로 저장...",
"ui.scale": "축척",
"ui.orientation": "배향",
"ui.twist": "비틀림",
"ui.end_scale": "끝 배율",
"ui.sweep_turn_with_path": "경로를 따라 회전",
"ui.sweep_keep_as_drawn": "그린 대로 유지",
"ui.sweep_follow_direction": "방향 따르기",
"ui.section_analysis": "단면 분석",
"ui.select": "선택",
"ui.selection": "선택",
@@ -361,6 +426,17 @@
"ui.pinToShortcuts": "바로 가기에 고정",
"ui.pinToToolbar": "도구 모음에 고정",
"ui.properties": "속성",
"ui.design_properties.project": "프로젝트",
"ui.design_properties.part_number": "부품 번호",
"ui.design_properties.revision": "리비전",
"ui.design_properties.author": "작성자",
"ui.design_properties.organization": "조직",
"ui.design_properties.description": "설명",
"ui.design_properties.license": "라이선스",
"ui.design_properties.not_text": "텍스트가 아닌 값으로 저장되어 있습니다. 여기에 입력하면 대체됩니다.",
"ui.design_properties.exports": "STEP, IGES, 3MF 내보내기는 형식에 해당 항목이 있으면 이 속성을 기록합니다.",
"ui.design_properties.others": "다른 도구의 데이터",
"ui.design_properties.others_note": "설계에 그대로 유지되며 내보내지지 않습니다.",
"ui.removeSection": "단면 제거",
"ui.removeFromToolbar": "도구 모음에서 제거",
"ui.repeat": "반복",
@@ -384,6 +460,9 @@
"ui.updateToCurrentView": "현재 보기로 업데이트",
"ui.zoom": "확대/축소",
"ui.command_done": "{command} 완료",
"drive.stopped_by_loop": "{value}에서 멈춤: {joint}을(를) 지나는 루프를 더 이상 닫을 수 없습니다",
"drive.stopped_by_contact": "{value}에서 멈춤: {a}이(가) {b}을(를) 통과하게 됩니다",
"drive.unconnected": "아무것도 움직이지 않습니다: {part}이(가) 고정된 것에 연결되어 있지 않습니다",
"cmd.appearance_assign": "외관",
"cmd.appearance_library": "외관 라이브러리",
"cmd.appearance_list": "외관 목록",
@@ -456,6 +535,9 @@
"cmd.doc_recovery_list": "복구 가능한 설계",
"cmd.document_inspect": "설계 검사",
"cmd.document_units": "설계 단위 변경",
"cmd.document_metadata": "설계 속성",
"cmd.document_metadata_set": "설계 속성 변경",
"cmd.document_metadata_remove": "설계 속성 제거",
"cmd.edit_checkpoint": "체크포인트",
"cmd.edit_restore_checkpoint": "체크포인트 복원",
"cmd.engine_commands": "명령 목록",
@@ -469,6 +551,7 @@
"cmd.file_insert_mesh": "메쉬 삽입",
"cmd.file_insert_step": "STEP 삽입",
"cmd.file_save_mesh": "메쉬로 저장",
"cmd.file_slicers": "설치된 슬라이서",
"cmd.inspect_accessibility": "접근성 분석",
"cmd.inspect_center_of_mass": "무게중심",
"cmd.inspect_curvature_comb": "곡률빗 분석",
@@ -495,6 +578,8 @@
"cmd.material_delete": "물리 재질 삭제",
"cmd.material_edit": "물리 재질 편집",
"cmd.material_list": "재질 목록",
"cmd.material_properties_directional": "방향별 탄성 계수",
"cmd.material_properties_validate": "물리 재질 데이터 검증",
"cmd.mesh_convert": "메쉬 변환",
"cmd.model_edges": "모서리 목록",
"cmd.model_faces": "면 목록",
@@ -515,6 +600,7 @@
"cmd.parameters_delete": "매개 변수 삭제",
"cmd.parameters_export": "매개 변수 내보내기",
"cmd.parameters_favorite": "즐겨찾는 매개 변수",
"cmd.parameters_move": "매개 변수 이동",
"cmd.parameters_graph": "매개 변수 그래프",
"cmd.parameters_import": "매개 변수 가져오기",
"cmd.parameters_list": "매개 변수 목록",
@@ -540,6 +626,7 @@
"cmd.sheet_fold": "접기",
"cmd.sheet_hem": "헴",
"cmd.sheet_manage_rules": "판금 규칙",
"cmd.sheet_perforate": "타공",
"cmd.sheet_refold": "다시 접기",
"cmd.sheet_rules": "판금 규칙 목록",
"cmd.sheet_unfold": "펼치기",
@@ -637,9 +724,11 @@
"cmd.surface_unstitch": "스티치 해제",
"cmd.surface_reverse_normal": "법선 반전",
"cmd.inspect_continuity": "연속성 분석",
"cmd.timeline_add": "기록된 특성 추가",
"cmd.timeline_compute_all": "모두 다시 계산",
"cmd.timeline_copy": "특성 복사",
"cmd.timeline_dependents": "특성 종속 항목",
"cmd.timeline_export_script": "명령 스크립트 내보내기",
"cmd.timeline_inspect": "특성 검사",
"cmd.timeline_paste": "특성 붙여넣기",
"cmd.timeline_redefine": "특성 편집",
@@ -658,9 +747,12 @@
"ui.heading_constraints": "구속 조건",
"ui.heading_utilities": "유틸리티",
"BROWSER": "브라우저",
"Bends": "벤딩",
"Drive Joint": "조인트 구동",
"Edit Profile Sketch": "윤곽 스케치 편집",
"Find in Browser": "브라우저에서 찾기",
"Copy Error Message": "오류 메시지 복사",
"Copy Warning Message": "경고 메시지 복사",
"Report a bug — review a screenshot and open a GitHub draft": "버그 신고 — 스크린샷을 검토하고 GitHub 초안 열기",
"Reset Toolbar": "도구 모음 재설정",
"Select bodies (a face picks its body)": "바디 선택(면을 클릭하면 해당 바디 선택)",
@@ -697,5 +789,122 @@
"sketch_text.align_right": "오른쪽",
"sketch_text.path_hint": "텍스트가 따라갈 스케치 곡선: 여기를 클릭한 다음 곡선을 클릭",
"sketch_text.path_clear": "곡선에서 텍스트 떼기",
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다"
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다",
"cmd.library_inspect": "로컬 라이브러리 확인",
"cmd.library_export": "설계 라이브러리 내보내기",
"cmd.library_apply": "로컬 라이브러리 항목 적용",
"Thin": "얇은 벽",
"Thin Extrude": "얇은 벽 돌출",
"Wall Location": "벽 위치",
"Wall Thickness": "벽 두께",
"Thickness Outside": "바깥쪽 두께",
"Inside": "안쪽",
"Outside": "바깥쪽",
"Profiles/Curves": "프로파일/곡선",
"update.button": "업데이트",
"update.button_tip": "SolidCraft {version} 사용 가능",
"update.title": "업데이트 사용 가능",
"update.available": "SolidCraft {version} 사용 가능",
"update.you_have": "현재 버전은 {version}입니다.",
"update.no_notes": "이 릴리스에는 릴리스 노트가 없습니다.",
"update.system_hint": "이 사본은 {kind}입니다. 다음 명령으로 업데이트하세요:",
"update.package_hint": "이 사본은 {kind}(으)로 설치되었습니다. 새 패키지를 내려받은 뒤 다음 명령으로 설치하세요:",
"update.copy": "복사",
"update.file": "내려받을 파일: {file}",
"update.download": "다운로드",
"update.release_page": "릴리스 페이지 열기",
"update.skip": "이 버전 건너뛰기",
"update.later": "나중에 알림",
"update.prefs_label": "업데이트",
"update.check_automatically": "업데이트 자동 확인",
"update.check_now": "지금 확인",
"update.checking": "확인 중…",
"update.failed": "확인할 수 없습니다: {error}",
"update.up_to_date": "SolidCraft가 최신 버전입니다.",
"update.prefs_note": "SolidCraft {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
"update.prefs_dev": "개발 빌드({version})이므로 업데이트를 확인하지 않습니다.",
"update.show": "{version} 업데이트 보기",
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. SolidCraft는 직접 확인하지 않습니다.",
"update.update_now": "지금 업데이트",
"update.install_msi": "설치 (SolidCraft 종료)",
"update.download_open": "다운로드 후 열기",
"update.downloading": "다운로드 및 확인 중… {done} / {total} MB",
"update.installed": "SolidCraft {version}이(가) 설치되었습니다. 다시 시작하면 사용할 수 있습니다.",
"update.restart": "지금 다시 시작",
"update.restart_later": "나중에",
"update.installer_started": "SolidCraft가 종료되면 설치 프로그램이 시작됩니다.",
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다.",
"cmd.render_settings_get": "렌더링 설정",
"cmd.render_settings_set": "렌더링 설정 저장",
"cmd.render_scene": "렌더링 장면 검사",
"cmd.render_settings_apply": "렌더링 카메라 적용",
"toolbar.render": "렌더링",
"toolbar.render_scene": "장면",
"render.preview": "통합 미리보기",
"render.preview_help": "래스터 셰이딩과 블렌딩 투명도를 사용합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.",
"render.setup_name": "설정 이름",
"render.background": "장면 배경",
"render.perspective": "원근 카메라",
"render.fit": "제품에 맞추기",
"render.restore_camera": "저장된 카메라 복원",
"render.save_setup": "설정 저장",
"render.unsaved": "저장하지 않은 미리보기 변경 사항",
"render.reload_settings": "저장된 설정 다시 불러오기",
"render.unavailable_preview": "이 설정은 사용할 수 없는 미리보기 프로필을 요청합니다.",
"render.use_preview": "통합 미리보기 사용",
"render.navigation_help": "드래그하여 회전하고, 가운데 버튼으로 드래그하여 이동하며, 스크롤하여 확대/축소합니다. 설정 저장은 현재 카메라를 기록합니다.",
"render.return_design": "설계로 돌아가기",
"render.image_title": "이미지 렌더링",
"render.image_help": "CPU 미리 보기 이미지 · PNG · 근사 투명도",
"render.image_width": "너비",
"render.image_height": "높이",
"render.image_transparent": "투명 배경",
"render.image_check": "렌더 요청 확인",
"render.image_supported": "지원되는 렌더 요청입니다. 시작 시 대기열과 메모리 풀을 다시 확인합니다.",
"render.image_queued": "렌더링 대기 중",
"render.image_preparing": "이미지 준비 중",
"render.image_running": "이미지 렌더링 중",
"render.image_encoding": "PNG 인코딩 중",
"render.image_cancelling": "취소 중",
"render.image_cancelled": "렌더링 취소됨",
"render.image_completed": "이미지 내보내기 준비됨",
"render.image_failed": "렌더링 실패",
"render.image_unknown": "렌더 상태를 확인할 수 없습니다",
"render.image_reduce": "더 작은 이미지나 더 단순한 장면을 사용해 보세요.",
"render.image_cancel": "렌더링 취소",
"render.image_stale": "이 이미지는 후속 변경 전에 캡처한 장면을 사용합니다.",
"render.image_start": "렌더링 시작",
"render.image_overwrite": "기존 파일 바꾸기",
"render.image_export": "PNG 내보내기",
"cmd.render_preflight": "렌더 요청 확인",
"cmd.render_start": "이미지 렌더링",
"cmd.render_status": "렌더링 진행 상황",
"cmd.render_cancel": "렌더링 취소",
"cmd.render_export": "PNG 내보내기",
"toolbar.image": "이미지",
"appearance.surface_response": "표면 반응",
"appearance.pbr_opaque": "불투명 PBR",
"appearance.metallic": "금속성",
"appearance.roughness": "거칠기",
"appearance.dielectric_ior": "유전체 굴절률",
"appearance.pbr_help": "이 표면은 렌더링 작업 공간이나 제품 PNG에서 확인하세요. 굴절과 투과는 지원하지 않습니다.",
"render.opaque_preview_help": "불투명 PBR 및 단색 표시, 블렌딩 투명도를 지원합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.",
"render.opaque_image_summary": "미리보기 PNG · 불투명 PBR · 블렌딩 투명도",
"UNTRIM": "트림 해제",
"Face": "면",
"Holes": "구멍",
"Holes (edges on them)": "구멍 (구멍의 모서리)",
"In place (surfaces)": "제자리에서 (서피스)",
"None": "없음",
"Picked": "선택한 항목",
"Restore outer boundary": "외부 경계 복원",
"RULED SURFACE": "룰드 서피스",
"Mitre": "연귀",
"Reference face": "참조 면",
"Round": "둥글게",
"Type": "유형",
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다."
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+207 -1
View File
@@ -1,4 +1,15 @@
{
"Send your report on GitHub": "在 GitHub 上发送报告",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 点击下方按钮打开 GitHub 草稿,然后粘贴已复制到剪贴板的报告。",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 点击下方按钮打开 GitHub 草稿。标题和报告将自动填入。",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 附上截图:复制并粘贴,或保存为 PNG 后上传。",
"3. Click Submit new issue on GitHub to send your report.": "3. 在 GitHub 上点击 Submit new issue 发送报告。",
"nav.touchpad.label": "触控板",
"nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
"nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)",
"nav.touchpad.pan": "平移(按住 Shift 并移动,无需鼠标按钮)",
"nav.touchpad.zoom": "缩放(垂直滚动或双指捏合)",
"nav.touchpad.scroll": "触控板使用垂直滚动进行缩放。双指平移设置将保留供其他预设使用。",
"browser.body_name": "实体{number}",
"browser.component_name": "组件{number}",
"browser.parameters": "参数 ({count})",
@@ -77,6 +88,9 @@
"cmd.doc_recovery_list": "可恢复的设计",
"cmd.document_inspect": "检查设计",
"cmd.document_units": "更改设计单位",
"cmd.document_metadata": "设计属性",
"cmd.document_metadata_set": "更改设计属性",
"cmd.document_metadata_remove": "删除设计属性",
"cmd.edit_checkpoint": "检查点",
"cmd.edit_restore_checkpoint": "恢复检查点",
"cmd.engine_commands": "列出命令",
@@ -90,6 +104,7 @@
"cmd.file_insert_mesh": "插入网格",
"cmd.file_insert_step": "插入 STEP",
"cmd.file_save_mesh": "另存为网格",
"cmd.file_slicers": "已安装的切片软件",
"cmd.inspect_accessibility": "可达性分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_curvature_comb": "曲率梳分析",
@@ -136,6 +151,7 @@
"cmd.parameters_delete": "删除参数",
"cmd.parameters_export": "导出参数",
"cmd.parameters_favorite": "收藏参数",
"cmd.parameters_move": "移动参数",
"cmd.parameters_graph": "参数关系图",
"cmd.parameters_import": "导入参数",
"cmd.parameters_list": "列出参数",
@@ -161,6 +177,7 @@
"cmd.sheet_fold": "折叠",
"cmd.sheet_hem": "卷边",
"cmd.sheet_manage_rules": "钣金规则",
"cmd.sheet_perforate": "冲孔",
"cmd.sheet_refold": "重新折弯",
"cmd.sheet_rules": "列出钣金规则",
"cmd.sheet_unfold": "展开",
@@ -259,9 +276,11 @@
"cmd.surface_unstitch": "取消缝合",
"cmd.surface_reverse_normal": "反转法向",
"cmd.inspect_continuity": "连续性分析",
"cmd.timeline_add": "添加记录的特征",
"cmd.timeline_compute_all": "全部重新計算",
"cmd.timeline_copy": "复制特征",
"cmd.timeline_dependents": "特征依存项",
"cmd.timeline_export_script": "导出命令脚本",
"cmd.timeline_inspect": "检查特征",
"cmd.timeline_paste": "粘贴特征",
"cmd.timeline_redefine": "编辑特征",
@@ -275,6 +294,7 @@
"cmd.view_list": "列出命名视图",
"cmd.view_rename": "重命名命名视图",
"cmd.view_save": "新建命名视图",
"dialog.computing": "正在计算… {seconds} 秒",
"dialog.custom_axis": "↖ 自定义轴",
"dialog.edit_title": "编辑 {title}",
"dialog.select": "↖ 选择",
@@ -308,6 +328,16 @@
"ui.animation": "动画",
"ui.animation_speed": "动画速度",
"ui.anti_aliasing": "抗锯齿",
"ui.supersampling": "超级采样 (SSAA)",
"ui.supersampling_off": "关 (1×)",
"ui.supersampling_adaptive": "仅在视图静止时",
"settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。",
"ui.display_quality": "显示质量",
"ui.display_quality_coarse": "粗糙",
"ui.display_quality_normal": "标准",
"ui.display_quality_fine": "精细",
"ui.display_quality_ultra": "极高",
"settings.display_quality_note": "级别越精细,曲面和边显示越平滑,但网格生成耗时更长。只影响视口,不影响设计或导出。",
"ui.apply": "应用",
"ui.arc": "圆弧",
"ui.assemble": "装配",
@@ -341,6 +371,21 @@
"ui.coincident": "重合",
"ui.collapse": "折叠",
"ui.collinear": "共线",
"ui.colour_vision": "色觉",
"ui.colour_vision_deutan": "绿色觉异常(Deutan)",
"ui.colour_vision_deutan_tip": "绿色感知较弱或缺失时使用",
"ui.colour_vision_failed": "失败",
"ui.colour_vision_fixed": "固定",
"ui.colour_vision_mode": "模式",
"ui.colour_vision_note": "更改表示含义的颜色:草图状态、轴、折弯、分析和图标。如果使用 Windows 颜色滤镜,请保持“关闭”。",
"ui.colour_vision_off": "关闭",
"ui.colour_vision_off_tip": "主题本身的颜色",
"ui.colour_vision_projected": "投影",
"ui.colour_vision_protan": "红色觉异常(Protan)",
"ui.colour_vision_protan_tip": "红色感知较弱或缺失时使用",
"ui.colour_vision_sample": "示例",
"ui.colour_vision_tritan": "蓝色觉异常(Tritan)",
"ui.colour_vision_tritan_tip": "蓝色感知较弱或缺失时使用",
"ui.combine": "组合",
"ui.coming_later": "即将推出",
"ui.concentric": "同心",
@@ -551,6 +596,10 @@
"ui.quit": "退出",
"ui.radial_context_menu": "环形上下文菜单",
"ui.radius": "半径",
"ui.pull_direction": "拔模方向",
"ui.tool_direction": "刀具方向",
"ui.reachable": "可到达",
"ui.blocked": "被遮挡",
"ui.radius_type": "半径类型",
"ui.rectangle": "矩形",
"ui.rectangular_pattern": "矩形阵列",
@@ -565,9 +614,33 @@
"ui.revolve": "旋转",
"ui.right": "右视",
"ui.save": "保存",
"ui.heading_save_mesh": "另存为网格",
"ui.format": "格式",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "带面标签的 3MF",
"ui.send_to_slicer": "发送到切片软件",
"ui.slicer": "切片软件",
"ui.custom_program": "自定义…",
"ui.no_slicer_found": "未找到切片软件",
"ui.send": "发送",
"ui.custom_program_tip": "选择要添加到列表的切片软件程序",
"ui.slicer_delete_tip": "{program}\n按 Delete 键从列表中移除",
"ui.save_mesh_pick_bodies": "请选择要保存的实体",
"ui.save_mesh_no_slicer": "未找到切片软件:请选择“自定义…”并指定切片软件程序,或关闭“发送到切片软件”",
"ui.not_a_program": "{path} 不是可以启动的程序",
"ui.sent_to_slicer": "已发送到 {slicer}",
"ui.saved_to": "已保存:{path}",
"ui.save_mesh_temp_failed": "无法创建 {folder}:{error}",
"ui.save_as": "另存为",
"ui.save_as_ellipsis": "另存为...",
"ui.scale": "缩放",
"ui.orientation": "定向",
"ui.twist": "扭转",
"ui.end_scale": "末端缩放",
"ui.sweep_turn_with_path": "随路径转动",
"ui.sweep_keep_as_drawn": "保持绘制方向",
"ui.sweep_follow_direction": "跟随方向",
"ui.section_analysis": "截面分析",
"ui.select": "选择",
"ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。",
@@ -719,6 +792,8 @@
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "许多 CAD 程序的文件以 Y 为上轴,因此在此处打开时会平躺(Z 朝上)。",
"Filter": "筛选",
"Find in Browser": "在浏览器中查找",
"Copy Error Message": "复制错误消息",
"Copy Warning Message": "复制警告消息",
"Flat size": "平板尺寸",
"Follow camera (Look At sketches)": "跟随相机(注视草图)",
"Font": "字体",
@@ -907,6 +982,17 @@
"ui.pinToShortcuts": "固定到快捷方式",
"ui.pinToToolbar": "固定到工具栏",
"ui.properties": "属性",
"ui.design_properties.project": "项目",
"ui.design_properties.part_number": "零件号",
"ui.design_properties.revision": "版本",
"ui.design_properties.author": "作者",
"ui.design_properties.organization": "组织",
"ui.design_properties.description": "描述",
"ui.design_properties.license": "许可证",
"ui.design_properties.not_text": "已保存为非文本值;在此输入将替换它。",
"ui.design_properties.exports": "导出 STEP、IGES 和 3MF 时,若格式有对应字段,会写入这些属性。",
"ui.design_properties.others": "其他工具的数据",
"ui.design_properties.others_note": "按原样保存在设计中,从不导出。",
"ui.removeSection": "移除剖面",
"ui.removeFromToolbar": "从工具栏移除",
"ui.repeat": "重复",
@@ -930,6 +1016,9 @@
"ui.updateToCurrentView": "更新为当前视图",
"ui.zoom": "缩放",
"ui.command_done": "{command} 完成",
"drive.stopped_by_loop": "停在 {value}:经过 {joint} 的环路无法再闭合",
"drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}",
"drive.unconnected": "没有任何移动:{part} 未连接到任何固定的零部件",
"timeline.group": "新建特征文件夹",
"timeline.ungroup": "移除特征文件夹",
"timeline.rename_group": "重命名特征文件夹",
@@ -957,5 +1046,122 @@
"sketch_text.on_path": "文字沿路径排列:由曲线放置",
"ui.analysis": "分析",
"ui.edit_section_analysis": "编辑截面分析",
"cmd.material_properties_validate":"验证物理材料数据"
"cmd.library_inspect": "检查本地库",
"cmd.library_export": "导出设计库",
"cmd.library_apply": "应用本地库条目",
"Thin": "薄壁",
"Thin Extrude": "薄壁拉伸",
"Wall Location": "壁位置",
"Wall Thickness": "壁厚度",
"Thickness Outside": "外侧厚度",
"Inside": "内侧",
"Outside": "外侧",
"Profiles/Curves": "轮廓/曲线",
"cmd.material_properties_validate": "验证物理材料数据",
"cmd.material_properties_directional": "方向弹性模量",
"update.button": "更新",
"update.button_tip": "SolidCraft {version} 已发布",
"update.title": "有可用更新",
"update.available": "SolidCraft {version} 已发布",
"update.you_have": "当前版本为 {version}。",
"update.no_notes": "此版本没有发布说明。",
"update.system_hint": "此副本为 {kind}。用以下命令更新:",
"update.package_hint": "此副本来自 {kind}。下载新软件包后用以下命令安装:",
"update.copy": "复制",
"update.file": "将下载 {file}。",
"update.download": "下载",
"update.release_page": "打开发布页面",
"update.skip": "跳过此版本",
"update.later": "稍后提醒",
"update.prefs_label": "更新",
"update.check_automatically": "自动检查更新",
"update.check_now": "立即检查",
"update.checking": "正在检查…",
"update.failed": "无法检查: {error}",
"update.up_to_date": "SolidCraft 已是最新版本。",
"update.prefs_note": "当前为 SolidCraft {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
"update.prefs_dev": "这是开发版本({version}),不检查更新。",
"update.show": "显示 {version} 更新",
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。SolidCraft 不会自行检查。",
"update.update_now": "立即更新",
"update.install_msi": "安装(将关闭 SolidCraft)",
"update.download_open": "下载并打开",
"update.downloading": "正在下载并校验… {done} / {total} MB",
"update.installed": "SolidCraft {version} 已安装。重新启动后生效。",
"update.restart": "立即重启",
"update.restart_later": "稍后",
"update.installer_started": "SolidCraft 关闭后将启动安装程序。",
"update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。",
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。",
"cmd.render_settings_get": "渲染设置",
"cmd.render_settings_set": "保存渲染设置",
"cmd.render_scene": "检查渲染场景",
"cmd.render_settings_apply": "应用渲染相机",
"toolbar.render": "渲染",
"toolbar.render_scene": "场景",
"render.preview": "集成预览",
"render.preview_help": "光栅着色和混合透明度。暂不支持折射、HDRI 和最终渲染。",
"render.setup_name": "设置名称",
"render.background": "场景背景",
"render.perspective": "透视相机",
"render.fit": "适配产品视图",
"render.restore_camera": "恢复已保存相机",
"render.save_setup": "保存设置",
"render.unsaved": "未保存的预览更改",
"render.reload_settings": "重新加载已保存设置",
"render.unavailable_preview": "此设置请求了不可用的预览配置。",
"render.use_preview": "使用集成预览",
"render.navigation_help": "拖动以旋转,按住中键拖动以平移,滚动以缩放。保存设置会记录当前相机。",
"render.return_design": "返回设计",
"render.image_title": "渲染图像",
"render.image_help": "CPU 预览图像 · PNG · 近似透明度",
"render.image_width": "宽度",
"render.image_height": "高度",
"render.image_transparent": "透明背景",
"render.image_check": "检查渲染请求",
"render.image_supported": "支持此渲染请求;开始时将再次检查队列和内存池。",
"render.image_queued": "等待渲染",
"render.image_preparing": "正在准备图像",
"render.image_running": "正在渲染图像",
"render.image_encoding": "正在编码 PNG",
"render.image_cancelling": "正在取消",
"render.image_cancelled": "渲染已取消",
"render.image_completed": "图像可导出",
"render.image_failed": "渲染失败",
"render.image_unknown": "渲染状态不可用",
"render.image_reduce": "请尝试更小的图像或更简单的场景。",
"render.image_cancel": "取消渲染",
"render.image_stale": "此图像使用后续更改之前捕获的场景。",
"render.image_start": "开始渲染",
"render.image_overwrite": "替换现有文件",
"render.image_export": "导出 PNG",
"cmd.render_preflight": "检查渲染请求",
"cmd.render_start": "渲染图像",
"cmd.render_status": "渲染进度",
"cmd.render_cancel": "取消渲染",
"cmd.render_export": "导出 PNG",
"toolbar.image": "图像",
"appearance.surface_response": "表面响应",
"appearance.pbr_opaque": "不透明 PBR",
"appearance.metallic": "金属度",
"appearance.roughness": "粗糙度",
"appearance.dielectric_ior": "电介质折射率",
"appearance.pbr_help": "在渲染工作区或产品 PNG 中查看此表面。不支持折射或透射。",
"render.opaque_preview_help": "不透明 PBR 和纯色;透明效果使用混合。不支持折射、HDRI 和最终渲染。",
"render.opaque_image_summary": "预览 PNG · 不透明 PBR · 混合透明",
"UNTRIM": "取消修剪",
"Face": "面",
"Holes": "孔",
"Holes (edges on them)": "孔(孔上的边)",
"In place (surfaces)": "原位(曲面)",
"None": "无",
"Picked": "所选",
"Restore outer boundary": "恢复外边界",
"RULED SURFACE": "直纹曲面",
"Mitre": "斜接",
"Reference face": "参考面",
"Round": "圆角",
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。"
}
+24 -7
View File
@@ -10,6 +10,10 @@ struct U {
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
acc_v: vec4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
// blocked; curvature flat, middle, tight
ana_c: array<vec4<f32>, 8>,
ana_f: vec4<f32>, // x = 1: stripe the negative draft and blocked faces
};
@group(1) @binding(2) var acc_t: texture_2d<f32>;
@@ -150,8 +154,8 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
// Slack of a pixel and a half, more on slopes (the map holds the pixel's highest point).
let slack = u.ana.y * (1.0 + sqrt(max(1.0 - cn * cn, 0.0)) / max(cn, 0.05));
let clear = dot(i.wp, d) >= top - slack;
var c = vec3<f32>(0.85, 0.22, 0.20);
if (facing && clear) { c = vec3<f32>(0.20, 0.75, 0.30); }
var c = u.ana_c[4].rgb;
if (facing && clear) { c = u.ana_c[3].rgb; } else { c = stripe(i, c); }
return out_color(vec4<f32>(c * lit, 1.0));
}
if (u.ana.x > 3.5) {
@@ -171,24 +175,37 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
}
if (u.ana.x < 2.5) {
let a = degrees(asin(clamp(dot(normalize(i.n), normalize(u.pull.xyz)), -1.0, 1.0)));
var c = vec3<f32>(0.95, 0.80, 0.15);
if (a >= u.ana.y) { c = vec3<f32>(0.20, 0.75, 0.30); }
if (a <= -u.ana.y) { c = vec3<f32>(0.85, 0.22, 0.20); }
var c = u.ana_c[1].rgb;
if (a >= u.ana.y) { c = u.ana_c[0].rgb; }
if (a <= -u.ana.y) { c = stripe(i, u.ana_c[2].rgb); }
return out_color(vec4<f32>(c * lit, 1.0));
}
// Curvature from how fast the normal turns across the pixel.
let t = clamp(k * u.ana.y, 0.0, 1.0);
let c = select(mix(vec3<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.0 - 1.0),
mix(vec3<f32>(0.15, 0.35, 0.95), vec3<f32>(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
let c = select(mix(u.ana_c[6].rgb, u.ana_c[7].rgb, t * 2.0 - 1.0), mix(u.ana_c[5].rgb, u.ana_c[6].rgb, t * 2.0), t < 0.5);
return out_color(vec4<f32>(c * lit, 1.0));
}
/// A "bad" analysis face (negative draft, blocked): with the stripes on (colour vision modes), the
/// section cap's 45 degree hatch over it, so colour is not the only cue.
fn stripe(i: TOut, c: vec3<f32>) -> vec3<f32> {
if (u.ana_f.x < 0.5) { return c; }
let h = fract((i.pos.x + i.pos.y) / 9.0);
return select(c, c * 0.6, h < 0.22);
}
@fragment
fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return shade(i);
}
/// Reference planes: shaded like `fs_tri`, but never cut by the section plane.
@fragment
fn fs_tri_all(i: TOut) -> @location(0) vec4<f32> {
return shade(i);
}
fn shade(i: TOut) -> vec4<f32> {
// A zero normal marks flat colour (selection fills, translucent planes).
if (length(i.n) < 0.5) {
+98 -1
View File
@@ -1,5 +1,5 @@
//! Original light and dark palette values. Compiled by the UI theme boundary.
use crate::theme::Tokens;
use crate::theme::{AnalysisColors, IconColors, Tokens};
use egui::Color32;
impl Tokens {
pub const fn light() -> Tokens {
@@ -75,12 +75,55 @@ impl Tokens {
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
rubber_band: Color32::from_rgb(30, 120, 230),
app_bar_text: Color32::from_rgb(230, 234, 242),
app_bar_hover: Color32::from_rgba_unmultiplied_const(255, 255, 255, 28),
timeline_item: Color32::WHITE,
timeline_item_error: Color32::from_rgb(250, 220, 220),
timeline_marker: Color32::from_rgb(70, 76, 88),
field: Color32::WHITE,
box_window: Color32::from_rgb(38, 120, 218),
box_crossing: Color32::from_rgb(40, 160, 80),
// Equal to `view::colors` (the engine's tags), so mapping a tag to its token
// changes nothing.
sketch_projected: Color32::from_rgb(150, 60, 190),
sketch_lost: Color32::from_rgb(200, 60, 90),
sketch_construction: Color32::from_rgb(230, 130, 40),
sketch_centerline: Color32::from_rgb(200, 110, 60),
triad: [Color32::from_rgb(220, 60, 60), Color32::from_rgb(60, 170, 60), Color32::from_rgb(60, 100, 230)],
gizmo: [Color32::from_rgb(225, 70, 60), Color32::from_rgb(80, 190, 80), Color32::from_rgb(70, 130, 240)],
bend_up: Color32::from_rgb(30, 140, 50),
bend_down: Color32::from_rgb(200, 90, 20),
interference: Color32::from_rgb(235, 45, 40),
analysis: AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]],
hatch: false,
},
icons: IconColors {
sheet: Color32::from_rgb(170, 200, 240),
sheet_2: Color32::from_rgb(150, 185, 235),
sheet_light: Color32::from_rgb(214, 229, 248),
sheet_line: Color32::from_rgb(127, 166, 218),
highlight: Color32::WHITE,
glint: Color32::from_rgba_premultiplied(160, 160, 160, 160),
folder: Color32::from_rgb(236, 200, 110),
plane: Color32::from_rgb(244, 214, 150),
fold: Color32::from_rgb(130, 140, 160),
stripe: Color32::from_gray(30),
sky: Color32::from_rgb(190, 205, 230),
floor: Color32::from_rgb(60, 62, 70),
paint: [Color32::from_rgb(220, 70, 60), Color32::from_rgb(70, 150, 230), Color32::from_rgb(240, 200, 60)],
axes: [Color32::from_rgb(210, 60, 60), Color32::from_rgb(60, 160, 60), Color32::from_rgb(60, 100, 220)],
access: [Color32::from_rgb(70, 180, 90), Color32::from_rgb(220, 70, 60)],
draft: [Color32::from_rgb(70, 180, 90), Color32::from_rgb(235, 200, 50)],
curvature: [Color32::from_rgb(60, 110, 230), Color32::from_rgb(70, 190, 90), Color32::from_rgb(225, 60, 45)],
interference: Color32::from_rgb(225, 70, 60),
warning: Color32::from_rgb(250, 200, 60),
agent: Color32::from_rgb(168, 85, 247),
marker_halo: Color32::from_black_alpha(150),
},
app_bar_icon: [Color32::from_rgb(225, 230, 238), Color32::from_rgb(120, 140, 170), Color32::from_rgb(120, 190, 255)],
logo: [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197), Color32::WHITE],
}
}
pub const fn dark() -> Tokens {
@@ -133,6 +176,60 @@ impl Tokens {
t.field = Color32::from_rgb(30, 34, 41);
t.box_window = Color32::from_rgb(70, 150, 255);
t.box_crossing = Color32::from_rgb(80, 200, 120);
t.bend_up = Color32::from_rgb(110, 200, 120);
t.bend_down = Color32::from_rgb(240, 150, 80);
t
}
/// Colour vision, red-green (Protan and Deutan share it, #778): values of the theme's colours
/// that a weak red or green sense still tells apart. Each was measured against normal vision,
/// protan and deutan, with two simulations of each loss (`colour_vision_tests`).
pub(super) const fn red_green(self, dark: bool) -> Tokens {
let mut t = self;
let positive = [0.20, 0.45, 0.90];
let negative = [0.85, 0.40, 0.05];
t.triad[0] = Color32::from_rgb(200, 40, 40);
t.triad[1] = Color32::from_rgb(120, 210, 80);
t.gizmo[1] = Color32::from_rgb(140, 220, 90);
t.preview_sketch = Color32::from_rgb(80, 185, 145);
t.analysis.draft = [positive, [0.95, 0.90, 0.60], negative];
t.analysis.access = [positive, negative];
t.analysis.hatch = true;
// The icons that show the same meanings follow; on the dark toolbar the blue is a little
// stronger, to stay clear of the icon fill.
let good = if dark { Color32::from_rgb(35, 115, 245) } else { Color32::from_rgb(51, 115, 230) };
t.icons.axes[0] = t.triad[0];
t.icons.axes[1] = t.triad[1];
t.icons.access = [good, Color32::from_rgb(217, 102, 13)];
t.icons.draft = [good, Color32::from_rgb(242, 230, 153)];
if dark {
t.bend_up = Color32::from_rgb(110, 165, 255);
t.bend_down = Color32::from_rgb(240, 180, 70);
t.sketch_accent = Color32::from_rgb(150, 225, 195);
} else {
t.sketch_projected = Color32::from_rgb(205, 120, 215);
t.sketch_locked = Color32::from_rgb(60, 150, 120);
t.timeline_item_error = Color32::from_rgb(232, 196, 170);
t.bend_up = Color32::from_rgb(30, 100, 210);
t.bend_down = Color32::from_rgb(200, 120, 0);
t.sketch_accent = Color32::from_rgb(60, 150, 105);
}
t
}
/// Colour vision, blue-yellow (Tritan, #778), measured against normal vision and tritan.
pub(super) const fn blue_yellow(self, dark: bool) -> Tokens {
let mut t = self;
t.analysis.hatch = true;
t.preview_sketch = Color32::from_rgb(125, 180, 105);
if dark {
t.sketch_locked = Color32::from_rgb(140, 225, 110);
t.box_crossing = Color32::from_rgb(240, 110, 170);
t.glyph_text = Color32::from_rgb(240, 180, 165);
} else {
t.sketch_locked = Color32::from_rgb(20, 165, 70);
t.box_crossing = Color32::from_rgb(200, 60, 120);
}
t
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "solidcraft-derive"
description = "SolidCraft derive macros: the walk over a feature's geometry, from role attributes on its fields"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
proc-macro = true
[lints]
workspace = true
[dependencies]
proc-macro2 = { workspace = true }
quote = { workspace = true }
syn = { workspace = true }
+294
View File
@@ -0,0 +1,294 @@
//! `#[derive(Walk)]`: the walks over a feature (`crate::walk::Walk`), generated from the fields of
//! a struct or an enum's variants. Each walk visits the fields in declaration order, and comes in
//! `&` and `&mut`:
//!
//! - expressions (`walk_exprs`): each with its label, kind and role; the inputs' order names their
//! parameters (`dN`);
//! - references (`walk_refs`): sketches by id, features and bodies by name;
//! - geometry (`walk_geometry`): points with their roles, planes, construction references,
//! placement points and profile selections.
//!
//! What a field is, is written once, on the field, with `#[walk(...)]`:
//!
//! - no attribute: the field's own type walks itself (`impl Walk`): a plane, a nested struct, a
//! list of them, or nothing for names, flags and numbers. A bare `Vec3` has no walk, so a point
//! that does not say its role does not compile;
//! - `length = label`, `angle = label`, `unitless = label`: an expression input (a `String`, an
//! optional one, a list or an array of them, or `(f64, String)` pairs). The label is any
//! expression: a `&'static str`, an array of them (one per element), or a closure
//! `|i: usize, n: usize| -> &'static str`; it may read the other fields;
//! - `sketch`, `feature`, `body`: a reference (an id, or a name, optional or a list);
//! - `pick`, `pinned`, `pos`, `dir`: a point (`Vec3`, optional or a list) with that role;
//! `pick_if = cond`: a pick when `cond` holds, else a position;
//! - `placement`: a primitive's placement point (its X, Y and Z expressions are not inputs);
//! - `profiles_of = sketch_field`, optionally with `when = cond`: a profile selection;
//! - `after = field`: visit this field right after `field` instead of in declaration order (where
//! the parameter names already given need it);
//! - `picks_faces`, on a nested value: its positions pick faces here (a `FaceAt` that names the
//! face it is on).
//!
//! Conditions and labels are worked out before the walk starts. The generated code names
//! `crate::walk`, so the derive is for `solidcraft-doc`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use proc_macro::TokenStream;
use proc_macro2::{Span, TokenStream as Tokens};
use quote::{format_ident, quote};
use syn::{Data, DeriveInput, Expr, Fields, Ident, Member, parse_macro_input};
#[proc_macro_derive(Walk, attributes(walk))]
pub fn derive_walk(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match expand(&input) {
Ok(t) => t.into(),
Err(e) => e.to_compile_error().into(),
}
}
/// A field's role in the geometry walk.
enum Geo {
Points(&'static str),
PickIf(Expr),
Placement,
Profiles { sketch: Ident, when: Option<Expr> },
}
/// What a field is, from its `#[walk(...)]` attributes.
#[derive(Default)]
struct Roles {
geo: Option<Geo>,
/// The kind (`Length`, `Angle`, `Unitless`) and the label.
expr: Option<(&'static str, Expr)>,
/// `Sketch`, `Feature` or `Body`.
reference: Option<&'static str>,
after: Option<Ident>,
/// The positions inside this (nested) value pick faces here.
picks_faces: bool,
}
impl Roles {
/// No role given: the field's type walks itself.
fn nested(&self) -> bool {
self.geo.is_none() && self.expr.is_none() && self.reference.is_none()
}
}
fn roles(field: &syn::Field) -> syn::Result<Roles> {
let mut r = Roles::default();
for attr in field.attrs.iter().filter(|a| a.path().is_ident("walk")) {
let mut sketch = None;
let mut when = None;
attr.parse_nested_meta(|meta| {
let name = meta.path.get_ident().map(Ident::to_string).unwrap_or_default();
match name.as_str() {
"pick" => r.geo = Some(Geo::Points("Pick")),
"pinned" => r.geo = Some(Geo::Points("Pinned")),
"pos" => r.geo = Some(Geo::Points("Pos")),
"dir" => r.geo = Some(Geo::Points("Dir")),
"placement" => r.geo = Some(Geo::Placement),
"pick_if" => r.geo = Some(Geo::PickIf(meta.value()?.parse()?)),
"profiles_of" => sketch = Some(meta.value()?.parse::<Ident>()?),
"when" => when = Some(meta.value()?.parse::<Expr>()?),
"length" => r.expr = Some(("Length", meta.value()?.parse()?)),
"angle" => r.expr = Some(("Angle", meta.value()?.parse()?)),
"unitless" => r.expr = Some(("Unitless", meta.value()?.parse()?)),
"sketch" => r.reference = Some("Sketch"),
"feature" => r.reference = Some("Feature"),
"body" => r.reference = Some("Body"),
"after" => r.after = Some(meta.value()?.parse()?),
"picks_faces" => r.picks_faces = true,
_ => return Err(meta.error("unknown walk role")),
}
Ok(())
})?;
if let Some(sketch) = sketch {
r.geo = Some(Geo::Profiles { sketch, when });
} else if when.is_some() {
return Err(syn::Error::new_spanned(attr, "`when` goes with `profiles_of`"));
}
}
Ok(r)
}
#[derive(Clone, Copy, PartialEq)]
enum Which {
Exprs,
Refs,
Geometry,
}
/// A value worked out before the walk (a condition, a label, a sketch id) for field `b`.
fn pre(b: &Ident, what: &str) -> Ident {
format_ident!("__{}_{}", what, b)
}
/// The statements that walk field `b` in walk `which`.
fn walk_field(b: &Ident, r: &Roles, which: Which, mutable: bool) -> Tokens {
let w = quote!(crate::walk);
let each = if mutable { quote!(each_mut) } else { quote!(each) };
if r.nested() {
let m = match which {
Which::Exprs => "walk_exprs",
Which::Refs => "walk_refs",
Which::Geometry => "walk_geometry",
};
let m = if mutable { format_ident!("{}_mut", m) } else { format_ident!("{}", m) };
if r.picks_faces && which == Which::Geometry {
return quote! {
#w::Walk::#m(#b, &mut |g| f(match g { #w::Geo::Pos(p) => #w::Geo::Pick(p), g => g }));
};
}
return quote! { #w::Walk::#m(#b, f); };
}
match which {
Which::Exprs => {
let label = pre(b, "label");
match (&r.expr, &r.geo) {
(Some((kind, _)), _) => {
let kind = Ident::new(kind, Span::call_site());
quote! {
#w::ExprField::#each(#b, &mut |i, n, e| f((#w::Label::at(&#label, i, n), e, #w::Kind::#kind, #w::ExprRole::Input)));
}
}
(None, Some(Geo::Placement)) => quote! {
#w::ExprField::#each(#b, &mut |i, n, e| f((#w::Label::at(&["X", "Y", "Z"], i, n), e, #w::Kind::Length, #w::ExprRole::Placement)));
},
_ => quote!(),
}
}
Which::Refs => match r.reference {
Some(kind) => {
let t = if kind == "Sketch" { quote!(u64) } else { quote!(String) };
let kind = Ident::new(kind, Span::call_site());
quote! { #w::Each::<#t>::#each(#b, &mut |x| f(#w::Ref::#kind(x))); }
}
None => quote!(),
},
Which::Geometry => match &r.geo {
Some(Geo::Points(v)) => {
let v = Ident::new(v, Span::call_site());
quote! { #w::Each::<#w::Vec3>::#each(#b, &mut |p| f(#w::Geo::#v(p))); }
}
Some(Geo::PickIf(_)) => {
let pick = pre(b, "pick");
quote! {
#w::Each::<#w::Vec3>::#each(#b, &mut |p| f(if #pick { #w::Geo::Pick(p) } else { #w::Geo::Pos(p) }));
}
}
Some(Geo::Placement) => quote! { f(#w::Geo::Placement(#b)); },
Some(Geo::Profiles { .. }) => {
let (id, when) = (pre(b, "sketch"), pre(b, "when"));
quote! {
if #when && let Some(id) = #id {
#w::Each::<#w::ProfileSel>::#each(#b, &mut |sel| f(#w::Geo::Profiles(id, sel)));
}
}
}
None => quote!(),
},
}
}
/// The values field `b`'s walk needs, worked out before the walk starts (the walk may hand out
/// the fields they read).
fn pre_values(b: &Ident, r: &Roles, which: Which) -> Tokens {
let w = quote!(crate::walk);
match (which, &r.expr, &r.geo) {
(Which::Exprs, Some((_, label)), _) => {
let l = pre(b, "label");
quote! { let #l = #label; }
}
(Which::Geometry, _, Some(Geo::PickIf(cond))) => {
let p = pre(b, "pick");
quote! { let #p: bool = #cond; }
}
(Which::Geometry, _, Some(Geo::Profiles { sketch, when })) => {
let (id, wh) = (pre(b, "sketch"), pre(b, "when"));
let when = when.as_ref().map_or(quote!(true), |c| quote!(#c));
quote! {
let #wh: bool = #when;
let mut #id: Option<u64> = None;
#w::Each::<u64>::each(&*#sketch, &mut |s| #id = Some(*s));
}
}
_ => quote!(),
}
}
/// A pattern that binds every field of `fields` by name (`_0`, `_1`… for tuple fields), and the
/// body of walk `which` over them.
fn arm(fields: &Fields, which: Which, mutable: bool) -> syn::Result<(Tokens, Tokens)> {
let mut members = Vec::new();
let mut walked: Vec<(Ident, Roles)> = Vec::new();
for (i, field) in fields.iter().enumerate() {
let b = field.ident.clone().unwrap_or_else(|| format_ident!("_{}", i));
members.push(field.ident.clone().map_or_else(|| Member::Unnamed(i.into()), Member::Named));
walked.push((b, roles(field)?));
}
let bindings: Vec<Ident> = walked.iter().map(|(b, _)| b.clone()).collect();
// `after = x` moves a field to just after `x`.
for b in &bindings {
let Some(pos) = walked.iter().position(|(x, _)| x == b) else { continue };
let Some(after) = walked.get(pos).and_then(|(_, r)| r.after.clone()) else { continue };
let moved = walked.remove(pos);
let Some(at) = walked.iter().position(|(x, _)| *x == after) else {
return Err(syn::Error::new_spanned(after, "no such field"));
};
walked.insert(at + 1, moved);
}
let mut body = Tokens::new();
for (b, r) in &walked {
body.extend(pre_values(b, r, which));
}
for (b, r) in &walked {
body.extend(walk_field(b, r, which, mutable));
}
let pattern = match fields {
Fields::Named(_) => quote!({ #(#bindings),* }),
Fields::Unnamed(_) => quote!({ #(#members: #bindings),* }),
Fields::Unit => quote!(),
};
Ok((pattern, body))
}
fn expand(input: &DeriveInput) -> syn::Result<Tokens> {
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let w = quote!(crate::walk);
let mut methods = Tokens::new();
for (which, method, item) in
[(Which::Exprs, "walk_exprs", "ExprItem"), (Which::Refs, "walk_refs", "RefItem"), (Which::Geometry, "walk_geometry", "GeoItem")]
{
for mutable in [false, true] {
let body = match &input.data {
Data::Struct(s) => {
let (pattern, body) = arm(&s.fields, which, mutable)?;
quote! { let Self #pattern = self; #body }
}
Data::Enum(e) => {
let mut arms = Tokens::new();
for v in &e.variants {
let ident = &v.ident;
let (pattern, body) = arm(&v.fields, which, mutable)?;
arms.extend(quote! { Self::#ident #pattern => { #body } });
}
quote! { match self { #arms } }
}
Data::Union(u) => return Err(syn::Error::new_spanned(u.union_token, "unions are not walked")),
};
let (method, item, r) = if mutable {
(format_ident!("{}_mut", method), format_ident!("{}Mut", item), quote!(&'a mut))
} else {
(format_ident!("{}", method), format_ident!("{}", item), quote!(&'a))
};
methods.extend(quote! {
#[allow(unused_variables, unused_mut, clippy::needless_borrow)]
fn #method<'a>(#r self, f: &mut dyn FnMut(#w::#item<'a>)) { #body }
});
}
}
Ok(quote! {
impl #impl_generics #w::Walk for #name #ty_generics #where_clause { #methods }
})
}
+2
View File
@@ -12,6 +12,8 @@ workspace = true
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
solidcraft-render = { workspace = true }
solidcraft-derive = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+40 -6
View File
@@ -12,11 +12,34 @@ use crate::Document;
/// A look: a name (a library entry's, or "Custom"), sRGB colour and opacity (1 = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "LookWire")]
pub struct Look {
pub name: String,
pub color: [u8; 3],
#[serde(default = "opaque")]
pub opacity: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pbr: Option<crate::pbr_appearance::PbrParameters>,
}
#[derive(Deserialize)]
struct LookWire {
name: String,
color: [u8; 3],
#[serde(default = "opaque")]
opacity: f64,
#[serde(default)]
pbr: Option<crate::pbr_appearance::PbrParameters>,
}
impl TryFrom<LookWire> for Look {
type Error = String;
fn try_from(value: LookWire) -> std::result::Result<Self, Self::Error> {
if value.pbr.is_some() && value.opacity != 1.0 {
return Err("opaque PBR requires opacity 1; refraction/transmission is not supported".into());
}
Ok(Self { name: value.name, color: value.color, opacity: value.opacity, pbr: value.pbr })
}
}
fn opaque() -> f64 {
@@ -25,7 +48,13 @@ fn opaque() -> f64 {
impl Look {
pub fn custom(color: [u8; 3], opacity: f64) -> Look {
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0) }
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
}
pub fn opaque_surface(&self) -> crate::Result<Option<solidcraft_render::surface::OpaqueSurface>> {
if self.pbr.is_some() && self.opacity != 1.0 {
return Err(crate::DocError::Invalid("opaque PBR requires opacity 1".into()));
}
self.pbr.map(|p| p.surface(self.color).map_err(|e| crate::DocError::Invalid(e.to_string()))).transpose()
}
/// Colour as 0–1 fractions.
pub fn rgb(&self) -> [f32; 3] {
@@ -109,14 +138,19 @@ pub use defaults::LIBRARY;
/// A library appearance by name (case-insensitive).
pub fn library(name: &str) -> Option<Look> {
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look { name: n.to_string(), color: *c, opacity: *o })
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look {
name: n.to_string(),
color: *c,
opacity: *o,
pbr: None,
})
}
/// The look a physical material gives a body that has no appearance of its own.
pub fn material_look(material: &str) -> Option<Look> {
let color = crate::material_color(material)?;
let opacity = if material == "Glass" { 0.25 } else { 1.0 };
Some(Look { name: material.to_string(), color, opacity })
Some(Look { name: material.to_string(), color, opacity, pbr: None })
}
impl Document {
@@ -162,12 +196,12 @@ impl Document {
pub fn face_colors(&self, b: &crate::ModelBody) -> Vec<(usize, Look)> {
// Face colours the body was imported with, under the design's own.
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.
// Carried from an earlier version of the body: by face name (worked out only here:
// the viewport asks every body for its face colours on every change).
None => match &b.imported {
Some(imp) => names
Some(imp) => crate::naming::face_names(b)
.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))))
+13 -153
View File
@@ -11,7 +11,7 @@
use serde::{Deserialize, Serialize};
use solidcraft_geom::{Plane, Vec3};
use crate::{AxisRef, DocError, Document, FeatureKind, PatternKind, PlaneRef, Result};
use crate::{DocError, Document, FeatureKind, PlaneRef, Result};
/// Column-major 4×4 affine transform (`m[col][row]`).
pub type Mat = [[f64; 4]; 4];
@@ -204,157 +204,17 @@ pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
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::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 }, .. }
| FeatureKind::SurfaceRevolve { axis: AxisRef::Line { origin, dir: d }, .. } => {
pt(origin);
dir(d);
}
FeatureKind::Fillet { edges, style, .. } => {
edges.iter_mut().for_each(pt);
if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style {
pt(start);
}
}
FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::Shell { faces, .. }
| FeatureKind::OffsetFace { faces, .. }
| FeatureKind::DeleteFace { faces, .. }
| FeatureKind::SurfaceOffset { faces, .. } => faces.iter_mut().for_each(pt),
FeatureKind::Draft { faces, neutral, pull, .. } => {
faces.iter_mut().for_each(pt);
map_plane_ref(m, neutral);
dir(pull);
}
FeatureKind::Box { corner, .. } => map_point_expr(m, corner),
FeatureKind::Cylinder { base, axis, .. } => {
map_point_expr(m, base);
dir(axis);
}
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => map_point_expr(m, center),
FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => {
dir(dir1);
if let Some(d) = dir2 {
dir(d);
}
}
FeatureKind::Pattern { pattern: PatternKind::Circular { origin, axis, .. }, .. } => {
pt(origin);
dir(axis);
}
// Follows its sketch.
FeatureKind::Pattern { pattern: PatternKind::Path { .. }, .. } => {}
FeatureKind::Hole { position, direction, .. } => {
pt(position);
dir(direction);
}
FeatureKind::Thread { face, .. } => pt(face),
FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)),
FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SheetConvert { face, bends, .. } => {
pt(face);
bends.iter_mut().for_each(pt);
}
FeatureKind::SheetFold { a, b, fixed, .. } => {
pt(a);
pt(b);
if let Some(f) = fixed {
pt(f);
}
}
FeatureKind::Boss { position, direction, .. } => {
pt(position);
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);
if let Some(a) = along {
dir(a);
}
}
FeatureKind::SnapFit { position, direction, hook, .. } => {
pt(position);
dir(direction);
dir(hook);
}
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
FeatureKind::Coil { base, axis, .. } => {
map_point_expr(m, base);
dir(axis);
}
FeatureKind::ReplaceFace { faces, target, .. } => {
faces.iter_mut().for_each(pt);
map_plane_ref(m, target);
}
FeatureKind::Align { from, to, from_normal, to_normal, .. } => {
pt(from);
pt(to);
if let Some(n) = from_normal {
dir(n);
}
if let Some(n) = to_normal {
dir(n);
}
}
FeatureKind::Scale { origin, .. } => pt(origin),
FeatureKind::Move { rotate_axis, .. } => {
if let Some(a) = rotate_axis {
dir(a);
}
}
FeatureKind::Revolve { .. }
| FeatureKind::Extrude { .. }
| FeatureKind::SurfaceExtrude { .. }
| FeatureKind::SurfaceRevolve { .. }
| FeatureKind::SurfaceSweep { .. }
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::Combine { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
| FeatureKind::BoundingSolid { .. }
| FeatureKind::Pipe { .. }
| FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::Remove { .. }
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. }
| FeatureKind::MeshConvert { .. }
| FeatureKind::BodyTransfer { .. } => {}
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::Unstitch { faces, .. } | FeatureKind::ReverseNormal { faces, .. } => faces.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);
}
}
use crate::feature_geometry::Geo;
use crate::walk::Walk;
self.walk_geometry_mut(&mut |g| match g {
Geo::Pick(p) | Geo::Pos(p) => *p = apply_point(m, *p),
Geo::Dir(v) => *v = apply_vector(m, *v),
Geo::Plane(p) => map_plane_ref(m, p),
Geo::Construct(r) => map_geo_ref(m, r),
Geo::Placement(e) => map_point_expr(m, e),
// An Emboss cylinder is picked in its component's frame (`EmbossFace`).
Geo::Pinned(_) | Geo::Profiles(..) => {}
});
}
}
@@ -507,7 +367,7 @@ impl Document {
return Err(DocError::Invalid("too many occurrences".into()));
}
let id = self.next_id.max(1);
self.next_id = id + 1;
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
let cname = self.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default();
let k = self.occurrences.iter().filter(|o| o.component == component).count() + 1;
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false });
+3 -163
View File
@@ -3,83 +3,7 @@
use std::collections::BTreeMap;
use crate::{FeatureKind, PatternKind, PlaneRef};
fn plane_names(p: &mut PlaneRef, names: &BTreeMap<String, String>) {
let mut p = p;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
PlaneRef::Construction { name } => {
if let Some(n) = names.get(name) {
*name = n.clone();
}
return;
}
_ => return,
}
}
}
impl FeatureKind {
/// Point references to sketches (by id) and features (by name) at their copies.
pub fn remap_refs(&mut self, sketches: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
let sk = |id: &mut u64| {
if let Some(n) = sketches.get(id) {
*id = *n;
}
};
let feats = |list: &mut Vec<String>| {
for x in list.iter_mut() {
if let Some(n) = names.get(x) {
*x = n.clone();
}
}
};
match self {
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::SurfaceExtrude { sketch, .. }
| FeatureKind::SurfaceRevolve { sketch, .. }
| FeatureKind::Revolve { sketch, .. }
| FeatureKind::Emboss { sketch, .. }
| FeatureKind::SheetBase { sketch, .. }
| FeatureKind::SheetContour { sketch, .. }
| FeatureKind::Rib { sketch, .. } => sk(sketch),
FeatureKind::Patch { sketch: Some(s), .. } => sk(s),
FeatureKind::Sweep { sketch, path_sketch, .. } | FeatureKind::SurfaceSweep { sketch, path_sketch, .. } => {
sk(sketch);
sk(path_sketch);
}
FeatureKind::Pipe { path_sketch, .. } => sk(path_sketch),
FeatureKind::SheetFold { sketch: Some(s), .. } => sk(s),
FeatureKind::Loft { sections, .. } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)),
FeatureKind::SurfaceLoft { sections } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)),
FeatureKind::Hole { points: Some(p), .. } => sk(&mut p.sketch),
FeatureKind::Pattern { features, pattern, .. } => {
feats(features);
if let PatternKind::Path { path_sketch, .. } = pattern {
sk(path_sketch);
}
}
FeatureKind::Mirror { features, plane, .. } => {
feats(features);
plane_names(plane, names);
}
FeatureKind::Draft { neutral, .. } => plane_names(neutral, names),
FeatureKind::ReplaceFace { target, .. } => plane_names(target, names),
_ => {}
}
}
}
use crate::{FeatureKind, PlaneRef};
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> String {
@@ -246,92 +170,6 @@ fn remap_link(
}
}
impl FeatureKind {
/// Point the body names a feature acts on (targets, tools, the body it rounds…) at the
/// copies in `bodies`; names not in the map stay as they are.
pub fn remap_bodies(&mut self, bodies: &BTreeMap<String, String>) {
use FeatureKind as K;
match self {
K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies),
K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => {
mapped_name(&mut tool.body, bodies)
}
_ => {}
}
match self {
K::Extrude { targets, .. }
| K::Revolve { targets, .. }
| K::Loft { targets, .. }
| K::Sweep { targets, .. }
| K::Pipe { targets, .. }
| K::Emboss { targets, .. } => {
for n in targets {
mapped_name(n, bodies);
}
}
K::Combine { target, tools, .. } => {
mapped_name(target, bodies);
for n in tools {
mapped_name(n, bodies);
}
}
K::Pattern { bodies: list, .. }
| K::Mirror { bodies: list, .. }
| K::Scale { bodies: list, .. }
| K::BoundingSolid { bodies: list, .. }
| K::Align { bodies: list, .. }
| K::Remove { bodies: list }
| K::Stitch { bodies: list, .. }
| K::Move { bodies: list, .. }
| K::BodyTransfer { bodies: list, .. } => {
for n in list {
mapped_name(n, bodies);
}
}
K::Thicken { bodies: list, targets, .. } => {
for n in list.iter_mut().chain(targets) {
mapped_name(n, bodies);
}
}
K::BoundaryFill { tools, .. } => {
for n in tools {
mapped_name(n, bodies);
}
}
K::Fillet { body, .. }
| K::Chamfer { body, .. }
| K::Shell { body, .. }
| K::Draft { body, .. }
| K::SplitFace { body, .. }
| K::OffsetFace { body, .. }
| K::DeleteFace { body, .. }
| K::SurfaceOffset { body, .. }
| K::ReplaceFace { body, .. }
| K::SheetFlange { body, .. }
| K::SheetHem { body, .. }
| K::SheetFold { body, .. }
| K::SheetUnfold { body, .. }
| K::Boss { body, .. }
| K::Lip { body, .. }
| K::SnapFit { body, .. }
| K::Rest { body, .. }
| K::Patch { body, .. } => {
if let Some(n) = body {
mapped_name(n, bodies);
}
}
K::Split { body, .. }
| K::SheetConvert { body, .. }
| K::SurfaceExtend { body, .. }
| K::SurfaceTrim { body, .. }
| K::Unstitch { body, .. }
| K::ReverseNormal { body, .. }
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
_ => {}
}
}
}
impl crate::Feature {
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
pub fn remap_clone(
@@ -356,6 +194,7 @@ impl crate::Feature {
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => checked_plane(plane, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs() {
checked_geo(r, 0)?;
@@ -410,6 +249,7 @@ impl crate::Feature {
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, names, bodies, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, names, bodies, 0)?;
+4
View File
@@ -118,6 +118,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
let boss = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
@@ -280,6 +281,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
let boss = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
@@ -429,6 +431,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
let boss = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
@@ -553,6 +556,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
let material = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
+9 -22
View File
@@ -125,10 +125,15 @@ fn construct<'a>(st: &'a ModelState, name: &str) -> Result<&'a ConstructGeom> {
/// 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,
};
// The named face itself: its nearest point to the old pick can lie on an edge it shares
// (a corner round that a parameter moved away from the pick), where picking again by the
// point finds the flat face beside it (#1255).
if let Some(hit) = name.as_deref().and_then(|n| crate::name_lookup::locate_face(st, n, at))
&& let Some(b) = st.bodies.get(hit.body).filter(|b| !b.body.is_mesh())
&& hit.face < b.body.face_count()
{
return Ok((b, hit.face, hit.point));
}
for b in bodies(st) {
if let Some(fi) = crate::appearance::face_index_at(b, at) {
return Ok((b, fi, at));
@@ -575,15 +580,6 @@ impl GeoRef {
}
}
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 {
@@ -594,11 +590,6 @@ impl AxisDef {
}
}
/// 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],
@@ -621,10 +612,6 @@ impl PointDef {
}
}
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],
+143
View File
@@ -0,0 +1,143 @@
//! How finely the viewport tessellates curved faces and edges (Preferences ▸ Graphics ▸ Display
//! quality). A viewing preference, not design data: it only picks the tolerances of the display
//! plan ([`crate::ModelBody::display_plan`]) the viewport draws. The reference mesh the model
//! reads (picks, holes, measuring) and exports keep their own tolerances.
use std::sync::atomic::{AtomicU8, Ordering};
/// A display quality level.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum DisplayQuality {
Coarse,
#[default]
Normal,
Fine,
Ultra,
}
impl DisplayQuality {
pub const ALL: [DisplayQuality; 4] = [DisplayQuality::Coarse, DisplayQuality::Normal, DisplayQuality::Fine, DisplayQuality::Ultra];
/// The id kept in the settings file.
pub fn id(self) -> &'static str {
match self {
DisplayQuality::Coarse => "coarse",
DisplayQuality::Normal => "normal",
DisplayQuality::Fine => "fine",
DisplayQuality::Ultra => "ultra",
}
}
pub fn parse(id: &str) -> Option<DisplayQuality> {
Self::ALL.into_iter().find(|q| q.id() == id)
}
/// Position in [`DisplayQuality::ALL`] (indexes the per-level display caches).
pub fn index(self) -> usize {
self as usize
}
/// Chord tolerance as a fraction of the body's size: the largest gap allowed between a
/// facet and its surface.
pub fn chord_fraction(self) -> f64 {
match self {
DisplayQuality::Coarse => 3e-3,
DisplayQuality::Normal => 1e-3,
DisplayQuality::Fine => 3e-4,
DisplayQuality::Ultra => 1e-4,
}
}
/// Smallest chord tolerance (mm), so a tiny body does not mesh down to nothing.
pub fn min_chord(self) -> f64 {
match self {
DisplayQuality::Coarse => 3e-3,
DisplayQuality::Normal => 1e-3,
DisplayQuality::Fine => 3e-4,
DisplayQuality::Ultra => 1e-4,
}
}
/// Most a facet of a curved face or edge may turn (radians), so a small radius on a large
/// body still looks round.
pub fn angle(self) -> f64 {
let deg: f64 = match self {
DisplayQuality::Coarse => 30.0,
DisplayQuality::Normal => 20.0,
DisplayQuality::Fine => 12.0,
DisplayQuality::Ultra => 7.0,
};
deg.to_radians()
}
/// The chord tolerance (mm) for a body of this size.
pub fn chord(self, size: f64) -> f64 {
let size = if size.is_finite() && size > 0.0 { size } else { 0.0 };
(size * self.chord_fraction()).max(self.min_chord())
}
}
static CURRENT: AtomicU8 = AtomicU8::new(1);
/// The level the viewport draws at (process-wide; set from the preferences).
pub fn current() -> DisplayQuality {
DisplayQuality::ALL.get(CURRENT.load(Ordering::Relaxed) as usize).copied().unwrap_or_default()
}
/// Set the level the viewport draws at. Bodies keep a display plan per level, so switching back
/// is instant and nothing is re-evaluated.
pub fn set_current(q: DisplayQuality) {
CURRENT.store(q.index() as u8, Ordering::Relaxed);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normal_is_the_application_tolerance_and_levels_get_finer() {
let n = DisplayQuality::Normal;
assert_eq!(n.chord(200.0), 0.2);
assert_eq!(n.chord(0.5), 1e-3);
assert!((n.angle() - solidcraft_kernel::DISPLAY_ANGLE).abs() < 1e-12);
for w in DisplayQuality::ALL.windows(2) {
assert!(w[1].chord(100.0) < w[0].chord(100.0) && w[1].angle() < w[0].angle());
}
for q in DisplayQuality::ALL {
assert_eq!(DisplayQuality::parse(q.id()), Some(q));
assert_eq!(DisplayQuality::ALL[q.index()], q);
}
}
/// Each level keeps its own plan: finer levels draw a round body with more triangles and
/// closer to the surface, and the reference mesh the model reads never changes.
#[test]
fn each_level_has_its_own_plan_and_the_reference_mesh_stays() {
use solidcraft_geom::Vec3;
let body = solidcraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 20.0, 50.0).unwrap();
let mb = crate::ModelBody::new("Body1".into(), body, 1);
let reference = mb.mesh();
let mut last = 0;
for q in DisplayQuality::ALL {
let mesh = mb.plan_mesh_at(q).unwrap();
let r = |v: Vec3| (v.x * v.x + v.y * v.y).sqrt();
// The worst chord's sag on the side wall, from its triangle centres.
let sag = mesh
.triangles
.iter()
.map(|t| t.map(|i| mesh.positions[i as usize]))
.filter(|t| t.iter().all(|p| (r(*p) - 20.0).abs() < 1e-9))
.map(|[a, b, c]| 20.0 - r((a + b + c) * (1.0 / 3.0)))
.fold(0.0, f64::max);
assert!(sag <= q.chord(mb.body.size()) * 1.01, "{q:?}: sag {sag}");
assert!(mesh.triangles.len() > last, "{q:?}: {} triangles", mesh.triangles.len());
last = mesh.triangles.len();
assert!(std::sync::Arc::ptr_eq(&mesh, &mb.plan_mesh_at(q).unwrap()), "made once per level");
}
assert!(std::sync::Arc::ptr_eq(&reference, &mb.mesh()));
assert_eq!(mb.display_plan_at(DisplayQuality::Normal).unwrap().vertex_count(), {
let plan = mb.body.display_plan_angular((mb.body.size() * 1e-3).max(1e-3), solidcraft_kernel::DISPLAY_ANGLE).unwrap();
plan.vertex_count()
});
}
}
+645 -355
View File
File diff suppressed because it is too large Load Diff
+294 -37
View File
@@ -30,10 +30,10 @@ pub struct ModelBody {
mesh: Arc<OnceLock<Arc<Mesh>>>,
/// The display plan (none when the body has no plan: a mesh body, or one the planner
/// refuses), shared like the mesh. A GPU viewport draws it.
plan: Arc<OnceLock<Option<Arc<TessPlan>>>>,
plan: Arc<[OnceLock<Option<Arc<TessPlan>>>; 4]>,
/// The plan evaluated on the CPU (none without a plan), shared like the mesh: the triangles a
/// GPU viewport draws, so a highlight over a face lies exactly on it.
plan_mesh: Arc<OnceLock<Option<Arc<Mesh>>>>,
plan_mesh: Arc<[OnceLock<Option<Arc<Mesh>>>; 4]>,
/// Identity of this body's content for the evaluation cache: the key of the evaluation
/// that produced it (kept while later features pass it through unchanged).
key: u64,
@@ -61,8 +61,8 @@ impl ModelBody {
body,
feature,
mesh: Arc::new(OnceLock::new()),
plan: Arc::new(OnceLock::new()),
plan_mesh: Arc::new(OnceLock::new()),
plan: Arc::new(Default::default()),
plan_mesh: Arc::new(Default::default()),
key: 0,
names: Default::default(),
made: Default::default(),
@@ -88,11 +88,19 @@ impl ModelBody {
.clone()
}
/// The display plan of this body (the same tolerance and angle as [`ModelBody::mesh`]), made
/// once; `None` when the body has no plan (a mesh body, or one the planner refuses).
/// The display plan of this body at the viewport's display quality
/// ([`crate::display_quality::current`]; at Normal, the same tolerance and angle as
/// [`ModelBody::mesh`]), made once per level; `None` when the body has no plan (a mesh body,
/// or one the planner refuses).
pub fn display_plan(&self) -> Option<Arc<TessPlan>> {
self.display_plan_at(crate::display_quality::current())
}
/// The display plan at a given display quality, made once per level.
pub fn display_plan_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<TessPlan>> {
self.plan
.get_or_init(|| match self.body.display_plan_angular(self.display_tol(), kernel::DISPLAY_ANGLE) {
.get(q.index())?
.get_or_init(|| match self.body.display_plan_angular(q.chord(self.body.size()), q.angle()) {
Ok(plan) => Some(Arc::new(plan)),
Err(e) => {
log::debug!("{}: no display plan ({e})", self.name);
@@ -102,12 +110,20 @@ impl ModelBody {
.clone()
}
/// The display plan's mesh, evaluated on the CPU once: the same triangles a GPU viewport
/// evaluates and draws for this body. Highlights over a face drawn from the plan use it, since
/// the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and the two
/// would fight at equal depth. `None` when the body has no plan.
/// The display plan's mesh, evaluated on the CPU once per level: the same triangles a GPU
/// viewport evaluates and draws for this body. Highlights over a face drawn from the plan use
/// it, since the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and
/// the two would fight at equal depth. `None` when the body has no plan.
pub fn plan_mesh(&self) -> Option<Arc<Mesh>> {
self.plan_mesh.get_or_init(|| self.display_plan().and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new)).clone()
self.plan_mesh_at(crate::display_quality::current())
}
/// The display plan's mesh at a given display quality.
pub fn plan_mesh_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<Mesh>> {
self.plan_mesh
.get(q.index())?
.get_or_init(|| self.display_plan_at(q).and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new))
.clone()
}
/// The display mesh's chord tolerance: 1/1000 of the body's size.
@@ -276,6 +292,24 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
if len / p > 190.0 {
return Err(DocError::Invalid(format!("a modelled thread is limited to 190 turns ({:.0} mm at this pitch); use a cosmetic one", 190.0 * p)));
}
// How deep the groove cuts into the material: from the hole wall out to the major diameter,
// or from the shaft's surface in to the root. Next to nothing (a hole as wide as the thread,
// #995) is refused: the faceted cut would leave the face faceted and unthreaded.
let basic = 5.0 * h / 8.0;
let depth = if t.internal { d / 2.0 - radius } else { radius - (d / 2.0 - basic) };
if !(depth >= basic / 4.0) {
let (m, dia) = (&t.designation, 2.0 * radius);
let cut = format!("it would cut {:.2} mm of the thread's {basic:.2} mm depth", depth.max(0.0));
let alt = thread_for(dia, t.internal).map(|(n, q)| format_thread(n, q)).filter(|a| a != m);
let shown = show_mm(dia);
return Err(DocError::Invalid(if t.internal {
let alt = alt.map(|a| format!(", or thread this hole {a}")).unwrap_or_default();
format!("this {shown} mm hole is too wide for a modelled {m} thread: {cut}; drill {} mm for {m}{alt}", show_mm(d - p))
} else {
let alt = alt.map(|a| format!(", or thread this shaft {a}")).unwrap_or_default();
format!("this {shown} mm shaft is too thin for a modelled {m} thread: {cut}; make the shaft {} mm for {m}{alt}", show_mm(d))
}));
}
// (radius, half width of the groove) at its two ends: a trapezoid in the axial plane.
let (r0, w0, r1, w1) = if t.internal {
// From inside the hole wall out to the major diameter, where the bolt's crest sits
@@ -1201,6 +1235,8 @@ impl Model {
}
}
state = Arc::new(next);
// A window opening the design can show what is built so far.
watched.offer(i + 1, doc, &state);
None
}
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
@@ -1261,6 +1297,17 @@ fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
Document::eval_in(vals, e, k)
}
/// A loft builds each section from its outline alone, so a section with a hole (a ring) is
/// refused rather than lofted as if it were filled.
fn refuse_loft_holes(r: &Region2) -> Result<()> {
if r.holes.is_empty() {
return Ok(());
}
Err(DocError::Invalid(
"not supported yet: a loft section with a hole; loft the outline and the hole as two bodies and cut one from the other".into(),
))
}
pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
let ps = &ss.profiles;
if ps.is_empty() {
@@ -1771,6 +1818,26 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
(lo2, hi2)
}
/// How high (mm) a tapered tool's sides stand straight where it starts on a face it can't run
/// on into.
const TAPER_FOOT: f64 = 0.01;
/// Whether a face of the targets (all bodies when none are named) lies in the plane at height
/// `h` along `plane`'s normal.
fn face_level_at(st: &ModelState, plane: &Plane, h: f64, targets: &[String]) -> bool {
let n = plane.normal();
st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).any(|b| {
let m = b.mesh();
let tol = (b.body.size() * 1e-7).max(1e-9);
m.triangles.iter().any(|t| {
m.tri(t).is_some_and(|[a, b, c]| {
(b - a).cross(c - a).normalized().is_some_and(|normal| normal.dot(n).abs() > 1.0 - 1e-9)
&& ((a - plane.origin).dot(n) - h).abs() < tol
})
})
})
}
/// The body a face-based feature applies to: by name, or the one whose surface is nearest the
/// first point.
fn body_at(st: &ModelState, body: &Option<String>, points: &[Vec3]) -> Result<usize> {
@@ -1932,6 +1999,24 @@ pub fn edge_names_for(st: &ModelState, body: &Option<String>, pts: &[Vec3]) -> V
}
}
/// The stored pick sits on the axis of a round, not on an edge. The nearest edge within a
/// quarter of the body is that round's tangency seam, which is a different edge (#775).
fn pick_lies_on_a_round_axis(body: &Body, p: Vec3) -> bool {
let Some(cyl) = kernel::cylinder_face_at(body, p) else { return false };
let Some(axis) = cyl.axis.normalized() else { return false };
let radial = p - cyl.axis_point;
let dist = (radial - axis * radial.dot(axis)).len();
if !(cyl.radius > 1e-6 && dist < cyl.radius * 0.35) {
return false;
}
let tight = (body.size() * 2e-3).max(1e-3);
// A pick on the wall itself is the seam. Only an axial pick is the vanished corner.
match body.nearest_edge(p, cyl.radius * 2.0) {
Ok(Some((_, d))) => d > tight,
_ => true,
}
}
/// Edge reference points re-found on the body: points on an edge stay; a point that moved off
/// (an upstream edit) is carried with the body (its place in the body's bounding box then and
/// now) to an edge running the same way; failing that it goes to the nearest edge with a
@@ -2191,17 +2276,24 @@ fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Featu
/// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out
/// or pocket of its flat pattern (#560). Returns the sheet bodies it machined; none, with the
/// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then
/// refuses it).
fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<more::PrimitivePrism>)]) -> Result<Vec<String>> {
/// refuses it). Pieces the hole parts from a sheet's base are named in `warning`.
fn sheet_hole_cut(
st: &mut ModelState,
f: &Feature,
tools: &[(Traced, Option<more::PrimitivePrism>)],
warning: &mut Option<String>,
) -> Result<Vec<String>> {
if st.sheets.is_empty() {
return Ok(Vec::new());
}
let saved = (st.sheets.clone(), st.bodies.clone());
let mut handled: Vec<String> = Vec::new();
// Noted only when every tool was machined here.
let mut parted = None;
for (tool, prism) in tools {
let tool = &tool.body;
let done = match prism {
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true) {
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true, &mut parted) {
Ok(done) => done,
// Drilled along a panel (into a rim) the hole is no flat cut-out: a bore of
// that panel, when it stays inside it.
@@ -2237,6 +2329,9 @@ fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<mor
}
}
}
if let Some(note) = parted {
append_warning(warning, note);
}
Ok(handled)
}
@@ -2495,6 +2590,35 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
.collect()
}
/// The regions a thin extrude's wall covers in its sketch.
pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result<Vec<Region2>> {
if thin.curves.is_empty() || thin.curves.len() > 1000 {
return Err(DocError::Invalid("a thin extrude needs 1…1000 sketch curves".into()));
}
let t = val(vals, &thin.thickness, Kind::Length)?;
let (inside, outside) = match thin.side {
crate::ThinSide::Center => (t / 2.0, t / 2.0),
crate::ThinSide::Inside => (t, 0.0),
crate::ThinSide::Outside => (0.0, t),
crate::ThinSide::TwoSides => {
let e = thin.thickness2.as_deref().ok_or_else(|| DocError::Invalid("a two-sided thin wall needs `thickness2`".into()))?;
(t, val(vals, e, Kind::Length)?)
}
};
if !(inside >= 0.0 && outside >= 0.0 && inside + outside > 0.0) {
return Err(DocError::Invalid("the wall thickness must be positive".into()));
}
let curves = thin
.curves
.iter()
.map(|id| {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}` of {}", ss.name)))?;
Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
})
.collect::<Result<Vec<_>>>()?;
solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| DocError::Invalid(format!("thin wall: {e}")))
}
/// How far apart (mm) profiles must stay for their extruded tools to be cut as one.
const APART_GAP: f64 = 1e-3;
@@ -2508,13 +2632,16 @@ fn feature_tools_with_constructor(
) -> Result<Vec<Traced>> {
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
match &f.kind {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
let ss = sketch_in_feature_frame(
doc,
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let regions = match thin {
Some(thin) => thin_regions(vals, &ss, thin)?,
None => solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?),
};
// From / To Object: where the face or point lies along the normal, measured over the
// middle of the profiles. The points are in this feature's component frame, so the
// model is seen from there: a target in another, moved component where it is shown.
@@ -2564,13 +2691,48 @@ fn feature_tools_with_constructor(
Some(crate::lineage::extrude_cap_role(from)),
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { from - len } else { from + len })),
];
return regions
// How far a tool grows past its profile at either end, at most.
let mut growth: f64 = 0.0;
// A face of the targets in the profile's plane (the same for every profile).
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets);
let tools: Vec<Traced> = regions
.iter()
.map(|r| {
let body = kernel::extrude_tapered(&start_plane, r, len, sign, taper)?;
// An end on a face of the target runs on into it, as a prism's does: tapered
// sides standing on a face are curved where they meet it (along text, say),
// and the boolean finds no result there. The sides keep their slope, so
// the profile stays the section at the start.
let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) };
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo, hi, *operation, targets);
let (a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
// A start that can't run on (the profile hangs over the face's edge):
// the sides stand straight for a hair first, so the boolean can push the
// face the tool stands on into the body.
let foot = if a >= 0.0 && on_face { TAPER_FOOT.min((b - a) * 0.01) } else { 0.0 };
let body = kernel::extrude_tapered_between(&start_plane, r, a, b, foot, sign, taper)?;
growth = growth.max(a.abs().max(b.abs()) * taper.tan().abs());
Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None))
})
.collect();
.collect::<Result<_>>()?;
// Profiles apart by more than the tools grow (the letters of a text): the tools
// are apart too, and go into the body as one, in one boolean rather than one per
// profile, each on a body grown or cut by the ones before. (A join meeting several
// bodies keeps joining them tool by tool; sheet metal keeps cutting tool by tool.)
let sheet =
if targets.is_empty() { !st.sheets.is_empty() } else { targets.iter().any(|t| st.sheets.iter().any(|sheet| &sheet.body == t)) };
let one_target = if targets.is_empty() { st.bodies.len() == 1 } else { targets.len() == 1 };
let at_once = match operation {
Operation::Cut => !sheet,
Operation::Join => one_target && !sheet,
_ => false,
};
if at_once && tools.len() > 1 && regions_apart(&regions, APART_GAP + 2.0 * growth) {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
if let Ok(Some(one)) = kernel::join_apart(&bodies) {
return Ok(vec![Traced::step(one, &tools.iter().collect::<Vec<_>>())]);
}
}
return Ok(tools);
}
let off = from
+ match &extent.start_offset {
@@ -2825,6 +2987,7 @@ fn feature_tools_with_constructor(
let ss = st.sketch(prof.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", prof.sketch)))?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &prof.profiles)?);
let [r] = &regions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
refuse_loft_holes(r)?;
let ps = st.sketch(pt.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", pt.sketch)))?;
let id = pt.point.as_deref().unwrap_or_default();
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
@@ -2880,6 +3043,7 @@ fn feature_tools_with_constructor(
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
let [r] = &regions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
refuse_loft_holes(r)?;
secs.push((ss.plane, r.outer.clone()));
sketches.push(ss);
}
@@ -2889,7 +3053,8 @@ fn feature_tools_with_constructor(
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
Ok(vec![roles.tool(body)])
}
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => {
let ctl = sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -2907,10 +3072,10 @@ fn feature_tools_with_constructor(
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
// A path through 3D sketch curves follows them as points.
if let Some(pts) = path_points(ps, path, start)? {
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect();
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect();
}
let segs = path_segments(ps, path, start)?;
regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect()
regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect()
}
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
let ps =
@@ -2958,6 +3123,32 @@ fn feature_tools_with_constructor(
}
/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`.
/// A sweep's options from its stored orientation and its twist and end scale expressions
/// (none: no twist, scale 1). Out-of-range values are refused by the kernel.
pub(crate) fn sweep_control(
vals: &BTreeMap<String, Value>,
orientation: &crate::SweepOrientation,
twist: &Option<String>,
scale: &Option<String>,
) -> Result<kernel::SweepControl> {
let orientation = match *orientation {
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel,
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d),
};
let twist = match twist {
Some(t) => val(vals, t, Kind::Angle)?,
None => 0.0,
};
let scale = match scale {
Some(k) => val(vals, k, Kind::Unitless)?,
None => 1.0,
};
let ctl = kernel::SweepControl { orientation, twist, scale };
ctl.check()?;
Ok(ctl)
}
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
let holes = match wall {
@@ -3153,7 +3344,11 @@ pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Exten
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
(None, crate::Direction::Positive) => (0.0, d),
(None, crate::Direction::Negative) => (-d, 0.0),
(None, crate::Direction::Symmetric) => (-d, d),
(None, crate::Direction::Symmetric) => {
// Through All is a reach computed from target bodies, not an entered total.
let half = if extent.through_all { d } else { extent.convention.symmetric_half_distance(d) };
(-half, half)
}
})
}
@@ -3782,6 +3977,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetFlange { .. }
| FeatureKind::SheetHem { .. }
| FeatureKind::SheetPerforate { .. }
| FeatureKind::SheetFold { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st, warning),
@@ -3799,7 +3995,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SurfaceRevolve { .. }
| FeatureKind::SurfaceSweep { .. }
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::SurfaceOffset { .. } => surface::eval(doc, vals, f, st),
| FeatureKind::SurfaceOffset { .. }
| FeatureKind::SurfaceRuled { .. }
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st),
FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
@@ -3857,7 +4055,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
// Cuts through sheet metal become cut-outs of its flat pattern.
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref(), warning)?;
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
}
@@ -3867,7 +4065,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Hole { thread, diameter, .. } => {
let (spots, dir) = hole_spots(st, f)?;
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
if handled.is_empty() {
apply_traced(st, f, tools, Operation::Cut, &[])?;
@@ -4169,23 +4367,28 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Some((_, Err(_))) | None => {
let mut w = None;
let refs: Vec<EdgeRef> = f.edge_refs.get(i).cloned().into_iter().collect();
if let Ok(q) = resolve_edges(&mb.body, &[*p], &refs, &mut w) {
let skipped =
format!("edge `{}` no longer exists and was skipped", f.edge_names.get(i).map(String::as_str).unwrap_or("?"));
// The name is gone. A pick on the axis of a round must not fall
// through to the tangency seam (#775). A sharp edge that moved
// still resolves from the point, which an edit relies on.
if pick_lies_on_a_round_axis(&mb.body, *p) {
notes.push(skipped);
} else if let Ok(q) = resolve_edges(&mb.body, &[*p], &refs, &mut w) {
out.extend(q);
notes.push(match f.edge_names.get(i) {
Some(n) if !n.is_empty() => format!("edge `{n}` no longer exists; using the edge at the picked point"),
_ => "an edge reference without a name was found by its point".into(),
});
} else {
notes.push(format!(
"edge `{}` no longer exists and was skipped",
f.edge_names.get(i).map(String::as_str).unwrap_or("?")
));
notes.push(skipped);
}
}
}
}
if out.is_empty() {
return Err(DocError::Invalid("none of the selected edges exist any more".into()));
let why = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) };
return Err(DocError::Invalid(format!("none of the selected edges exist any more{why}")));
}
if !notes.is_empty() {
*warning = Some(notes.join("; "));
@@ -4252,7 +4455,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false)?;
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false, warning)?;
let tools = vec![crate::lineage::box_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
@@ -4264,7 +4467,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let b = kernel::cylinder(base, *axis, radius, height)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height };
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false)?;
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false, warning)?;
let tools = vec![crate::lineage::cylinder_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
@@ -4621,9 +4824,61 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
/// occurrence of it (bodies of the first placement keep their names; further instances get the
/// occurrence path in brackets). Without components this is the model itself.
pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
world_state_reusing(doc, st, &mut PlacedBodies::default())
}
/// Bodies a [`world_state_reusing`] call placed away from their definition (a moved
/// component's bodies), by their definition and placement: the next call reuses each one whose
/// definition and placement are unchanged instead of transforming it again, and so keeps its
/// display mesh. Without this every change anywhere in a design transformed and meshed every
/// body of every moved component again.
#[derive(Default)]
pub struct PlacedBodies {
by_source: std::collections::HashMap<(usize, [u64; 16]), (ModelBody, ModelBody)>,
}
impl PlacedBodies {
/// The body `b` placed by `m`, from an earlier call when `b` is the same body as then.
fn placed(&self, b: &ModelBody, m: &crate::Mat, name: String) -> ModelBody {
let reused = self.by_source.get(&placed_key(b, m)).filter(|(source, _)| {
Arc::ptr_eq(&source.mesh, &b.mesh)
&& source.body.same(&b.body)
&& source.body.color() == b.body.color()
&& source.body.paint() == b.body.paint()
&& match (source.body.split_lines(), b.body.split_lines()) {
(Some(x), Some(y)) => Arc::ptr_eq(x, y),
(None, None) => true,
_ => false,
}
});
match reused {
Some((_, placed)) => {
let mut fresh = ModelBody::new(name, placed.body.clone(), b.feature);
fresh.mesh = placed.mesh.clone();
fresh.plan = placed.plan.clone();
fresh.plan_mesh = placed.plan_mesh.clone();
fresh
}
None => ModelBody::new(name, kernel::transform_matrix(&b.body, *m).unwrap_or_else(|_| b.body.clone()), b.feature),
}
}
}
fn placed_key(b: &ModelBody, m: &crate::Mat) -> (usize, [u64; 16]) {
let mut bits = [0u64; 16];
for (slot, v) in bits.iter_mut().zip(m.iter().flatten()) {
*slot = v.to_bits();
}
(Arc::as_ptr(&b.mesh) as usize, bits)
}
/// [`world_state`], reusing the bodies `placed` holds from an earlier call and leaving it holding
/// this call's.
pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut PlacedBodies) -> ModelState {
if doc.occurrences.is_empty() {
return st.clone();
}
let mut placed_now = PlacedBodies::default();
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
let first_path = |comp: u64| -> Vec<u64> {
let mut path = Vec::new();
@@ -4653,8 +4908,9 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
placed.name = name;
placed
} else {
let body = kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone());
ModelBody::new(name, body, b.feature)
let placed = placed_before.placed(b, &bm, name);
placed_now.by_source.insert(placed_key(b, &bm), (b.clone(), placed.clone()));
placed
};
// World states keep their previous display-only evaluation-key semantics.
placed.key = 0;
@@ -4702,6 +4958,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
}
}
}
*placed_before = placed_now;
out
}
+332 -81
View File
@@ -157,16 +157,25 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
}
/// Unit outward normal of the planar face through `p`.
/// Unit outward normal of the planar face through `p`: of the planar faces whose plane runs
/// through it, the one nearest it. A face elsewhere in such a plane (the wall of a notch, the end
/// of a rib, in line with a rim) is no pick of `p`, though its plane runs through it as exactly.
fn planar_normal_at(b: &Body, p: Vec3) -> Option<Vec3> {
let tol = (b.size() * 2e-3).max(1e-3);
let faces = b.faces(tol).ok()?;
let mesh = b.tessellate(tol).ok()?;
let mut near = vec![f64::INFINITY; faces.len()];
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
if let (Some([u, v, w]), Some(d)) = (mesh.tri(t), near.get_mut(*f as usize)) {
*d = d.min(crate::appearance::closest_on_triangle(p, u, v, w).dist(p));
}
}
faces
.iter()
.filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs())))
.filter(|(_, d)| *d < tol * 5.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(n, _)| n)
.filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs(), near.get(f.index).copied().unwrap_or(f64::INFINITY))))
.filter(|(_, d, _)| *d < tol * 5.0)
.min_by(|a, b| a.2.total_cmp(&b.2).then(a.1.total_cmp(&b.1)))
.map(|(n, _, _)| n)
}
/// The curves of a chain as one polyline in sketch coordinates, in order.
@@ -485,6 +494,32 @@ pub(super) fn sheet_eval(
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body } => {
// Without a body name the field goes on the newest sheet, as the command's own body lookup does.
let i = match body {
Some(_) => sheet_for(st, body, None)?,
None => st.sheets.len().checked_sub(1).ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))?,
};
let d = val(vals, diameter, Kind::Length)?;
let pitch = val(vals, pitch, Kind::Length)?;
let margin = match margin {
Some(e) => val(vals, e, Kind::Length)?,
None => d,
};
let square = match pattern.as_deref() {
None | Some("staggered") => false,
Some("square") => true,
Some(o) => return Err(DocError::Invalid(format!("perforation pattern {o:?}: use \"staggered\" or \"square\""))),
};
if !(d > 0.0 && pitch > d && margin >= 0.0) {
return Err(DocError::Invalid(format!(
"perforation needs diameter > 0, pitch > diameter and margin >= 0 (diameter {d}, pitch {pitch}, margin {margin})"
)));
}
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square });
Ok(())
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
let i = sheet_for(st, body, edges.first().copied())?;
let l = val(vals, length, Kind::Length)?;
@@ -583,33 +618,49 @@ pub(super) fn sheet_eval(
None => *p,
});
}
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
for note in notes {
append_warning(warning, note);
}
tag_flanges(&mut sh, 0, f.id);
put_sheet(st, sh)
}
FeatureKind::SheetConvert { body, face, rule, .. } => {
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
let tol = (mb.body.size() * 1e-3).max(1e-3);
let mesh = mb.body.tessellate(tol)?;
// Thickness: the body's extent along the face normal.
let ds: Vec<f64> = mesh.positions.iter().map(|p| (*p - *face).dot(n)).collect();
let (lo, hi) = (ds.iter().cloned().fold(f64::MAX, f64::min), ds.iter().cloned().fold(f64::MIN, f64::max));
let extent = |mesh: &solidcraft_geom::Mesh| {
mesh.positions.iter().map(|p| (*p - *face).dot(n)).fold((f64::MAX, f64::MIN), |(lo, hi), d| (lo.min(d), hi.max(d)))
};
// The tolerance (the mesh's chords, and how far off the face's plane a point of it may
// be) is at most a tenth of the thickness, not only a thousandth of the size. On a long
// thin plate that thousandth passes the thickness: the far side's triangles counted as
// this face's (no outline was left), and a hole of a smaller radius than that fell out.
let mut tol = (mb.body.size() * 1e-3).max(1e-3);
let mut mesh = mb.body.tessellate(tol)?;
let (mut lo, mut hi) = extent(&mesh);
if hi - lo > 0.0 && tol > (hi - lo) * 0.1 {
tol = ((hi - lo) * 0.1).max(1e-6);
mesh = mb.body.tessellate(tol)?;
(lo, hi) = extent(&mesh);
}
let t = hi - lo;
// The face outline: boundary edges of the triangles on that face's plane, where it runs
// along a round wall an arc of the wall's circle (not chords: the plate check below
// compares its area with the exact volume).
let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
// Recesses in one face (an engraved label) become pockets; the outline is the other face's.
let recesses = plate_recesses(&mb.body, &mesh, &plane, *face, n, (lo, hi), tol)?;
let mut walls = std::collections::HashMap::new();
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| round_wall(&mb.body, &plane, n, f));
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| sheet::round_wall(&mb.body, &plane, n, f));
let mut loops: Vec<Loop2> =
face_outline(&mesh, &plane, *face, n, lo, tol).iter().map(|l| outline_loop(l, &mut wall, tol * 1e-2)).collect();
face_outline(&mesh, &plane, *face, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect();
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let outer = it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?.ccw();
let region = Region2 { outer, holes: it.map(|l| l.ccw().reversed()).collect() };
let area = region.area().abs();
let vol = kernel::measure(&mb.body)?.volume;
let vol = kernel::measure(&mb.body)?.volume + recesses.volume;
if (area * t - vol).abs() > 1e-3 * vol.max(1.0) {
return Err(DocError::Invalid(
"not supported yet: converting bodies that are not a plate of even thickness without bend edges (`bends`)".into(),
@@ -620,6 +671,10 @@ pub(super) fn sheet_eval(
rv.thickness = t;
let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() });
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
let mut work = sheet::FlatWork::new();
for (pocket, z0, z1) in recesses.pockets {
sh.record_pocket(pocket, z0, z1, &mut work)?;
}
put_sheet(st, sh)
}
_ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))),
@@ -683,7 +738,11 @@ fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option
}
// Bound floating point projection separately from model geometry tolerance.
let roundoff = 128.0 * f64::EPSILON * (plane.origin.len() + current.origin.len() + source.origin.len()).max(1.0);
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff {
// The cap's current point lies off the recorded plane by the noise of evaluating
// its edges, which its fitted tilt shows across the distance between the two points
// (8e-12 over 2.4 mm on a 45° flange's drill, #1255; none on axis-aligned caps).
let noise = current.normal().cross(n).len() * (current.origin - plane.origin).len();
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff + noise {
return None;
}
projection_roundoff = projection_roundoff.max(roundoff);
@@ -705,6 +764,7 @@ pub(super) fn sheet_cut(
tools: &[Body],
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
warning: &mut Option<String>,
) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
@@ -712,7 +772,16 @@ pub(super) fn sheet_cut(
}
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some())
// The outlines of the sketch's text: the sheet remembers where its letters went.
let text: Vec<Seg2> = ss
.sketch
.links
.iter()
.filter(|l| matches!(l.source, solidcraft_sketch::LinkSource::Text { .. }))
.flat_map(|l| ss.sketch.link_curves(&l.id))
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
.collect();
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning)
}
/// Whether a cut of `f` with these targets (else its participants, else its component's
@@ -744,6 +813,7 @@ pub(super) fn sheet_primitive_cut(
prism: PrimitivePrism,
operation: Operation,
drill: bool,
warning: &mut Option<String>,
) -> Result<Vec<String>> {
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
return Ok(Vec::new());
@@ -768,7 +838,7 @@ pub(super) fn sheet_primitive_cut(
let Some(normal) = normal else { return Ok(Vec::new()) };
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
let tools = kernel::extrude(&plane, std::slice::from_ref(&region), interval.0, interval.1)?;
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false)
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false, &[], warning)
}
/// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each
@@ -855,6 +925,12 @@ impl PrimitivePrism {
}
}
/// The most outline segments of a sketch's text a cut tells letters by, and the most text regions
/// a sheet keeps (see [`crate::sheet::SheetBody::labels`]).
const MAX_TEXT_CURVES: usize = 20_000;
const MAX_LABELS: usize = 1000;
/// `text` is the outlines of the text in the cut's sketch (none for a primitive's cut).
#[allow(clippy::too_many_arguments)]
fn sheet_prism_cut(
f: &Feature,
@@ -866,6 +942,8 @@ fn sheet_prism_cut(
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
footprints_bound: bool,
text: &[Seg2],
warning: &mut Option<String>,
) -> Result<Vec<String>> {
let n = plane.normal();
let mut work = sheet::FlatWork::new();
@@ -904,7 +982,14 @@ fn sheet_prism_cut(
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
.then_some(c.interval)
});
// The regions of the cut that are letters, with their counters.
let letters: Vec<&Region2> = if operation == Operation::Cut && !text.is_empty() && text.len() <= MAX_TEXT_CURVES {
regions.iter().filter(|r| sheet::loop_lies_on(&r.outer, text)).collect()
} else {
Vec::new()
};
let mut plans = Vec::new();
let mut notes = Vec::new();
'sheets: for original in &st.sheets {
if !sheet_eligible(f, st, targets, original) {
continue;
@@ -937,6 +1022,7 @@ fn sheet_prism_cut(
sh.material_panels(Some(plane), &mut work)?
};
let mut modeled = false;
let mut labels = Vec::new();
for (pi, panel) in candidates {
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
@@ -976,6 +1062,7 @@ fn sheet_prism_cut(
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
// Sub-resolution layers must not be silently erased by endpoint snapping.
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
labels.extend(letters.iter().filter_map(|r| sh.region_to_flat(pi, plane, r).ok()));
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
} else {
@@ -1040,19 +1127,34 @@ fn sheet_prism_cut(
}
}
}
if modeled {
for flat in labels {
if !sh.labels.contains(&flat) && sh.labels.len() < MAX_LABELS {
sh.labels.push(flat);
}
}
}
if !modeled {
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
} else if sh.flat_material()?.is_empty() {
// Empty exact material removes geometry and metadata together. Budget or
// topology errors propagate; they are never mistaken for an empty blank.
plans.push((sh.body.clone(), None));
} else {
let body = sh.solid()?;
plans.push((sh.body.clone(), Some((sh, body))));
let material = sh.flat_material()?;
if material.is_empty() {
// Empty exact material removes geometry and metadata together. Budget or
// topology errors propagate; they are never mistaken for an empty blank.
plans.push((sh.body.clone(), None));
} else {
// Every piece the cut leaves stays; the ones it parts from the base are named.
notes.extend(parted_note(&sh, &material, original));
let body = sh.solid()?;
plans.push((sh.body.clone(), Some((sh, body))));
}
}
}
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
if !notes.is_empty() {
append_warning(warning, notes.join("; "));
}
for (name, result) in plans {
match result {
Some((sh, body)) => {
@@ -1073,6 +1175,35 @@ fn sheet_prism_cut(
Ok(handled)
}
/// A cut's note on the pieces it parted from the base of `sh` (see
/// [`sheet::SheetBody::parted_from_base`]; `material` is the sheet's flat material after the cut,
/// `before` the sheet before it): how many, their area and where they lie in the flat pattern.
/// The pieces stay, as every piece a cut leaves does. None when nothing is parted, and when the
/// pieces can't be worked out: the note is no reason for a cut to fail.
fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::SheetBody) -> Option<String> {
let pieces = sh.parted_from_base(material, before).ok().filter(|p| !p.is_empty())?;
let area: f64 = pieces.iter().map(Region2::area).sum();
let at: Vec<String> = pieces
.iter()
.take(4)
.map(|r| {
let c = r.interior_point();
format!("({:.1}, {:.1})", c.x, c.y)
})
.collect();
let at = at.join(", ");
let body = &sh.body;
Some(match pieces.len() {
1 => format!("1 piece of `{body}` is no longer joined to its base ({area:.2} mm², near {at} in the flat pattern); it is kept"),
n => {
let more = if n > 4 { format!(" and {} more", n - 4) } else { String::new() };
format!(
"{n} pieces of `{body}` are no longer joined to its base ({area:.2} mm² in all, near {at}{more} in the flat pattern); they are kept"
)
}
})
}
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
@@ -1084,18 +1215,8 @@ fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<(Vec2, Option<u32>)>> {
let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol));
let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).collect();
// Boundary edges by position (meshes may repeat vertices per triangle).
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
let key = |a: Vec3, b: Vec3| {
let (ka, kb) = (q(a), q(b));
(ka.min(kb), ka.max(kb))
};
let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default();
let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect();
for (a, b) in &edges {
*count.entry(key(*a, *b)).or_insert(0) += 1;
}
// The faces off the plane at each point on it, by position to a millionth of a millimetre
// either way (a neighbouring face's mesh may round a shared point differently).
let mut touch: std::collections::HashMap<(i64, i64, i64), Vec<u32>> = Default::default();
@@ -1119,6 +1240,34 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3,
let at_b = near(b);
near(a).into_iter().find(|f| at_b.contains(f))
};
chain_loops(mesh, plane, &on, across)
}
/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh.
fn boundary_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]]) -> Vec<Vec<Vec2>> {
chain_loops(mesh, plane, on, |_, _| None).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
}
/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh, each point with
/// `across(it, next)`: the face the outline runs along from it to the next point.
fn chain_loops(
mesh: &solidcraft_geom::Mesh,
plane: &Plane,
on: &[[u32; 3]],
across: impl Fn(Vec3, Vec3) -> Option<u32>,
) -> Vec<Vec<(Vec2, Option<u32>)>> {
// Boundary edges by position (meshes may repeat vertices per triangle).
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
let key = |a: Vec3, b: Vec3| {
let (ka, kb) = (q(a), q(b));
(ka.min(kb), ka.max(kb))
};
let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default();
let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect();
for (a, b) in &edges {
*count.entry(key(*a, *b)).or_insert(0) += 1;
}
let mut boundary: Vec<(Vec3, Vec3)> = edges.into_iter().filter(|(a, b)| count.get(&key(*a, *b)) == Some(&1)).collect();
// Chain into loops (plane coordinates).
let mut loops: Vec<Vec<(Vec2, Option<u32>)>> = Vec::new();
@@ -1157,60 +1306,162 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3,
loops
}
/// The circle (centre in `plane` coordinates, radius) of face `f` when it is a round wall
/// standing square on the plane: a cylinder about the plane's normal `n`.
fn round_wall(body: &Body, plane: &Plane, n: Vec3, f: u32) -> Option<(Vec2, f64)> {
match kernel::face_surface(body, f as usize)? {
kernel::FaceSurface::Cylinder { origin, axis, radius } if axis.cross(n).len() < 1e-6 => Some((plane.to_local(origin), radius)),
_ => None,
}
/// What the recesses of a plate (an engraved label, a counterbore) take out of it, for Convert
/// to Sheet Metal: each becomes a pocket of the sheet, from its floor to the face it opens on.
struct Recesses {
/// The level (along the picked face's normal) of the plate's face without recesses, whose
/// outline the sheet takes.
level: f64,
/// Pockets in flat coordinates, with their depth interval in the sheet (0 to its thickness).
pockets: Vec<(Region2, f64, f64)>,
/// The material they take out.
volume: f64,
}
/// A loop of [`face_outline`] as segments: each run along one round wall (`wall` gives a face's
/// circle) is an arc of that circle, the rest stays straight. Coaxial cylinders of one radius are
/// one wall, however many faces it comes in (many systems write a round hole's wall as two
/// half-cylinders), so such a hole is one circle. `eps`: how far off its circle a point may be.
fn outline_loop(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Loop2 {
use std::f64::consts::{PI, TAU};
let m = pts.len();
let point = |i: usize| pts.get(i % m).map(|x| x.0).unwrap_or_default();
// The circle each segment runs along: its wall's, when both its ends lie on it.
let circles: Vec<Option<(Vec2, f64)>> = (0..m)
.map(|i| {
let (c, r) = wall(pts.get(i)?.1?)?;
((point(i).dist(c) - r).abs() <= eps && (point(i + 1).dist(c) - r).abs() <= eps).then_some((c, r))
})
.collect();
let circle = |i: usize| circles.get(i % m).copied().flatten();
let same = |i: usize, (c, r): (Vec2, f64)| circle(i).is_some_and(|(d, s)| d.dist(c) <= eps && (s - r).abs() <= eps);
// Start where no arc runs on across the start (anywhere when the whole loop is one circle).
let start = (0..m).find(|i| circle(*i + m - 1).is_none_or(|c| !same(*i, c))).unwrap_or(0);
let mut segs = Vec::new();
let mut i = 0;
while i < m {
let k = start + i;
let Some((c, r)) = circle(k) else {
segs.push(Seg2::Line { a: point(k), b: point(k + 1) });
i += 1;
/// A planar face of the mesh parallel to the plate: its lowest and highest level, whether its
/// outward normal is the picked face's (it faces up), its area, a point on it, and its
/// triangles' centres and areas.
struct LevelFace {
lo: f64,
hi: f64,
up: bool,
area: f64,
at: Vec3,
tris: Vec<(Vec3, f64)>,
}
/// The recesses of a plate lying between levels `lo` and `hi` along `n` (measured from `at`),
/// whose sheet is built in `plane`. A recess's floor is a face parallel to the plate inside its
/// thickness, lying in an opening of the face it is cut into, and its walls are square to the
/// plate (walls that are not show as material the plate's volume doesn't account for). A body
/// that is no plate once its recesses are filled has none, so the plate check refuses it as
/// before: a bent part, whose legs' faces lie inside its extent. Recesses are taken in one face
/// only, up to half the thickness deep, and no larger than the face they are cut into (a plate
/// raised in places is no plate of the raised thickness).
fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (lo, hi): (f64, f64), tol: f64) -> Result<Recesses> {
let t = hi - lo;
let eps = t * 1e-3;
let level = |p: Vec3| (p - at).dot(n);
let mut faces: BTreeMap<u32, Option<LevelFace>> = BTreeMap::new();
for (tri, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let Some([a, b, c]) = mesh.tri(tri) else { continue };
let cr = (b - a).cross(c - a);
let len = cr.len();
if !(len > 1e-12) {
continue;
};
let mut j = i + 1;
while j < m && same(start + j, (c, r)) {
j += 1;
}
let sweep: f64 = (i..j)
.map(|s| {
let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle();
(d + PI).rem_euclid(TAU) - PI
})
.sum();
// Arcs of up to half a turn each.
let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4);
let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64);
segs.extend((0..pieces).map(|p| Seg2::Arc { center: c, radius: r, start: a0 + step * p as f64, sweep: step }));
i = j;
let up = cr.dot(n) > 0.0;
let entry = faces.entry(*f).or_insert(Some(LevelFace { lo: f64::MAX, hi: f64::MIN, up, area: 0.0, at: a, tris: Vec::new() }));
match entry {
Some(face) if face.up == up && cr.dot(n).abs() >= len * (1.0 - 1e-6) => {
for p in [a, b, c] {
face.lo = face.lo.min(level(p));
face.hi = face.hi.max(level(p));
}
face.area += len / 2.0;
face.tris.push(((a + b + c) / 3.0, len / 2.0));
}
_ => *entry = None,
}
}
Loop2 { segs }
// The floors (face index, level, the face), and the areas of the plate's two faces (at `lo`
// and at `hi`).
let mut floors: Vec<(usize, f64, &LevelFace)> = Vec::new();
let (mut bottom, mut top) = (0.0, 0.0);
for (f, face) in faces.iter().filter_map(|(f, face)| Some((*f as usize, face.as_ref()?))) {
let d = (face.lo + face.hi) / 2.0;
if face.hi - face.lo > eps {
continue;
} else if (d - hi).abs() <= eps && face.up {
top += face.area;
} else if (d - lo).abs() <= eps && !face.up {
bottom += face.area;
} else if d > lo + eps && d < hi - eps {
floors.push((f, d, face));
}
}
let none = || Recesses { level: lo, pockets: Vec::new(), volume: 0.0 };
if floors.is_empty() {
return Ok(none());
}
// A floor is a recess's where (most of) it lies in an opening of the face it is cut into:
// inside that face's outline and in a hole of it, not on an island in the hole. (Its
// triangles are those at its level within `eps`: `tol` may exceed a recess's depth.)
let loops_at = |level: f64| -> Vec<Loop2> { face_loops(mesh, plane, at, n, level, eps).into_iter().map(|l| Loop2::polygon(&l)).collect() };
let (top_loops, bottom_loops) = (loops_at(hi), loops_at(lo));
floors.retain(|(_, _, face)| {
let loops = if face.up { &top_loops } else { &bottom_loops };
let open = |p: Vec2| {
let k = loops.iter().filter(|l| l.contains(p)).count();
k >= 2 && k.is_multiple_of(2)
};
face.tris.iter().filter(|(c, _)| open(plane.to_local(*c))).map(|(_, a)| a).sum::<f64>() > face.area / 2.0
});
let Some(&(_, _, first)) = floors.first() else { return Ok(none()) };
// Filled, the recesses leave a plate: its outline as large at `lo` as at `hi` once their
// openings count, and its volume with theirs the outline times the thickness. Else (a bent
// part) they are none.
let depth_in = |d: f64, up: bool| if up { hi - d } else { d - lo };
let opened = |up: bool| floors.iter().filter(|f| f.2.up == up).map(|f| f.2.area).sum::<f64>();
let outline = bottom + opened(false);
let removed: f64 = floors.iter().map(|f| f.2.area * depth_in(f.1, f.2.up)).sum();
let volume = kernel::measure(body)?.volume;
if (outline - top - opened(true)).abs() > 1e-3 * outline || (outline * t - volume - removed).abs() > 1e-3 * volume.max(1.0) {
return Ok(none());
}
let pt = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z);
let up = first.up;
if let Some(other) = floors.iter().find(|f| f.2.up != up) {
return Err(DocError::Invalid(format!(
"not supported yet: converting a plate with recesses in both faces (at {} and {}); recesses in one face become pockets",
pt(first.at),
pt(other.2.at)
)));
}
let depth_of = |d: f64| depth_in(d, up);
if let Some(deep) = floors.iter().find(|f| depth_of(f.1) > t / 2.0 + eps) {
return Err(DocError::Invalid(format!(
"not supported yet: the recess at {} is {:.3} mm deep, more than half the plate's {:.3} mm; shallower recesses (an engraving, a label) become pockets",
pt(deep.2.at),
depth_of(deep.1),
t
)));
}
let face_area = if up { top } else { bottom };
if floors.iter().map(|f| f.2.area).sum::<f64>() > face_area {
return Err(DocError::Invalid(format!(
"not supported yet: converting a plate whose recesses are larger than the face they are in (a plate thinner than {t:.3} mm, raised in places)"
)));
}
// The floors' outlines, from a mesh fine enough to mark them by.
let fine = body.tessellate((t * 1e-2).min(tol).max(1e-4))?;
floors.sort_by(|a, b| depth_of(a.1).total_cmp(&depth_of(b.1)));
// Floors at one depth are one pocket where they meet.
let mut groups: Vec<(f64, Vec<Region2>)> = Vec::new();
for (f, d, ..) in &floors {
let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect();
let mut loops = boundary_loops(&fine, plane, &on);
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
let mut it = loops.into_iter();
let Some(outline) = it.next() else { continue };
let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
match groups.last_mut() {
Some((depth, regions)) if depth_of(*d) - *depth <= eps => regions.push(region),
_ => groups.push((depth_of(*d), vec![region])),
}
}
// The sheet's z starts from the picked face when its plane faces away from that face.
let from_zero = up == (plane.normal().dot(n) < 0.0);
let mut work = sheet::FlatWork::new();
let (mut pockets, mut volume) = (Vec::new(), 0.0);
for (depth, regions) in groups {
let (z0, z1) = if from_zero { (0.0, depth) } else { (t - depth, t) };
for region in sheet::material_union(regions, &mut work)? {
volume += region.area().abs() * depth;
pockets.push((region, z0, z1));
}
}
Ok(Recesses { level: if up { lo } else { hi }, pockets, volume })
}
/// The body a placed plastic feature joins: by name, else the one under the point.
+75 -8
View File
@@ -1,8 +1,9 @@
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend, Surface Extrude,
//! Revolve, Sweep and Loft, Offset, Unstitch and Reverse Normal (kernel `surfaces`,
//! `surface_offset`).
//! Revolve, Sweep and Loft, Offset, Ruled, Untrim, Unstitch and Reverse Normal (kernel `surfaces`,
//! `surface_offset`, `surface_ruled`, `surface_untrim`).
use super::*;
use crate::{RuledCorners, RuledMode};
use solidcraft_geom::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
@@ -77,11 +78,30 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceExtend { body, edges, distance } => {
let d = val(vals, distance, Kind::Length)?;
FeatureKind::SurfaceExtend { body, edges, distance, mode, up_to, neighbours } => {
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)?;
let mode = match mode {
crate::ExtendMode::Natural => kernel::ExtendMode::Natural,
crate::ExtendMode::Tangent => kernel::ExtendMode::Tangent,
crate::ExtendMode::Curvature => kernel::ExtendMode::Curvature,
};
let at = |limit| kernel::ExtendOptions { mode, limit, neighbours: *neighbours };
let out = match up_to {
None => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Distance(val(vals, distance, Kind::Length)?)))?,
Some(crate::ExtendTo::Bounds) => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Bounds))?,
Some(crate::ExtendTo::Plane(plane)) => {
let tool = kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?);
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
}
Some(crate::ExtendTo::Face(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 target point", t.body)))?;
let tool = kernel::SplitTool::Face { body: &tb.body, face };
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
}
};
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, out, mb.feature);
}
@@ -156,7 +176,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => {
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -175,8 +196,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
let body = match path_points(ps, path, start)? {
Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?,
None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?,
Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?,
None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?,
};
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
push_sheet(st, f, traced);
@@ -234,6 +255,52 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
st.bodies.push(ModelBody::traced_new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceRuled { body, edges, distance, mode, angle, direction, flip, corners, face } => {
let d = val(vals, distance, Kind::Length)?;
let mode = match mode {
RuledMode::Normal => kernel::RuledMode::Normal,
RuledMode::Tangent => kernel::RuledMode::Tangent,
RuledMode::Angle => {
let a = angle.as_ref().ok_or_else(|| DocError::Invalid("a ruled surface at an angle needs `angle`".into()))?;
kernel::RuledMode::Angle { angle: val(vals, a, Kind::Angle)?, reference: *direction }
}
RuledMode::Direction => kernel::RuledMode::Direction(
direction.ok_or_else(|| DocError::Invalid("a ruled surface along a direction needs `direction`".into()))?,
),
};
let corners = match corners {
RuledCorners::Mitre => kernel::RuledCorners::Mitre,
RuledCorners::Round => kernel::RuledCorners::Round,
};
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let r = kernel::Ruled { distance: d, mode, flip: *flip, corners, face: *face };
let made = Traced::step(kernel::ruled_surface(&mb.body, edges, &r)?, &[&mb.traced()]);
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::traced_new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceUntrim { face, holes, outer, in_place } => {
let i = st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", face.body)))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let fi = crate::appearance::face_index_at(&mb, face.point)
.ok_or_else(|| DocError::Invalid(format!("Untrim: no face of {} at {:?}", mb.name, face.point)))?;
let which = match holes {
None => kernel::UntrimHoles::All,
Some(points) => kernel::UntrimHoles::At(points),
};
// A surface body's face is untrimmed where it is when asked; a solid's is copied out.
let replace = *in_place && mb.body.is_surface();
let made = mb.traced().then(kernel::untrim(&mb.body, fi, which, *outer, replace)?);
if replace {
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, made, mb.feature);
}
} else {
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::traced_new(name, made, f.id));
}
Ok(())
}
_ => Err(DocError::Invalid("not a surface feature".into())),
}
}
+9 -1
View File
@@ -446,6 +446,7 @@ mod tests {
let producer = doc
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: source,
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
extent,
@@ -555,7 +556,14 @@ mod tests {
let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] },
FeatureKind::Extrude {
thin: None,
sketch: sk,
profiles: crate::ProfileSel::All,
extent,
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Boss"),
)
.unwrap();
+101
View File
@@ -0,0 +1,101 @@
//! The geometry a feature holds, each piece with its role: the points that pick faces (moved
//! with the feature, and the source of its persistent face names), other positions, directions,
//! planes, construction references, a primitive's placement point and its profile selections.
//! The walk (`crate::walk`) is behind `transform_geometry`, `face_points`, `profile_sels` and
//! `point_expr`.
use solidcraft_geom::Vec3;
use crate::walk::Walk;
use crate::{FeatureKind, PointExpr, ProfileSel};
/// A piece of a feature's geometry: `V` is `&Vec3` or `&mut Vec3`, and so on for the others.
pub(crate) enum Geo<V, P, G, E, S> {
/// A point that picks a face: it moves with the feature and names the face it picks.
Pick(V),
/// A point that picks a face but stays where it is when the feature is moved (an Emboss
/// cylinder, chosen in its component's frame).
Pinned(V),
/// Any other position (an edge pick, an axis origin, a fold line): it moves with the feature.
Pos(V),
/// A direction: it turns with the feature.
Dir(V),
Plane(P),
/// A construction axis's or point's reference to the model.
Construct(G),
/// A primitive's placement point (length expressions).
Placement(E),
/// A profile selection, with its sketch.
Profiles(u64, S),
}
impl FeatureKind {
/// The points that pick faces (shell, draft, offset and replace faces, a hole's placement and
/// `to`, an extrude's `to` and `from`…), in the order of the feature's persistent face names.
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
let mut v = Vec::new();
self.walk_geometry_mut(&mut |g| {
if let Geo::Pick(p) | Geo::Pinned(p) = g {
v.push(p);
}
});
v
}
/// Read-only view of [`FeatureKind::face_points_mut`].
pub fn face_points(&self) -> Vec<Vec3> {
let mut v = Vec::new();
self.walk_geometry(&mut |g| {
if let Geo::Pick(p) | Geo::Pinned(p) = g {
v.push(*p);
}
});
v
}
/// The feature's profile selections, each with its sketch.
pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> {
let mut v = Vec::new();
self.walk_geometry_mut(&mut |g| {
if let Geo::Profiles(sketch, sel) = g {
v.push((sketch, sel));
}
});
v
}
/// Read-only view of [`FeatureKind::profile_sels_mut`].
pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> {
let mut v = Vec::new();
self.walk_geometry(&mut |g| {
if let Geo::Profiles(sketch, sel) = g {
v.push((sketch, sel));
}
});
v
}
/// A primitive's placement point (length expressions). Its coordinates are not inputs:
/// they get no parameter names and keep bare numbers in mm whatever the design's units, as
/// every other position does; renames and users still find them.
pub fn point_expr_mut(&mut self) -> Option<&mut PointExpr> {
let mut found = None;
self.walk_geometry_mut(&mut |g| {
if let Geo::Placement(p) = g {
found = Some(p);
}
});
found
}
/// Read-only view of [`FeatureKind::point_expr_mut`].
pub fn point_expr(&self) -> Option<&PointExpr> {
let mut found = None;
self.walk_geometry(&mut |g| {
if let Geo::Placement(p) = g {
found = Some(p);
}
});
found
}
}
+60
View File
@@ -0,0 +1,60 @@
//! References from a feature to the rest of the design: the sketches it is built from (by id),
//! the features it names (patterned and mirrored features, the construction plane a plane is
//! built on) and the bodies it works on (by name). The walk (`crate::walk`) is behind
//! `input_sketches`, `remap_refs` and `remap_bodies`.
use std::collections::BTreeMap;
use crate::FeatureKind;
use crate::walk::Walk;
/// A reference a feature holds: `S` is `&u64` or `&mut u64` (a sketch id), `N` is `&String` or
/// `&mut String` (a feature or body name).
pub(crate) enum Ref<S, N> {
Sketch(S),
Feature(N),
Body(N),
}
impl FeatureKind {
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
/// deletes the feature.
pub fn input_sketches(&self) -> Vec<u64> {
let mut v = Vec::new();
self.walk_refs(&mut |r| {
if let Ref::Sketch(id) = r {
v.push(*id);
}
});
v
}
/// Point references to sketches (by id) and features (by name) at their copies, or at a
/// feature's new name (`timeline.rename`).
pub fn remap_refs(&mut self, sketches: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
self.walk_refs_mut(&mut |r| match r {
Ref::Sketch(id) => {
if let Some(n) = sketches.get(id) {
*id = *n;
}
}
Ref::Feature(name) => {
if let Some(n) = names.get(name) {
*name = n.clone();
}
}
Ref::Body(_) => {}
});
}
/// Point references to bodies (by name) at their copies.
pub fn remap_bodies(&mut self, bodies: &BTreeMap<String, String>) {
self.walk_refs_mut(&mut |r| {
if let Ref::Body(name) = r
&& let Some(n) = bodies.get(name)
{
*name = n.clone();
}
});
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Golden records of what the walks over a feature report, for every variant (and every fillet
//! style, pattern, hole and construction definition):
//! - expressions: `inputs` (label, expression, kind, in order: the order names the `dN`
//! parameters) and `expressions` (every expression, placement points included). Each
//! expression field holds its own name, so a moved, missing or relabelled input shows;
//! - references: the sketches a feature is built from, and the fields `remap_refs` and
//! `remap_bodies` rewrite when everything is copied under new ids and names;
//! - geometry: the fields `transform_geometry` moves, the face points, the profile selections and
//! the placement point.
//!
//! The records live in `feature_walk_tests/*.txt`; to record an intended change, run the tests
//! with `FEATURE_WALK_BLESS=1`.
mod fixtures;
use std::collections::BTreeMap;
use fixtures::{changed, fixtures, with_points, with_refs};
use crate::FeatureKind;
/// Per fixture: its inputs, one per line (`label = expression (kind)`), then every expression.
fn record() -> String {
let mut out = String::new();
for (name, v) in fixtures() {
let kind: FeatureKind = serde_json::from_value(v).unwrap_or_else(|e| panic!("{name}: {e}"));
out += &format!("{name}\n");
let inputs = kind.inputs();
if !inputs.is_empty() {
out += " inputs:\n";
for (label, e, k) in inputs {
out += &format!(" {label} = {e} ({k:?})\n");
}
}
let expressions = kind.expressions();
if !expressions.is_empty() {
out += &format!(" expressions: {}\n", expressions.join(", "));
}
}
out
}
/// Per fixture: the sketches it is built from, and the fields that copying with every sketch,
/// feature and body renamed rewrites (`remap_refs`: sketches and features; `remap_bodies`).
fn ref_record() -> String {
let sketches: BTreeMap<u64, u64> = (1..=8).map(|i| (i, i + 100)).collect();
let names = BTreeMap::from([("F".to_string(), "F2".to_string()), ("CP".to_string(), "CP2".to_string())]);
let bodies = BTreeMap::from([("B".to_string(), "B2".to_string())]);
let mut out = String::new();
for (name, v) in fixtures() {
let kind: FeatureKind = serde_json::from_value(with_refs(name, v)).unwrap_or_else(|e| panic!("{name}: {e}"));
let before = serde_json::to_value(&kind).unwrap();
let mut refs = kind.clone();
refs.remap_refs(&sketches, &names);
let mut by_refs = Vec::new();
changed(&before, &serde_json::to_value(&refs).unwrap(), "", &mut by_refs);
let mut body = kind.clone();
body.remap_bodies(&bodies);
let mut by_bodies = Vec::new();
changed(&before, &serde_json::to_value(&body).unwrap(), "", &mut by_bodies);
out += &format!("{name}\n");
let sketches = kind.input_sketches();
if !sketches.is_empty() {
out += &format!(" sketches: {sketches:?}\n");
}
if !by_refs.is_empty() {
out += &format!(" remap_refs: {}\n", by_refs.join(", "));
}
if !by_bodies.is_empty() {
out += &format!(" remap_bodies: {}\n", by_bodies.join(", "));
}
}
out
}
/// A variant with a field that refers to a sketch (a field named `*sketch*` holding an id, sketch
/// points or loft sections) must report it from `input_sketches`, or deleting the sketch leaves
/// the feature broken. New variants are caught by the walk's exhaustive match; this catches a
/// sketch field added to an existing variant (its fixture has to set it).
/// Per fixture (with every reference and position set): the fields `transform_geometry` changes
/// under a turn and a shift (every point, direction and plane it maps), the face points that name
/// faces, the profile selections with their sketches, and a primitive's placement point.
fn geometry_record() -> String {
// x → y → z → x, then (1, 2, 3): every point and every direction of the fixtures changes.
let m = [[0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [1.0, 0.0, 0.0, 0.0], [1.0, 2.0, 3.0, 1.0]];
let mut out = String::new();
for (name, v) in fixtures() {
let kind: FeatureKind = serde_json::from_value(with_points(name, with_refs(name, v))).unwrap_or_else(|e| panic!("{name}: {e}"));
let mut moved = kind.clone();
moved.transform_geometry(&m);
let mut paths = Vec::new();
changed(&serde_json::to_value(&kind).unwrap(), &serde_json::to_value(&moved).unwrap(), "", &mut paths);
out += &format!("{name}\n");
if !paths.is_empty() {
out += &format!(" moves: {}\n", paths.join(", "));
}
let faces: Vec<String> = kind.face_points().iter().map(|p| format!("[{}, {}, {}]", p.x, p.y, p.z)).collect();
if !faces.is_empty() {
out += &format!(" face points: {}\n", faces.join(", "));
}
let profiles: Vec<String> = kind
.profile_sels()
.iter()
.map(|(sketch, sel)| format!("{sketch} {}", serde_json::to_value(sel).unwrap()["type"].as_str().unwrap_or("?")))
.collect();
if !profiles.is_empty() {
out += &format!(" profiles: {}\n", profiles.join(", "));
}
if let Some(p) = kind.point_expr() {
out += &format!(" placement: {}\n", p.join(", "));
}
}
out
}
/// Compares `got` with the golden file `name` in `feature_walk_tests/`, line by line. With
/// `FEATURE_WALK_BLESS` set it rewrites the file instead, for an intended change (say why in the
/// pull request).
fn check_golden(name: &str, want: &str, got: &str) {
if std::env::var_os("FEATURE_WALK_BLESS").is_some() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/feature_walk_tests").join(name);
std::fs::write(&path, got).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
return;
}
for (line, (want, got)) in want.lines().zip(got.lines()).enumerate() {
assert_eq!(want, got, "{name} line {}; rerun with FEATURE_WALK_BLESS=1 if the change is intended", line + 1);
}
assert_eq!(want.lines().count(), got.lines().count(), "{name}: the record has a different number of lines");
}
/// Recorded from the hand-written `inputs_mut` and `expressions` the walk replaced.
const GOLDEN_EXPRS: &str = include_str!("feature_walk_tests/golden_exprs.txt");
/// Recorded from the hand-written `transform_geometry`, `face_points`, `profile_sels` and
/// `point_expr` the walk replaced.
const GOLDEN_GEOMETRY: &str = include_str!("feature_walk_tests/golden_geometry.txt");
/// Recorded from the hand-written `input_sketches`, `remap_refs` and `remap_bodies` the walk
/// replaced, with two fixes: a coil's `targets` and a surface trim's plane are remapped too.
const GOLDEN_REFS: &str = include_str!("feature_walk_tests/golden_refs.txt");
#[test]
fn input_sketches_lists_every_variant_with_a_sketch_field() {
let src = include_str!("document.rs").replace("\r\n", "\n");
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 = String::new();
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("").to_lowercase();
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("LoftSection");
if refers && !t.starts_with("//") && !with_sketch.contains(&variant) {
with_sketch.push(variant.clone());
}
}
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
for v in with_sketch {
let uses = fixtures().into_iter().filter(|(_, f)| f["type"].as_str().unwrap().replace('_', "") == v).any(|(name, f)| {
let kind: FeatureKind = serde_json::from_value(with_refs(name, f)).unwrap();
!kind.input_sketches().is_empty()
});
assert!(uses, "FeatureKind::{v} has a sketch field but input_sketches reports no sketch for its fixtures");
}
}
#[test]
fn geometry_matches_the_golden_record() {
check_golden("golden_geometry.txt", GOLDEN_GEOMETRY, &geometry_record());
}
#[test]
fn references_match_the_golden_record() {
check_golden("golden_refs.txt", GOLDEN_REFS, &ref_record());
}
#[test]
fn inputs_and_expressions_match_the_golden_record() {
check_golden("golden_exprs.txt", GOLDEN_EXPRS, &record());
}
#[test]
fn the_record_covers_every_variant() {
let src = include_str!("document.rs").replace("\r\n", "\n");
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
let names: Vec<String> = fixtures().into_iter().map(|(_, v)| v["type"].as_str().unwrap().replace('_', "")).collect();
for line in body.lines().filter(|l| l.starts_with(" ") && !l.starts_with(" ")) {
let t = line.trim();
if t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
let variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("").to_lowercase();
assert!(names.contains(&variant), "FeatureKind::{t} has no fixture in feature_walk_tests");
}
}
}
@@ -0,0 +1,269 @@
//! Fixtures for the golden records in `feature_walk_tests`: one feature per variant (and per
//! fillet style, pattern, hole kind and construction definition), each expression field holding
//! its own name, plus the reference fields `with_refs` sets.
use serde_json::{Value, json};
fn plane(chain: &str) -> Value {
json!({"type": "at_angle", "angle": format!("{chain}.angle"), "axis_origin": [0, 0, 0], "axis_dir": [1, 0, 0],
"base": {"type": "offset", "distance": format!("{chain}.offset"), "base": {"type": "construction", "name": "CP"}}})
}
fn geo_plane(chain: &str) -> Value {
json!({"type": "plane", "plane": plane(chain)})
}
fn along(chain: &str) -> Value {
json!({"type": "along_path", "path": {"type": "edge", "at": [0, 0, 0]}, "t": format!("{chain}.t")})
}
pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
let o = [0, 0, 0];
let sk = serde_json::to_value(solidcraft_sketch::Sketch::new()).unwrap();
let extent = |n: &str| {
json!({"distance": format!("{n}.distance"), "distance2": format!("{n}.distance2"), "start_offset": format!("{n}.start_offset"),
"taper": format!("{n}.taper"), "to_offset": format!("{n}.to_offset")})
};
let fillet = |style: Value| json!({"type": "fillet", "edges": [o], "radius": "fillet.radius", "style": style});
let pattern = |p: Value| json!({"type": "pattern", "features": ["F"], "pattern": p});
let hole =
|h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h});
vec![
(
"body_transfer",
json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}),
),
("sketch", json!({"type": "sketch", "plane": plane("sketch"), "sketch": sk.clone()})),
("sketch_along_path", json!({"type": "sketch", "plane": along("sketch"), "sketch": sk})),
("extrude", json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("extrude")})),
(
"extrude_thin",
json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("thin"),
"thin": {"curves": ["c1"], "thickness": "thin.thickness", "side": "two_sides", "thickness2": "thin.thickness2"}}),
),
(
"revolve",
json!({"type": "revolve", "sketch": 1, "profiles": {"type": "all"}, "axis": {"type": "world", "axis": "X"}, "angle": "revolve.angle", "angle2": "revolve.angle2"}),
),
("fillet_constant", fillet(json!({"type": "constant"}))),
("fillet_chord", fillet(json!({"type": "chord"}))),
("fillet_curvature", fillet(json!({"type": "curvature"}))),
("fillet_variable", fillet(json!({"type": "variable", "radius2": "fillet.radius2", "start": o, "mid": [[0.5, "fillet.mid"]]}))),
("fillet_spline", fillet(json!({"type": "spline", "start": o, "knots": [0.0, 1.0], "radii": ["fillet.r1", "fillet.r2", "fillet.r3"]}))),
(
"chamfer",
json!({"type": "chamfer", "edges": [o], "distance": "chamfer.distance", "distance2": "chamfer.distance2", "angle": "chamfer.angle"}),
),
("box", json!({"type": "box", "corner": ["box.x", "box.y", "box.z"], "length": "box.length", "width": "box.width", "height": "box.height"})),
("cylinder", json!({"type": "cylinder", "base": ["cyl.x", "cyl.y", "cyl.z"], "radius": "cyl.radius", "height": "cyl.height"})),
("sphere", json!({"type": "sphere", "center": ["sph.x", "sph.y", "sph.z"], "radius": "sph.radius"})),
("torus", json!({"type": "torus", "center": ["tor.x", "tor.y", "tor.z"], "major": "tor.major", "minor": "tor.minor"})),
("combine", json!({"type": "combine", "target": "A", "tools": ["B"], "operation": "join"})),
(
"pattern_rectangular",
pattern(
json!({"type": "rectangular", "dir1": [1, 0, 0], "count1": "rect.count1", "spacing1": "rect.spacing1", "dir2": [0, 1, 0], "count2": "rect.count2", "spacing2": "rect.spacing2"}),
),
),
("pattern_circular", pattern(json!({"type": "circular", "origin": o, "axis": [0, 0, 1], "count": "circ.count", "angle": "circ.angle"}))),
("pattern_path", pattern(json!({"type": "path", "path_sketch": 1, "path": ["l1"], "count": "path.count", "spacing": "path.spacing"}))),
("mirror", json!({"type": "mirror", "features": ["F"], "plane": plane("mirror")})),
("hole_simple", hole(json!({"type": "simple"}))),
("hole_drilled", hole(json!({"type": "drilled", "tip_angle": "hole.tip_angle"}))),
("hole_counterbore", hole(json!({"type": "counterbore", "cb_diameter": "hole.cb_diameter", "cb_depth": "hole.cb_depth"}))),
("hole_countersink", hole(json!({"type": "countersink", "cs_diameter": "hole.cs_diameter", "cs_angle": "hole.cs_angle"}))),
("thread", json!({"type": "thread", "face": o, "length": "thread.length"})),
("loft", json!({"type": "loft", "sections": []})),
("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})),
(
"sweep_options",
json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0, 1, 0]}, "twist": "sweep.twist", "scale": "sweep.scale"}),
),
("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})),
("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})),
("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})),
("split", json!({"type": "split", "body": "B", "plane": plane("split")})),
(
"construction_axis",
json!({"type": "construction_axis", "def": {"type": "two_planes", "a": geo_plane("axis_a"), "b": geo_plane("axis_b")}}),
),
(
"construction_point_planes",
json!({"type": "construction_point", "def": {"type": "three_planes", "a": geo_plane("pt_a"), "b": geo_plane("pt_b"), "c": geo_plane("pt_c")}}),
),
(
"construction_point_along",
json!({"type": "construction_point", "def": {"type": "along_path", "path": {"type": "edge", "at": o}, "t": "pt.t"}}),
),
("split_face", json!({"type": "split_face", "faces": [o], "plane": plane("split_face")})),
("scale", json!({"type": "scale", "bodies": ["B"], "factor": "scale.factor", "factors": ["scale.x", "scale.y", "scale.z"]})),
("offset_face", json!({"type": "offset_face", "faces": [o], "distance": "offset_face.distance"})),
("delete_face", json!({"type": "delete_face", "faces": [o]})),
("bounding_solid", json!({"type": "bounding_solid", "bodies": ["B"], "margin": "bounding.margin"})),
("pipe", json!({"type": "pipe", "path_sketch": 1, "path": ["l1"], "diameter": "pipe.diameter", "wall": "pipe.wall"})),
("emboss", json!({"type": "emboss", "sketch": 1, "profiles": {"type": "all"}, "depth": "emboss.depth"})),
("rib", json!({"type": "rib", "sketch": 1, "curves": ["l1"], "thickness": "rib.thickness", "depth": "rib.depth"})),
("replace_face", json!({"type": "replace_face", "faces": [o], "target": plane("replace_face")})),
("align", json!({"type": "align", "bodies": ["B"], "from": o, "to": o})),
(
"coil",
json!({"type": "coil", "base": ["coil.x", "coil.y", "coil.z"], "diameter": "coil.diameter", "pitch": "coil.pitch", "turns": "coil.turns", "section_size": "coil.section_size", "start_angle": "coil.start_angle"}),
),
("sheet_base", json!({"type": "sheet_base", "sketch": 1, "profiles": {"type": "all"}})),
("sheet_contour", json!({"type": "sheet_contour", "sketch": 1, "curves": ["l1"], "distance": "contour.distance"})),
(
"sheet_flange",
json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius"}),
),
("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})),
(
"sheet_perforate",
json!({"type": "sheet_perforate", "diameter": "perforate.diameter", "pitch": "perforate.pitch", "margin": "perforate.margin"}),
),
(
"sheet_fold",
json!({"type": "sheet_fold", "a": o, "b": [1, 0, 0], "angle": "fold.angle", "radius": "fold.radius", "position": "centerline"}),
),
("sheet_unfold", json!({"type": "sheet_unfold"})),
("sheet_convert", json!({"type": "sheet_convert", "body": "B", "face": o})),
(
"boss",
json!({"type": "boss", "position": o, "diameter": "boss.diameter", "height": "boss.height", "hole_diameter": "boss.hole_diameter", "hole_depth": "boss.hole_depth",
"draft": "boss.draft", "fillet": "boss.fillet", "ribs": "boss.ribs", "rib_thickness": "boss.rib_thickness", "rib_length": "boss.rib_length", "rib_offset": "boss.rib_offset"}),
),
("lip", json!({"type": "lip", "face": o, "width": "lip.width", "height": "lip.height", "gap": "lip.gap"})),
(
"snap_fit",
json!({"type": "snap_fit", "position": o, "hook": [1, 0, 0], "length": "snap.length", "thickness": "snap.thickness", "width": "snap.width", "catch_depth": "snap.catch_depth", "catch_length": "snap.catch_length"}),
),
(
"rest",
json!({"type": "rest", "position": o, "width": "rest.width", "length": "rest.length", "height": "rest.height", "draft": "rest.draft", "thickness": "rest.thickness"}),
),
("boundary_fill", json!({"type": "boundary_fill", "tools": ["B"], "cells": [o]})),
("remove", json!({"type": "remove", "bodies": ["B"]})),
("patch", json!({"type": "patch"})),
("stitch", json!({"type": "stitch", "bodies": ["B"], "tolerance": "stitch.tolerance"})),
("thicken", json!({"type": "thicken", "bodies": ["B"], "thickness": "thicken.thickness", "operation": "new_body"})),
("surface_extrude", json!({"type": "surface_extrude", "sketch": 1, "extent": extent("surface_extrude")})),
(
"surface_revolve",
json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}),
),
("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})),
(
"surface_sweep_options",
json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
),
("surface_loft", json!({"type": "surface_loft", "sections": []})),
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
("unstitch", json!({"type": "unstitch", "body": "B"})),
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})),
("surface_extend", json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance"})),
(
"surface_extend_to_plane",
json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance", "mode": "curvature", "up_to": {"plane": plane("extend")}}),
),
(
"surface_extend_to_face",
json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance", "up_to": {"face": {"body": "B", "point": o}}, "neighbours": false}),
),
(
"surface_ruled",
json!({"type": "surface_ruled", "body": "B", "edges": [o], "distance": "ruled.distance", "mode": "angle", "angle": "ruled.angle",
"direction": [0, 0, 1], "face": [1, 0, 0]}),
),
("surface_trim", json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o})),
("move", json!({"type": "move", "bodies": ["B"], "translate": ["move.x", "move.y", "move.z"], "angle": "move.angle"})),
("import", json!({"type": "import", "file": "a.step", "step": ""})),
("mesh_convert", json!({"type": "mesh_convert", "body": "B", "tolerance": "mesh_convert.tolerance"})),
("mesh_import", json!({"type": "mesh_import", "file": "a.stl", "meshes": []})),
]
}
/// The reference fields a fixture leaves out (they don't change its expressions), set here so
/// every sketch, feature and body reference is exercised: sketches 1–8, feature `F`,
/// construction plane `CP`, body `B`.
pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
let o = [0, 0, 0];
let tool = json!({"body": "B", "point": o});
let extra = match name {
"extrude" | "extrude_thin" | "revolve" | "sweep" | "sweep_options" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip"
| "snap_fit" | "rest" | "surface_offset" => {
json!({"body": "B"})
}
"combine" => json!({"target": "B", "tools": ["B"]}),
"pattern_rectangular" | "pattern_circular" | "pattern_path" | "mirror" => json!({"bodies": ["B"]}),
"hole_simple" | "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"points": {"sketch": 3, "ids": ["p"]}}),
"loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"]}),
"split" | "surface_trim" => json!({"tool": tool}),
"surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}]}),
"split_face" => json!({"body": "B", "tool": tool, "sketch": 6}),
"emboss" => json!({"face": {"body": "B", "at": o}, "targets": ["B"]}),
"sheet_fold" => json!({"sketch": 7, "body": "B"}),
"patch" => json!({"sketch": 8, "body": "B"}),
_ => json!({}),
};
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
v
}
/// The positions and directions a fixture leaves out (they don't change its expressions or
/// references), set here so every point, direction and axis the geometry walks read is present:
/// `to` / `from` picks, line axes, normals and origins. `hole_simple` places its hole at
/// `position` (no sketch points), the other holes at sketch points.
pub(super) fn with_points(name: &str, mut v: Value) -> Value {
let p = |i: f64| json!([i, i + 0.5, i + 0.25]);
let line = json!({"type": "line", "origin": p(9.0), "dir": [0.0, 0.6, 0.8]});
let extra = match name {
"extrude" | "surface_extrude" => {
if let Some(e) = v.get_mut("extent").and_then(Value::as_object_mut) {
e.insert("to".into(), p(1.0));
e.insert("from".into(), p(2.0));
}
json!({})
}
"revolve" => json!({"to": p(3.0), "axis": line}),
"surface_revolve" => json!({"axis": line}),
"hole_simple" => json!({"points": null, "to": p(4.0)}),
"hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"to": p(4.0)}),
"emboss" => json!({"face": {"body": "B", "at": p(5.0)}}),
"sheet_convert" => json!({"bends": [p(6.0)]}),
"sheet_fold" => json!({"fixed": p(7.0)}),
"rest" => json!({"along": [0.6, 0.8, 0.0]}),
"align" => json!({"from_normal": [0.0, 0.0, 1.0], "to_normal": [1.0, 0.0, 0.0]}),
"scale" => json!({"origin": p(8.0)}),
"move" => json!({"rotate_axis": [0.0, 1.0, 0.0]}),
"unstitch" | "reverse_normal" => json!({"faces": [p(10.0)]}),
_ => json!({}),
};
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
v
}
/// The paths of the leaves that differ between two JSON values.
pub(super) fn changed(a: &Value, b: &Value, path: &str, out: &mut Vec<String>) {
match (a, b) {
(Value::Object(x), Value::Object(y)) => {
for (k, v) in x {
changed(v, y.get(k).unwrap_or(&Value::Null), &format!("{path}.{k}"), out);
}
}
(Value::Array(x), Value::Array(y)) => {
for (i, v) in x.iter().enumerate() {
changed(v, y.get(i).unwrap_or(&Value::Null), &format!("{path}[{i}]"), out);
}
}
_ if a != b => out.push(path.trim_start_matches('.').to_string()),
_ => {}
}
}
@@ -0,0 +1,375 @@
body_transfer
sketch
inputs:
Angle = sketch.angle (Angle)
Offset = sketch.offset (Length)
expressions: sketch.angle, sketch.offset
sketch_along_path
inputs:
Distance (0 to 1) = sketch.t (Unitless)
expressions: sketch.t
extrude
inputs:
Distance = extrude.distance (Length)
Distance 2 = extrude.distance2 (Length)
Start offset = extrude.start_offset (Length)
Taper angle = extrude.taper (Angle)
To offset = extrude.to_offset (Length)
expressions: extrude.distance, extrude.distance2, extrude.start_offset, extrude.taper, extrude.to_offset
extrude_thin
inputs:
Distance = thin.distance (Length)
Distance 2 = thin.distance2 (Length)
Start offset = thin.start_offset (Length)
Taper angle = thin.taper (Angle)
To offset = thin.to_offset (Length)
Thickness = thin.thickness (Length)
Thickness 2 = thin.thickness2 (Length)
expressions: thin.distance, thin.distance2, thin.start_offset, thin.taper, thin.to_offset, thin.thickness, thin.thickness2
revolve
inputs:
Angle = revolve.angle (Angle)
Angle 2 = revolve.angle2 (Angle)
expressions: revolve.angle, revolve.angle2
fillet_constant
inputs:
Radius = fillet.radius (Length)
expressions: fillet.radius
fillet_chord
inputs:
Chord length = fillet.radius (Length)
expressions: fillet.radius
fillet_curvature
inputs:
Equivalent radius = fillet.radius (Length)
expressions: fillet.radius
fillet_variable
inputs:
Start radius = fillet.radius (Length)
End radius = fillet.radius2 (Length)
Mid radius = fillet.mid (Length)
expressions: fillet.radius, fillet.radius2, fillet.mid
fillet_spline
inputs:
Start radius = fillet.radius (Length)
Control radius = fillet.r1 (Length)
Control radius = fillet.r2 (Length)
End radius = fillet.r3 (Length)
expressions: fillet.radius, fillet.r1, fillet.r2, fillet.r3
chamfer
inputs:
Distance = chamfer.distance (Length)
Distance 2 = chamfer.distance2 (Length)
Angle = chamfer.angle (Angle)
expressions: chamfer.distance, chamfer.distance2, chamfer.angle
box
inputs:
Length = box.length (Length)
Width = box.width (Length)
Height = box.height (Length)
expressions: box.length, box.width, box.height, box.x, box.y, box.z
cylinder
inputs:
Radius = cyl.radius (Length)
Height = cyl.height (Length)
expressions: cyl.radius, cyl.height, cyl.x, cyl.y, cyl.z
sphere
inputs:
Radius = sph.radius (Length)
expressions: sph.radius, sph.x, sph.y, sph.z
torus
inputs:
Major radius = tor.major (Length)
Minor radius = tor.minor (Length)
expressions: tor.major, tor.minor, tor.x, tor.y, tor.z
combine
pattern_rectangular
inputs:
Quantity = rect.count1 (Unitless)
Spacing = rect.spacing1 (Length)
Quantity 2 = rect.count2 (Unitless)
Spacing 2 = rect.spacing2 (Length)
expressions: rect.count1, rect.spacing1, rect.count2, rect.spacing2
pattern_circular
inputs:
Quantity = circ.count (Unitless)
Total angle = circ.angle (Angle)
expressions: circ.count, circ.angle
pattern_path
inputs:
Quantity = path.count (Unitless)
Distance = path.spacing (Length)
expressions: path.count, path.spacing
mirror
inputs:
Angle = mirror.angle (Angle)
Offset = mirror.offset (Length)
expressions: mirror.angle, mirror.offset
hole_simple
inputs:
Diameter = hole.diameter (Length)
Depth = hole.depth (Length)
expressions: hole.diameter, hole.depth
hole_drilled
inputs:
Diameter = hole.diameter (Length)
Depth = hole.depth (Length)
Tip angle = hole.tip_angle (Angle)
expressions: hole.diameter, hole.depth, hole.tip_angle
hole_counterbore
inputs:
Diameter = hole.diameter (Length)
Depth = hole.depth (Length)
Counterbore diameter = hole.cb_diameter (Length)
Counterbore depth = hole.cb_depth (Length)
expressions: hole.diameter, hole.depth, hole.cb_diameter, hole.cb_depth
hole_countersink
inputs:
Diameter = hole.diameter (Length)
Depth = hole.depth (Length)
Countersink diameter = hole.cs_diameter (Length)
Countersink angle = hole.cs_angle (Angle)
expressions: hole.diameter, hole.depth, hole.cs_diameter, hole.cs_angle
thread
inputs:
Length = thread.length (Length)
expressions: thread.length
loft
sweep
sweep_options
inputs:
Twist = sweep.twist (Angle)
Scale = sweep.scale (Unitless)
expressions: sweep.twist, sweep.scale
shell
inputs:
Thickness = shell.thickness (Length)
expressions: shell.thickness
draft
inputs:
Angle = draft.angle (Angle)
Angle = draft_neutral.angle (Angle)
Offset = draft_neutral.offset (Length)
expressions: draft.angle, draft_neutral.angle, draft_neutral.offset
construction_plane
inputs:
Angle = cplane.angle (Angle)
Offset = cplane.offset (Length)
expressions: cplane.angle, cplane.offset
split
inputs:
Angle = split.angle (Angle)
Offset = split.offset (Length)
expressions: split.angle, split.offset
construction_axis
inputs:
Angle = axis_a.angle (Angle)
Offset = axis_a.offset (Length)
Angle = axis_b.angle (Angle)
Offset = axis_b.offset (Length)
expressions: axis_a.angle, axis_a.offset, axis_b.angle, axis_b.offset
construction_point_planes
inputs:
Angle = pt_a.angle (Angle)
Offset = pt_a.offset (Length)
Angle = pt_b.angle (Angle)
Offset = pt_b.offset (Length)
Angle = pt_c.angle (Angle)
Offset = pt_c.offset (Length)
expressions: pt_a.angle, pt_a.offset, pt_b.angle, pt_b.offset, pt_c.angle, pt_c.offset
construction_point_along
inputs:
Distance (0 to 1) = pt.t (Unitless)
expressions: pt.t
split_face
inputs:
Angle = split_face.angle (Angle)
Offset = split_face.offset (Length)
expressions: split_face.angle, split_face.offset
scale
inputs:
Scale factor = scale.factor (Unitless)
X scale = scale.x (Unitless)
Y scale = scale.y (Unitless)
Z scale = scale.z (Unitless)
expressions: scale.factor, scale.x, scale.y, scale.z
offset_face
inputs:
Distance = offset_face.distance (Length)
expressions: offset_face.distance
delete_face
bounding_solid
inputs:
Margin = bounding.margin (Length)
expressions: bounding.margin
pipe
inputs:
Diameter = pipe.diameter (Length)
Wall = pipe.wall (Length)
expressions: pipe.diameter, pipe.wall
emboss
inputs:
Depth = emboss.depth (Length)
expressions: emboss.depth
rib
inputs:
Thickness = rib.thickness (Length)
Depth = rib.depth (Length)
expressions: rib.thickness, rib.depth
replace_face
inputs:
Angle = replace_face.angle (Angle)
Offset = replace_face.offset (Length)
expressions: replace_face.angle, replace_face.offset
align
coil
inputs:
Diameter = coil.diameter (Length)
Pitch = coil.pitch (Length)
Revolutions = coil.turns (Unitless)
Section size = coil.section_size (Length)
Start angle = coil.start_angle (Angle)
expressions: coil.diameter, coil.pitch, coil.turns, coil.section_size, coil.start_angle, coil.x, coil.y, coil.z
sheet_base
sheet_contour
inputs:
Distance = contour.distance (Length)
expressions: contour.distance
sheet_flange
inputs:
Height = flange.height (Length)
Angle = flange.angle (Angle)
Bend radius = flange.radius (Length)
Edge angle = flange.e1_angle (Angle)
Edge height = flange.e1_height (Length)
Edge height = flange.e2_height (Length)
expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height
sheet_hem
inputs:
Length = hem.length (Length)
Gap = hem.gap (Length)
expressions: hem.length, hem.gap
sheet_perforate
inputs:
Diameter = perforate.diameter (Length)
Pitch = perforate.pitch (Length)
Margin = perforate.margin (Length)
expressions: perforate.diameter, perforate.pitch, perforate.margin
sheet_fold
inputs:
Angle = fold.angle (Angle)
Bend radius = fold.radius (Length)
expressions: fold.angle, fold.radius
sheet_unfold
sheet_convert
boss
inputs:
Diameter = boss.diameter (Length)
Height = boss.height (Length)
Hole diameter = boss.hole_diameter (Length)
Hole depth = boss.hole_depth (Length)
Draft angle = boss.draft (Angle)
Root fillet = boss.fillet (Length)
Ribs = boss.ribs (Unitless)
Rib thickness = boss.rib_thickness (Length)
Rib length = boss.rib_length (Length)
Rib offset = boss.rib_offset (Length)
expressions: boss.diameter, boss.height, boss.hole_diameter, boss.hole_depth, boss.draft, boss.fillet, boss.ribs, boss.rib_thickness, boss.rib_length, boss.rib_offset
lip
inputs:
Width = lip.width (Length)
Height = lip.height (Length)
Gap = lip.gap (Length)
expressions: lip.width, lip.height, lip.gap
snap_fit
inputs:
Length = snap.length (Length)
Thickness = snap.thickness (Length)
Width = snap.width (Length)
Catch depth = snap.catch_depth (Length)
Catch length = snap.catch_length (Length)
expressions: snap.length, snap.thickness, snap.width, snap.catch_depth, snap.catch_length
rest
inputs:
Width = rest.width (Length)
Height = rest.height (Length)
Length = rest.length (Length)
Draft angle = rest.draft (Angle)
Wall thickness = rest.thickness (Length)
expressions: rest.width, rest.height, rest.length, rest.draft, rest.thickness
boundary_fill
remove
patch
stitch
inputs:
Tolerance = stitch.tolerance (Length)
expressions: stitch.tolerance
thicken
inputs:
Thickness = thicken.thickness (Length)
expressions: thicken.thickness
surface_extrude
inputs:
Distance = surface_extrude.distance (Length)
Distance 2 = surface_extrude.distance2 (Length)
Start offset = surface_extrude.start_offset (Length)
Taper angle = surface_extrude.taper (Angle)
To offset = surface_extrude.to_offset (Length)
expressions: surface_extrude.distance, surface_extrude.distance2, surface_extrude.start_offset, surface_extrude.taper, surface_extrude.to_offset
surface_revolve
inputs:
Angle = srev.angle (Angle)
Angle 2 = srev.angle2 (Angle)
expressions: srev.angle, srev.angle2
surface_sweep
surface_sweep_options
inputs:
Twist = surface_sweep.twist (Angle)
Scale = surface_sweep.scale (Unitless)
expressions: surface_sweep.twist, surface_sweep.scale
surface_loft
surface_offset
inputs:
Distance = soff.distance (Length)
Tolerance = soff.tolerance (Length)
expressions: soff.distance, soff.tolerance
unstitch
reverse_normal
surface_untrim
surface_extend
inputs:
Distance = extend.distance (Length)
expressions: extend.distance
surface_extend_to_plane
inputs:
Distance = extend.distance (Length)
Angle = extend.angle (Angle)
Offset = extend.offset (Length)
expressions: extend.distance, extend.angle, extend.offset
surface_extend_to_face
inputs:
Distance = extend.distance (Length)
expressions: extend.distance
surface_ruled
inputs:
Distance = ruled.distance (Length)
Angle = ruled.angle (Angle)
expressions: ruled.distance, ruled.angle
surface_trim
inputs:
Angle = trim.angle (Angle)
Offset = trim.offset (Length)
expressions: trim.angle, trim.offset
move
inputs:
X distance = move.x (Length)
Y distance = move.y (Length)
Z distance = move.z (Length)
Angle = move.angle (Angle)
expressions: move.x, move.y, move.z, move.angle
import
mesh_convert
inputs:
Tolerance = mesh_convert.tolerance (Length)
expressions: mesh_convert.tolerance
mesh_import
@@ -0,0 +1,175 @@
body_transfer
sketch
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
sketch_along_path
moves: plane.path.at[0], plane.path.at[1], plane.path.at[2]
extrude
moves: extent.from[0], extent.from[1], extent.from[2], extent.to[0], extent.to[1], extent.to[2]
face points: [1, 1.5, 1.25], [2, 2.5, 2.25]
profiles: 1 all
extrude_thin
revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2], to[0], to[1], to[2]
face points: [3, 3.5, 3.25]
profiles: 1 all
fillet_constant
moves: edges[0][0], edges[0][1], edges[0][2]
fillet_chord
moves: edges[0][0], edges[0][1], edges[0][2]
fillet_curvature
moves: edges[0][0], edges[0][1], edges[0][2]
fillet_variable
moves: edges[0][0], edges[0][1], edges[0][2], style.start[0], style.start[1], style.start[2]
fillet_spline
moves: edges[0][0], edges[0][1], edges[0][2], style.start[0], style.start[1], style.start[2]
chamfer
moves: edges[0][0], edges[0][1], edges[0][2]
box
moves: corner[0], corner[1], corner[2]
placement: box.x, box.y, box.z
cylinder
moves: axis[0], axis[2], base[0], base[1], base[2]
placement: cyl.x, cyl.y, cyl.z
sphere
moves: center[0], center[1], center[2]
placement: sph.x, sph.y, sph.z
torus
moves: center[0], center[1], center[2]
placement: tor.x, tor.y, tor.z
combine
pattern_rectangular
moves: pattern.dir1[0], pattern.dir1[1], pattern.dir2[1], pattern.dir2[2]
pattern_circular
moves: pattern.axis[0], pattern.axis[2], pattern.origin[0], pattern.origin[1], pattern.origin[2]
pattern_path
mirror
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
hole_simple
moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2]
face points: [0, 0, 0], [4, 4.5, 4.25]
hole_drilled
moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2]
face points: [4, 4.5, 4.25]
hole_counterbore
moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2]
face points: [4, 4.5, 4.25]
hole_countersink
moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2]
face points: [4, 4.5, 4.25]
thread
moves: face[0], face[1], face[2]
face points: [0, 0, 0]
loft
profiles: 4 all, 5 all
sweep
profiles: 1 all
sweep_options
moves: orientation.direction[1], orientation.direction[2]
profiles: 1 all
shell
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
draft
moves: faces[0][0], faces[0][1], faces[0][2], neutral.axis_dir[0], neutral.axis_dir[1], neutral.axis_origin[0], neutral.axis_origin[1], neutral.axis_origin[2], pull[0], pull[2]
face points: [0, 0, 0]
construction_plane
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
split
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
construction_axis
moves: def.a.plane.axis_dir[0], def.a.plane.axis_dir[1], def.a.plane.axis_origin[0], def.a.plane.axis_origin[1], def.a.plane.axis_origin[2], def.b.plane.axis_dir[0], def.b.plane.axis_dir[1], def.b.plane.axis_origin[0], def.b.plane.axis_origin[1], def.b.plane.axis_origin[2]
construction_point_planes
moves: def.a.plane.axis_dir[0], def.a.plane.axis_dir[1], def.a.plane.axis_origin[0], def.a.plane.axis_origin[1], def.a.plane.axis_origin[2], def.b.plane.axis_dir[0], def.b.plane.axis_dir[1], def.b.plane.axis_origin[0], def.b.plane.axis_origin[1], def.b.plane.axis_origin[2], def.c.plane.axis_dir[0], def.c.plane.axis_dir[1], def.c.plane.axis_origin[0], def.c.plane.axis_origin[1], def.c.plane.axis_origin[2]
construction_point_along
moves: def.path.at[0], def.path.at[1], def.path.at[2]
split_face
moves: faces[0][0], faces[0][1], faces[0][2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
face points: [0, 0, 0]
scale
moves: origin[0], origin[1], origin[2]
offset_face
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
delete_face
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
bounding_solid
pipe
emboss
face points: [5, 5.5, 5.25]
profiles: 1 all
rib
replace_face
moves: faces[0][0], faces[0][1], faces[0][2], target.axis_dir[0], target.axis_dir[1], target.axis_origin[0], target.axis_origin[1], target.axis_origin[2]
face points: [0, 0, 0]
align
moves: from[0], from[1], from[2], from_normal[0], from_normal[2], to[0], to[1], to[2], to_normal[0], to_normal[1]
coil
moves: axis[0], axis[2], base[0], base[1], base[2]
placement: coil.x, coil.y, coil.z
sheet_base
profiles: 1 all
sheet_contour
sheet_flange
moves: edges[0].point[0], edges[0].point[1], edges[0].point[2], edges[1].point[0], edges[1].point[1], edges[1].point[2]
sheet_hem
moves: edges[0][0], edges[0][1], edges[0][2]
sheet_perforate
sheet_fold
moves: a[0], a[1], a[2], b[1], b[2], fixed[0], fixed[1], fixed[2]
sheet_unfold
sheet_convert
moves: bends[0][0], bends[0][1], bends[0][2], face[0], face[1], face[2]
face points: [0, 0, 0]
boss
moves: direction[0], direction[2], position[0], position[1], position[2]
face points: [0, 0, 0]
lip
moves: face[0], face[1], face[2]
snap_fit
moves: direction[0], direction[2], hook[0], hook[1], position[0], position[1], position[2]
face points: [0, 0, 0]
rest
moves: along[0], along[1], along[2], direction[0], direction[2], position[0], position[1], position[2]
face points: [0, 0, 0]
boundary_fill
moves: cells[0][0], cells[0][1], cells[0][2]
remove
patch
profiles: 8 all
stitch
thicken
surface_extrude
moves: extent.from[0], extent.from[1], extent.from[2], extent.to[0], extent.to[1], extent.to[2]
face points: [1, 1.5, 1.25], [2, 2.5, 2.25]
surface_revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
surface_sweep
surface_sweep_options
surface_loft
surface_offset
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
unstitch
moves: faces[0][0], faces[0][1], faces[0][2]
reverse_normal
moves: faces[0][0], faces[0][1], faces[0][2]
surface_untrim
moves: face.point[0], face.point[1], face.point[2], holes[0][0], holes[0][1], holes[0][2]
face points: [0, 0, 0]
surface_extend
moves: edges[0][0], edges[0][1], edges[0][2]
surface_extend_to_plane
moves: edges[0][0], edges[0][1], edges[0][2], up_to.plane.axis_dir[0], up_to.plane.axis_dir[1], up_to.plane.axis_origin[0], up_to.plane.axis_origin[1], up_to.plane.axis_origin[2]
surface_extend_to_face
moves: edges[0][0], edges[0][1], edges[0][2], up_to.face.point[0], up_to.face.point[1], up_to.face.point[2]
surface_ruled
moves: direction[0], direction[2], edges[0][0], edges[0][1], edges[0][2], face[1], face[2]
face points: [1, 0, 0]
surface_trim
moves: keep[0], keep[1], keep[2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
move
moves: rotate_axis[1], rotate_axis[2]
import
mesh_convert
mesh_import
@@ -0,0 +1,197 @@
body_transfer
remap_bodies: bodies[0]
sketch
remap_refs: plane.base.base.name
sketch_along_path
extrude
sketches: [1]
remap_refs: sketch
remap_bodies: targets[0]
extrude_thin
sketches: [1]
remap_refs: sketch
remap_bodies: targets[0]
revolve
sketches: [1]
remap_refs: sketch
remap_bodies: targets[0]
fillet_constant
remap_bodies: body
fillet_chord
remap_bodies: body
fillet_curvature
remap_bodies: body
fillet_variable
remap_bodies: body
fillet_spline
remap_bodies: body
chamfer
remap_bodies: body
box
cylinder
sphere
torus
combine
remap_bodies: target, tools[0]
pattern_rectangular
remap_refs: features[0]
remap_bodies: bodies[0]
pattern_circular
remap_refs: features[0]
remap_bodies: bodies[0]
pattern_path
sketches: [1]
remap_refs: features[0], pattern.path_sketch
remap_bodies: bodies[0]
mirror
remap_refs: features[0], plane.base.base.name
remap_bodies: bodies[0]
hole_simple
sketches: [3]
remap_refs: points.sketch
hole_drilled
sketches: [3]
remap_refs: points.sketch
hole_counterbore
sketches: [3]
remap_refs: points.sketch
hole_countersink
sketches: [3]
remap_refs: points.sketch
thread
loft
sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch
remap_bodies: targets[0]
sweep
sketches: [1, 2]
remap_refs: path_sketch, sketch
remap_bodies: targets[0]
sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
remap_bodies: targets[0]
shell
remap_bodies: body
draft
remap_refs: neutral.base.base.name
remap_bodies: body
construction_plane
remap_refs: plane.base.base.name
split
remap_refs: plane.base.base.name
remap_bodies: body, tool.body
construction_axis
construction_point_planes
construction_point_along
split_face
sketches: [6]
remap_refs: plane.base.base.name, sketch
remap_bodies: body, tool.body
scale
remap_bodies: bodies[0]
offset_face
remap_bodies: body
delete_face
remap_bodies: body
bounding_solid
remap_bodies: bodies[0]
pipe
sketches: [1]
remap_refs: path_sketch
remap_bodies: targets[0]
emboss
sketches: [1]
remap_refs: sketch
remap_bodies: face.body, targets[0]
rib
sketches: [1]
remap_refs: sketch
replace_face
remap_refs: target.base.base.name
remap_bodies: body
align
remap_bodies: bodies[0]
coil
remap_bodies: targets[0]
sheet_base
sketches: [1]
remap_refs: sketch
sheet_contour
sketches: [1]
remap_refs: sketch
sheet_flange
remap_bodies: body
sheet_hem
remap_bodies: body
sheet_perforate
remap_bodies: body
sheet_fold
sketches: [7]
remap_refs: sketch
remap_bodies: body
sheet_unfold
remap_bodies: body
sheet_convert
remap_bodies: body
boss
remap_bodies: body
lip
remap_bodies: body
snap_fit
remap_bodies: body
rest
remap_bodies: body
boundary_fill
remap_bodies: tools[0]
remove
remap_bodies: bodies[0]
patch
sketches: [8]
remap_refs: sketch
remap_bodies: body
stitch
remap_bodies: bodies[0]
thicken
remap_bodies: bodies[0], targets[0]
surface_extrude
sketches: [1]
remap_refs: sketch
surface_revolve
sketches: [1]
remap_refs: sketch
surface_sweep
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_loft
sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch
surface_offset
remap_bodies: body
unstitch
remap_bodies: body
reverse_normal
remap_bodies: body
surface_untrim
remap_bodies: face.body
surface_extend
remap_bodies: body
surface_extend_to_plane
remap_refs: up_to.plane.base.base.name
remap_bodies: body
surface_extend_to_face
remap_bodies: body, up_to.face.body
surface_ruled
remap_bodies: body
surface_trim
remap_refs: plane.base.base.name
remap_bodies: body, tool.body
move
remap_bodies: bodies[0]
import
mesh_convert
remap_bodies: body
mesh_import
+205 -3
View File
@@ -138,13 +138,75 @@
//! Under the same version, a fillet's style can be `curvature` (a G2 quintic blend; `radius`
//! is the equivalent circular setback). Fillets of the other styles save as before; 0.9.0
//! would refuse the unknown style.
//! - 20: an Extrude can be thin (`thin`: `curves`, `thickness`, optional `side` and
//! `thickness2`): a wall along sketch curves instead of its profiles. Version 19 designs need
//! no transformation, and an extrude without `thin` saves as before; the version stamp makes
//! 0.10.0 (which reads up to 19) ask for an update instead of ignoring the wall and filling
//! the sketch's profiles.
//! A `sheet_perforate` feature (Perforate: `diameter`, `pitch`, optional `margin`,
//! `pattern` and `body`): a perforation field stored on a sheet, for the flat pattern, the DXF
//! and the measure. It is also first saved in 20; 0.10.0 asks for an update instead of
//! meeting a feature it can't read.
//! Associative, editable sketch patterns (`patterns` in a sketch) and their point/radius
//! relations are also first saved in 20. Old sketch copies stay independent: migration cannot
//! infer which curves formed a pattern.
//! The saved visibility can also list hidden assembly joints (`visibility.hidden_joints`,
//! joint ids): their glyphs aren't drawn, but they still constrain and drive the parts. A
//! design without it shows every joint, as it always did; 0.10.0 would show them all.
//! - 21: named render setups (`render: {setups: {name: setup}}`): renderer-independent render
//! intent (camera, resolution, exposure, environment). Version 20 designs need no
//! transformation (an absent field means no setups, and empty render state is not saved); the
//! version stamp makes 0.11.0 (which reads up to 20) ask for an update instead of silently
//! dropping saved setups.
//! Also first saved in 21, a design can carry `metadata` (#1015): design properties
//! (`project`, `part_number`, `revision`, `author`, `organization`, `description`, `license`,
//! each text) and other tools' data, each tool's under its own reverse-DNS key
//! (`com.example.tool`). Every key is kept as it is, and nothing in it is checked when the
//! design opens. A design without it saves as before. 0.11.x refuses format 21, so no
//! released build drops the properties when it saves.
//! Under the same version, looks can explicitly select opaque PBR parameters (`pbr`: metal
//! fraction, roughness, dielectric IOR). Absent fields keep the scalar shading a look always
//! had; 0.11.0 asks for an update instead of silently dropping the response.
//! A `SurfaceUntrim` feature (Untrim: a `face` as `body` and `point`, optional `holes`
//! points, `outer` and `in_place`) is also first saved in 21; 0.11.0 asks for an update
//! instead of meeting a feature it can't read.
//! A `SurfaceRuled` feature (Ruled Surface: `body`, `edges` points, `distance`, optional
//! `mode`, `angle`, `direction`, `flip`, `corners` and `face`) is also first saved in 21;
//! 0.11.0 asks for an update instead of meeting a feature it can't read.
//! Under the same version, a `SurfaceExtend` can say how it continues the surface (`mode`:
//! `tangent` or `curvature`; `natural` when absent), extend up to something instead of its
//! `distance` (`up_to`: a `plane`, a `face` as `body` and `point`, or `bounds`) and keep the
//! neighbouring edges as they are (`neighbours: false`). An Extend without them builds the
//! planar extension it always did and saves as before; 0.11.0 asks for an update instead of
//! dropping them and extending a plane by the distance.
//! Under the same version, an extrude's extent records how a symmetric `distance` is read
//! (`convention`: `total_distance`, split evenly around the start plane, which new solid
//! extrudes save; or `legacy_per_side`). An extent without it keeps the per-side reading
//! designs always had, so their thickness and parameters don't change; 0.11.0 asks for an
//! update instead of doubling a total-distance extrude.
//! Under the same version, a Convert to Sheet Metal bend point can lie in the middle of a
//! chamfer (a flat strip between two faces), which then is one bend at the two faces' virtual
//! sharp; 0.11.0 bent the chamfer's nearest edge instead or found no edge there. Bend points
//! on edges convert as before.
//! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat
//! faces tangent to it), which then is one bend at the two faces' virtual sharp with the
//! rule's radius; 0.11.0 found no edge there or refused the curved face.
//! Also under 21, a Sweep and a Surface Sweep can carry `orientation` (`parallel`, or
//! `{direction: [x, y, z]}`; absent: perpendicular), `twist` (an angle expression) and `scale`
//! (the end scale, an expression). A sweep without them saves as before; 0.11.x refuses
//! format 21, so no released build sweeps a twisted or scaled profile straight.
//! Nothing new is saved for converted sheets' corners and bend ends, but a design converts as
//! the rule says when it is evaluated: corners between the walls take the rule's corner relief
//! and a bend ending beside more of the fixed face its bend relief (0.11.0 left every corner a
//! notch and cut no bend relief), and a wall on part of an edge bends only where it stands
//! (0.11.0 refused it).
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 19;
pub const FORMAT: u32 = 21;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -161,7 +223,7 @@ 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; 18] = [
const JSON_STEPS: [JsonStep; 20] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -180,6 +242,8 @@ const JSON_STEPS: [JsonStep; 18] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
@@ -378,7 +442,7 @@ pub fn read(s: &str) -> Result<Document> {
"this design was saved by a newer SolidCraft (file format {version}; this version reads formats up to {FORMAT}): update SolidCraft to open it"
)));
}
check_numbers(&v, 0)?;
check_design_numbers(&v)?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
crate::face_anchor::validate_face_anchors_json(&v)?;
@@ -455,11 +519,18 @@ pub fn read(s: &str) -> Result<Document> {
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
}
}
if !all_unique(d.features.iter().map(|f| f.id))
|| !all_unique(d.components.iter().map(|c| c.id))
|| !all_unique(d.occurrences.iter().map(|o| o.id))
{
return Err(DocError::Invalid("damaged design: duplicate feature, component or occurrence id".into()));
}
let component_ids: std::collections::BTreeSet<_> = d.components.iter().map(|component| component.id).chain(std::iter::once(0)).collect();
for feature in &d.features {
if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind {
sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_patterns().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
}
if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind
@@ -476,10 +547,21 @@ pub fn read(s: &str) -> Result<Document> {
// Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
crate::format_offsets::validate_typed(&d)?;
normalise(&mut d)?;
// `next_id` is trusted by nothing: a hand-edited or generated file may omit it or leave it stale,
// and the next feature, component or occurrence would then reuse an id that is already taken.
let largest =
d.features.iter().map(|f| f.id).chain(d.components.iter().map(|c| c.id)).chain(d.occurrences.iter().map(|o| o.id)).max().unwrap_or(0);
let floor = largest.checked_add(1).ok_or_else(|| DocError::Invalid("damaged design: the id space is exhausted".into()))?;
d.next_id = d.next_id.max(floor).max(1);
d.format = format_string(FORMAT);
Ok(d)
}
fn all_unique(ids: impl Iterator<Item = u64>) -> bool {
let mut seen = std::collections::BTreeSet::new();
ids.into_iter().all(|id| seen.insert(id))
}
/// The caps interactive editing enforces, applied to the raw JSON so an oversized sketch is
/// never deserialised. Every sketch in the timeline is solved when the design opens, and the
/// solver sizes its dense matrices by the sketch, so a file with hundreds of thousands of
@@ -496,9 +578,34 @@ fn validate_sketch_sizes(value: &Value) -> Result<()> {
if len("points") > solidcraft_sketch::MAX_POINTS
|| len("curves") > solidcraft_sketch::MAX_CURVES
|| len("constraints") > solidcraft_sketch::MAX_CONSTRAINTS
|| len("patterns") > solidcraft_sketch::MAX_CONSTRAINTS
{
return Err(invalid());
}
if let Some(patterns) = sketch.get("patterns").and_then(Value::as_array) {
let mut points = 0usize;
let mut curves = 0usize;
let mut constraints = 0usize;
for pattern in patterns {
let Some(entities) = pattern.get("entities").and_then(Value::as_array) else { return Err(invalid()) };
let Some(instances) = pattern.get("instances").and_then(Value::as_array) else { return Err(invalid()) };
if entities.len() > solidcraft_sketch::MAX_CURVES + solidcraft_sketch::MAX_POINTS || instances.len() >= 500 {
return Err(invalid());
}
for instance in instances {
let size = |key| instance.get(key).and_then(Value::as_array).map_or(0, Vec::len);
points = points.saturating_add(size("points"));
curves = curves.saturating_add(size("curves"));
constraints = constraints.saturating_add(size("constraints"));
if points > solidcraft_sketch::MAX_POINTS
|| curves > solidcraft_sketch::MAX_CURVES
|| constraints > solidcraft_sketch::MAX_CONSTRAINTS
{
return Err(invalid());
}
}
}
}
}
Ok(())
}
@@ -804,6 +911,16 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
/// outside what the kernel's tolerances serve.
const MAX_REAL: f64 = 1e9;
/// Every number the design itself holds is in range ([`check_numbers`]). `metadata` is left
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
/// already bounds its nesting.
fn check_design_numbers(v: &Value) -> Result<()> {
match v {
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != "metadata").try_for_each(|(_, x)| check_numbers(x, 1)),
v => check_numbers(v, 0),
}
}
/// Every non-integer number in the file is finite and within ±`MAX_REAL` (a damaged or hostile
/// file is refused instead of overflowing the geometry later).
fn check_numbers(v: &Value, depth: usize) -> Result<()> {
@@ -1031,9 +1148,11 @@ mod tests {
document
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch,
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "10".into(),
direction: Direction::Negative,
distance2: None,
@@ -1594,4 +1713,87 @@ mod tests {
assert!(error.contains("sketch too large"), "{key}: {error}");
}
}
fn two_components_one_occurrence() -> Document {
let mut d = Document::new("ids");
let a = d.add_component(None, 0).unwrap();
d.add_component(None, 0).unwrap();
d.add_occurrence(a, 0, crate::IDENTITY).unwrap();
d
}
fn edited(d: &Document, f: impl FnOnce(&mut Value)) -> String {
let mut v: Value = serde_json::from_str(&d.to_json()).unwrap();
f(&mut v);
v.to_string()
}
#[test]
fn missing_or_stale_next_id_does_not_reuse_existing_ids() {
let d = two_components_one_occurrence();
let largest = d.components.iter().map(|c| c.id).chain(d.occurrences.iter().map(|o| o.id)).max().unwrap();
for stale in [None, Some(0), Some(1)] {
let json = edited(&d, |v| {
let o = v.as_object_mut().unwrap();
match stale {
Some(n) => o.insert("next_id".into(), n.into()),
None => o.remove("next_id"),
};
});
let mut back = read(&json).unwrap();
assert!(back.next_id > largest, "next_id {} after load with {stale:?}", back.next_id);
let fresh = back.add_component(None, 0).unwrap();
assert!(fresh > largest, "new component reused id {fresh}");
}
}
#[test]
fn duplicate_component_or_occurrence_ids_are_a_damaged_design() {
let d = two_components_one_occurrence();
let dup_component = edited(&d, |v| v["components"][1]["id"] = v["components"][0]["id"].clone());
let dup_occurrence = edited(&d, |v| {
let first = v["occurrences"][0].clone();
v["occurrences"].as_array_mut().unwrap().push(first);
});
for json in [dup_component, dup_occurrence] {
let error = read(&json).unwrap_err().to_string();
assert!(error.contains("damaged design"), "{error}");
}
}
#[test]
fn exhausted_id_space_is_an_error_not_a_panic() {
let d = two_components_one_occurrence();
let json = edited(&d, |v| v["next_id"] = u64::MAX.into());
let mut back = read(&json).unwrap();
let before = back.components.len();
assert!(back.add_component(None, 0).is_err());
assert_eq!(back.components.len(), before);
let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into());
assert!(read(&json).unwrap_err().to_string().contains("damaged design"));
}
/// A sweep saved before its options existed loads as a plain sweep and saves as it was; the
/// options round-trip.
#[test]
fn sweep_options_are_optional_and_round_trip() {
use crate::{FeatureKind, SweepOrientation};
let old =
serde_json::json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], "operation": "new_body"});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &k else { panic!("{k:?}") };
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let old = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let new = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"});
let k: FeatureKind = serde_json::from_value(new.clone()).unwrap();
let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") };
assert_eq!(*orientation, SweepOrientation::Direction(solidcraft_geom::Vec3::Y));
assert_eq!(serde_json::to_value(&k).unwrap(), new);
let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap();
assert_eq!(parallel, SweepOrientation::Parallel);
}
}
+2
View File
@@ -423,9 +423,11 @@ mod tests {
document
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: if missing_sketch { 999_999 } else { id },
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
+754 -46
View File
@@ -7,14 +7,17 @@
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
//! are occurrence world transforms, `F` the joint frames in each component's own frame, and `q`
//! the joint's values (angles in radians, distances in mm). The solver walks the graph from
//! grounded occurrences (and the root, which never moves); joints that close a loop are checked
//! and reported when they disagree.
//! grounded occurrences (and the root, which never moves). Joints that close a loop (a four-bar
//! linkage, a slider-crank) are met by moving the values of the other joints around the loop;
//! those that still disagree are reported.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use solidcraft_sketch::linalg;
use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul};
/// Joints the solver (and the URDF export) reads at most.
@@ -320,6 +323,9 @@ pub struct Solution {
/// New world transforms of occurrences the joints place.
#[serde(skip)]
pub world: BTreeMap<u64, Mat>,
/// New values of the joints that moved to close loops (joint id → values).
#[serde(skip)]
pub values: BTreeMap<u64, Vec<f64>>,
/// Joints that disagree with the placement found through others: (joint id, name, why).
pub conflicts: Vec<(u64, String, String)>,
/// Joints that could not be applied (bad origin…): (joint id, name, why).
@@ -330,21 +336,60 @@ pub struct Solution {
pub fixed: Vec<u64>,
}
/// Joint values by joint id.
type Values = BTreeMap<u64, Vec<f64>>;
/// How far two placements differ: (translation mm, rotation rad).
fn mismatch(a: &Mat, b: &Mat) -> (f64, f64) {
let t = Vec3::new(a[3][0] - b[3][0], a[3][1] - b[3][1], a[3][2] - b[3][2]).len();
let mut tr = 0.0;
for i in 0..3 {
for k in 0..3 {
tr += a[i][k] * b[i][k];
// The turn between them from both its cosine and its sine: the cosine alone loses small
// angles to rounding.
let c = |i: usize, k: usize| (0..3).map(|n| a[i][n] * b[k][n]).sum::<f64>();
let sin2 = Vec3::new(c(1, 2) - c(2, 1), c(2, 0) - c(0, 2), c(0, 1) - c(1, 0)).len();
(t, sin2.atan2(c(0, 0) + c(1, 1) + c(2, 2) - 1.0))
}
/// Passes over the motion links at most: a chain of up to this many links carries its value
/// through, whatever order its links are stored in.
const MAX_LINK_PASSES: usize = 64;
/// Motion links, pass after pass until nothing changes (a chain stored from its end takes a pass
/// per link), each linked value within its joint's limits as it is set. The result is the same
/// whatever the values were before and however the links are stored, so applying them again
/// changes nothing (links that form a cycle stop after `MAX_LINK_PASSES`).
fn apply_links(asm: &Assembly, vals: &mut Values) {
let limits: BTreeMap<(u64, usize), (f64, f64)> = asm
.links
.iter()
.filter_map(|l| {
let (lo, hi) = asm.joints.iter().find(|j| j.id == l.b)?.limits.get(l.ib).copied().flatten()?;
Some(((l.b, l.ib), (lo.min(hi), hi.max(lo))))
})
.collect();
// The linked values, to see whether a pass changed any (several links writing one value
// change it within a pass, but settle from one pass to the next).
let linked = |vals: &Values| -> Vec<Option<f64>> { asm.links.iter().map(|l| vals.get(&l.b).and_then(|q| q.get(l.ib)).copied()).collect() };
for _ in 0..MAX_LINK_PASSES {
let before = linked(vals);
for l in &asm.links {
let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue };
let mut next = l.ratio * src + l.offset;
if let Some((lo, hi)) = limits.get(&(l.b, l.ib)) {
next = next.max(*lo).min(*hi);
}
if let Some(v) = vals.get_mut(&l.b).and_then(|q| q.get_mut(l.ib)) {
*v = next;
}
}
if linked(vals) == before {
break;
}
}
(t, ((tr - 1.0) / 2.0).clamp(-1.0, 1.0).acos())
}
/// Effective values of every joint after motion links and limits.
pub fn joint_values(asm: &Assembly) -> BTreeMap<u64, Vec<f64>> {
let mut vals: BTreeMap<u64, Vec<f64>> = asm
let mut vals: Values = asm
.joints
.iter()
.map(|j| {
@@ -353,15 +398,7 @@ pub fn joint_values(asm: &Assembly) -> BTreeMap<u64, Vec<f64>> {
(j.id, q)
})
.collect();
// Links in order, a few passes for chains.
for _ in 0..4 {
for l in &asm.links {
let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue };
if let Some(v) = vals.get_mut(&l.b).and_then(|q| q.get_mut(l.ib)) {
*v = l.ratio * src + l.offset;
}
}
}
apply_links(asm, &mut vals);
for j in &asm.joints {
if let Some(q) = vals.get_mut(&j.id) {
clamp(j, q);
@@ -370,12 +407,621 @@ pub fn joint_values(asm: &Assembly) -> BTreeMap<u64, Vec<f64>> {
vals
}
/// The turn and offset from A's joint frame to B's: mated (B's z against A's, as faces meet)
/// unless flipped.
fn flip_of(j: &Joint) -> Mat {
let mut m = rot_z(j.angle);
m[3][2] = j.offset;
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
}
/// B's world transform from A's (or A's from B's, going the other way) for the joint's values.
fn through(j: &Joint, (fa, fb): &(Mat, Mat), q: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
let link = mat_mul(&mat_mul(&motion(j.kind, q), &flip_of(j)), &mat_inverse(fb)?);
if from_a {
Some(mat_mul(&mat_mul(t, fa), &link))
} else {
// T_A = T_B · link⁻¹ · F_a⁻¹
Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?))
}
}
/// The spanning tree the solver places occurrences along, out from the fixed ones.
struct Tree<'a> {
joints: BTreeMap<u64, &'a Joint>,
/// Joint frames in component coordinates (A's, B's).
frames: BTreeMap<u64, (Mat, Mat)>,
fixed: BTreeMap<u64, Mat>,
/// Placed occurrence → (the occurrence it is placed from, the joint, from the joint's A side).
parent: BTreeMap<u64, (u64, u64, bool)>,
/// Placed occurrences in placement order.
order: Vec<u64>,
}
impl Tree<'_> {
fn step(&self, joint: u64, from_a: bool, t: &Mat, vals: &Values) -> Option<Mat> {
let q = vals.get(&joint).map(Vec::as_slice).unwrap_or(&[]);
through(self.joints.get(&joint)?, self.frames.get(&joint)?, q, from_a, t)
}
/// Where every placed occurrence is for these values.
fn place(&self, vals: &Values) -> BTreeMap<u64, Mat> {
let mut placed = self.fixed.clone();
for occ in &self.order {
let Some(&(from, joint, from_a)) = self.parent.get(occ) else { continue };
if let Some(m) = placed.get(&from).and_then(|t| self.step(joint, from_a, t, vals)) {
placed.insert(*occ, m);
}
}
placed
}
/// The fixed occurrence an occurrence is placed from, and the tree joints from it up to that one.
fn path(&self, mut occ: u64) -> (u64, Vec<(u64, bool)>) {
let mut up = Vec::new();
while let Some(&(from, joint, from_a)) = self.parent.get(&occ) {
if up.len() > self.order.len() {
break;
}
up.push((joint, from_a));
occ = from;
}
(occ, up)
}
/// One occurrence's world placement for these values.
fn world(&self, occ: u64, vals: &Values) -> Option<Mat> {
let (root, up) = self.path(occ);
let mut t = *self.fixed.get(&root)?;
for (joint, from_a) in up.iter().rev() {
t = self.step(*joint, *from_a, &t, vals)?;
}
Some(t)
}
}
/// Values the loop solver moves at most in one group of loops, and loop-closing joints it takes
/// on at most: larger ones are only checked.
const MAX_LOOP_VALUES: usize = 64;
const MAX_LOOPS: usize = 100;
/// Residual evaluations one solve may spend on all its loops.
const MAX_LOOP_WORK: usize = 200_000;
/// Held joint values by (joint, value index): the value each moves from.
pub type Held = BTreeMap<(u64, usize), f64>;
/// The values that set the `around` joints' values through motion links, followed back through
/// every link to them (several links may write one value; `apply_links`' order decides which
/// wins on the way) for as many links as `apply_links` passes: all the values reached, and those
/// that no link sets (the ones a solve can move).
fn link_chains(asm: &Assembly, tree: &Tree, around: &BTreeSet<u64>, linked: &BTreeSet<(u64, usize)>) -> (BTreeSet<(u64, usize)>, Vec<(u64, usize)>) {
let (mut feeds, mut starts) = (BTreeSet::new(), Vec::new());
let mut frontier: BTreeSet<(u64, usize)> =
around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))).collect();
let mut seen = frontier.clone();
for _ in 0..MAX_LINK_PASSES {
let next: BTreeSet<(u64, usize)> =
asm.links.iter().filter(|l| frontier.contains(&(l.b, l.ib))).map(|l| (l.a, l.ia)).filter(|v| !seen.contains(v)).collect();
if next.is_empty() {
break;
}
for &(id, i) in &next {
feeds.insert((id, i));
if !linked.contains(&(id, i)) && tree.joints.get(&id).is_some_and(|j| i < j.kind.dofs().len()) {
starts.push((id, i));
}
}
seen.extend(next.iter().copied());
frontier = next;
}
(feeds, starts)
}
/// The held values among `touches` that move: (joint, index, from, to), `from` within the
/// joint's limits. A suppressed joint still sets values through its motion links.
fn held_moves(asm: &Assembly, held: &Held, touches: &BTreeSet<(u64, usize)>, vals: &Values) -> Vec<(u64, usize, f64, f64)> {
let mut moves = Vec::new();
for ((id, i), from) in held.iter().filter(|(v, _)| touches.contains(v)) {
let (Some(j), Some(to)) = (asm.joints.iter().find(|j| j.id == *id), vals.get(id).and_then(|q| q.get(*i))) else { continue };
let from = match j.limits.get(*i).copied().flatten() {
Some((lo, hi)) => from.max(lo.min(hi)).min(hi.max(lo)),
None => *from,
};
if from != *to {
moves.push((*id, *i, from, *to));
}
}
moves
}
/// Close the loops, each group of loops that share joints on its own: see `close_group`.
/// Returns the moved joints' new stored values (and updates `vals`), and the closing joints of
/// the groups whose held values moved but which can't stay closed on the way (nothing in those
/// moves; a group that can't close without moving is left to the conflict check).
fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals: &mut Values) -> (Values, BTreeSet<u64>) {
let (mut changed, mut stuck) = (BTreeMap::new(), BTreeSet::new());
if closing.len() > MAX_LOOPS {
return (Values::new(), stuck);
}
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let groups = loop_groups(tree, closing, asm, &linked);
let spent = std::cell::Cell::new(0usize);
for (group, around, touches) in &groups {
let moves = held_moves(asm, held, touches, vals);
match close_group(tree, group, around, &moves, asm, held, &linked, &spent, vals) {
Some(m) => changed.extend(m),
None if !moves.is_empty() => stuck.extend(group.iter().copied()),
None => {}
}
}
// The stored values with what each group moved (groups may move different values of one
// joint).
let mut moved = Values::new();
for ((id, i), v) in changed {
let Some(j) = tree.joints.get(&id) else { continue };
let q = moved.entry(id).or_insert_with(|| {
let mut q = j.values.clone();
q.resize(j.kind.dofs().len(), 0.0);
q
});
if let Some(slot) = q.get_mut(i) {
*slot = v;
}
}
(moved, stuck)
}
/// A group of loops solved together: its closing joints, the joints around them, and the values
/// it touches.
type Group = (Vec<u64>, BTreeSet<u64>, BTreeSet<(u64, usize)>);
/// The loops' groups. Each loop: its closing joint and the joints on the tree paths between its
/// sides; loops that share a value (of a joint, or one feeding them through links) are solved
/// together.
fn loop_groups(tree: &Tree, closing: &[u64], asm: &Assembly, linked: &BTreeSet<(u64, usize)>) -> Vec<Group> {
let mut groups: Vec<Group> = Vec::new();
for id in closing {
let Some(j) = tree.joints.get(id) else { continue };
// A joint between an occurrence and itself (only a hand-edited file has one) is only checked.
if j.a.occurrence == j.b.occurrence {
continue;
}
let side = |occ: u64| -> BTreeSet<u64> { tree.path(occ).1.iter().map(|(k, _)| *k).collect() };
let mut around: BTreeSet<u64> = side(j.a.occurrence).symmetric_difference(&side(j.b.occurrence)).copied().collect();
around.insert(*id);
// The values the loop depends on: its joints' and those its links read. Loops tie together
// through a shared value only (a rigid joint on both paths has none; two values of one
// joint can feed two independent loops).
let mut touches = link_chains(asm, tree, &around, linked).0;
touches.extend(around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))));
let mut members = vec![*id];
groups.retain(|(c, a, t)| {
if t.is_disjoint(&touches) {
return true;
}
members.extend(c.iter().copied());
around.extend(a.iter().copied());
touches.extend(t.iter().copied());
false
});
groups.push((members, around, touches));
}
groups
}
/// The values a solve of the loops `around` moves: every value of their joints but the `held`
/// ones and motion-link targets, and the values the targets' link chains start from.
fn unknowns(tree: &Tree, around: &BTreeSet<u64>, asm: &Assembly, linked: &BTreeSet<(u64, usize)>, held: &Held) -> Vec<(u64, usize)> {
let mut vars: Vec<(u64, usize)> = Vec::new();
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
vars.extend((0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v) && !held.contains_key(v)));
}
for v in link_chains(asm, tree, around, linked).1 {
if !held.contains_key(&v) && !vars.contains(&v) {
vars.push(v);
}
}
vars
}
/// The loops' size: the diagonal of the box around their joints' A frames, 1 mm to 1 km. Points
/// that far apart weigh turns like moves.
fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
for id in around {
let (Some(j), Some((fa, _))) = (tree.joints.get(id), tree.frames.get(id)) else { continue };
let Some(t) = tree.world(j.a.occurrence, vals) else { continue };
let p = apply_point(&mat_mul(&t, fa), Vec3::ZERO);
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 }
}
/// How far each closing joint is from meeting: three points of B's joint frame, `scale` apart,
/// where the joint wants them against where the tree puts them (appended to `out`).
fn closure_gaps(tree: &Tree, closing: &[u64], vals: &Values, scale: f64, out: &mut Vec<f64>) -> Option<()> {
for id in closing {
let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?);
let tb = tree.world(j.b.occurrence, vals)?;
let want = tree.step(*id, true, &tree.world(j.a.occurrence, vals)?, vals)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let at = apply_point(fb, p);
let d = apply_point(&want, at) - apply_point(&tb, at);
out.extend([d.x, d.y, d.z]);
}
}
Some(())
}
/// Occurrences whose degrees of freedom are worked out through their loops, at most; the rest
/// keep the tree's count.
const MAX_DOF_OCCURRENCES: usize = 256;
/// The degrees of freedom of the occurrences loops constrain: how many independent ways each can
/// still move with every loop kept closed, the rank of its placement over the joint values once
/// the loops' closure is taken out (rank [J_loops; J_placement] - rank J_loops). An occurrence's
/// values are those on its tree path and the unknowns of the loops on that path. The tree's
/// count stands where a loop group is over the solver's caps or the work runs out.
fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &mut BTreeMap<u64, usize>) {
if closing.len() > MAX_LOOPS {
return;
}
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let groups = loop_groups(tree, closing, asm, &linked);
// The joints loops depend on: those around them, those whose values they read, and those a
// motion link carries any of these values into.
let mut relevant: BTreeSet<u64> = groups.iter().flat_map(|(_, a, t)| a.iter().copied().chain(t.iter().map(|(j, _)| *j))).collect();
for _ in 0..MAX_LINK_PASSES {
let more: Vec<u64> = asm.links.iter().filter(|l| relevant.contains(&l.a) && !relevant.contains(&l.b)).map(|l| l.b).collect();
if more.is_empty() {
break;
}
relevant.extend(more);
}
// The occurrences a loop may hold or move: placed by such a joint, or below one that is
// (worked out down the tree, parents first). The others keep the tree's count.
let mut candidates: BTreeSet<u64> = BTreeSet::new();
for occ in &tree.order {
if let Some(&(from, joint, _)) = tree.parent.get(occ)
&& (relevant.contains(&joint) || candidates.contains(&from))
{
candidates.insert(*occ);
}
}
let (mut spent, mut counted) = (0usize, 0usize);
for occ in tree.order.iter().filter(|o| candidates.contains(o)) {
let path: Vec<u64> = tree.path(*occ).1.iter().map(|(k, _)| *k).collect();
let (mut values, mut have): (Vec<(u64, usize)>, BTreeSet<(u64, usize)>) = (Vec::new(), BTreeSet::new());
let mut add = |values: &mut Vec<(u64, usize)>, v: (u64, usize)| {
if have.insert(v) {
values.push(v);
}
};
for j in path.iter().filter_map(|k| tree.joints.get(k)) {
for v in (0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v)) {
add(&mut values, v);
}
if values.len() > MAX_LOOP_VALUES {
break;
}
}
if values.len() > MAX_LOOP_VALUES {
continue;
}
// A value on the path a motion link sets moves with the values its link chain starts from.
let on_path: BTreeSet<u64> = path.iter().copied().collect();
for v in link_chains(asm, tree, &on_path, &linked).1 {
add(&mut values, v);
}
// The loops on the path, and every loop whose values those reach (a linked source may be
// held by a loop of its own), each once.
let (mut closers, mut around) = (Vec::new(), BTreeSet::new());
let mut taken: BTreeSet<usize> = BTreeSet::new();
loop {
let more: Vec<usize> = groups
.iter()
.enumerate()
.filter(|(g, _)| !taken.contains(g))
.filter(|(_, (_, a, t))| path.iter().any(|k| a.contains(k)) || t.iter().any(|v| values.contains(v)))
.map(|(g, _)| g)
.collect();
if more.is_empty() {
break;
}
for (c, a, _) in more.iter().filter_map(|g| groups.get(*g)) {
closers.extend(c.iter().copied());
around.extend(a.iter().copied());
for v in unknowns(tree, a, asm, &linked, &Held::new()) {
add(&mut values, v);
}
}
taken.extend(more);
}
// No loop reaches this occurrence's values (not even through a motion link it drives):
// the tree's count stands.
if taken.is_empty() {
continue;
}
counted += 1;
if counted > MAX_DOF_OCCURRENCES || spent > MAX_LOOP_WORK {
return;
}
if values.len() > MAX_LOOP_VALUES {
continue;
}
let scale = loop_scale(tree, &around, vals);
let mut work = vals.clone();
// The loops' gaps, then three points of the occurrence `scale` apart.
let mut f = |x: &[f64]| -> Option<Vec<f64>> {
spent += 1;
for ((id, i), v) in values.iter().zip(x) {
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = *v;
}
}
apply_links(asm, &mut work);
let mut out = Vec::with_capacity(closers.len() * 9 + 9);
closure_gaps(tree, &closers, &work, scale, &mut out)?;
let t = tree.world(*occ, &work)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let q = apply_point(&t, p);
out.extend([q.x, q.y, q.z]);
}
out.iter().all(|v| v.is_finite()).then_some(out)
};
let x0: Vec<f64> = values.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
let (n, gaps) = (values.len(), closers.len() * 9);
let Some(jac) = jacobian(&mut f, &x0, gaps + 9) else { continue };
let rank = |rows: usize| {
let mut a = linalg::Mat::zeros(rows, n);
let mut big = 0.0f64;
for r in 0..rows {
for c in 0..n {
let v = jac.get(r * n + c).copied().unwrap_or(0.0);
big = big.max(v.abs());
a.set(r, c, v);
}
}
linalg::rref(&mut a, 1e-6 * big.max(1.0)).len()
};
let free = rank(gaps + 9).saturating_sub(rank(gaps));
dof.insert(*occ, free.min(6));
}
}
/// Close one group of loops: move the values of the joints `around` them (all but the `held`
/// values, motion-link targets and rigid joints, within their limits; a linked value moves
/// through the start of its link chain) until every loop-closing joint meets. A loop open at the
/// start is closed first, from where it is. Then the held values `moves` go from the values they
/// are held from to their current ones in small steps, each solved from the last, so the mechanism
/// stays on its branch (an open four-bar does not flip crossed). Returns the values that changed
/// from what is stored, by (joint, index), and updates `vals` (empty when there is nothing to do
/// or the group is too big to solve), or `None` when the loops can't close, or can't stay closed
/// on the way.
fn close_group(
tree: &Tree,
closing: &[u64],
around: &BTreeSet<u64>,
moves: &[(u64, usize, f64, f64)],
asm: &Assembly,
held: &Held,
linked: &BTreeSet<(u64, usize)>,
spent: &std::cell::Cell<usize>,
vals: &mut Values,
) -> Option<BTreeMap<(u64, usize), f64>> {
let vars = unknowns(tree, around, asm, linked, held);
if vars.len() > MAX_LOOP_VALUES {
return Some(BTreeMap::new());
}
let bounds: Vec<(f64, f64)> = vars
.iter()
.map(|(id, i)| match tree.joints.get(id).and_then(|j| j.limits.get(*i).copied().flatten()) {
Some((lo, hi)) => (lo.min(hi), hi.max(lo)),
None => (f64::NEG_INFINITY, f64::INFINITY),
})
.collect();
let scale = loop_scale(tree, around, vals);
// Closer than the conflict check needs: 1e-7 mm, and turns of 1e-10 rad across the loop.
let tol = (1e-10 * scale).min(1e-7);
// The values with the held ones `t` of the way along (and the unknowns at `x`).
let place = |work: &mut Values, x: &[f64], t: f64| {
for (id, i, from, to) in moves {
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = from + (to - from) * t;
}
}
for ((id, i), (v, (lo, hi))) in vars.iter().zip(x.iter().zip(&bounds)) {
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = v.max(*lo).min(*hi);
}
}
apply_links(asm, work);
};
let x0: Vec<f64> = vars.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
// Steps that move no value around the loops (links included) more than 5 degrees or a
// twentieth of the loops' size.
// How many such steps apart two sets of values are, over the loops' values.
let stride = |a: &Values, b: &Values| -> f64 {
let mut most = 0.0f64;
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
for i in 0..j.kind.dofs().len() {
let (Some(p), Some(q)) = (a.get(&j.id).and_then(|v| v.get(i)), b.get(&j.id).and_then(|v| v.get(i))) else { continue };
let step = if j.kind.is_angle(i) { 5f64.to_radians() } else { 0.05 * scale };
most = most.max((q - p).abs() / step);
}
}
most
};
let effective = |x: &[f64], t: f64| {
let mut v = vals.clone();
place(&mut v, x, t);
v
};
let mut first = 1.0f64;
if !moves.is_empty() {
first = first.min(1.0 / stride(&effective(&x0, 0.0), &effective(&x0, 1.0)));
}
let mut work = vals.clone();
// How far each closing joint is from meeting with the held values `t` of the way along:
// three points of B's joint frame, where the joint wants them against where the tree puts them.
let mut gaps = |x: &[f64], t: f64| -> Option<Vec<f64>> {
spent.set(spent.get() + 1);
if spent.get() > MAX_LOOP_WORK {
return None;
}
place(&mut work, x, t);
let mut out = Vec::with_capacity(closing.len() * 9);
closure_gaps(tree, closing, &work, scale, &mut out)?;
out.iter().all(|v| v.is_finite()).then_some(out)
};
// Already closed, with no held value moving. (A moving one is followed all the way: a
// rocker driven a full turn ends where it started, but can't get there.)
if moves.is_empty() && gaps(&x0, 1.0).is_some_and(|r| largest(&r) <= tol) {
return Some(BTreeMap::new());
}
// A loop open at the start closes first.
let mut x = x0;
if largest(&gaps(&x, 0.0)?) > tol && !least_squares(&mut |x| gaps(x, 0.0), &mut x, &bounds, tol) {
return None;
}
let (mut t, mut dt) = (if moves.is_empty() { 1.0 } else { 0.0 }, first);
let mut at = effective(&x, t);
while t < 1.0 {
if dt < first * 1e-4 {
return None;
}
let t1 = (t + dt).min(1.0);
let mut trial = x.clone();
if least_squares(&mut |x| gaps(x, t1), &mut trial, &bounds, tol) {
// A step that moved some loop value too far (through a link and a limit, or close to
// a dead point) is taken again in halves.
let next = effective(&trial, t1);
if stride(&at, &next) > 2.0 {
dt *= 0.5;
continue;
}
(x, t, dt, at) = (trial, t1, (dt * 2.0).min(first), next);
} else if spent.get() > MAX_LOOP_WORK {
return None;
} else {
dt *= 0.5;
}
}
if largest(&gaps(&x, 1.0)?) > tol {
return None;
}
// The values that changed from what is stored.
let moved = vars
.iter()
.zip(&x)
.filter(|((id, i), v)| tree.joints.get(id).is_some_and(|j| j.values.get(*i).is_none_or(|s| (s - *v).abs() > 1e-12)))
.map(|(var, v)| (*var, *v))
.collect();
*vals = work;
Some(moved)
}
fn largest(r: &[f64]) -> f64 {
r.iter().fold(0.0f64, |m, v| m.max(v.abs()))
}
/// Levenberg–Marquardt: move `x` (kept within `bounds`) until every value of `f` is within
/// `tol` of zero. False when it can't get there.
/// The Jacobian of `f` (`m` values) at `x` by central differences, row-major.
fn jacobian(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &[f64], m: usize) -> Option<Vec<f64>> {
const H: f64 = 1e-6;
let n = x.len();
let mut jac = vec![0.0; m * n];
for k in 0..n {
let (mut xp, mut xm) = (x.to_vec(), x.to_vec());
if let (Some(p), Some(q)) = (xp.get_mut(k), xm.get_mut(k)) {
*p += H;
*q -= H;
}
let (rp, rm) = (f(&xp)?, f(&xm)?);
for (i, (a, b)) in rp.iter().zip(&rm).enumerate().take(m) {
if let Some(slot) = jac.get_mut(i * n + k) {
*slot = (a - b) / (2.0 * H);
}
}
}
Some(jac)
}
fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>, bounds: &[(f64, f64)], tol: f64) -> bool {
let n = x.len();
let norm = |r: &[f64]| r.iter().map(|v| v * v).sum::<f64>();
let Some(mut r) = f(x) else { return false };
if n == 0 {
return largest(&r) <= tol;
}
let mut lambda = 1e-3;
for _ in 0..50 {
if largest(&r) <= tol {
return true;
}
// The Jacobian by central differences, then the normal equations JᵀJ δ = −Jᵀr.
let m = r.len();
let Some(jac) = jacobian(f, x, m) else { return false };
let mut jtj = linalg::Mat::zeros(n, n);
let mut g = vec![0.0; n];
for (i, ri) in r.iter().enumerate() {
let row = jac.get(i * n..(i + 1) * n).unwrap_or(&[]);
for (p, a) in row.iter().enumerate().filter(|(_, a)| **a != 0.0) {
if let Some(gp) = g.get_mut(p) {
*gp -= a * ri;
}
for (q, b) in row.iter().enumerate().skip(p) {
jtj.add(p, q, a * b);
}
}
}
for p in 0..n {
for q in 0..p {
jtj.set(p, q, jtj.get(q, p));
}
}
let mut stepped = false;
while lambda < 1e12 {
let mut a = jtj.clone();
for p in 0..n {
a.add(p, p, lambda * (1.0 + jtj.get(p, p)));
}
if let Some(d) = linalg::solve(a, g.clone()) {
let next: Vec<f64> = x.iter().zip(&d).zip(bounds).map(|((v, dv), (lo, hi))| (v + dv).max(*lo).min(*hi)).collect();
if let Some(rn) = f(&next)
&& norm(&rn) < norm(&r)
{
*x = next;
r = rn;
lambda = (lambda / 3.0).max(1e-9);
stepped = true;
break;
}
}
lambda *= 4.0;
}
if !stepped {
return false;
}
}
largest(&r) <= tol
}
/// Place occurrences from the joint graph.
pub fn solve(doc: &Document, st: &ModelState) -> Solution {
solve_holding(doc, st, &BTreeMap::new())
}
/// Place occurrences from the joint graph. Joints that close loops are met by moving the values
/// of the other joints around the loop. The `held` values (the ones being driven) keep theirs,
/// moving there from the values given (where the loops last closed) so the others follow them.
pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
let asm = &doc.assembly;
let mut sol = Solution::default();
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
let vals = joint_values(asm);
let mut vals = joint_values(asm);
// Joint frames in component coordinates (errors leave the joint out).
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
for j in &joints {
@@ -403,30 +1049,15 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
dof.insert(j.a.occurrence, 0);
}
sol.fixed = placed.keys().copied().collect();
let flip_m = |j: &Joint| {
let mut m = rot_z(j.angle);
m[3][2] = j.offset;
// Origins mate (B's z against A's, as faces meet) unless flipped.
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
};
// B's world transform from A's (or A's from B's, going the other way).
let through = |j: &Joint, from_a: bool, t: &Mat| -> Option<Mat> {
let (fa, fb) = frames.get(&j.id)?;
let q = vals.get(&j.id).cloned().unwrap_or_default();
let link = mat_mul(&mat_mul(&motion(j.kind, &q), &flip_m(j)), &mat_inverse(fb)?);
if from_a {
Some(mat_mul(&mat_mul(t, fa), &link))
} else {
// T_A = T_B · link⁻¹ · F_a⁻¹
Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?))
}
};
let mut tree =
// Every joint's values are known, suppressed ones too (their motion links still set values).
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
let mut used: BTreeSet<u64> = BTreeSet::new();
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
while let Some(occ) = queue.pop_front() {
let Some(t) = placed.get(&occ).copied() else { continue };
for j in &joints {
if used.contains(&j.id) || !frames.contains_key(&j.id) {
if used.contains(&j.id) || !tree.frames.contains_key(&j.id) {
continue;
}
let (other, from_a) = if j.a.occurrence == occ {
@@ -439,29 +1070,50 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
if placed.contains_key(&other) {
continue;
}
let Some(m) = through(j, from_a, &t) else { continue };
let Some(m) = tree.step(j.id, from_a, &t, &vals) else { continue };
used.insert(j.id);
placed.insert(other, m);
tree.parent.insert(other, (occ, j.id, from_a));
tree.order.push(other);
dof.insert(other, dof.get(&occ).copied().unwrap_or(0) + j.kind.dofs().len());
queue.push_back(other);
}
}
// Joints that close loops: do they agree?
for j in &joints {
if used.contains(&j.id) || !frames.contains_key(&j.id) {
continue;
// Joints that close loops: move the loops' values to meet them, then check they agree.
let closing: Vec<u64> = joints
.iter()
.filter(|j| !used.contains(&j.id) && tree.frames.contains_key(&j.id))
.filter(|j| placed.contains_key(&j.a.occurrence) && placed.contains_key(&j.b.occurrence))
.map(|j| j.id)
.collect();
let mut stuck = BTreeSet::new();
if !closing.is_empty() {
(sol.values, stuck) = close_loops(&tree, &closing, asm, held, &mut vals);
if !sol.values.is_empty() {
placed = tree.place(&vals);
}
}
for id in &closing {
let Some(j) = tree.joints.get(id) else { continue };
let (Some(ta), Some(tb)) = (placed.get(&j.a.occurrence), placed.get(&j.b.occurrence)) else { continue };
let Some(want) = through(j, true, ta) else { continue };
let (dt, da) = mismatch(&want, tb);
let (Some(want), Some((_, fb))) = (tree.step(*id, true, ta, &vals), tree.frames.get(id)) else { continue };
// Measured at the joint, where the loop solver closes it.
let (dt, da) = mismatch(&mat_mul(&want, fb), &mat_mul(tb, fb));
if dt > 1e-6 || da > 1e-8 {
sol.conflicts.push((
j.id,
j.name.clone(),
format!("off by {dt:.4} mm and {:.3}° from the placement the other joints give", da.to_degrees()),
));
} else if stuck.contains(id) {
// It meets there, but the loop would have to come apart on the way (a rocker
// driven a full turn ends where it started).
sol.conflicts.push((j.id, j.name.clone(), "the loop can't stay closed on the way to these values".into()));
}
}
if !closing.is_empty() {
loop_dof(&tree, &closing, asm, &vals, &mut dof);
}
for o in &doc.occurrences {
dof.entry(o.id).or_insert(6);
}
@@ -472,10 +1124,16 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
}
impl Document {
/// Apply a solution: occurrence transforms (in their parents) for the occurrences it places.
/// Returns true when something moved.
/// Apply a solution: occurrence transforms (in their parents) for the occurrences it places,
/// and the values of the joints that moved to close loops. Returns true when something moved.
pub fn apply_joint_solution(&mut self, sol: &Solution) -> bool {
let mut changed = false;
for (id, q) in &sol.values {
if let Some(j) = self.assembly.joints.iter_mut().find(|j| j.id == *id) {
j.values.clone_from(q);
changed = true;
}
}
let parents: BTreeMap<u64, u64> = self.occurrences.iter().map(|o| (o.id, o.parent)).collect();
for (occ, world) in &sol.world {
let parent = parents.get(occ).copied().unwrap_or(0);
@@ -524,6 +1182,48 @@ mod tests {
t < 1e-9 && r < 1e-9
}
/// Revolute joints 1…n (joint 1 at `start`, the others at 0) linked 1 → 2 → … → n with
/// ratio 2, the links stored from the end of the chain; joint 3 limited to ±50.
fn chain(n: u64, start: f64) -> Assembly {
let origin = JointOrigin { occurrence: 0, snap: Snap::Point { point: Vec3::ZERO, z: None } };
let joints = (1..=n)
.map(|id| Joint {
id,
name: format!("J{id}"),
kind: JointKind::Revolute,
a: origin.clone(),
b: origin.clone(),
values: vec![if id == 1 { start } else { 0.0 }],
limits: vec![(id == 3).then_some((-50.0, 50.0))],
flip: false,
offset: 0.0,
angle: 0.0,
suppressed: false,
})
.collect();
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0 }).collect();
Assembly { joints, links, ..Assembly::default() }
}
/// Links carry a value down a chain stored from its end, each linked value within its limits
/// as it is set, and applying them again changes nothing.
#[test]
fn links_settle_whatever_order_they_are_stored_in() {
let asm = chain(8, 10.0);
let vals = joint_values(&asm);
let at = |id: u64| vals.get(&id).and_then(|q| q.first()).copied().unwrap_or(f64::NAN);
// 10 → 20 → 40 (within joint 3's limit) → 80 → … → 1280.
assert_eq!((at(1), at(2), at(3), at(4), at(8)), (10.0, 20.0, 40.0, 80.0, 1280.0));
let asm = chain(8, 40.0);
let vals = joint_values(&asm);
let at = |id: u64| vals.get(&id).and_then(|q| q.first()).copied().unwrap_or(f64::NAN);
// Joint 3 would be 160: held at 50, and what follows it follows the 50.
assert_eq!((at(3), at(4), at(8)), (50.0, 100.0, 1600.0));
let mut again = vals.clone();
apply_links(&asm, &mut again);
assert_eq!(again, vals);
}
#[test]
fn motions_compose() {
assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2)));
@@ -536,4 +1236,12 @@ mod tests {
let f = frame(Vec3::new(1.0, 2.0, 3.0), Vec3::Z, None).unwrap();
assert!(close(&mat_mul(&f, &mat_inverse(&f).unwrap()), &IDENTITY));
}
#[test]
fn mismatch_resolves_tiny_turns() {
let (_, a) = mismatch(&rot_z(1e-9), &IDENTITY);
assert!((a - 1e-9).abs() < 1e-15, "{a}");
let (_, a) = mismatch(&rot_z(3.0), &IDENTITY);
assert!((a - 3.0).abs() < 1e-12, "{a}");
}
}
+13 -2
View File
@@ -9,16 +9,20 @@ pub mod appearance;
mod assembly;
mod body_transfer;
mod body_transfer_sheet;
pub mod pbr_appearance;
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
mod clipboard;
pub use clipboard::{clone_expression, remap_topology};
pub mod config;
pub mod construct;
pub mod display_quality;
mod document;
mod eval;
pub mod expr;
pub mod face_anchor;
mod feature_geometry;
mod feature_refs;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
pub mod format;
mod format_offsets;
@@ -26,6 +30,8 @@ mod history_names;
pub use format_offsets::whole_profile_selection_from_value;
pub mod joints;
mod lineage;
pub mod metadata;
pub use metadata::Metadata;
pub mod motion;
mod name_lookup;
pub mod naming;
@@ -34,10 +40,12 @@ pub mod physical_material;
mod piece_names;
mod profile_refs;
pub mod progress;
mod walk;
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic;
pub mod project;
mod project3d;
pub mod render_settings;
pub use project3d::iso_grid;
pub mod sheet;
@@ -47,9 +55,10 @@ pub use assembly::{
};
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, edge_refs,
face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent,
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for,
edge_refs, face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent,
revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state,
world_state_reusing,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solidcraft_kernel as kernel;
@@ -71,6 +80,8 @@ pub enum DocError {
pub type Result<T> = std::result::Result<T, DocError>;
#[cfg(test)]
mod feature_walk_tests;
#[cfg(test)]
mod named_projection_tests;
#[cfg(test)]
+263
View File
@@ -0,0 +1,263 @@
//! Design properties (#1015): the design's optional top-level `metadata` object.
//!
//! SolidCraft reads a few well-known, unprefixed text keys from it (project, part number,
//! revision, author, organization, description, licence) and keeps every other key exactly as
//! it was through open, edit and save. Other tools keep their own data under one reverse-DNS
//! key each (`com.example.tool`), holding an object.
//!
//! Reading never validates: a well-known key holding something other than text is kept as it
//! is and reads as absent, so a file another tool wrote always opens. Writing through
//! [`Metadata::set`] (the `document.metadata.set` command) checks the key, the value's kind and
//! its size, so nothing SolidCraft writes is malformed.
//!
//! The map is stored as is: typed fields would refuse a design another tool had written
//! `"author": 42` into. Everything under `metadata` is informational, never geometry, so keeping
//! unknown keys can't leave stale model data behind (unlike keeping unknown top-level fields).
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::{DocError, Result};
pub const PROJECT: &str = "project";
pub const PART_NUMBER: &str = "part_number";
pub const REVISION: &str = "revision";
pub const AUTHOR: &str = "author";
pub const ORGANIZATION: &str = "organization";
pub const DESCRIPTION: &str = "description";
pub const LICENSE: &str = "license";
/// SolidCraft's own keys, in the order the Design Properties dialog shows them. Every other
/// unprefixed key is reserved for SolidCraft too.
pub const WELL_KNOWN: [&str; 7] = [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE];
/// Longest key, in bytes.
pub const MAX_KEY_BYTES: usize = 128;
/// Longest well-known text value, in bytes.
pub const MAX_TEXT_BYTES: usize = 4096;
/// Largest namespaced value, as compact JSON.
pub const MAX_VALUE_BYTES: usize = 64 * 1024;
/// Deepest nesting of a namespaced value.
pub const MAX_VALUE_DEPTH: usize = 32;
/// Most keys, and largest object as compact JSON, that a change may grow the metadata to.
pub const MAX_KEYS: usize = 256;
pub const MAX_TOTAL_BYTES: usize = 256 * 1024;
/// The design's `metadata` object.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Metadata(pub Map<String, Value>);
/// A key SolidCraft reads as a design property.
pub fn is_well_known(key: &str) -> bool {
WELL_KNOWN.contains(&key)
}
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
/// ASCII letters, digits, `-` and `_` (`com.example.tool`, `org.printfarm`).
pub fn is_namespaced(key: &str) -> bool {
key.len() <= MAX_KEY_BYTES
&& key.split('.').count() >= 2
&& key.split('.').all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
}
fn depth(v: &Value) -> usize {
// Iterative, so a hostile value can't overflow the stack here.
let mut deepest = 0;
let mut stack = vec![(v, 1usize)];
while let Some((v, d)) = stack.pop() {
deepest = deepest.max(d);
if d > MAX_VALUE_DEPTH {
break;
}
match v {
Value::Array(a) => stack.extend(a.iter().map(|x| (x, d + 1))),
Value::Object(m) => stack.extend(m.values().map(|x| (x, d + 1))),
_ => {}
}
}
deepest
}
fn json_len(v: &impl Serialize) -> usize {
serde_json::to_vec(v).map_or(usize::MAX, |b| b.len())
}
fn invalid(msg: String) -> DocError {
DocError::Invalid(msg)
}
impl Metadata {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.0.get(key)
}
/// A text value: `None` when the key is missing, empty or holds something other than text.
pub fn text(&self, key: &str) -> Option<&str> {
self.0.get(key)?.as_str().filter(|s| !s.is_empty())
}
pub fn project(&self) -> Option<&str> {
self.text(PROJECT)
}
pub fn part_number(&self) -> Option<&str> {
self.text(PART_NUMBER)
}
pub fn revision(&self) -> Option<&str> {
self.text(REVISION)
}
pub fn author(&self) -> Option<&str> {
self.text(AUTHOR)
}
pub fn organization(&self) -> Option<&str> {
self.text(ORGANIZATION)
}
pub fn description(&self) -> Option<&str> {
self.text(DESCRIPTION)
}
pub fn license(&self) -> Option<&str> {
self.text(LICENSE)
}
/// The keys SolidCraft doesn't read (other tools' data, or keys a newer SolidCraft added),
/// kept as they are.
pub fn others(&self) -> impl Iterator<Item = (&String, &Value)> {
self.0.iter().filter(|(k, _)| !is_well_known(k))
}
/// Set `key` to `value` after checking both; `null` (or empty text for a well-known key)
/// removes it. Returns whether the metadata changed.
pub fn set(&mut self, key: &str, value: Value) -> Result<bool> {
let value = check(key, value)?;
let Some(value) = value else { return Ok(self.remove(key)) };
if self.0.get(key) == Some(&value) {
return Ok(false);
}
let (keys, bytes) = (self.0.len(), json_len(&self.0));
let old = self.0.insert(key.to_string(), value);
// Growing past the limits is refused; a file already past them can still shrink.
let (new_keys, new_bytes) = (self.0.len(), json_len(&self.0));
if (new_keys > MAX_KEYS && new_keys > keys) || (new_bytes > MAX_TOTAL_BYTES && new_bytes > bytes) {
match old {
Some(old) => self.0.insert(key.to_string(), old),
None => self.0.remove(key),
};
return Err(invalid(format!("design properties are limited to {MAX_KEYS} keys and {} KiB", MAX_TOTAL_BYTES / 1024)));
}
Ok(true)
}
/// Remove `key`, whatever it holds. Returns whether it was there.
pub fn remove(&mut self, key: &str) -> bool {
self.0.remove(key).is_some()
}
}
/// `value` as `key` may hold it (`None`: remove the key), or why it can't.
fn check(key: &str, value: Value) -> Result<Option<Value>> {
if is_well_known(key) {
return match value {
Value::Null => Ok(None),
Value::String(s) if s.is_empty() => Ok(None),
Value::String(s) => {
if s.len() > MAX_TEXT_BYTES {
return Err(invalid(format!("`{key}` is longer than {MAX_TEXT_BYTES} bytes")));
}
if s.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
return Err(invalid(format!("`{key}` has a control character")));
}
Ok(Some(Value::String(s)))
}
_ => Err(invalid(format!("`{key}` takes text"))),
};
}
if is_namespaced(key) {
return match value {
Value::Null => Ok(None),
Value::Object(m) => {
let v = Value::Object(m);
if depth(&v) > MAX_VALUE_DEPTH {
return Err(invalid(format!("`{key}` is nested more than {MAX_VALUE_DEPTH} levels deep")));
}
if json_len(&v) > MAX_VALUE_BYTES {
return Err(invalid(format!("`{key}` is larger than {} KiB", MAX_VALUE_BYTES / 1024)));
}
Ok(Some(v))
}
_ => Err(invalid(format!("`{key}` must hold an object (a tool keeps its own data as an object under its reverse-DNS key)"))),
};
}
let shown: String = key.chars().take(64).collect();
Err(invalid(format!(
"`{shown}` is not a design property: SolidCraft's are {}; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
WELL_KNOWN.join(", ")
)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn well_known_keys_read_as_text_and_ignore_other_kinds() {
let m: Metadata = serde_json::from_value(json!({"author": "Jane Doe", "revision": 3, "project": "", "license": null})).unwrap();
assert_eq!(m.author(), Some("Jane Doe"));
assert_eq!(m.revision(), None, "a number is kept but is not a revision");
assert_eq!(m.project(), None, "empty text reads as absent");
assert_eq!(m.license(), None);
assert_eq!(m.get("revision"), Some(&json!(3)), "kept as it was");
}
#[test]
fn set_checks_keys_kinds_and_sizes() {
let mut m = Metadata::default();
assert!(m.set("author", json!("Jane")).unwrap());
assert!(!m.set("author", json!("Jane")).unwrap(), "the same value is no change");
assert!(m.set("com.acme.tool", json!({"shop_id": 88123, "tags": ["a", null, 1.5]})).unwrap());
assert!(m.set("author", json!(42)).is_err(), "well-known keys take text");
assert!(m.set("author", json!("a\u{7}b")).is_err(), "no control characters");
assert!(m.set("description", json!("two\nlines\tand a tab")).unwrap());
assert!(m.set("com.acme.tool", json!("text")).is_err(), "a tool's key holds an object");
assert!(m.set("colour", json!("red")).is_err(), "unprefixed keys are reserved");
assert!(m.set("", json!({})).is_err());
for bad in ["com.", ".com", "com..x", "com.ex ample", "com.é", "single"] {
assert!(m.set(bad, json!({})).is_err(), "{bad}");
}
assert!(m.set("author", json!("x".repeat(MAX_TEXT_BYTES + 1))).is_err());
assert!(m.set("org.big", json!({"a": "x".repeat(MAX_VALUE_BYTES)})).is_err());
let mut deep = json!({});
for _ in 0..MAX_VALUE_DEPTH {
deep = json!({ "a": deep });
}
assert!(m.set("org.deep", deep).is_err());
assert_eq!(m.author(), Some("Jane"), "a refused value leaves the old one");
// Null and empty text remove.
assert!(m.set("author", json!("")).unwrap());
assert!(m.set("com.acme.tool", Value::Null).unwrap());
assert!(!m.set("com.acme.tool", Value::Null).unwrap());
assert_eq!(m.0.keys().collect::<Vec<_>>(), ["description"]);
}
#[test]
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
let mut m = Metadata::default();
for i in 0..MAX_KEYS {
m.set(&format!("org.k{i}"), json!({})).unwrap();
}
assert!(m.set("org.one_more", json!({})).is_err());
assert_eq!(m.0.len(), MAX_KEYS);
// A file written past the limits by another tool still opens and can be cut down.
let mut big = Metadata::default();
for i in 0..MAX_KEYS + 10 {
big.0.insert(format!("org.k{i}"), json!({}));
}
assert!(big.set("org.k0", Value::Null).unwrap());
assert!(big.set("author", json!("Jane")).is_err(), "still over: no new keys");
assert!(big.set("org.k1", json!({"replaced": true})).is_ok(), "the count doesn't grow");
}
}
+27
View File
@@ -371,3 +371,30 @@ pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit> {
locate_in(std::iter::once((0, body)), name, p)
}
/// The one face a name `model.faces` lists finds, on the body called `body` or, without one, on
/// any body. `Err` says why there is no such face, or why the name does not name just one (the
/// pieces of a split face, or the same face on several bodies): a command that takes a face by
/// its name has no point to tell the pieces apart by.
pub fn locate_one(st: &ModelState, body: Option<&str>, name: &str) -> Result<FaceHit, String> {
if let Some(n) = body
&& !st.bodies.iter().any(|b| b.name == n)
{
return Err(format!("no body `{n}`"));
}
let bodies: Vec<(usize, &ModelBody)> =
st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh() && body.is_none_or(|n| b.name == n)).collect();
// As [`locate_face`]: the names of several bodies are worked out on all cores first.
if bodies.len() > 1 && legacy_name(name).is_none() {
solidcraft_kernel::for_each_on_threads(&bodies, 16, |(_, b)| {
let _ = naming::names(b);
});
}
let hit = locate_in(bodies.into_iter(), name, Vec3::ZERO).ok_or_else(|| format!("no face is named `{name}` (`model.faces` lists the names)"))?;
if hit.split {
return Err(format!(
"`{name}` names more than one face (pieces of a split face, or faces of several bodies): give `body`, or the name of one piece"
));
}
Ok(hit)
}
+121 -2
View File
@@ -76,7 +76,7 @@ impl NameCell {
}
}
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face};
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face, locate_one};
pub use crate::piece_names::parent_piece;
/// Deeper chains than this are named by position only (bounded work and stack).
@@ -511,7 +511,22 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
}
};
let mut order = cs.clone();
order.sort_by(|a, b| key(a.1).partial_cmp(&key(b.1)).unwrap_or(std::cmp::Ordering::Equal));
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
// keeps its number across runs (solidcraft#569). total_cmp keeps a NaN deterministic.
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
let bucket = |c: Vec3| {
let k = key(c);
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
};
order.sort_by_key(|a| bucket(a.1));
for (k, (j, _)) in order.iter().enumerate() {
if let Some(slot) = names.get_mut(*j) {
*slot = format!("{n}{mark}{}", k + 1);
@@ -741,6 +756,71 @@ pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
}
/// Where a face is, for a script: a point that lies on it and picks it, and the plane, cylinder,
/// cone, sphere or torus it is part of (when it is one).
#[derive(Clone, Copy, Debug)]
pub struct FaceSpot {
pub point: Vec3,
pub surface: Option<solidcraft_kernel::FaceSurface>,
}
/// The incentre and inradius of the roomiest triangle (greatest inradius) of each face of a mesh,
/// of face `only` alone if given.
fn roomiest(m: &Mesh, only: Option<usize>) -> Vec<Option<(f64, Vec3)>> {
let mut best = vec![None; m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0)];
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let f = *f as usize;
if only.is_some_and(|i| i != f) {
continue;
}
let Some([a, b, c]) = m.tri(t) else { continue };
// The incentre weighs each corner by the side opposite it; the inradius is area / half the perimeter.
let (la, lb, lc) = (b.dist(c), c.dist(a), a.dist(b));
let perimeter = la + lb + lc;
let radius = (b - a).cross(c - a).len() / perimeter;
let centre = (a * la + b * lb + c * lc) / perimeter;
if let Some(slot) = best.get_mut(f)
&& radius.is_finite()
&& centre.is_finite()
&& slot.is_none_or(|(old, _)| radius > old)
{
*slot = Some((radius, centre));
}
}
best
}
/// The spot of face `index` of `b` from its roomiest triangle: the triangle's incentre, so a point
/// that lies on the face however it is shaped (the centroid of a plate with a hole is in the hole,
/// that of a half cylinder inside the cylinder) and well away from its edges, so a pick there finds
/// this face. On a face of an analytic surface the point is moved onto the surface itself (a facet
/// of a curved face lies inside it by its sag), as far as that stays well inside the triangle.
fn spot(b: &ModelBody, index: usize, roomiest: Option<(f64, Vec3)>) -> Option<FaceSpot> {
let (radius, centre) = roomiest?;
let surface = solidcraft_kernel::face_surface(&b.body, index);
let point = surface.and_then(|s| s.nearest(centre)).filter(|q| q.dist(centre) <= radius * 0.5).unwrap_or(centre);
Some(FaceSpot { point, surface })
}
/// [`face_spot`] of every face of a body (`None`: the face has no triangles), in one pass.
pub fn face_spots(b: &ModelBody) -> Vec<Option<FaceSpot>> {
roomiest(&b.mesh(), None).into_iter().enumerate().map(|(i, best)| spot(b, i, best)).collect()
}
/// Where face `index` of `b` is (`None`: it has no triangles).
pub fn face_spot(b: &ModelBody, index: usize) -> Option<FaceSpot> {
spot(b, index, roomiest(&b.mesh(), Some(index)).get(index).copied().flatten())
}
/// A point on the face with this name, the name `model.faces` lists for it, on the body called
/// `body` or, without one, on any body (see [`locate_one`]): what a command that takes a face as
/// a point is given instead when the face is named.
pub fn named_face_point(st: &ModelState, body: Option<&str>, name: &str) -> Result<Vec3, String> {
let hit = locate_one(st, body, name)?;
let b = st.bodies.get(hit.body).ok_or_else(|| format!("no face is named `{name}`"))?;
face_spot(b, hit.face).map(|spot| spot.point).ok_or_else(|| format!("face `{name}` has no surface to pick"))
}
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
/// `true` for "not exactly that face": split, or merged into another); `None` when no face has
/// the name.
@@ -760,6 +840,45 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
#[cfg(test)]
mod tests {
/// Pieces lying almost level along the sort axis must keep the order they arrived in, whatever
/// the low bits of their centres do. The centre is the mean of a tessellated boundary, so those
/// bits depend on where the loop's polyline starts and can differ between processes — which is
/// how a name's number used to swap between runs (solidcraft#569).
#[test]
fn level_pieces_keep_their_input_order() {
let piece = |dx: f64| vec![Vec3::new(10.0 + dx, 0.0, 0.0), Vec3::new(10.0 + dx, 0.0, 5.0)];
for (first, second) in [(0.0, 1e-12), (1e-12, 0.0)] {
let mut names = vec!["f".to_string(), "f".to_string()];
let pts = vec![piece(first), piece(second)];
number_pieces(&mut names, &pts, '#');
assert_eq!(names, vec!["f#1".to_string(), "f#2".to_string()], "input order not kept for {first} then {second}");
}
}
/// Three pieces spaced just under the tolerance apart: `a` and `b` in one bucket and `b` and `c`
/// in the next, so a tolerance comparison would call `a≈b` and `b≈c` while `a<c` — the
/// non-transitivity that leaves `sort_by` without a total order to work with. The key must order
/// them by bucket, and within a bucket by the order they arrived in.
#[test]
fn pieces_just_under_the_tolerance_apart_still_have_a_total_order() {
let piece = |dx: f64| vec![Vec3::new(10.0 + dx, 0.0, 0.0), Vec3::new(10.0 + dx, 0.0, 5.0)];
// The grid is the spread of the centres floored at 1e-9; these centres span under 1.0, so it
// is 1e-9 and the three offsets below sit in buckets 0, 0 and 1.
let grid = 1e-9;
let step = grid * 0.4;
for ((a, b, c), want) in [
((0.0, step, 2.0 * step), ["f#1", "f#2", "f#3"]),
((2.0 * step, step, 0.0), ["f#3", "f#1", "f#2"]),
((0.0, 2.0 * step, step), ["f#1", "f#3", "f#2"]),
((step, 0.0, 2.0 * step), ["f#1", "f#2", "f#3"]),
] {
let mut names = vec!["f".to_string(); 3];
let pts = [a, b, c].iter().map(|d| piece(*d)).collect::<Vec<_>>();
number_pieces(&mut names, &pts, '#');
assert_eq!(names, want, "wrong order for offsets {a} {b} {c}");
}
}
use super::*;
/// A cone's tip repeats its apex vertex, so a triangle can have a zero-length edge. The nearest
+66 -321
View File
@@ -9,336 +9,50 @@ use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::expr::{self, Kind, ParamDef, Value};
use crate::{DocError, Document, FeatureKind, HoleKind, MAX_PARAMS, PatternKind, PlaneRef, Result};
use crate::walk::Walk;
use crate::{DocError, Document, FeatureKind, MAX_PARAMS, Result};
fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut String, Kind)>) {
match p {
PlaneRef::Offset { base, distance } => {
v.push(("Offset", distance, Kind::Length));
plane_inputs_mut(base, v);
}
PlaneRef::AtAngle { base, angle, .. } => {
v.push(("Angle", angle, Kind::Angle));
plane_inputs_mut(base, v);
}
PlaneRef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, Kind::Unitless)),
_ => {}
}
/// Whether an expression of a feature is one of its inputs, which become model parameters named
/// in walk order (`dN`), or a coordinate of a primitive's placement point, which gets no name and
/// keeps bare numbers in mm whatever the design's units.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ExprRole {
Input,
Placement,
}
impl FeatureKind {
/// The feature's numeric inputs: (label, expression, kind), in a fixed order.
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut String, Kind)> {
use Kind::{Angle as A, Length as L, Unitless as U};
let mut v: Vec<(&'static str, &mut String, Kind)> = Vec::new();
match self {
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);
}
}
let mut v = Vec::new();
self.walk_exprs_mut(&mut |(label, e, kind, role)| {
if role == ExprRole::Input {
v.push((label, e, kind));
}
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, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
v.push(("Distance", &mut extent.distance, L));
if let Some(d) = &mut extent.distance2 {
v.push(("Distance 2", d, L));
}
if let Some(d) = &mut extent.start_offset {
v.push(("Start offset", d, L));
}
if let Some(d) = &mut extent.taper {
v.push(("Taper angle", d, A));
}
if let Some(d) = &mut extent.to_offset {
v.push(("To offset", d, L));
}
}
FeatureKind::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { 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::Curvature => v.push(("Equivalent 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)));
}
crate::FilletStyle::Spline { radii, .. } => {
v.push(("Start radius", radius, L));
let n = radii.len();
v.extend(radii.iter_mut().enumerate().map(|(i, r)| (if i + 1 == n { "End radius" } else { "Control 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));
v.push(("Height", height, L));
}
FeatureKind::Cylinder { radius, height, .. } => {
v.push(("Radius", radius, L));
v.push(("Height", height, L));
}
FeatureKind::Sphere { radius, .. } => v.push(("Radius", radius, L)),
FeatureKind::Torus { major, minor, .. } => {
v.push(("Major radius", major, L));
v.push(("Minor radius", minor, L));
}
FeatureKind::Pattern { pattern, .. } => match pattern {
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
v.push(("Quantity", count1, U));
v.push(("Spacing", spacing1, L));
if let Some(c) = count2 {
v.push(("Quantity 2", c, U));
}
if let Some(sp) = spacing2 {
v.push(("Spacing 2", sp, L));
}
}
PatternKind::Path { count, spacing, .. } => {
v.push(("Quantity", count, U));
v.push(("Distance", spacing, L));
}
PatternKind::Circular { count, angle, .. } => {
v.push(("Quantity", count, U));
v.push(("Total angle", angle, A));
}
},
FeatureKind::Shell { thickness, .. } => v.push(("Thickness", thickness, L)),
FeatureKind::Draft { angle, neutral, .. } => {
v.push(("Angle", angle, A));
plane_inputs_mut(neutral, &mut v);
}
FeatureKind::Hole { diameter, depth, hole, .. } => {
v.push(("Diameter", diameter, L));
if let Some(d) = depth {
v.push(("Depth", d, L));
}
match hole {
HoleKind::Simple => {}
HoleKind::Drilled { tip_angle } => v.push(("Tip angle", tip_angle, A)),
HoleKind::Counterbore { cb_diameter, cb_depth } => {
v.push(("Counterbore diameter", cb_diameter, L));
v.push(("Counterbore depth", cb_depth, L));
}
HoleKind::Countersink { cs_diameter, cs_angle } => {
v.push(("Countersink diameter", cs_diameter, L));
v.push(("Countersink angle", cs_angle, A));
}
}
}
FeatureKind::Thread { length, .. } => {
if let Some(l) = length {
v.push(("Length", l, L));
}
}
FeatureKind::Scale { factor, factors, .. } => {
v.push(("Scale factor", factor, U));
if let Some([x, y, z]) = factors {
v.push(("X scale", x, U));
v.push(("Y scale", y, U));
v.push(("Z scale", z, U));
}
}
FeatureKind::OffsetFace { distance, .. } => v.push(("Distance", distance, L)),
FeatureKind::BoundingSolid { margin, .. } => v.push(("Margin", margin, L)),
FeatureKind::Pipe { diameter, wall, .. } => {
v.push(("Diameter", diameter, L));
if let Some(w) = wall {
v.push(("Wall", w, L));
}
}
FeatureKind::Move { translate, angle, .. } => {
let [x, y, z] = translate;
v.push(("X distance", x, L));
v.push(("Y distance", y, L));
v.push(("Z distance", z, L));
if let Some(a) = angle {
v.push(("Angle", a, A));
}
}
FeatureKind::Emboss { depth, .. } => v.push(("Depth", depth, L)),
FeatureKind::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, .. } => {
v.push(("Diameter", diameter, L));
v.push(("Height", height, L));
for (l, o, k) in [
("Hole diameter", hole_diameter, L),
("Hole depth", hole_depth, L),
("Draft angle", draft, A),
("Root fillet", fillet, L),
("Ribs", ribs, U),
("Rib thickness", rib_thickness, L),
("Rib length", rib_length, L),
("Rib offset", rib_offset, L),
] {
if let Some(e) = o {
v.push((l, e, k));
}
}
}
FeatureKind::Lip { width, height, gap, .. } => {
v.push(("Width", width, L));
v.push(("Height", height, L));
if let Some(g) = gap {
v.push(("Gap", g, L));
}
}
FeatureKind::Rest { width, length, height, draft, thickness, .. } => {
v.push(("Width", width, L));
v.push(("Height", height, L));
for (l, o, k) in [("Length", length, L), ("Draft angle", draft, A), ("Wall thickness", thickness, L)] {
if let Some(e) = o {
v.push((l, e, k));
}
}
}
FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => {
v.push(("Length", length, L));
v.push(("Thickness", thickness, L));
v.push(("Width", width, L));
v.push(("Catch depth", catch_depth, L));
v.push(("Catch length", catch_length, L));
}
FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)),
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
v.push(("Height", height, L));
v.push(("Angle", angle, A));
if let Some(r) = radius {
v.push(("Bend radius", r, L));
}
// The edges' own values come last, so the inputs above keep their names.
for e in edges {
if let Some(a) = &mut e.angle {
v.push(("Edge angle", a, A));
}
if let Some(h) = &mut e.height {
v.push(("Edge height", h, L));
}
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
v.push(("Angle", angle, A));
if let Some(r) = radius {
v.push(("Bend radius", r, L));
}
}
FeatureKind::SheetHem { length, gap, .. } => {
v.push(("Length", length, L));
if let Some(g) = gap {
v.push(("Gap", g, L));
}
}
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
v.push(("Diameter", diameter, L));
v.push(("Pitch", pitch, L));
v.push(("Revolutions", turns, U));
v.push(("Section size", section_size, L));
if let Some(a) = start_angle {
v.push(("Start angle", a, A));
}
}
FeatureKind::Rib { thickness, depth, .. } => {
v.push(("Thickness", thickness, L));
if let Some(d) = depth {
v.push(("Depth", d, L));
}
}
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
FeatureKind::MeshConvert { 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::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {}
FeatureKind::SurfaceOffset { distance, tolerance, .. } => {
v.push(("Distance", distance, L));
if let Some(t) = tolerance {
v.push(("Tolerance", t, L));
}
}
FeatureKind::Patch { .. } => {}
FeatureKind::Combine { .. }
| FeatureKind::SheetBase { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. }
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. }
| FeatureKind::DeleteFace { .. }
| FeatureKind::BoundaryFill { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
| FeatureKind::SurfaceSweep { .. }
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. }
| FeatureKind::BodyTransfer { .. } => {}
}
});
v
}
/// A primitive's placement point (length expressions). Its coordinates are not inputs:
/// they get no parameter names and keep bare numbers in mm whatever the design's units, as
/// every other position does; renames and users still find them.
pub fn point_expr_mut(&mut self) -> Option<&mut crate::PointExpr> {
match self {
FeatureKind::Box { corner: p, .. }
| FeatureKind::Cylinder { base: p, .. }
| FeatureKind::Sphere { center: p, .. }
| FeatureKind::Torus { center: p, .. }
| FeatureKind::Coil { base: p, .. } => Some(p),
_ => None,
}
}
/// Read-only view of [`FeatureKind::point_expr_mut`].
pub fn point_expr(&self) -> Option<&crate::PointExpr> {
match self {
FeatureKind::Box { corner: p, .. }
| FeatureKind::Cylinder { base: p, .. }
| FeatureKind::Sphere { center: p, .. }
| FeatureKind::Torus { center: p, .. }
| FeatureKind::Coil { base: p, .. } => Some(p),
_ => None,
}
}
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
// Imports have no inputs (and their STEP text is large: don't clone it).
if matches!(self, FeatureKind::Import { .. } | FeatureKind::MeshImport { .. }) {
return Vec::new();
}
let mut c = self.clone();
c.inputs_mut().into_iter().map(|(l, e, k)| (l, e.clone(), k)).collect()
let mut v = Vec::new();
self.walk_exprs(&mut |(label, e, kind, role)| {
if role == ExprRole::Input {
v.push((label, e.clone(), kind));
}
});
v
}
/// Every expression the feature uses: its inputs, then a primitive's placement coordinates.
pub fn expressions(&self) -> Vec<&str> {
let (mut inputs, mut placement) = (Vec::new(), Vec::new());
self.walk_exprs(&mut |(_, e, _, role)| match role {
ExprRole::Input => inputs.push(e.as_str()),
ExprRole::Placement => placement.push(e.as_str()),
});
inputs.extend(placement);
inputs
}
}
@@ -387,16 +101,20 @@ impl Document {
v
}
fn name_taken(&self, n: &str) -> bool {
pub(crate) fn name_taken(&self, n: &str) -> bool {
self.param(n).is_some() || self.features.iter().any(|f| f.param_names.iter().any(|x| x == n))
}
/// The next free `dN` name.
pub fn next_d_name(&self) -> String {
// Every taken name at once: asking `name_taken` for d1, d2, … searched the whole
// design once per number, quadratic in the number of named inputs.
let taken: std::collections::HashSet<&str> =
self.params.iter().map(|p| p.name.as_str()).chain(self.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str))).collect();
let mut n = 1;
loop {
let c = format!("d{n}");
if !self.name_taken(&c) {
if !taken.contains(c.as_str()) {
return c;
}
n += 1;
@@ -583,7 +301,15 @@ impl Document {
let (vals, errs) = self.param_values();
let all = self.all_param_exprs();
let refs: BTreeMap<String, Vec<String>> = all.iter().map(|(n, e, _)| (n.clone(), expr::references(e))).collect();
let used_by = |n: &str| -> Vec<String> { refs.iter().filter(|(_, r)| r.iter().any(|x| x == n)).map(|(k, _)| k.clone()).collect() };
// Who uses each name, in name order: one pass over the references (a design an agent
// built names every feature input, so a search per row was quadratic).
let mut users: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for (k, r) in &refs {
for x in r {
users.entry(x.as_str()).or_default().push(k.clone());
}
}
let used_by = |n: &str| -> Vec<String> { users.get(n).cloned().unwrap_or_default() };
let value = |n: &str, k: Kind| vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x });
let mut rows = Vec::new();
for p in &self.params {
@@ -626,6 +352,25 @@ impl Document {
rows
}
/// Move user parameter `name` to just before (or with `after`, just after) user parameter
/// `target`: the order the Parameters dialog lists them in. Nothing else changes.
pub fn move_user_param(&mut self, name: &str, target: &str, after: bool) -> Result<()> {
fn user(params: &[crate::Param], n: &str) -> Option<usize> {
params.iter().position(|p| p.name == n && !p.model)
}
let (Some(from), Some(_)) = (user(&self.params, name), user(&self.params, target)) else {
let missing = if user(&self.params, name).is_none() { name } else { target };
return Err(DocError::Unknown(format!("user parameter `{missing}`")));
};
if name == target {
return Err(DocError::Invalid("a parameter cannot move next to itself".into()));
}
let moved = self.params.remove(from);
let at = user(&self.params, target).map_or(self.params.len(), |i| if after { i + 1 } else { i });
self.params.insert(at, moved);
Ok(())
}
pub fn set_favorite(&mut self, name: &str, on: bool) -> Result<()> {
if !self.name_taken(name) {
return Err(DocError::Unknown(format!("parameter `{name}`")));
+87
View File
@@ -0,0 +1,87 @@
//! Persistable opaque optical parameters, independent of engineering material data.
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use solidcraft_render::surface::{OpaqueSurface, SurfaceError, srgb8_to_linear};
/// Explicitly selected opaque response. Existing scalar looks have no such record.
/// Construction and deserialization share the renderer's finite admission bounds.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "Parameters", into = "Parameters")]
pub struct PbrParameters {
metallic: f64,
roughness: f64,
dielectric_ior: f64,
}
impl Default for PbrParameters {
fn default() -> Self {
Self { metallic: 0.0, roughness: 0.5, dielectric_ior: 1.5 }
}
}
#[derive(Clone, Copy, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Parameters {
metallic: f64,
roughness: f64,
dielectric_ior: f64,
}
impl PbrParameters {
pub fn new(metallic: f64, roughness: f64, dielectric_ior: f64) -> Result<Self, SurfaceError> {
OpaqueSurface::new(Vec3::ZERO, metallic, roughness, dielectric_ior)?;
Ok(Self { metallic, roughness, dielectric_ior })
}
/// Decode the containing look's sRGB8 base color once, for the actual consumer.
pub fn surface(self, color: [u8; 3]) -> Result<OpaqueSurface, SurfaceError> {
let [r, g, b] = color.map(srgb8_to_linear);
OpaqueSurface::new(Vec3::new(r, g, b), self.metallic, self.roughness, self.dielectric_ior)
}
pub fn metallic(self) -> f64 {
self.metallic
}
pub fn roughness(self) -> f64 {
self.roughness
}
pub fn dielectric_ior(self) -> f64 {
self.dielectric_ior
}
}
impl TryFrom<Parameters> for PbrParameters {
type Error = SurfaceError;
fn try_from(p: Parameters) -> Result<Self, Self::Error> {
Self::new(p.metallic, p.roughness, p.dielectric_ior)
}
}
impl From<PbrParameters> for Parameters {
fn from(p: PbrParameters) -> Self {
Self { metallic: p.metallic, roughness: p.roughness, dielectric_ior: p.dielectric_ior }
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn optical_parameters_share_strict_admission_and_round_trip() {
let p = PbrParameters::new(1.0, 0.3, 1.5).unwrap();
let saved = serde_json::to_value(p).unwrap();
assert_eq!(serde_json::from_value::<PbrParameters>(saved.clone()).unwrap(), p);
for (key, value) in [("metallic", json!(1.1)), ("roughness", json!(0)), ("dielectric_ior", json!(0.9)), ("extra", json!(true))] {
let mut bad = saved.clone();
bad[key] = value;
assert!(serde_json::from_value::<PbrParameters>(bad).is_err());
}
assert!(PbrParameters::new(f64::NAN, 0.3, 1.5).is_err());
assert!(PbrParameters::new(0.0, f64::INFINITY, 1.5).is_err());
let surface = p.surface([128, 0, 255]).unwrap();
assert!((surface.base_color().x - 0.21586050011389926).abs() < 1e-14);
assert_eq!(surface.base_color().z, 1.0);
}
}
+69 -1
View File
@@ -305,6 +305,12 @@ impl MaterialCard {
/// Bound JSON traversal before cloning/deserializing it. Metadata and numeric
/// bounds are admission limits, not claims about a real material's valid range.
pub fn validate_card(value: &Value) -> Result<Validation> {
admit_card(value)?.validate()
}
/// Bound and deserialize a candidate before cloning it. Semantic checks belong
/// to `validate` or the derived query; this alone does not validate the numbers.
pub fn admit_card(value: &Value) -> Result<MaterialCard> {
let mut pending = vec![(value, 0usize)];
let (mut nodes, mut bytes) = (0usize, 0usize);
while let Some((value, depth)) = pending.pop() {
@@ -345,7 +351,69 @@ pub fn validate_card(value: &Value) -> Result<Validation> {
}
}
let card: MaterialCard = serde_json::from_value(value.clone()).map_err(|e| invalid(format!("material card: {e}")))?;
card.validate()
Ok(card)
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct DirectionalModulus {
pub direction: [f64; 3],
pub axes: Option<MaterialAxes>,
pub young_modulus_pa: f64,
pub validation: Validation,
}
impl MaterialCard {
/// Uniaxial small-strain modulus in the supplied material-local direction.
/// This is a constitutive query, not a body load case or structural solver.
pub fn directional_modulus(&self, direction: [f64; 3]) -> Result<DirectionalModulus> {
let validation = self.validate()?;
if !direction.iter().all(|x| x.is_finite()) {
return Err(invalid("direction: expected three finite components"));
}
let scale = direction.iter().fold(0.0_f64, |a, x| a.max(x.abs()));
if scale == 0. {
return Err(invalid("direction: must be nonzero"));
}
let scaled = direction.map(|x| x / scale);
let length = scaled.iter().map(|x| x * x).sum::<f64>().sqrt();
let direction = scaled.map(|x| x / length);
let model = self
.properties
.iter()
.find_map(|p| match &p.data {
PropertyData::Elasticity { model } => Some(model),
_ => None,
})
.ok_or_else(|| invalid("elasticity is unspecified"))?;
let (axes, modulus) = match model {
Elasticity::Isotropic { young_modulus_pa, .. } => (None, *young_modulus_pa),
Elasticity::Orthotropic { axes, young_moduli_pa, shear_moduli_pa, poisson_ratios } => {
let [x, y, z] = direction;
let [e1, e2, e3] = *young_moduli_pa;
let [g12, g23, g31] = *shear_moduli_pa;
let [n12, n23, n31] = *poisson_ratios;
let [a, b, c] = [-n12 * (e2.sqrt() / e1.sqrt()), -n23 * (e3.sqrt() / e2.sqrt()), -n31 * (e1.sqrt() / e3.sqrt())];
let minor = 1. - a * a;
let determinant = 1. + 2. * a * b * c - a * a - b * b - c * c;
// Cholesky of the scaled normal compliance: evaluate the energy
// as sums of squares instead of cancelling positive/negative terms.
let l22 = minor.sqrt();
let l32 = (b - a * c) / l22;
let l33 = (determinant / minor).sqrt();
let [u, v, w] = [x * x / e1.sqrt(), y * y / e2.sqrt(), z * z / e3.sqrt()];
let terms = [u + a * v + c * w, l22 * v + l32 * w, l33 * w];
let compliance = terms.iter().map(|t| t * t).sum::<f64>() + (x * y).powi(2) / g12 + (y * z).powi(2) / g23 + (z * x).powi(2) / g31;
if !compliance.is_finite() || compliance <= 0. {
return Err(invalid("directional compliance is unrepresentable"));
}
(Some(*axes), compliance.recip())
}
};
if !modulus.is_finite() || modulus <= 0. {
return Err(invalid("directional modulus is unrepresentable"));
}
Ok(DirectionalModulus { direction, axes, young_modulus_pa: modulus, validation })
}
}
#[cfg(test)]
+62
View File
@@ -14,6 +14,68 @@ fn orthotropic(e: [f64; 3], nu: [f64; 3]) -> Value {
"young_moduli_pa":e,"shear_moduli_pa":[4e9,4e9,4e9],"poisson_ratios":nu}})
}
#[test]
fn directional_isotropy_and_principal_axes_match_analytic_values() {
let iso = admit_card(&card(isotropic(12e9, 0.25))).unwrap();
let mut equivalent = orthotropic([12e9; 3], [0.25; 3]);
equivalent["model"]["shear_moduli_pa"] = json!([4.8e9, 4.8e9, 4.8e9]);
let equivalent = admit_card(&card(equivalent)).unwrap();
for direction in [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 2., 3.], [-1., 2., -3.], [f64::MAX; 3], [1e-320; 3]] {
let a = iso.directional_modulus(direction).unwrap();
let b = equivalent.directional_modulus(direction).unwrap();
assert_eq!(a.young_modulus_pa, 12e9);
assert!((b.young_modulus_pa / 12e9 - 1.).abs() < 1e-14);
assert_eq!(a.axes, None);
assert_eq!(b.axes, Some(MaterialAxes::WoodLrt));
assert!((a.direction.iter().map(|x| x * x).sum::<f64>() - 1.).abs() < 1e-14);
}
let ortho = admit_card(&card(orthotropic([12e9, 3e9, 1e9], [0.2, 0.1, 0.02]))).unwrap();
for (direction, e) in [([1., 0., 0.], 12e9), ([0., 1., 0.], 3e9), ([0., 0., 1.], 1e9)] {
assert!((ortho.directional_modulus(direction).unwrap().young_modulus_pa / e - 1.).abs() < 1e-14);
}
}
#[test]
fn directional_orthotropy_includes_shear_and_reciprocal_coupling() {
let mut data = orthotropic([12e9, 3e9, 1e9], [0.2, 0.1, 0.02]);
data["model"]["shear_moduli_pa"] = json!([2e9, 1e9, 0.5e9]);
let material = admit_card(&card(data)).unwrap();
// For n=(1,1,0)/sqrt(2), 1/E(n)=(S11+S22+2S12+1/G12)/4.
let expected = 4. / (1. / 12e9 + 1. / 3e9 - 2. * 0.2 / 12e9 + 1. / 2e9);
for d in [[1., 1., 0.], [-2., 2., 0.], [1e200, 1e200, 0.]] {
let r = material.directional_modulus(d).unwrap();
assert!((r.young_modulus_pa / expected - 1.).abs() < 1e-14);
assert_eq!(r.validation.source_review_required, vec!["test"]);
assert!(!r.validation.analysis_solver_available);
}
// Independent direct tensor contraction in a general non-principal direction.
let [x, y, z] = [2. / 3., -1. / 3., 2. / 3.];
let inverse = x * x * x * x / 12e9
+ y * y * y * y / 3e9
+ z * z * z * z / 1e9
+ x * x * y * y * (1. / 2e9 - 2. * 0.2 / 12e9)
+ y * y * z * z * (1. / 1e9 - 2. * 0.1 / 3e9)
+ z * z * x * x * (1. / 0.5e9 - 2. * 0.02 / 1e9);
let actual = material.directional_modulus([2., -1., 2.]).unwrap();
assert!((actual.young_modulus_pa * inverse - 1.).abs() < 1e-14);
}
#[test]
fn directional_queries_refuse_unknown_unstable_and_nonfinite_inputs() {
let mut material = admit_card(&card(isotropic(12e9, 0.25))).unwrap();
for d in [[0.; 3], [f64::NAN, 0., 1.], [0., f64::INFINITY, 0.]] {
assert!(material.directional_modulus(d).is_err());
}
material.properties.clear();
assert!(material.directional_modulus([1., 0., 0.]).is_err());
material.properties.push(Property {
data: PropertyData::Elasticity { model: Elasticity::Isotropic { young_modulus_pa: 12e9, poisson_ratio: 0.6 } },
source_id: "test".into(),
conditions: Conditions::default(),
});
assert!(material.directional_modulus([1., 0., 0.]).is_err());
}
#[test]
fn isotropic_moduli_match_closed_form_and_auxetic_values_are_valid() {
let r = validate_card(&card(isotropic(12e9, 0.25))).unwrap();
+4
View File
@@ -308,9 +308,11 @@ mod tests {
document
.add_feature(
crate::FeatureKind::Extrude {
thin: None,
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
@@ -433,9 +435,11 @@ mod tests {
document
.add_feature(
crate::FeatureKind::Extrude {
thin: None,
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
+96
View File
@@ -21,8 +21,23 @@ pub struct Progress {
work_stage: std::sync::Mutex<&'static str>,
work_done: AtomicUsize,
work_total: AtomicUsize,
/// A window wants the model as far as it is built, to show while the rest is ([`Progress::want_partial`]).
partial_wanted: AtomicBool,
/// The latest such model and when it was offered.
partial: std::sync::Mutex<(Option<Partial>, Option<web_time::Instant>)>,
}
/// The model as far as an evaluation has built it, placed in the world as the design places it.
#[derive(Clone, Debug)]
pub struct Partial {
/// Features evaluated.
pub features: usize,
pub world: Arc<crate::ModelState>,
}
/// How often an evaluation offers the model built so far.
const PARTIAL_EVERY: std::time::Duration = std::time::Duration::from_millis(1500);
impl Default for Progress {
fn default() -> Self {
Progress {
@@ -33,6 +48,8 @@ impl Default for Progress {
work_stage: std::sync::Mutex::new(""),
work_done: AtomicUsize::new(0),
work_total: AtomicUsize::new(0),
partial_wanted: AtomicBool::new(false),
partial: std::sync::Mutex::new((None, None)),
}
}
}
@@ -54,6 +71,14 @@ impl Progress {
pub fn features(&self) -> (usize, usize) {
(self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed))
}
/// Ask the watched evaluation to offer the model as far as it is built, now and then.
pub fn want_partial(&self) {
self.partial_wanted.store(true, Ordering::Relaxed);
}
/// The model as far as it was built when last offered, once (`None` until there is a newer one).
pub fn take_partial(&self) -> Option<Partial> {
self.partial.lock().ok().and_then(|mut p| p.0.take())
}
/// Long work inside a feature, such as reading a large STEP file: (step, done, total);
/// `None` while there is none to show.
pub fn work(&self) -> Option<(&'static str, usize, usize)> {
@@ -146,6 +171,37 @@ impl Evaluation {
}
}
impl Evaluation {
/// The outer evaluation has built the model up to feature `features`: a watcher that wants
/// partial models gets it, placed by `doc`, at most every [`PARTIAL_EVERY`] (the first one
/// after one interval, so a design that opens quickly offers none).
pub(crate) fn offer(&self, features: usize, doc: &crate::Document, state: &Arc<crate::ModelState>) {
if !self.outer {
return;
}
let progress = CURRENT.with(|c| c.borrow().as_ref().map(|cur| cur.progress.clone()));
let Some(progress) = progress.filter(|p| p.partial_wanted.load(Ordering::Relaxed) && !p.is_cancelled()) else { return };
let now = web_time::Instant::now();
let due = match progress.partial.lock() {
Ok(mut p) => match p.1 {
None => {
p.1 = Some(now);
false
}
Some(last) => now.duration_since(last) >= PARTIAL_EVERY,
},
Err(_) => false,
};
if !due {
return;
}
let world = Arc::new(crate::world_state(doc, state));
if let Ok(mut p) = progress.partial.lock() {
*p = (Some(Partial { features, world }), Some(web_time::Instant::now()));
}
}
}
impl Drop for Evaluation {
fn drop(&mut self) {
CURRENT.with(|c| {
@@ -185,4 +241,44 @@ mod tests {
assert_eq!(p.features(), (5, 5));
assert!(!begin(1).step(0), "the watch ends with its scope");
}
/// A watcher that asks for partial models gets the model built so far, placed, now and then;
/// one that doesn't ask, or an unwatched evaluation, never pays for it.
#[test]
fn partial_models_go_only_to_a_watcher_that_wants_them() {
let mut doc = crate::Document::new("partial");
let block = crate::FeatureKind::Box {
corner: crate::point_expr(solidcraft_geom::Vec3::ZERO),
length: "2 mm".into(),
width: "3 mm".into(),
height: "4 mm".into(),
operation: crate::Operation::NewBody,
};
doc.add_feature(block, Some("Block")).unwrap();
let mut model = crate::Model::default();
model.evaluate(&doc);
let state = model.state();
let offer = |p: &Arc<Progress>| {
watch(p.clone(), || {
let e = begin(1);
e.offer(1, &doc, &state);
})
};
let p = Arc::new(Progress::default());
offer(&p);
assert!(p.take_partial().is_none(), "not asked for");
p.want_partial();
offer(&p);
assert!(p.take_partial().is_none(), "nothing in the first interval: a quick open offers none");
if let Ok(mut g) = p.partial.lock() {
g.1 = web_time::Instant::now().checked_sub(PARTIAL_EVERY * 2);
}
offer(&p);
let partial = p.take_partial().unwrap();
assert_eq!(partial.features, 1);
assert_eq!(partial.world.bodies.len(), 1);
assert!(p.take_partial().is_none(), "taken once");
offer(&p);
assert!(p.take_partial().is_none(), "not again within the interval");
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Saved render intent, independent of runtime backend availability and GPU resources.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use solidcraft_render::{BackendId, Camera, RenderFormat, RenderLimits, RenderMode, UpAxis};
use crate::{DocError, Result};
pub const MAX_RENDER_SETUPS: usize = 32;
pub const MAX_SETUP_NAME_BYTES: usize = 128;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RenderSetup {
pub backend: BackendId,
pub mode: RenderMode,
#[serde(deserialize_with = "SavedCamera::deserialize")]
pub camera: Camera,
pub width: u32,
pub height: u32,
pub format: RenderFormat,
pub exposure_ev: f64,
/// Procedural environment orientation, in radians. No HDRI is loaded in R2.
pub environment_rotation: f64,
pub environment_intensity: f64,
/// sRGB background. Alpha/output colour management belong to rendering phases.
pub background: [u8; 3],
}
// Reuse Camera's representation and defaults, but reject misspelled saved fields. A remote
// derive constructs Camera itself, so adding a Camera field requires updating this adapter.
// Navigation/named-view deserialization keeps its existing compatibility policy.
#[derive(Deserialize)]
#[serde(remote = "Camera", deny_unknown_fields)]
struct SavedCamera {
target: Vec3,
yaw: f64,
pitch: f64,
#[serde(default)]
roll: f64,
distance: f64,
fov: f64,
#[serde(default)]
up: UpAxis,
}
impl Default for RenderSetup {
fn default() -> Self {
// The built-in descriptor owns the canonical default backend identifier.
Self {
backend: solidcraft_render::builtin_backend().id,
mode: RenderMode::Preview,
camera: Camera::default(),
width: 1024,
height: 768,
format: RenderFormat::Png,
exposure_ev: 0.0,
environment_rotation: 0.0,
environment_intensity: 1.0,
background: [236, 240, 245],
}
}
}
impl RenderSetup {
pub fn validate(&self) -> Result<()> {
let limits = RenderLimits::default();
if self.width == 0
|| self.height == 0
|| u64::from(self.width) > limits.max_dimension
|| u64::from(self.height) > limits.max_dimension
|| u64::from(self.width) * u64::from(self.height) > limits.max_pixels
{
return Err(DocError::Invalid("render output exceeds the dimension/pixel limits".into()));
}
let camera = &self.camera;
if !camera.is_valid()
|| [camera.target.x, camera.target.y, camera.target.z, camera.distance].into_iter().any(|x| x.abs() > 1e9)
|| [camera.yaw, camera.pitch, camera.roll].into_iter().any(|x| x.abs() > 1e6)
|| !camera.fov.is_finite()
|| !(0.0..std::f64::consts::PI).contains(&camera.fov)
{
return Err(DocError::Invalid("invalid render camera (finite mm/radians, positive distance, 0 <= fov < pi required)".into()));
}
if !self.exposure_ev.is_finite() || !(-32.0..=32.0).contains(&self.exposure_ev) {
return Err(DocError::Invalid("render exposure must be finite and between -32 and 32 EV".into()));
}
if !self.environment_rotation.is_finite() || self.environment_rotation.abs() > 1e6 {
return Err(DocError::Invalid("render environment rotation must be finite, bounded radians".into()));
}
if !self.environment_intensity.is_finite() || !(0.0..=100.0).contains(&self.environment_intensity) {
return Err(DocError::Invalid("render environment intensity must be finite and between 0 and 100".into()));
}
// Desired mode/format/backend may be unavailable on this host. Saving intent is not preflight.
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "UncheckedSettings")]
pub struct RenderSettings {
pub setups: BTreeMap<String, RenderSetup>,
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct UncheckedSettings {
setups: BTreeMap<String, RenderSetup>,
}
impl TryFrom<UncheckedSettings> for RenderSettings {
type Error = DocError;
fn try_from(value: UncheckedSettings) -> Result<Self> {
let settings = Self { setups: value.setups };
settings.validate()?;
Ok(settings)
}
}
impl RenderSettings {
pub fn is_empty(&self) -> bool {
self.setups.is_empty()
}
pub fn validate_name(name: &str) -> Result<()> {
if name.trim().is_empty() || name != name.trim() || name.len() > MAX_SETUP_NAME_BYTES || name.chars().any(char::is_control) {
return Err(DocError::Invalid("render setup name must be trimmed, nonempty, at most 128 bytes and contain no control characters".into()));
}
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self.setups.len() > MAX_RENDER_SETUPS {
return Err(DocError::Invalid("too many render setups (maximum 32)".into()));
}
for (name, setup) in &self.setups {
Self::validate_name(name)?;
setup.validate()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Document;
use std::sync::Arc;
#[test]
fn camera_fields_are_strict_without_changing_existing_camera_defaults() {
let mut camera = serde_json::to_value(Camera::default()).unwrap();
camera["fov_degrees"] = serde_json::json!(45);
let saved = serde_json::json!({"setups":{"Studio":{"camera":camera}}});
let mut doc = serde_json::to_value(Document::default()).unwrap();
doc["render"] = saved.clone();
assert!(Document::from_json(&doc.to_string()).is_err());
assert!(serde_json::from_value::<RenderSettings>(saved).is_err());
camera.as_object_mut().unwrap().remove("fov_degrees");
camera.as_object_mut().unwrap().remove("roll");
camera.as_object_mut().unwrap().remove("up");
let setup: RenderSetup = serde_json::from_value(serde_json::json!({"camera":camera})).unwrap();
assert_eq!(setup.camera, Camera::default());
}
#[test]
fn legacy_and_named_setups_round_trip_without_changing_other_data() {
let old = Document::new("Legacy");
let mut json = serde_json::to_value(&old).unwrap();
json["format"] = serde_json::json!("solidcraft/16");
json.as_object_mut().unwrap().remove("render");
let mut loaded = Document::from_json(&json.to_string()).unwrap();
assert!(loaded.render.is_empty());
assert_eq!(loaded.format, crate::format::format_string(crate::format::FORMAT));
// Upstream owns 19 for editable offsets; saved render intent needs 20.
for version in [17, 18, 19] {
json["format"] = serde_json::json!(crate::format::format_string(version));
assert_eq!(Document::from_json(&json.to_string()).unwrap(), loaded);
}
let before = loaded.clone();
let setup = RenderSetup {
backend: serde_json::from_value(serde_json::json!("future-backend")).unwrap(),
exposure_ev: 2.5,
mode: RenderMode::Final,
format: RenderFormat::Exr,
camera: Camera { up: solidcraft_render::UpAxis::Y, ..Default::default() },
..Default::default()
};
Arc::make_mut(&mut loaded.render).setups.insert("Studio".into(), setup);
assert!(loaded.differs_only_in_render(&before));
assert_eq!(Document::from_json(&loaded.to_json()).unwrap(), loaded);
let mut previous_draft = serde_json::to_value(&loaded).unwrap();
previous_draft["format"] = serde_json::json!("solidcraft/17");
assert_eq!(Document::from_json(&previous_draft.to_string()).unwrap(), loaded);
loaded.units = "in".into();
assert!(!loaded.differs_only_in_render(&before));
}
#[test]
fn mixed_document_changes_never_qualify_as_render_only() {
let before = Document::default();
let mut render_only = before.clone();
Arc::make_mut(&mut render_only.render).setups.insert("Studio".into(), RenderSetup::default());
assert!(render_only.differs_only_in_render(&before));
for (key, value) in [
("marker", serde_json::json!(0)),
("materials", serde_json::json!({"Body1":"Steel"})),
("visibility", serde_json::json!({"hidden_bodies":["Body1"]})),
("body_components", serde_json::json!({"Body1":1})),
("next_id", serde_json::json!(99)),
] {
let mut json = serde_json::to_value(&render_only).unwrap();
json[key] = value;
let mixed: Document = serde_json::from_value(json).unwrap();
assert!(!mixed.differs_only_in_render(&before), "{key}");
}
}
#[test]
fn invalid_saved_intent_is_rejected_at_deserialization() {
for patch in [
serde_json::json!({"width":0}),
serde_json::json!({"width":4097}),
serde_json::json!({"width":4096,"height":4096}),
serde_json::json!({"exposure_ev":33}),
serde_json::json!({"environment_rotation":1000001}),
serde_json::json!({"environment_intensity":-1}),
serde_json::json!({"background":[256,0,0]}),
serde_json::json!({"unknown":1}),
] {
let json = serde_json::json!({"setups":{"Studio":patch}});
assert!(serde_json::from_value::<RenderSettings>(json).is_err());
}
for name in ["", " padded", "line\nfeed"] {
assert!(RenderSettings::validate_name(name).is_err());
}
let mut settings = RenderSettings::default();
for i in 0..=MAX_RENDER_SETUPS {
settings.setups.insert(i.to_string(), RenderSetup::default());
}
assert!(serde_json::from_value::<RenderSettings>(serde_json::to_value(settings).unwrap()).is_err());
let mut setup = RenderSetup::default();
setup.camera.fov = f64::NAN;
assert!(setup.validate().is_err());
for field in ["yaw", "pitch", "roll", "distance", "fov"] {
let mut value = serde_json::to_value(RenderSetup::default()).unwrap();
value["camera"][field] = serde_json::json!(1e12);
let setup: RenderSetup = serde_json::from_value(value).unwrap();
assert!(setup.validate().is_err(), "{field}");
}
setup.camera = Camera::default();
setup.environment_intensity = f64::INFINITY;
assert!(setup.validate().is_err());
}
}
+502 -15
View File
@@ -19,6 +19,7 @@ use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector
#[path = "sheet_convert.rs"]
mod convert;
pub use convert::{bend_edge_names, convert_body};
pub(crate) use convert::{outline_loop, round_wall};
// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces.
// These are work limits, never geometry truncation: a larger input returns a useful error.
@@ -30,6 +31,8 @@ const MAX_FLAT_POINTS: usize = 16_384;
const MAX_FLAT_SAMPLES: usize = 65_536;
const MAX_FLAT_PIECES: usize = 4096;
const MAX_FLAT_WORK: usize = 500_000_000;
/// Pieces of flat material sharing less area than this (mm²) only touch.
const TOUCH_AREA: f64 = 1e-6;
/// Starts the warning a fold gives when its `fixed` point touches no material: a recompute keeps
/// the side the point lies on, and the `sheet.fold` command refuses such a point (#263).
@@ -446,6 +449,10 @@ pub struct SheetFlange {
/// instead of a rectangle `leg` long.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<Vec<Vec2>>,
/// A converted face's panel as drawn: `shape` with arcs where the face's edges are round
/// (flat coordinates, counter-clockwise).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exact: Option<Loop2>,
/// Which feature made it: `F<feature>.<k>` (persistent edge names).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tag: String,
@@ -482,6 +489,15 @@ pub struct SheetBody {
/// pattern both carry it (#560). Derived from feature history, like `pockets`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub bores: Vec<SheetBore>,
/// Perforation fields (#732), flat coordinates; their holes are computed on demand and never cut into the B-rep.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub perforations: Vec<SheetPerforation>,
/// Regions (flat coordinates) that text cut or machined, letters with their counters as
/// holes: a record of where the sheet's text went, so that its DXF can write the letters on
/// layers of their own (see [`SheetBody::letters`]). The cut-outs and pockets themselves are
/// in `holes` and `pockets`. Derived from feature history, like `pockets`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<Region2>,
/// Unfolded.
pub flat: bool,
/// Gap between flanges that meet at a corner.
@@ -509,6 +525,16 @@ pub struct SheetBore {
pub height: f64,
}
/// A perforation field from a `sheet.perforate` feature (#732).
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SheetPerforation {
pub feature: u64,
pub diameter: f64,
pub pitch: f64,
pub margin: f64,
pub square: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SheetPocket {
pub region: Region2,
@@ -829,6 +855,9 @@ impl SheetBody {
/// Flat region of a flange's panel.
pub fn panel(&self, f: &SheetFlange) -> Region2 {
if let Some(lp) = &f.exact {
return Region2 { outer: lp.ccw(), holes: Vec::new() };
}
if let Some(sh) = &f.shape {
return Region2 { outer: Loop2::polygon(sh).ccw(), holes: Vec::new() };
}
@@ -1068,7 +1097,17 @@ impl SheetBody {
if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
out.push(Cell { piece, outer: region.outer.segs, holes: region.holes.into_iter().map(|l| l.segs).collect() });
// A piece shorter than the model's tolerance is no edge: a cut-out's corner a hair
// beside a bend line comes out of the booleans as corners a hair apart, on the
// panel and on the bend, joined by such an edge or by a loop of them on its own.
// The sew welds the corners left on either side.
let hairless = |l: Loop2| -> Vec<Seg2> { l.segs.into_iter().filter(|e| e.length() >= LINEAR_TOL).collect() };
let outer = hairless(region.outer);
if outer.is_empty() {
continue;
}
let holes = region.holes.into_iter().map(hairless).filter(|h| !h.is_empty()).collect();
out.push(Cell { piece, outer, holes });
}
}
Ok(out)
@@ -1251,7 +1290,7 @@ impl SheetBody {
// origin. Fitting planes after a distant component transfer loses precision.
let mut local = self.clone();
local.frame = Plane::XY;
let body = kernel::sew(&local.faces(&cells, 0.0, self.t, &mut work)?)?;
let body = kernel::sew_touching(&local.faces(&cells, 0.0, self.t, &mut work)?)?;
let body = local.machine_pockets(body, &mut work)?;
let body = local.machine_bores(body, &mut work)?;
self.place_local_solid(body)
@@ -1421,7 +1460,7 @@ impl SheetBody {
})?;
kernel::extrude_planar_regions(&self.frame, &[region], 0.0, self.t)?
} else {
kernel::sew(&self.faces(std::slice::from_ref(&cell), 0.0, self.t, &mut work)?)?
kernel::sew_touching(&self.faces(std::slice::from_ref(&cell), 0.0, self.t, &mut work)?)?
};
if kernel::boolean(&solid, &drill, kernel::BoolOp::Intersect)?.is_none() {
continue;
@@ -1525,7 +1564,7 @@ impl SheetBody {
Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6,
})
.collect();
cells.iter().map(|c| Ok(kernel::sew(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect()
cells.iter().map(|c| Ok(kernel::sew_touching(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect()
}
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
@@ -1569,6 +1608,26 @@ impl SheetBody {
Ok(through.unwrap_or_default())
}
/// The text the sheet carries: what [`SheetBody::labels`] records, ready to tell whether a
/// loop of the flat pattern is a letter.
pub fn letters(&self) -> Letters {
let regions = self
.labels
.iter()
.map(|r| {
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in r.outer.polyline(1e-3) {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
// A polyline of 0.001 mm chords is within 0.001 mm of the curve.
let pad = Vec2::new(0.01, 0.01);
(lo - pad, hi + pad, std::iter::once(&r.outer).chain(&r.holes).flat_map(|lp| lp.segs.iter().copied()).collect())
})
.collect();
Letters { regions }
}
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
let material = self.flat_regions()?;
@@ -1639,6 +1698,84 @@ impl SheetBody {
let regions = if cuts.is_empty() { regions } else { material_boolean(&regions, &cuts, kernel::BoolOp::Cut, &mut work)? };
Ok(regions)
}
/// The pieces of flat material that a cut parted from the base: `after` is this sheet's
/// [`Self::flat_material`] after the cut, `before` the sheet before it. A piece is parted when
/// it holds none of the base panel, lies in a piece that held some before the cut, and no
/// other piece surrounds it: a flange cut off along its bend (with any flange on it), the
/// part of a ring's inside that lies on a flange's edge, the tip of a flange beyond a cut
/// through a closed corner. A ring's island and a letter's counter, inside other material,
/// are not counted; nor are pieces an earlier cut parted, nor anything once no piece holds
/// the base.
pub(crate) fn parted_from_base(&self, after: &[Region2], before: &SheetBody) -> Result<Vec<Region2>> {
if after.len() < 2 {
return Ok(Vec::new());
}
let mut work = FlatWork::new();
work.charge(after.len().saturating_mul(after.len()))?;
let base = [planar_region(&self.base)?];
let holds_base = |piece: &Region2, work: &mut FlatWork| -> Result<bool> {
let common = material_boolean(std::slice::from_ref(piece), &base, kernel::BoolOp::Intersect, work)?;
Ok(common.iter().map(Region2::area).sum::<f64>() > TOUCH_AREA)
};
// The cheap test first: a piece inside another's outline is an island, whatever it holds.
let outlines: Vec<(Vec<Vec2>, Vec2, Vec2)> = after
.iter()
.map(|r| {
let pts = r.outer.polyline(1e-3);
let lo = pts.iter().fold(Vec2::new(f64::MAX, f64::MAX), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
let hi = pts.iter().fold(Vec2::new(f64::MIN, f64::MIN), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
(pts, lo, hi)
})
.collect();
let (mut open, mut islands) = (Vec::new(), Vec::new());
for (i, piece) in after.iter().enumerate() {
let p = piece.interior_point();
let inside = |(pts, lo, hi): &(Vec<Vec2>, Vec2, Vec2)| (lo.x..=hi.x).contains(&p.x) && (lo.y..=hi.y).contains(&p.y) && point_in(pts, p);
if outlines.iter().enumerate().any(|(j, o)| j != i && inside(o)) {
islands.push(piece);
} else {
open.push(piece);
}
}
let (mut joined, mut loose) = (false, Vec::new());
for piece in open {
if holds_base(piece, &mut work)? {
joined = true;
} else {
loose.push(piece);
}
}
if loose.is_empty() {
return Ok(Vec::new());
}
// The base may be an island itself (a cut all round it).
for piece in islands {
if joined {
break;
}
joined = holds_base(piece, &mut work)?;
}
if !joined {
return Ok(Vec::new());
}
// A cut only takes material away: each piece lies in the piece it was part of.
let mut held = Vec::new();
for piece in before.flat_material()? {
if holds_base(&piece, &mut work)? {
held.push(piece);
}
}
Ok(loose
.into_iter()
.filter(|piece| {
let p = piece.interior_point();
held.iter().any(|h| h.contains(p))
})
.cloned()
.collect())
}
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
@@ -1695,6 +1832,97 @@ impl SheetBody {
out
}
/// The flat bend zones, one per bend.
pub fn bend_zones(&self) -> Vec<Region2> {
self.flanges.iter().map(|f| self.zone(f)).collect()
}
/// Hole centres of a perforation field (#732), flat coordinates: a staggered (60°, odd rows shifted half a pitch)
/// or square grid from the flat bounding box's lower-left corner plus margin + radius; a hole is kept where it is
/// at least margin clear of the material's edges and cut-outs and of every bend zone.
pub fn perforation_holes(&self, p: &SheetPerforation) -> Result<Vec<Vec2>> {
let regions = self.flat_regions()?;
let zones = self.bend_zones();
let clear = p.diameter / 2.0 + p.margin;
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
let mut add = |l: &Loop2| {
let pts = l.polyline(1e-3);
for k in 0..pts.len() {
if let (Some(&a), Some(&b)) = (pts.get(k), pts.get((k + 1) % pts.len())) {
segs.push((a, b));
}
}
};
for r in &regions {
add(&r.outer);
r.holes.iter().for_each(&mut add);
}
zones.iter().for_each(|z| add(&z.outer));
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for (a, _) in &segs {
lo = Vec2::new(lo.x.min(a.x), lo.y.min(a.y));
hi = Vec2::new(hi.x.max(a.x), hi.y.max(a.y));
}
if !(lo.x <= hi.x) || !(p.pitch > 0.0) {
return Ok(Vec::new());
}
// Segments bucketed in a grid, so each hole only checks the edges near it.
let cell = (2.0 * clear).max(p.pitch).max(1.0);
let (nx, ny) = (((hi.x - lo.x) / cell) as usize + 1, ((hi.y - lo.y) / cell) as usize + 1);
let mut grid: Vec<Vec<usize>> = vec![Vec::new(); nx.saturating_mul(ny)];
let idx = |x: f64, y: f64| -> (usize, usize) {
(((x - lo.x) / cell).floor().clamp(0.0, (nx - 1) as f64) as usize, ((y - lo.y) / cell).floor().clamp(0.0, (ny - 1) as f64) as usize)
};
for (i, (a, b)) in segs.iter().enumerate() {
let (i0, j0) = idx(a.x.min(b.x) - clear, a.y.min(b.y) - clear);
let (i1, j1) = idx(a.x.max(b.x) + clear, a.y.max(b.y) + clear);
for j in j0..=j1 {
for ii in i0..=i1 {
if let Some(c) = grid.get_mut(j * nx + ii) {
c.push(i);
}
}
}
}
let dist = |c: Vec2, (a, b): (Vec2, Vec2)| -> f64 {
let (d, l) = (b - a, (b - a).dot(b - a));
let t = if l > 0.0 { ((c - a).dot(d) / l).clamp(0.0, 1.0) } else { 0.0 };
c.dist(a + d * t)
};
let dy = if p.square { p.pitch } else { p.pitch * 3f64.sqrt() / 2.0 };
let mut out = Vec::new();
let mut row = 0usize;
loop {
let y = lo.y + clear + row as f64 * dy;
if y > hi.y - clear + 1e-9 {
break;
}
let mut x = lo.x + clear + if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
while x <= hi.x - clear + 1e-9 {
let c = Vec2::new(x, y);
let (i, j) = idx(x, y);
let near = grid.get(j * nx + i).map(Vec::as_slice).unwrap_or(&[]);
if regions.iter().any(|r| r.contains(c))
&& !zones.iter().any(|z| z.contains(c))
&& near.iter().all(|&k| segs.get(k).is_none_or(|&s| dist(c, s) >= clear - 1e-9))
{
out.push(c);
}
x += p.pitch;
}
row += 1;
}
Ok(out)
}
/// Open area of all perforation fields (mm²): what they take out of the sheet's faces.
pub fn perforated_area(&self) -> f64 {
self.perforations
.iter()
.map(|p| self.perforation_holes(p).map_or(0.0, |h| h.len() as f64) * std::f64::consts::PI * (p.diameter / 2.0).powi(2))
.sum()
}
/// Topology counted as Fusion does: a round hole is one face bounded by two closed circular
/// edges (one vertex each), where the sewn solid splits it into arcs. Returns the change to
/// the sewn (faces, edges, vertices).
@@ -2053,6 +2281,7 @@ impl SheetBody {
radius,
leg: leg.max(0.0),
shape: None,
exact: None,
tag: String::new(),
ext: (0.0, 0.0),
slope: (0.0, 0.0),
@@ -2078,7 +2307,7 @@ impl SheetBody {
/// such end left as it is.
fn relieve_bend_ends(&mut self, i: usize, free: (bool, bool)) -> Vec<String> {
let Some(f) = self.flanges.get(i).cloned() else { return Vec::new() };
if f.parent.is_some() || f.shape.is_some() {
if f.parent.is_some() {
return Vec::new();
}
let (width, depth) = (self.bend_relief.0, self.bend_relief.1.max(0.0));
@@ -2487,32 +2716,42 @@ impl SheetBody {
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
let corners = self.corners(made);
let mut notes = Vec::new();
let at = |s: &SheetBody, c: Vec2| {
let w = apply_point(&s.frame_mat(), v3(c));
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
};
let mut closed = Vec::with_capacity(corners.len());
for k in &corners {
match self.close_corner(k) {
Ok(()) => closed.push(*k),
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c))),
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", self.shown(k.c))),
}
}
notes.extend(self.relieve_corners(&corners, &closed));
notes
}
/// A point of the flat frame, in the part's coordinates, for a note.
fn shown(&self, c: Vec2) -> String {
let w = apply_point(&self.frame_mat(), v3(c));
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
}
/// Cut the rule's relief at each of `corners`, or tear those of them that are `closed`.
/// Returns a note for every corner without its relief or not torn.
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner]) -> Vec<String> {
let mut notes = Vec::new();
match self.relief {
ReliefShape::Notch => {}
// The reliefs come last: they move the bends' ends, which is how the corners were found.
ReliefShape::Round | ReliefShape::Rectangular => {
for k in &corners {
if let Err(why) = self.relieve_corner(k, &corners) {
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
for k in corners {
if let Err(why) = self.relieve_corner(k, corners) {
notes.push(format!("no corner relief at {}: {why}", self.shown(k.c)));
}
}
}
// A tear cuts to the flanges' closed ends, so a corner left open gets none.
ReliefShape::Tear => {
for k in &closed {
for k in closed {
if let Err(why) = self.tear_corner(k) {
notes.push(format!("the corner at {} keeps its notch: {why}", at(self, k.c)));
notes.push(format!("the corner at {} keeps its notch: {why}", self.shown(k.c)));
}
}
}
@@ -2785,6 +3024,7 @@ impl SheetBody {
slope: (0.0, 0.0),
tear: (false, false),
shape: None,
exact: None,
tag: String::new(),
});
Ok(self.flanges.len() - 1)
@@ -2919,6 +3159,7 @@ impl SheetBody {
slope: (0.0, 0.0),
tear: (false, false),
shape: None,
exact: None,
tag: String::new(),
};
let ba = self.allowance(&probe);
@@ -2983,6 +3224,7 @@ impl SheetBody {
Some(j) => {
if let Some(f) = self.flanges.get_mut(j) {
f.shape = Some(ccw(fixed_part.clone()));
f.exact = None;
}
}
}
@@ -2999,6 +3241,7 @@ impl SheetBody {
slope: (0.0, 0.0),
tear: (false, false),
shape: Some(ccw(moving)),
exact: None,
tag: String::new(),
});
let i = self.flanges.len() - 1;
@@ -3124,6 +3367,7 @@ impl SheetBody {
Some(j) => {
if let Some(f) = self.flanges.get_mut(j) {
f.shape = Some(ccw(fixed));
f.exact = None;
}
}
}
@@ -3142,6 +3386,7 @@ impl SheetBody {
slope: (0.0, 0.0),
tear: (false, false),
shape: Some(ccw(moving)),
exact: None,
tag: String::new(),
});
}
@@ -3824,6 +4069,30 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
})
}
/// Whether every segment of `lp` lies on one of `curves` (its ends and its middle, within
/// 0.0001 mm): the loop is made of those curves, or of parts of them.
pub fn loop_lies_on(lp: &Loop2, curves: &[Seg2]) -> bool {
const TOL: f64 = 1e-4;
let near = |p: Vec2| curves.iter().any(|c| c.point_at(c.closest_param(p)).dist(p) <= TOL);
!lp.segs.is_empty() && lp.segs.iter().all(|g| near(g.start()) && near(g.mid()) && near(g.end()))
}
/// Tells which loops of a sheet's flat pattern are letters (see [`SheetBody::letters`]).
#[derive(Clone, Debug)]
pub struct Letters {
/// Each text region's corners (lowest, highest) and the segments of its outlines.
regions: Vec<(Vec2, Vec2, Vec<Seg2>)>,
}
impl Letters {
/// Whether `lp` is made of the outlines of text the sheet carries: a letter, or one of its
/// counters.
pub fn is_letter(&self, lp: &Loop2) -> bool {
let Some(p) = lp.segs.first().map(Seg2::start) else { return false };
self.regions.iter().any(|(lo, hi, curves)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y && loop_lies_on(lp, curves))
}
}
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
let (a, b) = (planar_region(a)?, planar_region(b)?);
@@ -3844,6 +4113,8 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
holes,
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -3900,6 +4171,8 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
holes: Vec::new(),
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -3939,6 +4212,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
slope: (0.0, 0.0),
tear: (false, false),
shape: None,
exact: None,
tag: String::new(),
};
let ba = sheet.allowance(&f);
@@ -4043,6 +4317,41 @@ mod tests {
kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN)
}
#[test]
fn loops_lie_on_the_curves_they_are_made_of() {
let circle = Loop2::circle(Vec2::new(5.0, 5.0), 3.0);
let curves: Vec<Seg2> = circle.segs.clone();
// The loop itself, started elsewhere and run the other way, and a loop of its halves.
assert!(loop_lies_on(&circle, &curves));
assert!(loop_lies_on(&Loop2::circle_from(Vec2::new(5.0, 5.0), 3.0, 1.0).reversed(), &curves));
let halves: Vec<Seg2> = circle.segs.iter().flat_map(|g| [g.split_half().0, g.split_half().1]).collect();
assert!(loop_lies_on(&Loop2 { segs: halves }, &curves));
// Another circle, a square and an empty loop do not.
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 3.1), &curves));
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 2.0), &curves));
assert!(!loop_lies_on(&rect(Vec2::ZERO, Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y).outer, &curves));
assert!(!loop_lies_on(&Loop2 { segs: Vec::new() }, &curves));
// A loop only partly on them (a letter across the sheet's edge) is not on them.
let notch = Loop2 { segs: vec![circle.segs[0], Seg2::Line { a: circle.segs[0].end(), b: Vec2::new(50.0, 50.0) }] };
assert!(!loop_lies_on(&notch, &curves));
}
#[test]
fn a_sheet_knows_the_letters_it_carries() {
// A recorded text region with a counter: its outline and its counter are letters, the same
// circle elsewhere, a loop near it that is another size and a loop far away are not.
let mut s = plate(100.0, 60.0);
let counter = Loop2::circle(Vec2::new(20.0, 20.0), 2.0).reversed();
s.labels.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 5.0), holes: vec![counter.clone()] });
let letters = s.letters();
assert!(letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
assert!(letters.is_letter(&counter.reversed()));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 4.0)));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(70.0, 20.0), 5.0)));
// A sheet that carries none has no letters.
assert!(!plate(100.0, 60.0).letters().is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
}
#[test]
fn edge_flange_matches_fusion() {
// Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge.
@@ -4640,6 +4949,42 @@ mod tests {
s.flat_regions().unwrap().iter().map(Region2::area).sum()
}
/// A plate (0, 0) (100, 0) (85, 20) (0, 20) with a 30 mm inside flange on its slanted edge
/// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), and a
/// 4 mm² triangle cut out of the flat across the bend line, with a corner on the line or a
/// hair (0.01 to 0.1 µm) to either side of it, in the middle of the bend and near its ends.
/// The planar booleans leave such a corner as corners a hair apart on the base and on the
/// bend, and the refold joined them by an edge as short ("sew: a degenerate edge").
#[test]
fn a_cut_out_corner_a_hair_beside_a_bend_line_refolds() {
let rule = RuleValues { bend_radius: 2.0, ..alu() };
let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(85.0, 20.0), Vec2::new(0.0, 20.0)];
for deg in [90.0f64, 120.0] {
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] });
s.add_flange(Vec3::new(92.5, 10.0, 2.0), 30.0, deg.to_radians(), 2.0, "inside", false).unwrap();
let (a0, v0) = (flat_area(&s), vol(&s.solid().unwrap()));
if deg == 90.0 {
// The base less 4 mm along the edge's normal (5 mm along x): 1750; the bend
// 25 x 3π/2; the flange 25 x 26.
assert!((a0 - (2400.0 + 37.5 * std::f64::consts::PI)).abs() < 1e-9, "{a0}");
}
// The measure follows the bend by facets.
assert!((v0 / (a0 * 2.0) - 1.0).abs() < 1e-4, "{deg}°: {v0} vs {}", a0 * 2.0);
let f = s.flanges[0].clone();
for (along, off) in [(0.5, 0.0), (0.5, 1e-8), (0.5, 5e-8), (0.5, -5e-8), (0.5, 1e-7), (0.5, -1e-7), (0.16, 5e-8), (0.84, -5e-8)] {
let what = format!("{deg}°, corner {along} along the bend, {off} mm beside it");
let c = f.p + f.d * (f.len * along) + f.n * off;
let triangle = Loop2::polygon(&[c, c - f.n * 2.8 - f.d * 0.4, c + f.n * 0.4 - f.d * 2.8]).ccw();
let mut cut = s.clone();
cut.holes.push(Region2 { outer: triangle, holes: vec![] });
assert!((a0 - flat_area(&cut) - 4.0).abs() < 1e-6, "{what}: flat area {}", a0 - flat_area(&cut));
let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}"));
assert!(body.validity().is_empty(), "{what}: {:?}", body.validity());
assert!((v0 - vol(&body) - 8.0).abs() < 0.01, "{what}: {} vs 8", v0 - vol(&body));
}
}
}
/// Distance between two parallel 3D lines through a0→a1 and b0→b1 (None if not parallel).
fn parallel_gap(a0: Vec3, a1: Vec3, b0: Vec3, b1: Vec3) -> Option<f64> {
let (u, v) = ((a1 - a0).normalized()?, (b1 - b0).normalized()?);
@@ -6798,6 +7143,103 @@ mod tests {
}
}
/// The plate (0, 0) (100, 0) (85, 20) (0, 20) with a 30 mm inside flange on its slanted edge
/// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), and a
/// triangle cut out of the flat with one corner on the bend line: where the line meets the
/// plate's edge, at either end, the triangle on the bend or on the base; in the line's middle,
/// from either side; and on the plate's straight edge, from inside. At the plate's edge the
/// sheet is pinched (two wedges of material meet along the thickness); in the middle the
/// triangle's tip touches the next piece. The refold ran the face through that corner twice
/// ("This wire is not simple").
#[test]
fn a_cut_out_with_a_corner_on_a_bend_line_or_the_sheets_edge_refolds() {
let rule = RuleValues { bend_radius: 2.0, ..alu() };
let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(85.0, 20.0), Vec2::new(0.0, 20.0)];
for deg in [90.0f64, 120.0] {
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] });
s.add_flange(Vec3::new(92.5, 10.0, 2.0), 30.0, deg.to_radians(), 2.0, "inside", false).unwrap();
let (a0, v0) = (flat_area(&s), vol(&s.solid().unwrap()));
let f = s.flanges[0].clone();
let (start, end, middle) = (f.p, f.p + f.d * f.len, f.p + f.d * (f.len / 2.0));
// The bend line runs from the plate's edge y = 0 to y = 20, the bend on its +n side,
// square across at its ends; the base's corner at either end is at least 53.13°
// wide, the edge running off at (0.6, -0.8) along and across the line. The triangles'
// other corners as steps along the line and across it.
let cases = [
("on the bend at the line's start", start, [(2.0, 1.0), (0.4, 2.2)], 2.0),
("on the bend at the line's end", end, [(-2.0, 1.0), (-0.4, 2.2)], 2.0),
("on the base at the line's start", start, [(4.0, -1.0), (2.0, -2.0)], 3.0),
("on the base at the line's middle", middle, [(-1.0, -2.0), (1.0, -2.0)], 2.0),
("on the bend at the line's middle", middle, [(-1.0, 2.0), (1.0, 2.0)], 2.0),
];
for (what, c, others, area) in cases {
let what = format!("{deg}°, {what}");
let corners: Vec<Vec2> = std::iter::once(c).chain(others.iter().map(|(u, v)| c + f.d * *u + f.n * *v)).collect();
let mut cut = s.clone();
cut.holes.push(Region2 { outer: Loop2::polygon(&corners).ccw(), holes: vec![] });
assert!((a0 - flat_area(&cut) - area).abs() < 1e-6, "{what}: flat area {}", a0 - flat_area(&cut));
let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}"));
assert!(body.validity().is_empty() && body.shell_count() == 1, "{what}: {:?}", body.validity());
assert!((v0 - vol(&body) - area * 2.0).abs() < 0.01, "{what}: {} vs {}", v0 - vol(&body), area * 2.0);
}
// A 4 mm² triangle standing on the plate's edge y = 20 at x = 40.
let mut cut = s.clone();
cut.holes
.push(Region2 { outer: Loop2::polygon(&[Vec2::new(40.0, 20.0), Vec2::new(38.0, 18.0), Vec2::new(42.0, 18.0)]).ccw(), holes: vec![] });
assert!((a0 - flat_area(&cut) - 4.0).abs() < 1e-6, "{deg}°, on the edge: flat area {}", a0 - flat_area(&cut));
let body = cut.solid().unwrap_or_else(|e| panic!("{deg}°, on the edge: {e}"));
assert!(body.validity().is_empty() && body.shell_count() == 1, "{deg}°, on the edge: {:?}", body.validity());
assert!((v0 - vol(&body) - 8.0).abs() < 0.01, "{deg}°, on the edge: {} vs 8", v0 - vol(&body));
}
}
/// The plate (0, 0) (100, 0) (85, 20) (0, 20) with a 30 mm inside flange on its slanted edge
/// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), and a
/// triangle cut out of the flat on one side of the bend line only, with a corner a hair (0.01
/// or 0.05 µm) from the line, short of it or across: on the base, in the line's middle and
/// near its ends; and at either end of the line, where it meets the plate's edge, on the base
/// and on the bend. The planar booleans leave such a corner as corners a hair apart, on the
/// base and on the bend; joined, they are a corner where the cut-out touches the bend line or
/// the plate's edge. So the refold needs both: without the joining it failed with "sew: a
/// degenerate edge", without the touching sew with "This wire is not simple".
#[test]
fn a_cut_out_on_one_side_of_a_bend_line_with_a_corner_a_hair_from_it_refolds() {
let rule = RuleValues { bend_radius: 2.0, ..alu() };
let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(85.0, 20.0), Vec2::new(0.0, 20.0)];
for deg in [90.0f64, 120.0] {
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] });
s.add_flange(Vec3::new(92.5, 10.0, 2.0), 30.0, deg.to_radians(), 2.0, "inside", false).unwrap();
let (a0, v0) = (flat_area(&s), vol(&s.solid().unwrap()));
let f = s.flanges[0].clone();
// The corner at a fraction of the line, the triangle's other corners as steps along the
// line and across it (the bend on its +n side; the base's corner at either end of the
// line is at least 53.13° wide), and the triangle's area.
let cases = [
("on the base in the line's middle", 0.5, [(-1.0, -2.0), (1.0, -2.0)], 2.0),
("on the base near the line's start", 0.16, [(-1.0, -2.0), (1.0, -2.0)], 2.0),
("on the base near the line's end", 0.84, [(-1.0, -2.0), (1.0, -2.0)], 2.0),
("on the base at the line's start", 0.0, [(3.0, -2.0), (2.0, -2.0)], 1.0),
("on the base at the line's end", 1.0, [(-2.0, -2.0), (-3.0, -2.0)], 1.0),
("on the bend at the line's start", 0.0, [(1.25, 2.0), (0.25, 2.0)], 1.0),
("on the bend at the line's end", 1.0, [(-0.25, 2.0), (-1.25, 2.0)], 1.0),
];
for (what, along, others, area) in cases {
// Across the line toward the bend.
for off in [-5e-8, -1e-8, 1e-8, 5e-8] {
let what = format!("{deg}°, {what}, {off} mm across the line");
let c = f.p + f.d * (f.len * along) + f.n * off;
let corners: Vec<Vec2> = std::iter::once(c).chain(others.iter().map(|(u, v)| c + f.d * *u + f.n * *v)).collect();
let mut cut = s.clone();
cut.holes.push(Region2 { outer: Loop2::polygon(&corners).ccw(), holes: vec![] });
assert!((a0 - flat_area(&cut) - area).abs() < 1e-5, "{what}: flat area {}", a0 - flat_area(&cut));
let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}"));
assert!(body.validity().is_empty() && body.shell_count() == 1, "{what}: {:?}", body.validity());
assert!((v0 - vol(&body) - area * 2.0).abs() < 0.01, "{what}: {} vs {}", v0 - vol(&body), area * 2.0);
}
}
}
}
#[test]
fn a_tear_holds_at_every_angle() {
use std::f64::consts::{FRAC_PI_2, PI};
@@ -7027,4 +7469,49 @@ mod tests {
holds_the_flat_material(&s, &what);
}
}
/// The 100 x 60 plate with a 20 mm flange on its y = 0 edge (2.5 mm, R 2.5, K 0.44): in the
/// flat the base starts at y = t + R = 5, the bend's zone is (π/2)·(R + K·t) long and the
/// flange's panel 20 − 5, so its far edge lies at y = 5 − (π/2)·3.6 − 15.
#[test]
fn a_cut_names_the_pieces_it_parts_from_the_base_and_no_others() {
use std::f64::consts::{FRAC_PI_2, PI};
let far = 5.0 - FRAC_PI_2 * (2.5 + 0.44 * 2.5) - 15.0;
let mut s = plate(100.0, 60.0);
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
let r = |x0: f64, y0: f64, x1: f64, y1: f64| rect(Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1));
let ring = |c: Vec2, ro: f64, ri: f64| Region2 { outer: Loop2::circle(c, ro), holes: vec![Loop2::circle(c, ri)] };
let parted = |before: &SheetBody, holes: &[Region2]| {
let mut after = before.clone();
after.holes.extend_from_slice(holes);
after.parted_from_base(&after.flat_material().unwrap(), before).unwrap()
};
// A slot across the flange beside its bend parts the rest of the flange: 100 × (−3 − far).
let slot = r(-5.0, -3.0, 105.0, -1.0);
let p = parted(&s, std::slice::from_ref(&slot));
assert!(p.len() == 1 && (p[0].area() - 100.0 * (-3.0 - far)).abs() < 1e-6, "{p:?}");
// So does a ring on the flange's far edge: the half of its inside on the flange, π·2²/2.
let p = parted(&s, &[ring(Vec2::new(50.0, far), 4.0, 2.0)]);
assert!(p.len() == 1 && (p[0].area() - 2.0 * PI).abs() < 1e-6, "{p:?}");
// A ring's island, on the base or on the flange, has material round it; a strip across the
// whole sheet leaves two halves that each keep their part of the base. None is named.
for holes in [ring(Vec2::new(50.0, 30.0), 10.0, 5.0), ring(Vec2::new(50.0, -9.0), 4.0, 2.0), r(45.0, -30.0, 55.0, 70.0)] {
assert!(parted(&s, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
}
// Pieces an earlier cut parted are not named again, nor when a later cut splits them.
let mut cut = s.clone();
cut.holes.push(slot);
for holes in [Region2 { outer: Loop2::circle(Vec2::new(30.0, 30.0), 4.0), holes: vec![] }, r(45.0, -30.0, 55.0, -2.0)] {
assert!(parted(&cut, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
}
// Nothing is named once no piece holds the base: a cut that takes the base and both bends
// of a sheet with flanges on two sides leaves the two flanges.
let mut two = plate(100.0, 60.0);
two.add_flanges(&[Vec3::new(50.0, 0.0, 2.5), Vec3::new(50.0, 60.0, 2.5)], 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
let mut after = two.clone();
after.holes.push(r(-5.0, -3.0, 105.0, 63.0));
let material = after.flat_material().unwrap();
assert_eq!(material.len(), 2);
assert!(after.parted_from_base(&material, &two).unwrap().is_empty());
}
}
+675 -57
View File
@@ -11,20 +11,49 @@
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
//! gap from the plane halfway between the two.
//!
//! The bends on the fixed face are finished as the flanges of one edge flange feature are: where
//! two meet at a corner of it, their walls are closed with the rule's gap and the corner gets the
//! rule's corner relief (round, rectangular, a tear, or the notch the bends leave), and where one
//! ends beside more of the fixed face, the rule's bend relief is cut there. A wall that stands on
//! part of an edge of the fixed face (its outside in one plane with the plate's edge, which runs
//! on past it a strip no taller than the bend's setback) bends only where it stands: the strip is
//! the fixed face's own edge.
//!
//! A bend can also be a chamfer: a flat strip between two faces, as a model without bend radii
//! draws a bend (the chord of its arc). Picked in its middle, the strip is no face of the sheet:
//! the faces on either side are taken out to where their planes meet, and that line is the
//! bend's virtual sharp, bent with the rule's radius at the angle between the two faces.
//!
//! A bend modelled with its radius, a cylinder between two planar faces tangent to it along its
//! length, is picked anywhere on its cylinder, on either skin, and taken the same way: the two
//! faces out to their virtual sharp, bent there with the rule's radius and K-factor. The
//! modelled radius gives way to the rule's, as a sharp edge's does: the flat is measured from
//! the virtual sharps, which don't depend on it.
//!
//! The result is the model every sheet metal feature builds ([`SheetBody`]): the fixed face is
//! the base panel and every other panel a flange on its parent's edge, its outline taken through
//! the inverse of its fold. Unfold, the flat pattern, the bend table and cuts then work as on a
//! sheet made from flanges.
//!
//! Not supported yet: curved faces (rolled sheet, a filleted edge as a bend), faces with curved
//! edges or holes, loops of bends, two faces that meet along more than one edge.
//! A face's outline is read exactly, as a plate's is: where it runs along a round wall standing
//! square on the face (a rounded corner, a tooth) it is an arc of the wall's circle, and its
//! holes and slots are cut-outs of the sheet where they lie in its panel. Its pockets (an
//! engraved label: an inner loop whose walls end on flat floors, at most half the sheet deep)
//! are pockets of the sheet from that skin, round their islands; on a shell the faces of the
//! other skin are read for theirs.
//!
//! Not supported yet: curved faces other than bends (rolled sheet), holes and pockets that reach
//! into a bend, pockets deeper than half the sheet or with a step or a hole in their floor, round
//! edges that end at a bend or a cut edge, loops of bends, two faces that meet along more than
//! one edge, a wall on part of an edge of a face other than the fixed one.
use std::collections::BTreeMap;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Vec2, Vec3};
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
use solidcraft_kernel::{Body, FaceSurface, face_surface};
use super::{RuleValues, SheetBody, SheetFlange, base_flange, polys_cross, signed_area, snap};
use super::{FlatWork, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
const MAX_BENDS: usize = 1000;
@@ -38,11 +67,16 @@ fn at(p: Vec3) -> String {
}
/// A planar face of the body: its outward normal and its outline, counter-clockwise about the
/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`.
/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`: straight, or an
/// arc where that face is a round wall (`round[i]`: the circle's centre and the arc's middle).
/// `holes`: its inner loops, in `plane`, each with the faces of its walls.
struct Face {
n: Vec3,
pts: Vec<Vec3>,
across: Vec<Option<usize>>,
round: Vec<Option<(Vec3, Vec3)>>,
plane: Plane,
holes: Vec<(Loop2, Vec<usize>)>,
}
/// A face of the sheet: the panel it hangs off (none for the fixed face), and the bend between
@@ -68,38 +102,168 @@ fn turn<T>(per_edge: &mut [T]) {
}
}
/// Face `f` of the body as a panel outline. Only a planar face bounded by one loop of straight
/// edges can be one.
fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, f) else {
return err("not supported yet: curved faces in a converted sheet (only planar faces convert)");
};
// Its edges, each with the face on its other side.
let mut edges = Vec::new();
for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
if !faces.iter().any(|g| *g as usize == f) {
/// The circle (centre in `plane` coordinates, radius) of face `f` when it is a round wall
/// standing square on the plane: a cylinder about the plane's normal `n`.
pub(crate) fn round_wall(body: &Body, plane: &Plane, n: Vec3, f: u32) -> Option<(Vec2, f64)> {
match face_surface(body, f as usize)? {
FaceSurface::Cylinder { origin, axis, radius } if axis.cross(n).len() < 1e-6 => Some((plane.to_local(origin), radius)),
_ => None,
}
}
/// A loop of points on a face, each with the B-rep face across the loop from it to the next
/// point (the wall it runs along there), as segments: each run along one round wall (`wall`
/// gives a face's circle) is an arc of that circle, the rest stays straight. Coaxial cylinders of
/// one radius are one wall, however many faces it comes in (many systems write a round hole's
/// wall as two half-cylinders), so such a hole is one circle. `eps`: how far off its circle a
/// point may be.
pub(crate) fn outline_loop(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Loop2 {
Loop2 { segs: outline_segs(pts, wall, eps).into_iter().map(|(seg, _)| seg).collect() }
}
/// [`outline_loop`], each segment with its wall (an arc's: that of its first point).
fn outline_segs(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Vec<(Seg2, Option<u32>)> {
let m = pts.len();
let point = |i: usize| pts.get(i % m).map(|x| x.0).unwrap_or_default();
let face = |i: usize| pts.get(i % m).and_then(|x| x.1);
// The circle each segment runs along: its wall's, when both its ends lie on it.
let circles: Vec<Option<(Vec2, f64)>> = (0..m)
.map(|i| {
let (c, r) = wall(pts.get(i)?.1?)?;
((point(i).dist(c) - r).abs() <= eps && (point(i + 1).dist(c) - r).abs() <= eps).then_some((c, r))
})
.collect();
let circle = |i: usize| circles.get(i % m).copied().flatten();
let same = |i: usize, (c, r): (Vec2, f64)| circle(i).is_some_and(|(d, s)| d.dist(c) <= eps && (s - r).abs() <= eps);
// Start where no arc runs on across the start (anywhere when the whole loop is one circle).
let start = (0..m).find(|i| circle(*i + m - 1).is_none_or(|c| !same(*i, c))).unwrap_or(0);
let mut segs = Vec::new();
let mut i = 0;
while i < m {
let k = start + i;
let Some((c, r)) = circle(k) else {
segs.push((Seg2::Line { a: point(k), b: point(k + 1) }, face(k)));
i += 1;
continue;
};
let mut j = i + 1;
while j < m && same(start + j, (c, r)) {
j += 1;
}
let sweep: f64 = (i..j)
.map(|s| {
let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle();
(d + PI).rem_euclid(TAU) - PI
})
.sum();
// Arcs of up to half a turn each.
let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4);
let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64);
segs.extend((0..pieces).map(|p| (Seg2::Arc { center: c, radius: r, start: a0 + step * p as f64, sweep: step }, face(k))));
i = j;
}
segs
}
/// `segs` (a closed loop) with lines that run on in one line joined: a slot's side in two faces
/// is one line.
fn joined_lines(segs: impl IntoIterator<Item = Seg2>, tol: f64) -> Vec<Seg2> {
let on = |p: Vec2, a: Vec2, b: Vec2| p.extend(0.0).dist_to_segment(a.extend(0.0), b.extend(0.0)) <= tol;
let mut out: Vec<Seg2> = Vec::new();
for seg in segs {
if let (Some(Seg2::Line { a, b }), Seg2::Line { b: c, .. }) = (out.last_mut(), seg)
&& on(*b, *a, c)
{
*b = c;
continue;
}
let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue };
if p.dist(q) <= tol || line.iter().any(|x| x.dist_to_segment(p, q) > tol) {
return err("not supported yet: faces with curved edges in a converted sheet");
out.push(seg);
}
// Across the loop's start.
if let (true, Some(Seg2::Line { a, b }), Some(Seg2::Line { b: c, .. })) = (out.len() > 2, out.last().copied(), out.first().copied())
&& on(b, a, c)
{
out.pop();
if let Some(Seg2::Line { a: first, .. }) = out.first_mut() {
*first = a;
}
edges.push((p, q, faces.iter().map(|g| *g as usize).find(|g| *g != f)));
}
// Chained head to tail from the first one, they close one loop.
let Some((start, mut end, across)) = edges.pop() else { return err("a face of the body has no edges") };
let (mut pts, mut tags) = (vec![start], vec![across]);
while end.dist(start) > tol {
let Some(k) = edges.iter().position(|e| e.0.dist(end) <= tol || e.1.dist(end) <= tol) else {
return err("the outline of a face of the body does not close");
};
let (p, q, across) = edges.swap_remove(k);
pts.push(end);
tags.push(across);
end = if p.dist(end) <= tol { q } else { p };
out
}
/// Face `f` of the body as a panel outline. Only a planar face can be one. Its loops are read
/// exactly, as a plate's are (an arc where one runs along a round wall standing square on the
/// face): the outer one is the outline, the others are its holes.
fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
let Some(FaceSurface::Plane { normal: n, point }) = face_surface(b, f) else {
return err("not supported yet: curved faces in a converted sheet (only planar faces convert)");
};
let plane = Plane::from_normal(point, n).ok_or_else(|| DocError::Invalid("a face of the body has no plane".into()))?;
// Its edges, each with the face on its other side.
let mut edges: Vec<(&[Vec3], Option<usize>)> = mesh
.edges
.iter()
.zip(&mesh.edge_faces)
.filter(|(line, faces)| line.len() >= 2 && faces.iter().any(|g| *g as usize == f))
.map(|(line, faces)| (line.as_slice(), faces.iter().map(|g| *g as usize).find(|g| *g != f)))
.collect();
// Chained head to tail, they close the outline and its holes: each point with the face the
// loop runs along from it to the next point.
let mut loops: Vec<Vec<(Vec3, Option<usize>)>> = Vec::new();
while let Some((line, across)) = edges.pop() {
let (Some(&start), Some(&last)) = (line.first(), line.last()) else { continue };
let mut lp: Vec<(Vec3, Option<usize>)> = line.iter().take(line.len().saturating_sub(1)).map(|p| (*p, across)).collect();
let mut end = last;
while end.dist(start) > tol {
let meets = |p: Option<&Vec3>| p.is_some_and(|p| p.dist(end) <= tol);
let Some(k) = edges.iter().position(|(l, _)| meets(l.first()) || meets(l.last())) else {
return err("the outline of a face of the body does not close");
};
let (l, g) = edges.swap_remove(k);
let run: Vec<Vec3> = if meets(l.first()) { l.to_vec() } else { l.iter().rev().copied().collect() };
lp.extend(run.iter().take(run.len().saturating_sub(1)).map(|p| (*p, g)));
end = run.last().copied().unwrap_or(start);
}
loops.push(lp);
}
if !edges.is_empty() {
return err("not supported yet: faces with holes in a converted sheet (cut them after converting)");
let mut walls = std::collections::HashMap::new();
let mut wall = |g: u32| *walls.entry(g).or_insert_with(|| round_wall(b, &plane, n, g));
// Each loop with the faces of its walls.
let mut shaped: Vec<(Vec<(Seg2, Option<u32>)>, Vec<usize>)> = loops
.iter()
.map(|lp| {
let pts: Vec<(Vec2, Option<u32>)> = lp.iter().map(|(p, g)| (plane.to_local(*p), g.and_then(|g| u32::try_from(g).ok()))).collect();
let mut walls: Vec<usize> = lp.iter().filter_map(|(_, g)| *g).collect();
walls.sort_unstable();
walls.dedup();
(outline_segs(&pts, &mut wall, tol * 10.0), walls)
})
.collect();
let area = |segs: &[(Seg2, Option<u32>)]| segs.iter().map(|(seg, _)| seg.area_term()).sum::<f64>().abs();
shaped.sort_by(|x, y| area(&y.0).total_cmp(&area(&x.0)));
let mut it = shaped.into_iter();
let Some((outer, _)) = it.next() else { return err("a face of the body has no edges") };
let holes = it.map(|(segs, walls)| (Loop2 { segs: joined_lines(segs.into_iter().map(|(seg, _)| seg), tol) }, walls)).collect();
// The outline's corners, where its segments start (an arc's in quarter turns at most, so that
// the corners alone still make an outline), each with the face along it.
let world = |v: Vec2| plane.to_world(v);
let (mut pts, mut tags, mut round) = (Vec::new(), Vec::new(), Vec::new());
for (seg, g) in outer {
let g = g.map(|g| g as usize);
if let Seg2::Arc { center, radius, start, sweep } = seg {
let k = ((sweep.abs() / FRAC_PI_2 - 1e-9).ceil() as usize).clamp(1, 8);
let step = sweep / k as f64;
for p in 0..k {
let a = start + step * p as f64;
pts.push(world(center + Vec2::from_angle(a) * radius));
tags.push(g);
round.push(Some((world(center), world(center + Vec2::from_angle(a + step / 2.0) * radius))));
}
} else {
pts.push(world(seg.start()));
tags.push(g);
round.push(None);
}
}
// A straight run along one neighbour is one edge, however the body's edges divide it (a
// shell's outer corners are divided at the height of its floor).
@@ -107,31 +271,187 @@ fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
let corner = |i: usize| -> bool {
let before = (i + m - 1) % m;
let (Some(&a), Some(&p), Some(&b)) = (pts.get(before), pts.get(i), pts.get((i + 1) % m)) else { return true };
tags.get(before) != tags.get(i) || p.dist_to_segment(a, b) > tol
let arc = |k: usize| round.get(k).is_some_and(Option::is_some);
tags.get(before) != tags.get(i) || arc(before) || arc(i) || p.dist_to_segment(a, b) > tol
};
let keep: Vec<bool> = (0..m).map(corner).collect();
let (mut pts, mut tags): (Vec<Vec3>, Vec<Option<usize>>) = pts.into_iter().zip(tags).zip(keep).filter(|(_, kept)| *kept).map(|(x, _)| x).unzip();
let keep: Vec<usize> = (0..m).filter(|i| corner(*i)).collect();
let mut pts: Vec<Vec3> = keep.iter().filter_map(|i| pts.get(*i).copied()).collect();
let mut tags: Vec<Option<usize>> = keep.iter().filter_map(|i| tags.get(*i).copied()).collect();
let mut round: Vec<Option<(Vec3, Vec3)>> = keep.iter().filter_map(|i| round.get(*i).copied()).collect();
if area_about(&pts, n) < 0.0 {
pts.reverse();
turn(&mut tags);
turn(&mut round);
}
Ok(Face { n, pts, across: tags })
Ok(Face { n, pts, across: tags, round, plane, holes })
}
/// The face straight through the material behind planar face `f`, when it is parallel to it,
/// and how far behind it lies.
fn behind(b: &Body, mesh: &Mesh, f: usize) -> Option<(usize, f64)> {
let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, f) else { return None };
// From the middle of the face's largest triangle.
// The face most of it looks through to, from points in its largest triangles: a few of them
// may look at the floor of a label engraved in the other skin.
let area = |t: &[Vec3; 3]| (t[1] - t[0]).cross(t[2] - t[0]).len();
let tri = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t));
let [p, q, r] = tri.max_by(|x, y| area(x).total_cmp(&area(y)))?;
let (_, hit) = mesh.raycast((p + q + r) * (1.0 / 3.0), n * -1.0)?;
let g = *mesh.tri_face.get(hit)? as usize;
let mut tris: Vec<[Vec3; 3]> =
mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t)).collect();
tris.sort_by(|x, y| area(y).total_cmp(&area(x)));
let mut seen: Vec<(usize, f64)> = Vec::new();
for tri in tris.iter().take(8) {
let middle = (tri[0] + tri[1] + tri[2]) * (1.0 / 3.0);
for from in std::iter::once(middle).chain(tri.iter().map(|v| (middle + *v) * 0.5)) {
let Some(g) = mesh.raycast(from, n * -1.0).and_then(|(_, hit)| mesh.tri_face.get(hit)).map(|g| *g as usize) else { continue };
match seen.iter_mut().find(|(h, _)| *h == g) {
Some(entry) => entry.1 += area(tri),
None => seen.push((g, area(tri))),
}
}
}
let g = seen.iter().max_by(|x, y| x.1.total_cmp(&y.1))?.0;
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, g) else { return None };
(normal.dot(n) < -1.0 + 1e-9).then_some((g, (o - point).dot(n)))
}
/// A chamfer or a bend's cylinder picked as a bend: the face `strip` between the planar faces
/// `a` and `b`, which meet in that bend at the line where their planes cross.
#[derive(Clone, Copy)]
struct Chamfer {
strip: usize,
a: usize,
b: usize,
}
/// The chamfer or bend cylinder a bend point picks. A bend cylinder ([`bend_cylinder`]) is
/// picked by a point anywhere on it (within `near`, the cylinder being drawn in chords). A
/// chamfer by a point on the middle half of a planar face that runs between two planar faces
/// along two parallel edges, the two faces' planes crossing in a line parallel to them and the
/// strip facing between the two (as the chord of a bend's arc does). A point on any other face,
/// or near a chamfer's edge, picks an edge as before.
fn chamfer_at(mb: &ModelBody, mesh: &Mesh, p: Vec3, tol: f64, near: f64) -> Option<Chamfer> {
let b = &mb.body;
let (d, f) = crate::appearance::nearest_face(mb, p)?;
if d <= near
&& let Some(c) = bend_cylinder(b, mesh, f, tol)
{
return Some(c);
}
if d > tol {
return None;
}
let strip = outline(b, mesh, f, tol).ok()?;
let plane = |g: usize| match face_surface(b, g) {
Some(FaceSurface::Plane { normal, .. }) => Some(normal),
_ => None,
};
let m = strip.pts.len();
let edge = |i: usize| -> Option<(Vec3, Vec3, usize)> {
let (p, q) = (*strip.pts.get(i)?, *strip.pts.get((i + 1) % m)?);
Some((p, (q - p).normalized()?, strip.across.get(i).copied().flatten()?))
};
let mut sides = Vec::new();
for i in 0..m {
for j in i + 1..m {
let (Some((pa, da, a)), Some((pb, db, bb))) = (edge(i), edge(j)) else { continue };
let (Some(na), Some(nb)) = (plane(a), plane(bb)) else { continue };
let Some(sharp) = na.cross(nb).normalized() else { continue };
let lined_up = da.dot(db) < -1.0 + 1e-7 && sharp.dot(da).abs() > 1.0 - 1e-7;
if a != bb && a != f && bb != f && lined_up && strip.n.dot(na) > 1e-9 && strip.n.dot(nb) > 1e-9 {
sides.push((pa, pb, da, a, bb));
}
}
}
let &[(pa, pb, d, a, bb)] = sides.as_slice() else { return None };
// Its middle half: a quarter of its width or more from either edge.
let off = |o: Vec3| (p - o - d * (p - o).dot(d)).len();
let width = off(pa) + off(pb);
(off(pa) >= width / 4.0 && off(pb) >= width / 4.0).then_some(Chamfer { strip: f, a, b: bb })
}
/// Face `f` as a bend modelled with its radius: a cylinder with a planar face tangent to it
/// along a straight edge parallel to its axis on either side, the two planes crossing in a line
/// (a bend between 0 and 180°). Other faces along it (the walls of a notch) don't count.
fn bend_cylinder(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Option<Chamfer> {
let Some(FaceSurface::Cylinder { origin, axis, .. }) = face_surface(b, f) else { return None };
let mut sides: Vec<(usize, Vec3)> = Vec::new();
for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
let g = match *faces.as_slice() {
[x, y] if x as usize == f => y as usize,
[x, y] if y as usize == f => x as usize,
_ => continue,
};
let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue };
let along = (q - p).normalized().is_some_and(|d| d.cross(axis).len() <= 1e-6);
if g == f || !along || line.iter().any(|x| x.dist_to_segment(p, q) > tol) || sides.iter().any(|(h, _)| *h == g) {
continue;
}
// Tangent: the plane's normal is the cylinder's there, square to the axis.
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, g) else { continue };
let radial = (p - origin) - axis * (p - origin).dot(axis);
if radial.normalized().is_some_and(|out| n.dot(out).abs() >= 1.0 - 1e-6) {
sides.push((g, n));
}
}
let &[(a, na), (bb, nb)] = sides.as_slice() else { return None };
na.cross(nb).normalized().map(|_| Chamfer { strip: f, a, b: bb })
}
/// The cylinder of bend `c` on a shell's other skin: the bend cylinder between the faces
/// straight behind `c`'s two sides, so that a bend picked on either skin is that bend on both.
fn other_skin(b: &Body, mesh: &Mesh, c: &Chamfer, tol: f64) -> Option<Chamfer> {
let (x, y) = (behind(b, mesh, c.a)?.0, behind(b, mesh, c.b)?.0);
let beside_x = mesh.edge_faces.iter().filter(|faces| faces.contains(&(x as u32))).flatten().map(|g| *g as usize);
beside_x
.filter(|g| *g != x && *g != c.strip)
.find_map(|g| bend_cylinder(b, mesh, g, tol).filter(|o| (o.a, o.b) == (x, y) || (o.a, o.b) == (y, x)))
}
/// Face `g`'s outline with each picked chamfer along it taken out: the edge along the chamfer
/// moves to where `g`'s plane meets the plane of the face across the chamfer (its corners
/// sliding along the edges before and after it), and runs along that face.
fn without_chamfers(b: &Body, mut face: Face, g: usize, chamfers: &[Chamfer], tol: f64) -> Result<Face> {
for c in chamfers {
let other = if c.a == g {
c.b
} else if c.b == g {
c.a
} else {
continue;
};
let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, other) else {
return err("the face across a chamfer picked as a bend is not flat");
};
let m = face.pts.len();
let along: Vec<usize> = (0..m).filter(|i| face.across.get(*i).copied().flatten() == Some(c.strip)).collect();
let &[k] = along.as_slice() else {
return err("not supported yet: a chamfer picked as a bend that runs along a face more than once");
};
// Its corners slide along the edges before and after it: those must be straight.
if [k + m - 1, k + 1].iter().any(|i| face.round.get(i % m).is_some_and(Option::is_some)) {
return err("not supported yet: a round edge of a face that ends at a chamfer picked as a bend");
}
// Each end of the edge slides along the edge on its other side, out to the other plane.
let slide = |corner: usize, before: usize| -> Option<Vec3> {
let (p, q) = (*face.pts.get(corner % m)?, *face.pts.get(before % m)?);
let d = (p - q).normalized()?;
let s = (o - p).dot(n) / d.dot(n);
(s.is_finite() && s >= -tol).then_some(p + d * s)
};
let (Some(p), Some(q)) = (slide(k, k + m - 1), slide(k + 1, k + 2)) else {
return err("a face beside a chamfer picked as a bend does not reach the face across it");
};
if let Some(x) = face.pts.get_mut(k) {
*x = p;
}
if let Some(x) = face.pts.get_mut((k + 1) % m) {
*x = q;
}
if let Some(x) = face.across.get_mut(k) {
*x = Some(other);
}
}
Ok(face)
}
/// How far a panel's edge along a rip moves in, so that the panel, cut square to the sheet,
/// stays half the gap from the plane halfway between it and the panel `y` across the rip.
/// `e` is the edge's direction round panel `x`.
@@ -188,6 +508,96 @@ fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
Ok((out, from))
}
/// A wall standing on part of an edge of the fixed face: where the outline of `wall`, the face
/// across a bend from the fixed face `base` (`ids`: the two faces), leaves the bend's edge up a
/// strip no taller than the bend's setback `by`, runs back along the edge and climbs the wall's
/// end, the strip is the fixed face's own edge (a plate's edge face in one plane with the outside
/// of a wall that stands on part of it), which the setback would use up. It is cut off the wall,
/// whose bend's edge then ends where the line of the wall's end meets it, and the fixed face's
/// edge is split there: the pieces beside the wall are no bend. `bend` says whether a face is a
/// bend of the wall. Any other wall is left as it is.
fn end_at_wall(base: &mut Face, wall: &mut Face, ids: (usize, usize), by: f64, bend: &dyn Fn(usize) -> bool, tol: f64) {
let (m, n) = (wall.pts.len(), base.pts.len());
let at_wall: Vec<usize> = (0..m).filter(|i| wall.across.get(*i).copied().flatten() == Some(ids.0)).collect();
let at_base: Vec<usize> = (0..n).filter(|i| base.across.get(*i).copied().flatten() == Some(ids.1)).collect();
let (&[k], &[i]) = (at_wall.as_slice(), at_base.as_slice()) else { return };
if m < 5 {
return;
}
// Corner `j` and edge `j` (from corner `j`) counted from the bend's edge on.
let pt = |j: usize| wall.pts.get((k + j) % m).copied().unwrap_or_default();
let (p, q) = (pt(0), pt(1));
let Some(d) = (q - p).normalized() else { return };
// Up the wall from the bend's edge (the outline runs counter-clockwise about the normal), and
// along the edge.
let up = |x: Vec3| (x - p).dot(wall.n.cross(d));
let along = |x: Vec3| (x - p).dot(d);
let free = |j: usize| {
let j = (k + j) % m;
wall.round.get(j).is_some_and(Option::is_none) && wall.across.get(j).copied().flatten().is_none_or(|g| !bend(g))
};
let low = |x: Vec3| up(x) > tol && up(x) <= by + tol;
// A strip from an end of the bend's edge up to `x1`, back along the edge (`back`: the way it
// runs) to `x2`, then the wall's end up to `x3`: where the line of that end meets the edge.
let foot = |x1: Vec3, x2: Vec3, x3: Vec3, back: f64| {
let rise = up(x3) - up(x2);
(low(x1) && low(x2) && (x2 - x1).dot(d) * back > tol && rise > tol).then(|| x2 - (x3 - x2) * (up(x2) / rise))
};
// At the edge's end the strip is edges 1 and 2, the wall's end edge 3; at its start the strip
// is edges m - 1 and m - 2, the wall's end edge m - 3.
let ok = |js: [usize; 3]| js.iter().all(|j| free(*j));
let end = if ok([1, 2, 3]) { foot(pt(2), pt(3), pt(4), -1.0) } else { None };
let start = if ok([m - 1, m - 2, m - 3]) { foot(pt(m - 1), pt(m - 2), pt(m - 3), 1.0) } else { None };
let (p1, q1) = (start.unwrap_or(p), end.unwrap_or(q));
let len = along(q);
let inside = |x: Option<Vec3>| x.is_none_or(|x| along(x) > tol && along(x) < len - tol);
if (start.is_none() && end.is_none())
|| !inside(start)
|| !inside(end)
|| along(q1) - along(p1) <= tol
|| (start.is_some() && end.is_some() && m < 7)
{
return;
}
// The wall: the strips' corners and edges go, the bend's edge runs from `p1` to `q1`.
let (mut corners_out, mut edges_out) = (Vec::new(), Vec::new());
if end.is_some() {
corners_out.extend([2, 3]);
edges_out.extend([1, 2]);
}
if start.is_some() {
corners_out.extend([m - 1, m - 2]);
edges_out.extend([m - 1, m - 2]);
}
if let Some(x) = wall.pts.get_mut(k) {
*x = p1;
}
if let Some(x) = wall.pts.get_mut((k + 1) % m) {
*x = q1;
}
let kept = |j: usize, out: &[usize]| !out.contains(&((j + m - k) % m));
wall.pts = wall.pts.iter().enumerate().filter(|(j, _)| kept(*j, &corners_out)).map(|(_, x)| *x).collect();
wall.across = wall.across.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
wall.round = wall.round.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
// The fixed face's edge: the bend from `p1` to `q1` (it runs the other way along it), no bend
// beside it.
let (Some(&a), Some(&b)) = (base.pts.get(i), base.pts.get((i + 1) % n)) else { return };
let from_a = |x: Vec3| (x - a).dot(b - a);
let (x, y) = if from_a(p1) < from_a(q1) { (p1, q1) } else { (q1, p1) };
let mut pieces: Vec<(Vec3, Option<usize>)> = Vec::with_capacity(3);
if x.dist(a) > tol {
pieces.push((a, None));
}
pieces.push((if x.dist(a) > tol { x } else { a }, Some(ids.1)));
if y.dist(b) > tol {
pieces.push((y, None));
}
let count = pieces.len();
base.pts.splice(i..=i, pieces.iter().map(|(x, _)| *x));
base.across.splice(i..=i, pieces.iter().map(|(_, g)| *g));
base.round.splice(i..=i, std::iter::repeat_n(None, count));
}
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
/// bend first trimmed to the end of the bend zone.
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
@@ -210,20 +620,60 @@ fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
}
}
/// A panel's trimmed outline as drawn: an edge that was a round edge of the face is an arc again
/// (`round`, per edge of the untrimmed outline `pts`: the circle's centre and the arc's middle),
/// pieces of one circle in a row one arc of up to half a turn. None when the face has no round
/// edges. A round edge keeps both its ends: one that a bend's setback or a cut edge would move is
/// not supported yet.
fn drawn(trimmed: &[Vec2], from: &[Option<usize>], pts: &[Vec2], round: &[Option<(Vec2, Vec2)>]) -> Result<Option<Loop2>> {
if round.iter().all(Option::is_none) {
return Ok(None);
}
let (m, n) = (trimmed.len(), pts.len());
let mut segs: Vec<Seg2> = Vec::with_capacity(m);
for j in 0..m {
let (Some(&a), Some(&b)) = (trimmed.get(j), trimmed.get((j + 1) % m)) else { continue };
let arc = from.get(j).copied().flatten().and_then(|i| Some((i, round.get(i).copied().flatten()?)));
let Some((i, (c, mid))) = arc else {
segs.push(Seg2::Line { a: snap(a), b: snap(b) });
continue;
};
let (Some(&p), Some(&q)) = (pts.get(i), pts.get((i + 1) % n)) else { continue };
if a.dist(p) > 1e-6 || b.dist(q) > 1e-6 {
return err("not supported yet: a round edge of a face that ends at a bend or a cut edge of the sheet");
}
let (start, r) = ((p - c).angle(), c.dist(p));
let half = ((mid - c).angle() - start + PI).rem_euclid(TAU) - PI;
match segs.last_mut() {
Some(Seg2::Arc { center, radius, sweep, .. })
if center.dist(c) <= 1e-9 && (*radius - r).abs() <= 1e-9 && *sweep * half > 0.0 && (*sweep + 2.0 * half).abs() <= PI + 1e-9 =>
{
*sweep += 2.0 * half;
}
_ => segs.push(Seg2::Arc { center: c, radius: r, start, sweep: 2.0 * half }),
}
}
Ok(Some(Loop2 { segs }))
}
/// Names of the edges of `body` at the bend points, to find them again after the body changes.
/// A point on no edge gets none: the conversion then says so, instead of bending the edge that
/// happens to be nearest.
/// happens to be nearest. A point on a chamfer or a bend cylinder gets none either: it stays that
/// chamfer's or cylinder's point.
pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<String> {
let Some(b) = st.body(body) else { return Vec::new() };
let (mesh, near) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3));
let on = |p: &Vec3| mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near));
let (mesh, near, tol) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3), (b.body.size() * 1e-6).max(1e-6));
let on = |p: &Vec3| {
chamfer_at(b, &mesh, *p, tol, near).is_none() && mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near))
};
pts.iter().zip(crate::naming::edge_names_at(b, pts)).map(|(p, name)| if on(p) { name } else { String::new() }).collect()
}
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
/// fixed face and `bends` are points on the sharp edges that become bends (the body's
/// coordinates, mm). See the module docs.
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
/// fixed face and `bends` are points on the sharp edges that become bends, in the middle of
/// chamfers or on bend cylinders that do (the body's coordinates, mm). See the module docs.
/// Returns the sheet and a note for every corner or end of a bend not finished as the rule says.
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<(SheetBody, Vec<String>)> {
if bends.is_empty() || bends.len() > MAX_BENDS {
return err(format!("pick 1…{MAX_BENDS} bend edges"));
}
@@ -240,10 +690,20 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
let Some((_, f0)) = crate::appearance::nearest_face(mb, face).filter(|(d, _)| *d <= near) else {
return err(format!("no face of the body at the fixed face point {}", at(face)));
};
let fixed = outline(b, &mesh, f0, tol).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?;
// The two faces along each bend edge.
// The two faces along each bend edge, or on either side of each chamfer or bend cylinder
// (with a bend cylinder its other skin's too).
let mut picks = Vec::with_capacity(bends.len());
let mut chamfers: Vec<Chamfer> = Vec::new();
for p in bends {
if let Some(c) = chamfer_at(mb, &mesh, *p, tol, near) {
picks.push((*p, (c.a, c.b)));
for c in std::iter::once(c).chain(other_skin(b, &mesh, &c, tol)) {
if !chamfers.iter().any(|x| x.strip == c.strip) {
chamfers.push(c);
}
}
continue;
}
let dist = |e: &Vec<Vec3>| e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let edge = mesh.edges.iter().zip(&mesh.edge_faces).map(|(e, faces)| (dist(e), faces)).min_by(|x, y| x.0.total_cmp(&y.0));
match edge.filter(|(d, _)| *d <= near).map(|(_, faces)| faces.as_slice()) {
@@ -252,15 +712,31 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
None => return err(format!("no edge of the body at the bend point {}", at(*p))),
}
}
if let Some((p, _)) = picks.iter().find(|(_, (x, y))| chamfers.iter().any(|c| c.strip == *x || c.strip == *y)) {
return err(format!("the bend at {} is on a chamfer picked as a bend (pick the chamfer or its edges, not both)", at(*p)));
}
let face_of = |f: usize| outline(b, &mesh, f, tol).and_then(|face| without_chamfers(b, face, f, &chamfers, tol));
let fixed = face_of(f0).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?;
// A shell: the same small distance of material behind the fixed face and the faces at every
// bend. It keeps its thickness, and a bend picked on one skin is that bend on the other too.
let thin = behind(b, &mesh, f0).map(|(_, d)| d).filter(|d| *d > 0.0 && d * d * 16.0 <= area_about(&fixed.pts, fixed.n));
let wall = |f: usize, d: f64| behind(b, &mesh, f).filter(|(_, e)| (e - d).abs() <= 1e-6 * d.max(1.0)).map(|(g, _)| g);
let backs: Vec<Option<(usize, usize)>> = picks.iter().map(|(_, (x, y))| thin.and_then(|d| Some((wall(*x, d)?, wall(*y, d)?)))).collect();
let shell = thin.filter(|_| backs.iter().all(Option::is_some));
// On a shell, the face behind each face at a bend (its other skin, read for its pockets).
let skin_behind: BTreeMap<usize, usize> = picks
.iter()
.zip(&backs)
.filter(|_| shell.is_some())
.filter_map(|((_, (x, y)), back)| back.map(|(bx, by)| [(*x, bx), (bx, *x), (*y, by), (by, *y)]))
.flatten()
.collect();
let t = shell.map_or(rv.thickness, |d| (d * 1e9).round() / 1e9);
let r = rv.bend_radius;
let joined = |x: usize, y: usize| mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32)));
let joined = |x: usize, y: usize| {
mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32)))
|| chamfers.iter().any(|c| (c.a, c.b) == (x, y) || (c.a, c.b) == (y, x))
};
let mut todo: Vec<(Vec3, Vec<(usize, usize)>)> = Vec::with_capacity(picks.len());
for (&(p, pair), back) in picks.iter().zip(&backs) {
let other = back.filter(|(x, y)| shell.is_some() && joined(*x, *y));
@@ -288,8 +764,8 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
}
return err(format!("the bend at {} closes a loop of bends (leave one of its edges unpicked: it is cut open)", at(p)));
}
let child = outline(b, &mesh, to, tol)
.map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?;
let child =
face_of(to).map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?;
let Some(parent) = panels.get(&from) else { continue };
let theta = parent.face.n.dot(child.n).clamp(-1.0, 1.0).acos();
if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) {
@@ -313,12 +789,46 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
// The flat frame: on the fixed face, x along its longest edge, z into the material.
let Some(base) = panels.get(&f0) else { return err("fixed face") };
let corners = base.face.pts.iter().zip(base.face.pts.iter().cycle().skip(1));
let longest = corners.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1)));
let straight = corners.zip(&base.face.round).filter(|(_, r)| r.is_none()).map(|(c, _)| c);
let longest = straight.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1)));
let frame = longest
.and_then(|(a, b)| Some((*a, (*b - *a).normalized()?)))
.and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n)))
.ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?;
// A wall on part of an edge of the fixed face bends only where it stands (a bend picked as a
// chamfer or a cylinder is taken as it is).
let walls: Vec<(usize, f64)> = panels.iter().filter(|(_, p)| p.parent == Some(f0)).map(|(g, p)| (*g, p.setback)).collect();
for (g, by) in walls {
if chamfers.iter().any(|c| (c.a, c.b) == (f0, g) || (c.a, c.b) == (g, f0)) {
continue;
}
let kids: Vec<usize> = panels.iter().filter(|(_, p)| p.parent == Some(g)).map(|(h, _)| *h).collect();
let Some(mut wall) = panels.remove(&g) else { continue };
if let Some(base) = panels.get_mut(&f0) {
end_at_wall(&mut base.face, &mut wall.face, (f0, g), by, &|h| h == f0 || kids.contains(&h), tol);
}
panels.insert(g, wall);
}
let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default());
// How far behind a face of the sheet the body's face `g` starts and ends: a hole's wall
// reaches through the sheet, a pocket's ends on its floor, an island's starts at the face.
let mut points: BTreeMap<usize, Vec<Vec3>> = BTreeMap::new();
for (tri, g) in mesh.triangles.iter().zip(&mesh.tri_face) {
points.entry(*g as usize).or_default().extend(tri.iter().filter_map(|k| mesh.positions.get(*k as usize)));
}
let span = |g: usize, face: &Face| {
let o = face.pts.first().copied().unwrap_or_default();
let ds = points.get(&g).into_iter().flatten().map(|p| (o - *p).dot(face.n));
ds.fold((f64::INFINITY, 0.0), |(lo, hi): (f64, f64), d| (lo.min(d.max(0.0)), hi.max(d)))
};
// The span of a loop's walls: the least any starts at, and the least and most any ends at.
let walls_span = |walls: &[usize], face: &Face| {
walls
.iter()
.map(|g| span(*g, face))
.fold((f64::INFINITY, f64::INFINITY, 0.0), |(s, lo, hi): (f64, f64, f64), (a, b)| (s.min(a), lo.min(b), hi.max(b)))
};
let mut work = FlatWork::new();
// Lay the panels out, parents first: each is its face, seen through the inverse of its
// fold, with its edges moved in by a bend's setback or a rip's cut.
let mut queue: Vec<(usize, Option<usize>)> = vec![(f0, None)];
@@ -344,16 +854,105 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
});
}
let mut across = panel.face.across.clone();
let flat = |p: Vec3| {
let q = apply_point(&inv, p);
Vec2::new(q.x, q.y)
};
let mut round: Vec<Option<(Vec2, Vec2)>> = panel.face.round.iter().map(|r| r.map(|(c, m)| (flat(c), flat(m)))).collect();
if signed_area(&pts) < 0.0 {
pts.reverse();
turn(&mut by);
turn(&mut across);
turn(&mut round);
}
let first = panel.face.pts.first().copied().unwrap_or_default();
let (trimmed, from) = inset(&pts, &by).map_err(|e| DocError::Invalid(format!("{e} (the face at {})", at(first))))?;
let face_at = |e: DocError| DocError::Invalid(format!("{e} (the face at {})", at(first)));
let (trimmed, from) = inset(&pts, &by).map_err(face_at)?;
let exact = drawn(&trimmed, &from, &pts, &round).map_err(face_at)?;
match fl {
None => sh.base.outer = Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>()),
Some(i) => set_panel(&mut sh, i, &trimmed),
None => sh.base.outer = exact.unwrap_or_else(|| Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>())),
Some(i) => {
set_panel(&mut sh, i, &trimmed);
if let (Some(lp), Some(f)) = (exact, sh.flanges.get_mut(i)) {
f.shape = Some(trimmed.clone());
f.exact = Some(lp);
}
}
}
// The face's holes: cut-outs of the sheet where they lie, inside its panel. A hole's walls
// reach through the sheet; a pocket's (an engraved label) end inside it, on floors facing
// out as the face does: a pocket of the sheet from that skin, round its islands. On a
// shell the face behind, the other skin, is read for its pockets too.
let region = match fl {
None => sh.base.clone(),
Some(i) => sh.flanges.get(i).map(|f| sh.panel(f)).unwrap_or_default(),
};
let back = skin_behind.get(&pf).and_then(|g| outline(b, &mesh, *g, tol).ok());
let skins: Vec<(&Face, bool)> = std::iter::once((&panel.face, true)).chain(back.as_ref().map(|f| (f, false))).collect();
let bound = if skins.iter().all(|(f, _)| f.holes.is_empty()) { Vec::new() } else { region.outer.ccw().polyline(1e-3) };
for (face, front) in skins {
for (h, walls) in &face.holes {
let p = face.plane.to_world(h.centroid());
let (_, reach, deepest) = walls_span(walls, face);
let through = reach >= t - tol;
if through && !front {
continue;
}
let cut = sh.region_to_flat(fl, &face.plane, &Region2 { outer: h.clone(), holes: Vec::new() })?;
let ring = cut.outer.polyline(1e-3);
if !ring.iter().all(|q| point_in(&bound, *q)) || polys_cross(&bound, &ring) {
let kind = if through { "hole" } else { "pocket" };
return err(format!("not supported yet: the {kind} at {} reaches into a bend (cut it after converting)", at(p)));
}
if through {
sh.holes.push(cut);
continue;
}
let pocket =
|why: &str| err(format!("not supported yet: the pocket at {} in a face of the sheet {why} (cut it after converting)", at(p)));
let eps = t * 1e-3;
if reach > t / 2.0 + eps {
return pocket(&format!("ends {reach:.3} deep, more than half the sheet's {t:.3}"));
}
if deepest - reach > eps {
return pocket("has walls of more than one depth");
}
// Its floors: the faces at its depth beside its walls, facing out as the face does.
let mut floors: Vec<usize> = mesh
.edge_faces
.iter()
.filter(|fs| fs.iter().any(|g| walls.contains(&(*g as usize))))
.flatten()
.map(|g| *g as usize)
.filter(|g| {
!walls.contains(g)
&& matches!(face_surface(b, *g), Some(FaceSurface::Plane { normal, .. }) if normal.dot(face.n) > 1.0 - 1e-9)
})
.filter(|g| {
let (lo, hi) = span(*g, face);
(lo - reach).abs() <= eps && (hi - reach).abs() <= eps
})
.collect();
floors.sort_unstable();
floors.dedup();
if floors.is_empty() {
return pocket("has no flat floor");
}
// The islands left standing in it (a letter's counter): walls from a floor up to the face.
let mut islands = Vec::new();
for g in floors {
let floor = outline(b, &mesh, g, tol)?;
for (k, kw) in &floor.holes {
let (start, lo, hi) = walls_span(kw, face);
if start > eps || (lo - reach).abs() > eps || (hi - reach).abs() > eps {
return pocket("has a hole or a step in its floor");
}
islands.push(sh.region_to_flat(fl, &floor.plane, &Region2 { outer: k.clone(), holes: Vec::new() })?.outer.reversed());
}
}
let (z0, z1) = if front { (0.0, reach) } else { (t - reach, t) };
sh.record_pocket(Region2 { outer: cut.outer, holes: islands }, z0, z1, &mut work)?;
}
}
// Its children: flanges on the trimmed edges they bend at.
let m = trimmed.len();
@@ -379,13 +978,32 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
ext: (0.0, 0.0),
slope: (0.0, 0.0),
shape: None,
exact: None,
tag: String::new(),
tear: (false, false),
});
queue.push((g, Some(sh.flanges.len() - 1)));
}
}
Ok(sh)
// The bends on the fixed face finished as an edge flange feature finishes its own
// (`SheetBody::add_flanges_each`): the rule's bend relief where one ends beside more of the
// fixed face; where two meet at a corner of it, their walls closed with the rule's gap (which
// the cuts above already are) and the rule's corner relief.
let made: Vec<usize> = (0..sh.flanges.len()).collect();
let mut notes = Vec::new();
for &i in &made {
notes.extend(sh.relieve_bend_ends(i, (false, false)));
}
let corners = sh.corners(&made);
let mut closed = Vec::with_capacity(corners.len());
for k in &corners {
match sh.close_corner(k) {
Ok(()) => closed.push(*k),
Err(why) => notes.push(format!("the corner at {} stays cut open: {why}", sh.shown(k.c))),
}
}
notes.extend(sh.relieve_corners(&corners, &closed));
Ok((sh, notes))
}
#[cfg(test)]
+4 -35
View File
@@ -29,9 +29,11 @@ fn plate_doc() -> (Document, u64) {
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
doc.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: s,
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "20".into(),
direction: Direction::Positive,
distance2: None,
@@ -120,9 +122,11 @@ fn box_fillet_cut_timeline() {
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
doc.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: s2,
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "20".into(),
direction: Direction::Positive,
distance2: None,
@@ -342,41 +346,6 @@ fn input_sketches_cover_points_and_patches() {
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"
);
}
}
#[test]
fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
let body = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap();
+359
View File
@@ -0,0 +1,359 @@
//! The walks over a feature: its expressions (`params`), its references to the rest of the design
//! (`feature_refs`) and its geometry (`feature_geometry`). `#[derive(Walk)]`
//! (`solidcraft-derive`) generates them from `#[walk(...)]` roles on the fields of `FeatureKind`
//! and the types inside it, so what a field is, is written once, where it is declared. The
//! traits here carry the walks through the types the fields have: options, lists, arrays, planes
//! and construction definitions.
pub(crate) use solidcraft_geom::Vec3;
pub(crate) use crate::ProfileSel;
use crate::construct::{AxisDef, GeoRef, PointDef};
pub(crate) use crate::expr::Kind;
pub(crate) use crate::feature_geometry::Geo;
pub(crate) use crate::feature_refs::Ref;
pub(crate) use crate::params::ExprRole;
use crate::{PlaneRef, PointExpr};
/// An expression: its label, the expression, its kind and its role.
pub(crate) type ExprItem<'a> = (&'static str, &'a String, Kind, ExprRole);
pub(crate) type ExprItemMut<'a> = (&'static str, &'a mut String, Kind, ExprRole);
pub(crate) type RefItem<'a> = Ref<&'a u64, &'a String>;
pub(crate) type RefItemMut<'a> = Ref<&'a mut u64, &'a mut String>;
pub(crate) type GeoItem<'a> = Geo<&'a Vec3, &'a PlaneRef, &'a GeoRef, &'a PointExpr, &'a ProfileSel>;
pub(crate) type GeoItemMut<'a> = Geo<&'a mut Vec3, &'a mut PlaneRef, &'a mut GeoRef, &'a mut PointExpr, &'a mut ProfileSel>;
/// A value's expressions, references and geometry, each walked in order. A type with none of
/// them implements it empty.
pub(crate) trait Walk {
fn walk_exprs<'a>(&'a self, _: &mut dyn FnMut(ExprItem<'a>)) {}
fn walk_exprs_mut<'a>(&'a mut self, _: &mut dyn FnMut(ExprItemMut<'a>)) {}
fn walk_refs<'a>(&'a self, _: &mut dyn FnMut(RefItem<'a>)) {}
fn walk_refs_mut<'a>(&'a mut self, _: &mut dyn FnMut(RefItemMut<'a>)) {}
fn walk_geometry<'a>(&'a self, _: &mut dyn FnMut(GeoItem<'a>)) {}
fn walk_geometry_mut<'a>(&'a mut self, _: &mut dyn FnMut(GeoItemMut<'a>)) {}
}
impl Walk for String {}
impl Walk for std::sync::Arc<str> {}
impl Walk for bool {}
impl Walk for u64 {}
impl Walk for i8 {}
impl Walk for f64 {}
impl Walk for ProfileSel {}
impl Walk for solidcraft_sketch::Sketch {}
impl Walk for solidcraft_kernel::StepNode {}
impl Walk for crate::document::Operation {}
impl Walk for crate::document::Direction {}
impl Walk for crate::document::FilletFit {}
impl Walk for crate::document::MeshData {}
impl Walk for crate::document::RevolveConvention {}
impl Walk for crate::document::CoilSection {}
impl Walk for crate::document::ThinSide {}
impl Walk for crate::document::ExtendMode {}
impl Walk for crate::document::RuledMode {}
impl Walk for crate::document::RuledCorners {}
impl Walk for crate::document::ExtrudeConvention {}
/// Containers walk what they hold, in order.
macro_rules! walk_each {
($([$($g:tt)*] $t:ty),*) => {$(
impl<$($g)*> Walk for $t {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
for t in self {
t.walk_exprs(f)
}
}
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
for t in self {
t.walk_exprs_mut(f)
}
}
fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) {
for t in self {
t.walk_refs(f)
}
}
fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) {
for t in self {
t.walk_refs_mut(f)
}
}
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
for t in self {
t.walk_geometry(f)
}
}
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
for t in self {
t.walk_geometry_mut(f)
}
}
}
)*};
}
walk_each!([T: Walk] Option<T>, [T: Walk] Vec<T>, [T: Walk, const N: usize] [T; N]);
/// The values of a field with a role (`T`, an optional one or a list), in order.
pub(crate) trait Each<T> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T));
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T));
}
impl<T> Each<T> for T {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
f(self)
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
f(self)
}
}
impl<T> Each<T> for Option<T> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
if let Some(t) = self {
f(t)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
if let Some(t) = self {
f(t)
}
}
}
impl<T> Each<T> for Vec<T> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
for t in self {
f(t)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
for t in self {
f(t)
}
}
}
impl<T> Each<T> for Option<Vec<T>> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
for t in self.iter().flatten() {
f(t)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
for t in self.iter_mut().flatten() {
f(t)
}
}
}
/// The expressions of an expression field, each with its index and their count (for its label).
pub(crate) trait ExprField {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String));
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String));
}
impl ExprField for String {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
f(0, 1, self)
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
f(0, 1, self)
}
}
/// A position along an edge with its radius (a variable fillet's mid radii).
impl ExprField for (f64, String) {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
f(0, 1, &self.1)
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
f(0, 1, &mut self.1)
}
}
impl<T: ExprField> ExprField for Option<T> {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
if let Some(t) = self {
t.each(f)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
if let Some(t) = self {
t.each_mut(f)
}
}
}
/// Lists and arrays number their elements.
macro_rules! expr_list {
($([$($g:tt)*] $t:ty),*) => {$(
impl<$($g)*> ExprField for $t {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
let n = self.len();
for (i, t) in self.iter().enumerate() {
t.each(&mut |_, _, e| f(i, n, e));
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
let n = self.len();
for (i, t) in self.iter_mut().enumerate() {
t.each_mut(&mut |_, _, e| f(i, n, e));
}
}
}
)*};
}
expr_list!([T: ExprField] Vec<T>, [T: ExprField, const N: usize] [T; N]);
/// An expression's label: one for every element, one per element, or worked out from the
/// element's index and the count.
pub(crate) trait Label {
fn at(&self, i: usize, n: usize) -> &'static str;
}
impl Label for &'static str {
fn at(&self, _: usize, _: usize) -> &'static str {
self
}
}
impl<const N: usize> Label for [&'static str; N] {
fn at(&self, i: usize, _: usize) -> &'static str {
self.get(i).copied().unwrap_or("")
}
}
impl<F: Fn(usize, usize) -> &'static str> Label for F {
fn at(&self, i: usize, n: usize) -> &'static str {
self(i, n)
}
}
/// The offsets and angles of a plane, outermost first (nesting is capped like every walk over a
/// plane), the construction plane it is built on, and the plane itself.
impl Walk for PlaneRef {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, distance } => {
f(("Offset", distance, Kind::Length, ExprRole::Input));
p = base;
}
PlaneRef::AtAngle { base, angle, .. } => {
f(("Angle", angle, Kind::Angle, ExprRole::Input));
p = base;
}
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
_ => return,
}
}
}
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, distance } => {
f(("Offset", distance, Kind::Length, ExprRole::Input));
p = base;
}
PlaneRef::AtAngle { base, angle, .. } => {
f(("Angle", angle, Kind::Angle, ExprRole::Input));
p = base;
}
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
_ => return,
}
}
}
fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
_ => return,
}
}
}
fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
_ => return,
}
}
}
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
f(Geo::Plane(self))
}
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
f(Geo::Plane(self))
}
}
/// Construction axes and points: the expressions of the planes they are built from (and a point
/// along a path's distance), and their references to the model. They name no sketches, features
/// or bodies: copies remap their geometry references on their own (`Feature::remap_clone`).
fn plane_exprs<'a>(refs: Vec<&'a GeoRef>, f: &mut dyn FnMut(ExprItem<'a>)) {
for r in refs {
if let GeoRef::Plane { plane } = r {
plane.walk_exprs(f);
}
}
}
fn plane_exprs_mut<'a>(refs: Vec<&'a mut GeoRef>, f: &mut dyn FnMut(ExprItemMut<'a>)) {
for r in refs {
if let GeoRef::Plane { plane } = r {
plane.walk_exprs_mut(f);
}
}
}
macro_rules! construct_geometry {
() => {
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
for r in self.refs() {
f(Geo::Construct(r));
}
}
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
for r in self.refs_mut() {
f(Geo::Construct(r));
}
}
};
}
impl Walk for AxisDef {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
plane_exprs(self.refs(), f)
}
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
plane_exprs_mut(self.refs_mut(), f)
}
construct_geometry!();
}
impl Walk for PointDef {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
match self {
PointDef::AlongPath { t, .. } => f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
other => plane_exprs(other.refs(), f),
}
}
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
match self {
PointDef::AlongPath { t, .. } => f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
other => plane_exprs_mut(other.refs_mut(), f),
}
}
construct_geometry!();
}
+39 -4
View File
@@ -216,9 +216,23 @@ fn ls_extruded_up_and_down_from_one_grid_join() {
assert!((v - 6000.0).abs() < 1e-6, "{v}");
}
/// This process's CPU time (all threads) and peak resident memory, where the system says
/// (Linux: /proc/self); `None` elsewhere.
fn cpu_and_peak_memory() -> Option<(std::time::Duration, u64)> {
let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
// utime and stime, in clock ticks (100 a second on Linux), after the command's name.
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
let ticks: u64 = fields.get(11)?.parse::<u64>().ok()? + fields.get(12)?.parse::<u64>().ok()?;
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let kb: u64 = status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
Some((std::time::Duration::from_millis(ticks * 10), kb * 1024))
}
/// Press/pull on the sensor enclosure's lid top: joining the pulled face's prism imprints the
/// contact (#825), and boxing the lid's faces by dividing their curved edges down to the
/// contact tolerance ran away (over 24 GB and many minutes). It finishes in seconds.
/// contact tolerance ran away (over 24 GB and many minutes). It takes about 9 s of CPU here and
/// no memory beyond the design's. The budgets are work, not wall time, which a loaded runner
/// stretches (61-87 s for the same work on CI).
#[test]
fn press_pull_on_the_enclosure_lid_is_prompt() {
let mut s = Session::default();
@@ -226,9 +240,30 @@ fn press_pull_on_the_enclosure_lid_is_prompt() {
s.run_script(&script).unwrap();
let total = |s: &Session| -> f64 { s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum() };
let before = total(&s);
let t = std::time::Instant::now();
let start = cpu_and_peak_memory();
s.execute("solid.press_pull", &json!({"face": [-12.0, 27.4, 23.0], "distance": 5.0})).unwrap();
// A few seconds here; the bound leaves room for a loaded CI runner.
assert!(t.elapsed().as_secs() < 60, "took {:?}", t.elapsed());
if let (Some((cpu0, peak0)), Some((cpu1, peak1))) = (start, cpu_and_peak_memory()) {
let grew = peak1.saturating_sub(peak0);
assert!(grew < 1 << 30, "the press/pull grew the peak memory by {} MB", grew >> 20);
// Other tests in this process (cargo test) count too, so the bound is generous.
assert!(cpu1 - cpu0 < std::time::Duration::from_secs(300), "the press/pull took {:?} of CPU", cpu1 - cpu0);
}
assert!(total(&s) > before, "{before} -> {}", total(&s));
}
/// A lip swept round a block's rim, flush with its sides and round its corners, joins it as one
/// solid of both volumes: the block (50 × 40 with R4.5 corners, 10 high) and the lip (1 × 1 along
/// 41 + 31 + 41 mm of straight rim and two quarter turns between radii 3.5 and 4.5).
#[test]
fn lip_swept_flush_round_a_corner_joins() {
let s = replay(include_str!("../../../examples/regressions/lip_swept_flush_round_corner.json"));
let bodies = &s.model.state().bodies;
assert_eq!(bodies.len(), 1);
let b = &bodies[0];
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
assert_eq!(b.body.shell_count(), 1);
let block = (50.0 * 40.0 - (4.0 - std::f64::consts::PI) * 4.5 * 4.5) * 10.0;
let lip = 41.0 + 31.0 + 41.0 + 2.0 * std::f64::consts::PI * (4.5 * 4.5 - 3.5 * 3.5) / 4.0;
let volume = b.body.tessellate(1e-5).unwrap().measure().volume;
assert!((volume - block - lip).abs() < 0.02, "{volume} expected {}", block + lip);
}
+119 -5
View File
@@ -38,8 +38,9 @@ pub static COMMANDS: &[CommandSpec] = &[
),
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); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
"visible: bool; items?: [sketch id or name | body name | construction name | joint name | O|X|Y|Z|XY|XZ|YZ]; \
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
or folder?: bodies|sketches|construction|origin|joints (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
),
];
@@ -170,6 +171,8 @@ enum Shown {
Body(String),
/// Origin items and construction geometry, by name.
Origin(String),
/// An assembly joint's glyph, by joint id.
Joint(u64),
}
fn is_construction(k: &FeatureKind) -> bool {
@@ -188,6 +191,9 @@ fn shown_item(s: &Session, key: &str) -> Option<Shown> {
if s.world_state().bodies.iter().any(|b| b.name == key) {
return Some(Shown::Body(key.to_string()));
}
if let Some(j) = s.doc.assembly.joints.iter().find(|j| j.name == key) {
return Some(Shown::Joint(j.id));
}
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
}
@@ -255,12 +261,24 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
}
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
_ if p.get("joints").is_some() => {
if p.get("items").is_some() || p.get("folder").is_some() {
return Err(bad(cmd, "give `joints`, `items` or `folder`, not several"));
}
let list = p
.get("joints")
.and_then(Value::as_array)
.filter(|a| !a.is_empty() && a.len() <= 10_000)
.ok_or_else(|| bad(cmd, "`joints` must list 1..10000 joint ids or names"))?;
list.iter().map(|j| super::joints::joint_id(s, Some(j), cmd).map(Shown::Joint)).collect::<Result<_>>()?
}
(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)"))),
(Some("joints"), None) => s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)).collect(),
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin, joints)"))),
(None, Some(_)) => {
let keys = keys(p, "items", cmd)?;
if keys.is_empty() {
@@ -268,7 +286,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
}
// 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}`"))))
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body, construction item or joint `{k}`"))))
.collect::<Result<_>>()?
}
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
@@ -296,6 +314,12 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
v.hidden_origin.push(n.clone());
}
}
Shown::Joint(id) => {
v.hidden_joints.retain(|x| x != id);
if !visible {
v.hidden_joints.push(*id);
}
}
}
}
s.revision += 1;
@@ -489,22 +513,59 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("another body is called `{name}`")));
}
// The body is the k-th new body of its feature; its siblings keep the names they have now
// (unpinned pattern copies would otherwise be renumbered into the freed name, #265).
// (unpinned pattern copies would otherwise be renumbered into the freed name, #265). That
// includes the siblings whose stored name is shared: the solids of a STEP file with one label
// are stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`, so the stored names are set
// to the numbered ones, or `X (3)` would turn into `X (2)` once the second solid is renamed.
let fid = b.feature;
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
// A body numbered by the counter (`BodyN`) gives its number back once it has a name of its
// own, and every counted body after it would move down one (`Body3` becoming `Body1`) and
// break what names it. Those later features keep the names they made too (#1109): each one's
// own result holds them, also for bodies a later feature joins away.
let counted = |f: &solidcraft_doc::Feature, i: usize| {
let first = f.body_names.first().is_some_and(|n| !n.trim().is_empty());
f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first)
};
let later: Vec<(u64, Vec<String>)> = match s.doc.features.iter().position(|f| f.id == fid) {
Some(at) if s.doc.features.get(at).is_some_and(|f| counted(f, k)) => s
.doc
.features
.iter()
.skip(at + 1)
.filter_map(|f| {
let made: Vec<String> = s.model.result(f.id)?.state.bodies.iter().filter(|x| x.feature == f.id).map(|x| x.name.clone()).collect();
(0..made.len()).any(|i| counted(f, i)).then_some((f.id, made))
})
.collect(),
_ => Vec::new(),
};
let d = s.doc_mut();
for (id, made) in &later {
if let Some(f) = d.feature_mut(*id) {
f.body_names.clone_from(made);
}
}
let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?;
while f.body_names.len() < siblings.len().max(k + 1) {
let i = f.body_names.len();
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
}
for (slot, current) in f.body_names.iter_mut().zip(&siblings) {
slot.clone_from(current);
}
if let Some(slot) = f.body_names.get_mut(k) {
*slot = name.to_string();
}
// Later features refer to bodies by name.
let mut failed = None;
for f in d.features.iter_mut().filter(|f| f.id != fid) {
// The bodies a join/cut/intersect may change (a primitive's `targets`) are kept beside
// the feature's kind, not in it (#1108).
for b in f.participants.iter_mut().filter(|b| **b == old) {
*b = name.to_string();
}
if let FeatureKind::Sketch { plane, .. } = &mut f.kind {
solidcraft_doc::sheet::rename_plane_sheet_body(plane, &old, name);
}
@@ -705,6 +766,59 @@ mod tests {
assert_eq!(names(&s)[1], b);
}
/// #1108: a primitive's `targets` are its participants, kept beside its kind; a rename
/// follows them, as it follows the names inside the kind (a combine's target and tools).
#[test]
fn renamed_body_keeps_the_primitives_that_target_it() {
let mut s = Session::default();
let total = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "name": "Block", "body_name": "A"}));
run(&mut s, "solid.cylinder", json!({"diameter": 5, "height": 12, "base": [5, 5, -1], "operation": "cut", "targets": ["A"], "name": "Hole"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0], "operation": "new", "body_name": "Tab"}));
run(&mut s, "solid.combine", json!({"target": "A", "tools": ["Tab"], "operation": "join", "name": "Joined"}));
let want = total(&mut s);
run(&mut s, "body.rename", json!({"body": "A", "name": "B"}));
assert!(
s.model.results.iter().all(|r| r.error.is_none()),
"{:?}",
s.model.results.iter().filter_map(|r| r.error.clone()).collect::<Vec<_>>()
);
assert_eq!(s.doc.find_feature("Hole").unwrap().participants, vec!["B".to_string()]);
assert!((total(&mut s) - want).abs() < 1e-6);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.find_feature("Hole").unwrap().participants, vec!["A".to_string()]);
}
/// #1109: renaming counted bodies (`Body1`, `Body2` of one pattern) leaves the counted bodies
/// of later features as they were (`Body3`, `Body4`), and what names them keeps working.
#[test]
fn renaming_counted_bodies_keeps_later_counted_names() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "center": [0, 0, 2.5], "name": "B1", "body_name": "First"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["B1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 10, "name": "P1"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "center": [0, 20, 2.5], "name": "B2", "body_name": "Second"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["B2"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 10, "name": "P2"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "center": [10, 25, 2.5], "operation": "new", "name": "Bar"}));
let before = names(&s);
assert!(["Body1", "Body2", "Body3", "Body4", "Body5"].iter().all(|n| before.contains(&n.to_string())), "{before:?}");
run(&mut s, "solid.combine", json!({"target": "Body3", "tools": ["Body5"], "operation": "join", "name": "Joined"}));
// Renaming a body that already has a name of its own stores nothing elsewhere.
run(&mut s, "body.rename", json!({"body": "Second", "name": "Row"}));
assert!(s.doc.find_feature("P2").unwrap().body_names.is_empty());
run(&mut s, "body.rename", json!({"body": "Body1", "name": "FirstCopyA"}));
run(&mut s, "body.rename", json!({"body": "Body2", "name": "FirstCopyB"}));
assert!(
s.model.results.iter().all(|r| r.error.is_none()),
"{:?}",
s.model.results.iter().filter_map(|r| r.error.clone()).collect::<Vec<_>>()
);
let after = names(&s);
assert!(after.contains(&"Body3".to_string()) && after.contains(&"Body4".to_string()), "{after:?}");
assert!(!after.contains(&"Body1".to_string()) && !after.contains(&"Body5".to_string()), "{after:?}");
run(&mut s, "edit.undo", json!({}));
assert!(names(&s).contains(&"Body2".to_string()) && names(&s).contains(&"Body4".to_string()), "{:?}", names(&s));
}
#[test]
fn renaming_a_body_leaves_other_text_with_its_name_alone() {
let mut s = Session::default();
+282 -11
View File
@@ -104,17 +104,21 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let outputs = if f.body_names.is_empty() {
s.model
.result(f.id)
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect::<Vec<_>>())
.unwrap_or_default()
} else {
f.body_names.clone()
};
let built: Vec<String> = s
.model
.result(f.id)
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect())
.unwrap_or_default();
let outputs = if f.body_names.is_empty() { built.clone() } else { f.body_names.clone() };
// A later feature names a body as the model shows it, and that is not always the name stored
// on the feature: solids that share a label are stored as `X`, `X`, `X` and shown as `X`,
// `X (2)`, `X (3)`, and a second import of a file shows `Aa (2)` for the stored `Aa`. The
// copies are found by the shown names; the stored ones only name the copies.
let shown = if built.len() == outputs.len() { built } else { outputs.clone() };
let copied_names: Vec<_> = outputs
.iter()
.map(|original| {
.zip(&shown)
.map(|(original, shown)| {
let base = format!("{original} (copy)");
let mut name = base.clone();
let mut suffix = 2usize;
@@ -122,7 +126,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
name = format!("{base} {suffix}");
suffix += 1;
}
bodies.insert(original.clone(), name.clone());
bodies.insert(shown.clone(), name.clone());
name
})
.collect();
@@ -146,10 +150,18 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
m[3][2] = t.z;
kind.transform_geometry(&m);
}
// A cylinder Emboss finds its face only by the persistent name recorded when it was made
// (#1035): the copy keeps that name, pointed at the pasted features and bodies.
let face_names: Vec<String> = if matches!(kind, FeatureKind::Emboss { face: Some(_), .. }) {
f.face_names.iter().map(|n| solidcraft_doc::remap_topology(n, &sketches, &bodies)).collect()
} else {
Vec::new()
};
let id = doc.add_feature(kind, None)?;
if let Some(nf) = doc.feature_mut(id) {
nf.component = comp;
nf.body_names = copied_names;
nf.face_names = face_names;
}
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
sketches.insert(f.id, id);
@@ -167,7 +179,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use serde_json::json;
use serde_json::{Value, json};
use crate::Session;
@@ -233,6 +245,173 @@ mod tests {
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design");
}
/// A STEP file of three boxes at x = 0, 30 and 60. With `shared` the three solids carry one
/// label (`X`), as the parts of many assembly files do; otherwise each has its own.
fn three_box_step(tag: &str, shared: bool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-paste-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
for (i, label) in ["Aa", "Bb", "Cc"].iter().enumerate() {
let corner = [30.0 * i as f64, 0.0, 0.0];
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": corner, "operation": "new_body", "body_name": label}));
}
let path = dir.join("Three.step");
run(&mut s, "file.export", json!({"path": path.to_string_lossy()}));
if shared {
let mut text = std::fs::read_to_string(&path).unwrap();
for label in ["Aa", "Bb", "Cc"] {
assert!(text.contains(&format!("MANIFOLD_SOLID_BREP('{label}'")), "the file names its solids: {label}");
text = text.replace(&format!("MANIFOLD_SOLID_BREP('{label}'"), "MANIFOLD_SOLID_BREP('X'");
}
std::fs::write(&path, text).unwrap();
}
path
}
/// An OBJ file of three 10 mm cubes at x = 0, 30 and 60, every object called `X`.
fn three_cube_obj(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-paste-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
let faces =
[[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [3, 7, 6], [3, 6, 2], [0, 4, 7], [0, 7, 3], [1, 2, 6], [1, 6, 5]];
let mut text = String::new();
for i in 0..3 {
text += "o X\n";
for c in corners {
text += &format!("v {} {} {}\n", 30 * i + 10 * c[0], 10 * c[1], 10 * c[2]);
}
for f in faces {
text += &format!("f {} {} {}\n", 8 * i + f[0] + 1, 8 * i + f[1] + 1, 8 * i + f[2] + 1);
}
}
let path = dir.join("Three.obj");
std::fs::write(&path, text).unwrap();
path
}
fn body_names(s: &Session) -> Vec<String> {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
/// Where every body is: the lowest corner of its bounding box, rounded, in model order.
fn corners(s: &mut Session) -> Vec<Vec<f64>> {
let m = run(s, "inspect.measure", json!({}));
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
bodies
.iter()
.map(|b| b["bbox"]["min"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64().map(f64::round)).collect()).unwrap_or_default())
.collect()
}
/// Lifts the first of the three bodies of the import by 20 and the second by 50 (two features),
/// copies the import with both and pastes them: the copies must be lifted, the originals once.
fn paste_import_with_two_lifts(s: &mut Session, import: &Value, bodies: [&str; 3]) {
run(s, "solid.move", json!({"bodies": [bodies[0]], "translate": [0, 0, 20], "name": "Lift first"}));
run(s, "solid.move", json!({"bodies": [bodies[1]], "translate": [0, 0, 50], "name": "Lift second"}));
run(s, "timeline.copy", json!({"features": [import, "Lift first", "Lift second"]}));
let r = run(s, "timeline.paste", json!({}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
}
const LIFTED: [[f64; 3]; 3] = [[0.0, 0.0, 20.0], [30.0, 0.0, 50.0], [60.0, 0.0, 0.0]];
/// A control: three solids with labels of their own. The pasted features act on the pasted bodies.
#[test]
fn pasting_an_import_moves_the_copies_of_the_bodies_its_features_named() {
let path = three_box_step("distinct", false);
let mut s = Session::default();
let import = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc"]);
paste_import_with_two_lifts(&mut s, &import, ["Aa", "Bb", "Cc"]);
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (copy)", "Bb (copy)", "Cc (copy)"]);
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at);
}
/// Solids of a STEP file that share one label are built as `X`, `X (2)`, `X (3)`, and the features
/// made on them name them so. The pasted features act on the copies of those bodies, each on its
/// own: `X (2)` is not left on the original, and `X` does not go to the last copy.
#[test]
fn pasting_an_import_with_one_label_on_all_solids_moves_the_copy_of_each_body() {
let path = three_box_step("shared", true);
let mut s = Session::default();
let import = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
paste_import_with_two_lifts(&mut s, &import, ["X", "X (2)", "X (3)"]);
let names = body_names(&s);
assert_eq!(names.len(), 6, "{names:?}");
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{names:?}");
// The copies keep their names and their places when the design is saved and opened again.
let design = path.with_file_name("Three.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
let mut r = Session::default();
run(&mut r, "doc.open", json!({"path": design.to_string_lossy()}));
assert_eq!(body_names(&r), names);
assert_eq!(corners(&mut r), at);
}
/// The same for the objects of an OBJ file (3MF and STL files give the same kind of import).
#[test]
fn pasting_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_each_body() {
let path = three_cube_obj("shared-obj");
let mut s = Session::default();
let import = run(&mut s, "file.insert_mesh", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
paste_import_with_two_lifts(&mut s, &import, ["X", "X (2)", "X (3)"]);
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
}
/// No shared label is needed: a second import of the same file builds `Aa (2)`, `Bb (2)`, `Cc (2)`
/// from the labels `Aa`, `Bb`, `Cc`, and the features made on those bodies name them so.
#[test]
fn pasting_a_second_import_of_a_file_moves_the_copies_of_its_bodies() {
let path = three_box_step("twice", false);
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}));
let second = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
paste_import_with_two_lifts(&mut s, &second, ["Aa (2)", "Bb (2)", "Cc (2)"]);
let plain = [[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [60.0, 0.0, 0.0]];
let at: Vec<Vec<f64>> = plain.iter().chain(&LIFTED).chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
}
/// A cylinder Emboss finds its face by the persistent name it recorded when it was made. The
/// pasted Emboss keeps that name, moved onto the pasted cylinder, so the copy gets its own
/// badge, with or without a translate (#1035).
#[test]
fn pasted_cylinder_emboss_badges_the_pasted_cylinder() {
for translate in [Some(json!([50, 0, 0])), None] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": "10 mm", "height": "20 mm", "body_name": "Sleeve", "name": "Tube"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 10, 0], "x_dir": [-1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-2, 5], "p1": [2, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Badge", "face": [0, 10, 7.5], "depth": 1, "targets": ["Sleeve"], "name": "Mark"}));
let one = volume(&mut s);
let plain = std::f64::consts::PI * 100.0 * 20.0;
assert!(one > plain + 1.0, "the badge adds material: {one}");
run(&mut s, "timeline.copy", json!({"features": ["Tube", "Badge", "Mark"]}));
let p = translate.clone().map_or(json!({}), |t| json!({"translate": t}));
let r = run(&mut s, "timeline.paste", p);
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{translate:?}: {r}");
let mark = s.doc.find_feature(r["pasted"][2]["name"].as_str().unwrap_or_default()).cloned().unwrap();
let tube = r["pasted"][0]["id"].as_u64().unwrap();
assert!(mark.face_names.first().is_some_and(|n| n.starts_with(&format!("F{tube}:"))), "{:?}", mark.face_names);
assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "{translate:?}: both cylinders carry a badge");
if translate.is_some() {
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Sleeve (copy)"]}));
let body = &m["bodies"][0];
let x = body["center_of_mass"][0].as_f64().unwrap_or(f64::NAN);
let y = body["bbox"]["max"][1].as_f64().unwrap_or(f64::NAN);
assert!((x - 50.0).abs() < 1e-3 && (y - 11.0).abs() < 1e-6, "the copy's badge sits on the moved cylinder: {m}");
}
}
}
#[test]
fn pasted_fillet_rounds_the_copied_body_it_names() {
let mut s = Session::default();
@@ -253,4 +432,96 @@ mod tests {
assert_eq!(m["body_count"], 2, "{m}");
assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "both blocks are rounded: {m}");
}
/// One body's measures (bounding box, volume).
fn measures(s: &mut Session, body: &str) -> serde_json::Value {
run(s, "inspect.measure", json!({"bodies": [body]}))["bodies"][0].clone()
}
/// Pasting with a translate moves an extrude's `to` and `from` picks with it: the pasted posts
/// run to the pasted roof and from the pasted shelf, not to and from the originals.
#[test]
fn a_pasted_extrude_runs_to_and_from_the_pasted_faces() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [0, 0, 20], "name": "Roof", "body_name": "Roof"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [0, 0, 10], "name": "Shelf", "body_name": "Shelf"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5, "to": [7, 7, 20], "operation": "new", "name": "Up", "body_name": "Up"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "T"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [25, 25], "p1": [30, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"solid.extrude",
json!({"sketch": "T", "distance": 5, "from": [27, 27, 12], "operation": "new", "name": "On", "body_name": "On"}),
);
run(&mut s, "timeline.copy", json!({"features": ["Roof", "Shelf", "S", "Up", "T", "On"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [100, 0, 10]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let up = measures(&mut s, "Up (copy)");
assert!((up["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 10.0).abs() < 1e-6, "{up}");
assert!((up["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "it ends on the pasted roof: {up}");
let on = measures(&mut s, "On (copy)");
assert!((on["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 22.0).abs() < 1e-6, "it starts on the pasted shelf: {on}");
assert!((on["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 27.0).abs() < 1e-6, "{on}");
}
/// Pasting with a translate moves a hole's `to` pick with it: the pasted through hole still goes
/// through, rather than stopping at the original's depth.
#[test]
fn a_pasted_hole_drills_to_the_moved_point() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "name": "Block", "body_name": "Block"}));
run(&mut s, "solid.hole", json!({"position": [20, 20, 10], "direction": [0, 0, -1], "diameter": 10, "to": [30, 30, -2], "name": "Drill"}));
run(&mut s, "timeline.copy", json!({"features": ["Block", "Drill"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [60, 0, -6]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let (block, copy) = (measures(&mut s, "Block"), measures(&mut s, "Block (copy)"));
let v = |m: &serde_json::Value| m["volume_mm3"].as_f64().unwrap_or(f64::NAN);
assert!((v(&block) - (16000.0 - std::f64::consts::PI * 25.0 * 10.0)).abs() < 1.0, "a through hole: {block}");
assert!((v(&copy) - v(&block)).abs() < 1e-6, "the pasted hole goes through too: {copy}");
}
/// Pasting with a translate moves a revolve's `to` pick with it: the pasted turn ends at the same
/// angle about its moved axis.
#[test]
fn a_pasted_revolve_turns_to_the_moved_point() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
run(&mut s, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "S", "axis": "y", "to": [0, 12, 2], "name": "Turn", "body_name": "Arc"}));
run(&mut s, "timeline.copy", json!({"features": ["S", "Turn"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [50, 0, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let (arc, copy) = (measures(&mut s, "Arc"), measures(&mut s, "Arc (copy)"));
let v = |m: &serde_json::Value| m["volume_mm3"].as_f64().unwrap_or(f64::NAN);
// A quarter turn of a 5 × 5 section whose centre is 12.5 from the axis.
let quarter = 25.0 * std::f64::consts::FRAC_PI_2 * 12.5;
assert!((v(&arc) - quarter).abs() < quarter * 1e-3, "{arc}");
assert!((v(&copy) - v(&arc)).abs() < 1e-6, "the pasted arc turns a quarter too: {copy}");
}
/// A pasted coil that joins a body made by an earlier copy joins that copy, not the original.
#[test]
fn a_pasted_coil_joins_the_pasted_body() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "name": "Base", "body_name": "Plate"}));
run(
&mut s,
"solid.coil",
json!({"diameter": 16, "pitch": 6, "revolutions": 3, "section_size": 2, "base": [15, 15, 5], "operation": "join", "targets": ["Plate"], "name": "Spring"}),
);
run(&mut s, "timeline.copy", json!({"features": ["Base", "Spring"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [50, 0, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let spring = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string();
let def = run(&mut s, "timeline.inspect", json!({"feature": spring}));
assert_eq!(def["definition"]["targets"], json!(["Plate (copy)"]), "{def}");
let st = s.world_state();
let v = |name: &str| solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume;
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-6, "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
}
}
+24 -6
View File
@@ -30,7 +30,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.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); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.list", "List Components", list).noundo(),
CommandSpec::new("component.list", "List Components", list).noundo().params(
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
),
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
.icon("measure").noundo()
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"),
@@ -156,9 +159,23 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "active": id }))
}
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let doc = &s.doc;
// Compact: what a large assembly needs to be found around in, without every name and matrix.
let compact = bool_(p, "compact").unwrap_or(false);
let names = |v: Vec<&str>| if compact { json!(v.len()) } else { json!(v) };
let body_names = !compact || bool_(p, "bodies").unwrap_or(false);
let placement = |o: &Occurrence| {
if !compact {
return json!(o);
}
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
v["transform"] = json!(o.transform);
}
v
};
let node = |id: u64, name: &str, parent: Option<u64>| {
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect();
let features: Vec<&str> = doc
@@ -175,10 +192,11 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
.filter(|f| f.component == id && matches!(f.kind, FeatureKind::ConstructionPlane { .. }))
.map(|f| f.name.as_str())
.collect();
let occurrences: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.component == id).collect();
let children: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.parent == id).collect();
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": features, "sketches": sketches,
"construction": construction, "occurrences": occurrences, "children": children})
let occurrences: Vec<Value> = doc.occurrences.iter().filter(|o| o.component == id).map(placement).collect();
let children: Vec<Value> = doc.occurrences.iter().filter(|o| o.parent == id).map(|o| if compact { json!(o.id) } else { json!(o) }).collect();
let bodies = if body_names { json!(bodies) } else { json!(bodies.len()) };
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": names(features), "sketches": names(sketches),
"construction": names(construction), "occurrences": occurrences, "children": children})
};
let mut out = vec![node(0, &doc.name, None)];
for c in &doc.components {
+35 -4
View File
@@ -94,6 +94,24 @@ fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
name
}
/// Whether `built`, the bodies a feature made, are the ones its `stored` names give, one for one. A
/// name is built as stored, or as `stored (n)` when a body of that name exists already: the solids
/// of a STEP file that share a label are stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`,
/// and a second import of a file builds `Aa (2)` for the stored `Aa`.
fn named_by(stored: &[String], built: &[String]) -> bool {
!stored.is_empty()
&& stored.len() == built.len()
&& stored.iter().zip(built).all(|(stored, built)| {
let base = stored.trim();
!base.is_empty()
&& (built == base
|| built
.strip_prefix(base)
.and_then(|rest| rest.strip_prefix(" (")?.strip_suffix(')'))
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())))
})
}
fn graph(doc: &Document, root: u64) -> Result<Vec<u64>> {
let definitions: BTreeSet<_> = doc.components.iter().map(|c| c.id).collect();
let mut children: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
@@ -271,17 +289,26 @@ pub(super) fn paste(
}
// Collect historical outputs too: a later Combine may have consumed a named tool body.
let mut outputs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
// The names the bodies of a feature have in the model, where they are its stored names, numbered.
let mut shown: BTreeMap<u64, Vec<String>> = BTreeMap::new();
for f in &feats {
if let Some(result) = s.model.result(f.id) {
let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect();
for name in &created {
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
}
if named_by(&f.body_names, &created) {
shown.insert(f.id, created.clone());
}
outputs.insert(f.id, created);
}
for name in &f.body_names {
if !name.is_empty() {
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
// Later features name a body as the model shows it. Where that differs from the stored name,
// the stored name is only a label (the first body of another import may be called so).
if !shown.contains_key(&f.id) {
for name in &f.body_names {
if !name.is_empty() {
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
}
}
}
if bodies.len() > MAX_WORK {
@@ -379,7 +406,11 @@ pub(super) fn paste(
f.id = *features.get(&old.id).ok_or_else(|| bad(CMD, "missing feature mapping"))?;
f.name = names.get(&old.name).cloned().ok_or_else(|| bad(CMD, "missing feature name"))?;
f.component = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing component mapping"))?;
if f.body_names.is_empty() {
if let Some(names) = shown.get(&old.id) {
// Stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`: the copy is stored under the
// names of the bodies, so that it builds the names the copied features after it use.
f.body_names = names.clone();
} else if f.body_names.is_empty() {
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
}
f.remap_clone(&features, &names, &bodies, &parameters)?;
@@ -552,3 +552,169 @@ fn frozen_r6_bore_copy_uses_current_geometry_despite_mesh_measure_bias() {
run(&mut s, "edit.redo", json!({}));
check(&s);
}
/// A STEP file of three 10 mm boxes at x = 0, 30 and 60 (`Aa`, `Bb`, `Cc`). With `shared` the three
/// solids carry one label (`X`), as the parts of many assembly files do.
fn three_box_step(tag: &str, shared: bool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-paste-new-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
for (i, label) in ["Aa", "Bb", "Cc"].iter().enumerate() {
let corner = [30.0 * i as f64, 0.0, 0.0];
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":corner,"operation":"new_body","body_name":label}));
}
let path = dir.join("Three.step");
run(&mut s, "file.export", json!({"path":path.to_string_lossy()}));
if shared {
let mut text = std::fs::read_to_string(&path).unwrap();
for label in ["Aa", "Bb", "Cc"] {
assert!(text.contains(&format!("MANIFOLD_SOLID_BREP('{label}'")), "the file names its solids: {label}");
text = text.replace(&format!("MANIFOLD_SOLID_BREP('{label}'"), "MANIFOLD_SOLID_BREP('X'");
}
std::fs::write(&path, text).unwrap();
}
path
}
fn body_names(s: &Session) -> Vec<String> {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
/// Where the bodies of component `part` are: the lowest corner of each, rounded, in model order.
fn corners(s: &Session, part: u64) -> Vec<Vec<f64>> {
let state = s.model.state();
state
.bodies
.iter()
.filter(|b| s.doc.model_body_component(b) == part)
.map(|b| {
let min = b.mesh().bounds().min;
vec![min.x.round(), min.y.round(), min.z.round()]
})
.collect()
}
/// Lifts the first of the three bodies by 20 and the second by 50, as two features of the active
/// component.
fn lift_two(s: &mut Session, bodies: [&str; 3]) {
run(s, "solid.move", json!({"bodies":[bodies[0]],"translate":[0,0,20],"name":"Lift first"}));
run(s, "solid.move", json!({"bodies":[bodies[1]],"translate":[0,0,50],"name":"Lift second"}));
}
const LIFTED: [[f64; 3]; 3] = [[0.0, 0.0, 20.0], [30.0, 0.0, 50.0], [60.0, 0.0, 0.0]];
fn lifted() -> Vec<Vec<f64>> {
LIFTED.iter().map(|c| c.to_vec()).collect()
}
/// A component whose Import feature holds the three solids of `path`. `file.insert_step` puts its
/// feature into the root even while a component is active, so the feature is moved into it afterwards.
fn component_with_import(s: &mut Session, path: &std::path::Path) -> u64 {
let part = component(s, "Part");
let import = run(s, "file.insert_step", json!({"path":path.to_string_lossy()}))["feature"].as_u64().unwrap();
s.doc_mut().feature_mut(import).unwrap().component = part;
s.refresh();
part
}
/// A control: solids with labels of their own are built as they are labelled.
#[test]
fn paste_new_of_a_component_with_an_import_moves_the_copy_of_each_body() {
let path = three_box_step("distinct", false);
let mut s = Session::default();
let part = component_with_import(&mut s, &path);
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc"]);
lift_two(&mut s, ["Aa", "Bb", "Cc"]);
let copied = run(&mut s, "component.paste_new", json!({"component":part}))["component"].as_u64().unwrap();
assert_eq!(corners(&s, part), lifted());
assert_eq!(corners(&s, copied), lifted());
}
/// Solids of a STEP file that share one label are stored as `X`, `X`, `X` and built as `X`,
/// `X (2)`, `X (3)`, and the features made on them name them so. The copy of the component has
/// those features act on its own bodies, each on its own.
#[test]
fn paste_new_of_an_import_with_one_label_on_all_solids_moves_the_copy_of_each_body() {
let path = three_box_step("shared", true);
let mut s = Session::default();
let part = component_with_import(&mut s, &path);
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
lift_two(&mut s, ["X", "X (2)", "X (3)"]);
let copied = run(&mut s, "component.paste_new", json!({"component":part}))["component"].as_u64().unwrap();
assert_eq!(corners(&s, part), lifted());
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
// The copy keeps its bodies and their places when the design is saved and opened again.
let names = body_names(&s);
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(body_names(&reopened), names);
assert_eq!(corners(&reopened, copied), lifted());
}
/// No shared label is needed: a design linked a second time builds `Aa (2)`, `Bb (2)`, `Cc (2)`
/// from the names `Aa`, `Bb`, `Cc`, and the features made on those bodies name them so.
#[test]
fn paste_new_of_a_design_linked_twice_moves_the_copy_of_each_body() {
let dir = std::env::temp_dir().join(format!("solidcraft-paste-new-linked-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("Three.solidcraft");
let mut part = Session::default();
for (i, label) in ["Aa", "Bb", "Cc"].iter().enumerate() {
let corner = [30.0 * i as f64, 0.0, 0.0];
run(&mut part, "solid.box", json!({"length":10,"width":10,"height":10,"corner":corner,"operation":"new_body","body_name":label}));
}
run(&mut part, "file.save_as", json!({"path":design.to_string_lossy()}));
let mut s = Session::default();
run(&mut s, "component.insert_design", json!({"path":design.to_string_lossy()}));
let second = run(&mut s, "component.insert_design", json!({"path":design.to_string_lossy()}));
let linked = second["component"].as_u64().unwrap();
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
run(&mut s, "material.assign", json!({"bodies":["Aa"],"material":"Steel"}));
run(&mut s, "material.assign", json!({"bodies":["Aa (2)"],"material":"Brass"}));
run(&mut s, "component.activate", json!({"component":linked}));
lift_two(&mut s, ["Aa (2)", "Bb (2)", "Cc (2)"]);
let copied = run(&mut s, "component.paste_new", json!({"component":linked}))["component"].as_u64().unwrap();
assert_eq!(corners(&s, linked), lifted());
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
// The material of a body goes to its copy, and no material is kept for a body that does not exist.
let names = body_names(&s);
assert!(s.doc.materials.contains_key("Aa (2) (copy)"));
assert_eq!(s.doc.materials.get("Aa (2) (copy)"), s.doc.materials.get("Aa (2)"));
assert!(s.doc.materials.keys().all(|body| names.contains(body)), "{:?} for {names:?}", s.doc.materials.keys().collect::<Vec<_>>());
}
/// An OBJ file of three 10 mm cubes at x = 0, 30 and 60, every object called `X`.
fn three_cube_obj(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-paste-new-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
let faces = [[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [3, 7, 6], [3, 6, 2], [0, 4, 7], [0, 7, 3], [1, 2, 6], [1, 6, 5]];
let mut text = String::new();
for i in 0..3 {
text += "o X\n";
for c in corners {
text += &format!("v {} {} {}\n", 30 * i + 10 * c[0], 10 * c[1], 10 * c[2]);
}
for f in faces {
text += &format!("f {} {} {}\n", 8 * i + f[0] + 1, 8 * i + f[1] + 1, 8 * i + f[2] + 1);
}
}
let path = dir.join("Three.obj");
std::fs::write(&path, text).unwrap();
path
}
/// The same for the objects of an OBJ file (3MF and STL files give the same kind of import).
#[test]
fn paste_new_of_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_each_body() {
let path = three_cube_obj("shared-obj");
let mut s = Session::default();
let part = component(&mut s, "Part");
let import = run(&mut s, "file.insert_mesh", json!({"path":path.to_string_lossy()}))["feature"].as_u64().unwrap();
s.doc_mut().feature_mut(import).unwrap().component = part;
s.refresh();
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
lift_two(&mut s, ["X", "X (2)", "X (3)"]);
let copied = run(&mut s, "component.paste_new", json!({"component":part}))["component"].as_u64().unwrap();
assert_eq!(corners(&s, part), lifted());
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
}
@@ -159,7 +159,10 @@ fn inherited_component_appearance_and_legacy_assignment_survive_transfer() {
let mut s = Session::default();
let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
s.doc_mut().appearances.components.insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 });
s.doc_mut()
.appearances
.components
.insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7, pbr: None });
s.refresh();
let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap();
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
+21
View File
@@ -14,9 +14,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.doc_only()
.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()
.doc_only()
.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)
@@ -624,6 +626,13 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
merge(&mut v, set, 0);
let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
nf.id = f.id;
if let (FeatureKind::Extrude { extent: old, .. }, FeatureKind::Extrude { extent: new, .. }) = (&f.kind, &mut nf.kind)
&& old.direction != solidcraft_doc::Direction::Symmetric
&& new.direction == solidcraft_doc::Direction::Symmetric
&& set.get("extent").and_then(|e| e.get("convention")).is_none()
{
new.convention = solidcraft_doc::ExtrudeConvention::TotalDistance;
}
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
return Err(bad(cmd, "the feature type cannot change"));
}
@@ -844,6 +853,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
spec.id,
"sheet.flange"
| "sheet.hem"
| "sheet.perforate"
| "sheet.fold"
| "sheet.unfold"
| "sheet.refold"
@@ -859,6 +869,17 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
if let FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } = &old.kind
&& matches!(cid, "solid.extrude" | "surface.extrude")
&& let Some(parameters) = params.as_object_mut()
{
let convention = if cid == "surface.extrude" || extent.direction == solidcraft_doc::Direction::Symmetric {
extent.convention
} else {
solidcraft_doc::ExtrudeConvention::TotalDistance
};
parameters.entry("convention").or_insert_with(|| json!(convention));
}
if let FeatureKind::Revolve { convention, .. } = &old.kind
&& cid == "solid.revolve"
&& let Some(parameters) = params.as_object_mut()
@@ -0,0 +1,143 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::{Document, ExtrudeConvention, FeatureKind};
fn run(s: &mut Session, cmd: &str, params: Value) {
s.execute(cmd, &params).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
fn fixture(settings: Value) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Square"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
run(&mut s, "sketch.finish", json!({}));
let mut p = json!({"sketch":"Square","distance":"10 mm","direction":"symmetric","operation":"new","name":"Depth","body_name":"Block"});
p.as_object_mut().unwrap().extend(settings.as_object().unwrap().clone());
run(&mut s, "solid.extrude", p);
s
}
fn check(s: &Session, lo: f64, hi: f64) {
assert!(s.model.results.iter().all(|r| r.error.is_none()));
let st = s.model.state();
let body = st.body("Block").unwrap();
let bounds = body.mesh().bounds();
assert!((bounds.min.z - lo).abs() < 1e-7 && (bounds.max.z - hi).abs() < 1e-7, "{bounds:?}");
let m = solidcraft_kernel::measure(&body.body).unwrap();
assert!((m.volume - 100.0 * (hi - lo)).abs() < 1e-7, "{m:?}");
assert!((m.centroid.z - (lo + hi) * 0.5).abs() < 1e-7);
}
#[test]
fn symmetric_extrude_total_distance_is_centered_and_follows_parameters() {
for distance in ["10 mm", "-10 mm"] {
let mut s = fixture(json!({"distance":distance,"from":[0,0,2],"start_offset":"3 mm"}));
check(&s, 0.0, 10.0);
let FeatureKind::Extrude { extent, .. } = &s.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
assert_eq!(extent.convention, ExtrudeConvention::TotalDistance);
let name = s.doc.find_feature("Depth").unwrap().param_names[0].clone();
run(&mut s, "parameters.change", json!({"name":name,"expression":"20 mm"}));
check(&s, -5.0, 15.0);
run(&mut s, "edit.undo", json!({}));
check(&s, 0.0, 10.0);
check(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()), 0.0, 10.0);
}
}
#[test]
fn symmetric_extrude_switch_from_one_side_keeps_total_thickness() {
let mut s = fixture(json!({"direction":"positive","convention":"legacy_per_side"}));
check(&s, 0.0, 10.0);
run(&mut s, "timeline.edit", json!({"feature":"Depth","set":{"extent":{"direction":"symmetric"}}}));
check(&s, -5.0, 5.0);
run(&mut s, "edit.undo", json!({}));
check(&s, 0.0, 10.0);
run(
&mut s,
"timeline.redefine",
json!({"feature":"Depth","command":"solid.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Block"}}),
);
check(&s, -5.0, 5.0);
}
#[test]
fn symmetric_extrude_legacy_missing_marker_preserves_geometry_and_edits() {
let s = fixture(json!({"convention":"legacy_per_side"}));
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
saved["format"] = json!("solidcraft/21");
for f in saved["features"].as_array_mut().unwrap() {
if f["type"] == "extrude" {
f["extent"].as_object_mut().unwrap().remove("convention");
}
}
let mut s = Session::new(Document::from_json(&saved.to_string()).unwrap());
check(&s, -10.0, 10.0);
run(&mut s, "timeline.edit", json!({"feature":"Depth","set":{"extent":{"distance":"12 mm"}}}));
check(&s, -12.0, 12.0);
run(&mut s, "edit.undo", json!({}));
run(
&mut s,
"timeline.redefine",
json!({"feature":"Depth","command":"solid.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Block"}}),
);
check(&s, -10.0, 10.0);
check(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()), -10.0, 10.0);
}
#[test]
fn symmetric_extrude_through_all_keeps_reach_and_two_sides_keep_independent_distances() {
let mut s = fixture(json!({"distance2":"3 mm"}));
check(&s, -3.0, 10.0);
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,100],"body_name":"FarAbove"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,-110],"body_name":"FarBelow"}));
run(
&mut s,
"solid.extrude",
json!({"sketch":"Square","direction":"symmetric","through_all":true,"operation":"cut","targets":["FarAbove","FarBelow"]}),
);
assert!(s.model.state().body("FarAbove").is_none());
assert!(s.model.state().body("FarBelow").is_none());
}
#[test]
fn symmetric_extrude_invalid_convention_is_atomic() {
let mut s = fixture(json!({}));
let before = s.doc.to_json();
for convention in [json!("unknown"), json!(7), Value::Null] {
assert!(s.execute("solid.extrude", &json!({"sketch":"Square","distance":10,"direction":"symmetric","convention":convention})).is_err());
assert_eq!(s.doc.to_json(), before);
}
}
#[test]
fn symmetric_extrude_thin_wall_uses_total_distance_and_to_object_remains_one_sided() {
let s = fixture(json!({"thin":true,"thickness":"2 mm"}));
let st = s.model.state();
let body = st.body("Block").unwrap();
let b = body.mesh().bounds();
assert!((b.min.z + 5.0).abs() < 1e-7 && (b.max.z - 5.0).abs() < 1e-7);
// A centred 2 mm wall around a 10 mm square: (12² - 8²) × 10.
assert!((solidcraft_kernel::measure(&body.body).unwrap().volume - 800.0).abs() < 1e-7);
check(&fixture(json!({"to":[0,0,10]})), 0.0, 10.0);
}
#[test]
fn surface_extrude_shared_extent_retains_its_existing_per_side_semantics() {
let mut s = fixture(json!({}));
run(&mut s, "surface.extrude", json!({"sketch":"Square","distance":"10 mm","direction":"symmetric","name":"Sheet","body_name":"Sheet"}));
let check_sheet = |s: &Session| {
let st = s.model.state();
let bounds = st.body("Sheet").unwrap().mesh().bounds();
assert!((bounds.min.z + 10.0).abs() < 1e-7 && (bounds.max.z - 10.0).abs() < 1e-7);
};
check_sheet(&s);
run(
&mut s,
"timeline.redefine",
json!({"feature":"Sheet","command":"surface.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Sheet"}}),
);
check_sheet(&s);
check_sheet(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()));
}
+26 -5
View File
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use solidcraft_doc::{EmbossFace, apply_point, mat_inverse, mat_mul};
use solidcraft_geom::{Mesh, Vec2, Vec3};
use crate::params::bad;
use crate::params::{bad, vec3};
use crate::{Result, Session};
fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
@@ -57,6 +57,22 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6))
}
/// A face given to a command: a point `[x, y, z]` on it (or `{point}`), or its name as
/// `model.faces` lists it, looked up on `body` (any body without one). A name becomes a point on
/// that face, which the feature keeps like a picked one, so it also keeps the face's name.
/// The point is in the frame of the command's other picks: the component's own when
/// they were mapped into it, else the world's.
pub(super) fn face_ref(s: &Session, v: &Value, body: Option<&str>, cmd: &str) -> Result<Vec3> {
if let Some(name) = v.as_str() {
if name.is_empty() || name.len() > 512 {
return Err(bad(cmd, "a face name is 1…512 characters"));
}
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
return solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why));
}
vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it, or its name from model.faces"))
}
/// The body face under a point: (mesh, face index, distance, body).
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, String)> {
face_at_in(s, p, None).ok().flatten()
@@ -112,8 +128,9 @@ fn xy(v: Vec2) -> Value {
}
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2)> {
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
/// The sketch is left active.
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2, String)> {
let none = || match face_body {
Some(b) => bad(cmd, format!("no face of `{b}` at `face`")),
None => bad(cmd, "no body face at `face`"),
@@ -183,7 +200,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
// The face's edges came in as linked projections: they follow the face when earlier
// features change, so nothing is copied.
if r.get("projected").is_some_and(|x| !x.is_null()) {
return Ok((sid, plane.to_local(author_inside)));
return Ok((sid, plane.to_local(author_inside), body));
}
for e in &edges {
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
@@ -221,9 +238,13 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
}
};
}
Ok((sid, plane.to_local(author_inside)))
Ok((sid, plane.to_local(author_inside), body))
}
#[cfg(test)]
#[path = "face_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "face_join_tests.rs"]
mod join_tests;
+127
View File
@@ -0,0 +1,127 @@
//! #226: a face's outward extrusion must not join an assembly neighbour it only touches.
#![allow(clippy::unwrap_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::{Document, FeatureKind};
use solidcraft_geom::Vec3;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|error| panic!("{command}: {error}"))
}
fn assembly(cap_first: bool) -> Session {
let mut s = Session::default();
let wall = json!({"length":40,"width":30,"height":20,"body_name":"Wall"});
let cap = json!({"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Cap"});
for params in if cap_first { [cap, wall] } else { [wall, cap] } {
run(&mut s, "solid.box", params);
}
s
}
fn check_wall(s: &Session, count: usize) {
assert!(s.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", s.model.results);
let state = s.model.state();
assert_eq!(state.bodies.len(), count);
let wall = state.body("Wall").unwrap();
let measure = solidcraft_kernel::measure(&wall.body).unwrap();
assert!((measure.volume - 30_000.0).abs() < 1e-6, "{measure:?}");
assert!(measure.bbox.max.dist(Vec3::new(50.0, 30.0, 20.0)) < 1e-9);
assert!(wall.body.contains(Vec3::new(49.0, 15.0, 10.0)));
let cap = solidcraft_kernel::measure(&state.body("Cap").unwrap().body).unwrap();
assert!((cap.volume - 6000.0).abs() < 1e-6);
assert!(cap.bbox.max.dist(Vec3::new(40.0, 30.0, 25.0)) < 1e-9);
}
#[test]
fn face_join_and_press_pull_leave_the_edge_touching_cap_in_either_body_order() {
for cap_first in [true, false] {
for operation in ["join", "auto", "press_pull"] {
let mut s = assembly(cap_first);
let before = s.model.state();
let params = json!({"face":[40,15,10],"distance":10,"operation":operation});
let command = if operation == "press_pull" { "solid.press_pull" } else { "solid.extrude" };
let result = run(&mut s, command, params);
assert_eq!(result["changed_bodies"], json!(["Wall"]));
assert_eq!(result["removed_bodies"], json!([]));
assert!(s.model.state().body("Cap").unwrap().body.same(&before.body("Cap").unwrap().body));
check_wall(&s, 2);
let saved = s.doc.to_json();
run(&mut s, "edit.undo", json!({}));
assert!(s.model.state().body("Wall").unwrap().body.same(&before.body("Wall").unwrap().body));
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), saved);
check_wall(&Session::new(Document::from_json(&saved).unwrap()), 2);
}
}
}
#[test]
fn face_join_respects_explicit_targets_and_participants() {
for key in ["targets", "participants"] {
for all in [false, true] {
let mut s = assembly(!all);
let mut params = json!({"face":[40,15,10],"distance":10,"operation":"join"});
// Joining the wall first permits the cap's full base face to join afterwards.
params[key] = if all { json!(["*"]) } else { json!(["Wall", "Cap"]) };
run(&mut s, "solid.extrude", params);
let state = s.model.state();
assert_eq!(state.bodies.len(), 1, "{key}");
assert!((solidcraft_kernel::measure(&state.body("Wall").unwrap().body).unwrap().volume - 36_000.0).abs() < 1e-6);
}
}
}
#[test]
fn face_join_maps_the_picked_instance_body_and_point_together() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Repeated"}))["component"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Wall"}));
run(&mut s, "component.activate", json!({"component":0}));
let occurrence = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[100,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":component,"occurrence":occurrence}));
let world = s.world_state();
let body = world.bodies.iter().find(|body| body.body.contains(Vec3::new(139.0, 15.0, 10.0))).unwrap().name.clone();
assert_ne!(body, "Wall", "exercise an instance body name");
let result = run(&mut s, "solid.extrude", json!({"face":[140,15,10],"face_body":body,"distance":10,"operation":"join"}));
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
assert_eq!(targets, &["Wall"]);
let world = s.world_state();
assert_eq!(world.bodies.len(), 2);
for point in [Vec3::new(49.0, 15.0, 10.0), Vec3::new(149.0, 15.0, 10.0)] {
assert!(world.bodies.iter().any(|body| body.body.contains(point)), "{point:?}");
}
}
#[test]
fn face_join_of_a_step_import_keeps_all_32_neighbours_unchanged() {
let mut source = assembly(true);
for i in 0..31 {
run(&mut source, "solid.box", json!({"corner":[100+20*i,100,0],"length":5,"width":5,"height":5,"body_name":format!("Distant{i}")}));
}
let step = solidcraft_io::export(&source.model.state(), &[], solidcraft_io::Format::Step, "Original assembly").unwrap();
let imported = solidcraft_io::step_import_feature(&step, "original-assembly.step").unwrap();
let mut doc = Document::new("Imported assembly");
let feature = doc.add_feature(imported.kind, None).unwrap();
doc.feature_mut(feature).unwrap().body_names = imported.body_names;
let mut s = Session::new(doc);
assert_eq!(s.doc.features.len(), 1);
let before = s.model.state();
let result = run(&mut s, "solid.extrude", json!({"face":[40,15,10],"distance":"10 mm","direction":"positive","operation":"join"}));
assert_eq!(result["changed_bodies"], json!(["Wall"]));
check_wall(&s, 33);
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
check_wall(&reopened, 33);
for body in before.bodies.iter().filter(|body| body.name != "Wall") {
let after = s.model.state();
assert!(body.body.same(&after.body(&body.name).unwrap().body), "{}", body.name);
let cold = reopened.model.state();
let original = solidcraft_kernel::measure(&body.body).unwrap();
let restored = solidcraft_kernel::measure(&cold.body(&body.name).unwrap().body).unwrap();
assert!((original.volume - restored.volume).abs() < 1e-9, "{}", body.name);
assert!(original.bbox.min.dist(restored.bbox.min) < 1e-9 && original.bbox.max.dist(restored.bbox.max) < 1e-9);
}
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
assert_eq!(targets, &["Wall"]);
}
+184 -28
View File
@@ -1,7 +1,9 @@
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
use serde_json::{Value, json};
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
use solidcraft_doc::{
AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
};
use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch};
@@ -13,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.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] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); 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, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); 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, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -21,7 +23,7 @@ pub static COMMANDS: &[CommandSpec] = &[
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?, targets?, name?, body_name?"),
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); operation?, targets?, name?, body_name?"),
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?, targets?, name?, body_name?"),
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] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
@@ -67,11 +69,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("face.delete", "Delete Face", delete_face)
.at("SOLID", "MODIFY")
.icon("delete")
.params("faces: [[x,y,z] points on the faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
.params("faces: [[x,y,z] points on the faces, or face names from model.faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
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?"),
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; distance: expr (positive: outward); body?"),
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
@@ -79,11 +81,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
.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?"),
.params("faces: [[x,y,z] points on the faces to remove, or face names from model.faces]; 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?"),
.params("faces: [[x,y,z] points, or face names from model.faces]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
.at("SOLID", "CONSTRUCT")
.icon("plane")
@@ -96,7 +98,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.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)",
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; 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")
@@ -119,6 +121,11 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
let sid = feature_sketch(s, p, "Extrude").ok()?;
let st = s.model.state();
let ss = st.sketch(sid)?;
if bool_(p, "thin").unwrap_or(false) {
let thin = thin_param(s, p, sid, "solid.extrude").ok()?;
let q = thin_regions(s, ss, &thin).ok()?.first()?.interior_point();
return Some((ss.plane.to_world(q), ss.plane.normal()));
}
let ps = &ss.profiles;
let q = match profiles(p, "Extrude").ok()? {
ProfileSel::All => ps.first()?.region.interior_point(),
@@ -132,9 +139,16 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
Some((ss.plane.to_world(q), ss.plane.normal()))
}
/// How far past the profile [`auto_operation`] looks for material it starts in.
const START_PROBE_MM: f64 = 0.1;
/// What `operation: "auto"` means for an extrude with these parameters: `cut` when it goes
/// into a body, otherwise `join` when the active component has a solid. Keeping an
/// addition separate requires an explicit `new` operation.
///
/// "Goes into a body" is decided where the extrude starts: just past the profile in the
/// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far
/// past a body's far side read as a Join (#1257).
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?;
let dir = str_(p, "direction").map(str::to_ascii_lowercase);
@@ -156,10 +170,13 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
return Some("new");
}
let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q));
// A step into whatever is right in front of the profile, short of the far end.
let near = |d: f64| d.signum() * (d.abs() * 0.5).min(START_PROBE_MM);
let into = |d: f64| inside(base + n * near(d)) || inside(base + n * (d * 0.5));
if symmetric {
return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "join" });
return Some(if into(d.abs()) || into(-d.abs()) { "cut" } else { "join" });
}
if inside(base + n * (d * 0.5)) {
if into(d) {
Some("cut")
} else {
// A profile can meet material along its side, or only around part of a
@@ -497,30 +514,103 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
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) {
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }))
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
}
None => (feature_sketch(s, p, cmd)?, None),
None => (feature_sketch(s, p, cmd)?, None, None),
};
let extent = extent_param(s, p, cmd)?;
let thin = match bool_(p, "thin") {
Some(true) => {
if face_profile.is_some() || p.get("profiles").is_some() {
return Err(bad(cmd, "a thin extrude follows `curves`; it takes no `profiles` or `face`"));
}
Some(thin_param(s, p, sketch, cmd)?)
}
_ if p.get("thin").is_some_and(|v| !v.is_boolean()) => return Err(bad(cmd, "`thin` must be true or false")),
_ => None,
};
let 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)?,
};
let mut targets = string_list(p, "targets");
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
// A face's outward extension joins that body. Assembly neighbours can touch only an
// edge of the tool: trying to join them first fails, or absorbs unrelated parts (#226).
// Store the resolved target so recomputes and reopening keep the same scope.
targets.extend(face_body);
}
let kind = FeatureKind::Extrude {
sketch,
profiles: match face_profile {
Some(f) => f,
None if thin.is_some() => ProfileSel::All,
None => profiles(p, cmd)?,
},
extent,
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)?,
},
targets: string_list(p, "targets"),
operation,
targets,
thin,
};
add_feature(s, p, kind)
}
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
/// curve when none are given), its thickness and which side of them it lies on.
fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result<ThinWall> {
let st = s.model.state();
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
let mut curves = string_list(p, "curves");
if curves.is_empty() {
curves = ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && c.link.is_none()).map(|c| c.id.clone()).collect();
}
if curves.is_empty() || curves.len() > 1000 {
return Err(bad(cmd, "a thin extrude needs 1…1000 sketch curves (`curves`)"));
}
if let Some(c) = curves.iter().find(|c| ss.sketch.curve_index(c).is_none()) {
return Err(bad(cmd, format!("no curve `{c}` in the sketch")));
}
let thickness = req_expr(cmd, p, "thickness")?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let side = match str_(p, "side") {
None => ThinSide::Center,
Some(o) => ThinSide::parse(o).ok_or_else(|| bad(cmd, format!("unknown side `{o}` (center, inside, outside or two_sides)")))?,
};
let thickness2 = expr(p, "thickness2");
match (&thickness2, side) {
(Some(t), ThinSide::TwoSides) => check_expr(s, t, Kind::Length, cmd, "thickness2")?,
(None, ThinSide::TwoSides) => return Err(bad(cmd, "`side: two_sides` needs `thickness2` (the thickness outside)")),
(Some(_), _) => return Err(bad(cmd, "`thickness2` goes with `side: two_sides`")),
(None, _) => {}
}
let thin = ThinWall { curves, thickness, side, thickness2 };
// Refuse a wall that can't be built now, with the reason, rather than a failed feature.
thin_regions(s, ss, &thin).map_err(|e| bad(cmd, e))?;
Ok(thin)
}
/// The regions of a thin wall in its (solved) sketch, with the current parameter values.
fn thin_regions(s: &Session, ss: &solidcraft_doc::SolvedSketch, thin: &ThinWall) -> std::result::Result<Vec<solidcraft_geom::Region2>, String> {
let length = |e: &str| s.doc.eval(&s.doc.with_design_unit(e, Kind::Length), Kind::Length).map_err(|e| e.to_string());
let t = length(&thin.thickness)?;
let (inside, outside) = match thin.side {
ThinSide::Center => (t / 2.0, t / 2.0),
ThinSide::Inside => (t, 0.0),
ThinSide::Outside => (0.0, t),
ThinSide::TwoSides => (t, length(thin.thickness2.as_deref().unwrap_or("0"))?),
};
let curves: Vec<Vec<solidcraft_geom::Seg2>> = thin
.curves
.iter()
.filter_map(|id| ss.sketch.curve_index(id))
.map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
.collect();
solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| format!("thin wall: {e}"))
}
/// An extrude's extent from its parameters: distance (or through all, or to an object), the
/// side(s), a second distance, a start offset, a taper, and from an object.
pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent> {
@@ -552,8 +642,15 @@ pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent>
if let Some(d) = &start_offset {
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
}
let convention = match p.get("convention") {
// Surface Extrude shares Extent but retains its existing per-side distance semantics.
None if cmd == "surface.extrude" => solidcraft_doc::ExtrudeConvention::LegacyPerSide,
None => solidcraft_doc::ExtrudeConvention::TotalDistance,
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be total_distance or legacy_per_side"))?,
};
Ok(Extent {
distance,
convention,
direction,
distance2,
start_offset,
@@ -570,7 +667,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
// 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) => {
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
let (id, inside, _) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
s.active_sketch = None;
(id, Some(ProfileSel::Points { points: vec![inside] }))
}
@@ -1253,7 +1350,7 @@ 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 faces = face_points(s, p, cmd)?;
let body = str_(p, "body").map(str::to_string);
let xy = || solidcraft_doc::PlaneRef::Origin { name: "XY".into() };
let kind = if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
@@ -1472,17 +1569,17 @@ fn sketch_hole_points(s: &Session, sketch: u64) -> Vec<String> {
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>> {
let a = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must be a list of points"))?;
fn face_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must be a list of points or face names"))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "select 1…1000 faces"));
}
a.iter().map(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it"))).collect()
a.iter().map(|v| super::face::face_ref(s, v, str_(p, "body"), cmd)).collect()
}
fn shell(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.shell";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let thickness = req_expr(cmd, p, "thickness")?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let direction = str_(p, "direction").map(str::to_ascii_lowercase).unwrap_or_default();
@@ -1504,7 +1601,7 @@ fn shell(s: &mut Session, p: &Value) -> Result<Value> {
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.draft";
let faces = face_points(p, cmd)?;
let faces = face_points(s, 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)?;
@@ -1534,6 +1631,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
if path.is_empty() {
return Err(bad(cmd, "`path` must list curve ids"));
}
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
add_feature(
s,
p,
@@ -1542,12 +1640,58 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
profiles: profiles(p, cmd)?,
path_sketch,
path,
orientation,
twist,
scale,
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
},
)
}
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<String>, Option<String>)> {
let orientation = match p.get("orientation") {
None | Some(Value::Null) => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
Some(v) => {
let d = vec3(v)
.or_else(|| v.get("direction").and_then(vec3))
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
if !(d.len() > 1e-9) {
return Err(bad(cmd, "the `orientation` direction must not be zero"));
}
SweepOrientation::Direction(d)
}
};
let twist = match p.get("twist") {
None | Some(Value::Null) => None,
Some(_) => {
let t = expr(p, "twist").ok_or_else(|| bad(cmd, "`twist` must be an angle or an expression"))?;
let v = s.doc.eval(&t, Kind::Angle).map_err(|e| bad(cmd, format!("twist: {e}")))?;
if !(v.is_finite() && v.abs() <= solidcraft_kernel::SweepControl::MAX_TWIST) {
return Err(bad(cmd, "`twist` must be within four turns (1440 deg)"));
}
Some(t)
}
};
let scale = match p.get("scale") {
None | Some(Value::Null) => None,
Some(_) => {
let k = expr(p, "scale").ok_or_else(|| bad(cmd, "`scale` must be a number or an expression"))?;
let v = s.doc.eval(&k, Kind::Unitless).map_err(|e| bad(cmd, format!("scale: {e}")))?;
let range = solidcraft_kernel::SweepControl::MIN_SCALE..=solidcraft_kernel::SweepControl::MAX_SCALE;
if !(v.is_finite() && range.contains(&v)) {
return Err(bad(cmd, "`scale` (the end scale) must be between 0.01 and 100"));
}
Some(k)
}
};
Ok((orientation, twist, scale))
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.loft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
@@ -1645,14 +1789,14 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(p, "face.delete")?;
let faces = face_points(s, p, "face.delete")?;
let heal = bool_(p, "heal").unwrap_or(true);
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string), heal })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.offset_face";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::OffsetFace { faces, distance, body: str_(p, "body").map(str::to_string) })
@@ -1666,6 +1810,10 @@ mod patterns_tests;
#[path = "revolve_convention_tests.rs"]
mod revolve_convention_tests;
#[cfg(test)]
#[path = "sweep_options_tests.rs"]
mod sweep_options_tests;
#[cfg(test)]
#[path = "offset_topology_tests.rs"]
mod offset_topology_tests;
@@ -1696,3 +1844,11 @@ mod solid_move_copy_tests;
#[cfg(test)]
#[path = "move_in_sketch_tests.rs"]
mod move_in_sketch_tests;
#[cfg(test)]
#[path = "thin_extrude_tests.rs"]
mod thin_extrude_tests;
#[cfg(test)]
#[path = "extrude_convention_tests.rs"]
mod extrude_convention_tests;
+5 -5
View File
@@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[
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?"),
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("move")
@@ -316,17 +316,17 @@ fn infer_emboss_selection(
}
Ok(chosen)
}
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?;
fn face_points(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z] or face names")))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, format!("`{k}` must list 1…1000 points")));
}
a.iter().map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y, z] points")))).collect()
a.iter().map(|v| super::face::face_ref(s, v, str_(p, "body"), cmd)).collect()
}
fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.replace_face";
let faces = face_points(p, "faces", cmd)?;
let faces = face_points(s, p, "faces", cmd)?;
for f in &faces {
if super::face::face_normal(s, *f).is_none() {
return Err(bad(cmd, "not supported yet: replacing a face that is not planar"));

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