Compare commits

...

75 Commits

Author SHA1 Message Date
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 4c09fa5355 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 12:56:47 +00: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
253 changed files with 29240 additions and 2546 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
+25 -1
View File
@@ -286,9 +286,31 @@ 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
- run: cargo test -p solidcraft-gpu --profile ci --locked
# 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]
if: always()
runs-on: ubuntu-24.04
steps:
@@ -302,6 +324,7 @@ jobs:
TESTS: ${{ needs.tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -311,6 +334,7 @@ jobs:
test "$TESTS" = success
test "$DOCS" = success
test "$ORACLE" = success
test "$WINDOWS_FXC" = 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
+3 -1
View File
@@ -27,7 +27,9 @@ 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 one exception is two functions in
`apps/solidcraft/src/open_documents.rs` (that crate denies it instead): macOS hands Finder files
only to an Objective-C method.
- **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
+2
View File
@@ -91,6 +91,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | 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/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 +120,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 |
+3 -1
View File
@@ -27,7 +27,9 @@ 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 one exception is two functions in
`apps/solidcraft/src/open_documents.rs` (that crate denies it instead): macOS hands Finder files
only to an Objective-C method.
- **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
+2 -1
View File
@@ -107,7 +107,8 @@ 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 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
+255 -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,25 +3445,33 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.10.0"
version = "0.11.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",
"winit",
]
[[package]]
name = "solidcraft-cli"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3308,7 +3480,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"log",
"serde",
@@ -3323,7 +3495,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"log",
"serde",
@@ -3339,7 +3511,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde",
"serde_json",
@@ -3348,9 +3520,10 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"log",
"naga",
"pollster 0.4.0",
"solidcraft-geom",
"solidcraft-kernel",
@@ -3360,7 +3533,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"quick-xml",
"serde",
@@ -3375,7 +3548,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"log",
"rayon",
@@ -3393,7 +3566,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3401,7 +3574,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"serde",
@@ -3411,7 +3584,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde",
"serde_json",
@@ -3422,24 +3595,28 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.10.0"
version = "0.11.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.11.0"
dependencies = [
"eframe",
"egui",
@@ -3486,6 +3663,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 +4091,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 +4137,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 +4465,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 +4962,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -4862,7 +5094,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde_json",
"sha2",
@@ -5038,6 +5270,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"
+3 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.10.0"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -31,6 +31,7 @@ serde = { version = "1", features = ["derive", "rc"] }
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"
@@ -61,6 +62,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"
+11 -1
View File
@@ -134,6 +134,16 @@ rebuild with no errors, replayed here at 2× speed.
(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)).
- **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.
@@ -215,7 +225,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 -->
+26 -4
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) |
@@ -50,9 +50,9 @@ 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 |
| 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 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 |
| 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 |
@@ -67,8 +67,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,6 +89,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Render foundation
- The initial opaque surface numerical contract for #1004 provides bounded linear
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
It does not enable PBR rendering or change stored appearances. 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.
@@ -155,6 +170,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
+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")));
}
}
+27 -1
View File
@@ -3,7 +3,9 @@
//! ```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 script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] 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> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
@@ -17,6 +19,7 @@
#![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;
@@ -37,7 +40,10 @@ const USAGE: &str = "usage:
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 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]
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
@@ -55,6 +61,7 @@ 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("exec") => cmd_exec(&args[1..]),
Some("commands") => {
@@ -91,6 +98,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(())
@@ -242,6 +251,23 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
Ok(())
}
/// 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")?;
@@ -401,7 +427,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);
+11
View File
@@ -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}));
}
+10 -3
View File
@@ -693,6 +693,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
@@ -785,7 +787,9 @@ 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;
@@ -800,7 +804,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 +813,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"]});
}
}
@@ -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,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");
}
}
+8 -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()
};
+26 -2
View File
@@ -7,8 +7,17 @@ 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 can
# allow it (macOS file-open events have no 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,7 +27,12 @@ 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"
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 }
@@ -26,8 +40,18 @@ quick-xml = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
[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"] }
[dev-dependencies]
pollster = { workspace = true }
[build-dependencies]
embed-resource = "3.0.11"
+205 -18
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,34 @@
//! 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 = "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>,
#[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 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 +49,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 +74,22 @@ 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();
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 +116,11 @@ 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_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 +130,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 +156,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")
@@ -219,7 +321,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 +330,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 +349,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| {
@@ -268,8 +389,17 @@ fn main() -> eframe::Result {
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),
..Default::default()
};
#[cfg(all(unix, not(target_os = "macos")))]
@@ -286,15 +416,23 @@ 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_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.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 {
@@ -320,6 +458,12 @@ 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);
}
@@ -333,20 +477,63 @@ 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);
Ok(Box::new(App {
solve: app,
starting,
#[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,
}
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe).
fn wgpu_options(cpu: bool) -> eframe::egui_wgpu::WgpuConfiguration {
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if cpu && let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
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
+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());
}
}
+204
View File
@@ -0,0 +1,204 @@
//! 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(", ") })
})
}
/// 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 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);
}
}
}
+6
View File
@@ -266,6 +266,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);
+56 -1
View File
@@ -1,4 +1,9 @@
{
"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.",
"browser.body_name": "Körper {number}",
"browser.component_name": "Komponente {number}",
"browser.parameters": "Parameter ({count})",
@@ -495,6 +500,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",
@@ -540,6 +547,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 +645,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",
@@ -697,5 +707,50 @@
"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."
}
+55 -1
View File
@@ -1,4 +1,9 @@
{
"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.",
"browser.body_name": "Body{number}",
"browser.component_name": "Component{number}",
"browser.parameters": "Parameters ({count})",
@@ -161,6 +166,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 +265,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",
@@ -957,5 +965,51 @@
"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."
}
+55 -1
View File
@@ -1,4 +1,9 @@
{
"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をクリックしてレポートを送信します。",
"browser.body_name": "ボディ{number}",
"browser.component_name": "コンポーネント{number}",
"browser.parameters": "パラメータ ({count})",
@@ -161,6 +166,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 +265,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": "フィーチャーを編集",
@@ -957,5 +965,51 @@
"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}。何も変更されていません。「ダウンロード」でパッケージを入手できます。"
}
+56 -1
View File
@@ -1,4 +1,9 @@
{
"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를 클릭하여 보고서를 보내세요.",
"browser.body_name": "바디{number}",
"browser.component_name": "컴포넌트{number}",
"browser.parameters": "매개변수 ({count})",
@@ -495,6 +500,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": "면 목록",
@@ -540,6 +547,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 +645,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": "특성 편집",
@@ -697,5 +707,50 @@
"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}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다."
}
+961
View File
@@ -0,0 +1,961 @@
{
"browser.body_name": "Тело{number}",
"browser.component_name": "Компонент{number}",
"browser.parameters": "Параметры ({count})",
"browser.precision": "Точность: {precision}",
"browser.sketch_name": "Эскиз{number}",
"browser.units": "Единицы: {units}",
"cmd.appearance_assign": "Внешний вид",
"cmd.appearance_library": "Библиотека внешних видов",
"cmd.appearance_list": "Список внешних видов",
"cmd.body_rename": "Переименовать тело",
"cmd.browser_group": "Новая группа",
"cmd.browser_move": "Переместить объекты браузера",
"cmd.browser_order": "Упорядочить объекты браузера",
"cmd.browser_rename_group": "Переименовать группу",
"cmd.browser_ungroup": "Разгруппировать",
"cmd.browser_visibility": "Показать/Скрыть",
"cmd.canvas_calibrate": "Калибровать подложку",
"cmd.canvas_decal": "Наклейка",
"cmd.canvas_delete": "Удалить подложку",
"cmd.canvas_edit": "Изменить подложку",
"cmd.canvas_insert": "Подложка",
"cmd.component_activate": "Активировать компонент",
"cmd.component_break_link": "Разорвать связь",
"cmd.component_capture_position": "Зафиксировать положение",
"cmd.component_create": "Новый компонент",
"cmd.component_delete": "Удалить компонент",
"cmd.component_from_bodies": "Создать компоненты из тел",
"cmd.component_insert_design": "Вставить дизайн",
"cmd.component_links": "Список связей",
"cmd.component_list": "Список компонентов",
"cmd.component_move_bodies": "Переместить тела в компонент",
"cmd.component_move_sketches": "Переместить эскизы в компонент",
"cmd.component_paste_new": "Вставить как новый",
"cmd.component_properties": "Физические свойства компонента",
"cmd.component_rename": "Переименовать компонент",
"cmd.component_revert_position": "Вернуть положение",
"cmd.component_update_links": "Обновить связи",
"cmd.config_activate": "Активировать конфигурацию",
"cmd.config_column": "Столбец конфигурации",
"cmd.config_configure": "Настроить",
"cmd.config_preview": "Предпросмотр конфигурации",
"cmd.config_row": "Строка конфигурации",
"cmd.config_rule": "Правило конфигурации",
"cmd.config_rules": "Правила конфигурации",
"cmd.config_table": "Показать таблицу конфигурации",
"cmd.construct_axis_edge": "Ось через ребро",
"cmd.construct_axis_normal_to_face": "Ось перпендикулярно грани",
"cmd.construct_axis_through_cylinder": "Ось через цилиндр/конус/тор",
"cmd.construct_axis_two_planes": "Ось через две плоскости",
"cmd.construct_axis_two_points": "Ось через две точки",
"cmd.construct_plane_along_path": "Плоскость вдоль траектории",
"cmd.construct_plane_midplane": "Средняя плоскость",
"cmd.construct_plane_perpendicular": "Перпендикулярная плоскость",
"cmd.construct_plane_tangent": "Касательная плоскость",
"cmd.construct_plane_three_points": "Плоскость через три точки",
"cmd.construct_plane_two_edges": "Плоскость через два ребра",
"cmd.construct_point_along_path": "Точка вдоль траектории",
"cmd.construct_point_center": "Точка в центре окружности/сферы/тора",
"cmd.construct_point_edge_plane": "Точка на ребре и плоскости",
"cmd.construct_point_three_planes": "Точка через три плоскости",
"cmd.construct_point_two_edges": "Точка через два ребра",
"cmd.construct_point_vertex": "Точка в вершине",
"cmd.contact_check": "Проверить контакт",
"cmd.contact_create": "Новый набор контактов",
"cmd.contact_disable_all": "Отключить контакт",
"cmd.contact_edit": "Изменить набор контактов",
"cmd.contact_enable_all": "Включить весь контакт",
"cmd.contact_enable_sets": "Включить наборы контактов",
"cmd.contact_list": "Список наборов контактов",
"cmd.diagnostics_crash_report": "Отчёт о сбое",
"cmd.diagnostics_crash_reports": "Отчёты о сбоях",
"cmd.diagnostics_crash_reports_clear": "Удалить отчёты о сбоях",
"cmd.diagnostics_crash_test": "Тест сбоя",
"cmd.doc_recover": "Восстановить дизайн",
"cmd.doc_recovery_discard": "Отказаться от восстановленного дизайна",
"cmd.doc_recovery_list": "Восстановимые дизайны",
"cmd.document_inspect": "Инспекция дизайна",
"cmd.document_units": "Изменить единицы дизайна",
"cmd.edit_checkpoint": "Контрольная точка",
"cmd.edit_restore_checkpoint": "Вернуть контрольную точку",
"cmd.engine_commands": "Список команд",
"cmd.explode_create": "Разобранный вид",
"cmd.explode_delete": "Удалить разобранный вид",
"cmd.explode_list": "Список разобранных видов",
"cmd.explode_show": "Показать разобранный вид",
"cmd.expr_evaluate": "Вычислить выражение",
"cmd.face_delete": "Удалить грань",
"cmd.file_export": "Экспорт",
"cmd.file_insert_mesh": "Вставить меш",
"cmd.file_insert_step": "Вставить STEP",
"cmd.file_save_mesh": "Сохранить как меш",
"cmd.inspect_accessibility": "Анализ доступности",
"cmd.inspect_center_of_mass": "Центр масс",
"cmd.inspect_curvature_comb": "Анализ кривизны (гребёнка)",
"cmd.inspect_curvature_map": "Карта кривизны",
"cmd.inspect_draft": "Анализ уклонов",
"cmd.inspect_environment_map": "Анализ карты окружения",
"cmd.inspect_interference": "Интерференция",
"cmd.inspect_isocurve": "Анализ изолиний",
"cmd.inspect_minimum_radius": "Анализ минимального радиуса",
"cmd.inspect_zebra": "Зебра-анализ",
"cmd.joint_as_built": "Сопряжение на месте",
"cmd.joint_create": "Сопряжение",
"cmd.joint_delete": "Удалить сопряжение",
"cmd.joint_drive": "Привод сопряжений",
"cmd.joint_edit": "Изменить сопряжение",
"cmd.joint_limits": "Ограничения сопряжения",
"cmd.joint_list": "Список сопряжений",
"cmd.joint_motion_link": "Связь движений",
"cmd.joint_origin": "Точка сопряжения",
"cmd.joint_rigid_group": "Жёсткая группа",
"cmd.joint_solve": "Решить сопряжения",
"cmd.material_assign": "Физический материал",
"cmd.material_create": "Новый физический материал",
"cmd.material_delete": "Удалить физический материал",
"cmd.material_edit": "Изменить физический материал",
"cmd.material_list": "Список материалов",
"cmd.mesh_convert": "Преобразовать меш",
"cmd.model_edges": "Список рёбер",
"cmd.model_faces": "Список граней",
"cmd.model_threads": "Список резьб",
"cmd.motion_delete": "Удалить исследование движения",
"cmd.motion_export": "Экспортировать исследование движения",
"cmd.motion_list": "Список исследований движения",
"cmd.motion_play": "Воспроизвести исследование движения",
"cmd.motion_study": "Исследование движения",
"cmd.occurrence_copy": "Вставить (вхождение)",
"cmd.occurrence_ground": "Закрепить",
"cmd.occurrence_move": "Переместить вхождение",
"cmd.occurrence_properties": "Физические свойства вхождения",
"cmd.parameters_add": "Добавить пользовательский параметр",
"cmd.parameters_change": "Изменить параметры",
"cmd.parameters_comment": "Комментарий к параметру",
"cmd.parameters_complete": "Дополнить выражение",
"cmd.parameters_delete": "Удалить параметр",
"cmd.parameters_export": "Экспортировать параметры",
"cmd.parameters_favorite": "Избранный параметр",
"cmd.parameters_graph": "График параметра",
"cmd.parameters_import": "Импортировать параметры",
"cmd.parameters_list": "Список параметров",
"cmd.parameters_rename": "Переименовать параметр",
"cmd.parameters_users": "Где используется параметр",
"cmd.parts_fastener": "Вставить крепёж",
"cmd.parts_insert": "Вставить деталь",
"cmd.parts_library": "Стандартные детали",
"cmd.parts_resize": "Изменить размер детали",
"cmd.plastic_assign_rule": "Назначить правило пластика",
"cmd.plastic_boss": "Бобышка",
"cmd.plastic_lip": "Выступ",
"cmd.plastic_manage_rules": "Управление правилами пластика",
"cmd.plastic_rest": "Опора",
"cmd.plastic_rules": "Список правил пластика",
"cmd.plastic_snap_fit": "Защёлка",
"cmd.select_clear": "Снять выделение",
"cmd.sheet_convert": "Преобразовать в листовой металл",
"cmd.sheet_export_bend_table": "Экспортировать таблицу гибов",
"cmd.sheet_export_dxf": "Экспортировать развёртку в DXF",
"cmd.sheet_flange": "Фланец",
"cmd.sheet_flat_pattern": "Создать развёртку",
"cmd.sheet_fold": "Гиб",
"cmd.sheet_hem": "Кромка",
"cmd.sheet_manage_rules": "Правила листового металла",
"cmd.sheet_refold": "Повторный гиб",
"cmd.sheet_rules": "Список правил листового металла",
"cmd.sheet_unfold": "Разогнуть",
"cmd.sketch_arc_center_point": "Дуга из центра",
"cmd.sketch_arc_tangent": "Касательная дуга",
"cmd.sketch_arc_three_point": "Дуга по 3 точкам",
"cmd.sketch_auto_constrain": "Автоограничения",
"cmd.sketch_auto_constrain_from_datum": "Автоограничения от базы",
"cmd.sketch_auto_project": "Автопроекция рёбер на ссылку",
"cmd.sketch_blend_curve": "Сопрягающая кривая",
"cmd.sketch_chain": "Цепочка",
"cmd.sketch_chamfer_distance_angle": "Фаска по расстоянию и углу",
"cmd.sketch_chamfer_equal_distance": "Фаска по равным расстояниям",
"cmd.sketch_chamfer_two_distance": "Фаска по двум расстояниям",
"cmd.sketch_circle_center": "Окружность по центру и диаметру",
"cmd.sketch_circle_three_point": "Окружность по 3 точкам",
"cmd.sketch_circle_three_tangent": "Окружность по 3 касательным",
"cmd.sketch_circle_two_point": "Окружность по 2 точкам",
"cmd.sketch_circle_two_tangent": "Окружность по 2 касательным",
"cmd.sketch_constrainer": "Ограничитель эскиза",
"cmd.sketch_constraint_fix": "Зафиксировать/Освободить",
"cmd.sketch_constraint_midpoint": "Середина",
"cmd.sketch_construction": "Обычная/Вспомогательная",
"cmd.sketch_delete": "Удалить объекты эскиза",
"cmd.sketch_dimension_text": "Переместить текст размера",
"cmd.sketch_drag": "Перетащить геометрию эскиза",
"cmd.sketch_drag_end": "Завершить перетаскивание",
"cmd.sketch_draw_dimensioned": "Начертить размерную геометрию",
"cmd.sketch_edit": "Редактировать эскиз",
"cmd.sketch_edit_text": "Редактировать текст",
"cmd.sketch_export_dxf": "Сохранить как DXF",
"cmd.sketch_finish_auto_constrain": "Завершить с автоограничениями",
"cmd.sketch_fit_curves_to_section": "Подобрать кривые по сечению меша",
"cmd.sketch_glyphs": "Знаки ограничений",
"cmd.sketch_include_3d": "Включить 3D-геометрию",
"cmd.sketch_insert_dxf": "Вставить DXF",
"cmd.sketch_insert_svg": "Вставить SVG",
"cmd.sketch_inspect": "Инспекция эскиза",
"cmd.sketch_intersection_curve": "Кривая пересечения",
"cmd.sketch_isoparametric_curve": "Изопараметрическая кривая",
"cmd.sketch_line3d": "3D-линия",
"cmd.sketch_line_midpoint": "Линия от середины",
"cmd.sketch_move": "Переместить/Копировать объекты эскиза",
"cmd.sketch_move3d": "Переместить 3D-точку",
"cmd.sketch_move_point": "Перетащить точку эскиза",
"cmd.sketch_offset_break": "Разорвать связь смещения",
"cmd.sketch_options": "Параметры палитры эскиза",
"cmd.sketch_point3d": "3D-точка",
"cmd.sketch_polygon_circumscribed": "Описанный многоугольник",
"cmd.sketch_polygon_edge": "Многоугольник по ребру",
"cmd.sketch_polygon_inscribed": "Вписанный многоугольник",
"cmd.sketch_project_to_surface": "Спроецировать на поверхность",
"cmd.sketch_rectangle_center": "Прямоугольник из центра",
"cmd.sketch_rectangle_rounded": "Скруглённый прямоугольник",
"cmd.sketch_rectangle_three_point": "Прямоугольник по 3 точкам",
"cmd.sketch_rectangle_two_point": "Прямоугольник по 2 точкам",
"cmd.sketch_redefine": "Переопределить плоскость эскиза",
"cmd.sketch_slot_arc_center": "Дуговой паз из центра",
"cmd.sketch_slot_arc_three_point": "Дуговой паз по 3 точкам",
"cmd.sketch_slot_center_point": "Паз из центра",
"cmd.sketch_slot_center_to_center": "Паз между центрами",
"cmd.sketch_slot_overall": "Паз по полной длине",
"cmd.sketch_snap": "Привязка",
"cmd.sketch_solve": "Решить эскиз",
"cmd.sketch_spline3d": "3D-сплайн",
"cmd.sketch_spline_control_point": "Сплайн по контрольным точкам",
"cmd.sketch_spline_control_point_5": "Сплайн по контрольным точкам (степень 5)",
"cmd.sketch_spline_fit_point": "Сплайн по точкам сглаживания",
"cmd.sketch_spun_profile": "Профиль вращения",
"cmd.sketch_toggle_driven": "Переключить справочный размер",
"cmd.solid_align": "Выровнять",
"cmd.solid_boundary_fill": "Заполнение по границе",
"cmd.solid_bounding_solid": "Габаритное тело",
"cmd.solid_emboss": "Тиснение",
"cmd.solid_move": "Переместить/Копировать",
"cmd.solid_offset_face": "Смещение грани",
"cmd.solid_press_pull": "Нажать и потянуть",
"cmd.solid_remove": "Удалить",
"cmd.solid_replace_face": "Заменить грань",
"cmd.solid_rib": "Ребро жёсткости",
"cmd.solid_split_face": "Разделить грань",
"cmd.solid_web": "Перегородка",
"cmd.surface_patch": "Заплатка",
"cmd.surface_stitch": "Сшить",
"cmd.surface_thicken": "Утолщить",
"cmd.surface_extrude": "Выдавливание поверхности",
"cmd.surface_revolve": "Вращение поверхности",
"cmd.surface_sweep": "Сдвиг поверхности",
"cmd.surface_loft": "Лофт поверхности",
"cmd.surface_ruled": "Линейчатая поверхность",
"cmd.surface_boundary": "Поверхность по границе",
"cmd.surface_offset": "Смещённая поверхность",
"cmd.surface_blend": "Сопряжённая поверхность",
"cmd.surface_fillet": "Скругление поверхности",
"cmd.surface_untrim": "Отменить обрезку",
"cmd.surface_unstitch": "Расшить",
"cmd.surface_reverse_normal": "Обратить нормаль",
"cmd.inspect_continuity": "Анализ непрерывности",
"cmd.timeline_compute_all": "Пересчитать всё",
"cmd.timeline_copy": "Копировать операции",
"cmd.timeline_dependents": "Зависимые операции",
"cmd.timeline_inspect": "Инспекция операции",
"cmd.timeline_paste": "Вставить операции",
"cmd.timeline_redefine": "Изменить операцию",
"cmd.timeline_rename": "Переименовать",
"cmd.timeline_reorder": "Изменить порядок операции",
"cmd.timeline_roll_to": "Переместить маркер истории",
"cmd.timeline_rollback": "Сдвинуть маркер шкалы",
"cmd.timeline_suppress": "Подавить операцию",
"cmd.view_apply": "Вернуть именованный вид",
"cmd.view_delete": "Удалить именованный вид",
"cmd.view_list": "Список именованных видов",
"cmd.view_rename": "Переименовать именованный вид",
"cmd.view_save": "Новый именованный вид",
"dialog.custom_axis": "↖ Своя ось",
"dialog.edit_title": "ИЗМЕНИТЬ: {title}",
"dialog.select": "↖ Выбрать",
"dialog.selected_count": "↖ Выбрано: {count}",
"dialog.step": "Шаг",
"dialog.selection_step": "{step} из {total}: Выберите {input}",
"dialog.selection_guidance": "Щёлкните поле ввода, чтобы изменить его. Щелчок по выделенному объекту убирает его; Ctrl/Cmd+щелчок тоже переключает. Проверьте предпросмотр и нажмите OK.",
"document.save_changes": "Сохранить изменения в {name}?",
"document.unsaved_changes": "В {name} есть несохранённые изменения. Если их не сохранить, они будут потеряны.",
"settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске SolidCraft",
"settings.msaa_reset": "{setting}: {requested}× не поддерживается этим графическим устройством; теперь {reset}× (поддерживается: {supported}).",
"settings.msaa_unsupported": "Не поддерживается этим графическим устройством",
"settings.reset_intro": "Эти настройки не работают на этом компьютере и могли бы помешать запуску SolidCraft, поэтому они были изменены:",
"settings.reset_title": "Настройки были изменены",
"sketch.degrees_of_freedom": "Степеней свободы: {count}",
"sketch.draw_curves": "Рисовать как {linetype}",
"sketch.make_selection": "Преобразовать выделение в {linetype}",
"ui.1_degree_of_freedom": "1 степень свободы",
"ui.3d_mouse_speed": "Скорость 3D-мыши",
"ui.3d_mouse_windows": "3D-мышь (Windows)",
"ui.3d_sketch": "3D-эскиз",
"ui.about": "О программе",
"ui.activate": "Активировать",
"ui.active_component": "Активный компонент",
"ui.agent_cursor": "Курсор агента",
"ui.agent_cursor_speed": "Скорость курсора агента",
"ui.all": "Все",
"ui.analysis": "Анализ",
"ui.angle": "Угол",
"ui.angle_decimals": "Знаков после запятой (углы)",
"ui.animate_view_changes": "Анимировать смену вида",
"ui.animation": "Анимация",
"ui.animation_speed": "Скорость анимации",
"ui.anti_aliasing": "Сглаживание",
"ui.apply": "Применить",
"ui.arc": "Дуга",
"ui.assemble": "Сборка",
"ui.autosave_every": "Автосохранение каждые",
"ui.axes": "Оси",
"ui.axis": "Ось",
"ui.base_point": "Базовая точка",
"ui.bodies": "Тела",
"ui.box": "Бокс",
"ui.break": "Разорвать",
"ui.cancel": "Отмена",
"ui.canvases": "Подложки",
"ui.center": "Центр",
"ui.centerline": "Осевая линия",
"ui.chain": "Цепочка",
"ui.chamfer": "Фаска",
"ui.chamfer_type": "Тип фаски",
"ui.change_workspace": "Сменить рабочую область",
"ui.chord_length": "Длина хорды",
"ui.curvature_g2": "Кривизна (G2)",
"ui.equivalent_radius": "Эквивалентный радиус",
"ui.circle": "Окружность",
"ui.circular_pattern": "Круговой массив",
"ui.clear": "Очистить",
"ui.clear_recent": "Очистить недавние",
"ui.clear_the_selection": "Снять выделение",
"ui.click_selects_whole_bodies": "Щелчок выделяет тела целиком",
"ui.click_then_press_key": "Щёлкните, затем нажмите новую клавишу",
"ui.close": "Закрыть",
"ui.coil": "Спираль",
"ui.coincident": "Совпадение",
"ui.collapse": "Свернуть",
"ui.collinear": "Коллинеарность",
"ui.combine": "Объединение",
"ui.coming_later": "появится позже",
"ui.concentric": "Концентричность",
"ui.configurations": "Конфигурации",
"ui.conic_curve": "Коническая кривая",
"ui.constant": "Постоянный",
"ui.constraints_conflict": "Ограничения конфликтуют",
"ui.construct": "Построение",
"ui.construction": "Вспомогательная",
"ui.context_menu": "Контекстное меню",
"ui.copy": "Копировать",
"ui.corner": "Угол",
"ui.corners": "Углы",
"ui.counterbore": "Цековка",
"ui.countersink": "Зенковка",
"ui.create": "Создать",
"ui.create_copy": "Создать копию",
"ui.create_sketch": "Создать эскиз",
"ui.current": "текущий",
"ui.curvature": "Кривизна",
"ui.curves": "Кривые",
"ui.cut": "Вырезать",
"ui.cylinder": "Цилиндр",
"ui.dark": "Тёмная",
"ui.default_modelling_orientation": "Ориентация моделирования по умолчанию",
"ui.degrees_of_freedom": "степеней свободы",
"ui.delete": "Удалить",
"ui.depth": "Глубина",
"ui.design": "Дизайн",
"ui.diameter": "Диаметр",
"ui.dimension": "Размер",
"ui.direction": "Направление",
"ui.distance": "Расстояние",
"ui.distance_1": "Расстояние 1",
"ui.distance_2": "Расстояние 2",
"ui.document_settings": "Настройки документа",
"ui.don_t_save": "Не сохранять",
"ui.draft": "Уклон",
"ui.drawing": "Чертёж",
"ui.drill_point": "Конус сверла",
"ui.edit": "Изменить",
"ui.edit_section_analysis": "Изменить анализ сечения",
"ui.ellipse": "Эллипс",
"ui.end_radius": "Конечный радиус",
"ui.entities": "Объекты",
"ui.equal": "Равенство",
"ui.expand": "Развернуть",
"ui.export_ellipsis": "Экспорт...",
"ui.export_ellipsis_keymap": "Экспорт…",
"ui.export_keymap_tip": "Сочетания клавиш, избранное панели S и панель инструментов — в файл",
"ui.extend": "Удлинить",
"ui.extent_type": "Тип предела",
"ui.extrude": "Выдавливание",
"ui.factor": "Коэффициент",
"ui.feature_history": "История операций",
"ui.feature_options": "Параметры операции",
"ui.features": "Операции",
"ui.file": "Файл",
"ui.fillet": "Скругление",
"ui.filter_commands_or_keys": "Фильтр команд или клавиш",
"ui.finish_sketch": "Завершить эскиз",
"ui.fix_unfix": "Зафиксировать/Освободить",
"ui.flat": "Плоский",
"ui.flip": "Перевернуть",
"ui.front": "Спереди",
"ui.fully_constrained": "Полностью определён",
"ui.general": "Общие",
"ui.graphics": "Графика",
"ui.ground_shadow": "Тень на земле",
"ui.heading_add_ins": "НАДСТРОЙКИ",
"ui.heading_assemble": "СБОРКА",
"ui.heading_box": "БОКС",
"ui.heading_chamfer": "ФАСКА",
"ui.heading_circular_pattern": "КРУГОВОЙ МАССИВ",
"ui.heading_coil": "СПИРАЛЬ",
"ui.heading_combine": "ОБЪЕДИНЕНИЕ",
"ui.heading_configure": "КОНФИГУРАЦИЯ",
"ui.heading_constraints": "ОГРАНИЧЕНИЯ",
"ui.heading_construct": "ПОСТРОЕНИЕ",
"ui.heading_create": "СОЗДАТЬ",
"ui.heading_create_sketch": "СОЗДАТЬ ЭСКИЗ",
"ui.heading_cylinder": "ЦИЛИНДР",
"ui.heading_design": "ДИЗАЙН",
"ui.heading_draft": "УКЛОН",
"ui.heading_edit": "ИЗМЕНИТЬ",
"ui.heading_extrude": "ВЫДАВЛИВАНИЕ",
"ui.heading_fillet": "СКРУГЛЕНИЕ",
"ui.heading_finish_sketch": "ЗАВЕРШИТЬ ЭСКИЗ",
"ui.heading_front": "СПЕРЕДИ",
"ui.heading_hole": "ОТВЕРСТИЕ",
"ui.heading_home": "ГЛАВНАЯ",
"ui.heading_insert": "ВСТАВИТЬ",
"ui.heading_inspect": "ИНСПЕКЦИЯ",
"ui.heading_loft": "ЛОФТ",
"ui.heading_make": "ИЗГОТОВЛЕНИЕ",
"ui.heading_measure": "ИЗМЕРИТЬ",
"ui.heading_mesh": "МЕШ",
"ui.heading_mirror": "ЗЕРКАЛО",
"ui.heading_modify": "ИЗМЕНЕНИЕ",
"ui.heading_move": "ПЕРЕМЕСТИТЬ",
"ui.heading_offset": "СМЕЩЕНИЕ",
"ui.heading_move_copy": "ПЕРЕМЕСТИТЬ/СКОПИРОВАТЬ",
"ui.heading_offset_plane": "СМЕЩЁННАЯ ПЛОСКОСТЬ",
"ui.heading_ok": "OK",
"ui.heading_parameters": "ПАРАМЕТРЫ",
"ui.heading_pattern_on_path": "МАССИВ ПО ТРАЕКТОРИИ",
"ui.heading_pipe": "ТРУБА",
"ui.heading_plane_at_angle": "ПЛОСКОСТЬ ПОД УГЛОМ",
"ui.heading_plastic": "ПЛАСТИК",
"ui.heading_prepare": "ПОДГОТОВКА",
"ui.heading_rectangular_pattern": "ПРЯМОУГОЛЬНЫЙ МАССИВ",
"ui.heading_revolve": "ВРАЩЕНИЕ",
"ui.heading_right": "СПРАВА",
"ui.heading_scale": "МАСШТАБ",
"ui.heading_section_analysis": "АНАЛИЗ СЕЧЕНИЯ",
"ui.heading_select": "ВЫБРАТЬ",
"ui.heading_sheet_metal": "ЛИСТОВОЙ МЕТАЛЛ",
"ui.heading_shell": "ОБОЛОЧКА",
"ui.heading_sketch": "ЭСКИЗ",
"ui.heading_sketch_palette": "ПАЛИТРА ЭСКИЗА",
"ui.heading_sketch_scale": "МАСШТАБ ЭСКИЗА",
"ui.heading_solid": "ТЕЛО",
"ui.heading_sphere": "СФЕРА",
"ui.heading_split_body": "РАЗДЕЛИТЬ ТЕЛО",
"ui.heading_surface": "ПОВЕРХНОСТЬ",
"ui.heading_sweep": "СДВИГ",
"ui.heading_thread": "РЕЗЬБА",
"ui.heading_top": "СВЕРХУ",
"ui.heading_torus": "ТОР",
"ui.heading_utilities": "УТИЛИТЫ",
"ui.heading_utility": "УТИЛИТА",
"ui.help": "Справка",
"ui.hide": "Скрыть",
"ui.hole": "Отверстие",
"ui.hole_tap_type": "Тип резьбы отверстия",
"ui.hole_type": "Тип отверстия",
"ui.home": "Главная",
"ui.horizontal_vertical": "Горизонталь/Вертикаль",
"ui.import_ellipsis": "Импорт…",
"ui.insert": "Вставить",
"ui.inspect": "Инспекция",
"ui.intersect": "Пересечение",
"ui.join": "Объединить",
"ui.joints": "Сопряжения",
"ui.keyboard_shortcuts": "Сочетания клавиш",
"ui.language": "Язык",
"ui.length_decimals": "Знаков после запятой (длины)",
"ui.light": "Светлая",
"ui.line": "Линия",
"ui.linetype": "Тип линии",
"ui.loft": "Лофт",
"ui.look_at": "Взгляд на",
"ui.look_at_anim_desc": "Осевые виды, «Взгляд на», «Главная» и «Вписать»",
"ui.manufacture": "Производство",
"ui.measure": "Измерить",
"ui.midpoint": "Середина",
"ui.mirror": "Зеркало",
"ui.mirror_line": "Линия зеркала",
"ui.modify": "Изменение",
"ui.move": "Переместить",
"ui.multiple": "Несколько",
"ui.named_views": "Именованные виды",
"ui.navigation": "Навигация",
"ui.needs_design": "{command}: сначала откройте дизайн",
"ui.new_body": "Новое тело",
"ui.new_design": "Новый дизайн",
"ui.normal": "Нормаль",
"ui.object_type": "Тип объекта",
"ui.objects": "Объекты",
"ui.offer_to_stand_up_y_up_files": "Предлагать ставить Y-up файлы вертикально",
"ui.offset": "Смещение",
"ui.offset_plane": "Смещённая плоскость",
"ui.one_side": "Одна сторона",
"ui.open": "Открыть",
"ui.open_ellipsis": "Открыть...",
"ui.open_preferences": "Открыть настройки",
"ui.open_recent": "Открыть недавние",
"ui.operation": "Операция",
"ui.options": "Параметры",
"ui.orbit_around": "Вращать вокруг",
"ui.origin": "Начало координат",
"ui.palette_points": "Точки",
"ui.palette_profile": "Профиль",
"ui.pan_zoom_orbit": "Панорама, масштаб, орбита",
"ui.parallel": "Параллельность",
"ui.parameters": "Параметры",
"ui.paste": "Вставить",
"ui.path": "Траектория",
"ui.pattern_on_path": "Массив по траектории",
"ui.perpendicular": "Перпендикулярность",
"ui.perspective_view": "Перспективный вид",
"ui.pipe": "Труба",
"ui.placement": "Размещение",
"ui.plane": "Плоскость",
"ui.plane_at_angle": "Плоскость под углом",
"ui.point": "Точка",
"ui.point_under_the_cursor": "Точка под курсором",
"ui.points": "Точки",
"ui.polygon": "Многоугольник",
"ui.preferences": "Настройки",
"ui.press_a_key": "нажмите клавишу…",
"ui.profile": "Профиль",
"ui.profile_plane": "Плоскость профиля",
"ui.profiles": "Профили",
"ui.project": "Проецировать",
"ui.project_face_edges_sketch": "Проецировать рёбра граней в новые эскизы",
"ui.quantity": "Количество",
"ui.quantity_2": "Количество 2",
"ui.quit": "Выход",
"ui.radial_context_menu": "Радиальное контекстное меню",
"ui.radius": "Радиус",
"ui.radius_type": "Тип радиуса",
"ui.rectangle": "Прямоугольник",
"ui.rectangular_pattern": "Прямоугольный массив",
"ui.redo": "Повторить",
"ui.render": "Рендер",
"ui.renderer": "Рендерер",
"ui.reset": "Сбросить",
"ui.reset_all": "Сбросить всё",
"ui.reset_the_settings_on_this_page": "Сбросить настройки этой страницы",
"ui.restore_defaults": "Восстановить по умолчанию",
"ui.reverse_zoom_direction": "Обратить направление масштабирования",
"ui.revolve": "Вращение",
"ui.right": "Справа",
"ui.save": "Сохранить",
"ui.save_as": "Сохранить как",
"ui.save_as_ellipsis": "Сохранить как...",
"ui.scale": "Масштаб",
"ui.section_analysis": "Анализ сечения",
"ui.select": "Выбрать",
"ui.select_other_hint": "Выбрать другое: удерживайте левую кнопку на модели (или правый щелчок › Выбрать другое…), чтобы увидеть всё под курсором; повторный щелчок в том же месте выбирает следующий объект.",
"ui.selection": "Выделение",
"ui.shell": "Оболочка",
"ui.show": "Показать",
"ui.show_constraints": "Ограничения",
"ui.show_dimensions": "Размеры",
"ui.show_points": "Показать точки",
"ui.show_profile": "Показать профиль",
"ui.show_projected_geometries": "Спроецированная геометрия",
"ui.simple": "Простой",
"ui.simulation": "Симуляция",
"ui.single": "Один",
"ui.sketch": "Эскиз",
"ui.sketch_dimension": "Размер эскиза",
"ui.sketch_grid": "Сетка эскиза",
"ui.sketch_scale": "Масштаб эскиза",
"ui.sketch_status": "Статус эскиза",
"ui.sketches": "Эскизы",
"ui.slice": "Срез",
"ui.slot": "Паз",
"ui.smooth": "Сглаживание",
"ui.smooth_wheel_zoom": "Плавное масштабирование колесом",
"ui.smooth_wheel_zoom_tip": "Отключите, чтобы каждое движение колеса применялось сразу, без сглаживания",
"ui.snap": "Привязка",
"ui.space_mouse_hint": "Совместное перемещение и вращение вида; в эскизе — только панорама и масштаб.",
"ui.space_mouse_navigate": "Навигация через SpaceNavigator / SpaceMouse",
"ui.spacing": "Интервал",
"ui.spacing_2": "Интервал 2",
"ui.sphere": "Сфера",
"ui.spline": "Сплайн",
"ui.split_body": "Разделить тело",
"ui.stand_up_tip": "Если файл STEP или IGES открывается с Z вверх, предложить поворот на четверть оборота вокруг X",
"ui.start": "Старт",
"ui.start_radius": "Начальный радиус",
"ui.sweep": "Сдвиг",
"ui.symmetric": "Симметрично",
"ui.symmetry": "Симметрия",
"ui.system": "Системная",
"ui.tangent": "Касание",
"ui.taper_angle": "Угол наклона",
"ui.tapped": "С резьбой",
"ui.text": "Текст",
"ui.theme": "Тема",
"ui.thickness": "Толщина",
"ui.thread": "Резьба",
"ui.timeline_bottom": "Шкала внизу",
"ui.timeline_left_note": "В левом макете полоса отката находится над браузером деталей и сцены.",
"ui.timeline_left_sidebar": "Левая боковая панель",
"ui.tip_angle": "Угол при вершине",
"ui.to_object": "До объекта",
"ui.top": "Сверху",
"ui.torus": "Тор",
"ui.trackpad_pan_tip": "Жесты тачпада панорамируют (выкл.: масштабируют как колесо); колесо мыши масштабирует, боковое колесо панорамирует вбок, щипок всегда масштабирует",
"ui.trim": "Обрезать",
"ui.two_finger_scroll_pans": "Прокрутка двумя пальцами панорамирует",
"ui.two_sides": "Две стороны",
"ui.ui_scale": "Масштаб интерфейса",
"ui.undo": "Отменить",
"ui.units": "Единицы",
"ui.units_of_new_designs": "Единицы новых дизайнов",
"ui.untitled": "Без имени",
"ui.up_axis_note": "Какая ось направлена вверх в видах (осевые виды, «Главная», «Взгляд на»). Дизайны не меняются.",
"ui.variable": "Переменный",
"ui.view_centre": "Центр вида",
"ui.visual_shaded_hidden_edges": "Заполненный с невидимыми рёбрами",
"ui.visual_shaded_with_edges": "Заполненный с рёбрами",
"ui.visual_style": "Стиль отображения",
"ui.visual_wireframe": "Каркас",
"ui.workspace": "рабочая область",
"ui.x_distance": "Расстояние X",
"ui.y_distance": "Расстояние Y",
"ui.y_up": "Y вверх",
"ui.z_up": "Z вверх",
"ui.zoom_to_cursor_tip": "Колесо масштабирует к точке под курсором (выкл.: к центру вида)",
"ui.zoom_to_the_cursor": "Масштаб к курсору",
"A new configuration, a copy of the active one": "Новая конфигурация — копия активной",
"About SolidCraft": "О SolidCraft",
"Active": "Активный",
"Active rule": "Активное правило",
"Add": "Добавить",
"Add Key": "Добавить клавишу",
"Add Mid Radius": "Добавить средний радиус",
"Add Row": "Добавить строку",
"All Sketch Points": "Все точки эскиза",
"All open designs": "Все открытые дизайны",
"Allowed expressions, separated by semicolons": "Разрешённые выражения через точку с запятой",
"Also deletes": "Также удаляет",
"Angle 2": "Угол 2",
"Animate": "Анимировать",
"Appearance name": "Имя внешнего вида",
"Applied Radius": "Применённый радиус",
"Apply To": "Применить к",
"Area": "Площадь",
"Attachment": "Привязка",
"BROWSER": "БРАУЗЕР",
"Bend Line Position": "Положение линии гиба",
"Bend Position": "Положение гиба",
"Bend Radius": "Радиус гиба",
"Bends": "Гибы",
"Body": "Тело",
"Bounding box": "Габаритная рамка",
"CSV or JSON: updates existing parameters, adds new ones": "CSV или JSON: обновляет существующие параметры, добавляет новые",
"Capture All Positions": "Зафиксировать все положения",
"Catch Direction": "Направление защёлкивания",
"Cells": "Ячейки",
"Center X": "Центр X",
"Center Y": "Центр Y",
"Center of mass": "Центр масс",
"Chamfer Type": "Тип фаски",
"Check collisions": "Проверить столкновения",
"Checked": "Включено",
"Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Выберите новую плоскость. Геометрия и размеры эскиза останутся неизменными в её двумерных координатах.",
"Close draft": "Закрыть анализ уклонов",
"Collisions": "Столкновения",
"Colour": "Цвет",
"Component1": "Компонент1",
"Configuration": "Конфигурация",
"Configuration rules": "Правила конфигурации",
"Contact": "Контакт",
"Copy all licences": "Копировать все лицензии",
"Copy diagnostic information": "Копировать диагностическую информацию",
"Copy report": "Копировать отчёт",
"Corner Relief Shape": "Форма разгрузки угла",
"Counterbore depth": "Глубина цековки",
"Countersink angle": "Угол зенковки",
"Create Issue — review draft": "Создать Issue — проверить черновик",
"Customize Toolbar": "Настроить панель инструментов",
"Delete all": "Удалить все",
"Delete rule": "Удалить правило",
"Delete the row": "Удалить строку",
"Delete the set": "Удалить набор",
"Delete this parameter": "Удалить этот параметр",
"Density": "Плотность",
"Designs you open or save appear here.": "Здесь появляются открытые и сохранённые дизайны.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Определяется параметром «До объекта». Выберите «Угол», чтобы задать угловой предел.",
"Discard": "Отменить",
"Double-click to rename": "Двойной щелчок — переименовать",
"Drive Joint": "Привод сопряжения",
"Edit Profile Sketch": "Редактировать эскиз профиля",
"Enable rule": "Включить правило",
"Enabled": "Включено",
"Explode": "Разобрать",
"Extents": "Пределы",
"Face attachment lost": "Привязка к грани потеряна",
"Faces / edges / vertices": "Грани / рёбра / вершины",
"Favourite": "Избранное",
"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": "Найти в браузере",
"Flat size": "Размер развёртки",
"Follow camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
"Font": "Шрифт",
"Full Length": "Полная длина",
"Gap": "Зазор",
"Grid": "Сетка",
"Height": "Высота",
"Home view": "Главный вид",
"In This Design": "В этом дизайне",
"Interferes": "Пересекается",
"Issue title": "Заголовок Issue",
"Joint Origin1": "Точка сопряжения1",
"Keep Tools": "Оставить инструменты",
"Later": "Позже",
"Leave it": "Оставить как есть",
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Устаревшая привязка к грани. Перепривязка к сертифицированной грани создаёт точную привязку.",
"Length": "Длина",
"Length units": "Единицы длины",
"Library": "Библиотека",
"Loop": "Контур",
"Margin": "Отступ",
"Mass": "Масса",
"Material": "Материал",
"Mid Radius": "Средний радиус",
"Modeled": "Смоделировано",
"More toolbar panels": "Другие панели инструментов",
"Motion": "Движение",
"Move Type": "Тип перемещения",
"Move earlier": "Сдвинуть раньше",
"Move later": "Сдвинуть позже",
"Name": "Имя",
"No crash reports.": "Нет отчётов о сбоях.",
"No matching appearances": "Нет подходящих внешних видов",
"Not available yet": "Пока недоступно",
"Opacity": "Непрозрачность",
"Open GitHub draft": "Открыть черновик на GitHub",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Открытая параметрическая 3D CAD на Rust. Лицензия MIT OR Apache-2.0.",
"Other side": "Другая сторона",
"Parameter values": "Значения параметров",
"Per Edge": "По каждому ребру",
"Pick the tool bodies": "Выберите тела-инструменты",
"Place at the origin instead": "Разместить в начале координат",
"Playback": "Воспроизведение",
"Position": "Положение",
"Position (0 to 1)": "Положение (от 0 до 1)",
"Position along the edge, 0 at the start end, 1 at the other": "Положение вдоль ребра: 0 — начало, 1 — конец",
"Preview leaves the current design unchanged.": "Предпросмотр не меняет текущий дизайн.",
"RECENT": "НЕДАВНИЕ",
"Radius Fit": "Подобрать радиус",
"Ratio": "Отношение",
"Recover": "Восстановить",
"Recover unsaved design?": "Восстановить несохранённый дизайн?",
"Remove Tools": "Убрать инструменты",
"Remove from list": "Убрать из списка",
"Remove private design details or paths before sharing.": "Перед публикацией удалите личные данные и пути из дизайна.",
"Remove the column": "Удалить столбец",
"Remove the track": "Удалить дорожку",
"Remove this point": "Удалить эту точку",
"Report a bug — review a screenshot and open a GitHub draft": "Сообщить об ошибке — проверьте скриншот и откройте черновик на GitHub",
"Reset Toolbar": "Сбросить панель инструментов",
"Restore the inherited component or physical material appearance": "Вернуть унаследованный внешний вид компонента или физического материала",
"Result": "Результат",
"Retake screenshot": "Сделать скриншот заново",
"Reverse": "Обратить",
"Reverse Direction": "Обратить направление",
"Revert": "Вернуть",
"Revert All Positions": "Вернуть все положения",
"Review the report and screenshot before opening GitHub.": "Проверьте отчёт и скриншот перед открытием GitHub.",
"Rim Edge": "Кромка обода",
"Roll History Marker Here": "Переместить маркер истории сюда",
"Rule": "Правило",
"Saved preview. Geometry is being checked before editing.": "Сохранённый предпросмотр. Геометрия проверяется перед редактированием.",
"Scale factor": "Коэффициент масштаба",
"Search": "Поиск",
"Search commands": "Поиск команд",
"Select bodies (a face picks its body)": "Выберите тела (выбор грани выбирает её тело)",
"Select bodies or faces instead": "Вместо этого выберите тела или грани",
"Selection filter": "Фильтр выделения",
"Semantic faces share their appearance across instances.": "Семантические грани разделяют внешний вид между вхождениями.",
"Shape": "Форма",
"Show Agent Cursor": "Показывать курсор агента",
"Show in folder": "Показать в папке",
"Size": "Размер",
"Sketch plane has no body-face attachment.": "Плоскость эскиза не привязана к грани тела.",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft закрылся до сохранения этих дизайнов. Можно открыть последнюю автосохранённую версию.",
"Speed": "Скорость",
"Stand it up": "Поставить вертикально",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Поставить вертикально: четверть оборота вокруг X одной операцией «Переместить», которую можно отменить.",
"Stand this part up?": "Поставить эту деталь вертикально?",
"Step": "Шаг",
"Steps": "Шаги",
"Suppressed": "Подавлено",
"Swap Ends": "Поменять концы",
"Tangent Chain": "Касательная цепочка",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Пунктирный эскиз — сохранённая подсказка плоскости. Выберите новую грань, чтобы восстановить привязку.",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Сохранённое исходное вхождение отсутствует. Выберите корректное исходное положение перед перепривязкой.",
"Then": "Затем",
"These features will be deleted:": "Эти операции будут удалены:",
"These features will fail:": "Эти операции завершатся с ошибкой:",
"This child is shared. Deleting it removes it from every copy of its parent.": "Этот дочерний объект общий. Его удаление затронет все копии родителя.",
"This child is shared. Position changes apply to every copy of its parent.": "Этот дочерний объект общий. Изменения положения применятся ко всем копиям родителя.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Эта грань не может обеспечить надёжную привязку. Выберите другую грань или явно используйте фиксированную плоскость.",
"To Offset": "До смещения",
"Tolerance": "Допуск",
"Type": "Тип",
"Updating the preview": "Обновление предпросмотра",
"Use fixed plane": "Использовать фиксированную плоскость",
"VIEW": "ВИД",
"Value": "Значение",
"Volume": "Объём",
"Wall": "Стенка",
"When": "Когда",
"Width": "Ширина",
"Will fail": "Завершится с ошибкой",
"angle, height": "угол, высота",
"command name": "имя команды",
"expression, e.g. 25 mm": "выражение, напр. 25 мм",
"fits the face, e.g. M8": "подходит грани, напр. M8",
"from the rule": "из правила",
"needs two joints": "нужны два сопряжения",
"new configuration": "новая конфигурация",
"new name": "новое имя",
"new parameter": "новый параметр",
"no interference": "нет интерференции",
"no joint that moves yet": "пока нет подвижных сопряжений",
"no picture": "нет картинки",
"pick a hole to seat it, or OK places it at the origin": "выберите отверстие для установки, или OK разместит в начале координат",
"pick one or two vertices, edges or faces": "выберите одну-две вершины, ребра или грани",
"the parts library is not available": "библиотека деталей недоступна",
"to the next face": "до следующей грани",
"Revert Position": "Вернуть положение",
"Construction": "Вспомогательная",
"Faces": "Грани",
"Edges": "Рёбра",
"Vertices": "Вершины",
"Sketch geometry and profiles": "Геометрия и профили эскиза",
"Sketch curves": "Кривые эскиза",
"Sketch points": "Точки эскиза",
"Sketch dimensions": "Размеры эскиза",
"Sketch constraints": "Ограничения эскиза",
"0 turns it off": "0 отключает",
"Show the pointer an AI agent moves (#35)": "Показывать указатель, которым двигается ИИ-агент (#35)",
"Follow the camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
"Flat": "Плоский",
"Save Rule": "Сохранить правило",
"Preview Row": "Строка предпросмотра",
"Add condition": "Добавить условие",
"Apply to selection": "Применить к выделению",
"Remove override": "Убрать переопределение",
"Copy screenshot": "Копировать скриншот",
"Remove screenshot": "Убрать скриншот",
"Use active author placement": "Использовать активное исходное положение",
"Reattach selected face": "Перепривязать выбранную грань",
"Use saved fixed plane": "Использовать сохранённую фиксированную плоскость",
"Use selected face as fixed plane": "Использовать выбранную грань как фиксированную плоскость",
"View navigator": "Навигатор видов",
"Signed axes": "Знаковые оси",
"Cube": "Куб",
"Middle double-click": "Двойной щелчок средней кнопкой",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Двойной щелчок средней кнопкой в виде: «Главная» (главный вид, кадрирует показанное), «Вписать» (кадрирует, сохраняя ориентацию) или ничего",
"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. Double-click the middle button to go Home (Preferences › Navigation).": "Выбрать другое: удерживайте левую кнопку на модели (или правый щелчок › Выбрать другое…), чтобы увидеть всё под курсором; повторный щелчок в том же месте выбирает следующий объект. Двойной щелчок средней кнопкой — «Главная» (Настройки › Навигация).",
"cmd.render_backends": "Рендер-бэкенды",
"cmd.view_navigate": "Навигация вида",
"ui.animateJoint": "Анимировать сопряжение",
"ui.calibrate": "Калибровать",
"ui.customizeToolbar": "Настроить панель инструментов…",
"ui.editJointLimits": "Изменить ограничения сопряжения",
"ui.exportDxf": "Экспорт DXF…",
"ui.findInTimeline": "Найти на шкале",
"ui.findInWindow": "Найти в окне",
"ui.fit": "Вписать",
"ui.groupSelected": "Группировать выбранное",
"ui.hideAxes": "Скрыть оси",
"ui.hideDimension": "Скрыть размер",
"ui.hideOrigin": "Скрыть начало координат",
"ui.hidePlanes": "Скрыть плоскости",
"ui.hideProfile": "Скрыть профиль",
"ui.homeView": "Главный вид",
"ui.isolate": "Изолировать",
"ui.lock": "Заблокировать",
"ui.newSketchGroup": "Новая группа эскизов",
"ui.normalConstruction": "Обычная / Вспомогательная",
"ui.orbit": "Орбита",
"ui.heading_position": "ПОЛОЖЕНИЕ",
"ui.pan": "Панорамирование",
"ui.pinToShortcuts": "Закрепить в сочетаниях клавиш",
"ui.pinToToolbar": "Закрепить на панели",
"ui.properties": "Свойства",
"ui.removeSection": "Убрать сечение",
"ui.removeFromToolbar": "Убрать с панели",
"ui.repeat": "Повторить",
"ui.report_a_bug": "Сообщить об ошибке",
"ui.restore": "Восстановить",
"ui.heading_setup": "НАСТРОЙКА",
"ui.selectOther": "Выбрать другое…",
"ui.selectTangentChain": "Выбрать касательную цепочку",
"ui.showAll": "Показать всё",
"ui.showAllBodies": "Показать все тела",
"ui.showAxes": "Показать оси",
"ui.showDimension": "Показать размер",
"ui.showOrigin": "Показать начало координат",
"ui.showPlanes": "Показать плоскости",
"ui.suppress": "Подавить",
"ui.command_palette_hint": "Введите команду, напр. {example} — или нажмите S для поиска",
"ui.unground": "Открепить",
"ui.unisolate": "Снять изоляцию",
"ui.unlock": "Разблокировать",
"ui.unsuppress": "Восстановить операцию",
"ui.updateToCurrentView": "Обновить до текущего вида",
"ui.zoom": "Масштаб",
"ui.command_done": "{command} — готово",
"timeline.group": "Новая папка операций",
"timeline.ungroup": "Убрать папки операций",
"timeline.rename_group": "Переименовать папку операций",
"timeline.move_to_group": "Переместить операции в папку",
"timeline.move_many": "Переместить объекты шкалы",
"timeline.reorder_many": "Изменить порядок операций",
"ui.show_construction_geometries": "Вспомогательная геометрия",
"ui.total_angle": "Полный угол",
"sketch_text.builtin_font": "{family} (встроенный)",
"sketch_text.font_search": "Поиск шрифтов",
"sketch_text.loading_fonts": "Поиск установленных шрифтов…",
"sketch_text.use_font_file": "Использовать файл шрифта {path}",
"sketch_text.bold": "Полужирный",
"sketch_text.italic": "Курсив",
"sketch_text.align_left": "По левому краю",
"sketch_text.align_center": "По центру",
"sketch_text.align_right": "По правому краю",
"sketch_text.path_hint": "Кривая эскиза, вдоль которой идёт текст: щёлкните здесь, затем по кривой",
"sketch_text.path_clear": "Снять текст с кривой",
"Style": "Стиль",
"Alignment": "Выравнивание",
"Letter Spacing": "Межбуквенный интервал",
"Line Spacing": "Межстрочный интервал",
"Path": "Траектория",
"sketch_text.on_path": "Текст следует по траектории: кривая задаёт его положение",
"cmd.material_properties_validate": "Проверить данные физического материала"
}
+55 -1
View File
@@ -1,4 +1,9 @@
{
"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 发送报告。",
"browser.body_name": "实体{number}",
"browser.component_name": "组件{number}",
"browser.parameters": "参数 ({count})",
@@ -161,6 +166,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 +265,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": "编辑特征",
@@ -957,5 +965,51 @@
"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}。未做任何更改;可用“下载”获取安装包。"
}
+2
View File
@@ -75,6 +75,8 @@ 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),
app_bar_tab_edge: Color32::from_rgba_unmultiplied_const(255, 255, 255, 40),
timeline_item: Color32::WHITE,
timeline_item_error: Color32::from_rgb(250, 220, 220),
timeline_marker: Color32::from_rgb(70, 76, 88),
+3 -3
View File
@@ -162,12 +162,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))))
+12 -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,16 @@ 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;
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 +366,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 });
+1 -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(
+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(),
-18
View File
@@ -575,15 +575,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 +585,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 +607,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],
+155 -338
View File
@@ -8,6 +8,9 @@ use crate::{DocError, Result};
pub const MAX_FEATURES: usize = 10_000;
pub const MAX_PARAMS: usize = 10_000;
/// Largest feature id a replayed feature may keep (`Document::insert_feature`): exact in JSON
/// numbers, and far from overflowing the next id.
pub const MAX_FEATURE_ID: u64 = 1 << 52;
/// A named parameter. User parameters are created by the user; model parameters (`d1`, `d2`…)
/// are created by dimensions and features.
@@ -176,6 +179,51 @@ pub struct Extent {
pub from: Option<Vec3>,
}
/// Thin Extrude: a wall of constant thickness along sketch curves instead of the profiles.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ThinWall {
/// The sketch curves the wall follows: open chains and closed loops, joined where they
/// meet end to end.
pub curves: Vec<String>,
/// The wall thickness (expression); with `side: two_sides`, the thickness inside.
pub thickness: String,
#[serde(default, skip_serializing_if = "ThinSide::is_center")]
pub side: ThinSide,
/// With `side: two_sides`, the thickness outside (expression).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thickness2: Option<String>,
}
/// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses;
/// an open chain's is the side it bends toward (a straight one's, the left of its first
/// curve as drawn).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThinSide {
/// Half the thickness either side of the curves.
#[default]
Center,
Inside,
Outside,
/// `thickness` inside and `thickness2` outside.
TwoSides,
}
impl ThinSide {
pub fn is_center(&self) -> bool {
*self == ThinSide::Center
}
pub fn parse(s: &str) -> Option<ThinSide> {
match s.trim().to_ascii_lowercase().replace([' ', '-'], "_").as_str() {
"center" | "centre" | "centered" | "centred" | "symmetric" => Some(ThinSide::Center),
"inside" | "side1" | "side_1" => Some(ThinSide::Inside),
"outside" | "side2" | "side_2" => Some(ThinSide::Outside),
"two_sides" | "both" => Some(ThinSide::TwoSides),
_ => None,
}
}
}
/// Revolve axis.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -362,6 +410,9 @@ pub enum FeatureKind {
/// Target body names for join/cut/intersect (empty = all bodies).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
/// Thin: a wall along sketch curves instead of the profiles (which are then unused).
#[serde(default, skip_serializing_if = "Option::is_none")]
thin: Option<ThinWall>,
},
Revolve {
sketch: u64,
@@ -757,6 +808,20 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// A perforation field (#732): round holes of `diameter` on a staggered (60°) or square grid of `pitch` over the
/// whole flat sheet, kept `margin` (default: the diameter) clear of its edges, cut-outs and bend zones. Stored like
/// a cosmetic thread: the holes go to the flat pattern, the DXF and the measured volume, not into the B-rep, so a
/// panel can carry tens of thousands of them.
SheetPerforate {
diameter: String,
pitch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
margin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Fold a sheet along a line on its base face: a sketch line (followed when the sketch
/// changes), else the points `a`, `b`.
SheetFold {
@@ -1247,27 +1312,6 @@ pub enum PatternKind {
},
}
fn plane_exprs<'a>(p: &'a PlaneRef, v: &mut Vec<&'a str>) {
let mut p = p;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, distance } => {
v.push(distance);
p = base;
}
PlaneRef::AtAngle { base, angle, .. } => {
v.push(angle);
p = base;
}
PlaneRef::AlongPath { t, .. } => {
v.push(t);
break;
}
_ => break,
}
}
}
fn z_axis() -> Vec3 {
Vec3::Z
}
@@ -1312,74 +1356,6 @@ mod point_expr {
}
impl FeatureKind {
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
/// deletes the feature. Every kind is listed, so a new one has to say what it uses.
pub fn input_sketches(&self) -> Vec<u64> {
use FeatureKind as K;
match self {
K::Extrude { sketch, .. }
| K::SurfaceExtrude { sketch, .. }
| K::SurfaceRevolve { sketch, .. }
| K::Revolve { sketch, .. }
| K::Emboss { sketch, .. }
| K::Rib { sketch, .. }
| K::SheetBase { sketch, .. }
| K::SheetContour { sketch, .. } => vec![*sketch],
K::Sweep { sketch, path_sketch, .. } | K::SurfaceSweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
K::SurfaceLoft { sections } => sections.iter().map(|s| s.sketch).collect(),
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
K::Sketch { .. }
| K::Pattern { .. }
| K::Fillet { .. }
| K::Chamfer { .. }
| K::Box { .. }
| K::Cylinder { .. }
| K::Sphere { .. }
| K::Torus { .. }
| K::Combine { .. }
| K::Mirror { .. }
| K::Thread { .. }
| K::Shell { .. }
| K::Draft { .. }
| K::ConstructionPlane { .. }
| K::Split { .. }
| K::ConstructionAxis { .. }
| K::ConstructionPoint { .. }
| K::Scale { .. }
| K::OffsetFace { .. }
| K::DeleteFace { .. }
| K::SurfaceOffset { .. }
| K::BoundingSolid { .. }
| K::ReplaceFace { .. }
| K::Align { .. }
| K::Coil { .. }
| K::SheetFlange { .. }
| K::SheetHem { .. }
| K::SheetUnfold { .. }
| K::SheetConvert { .. }
| K::Boss { .. }
| K::Lip { .. }
| K::SnapFit { .. }
| K::Rest { .. }
| K::BoundaryFill { .. }
| K::Remove { .. }
| K::Stitch { .. }
| K::Thicken { .. }
| K::SurfaceExtend { .. }
| K::SurfaceTrim { .. }
| K::Unstitch { .. }
| K::ReverseNormal { .. }
| K::Move { .. }
| K::Import { .. }
| K::MeshImport { .. }
| K::MeshConvert { .. }
| K::BodyTransfer { .. } => Vec::new(),
}
}
/// A sheet metal feature (its result depends on the sheet metal rules).
pub fn is_plastic(&self) -> bool {
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
@@ -1392,6 +1368,7 @@ impl FeatureKind {
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetFlange { .. }
| FeatureKind::SheetHem { .. }
| FeatureKind::SheetPerforate { .. }
| FeatureKind::SheetFold { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. }
@@ -1445,6 +1422,7 @@ impl FeatureKind {
FeatureKind::SheetContour { .. } => "ContourFlangeFeature",
FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature",
FeatureKind::SheetHem { .. } => "HemFeature",
FeatureKind::SheetPerforate { .. } => "PerforateFeature",
FeatureKind::SheetFold { .. } => "FoldFeature",
FeatureKind::SheetUnfold { refold: false, .. } => "UnfoldFeature",
FeatureKind::SheetUnfold { .. } => "RefoldFeature",
@@ -1518,6 +1496,7 @@ impl FeatureKind {
FeatureKind::SheetContour { .. } => "ContourFlange",
FeatureKind::SheetFlange { .. } => "EdgeFlange",
FeatureKind::SheetHem { .. } => "Hem",
FeatureKind::SheetPerforate { .. } => "Perforate",
FeatureKind::SheetFold { .. } => "Fold",
FeatureKind::SheetUnfold { refold: false, .. } => "Unfold",
FeatureKind::SheetUnfold { .. } => "Refold",
@@ -1544,250 +1523,6 @@ impl FeatureKind {
FeatureKind::MeshConvert { .. } => "ConvertMesh",
}
}
/// Every expression the feature uses.
/// Points on faces the feature refers to (shell, draft, offset and replace faces, a hole's
/// placement), for persistent face names.
/// The feature's profile selections, each with its sketch.
pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> {
match self {
FeatureKind::Extrude { sketch, profiles, .. }
| FeatureKind::Revolve { sketch, profiles, .. }
| FeatureKind::Sweep { sketch, profiles, .. }
| FeatureKind::Emboss { sketch, profiles, .. }
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sk), profiles, .. }
| FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. }
| FeatureKind::SurfaceRevolve { sketch: sk, profiles: Some(profiles), .. }
| FeatureKind::SurfaceSweep { sketch: sk, profiles: Some(profiles), .. } => {
vec![(*sk, profiles)]
}
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
FeatureKind::SurfaceLoft { sections } => sections.iter_mut().filter_map(|s| Some((s.sketch, s.profiles.as_mut()?))).collect(),
_ => Vec::new(),
}
}
/// Profile selections and their source sketches without cloning feature data.
pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> {
match self {
FeatureKind::Extrude { sketch, profiles, .. }
| FeatureKind::Revolve { sketch, profiles, .. }
| FeatureKind::Sweep { sketch, profiles, .. }
| FeatureKind::Emboss { sketch, profiles, .. }
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sketch), profiles, .. }
| FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. }
| FeatureKind::SurfaceRevolve { sketch, profiles: Some(profiles), .. }
| FeatureKind::SurfaceSweep { sketch, profiles: Some(profiles), .. } => {
vec![(*sketch, profiles)]
}
FeatureKind::Loft { sections, .. } => {
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, &section.profiles)).collect()
}
FeatureKind::SurfaceLoft { sections } => sections.iter().filter_map(|s| Some((s.sketch, s.profiles.as_ref()?))).collect(),
_ => Vec::new(),
}
}
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
match self {
FeatureKind::Shell { faces, .. }
| FeatureKind::Draft { faces, .. }
| FeatureKind::OffsetFace { faces, .. }
| FeatureKind::DeleteFace { faces, .. }
| FeatureKind::SurfaceOffset { faces, .. }
| FeatureKind::SplitFace { faces, .. }
| FeatureKind::ReplaceFace { faces, .. } => faces.iter_mut().collect(),
FeatureKind::Hole { position, points: None, to, .. } => std::iter::once(position).chain(to.iter_mut()).collect(),
FeatureKind::Hole { to: Some(t), .. } => vec![t],
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
FeatureKind::Thread { face, .. } => vec![face],
FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at],
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
// With bend edges the fixed face is found by name (a plate keeps its point).
FeatureKind::SheetConvert { face, bends, .. } if !bends.is_empty() => vec![face],
_ => Vec::new(),
}
}
pub fn face_points(&self) -> Vec<Vec3> {
let mut k = self.clone();
k.face_points_mut().into_iter().map(|p| *p).collect()
}
pub fn expressions(&self) -> Vec<&str> {
let mut v: Vec<&str> = Vec::new();
match self {
FeatureKind::ConstructionAxis { def } => def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v)),
FeatureKind::ConstructionPoint { def } => {
if let PointDef::AlongPath { t, .. } = def {
v.push(t);
}
def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v));
}
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
v.push(&extent.distance);
if let Some(d) = &extent.distance2 {
v.push(d);
}
if let Some(d) = &extent.start_offset {
v.push(d);
}
if let Some(d) = &extent.taper {
v.push(d);
}
if let Some(d) = &extent.to_offset {
v.push(d);
}
}
FeatureKind::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { angle, angle2, .. } => {
v.push(angle);
v.extend(angle2.iter().map(String::as_str));
}
FeatureKind::Fillet { radius, style, .. } => {
v.push(radius);
match style {
FilletStyle::Variable { radius2, mid, .. } => {
v.push(radius2);
v.extend(mid.iter().map(|(_, r)| r.as_str()));
}
FilletStyle::Spline { radii, .. } => v.extend(radii.iter().map(String::as_str)),
FilletStyle::Constant | FilletStyle::Chord | FilletStyle::Curvature => {}
}
}
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(distance);
v.extend(distance2.iter().map(String::as_str));
v.extend(angle.iter().map(String::as_str));
}
FeatureKind::Box { length, width, height, corner, .. } => {
v.extend([length.as_str(), width, height]);
v.extend(corner.iter().map(String::as_str));
}
FeatureKind::Cylinder { radius, height, base, .. } => {
v.extend([radius.as_str(), height]);
v.extend(base.iter().map(String::as_str));
}
FeatureKind::Sphere { radius, center, .. } => {
v.push(radius);
v.extend(center.iter().map(String::as_str));
}
FeatureKind::Torus { major, minor, center, .. } => {
v.extend([major.as_str(), minor]);
v.extend(center.iter().map(String::as_str));
}
FeatureKind::Combine { .. } => {}
FeatureKind::Pattern { pattern, .. } => match pattern {
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
v.extend([count1.as_str(), spacing1]);
v.extend(count2.iter().map(String::as_str));
v.extend(spacing2.iter().map(String::as_str));
}
PatternKind::Circular { count, angle, .. } => v.extend([count.as_str(), angle]),
PatternKind::Path { count, spacing, .. } => v.extend([count.as_str(), spacing]),
},
FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::Shell { thickness, .. } => v.push(thickness),
FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::SurfaceSweep { .. } | FeatureKind::SurfaceLoft { .. } => {}
FeatureKind::Draft { angle, neutral, .. } => {
v.push(angle);
plane_exprs(neutral, &mut v);
}
FeatureKind::Hole { diameter, depth, hole, .. } => {
v.push(diameter);
v.extend(depth.iter().map(String::as_str));
match hole {
HoleKind::Simple => {}
HoleKind::Drilled { tip_angle } => v.push(tip_angle),
HoleKind::Counterbore { cb_diameter, cb_depth } => v.extend([cb_diameter.as_str(), cb_depth]),
HoleKind::Countersink { cs_diameter, cs_angle } => v.extend([cs_diameter.as_str(), cs_angle]),
}
}
FeatureKind::Thread { length, .. } => v.extend(length.iter().map(String::as_str)),
FeatureKind::Scale { factor, factors, .. } => {
v.push(factor);
v.extend(factors.iter().flatten().map(String::as_str));
}
FeatureKind::OffsetFace { distance, .. } => v.push(distance),
FeatureKind::BoundingSolid { margin, .. } => v.push(margin),
FeatureKind::Pipe { diameter, wall, .. } => {
v.push(diameter);
v.extend(wall.iter().map(String::as_str));
}
FeatureKind::Move { translate, angle, .. } => {
v.extend(translate.iter().map(String::as_str));
if let Some(a) = angle {
v.push(a);
}
}
FeatureKind::Emboss { depth, .. } => v.push(depth),
FeatureKind::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, .. } => {
v.extend([diameter.as_str(), height]);
for o in [hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset].into_iter().flatten() {
v.push(o);
}
}
FeatureKind::Lip { width, height, gap, .. } => {
v.extend([width.as_str(), height]);
v.extend(gap.iter().map(String::as_str));
}
FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => {
v.extend([length.as_str(), thickness, width, catch_depth, catch_length]);
}
FeatureKind::Rest { width, length, height, draft, thickness, .. } => {
v.extend([width.as_str(), height]);
v.extend([length, draft, thickness].into_iter().flatten().map(String::as_str));
}
FeatureKind::SheetContour { distance, .. } => v.push(distance),
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
v.extend([height.as_str(), angle]);
v.extend(radius.iter().map(String::as_str));
for e in edges {
v.extend([&e.angle, &e.height].into_iter().flatten().map(String::as_str));
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
v.push(angle);
v.extend(radius.iter().map(String::as_str));
}
FeatureKind::SheetHem { length, gap, .. } => {
v.push(length);
v.extend(gap.iter().map(String::as_str));
}
FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {}
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => {
v.extend([diameter.as_str(), pitch, turns, section_size]);
v.extend(start_angle.iter().map(String::as_str));
v.extend(base.iter().map(String::as_str));
}
FeatureKind::Rib { thickness, depth, .. } => {
v.push(thickness);
v.extend(depth.iter().map(String::as_str));
}
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
FeatureKind::SurfaceOffset { distance, tolerance, .. } => {
v.push(distance);
if let Some(t) = tolerance {
v.push(t);
}
}
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
FeatureKind::MeshConvert { tolerance, .. } => v.push(tolerance),
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {}
FeatureKind::Patch { .. } => {}
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {}
}
v
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -1974,6 +1709,9 @@ pub struct Visibility {
pub hidden_sketches: Vec<u64>,
/// Finished sketches shown although a feature uses them.
pub shown_sketches: Vec<u64>,
/// Assembly joints by id: their glyphs are not drawn (they still constrain and move).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hidden_joints: Vec<u64>,
}
impl Visibility {
@@ -1984,6 +1722,7 @@ impl Visibility {
&& self.hidden_origin.is_empty()
&& self.hidden_sketches.is_empty()
&& self.shown_sketches.is_empty()
&& self.hidden_joints.is_empty()
}
}
@@ -2232,7 +1971,7 @@ impl Document {
return Err(DocError::Invalid("too many components".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 name = match name.map(str::trim) {
Some(n) if !n.is_empty() => n.to_string(),
_ => {
@@ -2344,7 +2083,7 @@ impl Document {
0
};
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 name = match name {
Some(n) if !n.trim().is_empty() => n.trim().to_string(),
_ => self.unique_name(kind.base_name()),
@@ -2363,6 +2102,79 @@ impl Document {
shell_piece_order: false,
kind,
};
self.place_feature(f);
Ok(id)
}
/// Insert a feature as given, keeping its id, name, parameter names and stored edge and
/// face names, at the marker (or the end), with the model parameters its sketch dimensions
/// use: a feature replayed from a script (`timeline.add`) is the feature the design had.
/// An id of 0 or an empty name gets a new one. Refused, changing nothing, when the id, the
/// name or a parameter name is already used, or the feature belongs to a component this
/// design does not have.
pub fn insert_feature(&mut self, mut f: Feature, params: Vec<Param>) -> Result<u64> {
if self.features.len() >= MAX_FEATURES {
return Err(DocError::Invalid("too many features".into()));
}
if f.id == 0 {
f.id = self.next_id.max(1);
} else if f.id > MAX_FEATURE_ID {
return Err(DocError::Invalid(format!("feature id {} is out of range", f.id)));
} else if self.feature(f.id).is_some() {
return Err(DocError::Invalid(format!("feature id {} is already used", f.id)));
}
f.name = f.name.trim().to_string();
if f.name.is_empty() {
f.name = self.unique_name(f.kind.base_name());
} else if self.features.iter().any(|x| x.name == f.name) {
return Err(DocError::Invalid(format!("a feature named `{}` already exists", f.name)));
}
if f.component != 0 && !self.components.iter().any(|c| c.id == f.component) {
return Err(DocError::Invalid(format!("feature `{}` belongs to an unknown component", f.name)));
}
if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &f.kind {
crate::validate_body_transfer(self, bodies, *from, *to, transform)?;
if f.component != *to {
return Err(DocError::Invalid("a body transfer belongs to its destination component".into()));
}
}
let mut own = std::collections::BTreeSet::new();
for n in f.param_names.iter().filter(|n| !n.is_empty()) {
if let Some(why) = Self::param_name_problem(n) {
return Err(DocError::Invalid(why));
}
if self.name_taken(n) || !own.insert(n) {
return Err(DocError::Invalid(format!("parameter name `{n}` is already used")));
}
}
if self.params.len().saturating_add(params.len()) > MAX_PARAMS {
return Err(DocError::Invalid("too many parameters".into()));
}
for p in &params {
if let Some(why) = Self::param_name_problem(&p.name) {
return Err(DocError::Invalid(why));
}
if self.name_taken(&p.name) || !own.insert(&p.name) {
return Err(DocError::Invalid(format!("parameter name `{}` is already used", p.name)));
}
if p.expr.len() > 4096 || p.comment.len() > 4096 {
return Err(DocError::Expr(format!("parameter `{}`: expression or comment too long", p.name)));
}
if !p.unit.is_empty() && expr::unit_info(&p.unit).is_none() {
return Err(DocError::Invalid(format!("parameter `{}`: unknown unit `{}`", p.name, p.unit)));
}
}
let id = f.id;
self.next_id = self.next_id.max(id.saturating_add(1));
self.params.extend(params.into_iter().map(|p| Param { model: true, ..p }));
self.place_feature(f);
Ok(id)
}
/// Put a feature at the marker (or the end), in the timeline folder that spans that slot,
/// and name its inputs.
fn place_feature(&mut self, f: Feature) {
let id = f.id;
let insertion_group = self.marker.and_then(|marker| self.timeline_group_at_insertion(marker));
match self.marker {
Some(m) if m < self.features.len() => {
@@ -2380,7 +2192,6 @@ impl Document {
}
}
self.name_feature_inputs(id);
Ok(id)
}
/// Delete a feature (and, for a sketch, the features that use it).
@@ -2551,8 +2362,14 @@ impl Document {
/// Evaluate an expression against the document's parameters.
pub fn eval(&self, e: &str, kind: Kind) -> Result<f64> {
let (vals, _) = self.param_values();
Self::eval_in(&vals, e, kind)
// The parameters are evaluated only when the expression names one: most expressions an
// agent sends are plain values, and evaluating every parameter of a large design for
// each of them made every new feature cost more than the last.
let vals: std::cell::OnceCell<std::collections::BTreeMap<String, expr::Value>> = std::cell::OnceCell::new();
expr::eval_with(e, &|n| {
vals.get_or_init(|| self.param_values().0).get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")))
})?
.to_kind(kind)
}
pub fn eval_in(vals: &std::collections::BTreeMap<String, expr::Value>, e: &str, kind: Kind) -> Result<f64> {
+213 -13
View File
@@ -31,6 +31,9 @@ pub struct ModelBody {
/// 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>>>>,
/// 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>>>>,
/// 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,
@@ -59,6 +62,7 @@ impl ModelBody {
feature,
mesh: Arc::new(OnceLock::new()),
plan: Arc::new(OnceLock::new()),
plan_mesh: Arc::new(OnceLock::new()),
key: 0,
names: Default::default(),
made: Default::default(),
@@ -98,6 +102,14 @@ 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.
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()
}
/// The display mesh's chord tolerance: 1/1000 of the body's size.
fn display_tol(&self) -> f64 {
(self.body.size() * 1e-3).max(1e-3)
@@ -264,6 +276,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
@@ -1189,6 +1219,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()),
@@ -1759,6 +1791,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> {
@@ -1920,6 +1972,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
@@ -2483,6 +2553,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;
@@ -2496,13 +2595,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.
@@ -2552,13 +2654,51 @@ 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;
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 && matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets)
{
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 {
@@ -3770,6 +3910,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),
@@ -4157,23 +4298,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("; "));
@@ -4609,9 +4755,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();
@@ -4641,8 +4839,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;
@@ -4690,6 +4889,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
}
}
}
*placed_before = placed_now;
out
}
+41 -4
View File
@@ -485,6 +485,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)?;
@@ -590,11 +616,22 @@ pub(super) fn sheet_eval(
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
+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();
+381
View File
@@ -0,0 +1,381 @@
//! 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.
//! One walk, written once for `&` and `&mut`, behind `transform_geometry`, `face_points`,
//! `profile_sels` and `point_expr`. Every variant is listed, so a new one has to say what each of
//! its points is.
use solidcraft_geom::Vec3;
use crate::construct::GeoRef;
use crate::{AxisRef, FeatureKind, PatternKind, PlaneRef, 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),
}
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>;
/// Every piece of a feature's geometry, written once for `&` and `&mut`: `$item` is `GeoItem` or
/// `GeoItemMut`, `$refs` the matching `refs` / `refs_mut`, `$m` is `mut` or nothing. Face picks
/// come in the order the feature's face names follow.
macro_rules! geometry_walk {
($name:ident, $item:ident, $refs:ident $(, $m:tt)?) => {
pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut($item<'a>)) {
use Geo::{Construct, Dir, Pick, Pinned, Placement, Plane, Pos, Profiles};
match self {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => f(Plane(plane)),
FeatureKind::ConstructionAxis { def } => {
for r in def.$refs() {
f(Construct(r));
}
}
FeatureKind::ConstructionPoint { def } => {
for r in def.$refs() {
f(Construct(r));
}
}
FeatureKind::Extrude { sketch, profiles, extent, thin, .. } => {
// A thin extrude follows curves; its profiles are not used.
if thin.is_none() {
f(Profiles(*sketch, profiles));
}
if let Some(p) = & $($m)? extent.to {
f(Pick(p));
}
if let Some(p) = & $($m)? extent.from {
f(Pick(p));
}
}
// Surface Extrude refuses `to` and `from` for now; they would pick faces as for
// Extrude.
FeatureKind::SurfaceExtrude { sketch, profiles, extent, .. } => {
if let Some(s) = profiles {
f(Profiles(*sketch, s));
}
if let Some(p) = & $($m)? extent.to {
f(Pick(p));
}
if let Some(p) = & $($m)? extent.from {
f(Pick(p));
}
}
FeatureKind::Revolve { sketch, profiles, axis, to, .. } => {
f(Profiles(*sketch, profiles));
if let AxisRef::Line { origin, dir } = axis {
f(Pos(origin));
f(Dir(dir));
}
if let Some(p) = to {
f(Pick(p));
}
}
FeatureKind::SurfaceRevolve { sketch, profiles, axis, .. } => {
if let Some(s) = profiles {
f(Profiles(*sketch, s));
}
if let AxisRef::Line { origin, dir } = axis {
f(Pos(origin));
f(Dir(dir));
}
}
FeatureKind::Sweep { sketch, profiles, .. } | FeatureKind::SheetBase { sketch, profiles, .. } => f(Profiles(*sketch, profiles)),
FeatureKind::SurfaceSweep { sketch, profiles, .. } => {
if let Some(s) = profiles {
f(Profiles(*sketch, s));
}
}
FeatureKind::Emboss { sketch, profiles, face, .. } => {
f(Profiles(*sketch, profiles));
if let Some(face) = face {
f(Pinned(& $($m)? face.at));
}
}
FeatureKind::Loft { sections, .. } => {
// A section that is a point has no profile.
for s in sections {
if s.point.is_none() {
f(Profiles(s.sketch, & $($m)? s.profiles));
}
}
}
FeatureKind::SurfaceLoft { sections } => {
for s in sections {
if let Some(p) = & $($m)? s.profiles {
f(Profiles(s.sketch, p));
}
}
}
FeatureKind::Patch { sketch, profiles, edges, .. } => {
if let Some(sketch) = sketch {
f(Profiles(*sketch, profiles));
}
for e in edges {
f(Pos(e));
}
}
FeatureKind::Fillet { edges, style, .. } => {
for e in edges {
f(Pos(e));
}
if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style {
f(Pos(start));
}
}
FeatureKind::Chamfer { edges, .. } | FeatureKind::SheetHem { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => {
for e in edges {
f(Pos(e));
}
}
FeatureKind::Shell { faces, .. }
| FeatureKind::OffsetFace { faces, .. }
| FeatureKind::DeleteFace { faces, .. }
| FeatureKind::SurfaceOffset { faces, .. } => {
for p in faces {
f(Pick(p));
}
}
FeatureKind::Unstitch { faces, .. } | FeatureKind::ReverseNormal { faces, .. } => {
for p in faces {
f(Pos(p));
}
}
FeatureKind::BoundaryFill { cells, .. } => {
for p in cells {
f(Pos(p));
}
}
FeatureKind::Draft { faces, neutral, pull, .. } => {
for p in faces {
f(Pick(p));
}
f(Plane(neutral));
f(Dir(pull));
}
FeatureKind::ReplaceFace { faces, target, .. } => {
for p in faces {
f(Pick(p));
}
f(Plane(target));
}
FeatureKind::SplitFace { faces, plane, tool, .. } => {
for p in faces {
f(Pick(p));
}
f(Plane(plane));
if let Some(t) = tool {
f(Pos(& $($m)? t.point));
}
}
FeatureKind::Split { plane, tool, .. } => {
f(Plane(plane));
if let Some(t) = tool {
f(Pos(& $($m)? t.point));
}
}
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
f(Plane(plane));
if let Some(t) = tool {
f(Pos(& $($m)? t.point));
}
f(Pos(keep));
}
FeatureKind::Box { corner: p, .. } | FeatureKind::Sphere { center: p, .. } | FeatureKind::Torus { center: p, .. } => {
f(Placement(p))
}
FeatureKind::Cylinder { base, axis, .. } | FeatureKind::Coil { base, axis, .. } => {
f(Placement(base));
f(Dir(axis));
}
FeatureKind::Pattern { pattern, .. } => match pattern {
PatternKind::Rectangular { dir1, dir2, .. } => {
f(Dir(dir1));
if let Some(d) = dir2 {
f(Dir(d));
}
}
PatternKind::Circular { origin, axis, .. } => {
f(Pos(origin));
f(Dir(axis));
}
// Follows its path sketch.
PatternKind::Path { .. } => {}
},
FeatureKind::Hole { position, direction, points, to, .. } => {
// Placed at sketch points, the hole names no face by its position.
if points.is_none() {
f(Pick(position));
} else {
f(Pos(position));
}
f(Dir(direction));
if let Some(p) = to {
f(Pick(p));
}
}
FeatureKind::Thread { face, .. } => f(Pick(face)),
FeatureKind::SheetFlange { edges, .. } => {
for e in edges {
f(Pos(& $($m)? e.point));
}
}
FeatureKind::SheetFold { a, b, fixed, .. } => {
f(Pos(a));
f(Pos(b));
if let Some(p) = fixed {
f(Pos(p));
}
}
FeatureKind::SheetConvert { face, bends, .. } => {
// With bend edges the fixed face is found by name (a plate keeps its point).
if bends.is_empty() {
f(Pos(face));
} else {
f(Pick(face));
}
for p in bends {
f(Pos(p));
}
}
FeatureKind::Boss { position, direction, .. } => {
f(Pick(position));
f(Dir(direction));
}
FeatureKind::SnapFit { position, direction, hook, .. } => {
f(Pick(position));
f(Dir(direction));
f(Dir(hook));
}
FeatureKind::Rest { position, direction, along, .. } => {
f(Pick(position));
f(Dir(direction));
if let Some(a) = along {
f(Dir(a));
}
}
FeatureKind::Lip { face, .. } => f(Pos(face)),
FeatureKind::Align { from, to, from_normal, to_normal, .. } => {
f(Pos(from));
f(Pos(to));
if let Some(n) = from_normal {
f(Dir(n));
}
if let Some(n) = to_normal {
f(Dir(n));
}
}
FeatureKind::Scale { origin, .. } => f(Pos(origin)),
FeatureKind::Move { rotate_axis, .. } => {
if let Some(a) = rotate_axis {
f(Dir(a));
}
}
FeatureKind::SheetContour { .. }
| FeatureKind::SheetPerforate { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::Combine { .. }
| FeatureKind::BoundingSolid { .. }
| FeatureKind::Pipe { .. }
| FeatureKind::Rib { .. }
| FeatureKind::Remove { .. }
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. }
| FeatureKind::MeshConvert { .. }
| FeatureKind::BodyTransfer { .. } => {}
}
}
};
}
impl FeatureKind {
geometry_walk!(walk_geometry, GeoItem, refs);
geometry_walk!(walk_geometry_mut, GeoItemMut, refs_mut, mut);
/// 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
}
}
+272
View File
@@ -0,0 +1,272 @@
//! 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). One walk, written once for `&` and `&mut`,
//! behind `input_sketches`, `remap_refs` and `remap_bodies`. Every variant is listed, so a new
//! one has to say what it refers to.
use std::collections::BTreeMap;
use crate::{FeatureKind, PatternKind, PlaneRef};
/// 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),
}
/// The construction plane a plane is built on (through offsets and angles), for `&` and `&mut`
/// (`$m` is `mut` or nothing). Nesting is capped like every walk over a plane.
macro_rules! plane_walk {
($name:ident $(, $m:tt)?) => {
fn $name<'a>(mut p: &'a $($m)? PlaneRef, f: &mut impl FnMut(Ref<&'a $($m)? u64, &'a $($m)? String>)) {
for _ in 0..64 {
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
_ => return,
}
}
}
};
}
plane_walk!(plane_refs);
plane_walk!(plane_refs_mut, mut);
/// Every reference of a feature, written once for `&` and `&mut`: `$plane` is the matching plane
/// walk, `$m` is `mut` or nothing.
macro_rules! feature_walk {
($name:ident, $plane:ident $(, $m:tt)?) => {
pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut(Ref<&'a $($m)? u64, &'a $($m)? String>)) {
use Ref::{Body as B, Feature as F, Sketch as S};
match self {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } => $plane(plane, f),
FeatureKind::Extrude { sketch, targets, .. } | FeatureKind::Revolve { sketch, targets, .. } => {
f(S(sketch));
for b in targets {
f(B(b));
}
}
FeatureKind::Emboss { sketch, targets, face, .. } => {
f(S(sketch));
if let Some(face) = face {
f(B(& $($m)? face.body));
}
for b in targets {
f(B(b));
}
}
FeatureKind::SurfaceExtrude { sketch, .. }
| FeatureKind::SurfaceRevolve { sketch, .. }
| FeatureKind::Rib { sketch, .. }
| FeatureKind::SheetBase { sketch, .. }
| FeatureKind::SheetContour { sketch, .. } => f(S(sketch)),
FeatureKind::SurfaceSweep { sketch, path_sketch, .. } => {
f(S(sketch));
f(S(path_sketch));
}
FeatureKind::SurfaceLoft { sections } => {
for s in sections {
f(S(& $($m)? s.sketch));
}
}
FeatureKind::Sweep { sketch, path_sketch, targets, .. } => {
f(S(sketch));
f(S(path_sketch));
for b in targets {
f(B(b));
}
}
FeatureKind::Pipe { path_sketch, targets, .. } => {
f(S(path_sketch));
for b in targets {
f(B(b));
}
}
FeatureKind::Loft { sections, targets, .. } => {
for s in sections {
f(S(& $($m)? s.sketch));
}
for b in targets {
f(B(b));
}
}
FeatureKind::Coil { targets, .. } => {
for b in targets {
f(B(b));
}
}
FeatureKind::Hole { points, .. } => {
if let Some(p) = points {
f(S(& $($m)? p.sketch));
}
}
FeatureKind::Pattern { features, pattern, bodies } => {
for n in features {
f(F(n));
}
if let PatternKind::Path { path_sketch, .. } = pattern {
f(S(path_sketch));
}
for b in bodies {
f(B(b));
}
}
FeatureKind::Mirror { features, plane, bodies, .. } => {
for n in features {
f(F(n));
}
$plane(plane, f);
for b in bodies {
f(B(b));
}
}
FeatureKind::Split { body, plane, tool } | FeatureKind::SurfaceTrim { body, plane, tool, .. } => {
f(B(body));
$plane(plane, f);
if let Some(t) = tool {
f(B(& $($m)? t.body));
}
}
FeatureKind::SplitFace { body, plane, tool, sketch, .. } => {
$plane(plane, f);
if let Some(s) = sketch {
f(S(s));
}
if let Some(b) = body {
f(B(b));
}
if let Some(t) = tool {
f(B(& $($m)? t.body));
}
}
FeatureKind::Draft { neutral: plane, body, .. } | FeatureKind::ReplaceFace { target: plane, body, .. } => {
$plane(plane, f);
if let Some(b) = body {
f(B(b));
}
}
FeatureKind::SheetFold { sketch, body, .. } | FeatureKind::Patch { sketch, body, .. } => {
if let Some(s) = sketch {
f(S(s));
}
if let Some(b) = body {
f(B(b));
}
}
FeatureKind::Fillet { body, .. }
| FeatureKind::Chamfer { body, .. }
| FeatureKind::Shell { body, .. }
| FeatureKind::OffsetFace { body, .. }
| FeatureKind::DeleteFace { body, .. }
| FeatureKind::SheetFlange { body, .. }
| FeatureKind::SheetHem { body, .. }
| FeatureKind::SheetPerforate { body, .. }
| FeatureKind::SheetUnfold { body, .. }
| FeatureKind::Boss { body, .. }
| FeatureKind::Lip { body, .. }
| FeatureKind::SnapFit { body, .. }
| FeatureKind::Rest { body, .. }
| FeatureKind::SurfaceOffset { body, .. } => {
if let Some(b) = body {
f(B(b));
}
}
FeatureKind::SheetConvert { body, .. }
| FeatureKind::SurfaceExtend { body, .. }
| FeatureKind::Unstitch { body, .. }
| FeatureKind::ReverseNormal { body, .. }
| FeatureKind::MeshConvert { body, .. } => f(B(body)),
FeatureKind::Combine { target, tools, .. } => {
f(B(target));
for b in tools {
f(B(b));
}
}
FeatureKind::Thicken { bodies, targets, .. } => {
for b in bodies {
f(B(b));
}
for b in targets {
f(B(b));
}
}
FeatureKind::BodyTransfer { bodies, .. }
| FeatureKind::Scale { bodies, .. }
| FeatureKind::BoundingSolid { bodies, .. }
| FeatureKind::Align { bodies, .. }
| FeatureKind::Remove { bodies }
| FeatureKind::Stitch { bodies, .. }
| FeatureKind::Move { bodies, .. } => {
for b in bodies {
f(B(b));
}
}
FeatureKind::BoundaryFill { tools, .. } => {
for b in tools {
f(B(b));
}
}
// Construction axes and points refer to geometry, which copies remap on their own
// (`Feature::remap_clone`).
FeatureKind::ConstructionAxis { .. }
| FeatureKind::ConstructionPoint { .. }
| FeatureKind::Box { .. }
| FeatureKind::Cylinder { .. }
| FeatureKind::Sphere { .. }
| FeatureKind::Torus { .. }
| FeatureKind::Thread { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. } => {}
}
}
};
}
impl FeatureKind {
feature_walk!(walk_refs, plane_refs);
feature_walk!(walk_refs_mut, plane_refs_mut, mut);
/// 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");
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,245 @@
//! 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"]})),
("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_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_extend", json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance"})),
("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" | "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,349 @@
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
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_loft
surface_offset
inputs:
Distance = soff.distance (Length)
Tolerance = soff.tolerance (Length)
expressions: soff.distance, soff.tolerance
unstitch
reverse_normal
surface_extend
inputs:
Distance = extend.distance (Length)
expressions: extend.distance
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,161 @@
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
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_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_extend
moves: edges[0][0], edges[0][1], edges[0][2]
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,181 @@
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]
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_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_extend
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
+121 -2
View File
@@ -138,13 +138,28 @@
//! 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.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 19;
pub const FORMAT: u32 = 20;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -161,7 +176,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; 19] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -180,6 +195,7 @@ const JSON_STEPS: [JsonStep; 18] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
@@ -455,11 +471,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 +499,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 +530,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(())
}
@@ -1031,6 +1090,7 @@ mod tests {
document
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch,
profiles: ProfileSel::All,
extent: Extent {
@@ -1594,4 +1654,63 @@ 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"));
}
}
+1
View File
@@ -423,6 +423,7 @@ 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 {
+520 -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,42 @@ 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: a chain of this many links carries a value through in any order.
const MAX_LINK_HOPS: usize = 4;
/// Motion links in order (a few passes for chains), then the linked values' limits.
fn apply_links(asm: &Assembly, vals: &mut Values) {
for _ in 0..MAX_LINK_HOPS {
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;
}
}
}
for l in &asm.links {
if let (Some(j), Some(q)) = (asm.joints.iter().find(|j| j.id == l.b), vals.get_mut(&l.b)) {
clamp(j, q);
}
}
(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 +380,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 +389,450 @@ 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_HOPS {
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();
// Each loop: its closing joint and the joints on the tree paths between its sides; loops that
// share a joint (or a value feeding them through links) are solved together.
let mut groups: Vec<(Vec<u64>, BTreeSet<u64>, BTreeSet<(u64, usize)>)> = 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));
}
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)
}
/// 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 starts = link_chains(asm, tree, around, linked).1;
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 starts {
if !held.contains_key(&v) && !vars.contains(&v) {
vars.push(v);
}
}
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();
// Turns weigh like moves through points a loop's size apart.
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));
}
let scale = if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 };
// 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);
for id in closing {
let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?);
let tb = tree.world(j.b.occurrence, &work)?;
let want = tree.step(*id, true, &tree.world(j.a.occurrence, &work)?, &work)?;
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]);
}
}
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.
fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>, bounds: &[(f64, f64)], tol: f64) -> bool {
const H: f64 = 1e-6;
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 mut jac = vec![0.0; m * n];
for k in 0..n {
let (mut xp, mut xm) = (x.clone(), x.clone());
if let (Some(p), Some(q)) = (xp.get_mut(k), xm.get_mut(k)) {
*p += H;
*q -= H;
}
let (Some(rp), Some(rm)) = (f(&xp), f(&xm)) else { return false };
for (i, (a, b)) in rp.iter().zip(&rm).enumerate() {
if let Some(slot) = jac.get_mut(i * n + k) {
*slot = (a - b) / (2.0 * H);
}
}
}
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 +860,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,27 +881,45 @@ 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()));
}
}
for o in &doc.occurrences {
@@ -472,10 +932,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);
@@ -536,4 +1002,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}");
}
}
+7 -2
View File
@@ -19,6 +19,8 @@ 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;
@@ -47,9 +49,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 +74,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)]
+55 -1
View File
@@ -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);
@@ -760,6 +775,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
+384 -298
View File
@@ -11,334 +11,408 @@ use serde::{Deserialize, Serialize};
use crate::expr::{self, Kind, ParamDef, Value};
use crate::{DocError, Document, FeatureKind, HoleKind, MAX_PARAMS, PatternKind, PlaneRef, 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);
/// The offsets and angles of a plane, outermost first, for `&` and `&mut` (`$m` is `mut` or
/// nothing). Nesting is capped like every walk over a plane.
macro_rules! plane_walk {
($name:ident $(, $m:tt)?) => {
fn $name<'a>(mut p: &'a $($m)? PlaneRef, f: &mut impl FnMut(&'static str, &'a $($m)? String, Kind, ExprRole)) {
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, .. } => {
f("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input);
break;
}
_ => break,
}
}
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() {
}
};
}
plane_walk!(plane_exprs);
plane_walk!(plane_exprs_mut, mut);
/// Every expression of a feature with its label, kind and role, in a fixed order (the inputs'
/// order names their parameters), written once for `&` and `&mut`: `$plane` is the matching plane
/// walk, `$refs` the matching `refs`/`refs_mut`, `$m` is `mut` or nothing.
macro_rules! feature_walk {
($name:ident, $plane:ident, $refs:ident $(, $m:tt)?) => {
pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut(&'static str, &'a $($m)? String, Kind, ExprRole)) {
use ExprRole::{Input as I, Placement as P};
use Kind::{Angle as A, Length as L, Unitless as U};
const XYZ: [&str; 3] = ["X", "Y", "Z"];
match self {
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. }
| FeatureKind::Mirror { plane, .. } => $plane(plane, f),
FeatureKind::ConstructionAxis { def } => {
for r in def.$refs() {
if let crate::construct::GeoRef::Plane { plane } = r {
plane_inputs_mut(plane, &mut v);
$plane(plane, f);
}
}
}
},
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));
FeatureKind::ConstructionPoint { def } => match def {
crate::construct::PointDef::AlongPath { t, .. } => f("Distance (0 to 1)", t, U, I),
other => {
for r in other.$refs() {
if let crate::construct::GeoRef::Plane { plane } = r {
$plane(plane, f);
}
}
}
if let Some(sp) = spacing2 {
v.push(("Spacing 2", sp, L));
},
FeatureKind::Extrude { extent, thin, .. } => {
f("Distance", & $($m)? extent.distance, L, I);
if let Some(d) = & $($m)? extent.distance2 {
f("Distance 2", d, L, I);
}
if let Some(d) = & $($m)? extent.start_offset {
f("Start offset", d, L, I);
}
if let Some(d) = & $($m)? extent.taper {
f("Taper angle", d, A, I);
}
if let Some(d) = & $($m)? extent.to_offset {
f("To offset", d, L, I);
}
if let Some(thin) = thin {
f("Thickness", & $($m)? thin.thickness, L, I);
if let Some(t) = & $($m)? thin.thickness2 {
f("Thickness 2", t, L, I);
}
}
}
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));
FeatureKind::SurfaceExtrude { extent, .. } => {
f("Distance", & $($m)? extent.distance, L, I);
if let Some(d) = & $($m)? extent.distance2 {
f("Distance 2", d, L, I);
}
HoleKind::Countersink { cs_diameter, cs_angle } => {
v.push(("Countersink diameter", cs_diameter, L));
v.push(("Countersink angle", cs_angle, A));
if let Some(d) = & $($m)? extent.start_offset {
f("Start offset", d, L, I);
}
if let Some(d) = & $($m)? extent.taper {
f("Taper angle", d, A, I);
}
if let Some(d) = & $($m)? extent.to_offset {
f("To offset", d, L, I);
}
}
}
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::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { angle, angle2, .. } => {
f("Angle", angle, A, I);
if let Some(a) = angle2 {
f("Angle 2", a, A, I);
}
}
}
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::Fillet { radius, style, .. } => match style {
crate::FilletStyle::Constant => f("Radius", radius, L, I),
crate::FilletStyle::Curvature => f("Equivalent radius", radius, L, I),
crate::FilletStyle::Chord => f("Chord length", radius, L, I),
crate::FilletStyle::Variable { radius2, mid, .. } => {
f("Start radius", radius, L, I);
f("End radius", radius2, L, I);
for (_, r) in mid {
f("Mid radius", r, L, I);
}
}
crate::FilletStyle::Spline { radii, .. } => {
f("Start radius", radius, L, I);
let n = radii.len();
for (i, r) in radii.into_iter().enumerate() {
f(if i + 1 == n { "End radius" } else { "Control radius" }, r, L, I);
}
}
},
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
f("Distance", distance, L, I);
if let Some(d) = distance2 {
f("Distance 2", d, L, I);
}
if let Some(a) = angle {
f("Angle", a, A, I);
}
}
}
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::Box { length, width, height, corner, .. } => {
f("Length", length, L, I);
f("Width", width, L, I);
f("Height", height, L, I);
for (l, c) in XYZ.into_iter().zip(corner) {
f(l, c, L, P);
}
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
v.push(("Angle", angle, A));
if let Some(r) = radius {
v.push(("Bend radius", r, L));
FeatureKind::Cylinder { radius, height, base, .. } => {
f("Radius", radius, L, I);
f("Height", height, L, I);
for (l, c) in XYZ.into_iter().zip(base) {
f(l, c, L, P);
}
}
}
FeatureKind::SheetHem { length, gap, .. } => {
v.push(("Length", length, L));
if let Some(g) = gap {
v.push(("Gap", g, L));
FeatureKind::Sphere { radius, center, .. } => {
f("Radius", radius, L, I);
for (l, c) in XYZ.into_iter().zip(center) {
f(l, c, L, P);
}
}
}
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::Torus { major, minor, center, .. } => {
f("Major radius", major, L, I);
f("Minor radius", minor, L, I);
for (l, c) in XYZ.into_iter().zip(center) {
f(l, c, L, P);
}
}
}
FeatureKind::Rib { thickness, depth, .. } => {
v.push(("Thickness", thickness, L));
if let Some(d) = depth {
v.push(("Depth", d, L));
FeatureKind::Pattern { pattern, .. } => match pattern {
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
f("Quantity", count1, U, I);
f("Spacing", spacing1, L, I);
if let Some(c) = count2 {
f("Quantity 2", c, U, I);
}
if let Some(sp) = spacing2 {
f("Spacing 2", sp, L, I);
}
}
PatternKind::Path { count, spacing, .. } => {
f("Quantity", count, U, I);
f("Distance", spacing, L, I);
}
PatternKind::Circular { count, angle, .. } => {
f("Quantity", count, U, I);
f("Total angle", angle, A, I);
}
},
FeatureKind::Shell { thickness, .. } => f("Thickness", thickness, L, I),
FeatureKind::Draft { angle, neutral, .. } => {
f("Angle", angle, A, I);
$plane(neutral, f);
}
}
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::Hole { diameter, depth, hole, .. } => {
f("Diameter", diameter, L, I);
if let Some(d) = depth {
f("Depth", d, L, I);
}
match hole {
HoleKind::Simple => {}
HoleKind::Drilled { tip_angle } => f("Tip angle", tip_angle, A, I),
HoleKind::Counterbore { cb_diameter, cb_depth } => {
f("Counterbore diameter", cb_diameter, L, I);
f("Counterbore depth", cb_depth, L, I);
}
HoleKind::Countersink { cs_diameter, cs_angle } => {
f("Countersink diameter", cs_diameter, L, I);
f("Countersink angle", cs_angle, A, I);
}
}
}
FeatureKind::Thread { length, .. } => {
if let Some(l) = length {
f("Length", l, L, I);
}
}
FeatureKind::Scale { factor, factors, .. } => {
f("Scale factor", factor, U, I);
if let Some([x, y, z]) = factors {
f("X scale", x, U, I);
f("Y scale", y, U, I);
f("Z scale", z, U, I);
}
}
FeatureKind::OffsetFace { distance, .. } => f("Distance", distance, L, I),
FeatureKind::BoundingSolid { margin, .. } => f("Margin", margin, L, I),
FeatureKind::Pipe { diameter, wall, .. } => {
f("Diameter", diameter, L, I);
if let Some(w) = wall {
f("Wall", w, L, I);
}
}
FeatureKind::Move { translate, angle, .. } => {
let [x, y, z] = translate;
f("X distance", x, L, I);
f("Y distance", y, L, I);
f("Z distance", z, L, I);
if let Some(a) = angle {
f("Angle", a, A, I);
}
}
FeatureKind::Emboss { depth, .. } => f("Depth", depth, L, I),
FeatureKind::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, .. } => {
f("Diameter", diameter, L, I);
f("Height", height, L, I);
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 {
f(l, e, k, I);
}
}
}
FeatureKind::Lip { width, height, gap, .. } => {
f("Width", width, L, I);
f("Height", height, L, I);
if let Some(g) = gap {
f("Gap", g, L, I);
}
}
FeatureKind::Rest { width, length, height, draft, thickness, .. } => {
f("Width", width, L, I);
f("Height", height, L, I);
for (l, o, k) in [("Length", length, L), ("Draft angle", draft, A), ("Wall thickness", thickness, L)] {
if let Some(e) = o {
f(l, e, k, I);
}
}
}
FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => {
f("Length", length, L, I);
f("Thickness", thickness, L, I);
f("Width", width, L, I);
f("Catch depth", catch_depth, L, I);
f("Catch length", catch_length, L, I);
}
FeatureKind::SheetContour { distance, .. } => f("Distance", distance, L, I),
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
f("Height", height, L, I);
f("Angle", angle, A, I);
if let Some(r) = radius {
f("Bend radius", r, L, I);
}
// The edges' own values come last, so the inputs above keep their names.
for e in edges {
if let Some(a) = & $($m)? e.angle {
f("Edge angle", a, A, I);
}
if let Some(h) = & $($m)? e.height {
f("Edge height", h, L, I);
}
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
f("Angle", angle, A, I);
if let Some(r) = radius {
f("Bend radius", r, L, I);
}
}
FeatureKind::SheetHem { length, gap, .. } => {
f("Length", length, L, I);
if let Some(g) = gap {
f("Gap", g, L, I);
}
}
FeatureKind::SheetPerforate { diameter, pitch, margin, .. } => {
f("Diameter", diameter, L, I);
f("Pitch", pitch, L, I);
if let Some(m) = margin {
f("Margin", m, L, I);
}
}
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => {
f("Diameter", diameter, L, I);
f("Pitch", pitch, L, I);
f("Revolutions", turns, U, I);
f("Section size", section_size, L, I);
if let Some(a) = start_angle {
f("Start angle", a, A, I);
}
for (l, c) in XYZ.into_iter().zip(base) {
f(l, c, L, P);
}
}
FeatureKind::Rib { thickness, depth, .. } => {
f("Thickness", thickness, L, I);
if let Some(d) = depth {
f("Depth", d, L, I);
}
}
FeatureKind::ReplaceFace { target, .. } => $plane(target, f),
FeatureKind::Stitch { tolerance, .. } => f("Tolerance", tolerance, L, I),
FeatureKind::MeshConvert { tolerance, .. } => f("Tolerance", tolerance, L, I),
FeatureKind::Thicken { thickness, .. } => f("Thickness", thickness, L, I),
FeatureKind::SurfaceTrim { plane, .. } => $plane(plane, f),
FeatureKind::SurfaceExtend { distance, .. } => f("Distance", distance, L, I),
FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {}
FeatureKind::SurfaceOffset { distance, tolerance, .. } => {
f("Distance", distance, L, I);
if let Some(t) = tolerance {
f("Tolerance", t, L, I);
}
}
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 { .. } => {}
}
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 { .. } => {}
}
};
}
impl FeatureKind {
feature_walk!(walk_exprs, plane_exprs, refs);
feature_walk!(walk_exprs_mut, plane_exprs_mut, refs_mut, mut);
/// The feature's numeric inputs: (label, expression, kind), in a fixed order.
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut String, Kind)> {
let mut v = Vec::new();
self.walk_exprs_mut(&mut |label, e, kind, role| {
if role == ExprRole::Input {
v.push((label, e, kind));
}
});
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 v = Vec::new();
self.walk_exprs(&mut |_, e, _, _| v.push(e.as_str()));
v
}
}
@@ -387,16 +461,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 +661,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 {
+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();
+2
View File
@@ -308,6 +308,7 @@ mod tests {
document
.add_feature(
crate::FeatureKind::Extrude {
thin: None,
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
@@ -433,6 +434,7 @@ mod tests {
document
.add_feature(
crate::FeatureKind::Extrude {
thin: None,
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
+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");
}
}
+253 -4
View File
@@ -482,6 +482,9 @@ 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>,
/// Unfolded.
pub flat: bool,
/// Gap between flanges that meet at a corner.
@@ -509,6 +512,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,
@@ -1068,7 +1081,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 +1274,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 +1444,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 +1548,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>> {
@@ -1695,6 +1718,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).
@@ -3844,6 +3958,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
holes,
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -3900,6 +4015,7 @@ 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(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -4640,6 +4756,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 +6950,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};
+2 -35
View File
@@ -29,6 +29,7 @@ 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 {
@@ -120,6 +121,7 @@ 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 {
@@ -342,41 +344,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();
+22 -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,13 @@ 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));
}
+63 -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,7 +513,10 @@ 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);
@@ -499,12 +526,20 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
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 +740,29 @@ 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()]);
}
#[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)"));
}
}
+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));
}
+1
View File
@@ -844,6 +844,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
spec.id,
"sheet.flange"
| "sheet.hem"
| "sheet.perforate"
| "sheet.fold"
| "sheet.unfold"
| "sheet.refold"
+76 -2
View File
@@ -1,7 +1,7 @@
//! 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, ThinSide, ThinWall, expr::Kind, point_expr};
use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch};
@@ -13,7 +13,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; 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?; 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")
@@ -119,6 +119,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(),
@@ -505,10 +510,21 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
None => (feature_sketch(s, p, cmd)?, 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 kind = FeatureKind::Extrude {
sketch,
profiles: match face_profile {
Some(f) => f,
None if thin.is_some() => ProfileSel::All,
None => profiles(p, cmd)?,
},
extent,
@@ -517,10 +533,64 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
_ => operation(p, cmd)?,
},
targets: string_list(p, "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> {
@@ -1696,3 +1766,7 @@ 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;
@@ -729,3 +729,232 @@ fn cutting_a_text_removes_each_letter_whether_its_letters_stand_apart_or_touch()
assert_eq!(s.world_state().bodies.len(), 1);
}
}
/// A text joined onto a block with a taper: its letters are spline and conic outlines whose
/// sides slope, standing on the block's face (run on into it) or hanging over its edge (a
/// straight foot first). An outward taper adds a little more than the straight text; one
/// steeper than the letters' strokes is refused, saying so.
#[test]
fn a_tapered_text_joins_a_block_on_its_face_or_hanging_over_its_edge() {
for (at, hanging) in [([5.0, 12.0], false), ([150.0, 12.0], true)] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[200,40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":-10}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.text", json!({"text":"tres long","at":at,"height":8}));
let state = s.world_state();
let ss = state.sketch(s.active_sketch.unwrap()).unwrap();
// The letters' area, counters left out (a letter's hole is its own profile too).
let sorted = |ids: &[String]| {
let mut ids = ids.to_vec();
ids.sort();
ids
};
let counter = |p: &solidcraft_sketch::Profile| ss.profiles.iter().any(|q| q.hole_curves.iter().any(|h| sorted(h) == sorted(&p.outer_curves)));
let area: f64 = ss.profiles.iter().filter(|p| !counter(p)).map(|p| p.area).sum();
run(&mut s, "sketch.finish", json!({}));
let steep = s.execute("solid.extrude", &json!({"distance":2,"operation":"join","taper":"15 deg"})).unwrap_err();
assert!(steep.to_string().contains("the taper closes the profile"), "{steep}");
run(&mut s, "solid.extrude", json!({"distance":2,"operation":"join","taper":"3 deg"}));
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let (block, text) = (200.0 * 40.0 * 10.0, 2.0 * area);
assert!(volume > block + text && volume < block + 1.3 * text, "hanging {hanging}: {volume} for {text} mm³ of straight text");
assert_eq!(s.world_state().bodies.len(), 1);
}
}
/// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top
/// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front
/// face and in a knife-edge tip where it runs into the back face.
fn knife_edge_part() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0.0, 0.0], "p1": [41.5, -21.0]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "text_at": [36.5, -8.5], "value": "21 mm"}));
run(&mut s, "sketch.line", json!({"infer": true, "points": ["p2", [61.5, -21.0]]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l5"], "text_at": [54.5, -26.5], "value": "21"}));
run(&mut s, "sketch.circle.center", json!({"center": "l5.end", "radius": 21.0}));
run(&mut s, "sketch.line", json!({"infer": true, "points": ["p3", [62.166666666666664, 0.0]]}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l6"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"direction": "positive", "distance": "10 mm", "operation": "new", "profiles": [0, 1], "sketch": 1}));
s
}
/// The volume of the session's bodies through the display plan (the measure's mesh cuts across
/// the concave arc). It holds to about 1e-3 of what a blend removes here; the bounds below are
/// about twice what each test reads.
fn planned(s: &Session) -> f64 {
s.model.state().bodies.iter().map(|b| b.body.display_plan(2e-4).unwrap().evaluate().measure().volume).sum()
}
/// The middle of the part's arc.
const KNIFE_ARC_MID: [f64; 2] = [62.166666666666664 - 21.0 * std::f64::consts::FRAC_1_SQRT_2, -21.0 + 21.0 * std::f64::consts::FRAC_1_SQRT_2];
/// Simpson's rule over the round's corner (2 × 2 less a quarter disc) at distance ρ = 21 + x
/// from the arc's axis, times `length(ρ)`.
fn round_along(length: impl Fn(f64) -> f64) -> f64 {
let (r, n) = (2.0f64, 2000);
let h = r / n as f64;
(0..=n)
.map(|i| {
let x = i as f64 * h;
let w = if i == 0 || i == n { 1.0 } else { [2.0, 4.0][i % 2] };
w * (r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) * length(21.0 + x)
})
.sum::<f64>()
* h
/ 3.0
}
/// Its edges are stored the other way round from their curves, which once chained the arc
/// with the front edge across their corner. Rounding the arc at the bottom and the top removes
/// the round's section along it, cut off at the tip by the back face's plane: ρ asin(21 / ρ).
#[test]
fn fillet_an_arc_that_runs_into_a_knife_edge_tip() {
let mut s = knife_edge_part();
let before = planned(&s);
let [x, y] = KNIFE_ARC_MID;
run(&mut s, "solid.fillet", json!({"edges": [[x, y, 0.0]], "radius": 2}));
run(&mut s, "solid.fillet", json!({"edges": [[x, y, 10.0]], "radius": 2}));
let removed = round_along(|rho| rho * (21.0 / rho).asin());
let after = planned(&s);
assert!(rel(before - after, 2.0 * removed) < 2e-3, "removed {} vs {}", before - after, 2.0 * removed);
}
/// The part's front corner (the front face into the arc) rounded with 2 first, then the bottom
/// run (front edge, the corner round's arc, the arc) with 2 as well: a round on a round of the
/// same radius. Along the front edge a straight round; at the corner the ball turns about one
/// point, a piece of sphere (θ r³ / 6 over the corner's θ); along the arc the torus section from
/// the corner round to the tip.
#[test]
fn fillet_a_run_over_a_corner_rounded_with_the_same_radius() {
let mut s = knife_edge_part();
run(&mut s, "solid.fillet", json!({"edges": [[41.1667, -21.0, 5.0]], "radius": 2}));
let before = planned(&s);
let [x, y] = KNIFE_ARC_MID;
// The corner round: about (62.17 - √(23² - 2²), -19), its arc's middle 2 from there.
let cx = 62.166666666666664 - (23.0f64 * 23.0 - 4.0).sqrt();
let joint = 2.0f64.atan2((23.0f64 * 23.0 - 4.0).sqrt());
let theta = std::f64::consts::FRAC_PI_2 - joint;
let corner_mid = [cx + 2.0 * (theta / 2.0).sin(), -19.0 - 2.0 * (theta / 2.0).cos()];
run(&mut s, "solid.fillet", json!({"edges": [[x, y, 0.0], [corner_mid[0], corner_mid[1], 0.0], [20.0, -21.0, 0.0]], "radius": 2}));
let removed = (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * cx + theta * 8.0 / 6.0 + round_along(|rho| rho * ((21.0 / rho).asin() - joint));
let after = planned(&s);
assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after);
}
/// Straight edges of the part ending at its arc wall: the long front edge at the top (the wall
/// cuts the round off at x = 62.17 − √(21² − d²), d from the front face) and the back edge at
/// the top, which runs into the knife-edge tip (x = 62.17 − √(21² − (21 − d)²), d from the back
/// face).
#[test]
fn fillet_straight_edges_that_end_at_the_arc_wall() {
let cx = 62.166666666666664;
for (edge, cut) in [([20.0, -21.0, 10.0], 0.0), ([30.0, 0.0, 10.0], 21.0)] {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [edge], "radius": 2}));
let removed = round_along(|rho| {
let d = rho - 21.0;
cx - (441.0 - (cut - d) * (cut - d)).sqrt()
});
let after = planned(&s);
assert!(rel(before - after, removed) < 3e-3, "{edge:?}: removed {} vs {removed}", before - after);
}
}
/// The vertical knife edge at the tip (the arc and the back face meeting at no angle) rounds to
/// the ball 2 from both, about 13 back from the tip: the cusp beyond the ball, on the tip's side
/// of its contacts, all the way up.
#[test]
fn fillet_the_knife_edge_at_the_tip() {
let cx = 62.166666666666664;
let (kx, ky) = (cx - (23.0f64 * 23.0 - 19.0 * 19.0).sqrt(), -2.0);
let (hx, hy) = (0.002, 0.0005);
let mut area = 0.0;
let mut x = kx + hx / 2.0;
while x < cx {
let lo = -21.0 + (441.0 - (x - cx) * (x - cx)).max(0.0).sqrt();
for j in 0..((0.0 - lo) / hy).ceil() as usize {
let (y0, y1) = (lo + j as f64 * hy, (lo + (j + 1) as f64 * hy).min(0.0));
let y = (y0 + y1) * 0.5;
let a = (y - ky).atan2(x - kx);
if (x - kx).hypot(y - ky) > 2.0 && a >= (-21.0 - ky).atan2(cx - kx) {
area += (y1 - y0) * hx;
}
}
x += hx;
}
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [[cx, 0.0, 5.0]], "radius": 2}));
let after = planned(&s);
assert!(rel(before - after, area * 10.0) < 5e-4, "removed {} vs {}", before - after, area * 10.0);
let mut s = knife_edge_part();
run(&mut s, "solid.chamfer", json!({"edges": [[cx, 0.0, 5.0]], "distance": 2}));
assert!(before - planned(&s) > before - after, "the chamfer takes the ball's bulge off too");
}
/// Rounds of the edges at the arc's top front corner, two or all three together (r 2): they meet
/// in creases (with all three, a piece of sphere fills the corner). Each set takes off its rounds
/// alone, less what each alone takes in a box about the corner, plus what they take there
/// together: the CSG grid of the kernel's `rounds_meeting_at_a_corner_of_an_arc_wall`
/// (curvecorner_tests.rs, 0.02 mm cells), within its accuracy.
#[test]
fn fillet_edges_meeting_at_the_front_corner_of_the_arc() {
// In the box about the corner: what the long edge's, the arc's and the upright's rounds take
// alone, and what two or all three take together.
let corner = [2.719176, 3.015864, 1.863];
let together = [(vec![0, 2], 3.886936), (vec![0, 1], 4.971256), (vec![1, 2], 4.19864), (vec![0, 1, 2], 6.3332)];
let [x, y] = KNIFE_ARC_MID;
let (long, arc, upright) = ([20.0, -21.0, 10.0], [x, y, 10.0], [41.1667, -21.0, 5.0]);
let off = |edges: Vec<[f64; 3]>| {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 2}));
before - planned(&s)
};
let edges = [long, arc, upright];
let alone = edges.map(|e| off(vec![e]));
for (which, both) in together {
let want = which.iter().map(|i| alone[*i] - corner[*i]).sum::<f64>() + both;
let got = off(which.iter().map(|i| edges[*i]).collect());
assert!((got - want).abs() < 0.03, "{which:?}: removed {got} vs {want}");
}
}
/// The long top front edge rounded, then the small front-left upright as a fillet of its own
/// (as in the app): the new round ends in the earlier one, taking off what both take together.
#[test]
fn fillet_the_long_edge_then_the_small_left_edge() {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0]], "radius": 2}));
run(&mut s, "solid.fillet", json!({"edges": [[0.0, -21.0, 5.0]], "radius": 2}));
let one_by_one = before - planned(&s);
let mut s = knife_edge_part();
run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0], [0.0, -21.0, 5.0]], "radius": 2}));
let together = before - planned(&s);
// Within the display meshes' accuracy (the two are built of different faces).
assert!(rel(one_by_one, together) < 2e-4, "one by one {one_by_one} vs together {together}");
}
/// The long top front edge and the front-left vertical edge rounded together (r 2): the long
/// round ends at the arc wall, the two meet at the top-left corner (the top-left edge sharp).
/// Each removes its own round (the long one cut off by the wall), less what both would take at
/// the corner: in its 2 mm cube, what lies outside both quarter circles, ∫ (2 − √(4 − t²))² dt
/// over 0..2 = 16 − 8/3 − 4π.
#[test]
fn fillet_two_edges_meeting_at_a_corner_one_running_into_the_arc_wall() {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0], [0.0, -21.0, 5.0]], "radius": 2}));
let long = round_along(|rho| 62.166666666666664 - (441.0 - (rho - 21.0) * (rho - 21.0)).sqrt());
let upright = (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * 10.0;
let removed = long + upright - (16.0 - 8.0 / 3.0 - 4.0 * std::f64::consts::PI);
let after = planned(&s);
assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after);
}
+17
View File
@@ -95,6 +95,23 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
b[k] = json!(b["kernel"][k].as_i64().unwrap_or(0) + d);
}
b["sheet_metal"] = json!(true);
// A perforation field (#732) is not cut into the B-rep: take its open area out of the volume, the mass
// and the area (both faces minus the holes, plus the bores).
let open = sh.perforated_area();
if open > 0.0 {
let v = b["volume_mm3"].as_f64().unwrap_or(0.0);
let nv = v - open * sh.t;
if let Some(m) = b["mass_g"].as_f64().filter(|_| v > 0.0) {
b["mass_g"] = json!(m * nv / v);
}
b["volume_mm3"] = json!(nv);
let bores: f64 = sh
.perforations
.iter()
.map(|p| sh.perforation_holes(p).map_or(0.0, |h| h.len() as f64) * std::f64::consts::PI * p.diameter * sh.t)
.sum();
b["area_mm2"] = json!(b["area_mm2"].as_f64().unwrap_or(0.0) - 2.0 * open + bores);
}
}
}
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
+22 -5
View File
@@ -3,6 +3,8 @@
//! joint graph and moves the occurrences it places; the session re-solves after any edit too,
//! so joints follow the geometry their origins snap to.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use solidcraft_doc::expr::Kind;
use solidcraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap};
@@ -25,7 +27,8 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
"joint: id|name; value | values: [angles in deg or expressions, distances] (clamped to the limits)",
"joint: id|name; value | values: [angles in deg or expressions, distances] (clamped to the limits; the other joints of a \
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop)",
),
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
.at("SOLID", "ASSEMBLE")
@@ -43,8 +46,14 @@ pub static COMMANDS: &[CommandSpec] = &[
/// Re-solve the joints and move the occurrences they place (after any edit).
pub fn resolve(s: &mut Session) -> joints::Solution {
resolve_holding(s, &BTreeMap::new())
}
/// Re-solve the joints, keeping the `held` values (the ones being driven, mapped to the values
/// they move from) where loops are closed.
pub fn resolve_holding(s: &mut Session, held: &joints::Held) -> joints::Solution {
let st = s.model.state();
let sol = joints::solve(&s.doc, &st);
let sol = joints::solve_holding(&s.doc, &st, held);
if !sol.world.is_empty() {
let mut next = (*s.doc).clone();
if next.apply_joint_solution(&sol) {
@@ -225,6 +234,8 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
}
let id = j.id;
let name = j.name.clone();
// A new joint can take a deleted one's id; it starts shown.
s.visibility.hidden_joints.retain(|h| *h != id);
s.doc_mut().assembly.joints.push(j);
let sol = resolve(s);
let mut out = sol_json(&sol);
@@ -353,13 +364,19 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
}
}
joints::clamp(j, &mut q);
// Moved as far toward `q` as contact sets allow.
let r = super::motion::drive_joint(s, id, q)?;
// Moved as far toward `q` as contact sets and closed loops allow. Values left out of
// `values` are not driven: in a closed loop they follow.
let given = match p.get("values") {
Some(Value::Array(a)) => a.len(),
_ => 1,
};
let r = super::motion::drive_joint(s, id, q, given)?;
let sol = joints::solve(&s.doc, &s.model.state());
let mut out = sol_json(&sol);
out["joint"] = json!(id);
out["values"] = json!(s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.values.clone()));
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
Ok(out)
}
@@ -504,7 +521,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
.collect();
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "frame_a": world(&j.a), "frame_b": world(&j.b)})
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)})
})
.collect();
let links: Vec<Value> =
+547
View File
@@ -295,3 +295,550 @@ fn interference_box_sweep_matches_every_pair_check() {
let (pairs, skipped) = super::overlapping_pairs(&boxes, 10);
assert_eq!((pairs.len(), skipped), (10, every.len() - 10));
}
/// A small block in a new component: a part of a linkage, where only its joints matter.
fn part(s: &mut Session, name: &str, corner: [f64; 3]) {
run(s, "component.activate", json!({"component": "root"}));
run(s, "component.create", json!({"name": name}));
run(s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": corner}));
}
/// A joint between two parts as they are, at a world point with its z along `z`.
fn pin(s: &mut Session, kind: &str, name: &str, a: &str, b: &str, p: Vec3, z: Vec3) {
let at = |o: &str| json!({"occurrence": o, "point": [p.x, p.y, p.z], "z": [z.x, z.y, z.z]});
let r = run(s, "joint.create", json!({"type": kind, "a": at(a), "b": at(b), "flip": true, "name": name}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
}
/// Where circles about `p` (radius `r`) and `q` (radius `s`) in the XY plane meet.
fn meet(p: Vec3, r: f64, q: Vec3, s: f64) -> [Vec3; 2] {
let d = q - p;
let l = d.len();
let a = (r * r - s * s + l * l) / (2.0 * l);
let m = p + d * (a / l);
let n = Vec3::new(-d.y, d.x, 0.0) * ((r * r - a * a).max(0.0).sqrt() / l);
[m + n, m - n]
}
/// A crank-rocker four-bar about +Z: ground pivots O2 = (0, 0) and O4 = (45, 0), crank 20,
/// coupler 50, rocker 40, assembled with the crank at 90°. Returns the crank pin P0 and the
/// rocker pin Q0 (the upper, open assembly).
fn four_bar() -> (Session, Vec3, Vec3) {
let mut s = Session::default();
let (o2, o4, p0) = (Vec3::ZERO, Vec3::new(45.0, 0.0, 0.0), Vec3::new(0.0, 20.0, 0.0));
let [q0, _] = meet(p0, 50.0, o4, 40.0);
for (name, c) in [("Ground", o2), ("Crank", p0), ("Coupler", (p0 + q0) * 0.5), ("Rocker", q0)] {
part(&mut s, name, [c.x, c.y, -5.0]);
}
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
pin(&mut s, "revolute", "Crank", "Ground", "Crank", o2, Vec3::Z);
pin(&mut s, "revolute", "Crank pin", "Crank", "Coupler", p0, Vec3::Z);
pin(&mut s, "revolute", "Rocker pin", "Coupler", "Rocker", q0, Vec3::Z);
pin(&mut s, "revolute", "Rocker", "Ground", "Rocker", o4, Vec3::Z);
(s, p0, q0)
}
/// The four-bar's rocker pin with the crank driven to `to`°, followed a degree at a time from
/// `from`° (where it is at `q`), so it stays on its branch.
fn rocker_pin(from: f64, to: f64, mut q: Vec3) -> Vec3 {
let n = ((to - from).abs().ceil() as usize).max(1);
for k in 1..=n {
let th = (90.0 + from + (to - from) * k as f64 / n as f64).to_radians();
let [m1, m2] = meet(Vec3::new(20.0 * th.cos(), 20.0 * th.sin(), 0.0), 50.0, Vec3::new(45.0, 0.0, 0.0), 40.0);
q = if m1.dist(q) <= m2.dist(q) { m1 } else { m2 };
}
q
}
#[test]
fn four_bar_follows_its_crank() {
let (mut s, p0, q0) = four_bar();
let mut q = q0;
let mut at_330 = q0;
for k in 1..=12 {
let v = 30.0 * f64::from(k);
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": v}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{v}°: {r}");
assert!(r["stopped_by_loop"].is_null(), "{v}°: {r}");
// The driven joint keeps exactly the value asked for.
assert!((r["values"][0].as_f64().unwrap_or(f64::NAN) - v.to_radians()).abs() < 1e-12, "{r}");
q = rocker_pin(v - 30.0, v, q);
let th = (90.0 + v).to_radians();
let p = Vec3::new(20.0 * th.cos(), 20.0 * th.sin(), 0.0);
for (part, local, want) in [("Crank", p0, p), ("Coupler", p0, p), ("Coupler", q0, q), ("Rocker", q0, q)] {
assert!(near(place(&s, part, local), want), "{v}°: {part} {:?} wants {want:?}", place(&s, part, local));
}
if k == 11 {
at_330 = q;
}
}
// Back where it started after a full turn, and undo steps back.
assert!(near(q, q0));
run(&mut s, "edit.undo", json!({}));
assert!(near(place(&s, "Rocker", q0), at_330), "{:?}", place(&s, "Rocker", q0));
// One 180° drive lands on the same (open) branch as the stepped path.
let (mut s, _, q0) = four_bar();
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 180}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
assert!(near(place(&s, "Rocker", q0), rocker_pin(0.0, 180.0, q0)), "{:?}", place(&s, "Rocker", q0));
// A motion study playing the crank places the rocker the same way.
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 10, "keys": [{"joint": "Crank", "points": [[0, 0], [10, 120]]}]}));
let r = run(&mut s, "motion.play", json!({"study": "Turn", "step": 5}));
assert!(r["stopped_by_loop"].is_null(), "{r}");
assert!(near(place(&s, "Rocker", q0), rocker_pin(180.0, 60.0, rocker_pin(0.0, 180.0, q0))), "{:?}", place(&s, "Rocker", q0));
}
/// The rocker swings between its toggle positions, where crank and coupler line up (|Q| = 70
/// and |Q| = 30, about 69.3° and 139.2° from the ground line); driving it further stops where
/// the loop still closes.
#[test]
fn four_bar_rocker_stops_where_the_loop_cannot_close() {
let (mut s, _, q0) = four_bar();
let start = q0.y.atan2(q0.x - 45.0).to_degrees();
let r = run(&mut s, "joint.drive", json!({"joint": "Rocker", "value": 100}));
assert!(r["stopped_by_loop"].is_object(), "{r}");
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
let reached = r["values"][0].as_f64().unwrap_or(f64::NAN).to_degrees();
// |Q|² = 45² + 40² + 2·45·40·cos φ = 30².
let limit = ((900.0f64 - 3625.0) / 3600.0).acos().to_degrees() - start;
assert!(reached > limit - 5.0 && reached <= limit, "stopped at {reached}°, the limit is {limit}°");
// Inside its swing it moves freely, and the crank follows.
let r = run(&mut s, "joint.drive", json!({"joint": "Rocker", "value": -10}));
assert!(r["stopped_by_loop"].is_null() && r["conflicts"].as_array().map(Vec::len) == Some(0), "{r}");
let q = place(&s, "Rocker", q0);
assert!((q.y.atan2(q.x - 45.0).to_degrees() - (start - 10.0)).abs() < 1e-6, "{q:?}");
assert!(place(&s, "Coupler", q0).dist(q) < 1e-6);
// A full turn would end where it started, but the rocker can't get there.
let (mut s, _, _) = four_bar();
let r = run(&mut s, "joint.drive", json!({"joint": "Rocker", "value": 360}));
assert!(r["stopped_by_loop"].is_object(), "{r}");
assert!(r["values"][0].as_f64().unwrap_or(f64::NAN).to_degrees() <= limit, "{r}");
}
/// A loop value a motion link sets follows through the start of its link chain: a dial outside
/// the loop turns a gear, which sets the rocker's angle, so turning the crank turns the dial.
#[test]
fn four_bar_turns_the_source_of_a_linked_loop_value() {
let linked = || {
let (mut s, _, q0) = four_bar();
for (name, x) in [("Dial", 80.0), ("Gear", 100.0)] {
part(&mut s, name, [x, 0.0, -5.0]);
pin(&mut s, "revolute", name, "Ground", name, Vec3::new(x, 0.0, 0.0), Vec3::Z);
}
run(&mut s, "joint.motion_link", json!({"a": "Dial", "b": "Gear", "ratio": 1}));
run(&mut s, "joint.motion_link", json!({"a": "Gear", "b": "Rocker", "ratio": 0.5}));
(s, q0)
};
let (mut s, q0) = linked();
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 60}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
let q = rocker_pin(0.0, 60.0, q0);
assert!(near(place(&s, "Rocker", q0), q), "{:?} wants {q:?}", place(&s, "Rocker", q0));
let turn = q.y.atan2(q.x - 45.0) - q0.y.atan2(q0.x - 45.0);
let dial = s.doc.assembly.joints.iter().find(|j| j.name == "Dial").and_then(|j| j.values.first().copied()).unwrap_or(f64::NAN);
assert!((dial - 2.0 * turn).abs() < 1e-9, "dial {dial}, rocker turned {turn}");
// Driving the dial drives the rocker through the chain: two turns of the dial would turn
// the rocker a full turn, which it can't make.
let (mut s, _) = linked();
let r = run(&mut s, "joint.drive", json!({"joint": "Dial", "value": 720}));
assert!(r["stopped_by_loop"].is_object(), "{r}");
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
}
/// Values left out of a drive are free to follow in a loop: a plate on a planar joint whose
/// travel a diagonal slider limits. Driving only the planar joint's x moves its y along.
#[test]
fn values_left_out_of_a_drive_follow_the_loop() {
let mut s = Session::default();
part(&mut s, "Ground", [-1.0, -1.0, -5.0]);
part(&mut s, "Plate", [-1.0, -1.0, 0.0]);
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
let frame = |o: &str, z: [f64; 3], x: [f64; 3]| json!({"occurrence": o, "frame": {"origin": [0, 0, 0], "z": z, "x": x}});
let flat = |o: &str| frame(o, [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let r = run(&mut s, "joint.create", json!({"type": "planar", "a": flat("Ground"), "b": flat("Plate"), "flip": true, "name": "Table"}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
let diagonal = |o: &str| frame(o, [1.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
let r = run(&mut s, "joint.create", json!({"type": "slider", "a": diagonal("Ground"), "b": diagonal("Plate"), "flip": true, "name": "Rail"}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
let r = run(&mut s, "joint.drive", json!({"joint": "Table", "values": [10]}));
assert!(r["stopped_by_loop"].is_null() && r["conflicts"].as_array().map(Vec::len) == Some(0), "{r}");
assert!(near(place(&s, "Plate", Vec3::ZERO), Vec3::new(10.0, 10.0, 0.0)), "{:?}", place(&s, "Plate", Vec3::ZERO));
}
/// A slider-crank: crank 15 about the origin, rod 40, a piston sliding along X. The piston
/// sits at a·cos θ + √(b² − (a·sin θ)²).
#[test]
fn slider_crank_moves_the_piston() {
let mut s = Session::default();
let (o, p0) = (Vec3::ZERO, Vec3::new(0.0, 15.0, 0.0));
let s0 = Vec3::new((40.0f64 * 40.0 - 15.0 * 15.0).sqrt(), 0.0, 0.0);
for (name, c) in [("Ground", o), ("Crank", p0), ("Rod", s0 * 0.5), ("Piston", s0)] {
part(&mut s, name, [c.x, c.y, -5.0]);
}
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
pin(&mut s, "revolute", "Crank", "Ground", "Crank", o, Vec3::Z);
pin(&mut s, "revolute", "Crank pin", "Crank", "Rod", p0, Vec3::Z);
pin(&mut s, "revolute", "Wrist pin", "Rod", "Piston", s0, Vec3::Z);
pin(&mut s, "slider", "Bore", "Ground", "Piston", s0, Vec3::X);
for v in [40.0, 135.0, 270.0] {
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": v}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{v}°: {r}");
let th = (90.0f64 + v).to_radians();
let x = 15.0 * th.cos() + (40.0f64.powi(2) - (15.0 * th.sin()).powi(2)).sqrt();
assert!(near(place(&s, "Piston", s0), Vec3::new(x, 0.0, 0.0)), "{v}°: {:?} wants x = {x}", place(&s, "Piston", s0));
assert!(near(place(&s, "Rod", p0), Vec3::new(15.0 * th.cos(), 15.0 * th.sin(), 0.0)));
}
}
/// A joint frame on an occurrence, by origin and axes.
fn framed(o: &str, origin: [f64; 3], z: [f64; 3], x: [f64; 3]) -> Value {
json!({"occurrence": o, "frame": {"origin": origin, "z": z, "x": x}})
}
/// A loop open from the start closes from where it is: an arm on a revolute joint, and a rigid
/// joint that wants it turned 90°.
#[test]
fn an_open_loop_closes_from_where_it_is() {
let mut s = Session::default();
part(&mut s, "Ground", [-1.0, -1.0, -5.0]);
part(&mut s, "Arm", [0.0, -1.0, 0.0]);
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
pin(&mut s, "revolute", "Pivot", "Ground", "Arm", Vec3::ZERO, Vec3::Z);
let r = run(
&mut s,
"joint.create",
json!({"type": "rigid", "a": framed("Ground", [0.0; 3], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
"b": framed("Arm", [0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]), "flip": true, "name": "Lock"}),
);
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
assert!(near(place(&s, "Arm", Vec3::new(10.0, 0.0, 0.0)), Vec3::new(0.0, 10.0, 0.0)), "{:?}", place(&s, "Arm", Vec3::new(10.0, 0.0, 0.0)));
}
/// A loop that can't close does not hold up another one: the four-bar still drives beside a
/// block whose rigid joint wants it where its pivot can't take it.
#[test]
fn a_broken_loop_does_not_stop_another() {
let (mut s, _, q0) = four_bar();
part(&mut s, "Block", [200.0, -1.0, -5.0]);
pin(&mut s, "revolute", "Block pivot", "Ground", "Block", Vec3::new(200.0, 0.0, 0.0), Vec3::Z);
run(
&mut s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": "Ground", "point": [200, 0, 0]}, "b": {"occurrence": "Block", "point": [210, 0, 0]}, "flip": true, "name": "Wrong"}),
);
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 60}));
let names: Vec<&str> = r["conflicts"].as_array().into_iter().flatten().filter_map(|c| c["name"].as_str()).collect();
assert_eq!(names, ["Wrong"], "{r}");
assert!(r["stopped_by_loop"].is_null(), "{r}");
assert!(near(place(&s, "Rocker", q0), rocker_pin(0.0, 60.0, q0)), "{:?}", place(&s, "Rocker", q0));
}
/// A loop locked rigid can't move at all: driving its crank stops at once.
#[test]
fn a_locked_loop_does_not_move() {
let (mut s, _, _) = four_bar();
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Rocker", "name": "Lock"}));
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 360}));
assert!(r["stopped_by_loop"].is_object(), "{r}");
assert!(r["values"][0].as_f64().unwrap_or(f64::NAN).abs() < 1e-3, "{r}");
}
/// A link that turns the rocker 100 times as far as its dial: a small turn of the dial is a full
/// turn of the rocker, which it can't make.
#[test]
fn a_geared_down_loop_value_is_followed_all_the_way() {
let (mut s, _, _) = four_bar();
part(&mut s, "Dial", [80.0, 0.0, -5.0]);
pin(&mut s, "revolute", "Dial", "Ground", "Dial", Vec3::new(80.0, 0.0, 0.0), Vec3::Z);
run(&mut s, "joint.motion_link", json!({"a": "Dial", "b": "Rocker", "ratio": 100}));
let r = run(&mut s, "joint.drive", json!({"joint": "Dial", "value": 3.6}));
assert!(r["stopped_by_loop"].is_object(), "{r}");
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
}
/// A suppressed dial still turns the rocker through its motion link, and a rocker whose value
/// the file leaves out starts from 0: both are followed all the way, so a full turn stops.
#[test]
fn a_full_turn_stops_from_a_suppressed_dial_or_a_missing_value() {
let (mut s, _, _) = four_bar();
part(&mut s, "Dial", [80.0, 0.0, -5.0]);
pin(&mut s, "revolute", "Dial", "Ground", "Dial", Vec3::new(80.0, 0.0, 0.0), Vec3::Z);
run(&mut s, "joint.motion_link", json!({"a": "Dial", "b": "Rocker", "ratio": 0.5}));
run(&mut s, "joint.edit", json!({"joint": "Dial", "suppressed": true}));
let r = run(&mut s, "joint.drive", json!({"joint": "Dial", "value": 720}));
assert!(r["stopped_by_loop"].is_object(), "{r}");
// Undriven, the suppressed dial still follows the rocker when the crank turns.
run(&mut s, "joint.drive", json!({"joint": "Dial", "value": 0}));
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 60}));
assert!(r["stopped_by_loop"].is_null() && r["conflicts"].as_array().map(Vec::len) == Some(0), "{r}");
let (mut s, _, _) = four_bar();
if let Some(j) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.name == "Rocker") {
j.values.clear();
}
let r = run(&mut s, "joint.drive", json!({"joint": "Rocker", "value": 360}));
assert!(r["stopped_by_loop"].is_object(), "{r}");
}
/// Loops that only share a rigid joint are independent: an arm whose lock can't close does not
/// stop another arm, beside it on the same rigidly mounted base, from closing its own loop.
#[test]
fn loops_sharing_only_a_rigid_joint_are_solved_apart() {
let mut s = Session::default();
for (name, x) in [("Ground", -1.0), ("Base", -1.0), ("Arm", 0.0), ("Other", 100.0)] {
part(&mut s, name, [x, -1.0, 0.0]);
}
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Base", "name": "Mount"}));
pin(&mut s, "revolute", "Pivot", "Base", "Arm", Vec3::ZERO, Vec3::Z);
pin(&mut s, "revolute", "Other pivot", "Base", "Other", Vec3::new(100.0, 0.0, 0.0), Vec3::Z);
run(
&mut s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": "Ground", "point": [100, 0, 0]}, "b": {"occurrence": "Other", "point": [110, 0, 0]}, "flip": true, "name": "Wrong"}),
);
let r = run(
&mut s,
"joint.create",
json!({"type": "rigid", "a": framed("Ground", [0.0; 3], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
"b": framed("Arm", [0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]), "flip": true, "name": "Lock"}),
);
// The broken loop is reported (on whichever of its joints closes it), the arm's is not.
let names: Vec<&str> = r["conflicts"].as_array().into_iter().flatten().filter_map(|c| c["name"].as_str()).collect();
assert!(names.len() == 1 && ["Wrong", "Other pivot"].contains(&names[0]), "{r}");
assert!(near(place(&s, "Arm", Vec3::new(10.0, 0.0, 0.0)), Vec3::new(0.0, 10.0, 0.0)), "{:?}", place(&s, "Arm", Vec3::new(10.0, 0.0, 0.0)));
}
/// Two links writing one value: whichever carries a driven dial's turn into the loop, the turn
/// is followed (here dial A turns gear B, which turns the rocker, before dial D rewrites B).
#[test]
fn a_turn_carried_in_by_any_link_is_followed() {
let (mut s, _, _) = four_bar();
for (name, x) in [("A", 80.0), ("B", 100.0), ("D", 120.0)] {
part(&mut s, name, [x, 0.0, -5.0]);
pin(&mut s, "revolute", name, "Ground", name, Vec3::new(x, 0.0, 0.0), Vec3::Z);
}
run(&mut s, "joint.motion_link", json!({"a": "A", "b": "B", "ratio": 1}));
run(&mut s, "joint.motion_link", json!({"a": "B", "b": "Rocker", "ratio": 0.5}));
run(&mut s, "joint.motion_link", json!({"a": "D", "b": "B", "ratio": 1}));
let r = run(&mut s, "joint.drive", json!({"joint": "A", "value": 720}));
assert!(r["stopped_by_loop"].is_object(), "{r}");
}
/// Several links writing one value: every value a link chain starts from can move to close the
/// loop, whichever of them the link order lets through (here dial A's, before dial B rewrites
/// the gear).
#[test]
fn a_loop_value_moves_through_whichever_link_carries_it() {
let (mut s, _, q0) = four_bar();
for (name, x) in [("A", 80.0), ("G", 100.0), ("B", 120.0)] {
part(&mut s, name, [x, 0.0, -5.0]);
pin(&mut s, "revolute", name, "Ground", name, Vec3::new(x, 0.0, 0.0), Vec3::Z);
}
run(&mut s, "joint.motion_link", json!({"a": "A", "b": "G", "ratio": 1}));
run(&mut s, "joint.motion_link", json!({"a": "G", "b": "Rocker", "ratio": 1}));
run(&mut s, "joint.motion_link", json!({"a": "B", "b": "G", "ratio": 1}));
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 60}));
assert!(r["stopped_by_loop"].is_null() && r["conflicts"].as_array().map(Vec::len) == Some(0), "{r}");
assert!(near(place(&s, "Rocker", q0), rocker_pin(0.0, 60.0, q0)), "{:?}", place(&s, "Rocker", q0));
}
/// Two values of one joint can feed two independent loops: a puck's x drives a pair of parallel
/// slides, its y a slide whose rigid lock can't close. Driving x moves the healthy pair.
#[test]
fn values_of_one_joint_feed_independent_loops() {
let mut s = Session::default();
for (name, y) in [("Ground", -10.0), ("Puck", -20.0), ("Car", 50.0), ("Other", 100.0)] {
part(&mut s, name, [-1.0, y, 0.0]);
}
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
let flat = |o: &str| framed(o, [0.0, -20.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
run(&mut s, "joint.create", json!({"type": "planar", "a": flat("Ground"), "b": flat("Puck"), "flip": true, "name": "Table"}));
pin(&mut s, "slider", "Rail", "Ground", "Car", Vec3::new(0.0, 50.0, 0.0), Vec3::X);
pin(&mut s, "slider", "Guide", "Ground", "Car", Vec3::new(10.0, 50.0, 0.0), Vec3::X);
pin(&mut s, "slider", "Lift", "Ground", "Other", Vec3::new(0.0, 100.0, 0.0), Vec3::Y);
run(
&mut s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": "Ground", "point": [0, 100, 0]}, "b": {"occurrence": "Other", "point": [10, 100, 0]}, "flip": true, "name": "Wrong"}),
);
run(&mut s, "joint.motion_link", json!({"a": "Table", "ia": 0, "b": "Rail", "ratio": 1}));
run(&mut s, "joint.motion_link", json!({"a": "Table", "ia": 1, "b": "Lift", "ratio": 1}));
let r = run(&mut s, "joint.drive", json!({"joint": "Table", "values": [10]}));
assert!(r["stopped_by_loop"].is_null(), "{r}");
let names: Vec<&str> = r["conflicts"].as_array().into_iter().flatten().filter_map(|c| c["name"].as_str()).collect();
assert_eq!(names, ["Wrong"], "{r}");
assert!(near(place(&s, "Car", Vec3::new(0.0, 50.0, 0.0)), Vec3::new(10.0, 50.0, 0.0)), "{:?}", place(&s, "Car", Vec3::new(0.0, 50.0, 0.0)));
}
/// Two loops can move different values of one joint: each keeps what it moved. A table's x
/// feeds one pair of parallel slides and its y another; a study drives both pairs.
#[test]
fn loops_moving_different_values_of_one_joint_keep_both() {
let mut s = Session::default();
for (name, y) in [("Ground", -10.0), ("Puck", -20.0), ("CarA", 50.0), ("CarB", 100.0)] {
part(&mut s, name, [-1.0, y, 0.0]);
}
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
let flat = |o: &str| framed(o, [0.0, -20.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
run(&mut s, "joint.create", json!({"type": "planar", "a": flat("Ground"), "b": flat("Puck"), "flip": true, "name": "Table"}));
for (car, y) in [("CarA", 50.0), ("CarB", 100.0)] {
pin(&mut s, "slider", &format!("{car} rail"), "Ground", car, Vec3::new(0.0, y, 0.0), Vec3::X);
pin(&mut s, "slider", &format!("{car} guide"), "Ground", car, Vec3::new(10.0, y, 0.0), Vec3::X);
}
run(&mut s, "joint.motion_link", json!({"a": "Table", "ia": 0, "b": "CarA rail", "ratio": 1}));
run(&mut s, "joint.motion_link", json!({"a": "Table", "ia": 1, "b": "CarB rail", "ratio": 1}));
let keys = json!([{"joint": "CarA guide", "points": [[0, 0], [10, 10]]}, {"joint": "CarB guide", "points": [[0, 0], [10, 20]]}]);
run(&mut s, "motion.study", json!({"name": "Both", "steps": 10, "keys": keys}));
let r = run(&mut s, "motion.play", json!({"study": "Both", "step": 10}));
assert!(r["stopped_by_loop"].is_null(), "{r}");
let table = s.doc.assembly.joints.iter().find(|j| j.name == "Table").map(|j| j.values.clone()).unwrap_or_default();
assert!(table.len() == 3 && (table[0] - 10.0).abs() < 1e-6 && (table[1] - 20.0).abs() < 1e-6, "{table:?}");
// A solve with nothing driven leaves them where they are.
run(&mut s, "joint.solve", json!({}));
assert!(near(place(&s, "CarA", Vec3::new(0.0, 50.0, 0.0)), Vec3::new(10.0, 50.0, 0.0)), "{:?}", place(&s, "CarA", Vec3::new(0.0, 50.0, 0.0)));
assert!(near(place(&s, "CarB", Vec3::new(0.0, 100.0, 0.0)), Vec3::new(20.0, 100.0, 0.0)), "{:?}", place(&s, "CarB", Vec3::new(0.0, 100.0, 0.0)));
}
/// A clamped link can jump: a 100:1 link to a rocker limited to a full turn reaches its limit
/// after the dial's first few degrees, back where it started. Each step is checked, so the drive
/// still stops where the rocker can't go.
#[test]
fn a_clamped_link_is_followed_step_by_step() {
let (mut s, _, _) = four_bar();
part(&mut s, "Dial", [80.0, 0.0, -5.0]);
pin(&mut s, "revolute", "Dial", "Ground", "Dial", Vec3::new(80.0, 0.0, 0.0), Vec3::Z);
run(&mut s, "joint.limits", json!({"joint": "Rocker", "min": 0, "max": 360}));
run(&mut s, "joint.motion_link", json!({"a": "Dial", "b": "Rocker", "ratio": 100}));
let r = run(&mut s, "joint.drive", json!({"joint": "Dial", "value": 360}));
assert!(r["stopped_by_loop"].is_object(), "{r}");
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
}
/// A design file path in a fresh temporary folder (removed by the caller).
fn temp_design(tag: &str, ext: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("solidcraft-joint-visibility-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("design.{ext}")).to_string_lossy().to_string();
(dir, path)
}
/// Two hinge leaves joined by a revolute "Hinge" and a rigid "Glue" to a third part.
fn hinged() -> Session {
let mut s = Session::default();
leaf(&mut s, "LeafA");
leaf(&mut s, "LeafB");
part(&mut s, "Tag", [60.0, 0.0, 0.0]);
let o = occ(&s, "LeafA");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
run(
&mut s,
"joint.create",
json!({"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "flip": true, "name": "Hinge"}),
);
pin(&mut s, "rigid", "Glue", "LeafA", "Tag", Vec3::new(61.0, 1.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
s
}
fn joint(s: &Session, name: &str) -> u64 {
s.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap()
}
/// #1160: hiding a joint hides only its glyph. It is saved with the design (not an undo step,
/// like other visibility), the hinge still drives its leaf while hidden, and the folder hides
/// and shows every joint at once.
#[test]
fn hidden_joints_still_drive_and_are_saved_with_the_design() {
let (dir, path) = temp_design("saved", "json");
let mut s = hinged();
let (hinge, glue) = (joint(&s, "Hinge"), joint(&s, "Glue"));
run(&mut s, "file.save", json!({"path": path}));
let undo = s.undo.len();
let r = run(&mut s, "browser.visibility", json!({"joints": ["Hinge"], "visible": false}));
assert_eq!(r["changed"], 1, "{r}");
assert!(!s.joint_visible(hinge) && s.joint_visible(glue));
assert_eq!(s.undo.len(), undo, "hiding is not an undo step");
assert!(s.is_dirty(), "hiding leaves unsaved changes");
let l = run(&mut s, "joint.list", json!({}));
assert_eq!(l["joints"].as_array().unwrap().iter().map(|j| j["visible"].clone()).collect::<Vec<_>>(), [json!(false), json!(true)]);
// Hidden, the hinge still constrains and moves its leaf; undoing the drive keeps it hidden.
let p = Vec3::new(20.0, 0.0, 0.0);
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 90}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)), "{:?}", place(&s, "LeafB", p));
run(&mut s, "edit.undo", json!({}));
assert!(near(place(&s, "LeafB", p), p));
assert!(!s.joint_visible(hinge), "undo keeps the hiding");
run(&mut s, "edit.redo", json!({}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
// Saved and opened again, the hinge stays hidden.
run(&mut s, "file.save", json!({}));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
let (hinge, glue) = (joint(&s, "Hinge"), joint(&s, "Glue"));
assert!(!s.joint_visible(hinge) && s.joint_visible(glue), "{:?}", s.visibility);
assert!(!s.is_dirty());
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["visibility"]["hidden_joints"], json!([hinge]));
// The folder: all hidden, then all shown (and saved without any hidden joints).
run(&mut s, "browser.visibility", json!({"folder": "joints", "visible": false}));
assert!(!s.joint_visible(hinge) && !s.joint_visible(glue));
run(&mut s, "browser.visibility", json!({"folder": "joints", "visible": true}));
assert!(s.joint_visible(hinge) && s.joint_visible(glue));
// By name among other items, too.
run(&mut s, "browser.visibility", json!({"items": ["Glue"], "visible": false}));
assert!(!s.joint_visible(glue));
run(&mut s, "browser.visibility", json!({"items": ["Glue"], "visible": true}));
run(&mut s, "file.save", json!({}));
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(saved.get("visibility").is_none_or(|v| v.get("hidden_joints").is_none()), "{saved}");
// Bad requests change nothing.
let before = s.visibility.clone();
for p in [
json!({"joints": ["Nope"], "visible": false}),
json!({"joints": [], "visible": false}),
json!({"joints": "Hinge", "visible": false}),
json!({"joints": ["Hinge"], "folder": "joints", "visible": false}),
json!({"joints": ["Hinge"], "items": ["Glue"], "visible": false}),
] {
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
}
assert_eq!(s.visibility, before);
let _ = std::fs::remove_dir_all(&dir);
}
/// #1160: a design saved before joints could be hidden (format 19, no `hidden_joints`) opens
/// with every joint shown; and a new joint that takes a deleted hidden joint's id starts shown.
#[test]
fn older_designs_show_every_joint_and_new_joints_start_shown() {
let (dir, path) = temp_design("legacy", "json");
let mut s = hinged();
let mut legacy: Value = serde_json::from_slice(&solidcraft_io::write_design(&s.doc)).unwrap();
legacy["format"] = json!("solidcraft/19");
legacy["visibility"] =
json!({"hidden_bodies": [], "hidden_occurrences": [], "hidden_origin": ["XY"], "hidden_sketches": [], "shown_sketches": []});
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
assert_eq!(s.doc.assembly.joints.len(), 2);
assert!(s.doc.assembly.joints.iter().all(|j| s.joint_visible(j.id)));
assert_eq!(s.visibility.hidden_origin, ["XY"]);
let glue = joint(&s, "Glue");
run(&mut s, "browser.visibility", json!({"joints": [glue], "visible": false}));
run(&mut s, "joint.delete", json!({"joint": glue}));
pin(&mut s, "rigid", "Glue2", "LeafA", "Tag", Vec3::new(61.0, 1.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
let again = joint(&s, "Glue2");
assert!(s.joint_visible(again), "id {again} reused from {glue}");
let _ = std::fs::remove_dir_all(&dir);
}
+199
View File
@@ -0,0 +1,199 @@
//! Scalar local libraries: inspect/export files and copy exact revisions into a design.
use super::CommandSpec;
use crate::{Result, Session, params::bad};
use serde::Deserialize;
use serde_json::{Value, json};
use solidcraft_io::catalog::{Catalog, Definition, Provenance};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("library.inspect", "Inspect Local Library", inspect).noundo().doc_only()
.params("path: scalar library JSON; returns validated catalog and exact entry revisions, without changing the design"),
CommandSpec::new("library.export", "Export Design Library", export).noundo().doc_only()
.params("path: .json; id? (default user.local), name?, provenance?, overwrite? false. Copies assigned looks and design-local physical definitions; never changes the design"),
CommandSpec::new("library.apply", "Apply Local Library Entry", apply).enabled(super::not_in_sketch)
.params("path, entry: qualified ID, revision: exact content SHA-256; targets?: {bodies?, faces?: [{body,point}], components?}; name?: physical material name on import. Copies values in one undo step; material conflicts are explicit"),
];
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Inspect {
path: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Export {
path: String,
#[serde(default = "default_id")]
id: String,
#[serde(default = "default_name")]
name: String,
#[serde(default)]
provenance: Provenance,
#[serde(default)]
overwrite: bool,
}
fn default_id() -> String {
"user.local".into()
}
fn default_name() -> String {
"My library".into()
}
#[derive(Default, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct Targets {
#[serde(default)]
bodies: Vec<String>,
#[serde(default)]
faces: Vec<Face>,
#[serde(default)]
components: Vec<String>,
}
#[derive(Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct Face {
body: String,
point: [f64; 3],
}
impl Targets {
fn validate(&self) -> Result<()> {
let cmd = "library.apply";
if self.bodies.len().saturating_add(self.faces.len()).saturating_add(self.components.len()) > 1024 {
return Err(bad(cmd, "at most 1024 library application targets"));
}
for name in self.bodies.iter().chain(&self.components).chain(self.faces.iter().map(|f| &f.body)) {
if name.is_empty() || name.len() > 1024 || name.chars().any(char::is_control) {
return Err(bad(cmd, "invalid library target name"));
}
}
if self.faces.iter().any(|f| f.point.iter().any(|x| !x.is_finite() || x.abs() > 1e9)) {
return Err(bad(cmd, "invalid library target point"));
}
Ok(())
}
fn empty(&self) -> bool {
self.bodies.is_empty() && self.faces.is_empty() && self.components.is_empty()
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Apply {
path: String,
entry: String,
revision: String,
#[serde(default)]
targets: Targets,
name: Option<String>,
}
fn path(cmd: &str, value: &str) -> Result<()> {
if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) {
return Err(bad(cmd, "invalid library path"));
}
solidcraft_io::vfs::check(value).map_err(|e| bad(cmd, e.to_string()))
}
fn nested(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
crate::find_command(id).ok_or_else(|| bad("library.apply", "required assignment command is unavailable"))?.run_nested(s, p)
}
fn inspect(_s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.inspect";
let request: Inspect = serde_json::from_value(p.clone()).map_err(|e| bad(cmd, e.to_string()))?;
path(cmd, &request.path)?;
let catalog = Catalog::read(&request.path).map_err(|e| bad(cmd, e.to_string()))?;
Ok(json!({"catalog":catalog}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.export";
let request: Export = serde_json::from_value(p.clone()).map_err(|e| bad(cmd, e.to_string()))?;
path(cmd, &request.path)?;
if !request.path.to_ascii_lowercase().ends_with(".json") {
return Err(bad(cmd, "library export path must end in .json"));
}
let catalog = Catalog::from_document(&s.doc, &request.id, &request.name, request.provenance).map_err(|e| bad(cmd, e.to_string()))?;
let bytes = catalog.encode().map_err(|e| bad(cmd, e.to_string()))?;
#[cfg(not(target_arch = "wasm32"))]
let result = if request.overwrite {
solidcraft_io::write_atomic(std::path::Path::new(&request.path), &bytes, false)
} else {
solidcraft_io::safe_write::create_atomic(std::path::Path::new(&request.path), &bytes)
};
#[cfg(target_arch = "wasm32")]
let result = if !request.overwrite && solidcraft_io::vfs::exists(&request.path) {
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "library file already exists"))
} else {
solidcraft_io::vfs::write(&request.path, &bytes)
};
result.map_err(|e| bad(cmd, format!("could not export library: {e}")))?;
Ok(json!({"path":request.path,"entries":catalog.entries.len(),"bytes":bytes.len(),"catalog":catalog}))
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.apply";
let request: Apply = serde_json::from_value(p.clone()).map_err(|e| bad(cmd, e.to_string()))?;
path(cmd, &request.path)?;
request.targets.validate()?;
if request.entry.len() > 129 || request.revision.len() != 64 {
return Err(bad(cmd, "invalid entry ID or revision"));
}
let catalog = Catalog::read(&request.path).map_err(|e| bad(cmd, e.to_string()))?;
let entry = catalog.entries.iter().find(|entry| entry.id == request.entry).ok_or_else(|| bad(cmd, "library entry is missing"))?;
if entry.revision != request.revision {
return Err(bad(cmd, "library entry changed; reload and explicitly select its new revision"));
}
let result = match &entry.definition {
Definition::Appearance(look) => {
if request.name.is_some() || request.targets.empty() {
return Err(bad(cmd, "appearance entries need targets and do not take a material name"));
}
let mut target = serde_json::to_value(&request.targets).map_err(|e| bad(cmd, e.to_string()))?;
target["appearance"] = json!(look.name);
target["color"] = json!(look.color);
target["opacity"] = json!(look.opacity);
nested(s, "appearance.assign", &target)?
}
Definition::Material(material) => {
if !request.targets.faces.is_empty() || !request.targets.components.is_empty() {
return Err(bad(cmd, "physical material assignment takes body targets only"));
}
if request.targets.bodies.iter().any(|body| s.model.state().body(body).is_none()) {
return Err(bad(cmd, "physical material targets must name existing bodies"));
}
let wanted = request.name.as_deref().unwrap_or(&material.name).trim();
if wanted.is_empty() || wanted.len() > 128 || wanted.chars().any(char::is_control) {
return Err(bad(cmd, "invalid physical material name"));
}
let existing = s.doc.user_materials.iter().find(|m| m.name.eq_ignore_ascii_case(wanted)).cloned();
let name = existing.as_ref().map_or(wanted, |m| m.name.as_str()).to_string();
let copy = material.to_material(name.clone());
let reused = if let Some(existing) = existing {
if existing != copy {
return Err(bad(cmd, "physical material name conflicts; choose a new name explicitly"));
}
true
} else {
let mut params = json!({"name":name,"density_g_cm3":material.density_g_cm3});
if let Some(look) = &material.appearance {
params["color"] = json!(look.color);
params["opacity"] = json!(look.opacity);
}
nested(s, "material.create", &params)?;
// The existing colour API labels a custom look after its material.
// Preserve the library's exact look label inside this same transaction.
let imported = s
.doc_mut()
.user_materials
.iter_mut()
.find(|m| m.name == name)
.ok_or_else(|| bad(cmd, "imported physical material is missing"))?;
imported.appearance = copy.appearance.clone();
false
};
if !request.targets.bodies.is_empty() {
nested(s, "material.assign", &json!({"bodies":request.targets.bodies,"material":name}))?;
}
json!({"material":name,"reused":reused,"bodies":request.targets.bodies})
}
};
Ok(json!({"entry":entry.id,"revision":entry.revision,"copied":true,"result":result}))
}
#[cfg(test)]
#[path = "libraries_tests.rs"]
mod tests;
+220
View File
@@ -0,0 +1,220 @@
use super::*;
use solidcraft_io::catalog::{Definition, Entry, FORMAT, ScalarLook, ScalarMaterial};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
struct File(String);
impl File {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
Self(
std::env::temp_dir()
.join(format!("solidcraft-catalog-{}-{}.json", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)))
.to_string_lossy()
.into_owned(),
)
}
fn write(&self, catalog: &Catalog) {
std::fs::write(&self.0, catalog.encode().unwrap()).unwrap();
}
}
impl Drop for File {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn catalog() -> Catalog {
Catalog {
format: FORMAT.into(),
id: "user.test".into(),
name: "Original scalar test library".into(),
entries: vec![
Entry::new(
"user.test",
Definition::Appearance(ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }),
Provenance::default(),
)
.unwrap(),
Entry::new(
"user.test",
Definition::Material(ScalarMaterial {
name: "Timber density".into(),
density_g_cm3: 0.7,
appearance: Some(ScalarLook { name: "Timber tint".into(), color: [180, 120, 70], opacity: 1.0 }),
}),
Provenance::default(),
)
.unwrap(),
],
}
}
fn session() -> Session {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
s.mark_saved();
s
}
fn apply_entry(s: &mut Session, file: &File, entry: &Entry, targets: Value) -> Result<Value> {
s.execute("library.apply", &json!({"path":file.0,"entry":entry.id,"revision":entry.revision,"targets":targets}))
}
#[test]
fn catalog_export_obeys_file_scope_in_new_file_and_overwrite_modes() {
let file = File::new();
let base = std::path::Path::new(&file.0).with_extension("scope");
std::fs::create_dir(&base).unwrap();
let root = base.join("allowed");
let outside = base.join("outside");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&outside).unwrap();
let sentinel = outside.join("existing.json");
std::fs::write(&sentinel, b"previous contents").unwrap();
let mut s = session();
let before = s.doc.clone();
let undo = s.undo.len();
solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([root.clone()])), || {
for path in [outside.join("new.json"), root.join("../outside/traversal.json"), sentinel.clone()] {
for overwrite in [false, true] {
let error = s.execute("library.export", &json!({"path":path,"overwrite":overwrite})).unwrap_err();
assert!(error.to_string().contains("outside the folders this agent may use"), "{error}");
}
}
for overwrite in [false, true] {
s.execute("library.export", &json!({"path":root.join("allowed.json"),"overwrite":overwrite})).unwrap();
}
});
assert_eq!(std::fs::read(&sentinel).unwrap(), b"previous contents");
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1);
assert!(root.join("allowed.json").is_file());
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!(s.undo.len(), undo);
assert!(!s.is_dirty());
std::fs::remove_dir_all(&base).unwrap();
}
#[test]
fn catalog_appearance_copy_keeps_mass_and_survives_undo_and_cold_reopen_without_file() {
let file = File::new();
let catalog = catalog();
file.write(&catalog);
let mut s = session();
let original_format = s.doc.format.clone();
let before = s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
let undo = s.undo.len();
apply_entry(&mut s, &file, &catalog.entries[0], json!({"bodies":["Body1"]})).unwrap();
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.doc.appearances.bodies["Body1"].name, "Warm oak colour");
assert_eq!(s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap(), before);
let saved = solidcraft_io::write_design(&s.doc);
std::fs::remove_file(&file.0).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc.appearances.bodies.is_empty());
s.execute("edit.redo", &json!({})).unwrap();
let reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
assert_eq!(reopened.doc.appearances, s.doc.appearances);
assert_eq!(reopened.doc.format, original_format, "scalar library adds no document schema");
}
#[test]
fn catalog_physical_copy_preserves_exact_look_label_and_is_one_transaction() {
let file = File::new();
let catalog = catalog();
file.write(&catalog);
let mut s = session();
let undo = s.undo.len();
apply_entry(&mut s, &file, &catalog.entries[1], json!({"bodies":["Body1"]})).unwrap();
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.doc.user_materials[0].appearance.as_ref().unwrap().name, "Timber tint");
let mass = s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
assert!((mass - 0.7).abs() < 1e-8);
let saved = solidcraft_io::write_design(&s.doc);
std::fs::remove_file(&file.0).unwrap();
let mut reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
let actual = reopened.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
assert!((actual - 0.7).abs() < 1e-8);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc.user_materials.is_empty());
assert!(s.doc.materials.is_empty());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc.user_materials, reopened.doc.user_materials);
}
#[test]
fn catalog_conflicts_stale_revisions_and_invalid_targets_are_atomic() {
let file = File::new();
let catalog = catalog();
file.write(&catalog);
let mut s = session();
s.execute("material.create", &json!({"name":"Timber density","density_g_cm3":1.2})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let redo = s.redo.len();
let doc = s.doc.clone();
let undo = s.undo.len();
let revision = s.revision;
assert!(apply_entry(&mut s, &file, &catalog.entries[1], json!({"bodies":["missing"]})).is_err());
assert_eq!(*s.doc, *doc);
assert_eq!((s.undo.len(), s.redo.len(), s.revision), (undo, redo, revision));
s.execute("edit.redo", &json!({})).unwrap();
let doc = s.doc.clone();
let history = (s.undo.len(), s.redo.len(), s.revision);
assert!(apply_entry(&mut s, &file, &catalog.entries[1], json!({"bodies":["Body1"]})).is_err());
assert_eq!(*s.doc, *doc);
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
let mut changed = catalog.clone();
if let Definition::Appearance(look) = &mut changed.entries[0].definition {
look.color = [1, 2, 3];
}
changed.entries[0].revision = changed.entries[0].content_revision().unwrap();
file.write(&changed);
assert!(apply_entry(&mut s, &file, &catalog.entries[0], json!({"bodies":["Body1"]})).is_err());
assert_eq!(*s.doc, *doc);
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
}
#[test]
fn catalog_explicit_material_rename_and_identical_definition_reuse() {
let file = File::new();
let catalog = catalog();
file.write(&catalog);
let entry = &catalog.entries[1];
let mut s = session();
s.execute("material.create", &json!({"name":"Timber density","density_g_cm3":1.2})).unwrap();
let params = json!({"path":file.0,"entry":entry.id,"revision":entry.revision,"name":"Imported timber"});
s.execute("library.apply", &params).unwrap();
assert_eq!(s.doc.user_materials.len(), 2);
let undo = s.undo.len();
let revision = s.revision;
let again = s.execute("library.apply", &params).unwrap();
assert_eq!(again["result"]["reused"], true);
assert_eq!((s.undo.len(), s.revision), (undo, revision));
assert_eq!(s.doc.user_materials[0].density, 1.2);
}
#[test]
fn catalog_inspection_export_and_no_clobber_do_not_touch_design_or_history() {
let file = File::new();
let mut s = session();
s.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap();
s.mark_saved();
let doc = s.doc.clone();
let undo = s.undo.len();
let revision = s.revision;
let result = s.execute("library.export", &json!({"path":file.0})).unwrap();
assert_eq!(result["entries"], 1);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!((s.undo.len(), s.revision), (undo, revision));
assert!(!s.is_dirty());
let before = std::fs::read(&file.0).unwrap();
s.execute("library.inspect", &json!({"path":file.0})).unwrap();
assert!(s.execute("library.export", &json!({"path":file.0})).is_err());
assert_eq!(before, std::fs::read(&file.0).unwrap());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!((s.undo.len(), s.revision), (undo, revision));
s.execute("library.export", &json!({"path":file.0,"overwrite":true})).unwrap();
assert_eq!(before, std::fs::read(&file.0).unwrap());
for command in ["library.inspect", "library.export", "library.apply"] {
assert!(s.execute(command, &json!({"path":file.0,"unknown":true})).is_err());
}
}
+46 -1
View File
@@ -8,7 +8,28 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.va
.icon("material")
.noundo()
.doc_only()
.params("card: schema_version 1 engineering material card in SI units. Returns numerical consistency, derived isotropic moduli, missing conditions and sources needing review. Read-only; does not assign/save properties or provide an analysis solver. See docs/physical-material-cards.md")];
.params("card: schema_version 1 engineering material card in SI units. Returns numerical consistency, derived isotropic moduli, missing conditions and sources needing review. Read-only; does not assign/save properties or provide an analysis solver. See docs/physical-material-cards.md"),
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", directional).icon("material").noundo().doc_only()
.params("card: schema_version 1 engineering material card; direction: three finite nonzero-vector components in the card's material-local axes. Returns normalized direction, Young modulus in Pa and source/condition review. Read-only constitutive query; no structural solver, persistence or body-axis assignment. See docs/physical-material-cards.md")];
fn directional(_: &mut Session, params: &Value) -> Result<Value> {
let cmd = "material.properties.directional";
let object = params
.as_object()
.filter(|o| o.len() == 2 && o.contains_key("card") && o.contains_key("direction"))
.ok_or_else(|| bad(cmd, "expected {card: ..., direction: [x,y,z]}"))?;
let value = object.get("card").ok_or_else(|| bad(cmd, "missing card"))?;
let direction = object
.get("direction")
.and_then(Value::as_array)
.filter(|v| v.len() == 3)
.ok_or_else(|| bad(cmd, "direction: expected exactly three numeric components"))?;
let component = |i| direction.get(i).and_then(Value::as_f64).ok_or_else(|| bad(cmd, "direction: expected numeric components"));
let direction = [component(0)?, component(1)?, component(2)?];
let card = solidcraft_doc::physical_material::admit_card(value).map_err(|e| bad(cmd, e.to_string()))?;
let response = card.directional_modulus(direction).map_err(|e| bad(cmd, e.to_string()))?;
serde_json::to_value(response).map_err(|e| bad(cmd, e.to_string()))
}
fn validate(_: &mut Session, params: &Value) -> Result<Value> {
let object = params
@@ -25,6 +46,25 @@ mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn directional_command_reports_modulus_and_preserves_state_on_refusal() {
let mut s = Session::default();
let card = json!({"schema_version":1,"designation":"Synthetic","family":"other",
"sources":[{"id":"test","title":"Original synthetic fixture","kind":"illustrative"}],
"properties":[{"source_id":"test","data":{"kind":"elasticity","model":{"symmetry":"isotropic","young_modulus_pa":12e9,"poisson_ratio":0.25}}}]});
let doc = s.doc.clone();
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.camera, s.selection.clone());
let response = s.execute("material.properties.directional", &json!({"card":card,"direction":[1,2,3]})).unwrap();
assert_eq!(response["young_modulus_pa"], 12e9);
assert_eq!(response["validation"]["source_review_required"], json!(["test"]));
for direction in [json!([0, 0, 0]), json!([1, 2]), json!([1, 2, 3, 4]), json!([true, 0, 0]), json!(["1", 0, 0]), Value::Null] {
assert!(s.execute("material.properties.directional", &json!({"card":card,"direction":direction})).is_err());
}
assert!(s.execute("material.properties.directional", &json!({"card":card,"direction":[1,0,0],"assign":true})).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.camera, s.selection.clone()));
}
#[test]
fn command_reports_analytic_moduli_and_refuses_unstable_data() {
let mut s = Session::default();
@@ -85,6 +125,11 @@ mod tests {
let card = json!({"schema_version":1,"designation":"Synthetic","family":"other","sources":[],"properties":[]});
s.execute("material.properties.validate", &json!({"card":card})).unwrap();
assert!(s.execute("material.properties.validate", &json!({"card":false})).is_err());
let elastic = json!({"schema_version":1,"designation":"Synthetic","family":"other",
"sources":[{"id":"test","title":"Original fixture","kind":"illustrative"}],
"properties":[{"source_id":"test","data":{"kind":"elasticity","model":{"symmetry":"isotropic","young_modulus_pa":12e9,"poisson_ratio":0.25}}}]});
s.execute("material.properties.directional", &json!({"card":elastic,"direction":[1,2,3]})).unwrap();
assert!(s.execute("material.properties.directional", &json!({"card":elastic,"direction":[0,0,0]})).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert!(s.model.is_stale());
assert_eq!(before, (s.revision, s.evaluations, s.active_sketch, s.undo.len(), s.redo.len()));
+6
View File
@@ -38,12 +38,14 @@ mod config;
mod file;
mod inspect;
pub(crate) mod joints;
mod libraries;
mod material_properties;
mod materials;
mod measure_sel;
mod mesh;
pub(crate) mod motion;
mod parts;
mod script;
mod section;
pub(crate) use measure_sel::measure_items;
#[cfg(test)]
@@ -51,6 +53,8 @@ mod kernel_heal_tests;
mod parameters;
mod plastic;
mod render;
#[cfg(test)]
mod script_tests;
mod sheet;
mod sketch;
pub(crate) use sketch::author_path_for_rebind;
@@ -245,11 +249,13 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(clipboard::COMMANDS.iter());
v.extend(script::COMMANDS.iter());
v.extend(parameters::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(material_properties::COMMANDS.iter());
v.extend(libraries::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
+51 -12
View File
@@ -1,5 +1,7 @@
//! Contact Sets, Motion Study and Exploded views.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use solidcraft_doc::Mat;
use solidcraft_doc::joints::{self, JointKind};
@@ -8,7 +10,7 @@ use solidcraft_geom::Vec3;
use solidcraft_kernel::Body;
use super::CommandSpec;
use super::joints::{joint_id, occurrence_id, resolve, value};
use super::joints::{joint_id, occurrence_id, resolve_holding, value};
use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
@@ -254,27 +256,41 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
a + ab * (vb / den) + ac * (vc / den)
}
/// Set joint values, stopping short where contact sets would collide: the values are moved
/// from where they are toward `want` as far as they go without a collision.
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value> {
/// Set joint values, stopping short where contact sets would collide or a closed loop could no
/// longer close: the values are moved from where they are toward `want` as far as they go. The
/// `given` values (joint, index) are driven; in a closed loop the others follow.
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSet<(u64, usize)>) -> Result<Value> {
let baseline = s.doc.clone();
let start: Vec<(u64, Vec<f64>)> =
want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect();
// Closed loops follow the driven values from where they start.
let held: joints::Held = given
.iter()
.filter(|&&(id, i)| want.iter().any(|(w, q)| *w == id && i < q.len()))
.map(|&(id, i)| {
// A value the file leaves out is 0, as the solver reads it.
let from = start.iter().find(|(s, _)| *s == id).and_then(|(_, q)| q.get(i).copied());
((id, i), from.unwrap_or(0.0))
})
.collect();
let set = |s: &mut Session, t: f64| {
// Trial order must not affect free anchors or linked joint placements.
s.doc = baseline.clone();
let doc = s.doc_mut();
for ((id, a), (_, b)) in start.iter().zip(want) {
if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) {
let mut q: Vec<f64> = b.iter().enumerate().map(|(i, bv)| a.get(i).copied().unwrap_or(*bv) * (1.0 - t) + bv * t).collect();
let mut q: Vec<f64> = b.iter().enumerate().map(|(i, bv)| a.get(i).copied().unwrap_or(0.0) * (1.0 - t) + bv * t).collect();
joints::clamp(j, &mut q);
j.values = q;
}
}
resolve(s)
resolve_holding(s, &held)
};
// Loops already open at the start stay as they are; a loop the motion opens stops it.
let open_at_start: BTreeSet<u64> = set(s, 0.0).conflicts.iter().map(|c| c.0).collect();
let opened = |sol: &joints::Solution| sol.conflicts.iter().find(|c| !open_at_start.contains(&c.0)).map(|c| c.1.clone());
let cache = if s.doc.assembly.contact_enabled { Some(contact_sweep::ContactGeometry::new(s)?) } else { None };
let sol;
let mut sol;
let mut stopped = None;
if let Some(cache) = cache.filter(|cache| cache.has_pairs()) {
let mut end = s.scratch();
@@ -313,16 +329,33 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value
} else {
sol = set(s, 1.0);
}
let mut stopped_by_loop = None;
if let Some(mut name) = opened(&sol) {
// The furthest the loops still close, short of where contact stopped; the joint named is
// the one whose loop opens first.
let (mut lo, mut hi) = (0.0, stopped.as_ref().and_then(|v| v["fraction"].as_f64()).unwrap_or(1.0));
for _ in 0..14 {
let mid = (lo + hi) / 2.0;
match opened(&set(s, mid)) {
None => lo = mid,
Some(n) => (hi, name) = (mid, n),
}
}
sol = set(s, lo);
stopped = None;
stopped_by_loop = Some(json!({"joint": name, "fraction": lo}));
}
let values: Vec<Value> = want
.iter()
.map(|(id, _)| json!({"joint": id, "values": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone())}))
.collect();
Ok(json!({"values": values, "stopped_by_contact": stopped, "conflicts": sol.conflicts.len()}))
Ok(json!({"values": values, "stopped_by_contact": stopped, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()}))
}
/// Drive one joint (joint.drive), honouring contact sets.
pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>) -> Result<Value> {
drive_with_contact(s, &[(id, q)])
pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>, given: usize) -> Result<Value> {
let given = (0..given.min(q.len())).map(|i| (id, i)).collect();
drive_with_contact(s, &[(id, q)], &given)
}
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
@@ -517,6 +550,11 @@ fn values_at(s: &Session, m: &MotionStudy, step: f64) -> Vec<(u64, Vec<f64>)> {
out
}
/// The values a study keys: (joint, index).
fn keyed(m: &MotionStudy) -> BTreeSet<(u64, usize)> {
m.keys.iter().map(|k| (k.joint, k.index)).collect()
}
/// Where each occurrence is: (name, world transform).
fn positions(s: &Session) -> Vec<(String, Mat)> {
s.doc.occurrences.iter().map(|o| (o.name.clone(), joints::occurrence_world(&s.doc, o.id))).collect()
@@ -532,7 +570,7 @@ fn play(s: &mut Session, p: &Value) -> Result<Value> {
let m = s.doc.assembly.studies.get(i).cloned().ok_or_else(|| bad(cmd, "study"))?;
let step = p.get("step").and_then(Value::as_f64).filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`step` must be a number"))?;
let want = values_at(s, &m, step.clamp(0.0, f64::from(m.steps)));
let mut out = drive_with_contact(s, &want)?;
let mut out = drive_with_contact(s, &want, &keyed(&m))?;
out["positions"] = positions(s).iter().map(|(n, t)| json!({"occurrence": n, "pose": pose_json(t)})).collect();
Ok(out)
}
@@ -551,10 +589,11 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
for k in 0..samples {
let step = f64::from(m.steps) * k as f64 / (samples - 1) as f64;
let want = values_at(&t, &m, step);
let r = drive_with_contact(&mut t, &want)?;
let r = drive_with_contact(&mut t, &want, &keyed(&m))?;
rows.push(json!({
"step": step,
"stopped_by_contact": r["stopped_by_contact"],
"stopped_by_loop": r["stopped_by_loop"],
"positions": positions(&t).iter().map(|(n, x)| json!({"occurrence": n, "pose": pose_json(x)})).collect::<Vec<_>>(),
}));
}
+1 -1
View File
@@ -130,7 +130,7 @@ fn motion_study_plays_and_exports() {
// The B occurrence's origin moves by the slider value (it starts at 0 offset with value 0 at 10 mm… so 10 → −5).
assert!((bx(&rows[1]) - bx(&rows[0]) + 3.75).abs() < 1e-6, "{e}");
assert!(rows[4]["stopped_by_contact"].is_object());
let dir = std::env::temp_dir().join(format!("solidcraft-motion-{}", std::process::id()));
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-motion-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("push.csv");
run(&mut s, "motion.export", json!({"study": "Push", "samples": 3, "path": csv.to_string_lossy()}));
+289
View File
@@ -0,0 +1,289 @@
//! A design's history as a command script (#886): `timeline.export_script` writes the steps
//! that rebuild the design (`solidcraft-cli eval` / `run` replay them), and `timeline.add`
//! appends one recorded feature, as the design file holds it, so every feature kind replays
//! exactly: same ids, names, parameter names and stored edge and face names. Trimming the
//! script after a step gives the design as it was at that step.
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Value, json};
use solidcraft_doc::{Document, Feature, FeatureKind, Param, expr};
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("timeline.add", "Add Recorded Feature", add).params(
"feature: a feature as the design file and timeline.inspect hold it ({type, name?, id?, …}; id and name are kept when free, \
so later steps can refer to them); params?: [{name, expression, unit?, comment?}] (the model parameters its sketch \
dimensions use); the feature goes at the marker → feature, name, error, warning",
),
CommandSpec::new("timeline.export_script", "Export Command Script", export_script).noundo().doc_only().params(
"path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \
steps, omitted: what the script leaves out (appearances, folders, views, configurations); components and assemblies \
are not supported yet",
),
];
/// Most model parameters one `timeline.add` step may bring (a sketch's dimensions).
const MAX_STEP_PARAMS: usize = solidcraft_sketch::MAX_CONSTRAINTS;
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.add";
let raw = p
.get("feature")
.and_then(Value::as_object)
.ok_or_else(|| bad(cmd, "`feature` must be a feature object, as a design file or timeline.inspect has it"))?;
let feature = recorded_feature(cmd, raw)?;
let params = step_params(cmd, p.get("params"))?;
let id = s.doc_mut().insert_feature(feature, params)?;
s.refresh();
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let r = s.model.result(id);
Ok(json!({"feature": id, "name": name, "error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone())}))
}
/// The feature, through the checks a design file gets when it opens (numbers, sketch sizes, 3D
/// curves, face attachments…), so a script step can hold nothing a design could not.
fn recorded_feature(cmd: &str, raw: &Map<String, Value>) -> Result<Feature> {
let mut raw = raw.clone();
// A hand-written step may leave out the id and the name: they are then given new ones.
raw.entry("id").or_insert(json!(0));
raw.entry("name").or_insert(json!(""));
// Opening fills in missing input names with the next free `dN` of the one-feature design
// below; the real design names them instead (`Document::insert_feature`).
let inputs = match raw.get("param_names") {
None => Vec::new(),
Some(Value::Array(a)) if a.len() <= 1000 => {
a.iter().map(|n| n.as_str().map(str::to_string)).collect::<Option<Vec<_>>>().ok_or_else(|| bad(cmd, "`param_names` must be names"))?
}
Some(_) => return Err(bad(cmd, "`param_names` must be a list of names")),
};
let design = json!({
"format": solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
"name": "",
"params": [],
"features": [Value::Object(raw)],
});
let mut d = Document::from_json(&design.to_string()).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
let mut f = d.features.pop().ok_or_else(|| bad(cmd, "invalid feature"))?;
f.param_names = inputs;
Ok(f)
}
fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
let list = v.as_array().filter(|a| a.len() <= MAX_STEP_PARAMS).ok_or_else(|| bad(cmd, "`params` must be a list of parameters"))?;
let text = |p: &Value, k: &str| match p.get(k) {
None | Some(Value::Null) => Some(String::new()),
Some(Value::String(s)) => Some(s.clone()),
Some(Value::Number(n)) if k == "expression" => n.as_f64().filter(|x| x.is_finite()).map(|x| x.to_string()),
Some(_) => None,
};
let mut out = Vec::with_capacity(list.len());
for p in list {
let name = str_(p, "name").ok_or_else(|| bad(cmd, "each parameter needs a `name`"))?;
let expr =
text(p, "expression").filter(|e| !e.trim().is_empty()).ok_or_else(|| bad(cmd, format!("parameter `{name}` needs an `expression`")))?;
let unit = text(p, "unit").ok_or_else(|| bad(cmd, format!("parameter `{name}`: `unit` must be text")))?;
let comment = text(p, "comment").ok_or_else(|| bad(cmd, format!("parameter `{name}`: `comment` must be text")))?;
out.push(Param { name: name.trim().to_string(), expr: expr.trim().to_string(), unit: unit.trim().to_string(), comment, model: true });
}
Ok(out)
}
fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.export_script";
let (commands, omitted) = script_steps(&s.doc)?;
let steps = commands.len();
let script = json!({
"about": {
"design": s.doc.name,
"format": solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
"version": env!("CARGO_PKG_VERSION"),
"replay": "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)",
},
"commands": commands,
});
let path = str_(p, "path");
if let Some(path) = path {
let text = serde_json::to_string_pretty(&script).map_err(|e| EngineError::Other(e.to_string()))?;
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
} else if p.get("path").is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, "`path` must be a file path"));
}
Ok(json!({"script": script, "steps": steps, "omitted": omitted, "path": path}))
}
fn step(command: &str, params: Value) -> Value {
json!({"command": command, "params": params})
}
fn not_supported(what: &str) -> EngineError {
EngineError::Other(format!("not supported yet: a command script of a design with {what} (#886)"))
}
/// The steps that rebuild `doc` on an empty design, and what they leave out.
pub(crate) fn script_steps(doc: &Document) -> Result<(Vec<Value>, Vec<String>)> {
if !doc.components.is_empty() || !doc.occurrences.is_empty() || !doc.parts.is_empty() || !doc.links.is_empty() {
return Err(not_supported("components"));
}
if !doc.assembly.is_empty() {
return Err(not_supported("joints"));
}
if !doc.body_components.is_empty()
|| !doc.body_offsets.is_empty()
|| doc.features.iter().any(|f| f.component != 0 || matches!(f.kind, FeatureKind::BodyTransfer { .. }))
{
return Err(not_supported("bodies moved between components"));
}
let mut omitted = Vec::new();
for (empty, what) in [
(doc.appearances.is_empty(), "appearances (colours of bodies and faces)"),
(doc.configs.is_empty(), "configurations"),
(doc.canvases.is_empty(), "canvases (reference images)"),
(doc.named_views.is_empty(), "named views"),
(doc.browser_groups.is_empty() && doc.browser_order.is_empty(), "Browser folders and order"),
(doc.timeline_groups.is_empty(), "timeline folders"),
(doc.visibility.is_empty(), "hidden items"),
] {
if !empty {
omitted.push(what.to_string());
}
}
let mut out = Vec::new();
if doc.units != "mm" {
out.push(step("document.units", json!({"units": doc.units})));
}
for rule in &doc.sheet.rules {
let mut p = serde_json::to_value(rule).map_err(|e| EngineError::Other(e.to_string()))?;
if doc.sheet.active.as_deref() == Some(rule.name.as_str())
&& let Some(o) = p.as_object_mut()
{
o.insert("active".into(), json!(true));
}
out.push(step("sheet.manage_rules", p));
}
if let Some(active) = doc.sheet.active.as_deref().filter(|a| !doc.sheet.rules.iter().any(|r| r.name == *a)) {
out.push(step("sheet.manage_rules", json!({"name": active, "active": true})));
}
for rule in &doc.plastic.rules {
out.push(step("plastic.manage_rules", serde_json::to_value(rule).map_err(|e| EngineError::Other(e.to_string()))?));
}
if let Some(active) = &doc.plastic.active {
out.push(step("plastic.manage_rules", json!({"name": active, "active": true})));
}
for m in &doc.user_materials {
let mut p = json!({"name": m.name, "density_g_cm3": m.density});
if let (Some(look), Some(o)) = (&m.appearance, p.as_object_mut()) {
// A colour of its own carries the material's name; otherwise it is a library look.
if look.name == m.name {
o.insert("color".into(), json!(look.color));
} else {
o.insert("appearance".into(), json!(look.name));
}
o.insert("opacity".into(), json!(look.opacity));
}
out.push(step("material.create", p));
}
// Each sketch brings the model parameters of its dimensions.
let models: BTreeMap<&str, &Param> = doc.params.iter().filter(|p| p.model).map(|p| (p.name.as_str(), p)).collect();
let mut owned: BTreeSet<&str> = BTreeSet::new();
let mut brings: BTreeMap<u64, Vec<&Param>> = BTreeMap::new();
for f in &doc.features {
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
for name in sketch.constraints.iter().filter_map(|c| c.param.as_deref()) {
if let Some(p) = models.get(name)
&& owned.insert(name)
{
brings.entry(f.id).or_default().push(p);
}
}
}
}
if let Some(p) = models.keys().find(|n| !owned.contains(*n)) {
return Err(not_supported(&format!("model parameter `{p}` outside a sketch dimension")));
}
// User parameters go in as soon as everything they refer to exists, so each step sees the
// values the design has (parameters are global; the order only has to let each one evaluate).
let mut known: BTreeSet<String> = BTreeSet::new();
let mut pending: Vec<&Param> = doc.params.iter().filter(|p| !p.model).collect();
let flush = |pending: &mut Vec<&Param>, known: &mut BTreeSet<String>, out: &mut Vec<Value>| {
while let Some(i) = pending.iter().position(|p| expr::references(&p.expr).iter().all(|r| known.contains(r))) {
let p = pending.remove(i);
let mut params = json!({"name": p.name, "expression": p.expr, "unit": p.unit});
if let Some(o) = params.as_object_mut() {
if !p.comment.is_empty() {
o.insert("comment".into(), json!(p.comment));
}
if doc.favorites.contains(&p.name) {
o.insert("favorite".into(), json!(true));
}
}
out.push(step("parameters.add", params));
known.insert(p.name.clone());
}
};
for f in &doc.features {
flush(&mut pending, &mut known, &mut out);
let feature = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
let mut params = json!({"feature": feature});
if let (Some(list), Some(o)) = (brings.get(&f.id), params.as_object_mut()) {
let list: Vec<Value> = list
.iter()
.map(|p| {
let mut v = json!({"name": p.name, "expression": p.expr, "unit": p.unit});
if let (false, Some(o)) = (p.comment.is_empty(), v.as_object_mut()) {
o.insert("comment".into(), json!(p.comment));
}
v
})
.collect();
o.insert("params".into(), Value::Array(list));
known.extend(brings.get(&f.id).into_iter().flatten().map(|p| p.name.clone()));
}
known.extend(f.param_names.iter().filter(|n| !n.is_empty()).cloned());
out.push(step("timeline.add", params));
}
flush(&mut pending, &mut known, &mut out);
if let Some(p) = pending.first() {
return Err(EngineError::Other(format!(
"parameter `{}` refers to a parameter this design does not define (or to itself): fix it before exporting a script",
p.name
)));
}
// Comments and favourites of feature inputs and dimensions.
for (name, comment) in &doc.param_comments {
out.push(step("parameters.comment", json!({"name": name, "comment": comment})));
}
let users: BTreeSet<&str> = doc.params.iter().filter(|p| !p.model).map(|p| p.name.as_str()).collect();
for name in doc.favorites.iter().filter(|n| !users.contains(n.as_str())) {
out.push(step("parameters.favorite", json!({"name": name, "favorite": true})));
}
// Materials and plastic rules of bodies, once the bodies exist.
let mut by_material: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (body, material) in &doc.materials {
by_material.entry(material.as_str()).or_default().push(body.as_str());
}
for (material, bodies) in by_material {
out.push(step("material.assign", json!({"bodies": bodies, "material": material})));
}
let mut by_rule: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (body, rule) in &doc.plastic.assigned {
by_rule.entry(rule.as_str()).or_default().push(body.as_str());
}
for (rule, bodies) in by_rule {
out.push(step("plastic.assign_rule", json!({"bodies": bodies, "rule": rule})));
}
if let Some(m) = doc.marker.filter(|m| *m < doc.features.len()) {
out.push(step("timeline.rollback", json!({"position": m})));
}
Ok((out, omitted))
}
+236
View File
@@ -0,0 +1,236 @@
//! `timeline.export_script` and `timeline.add` (#886): a design's script replays into the same
//! design, and a recorded feature that does not fit the design is refused, changing nothing.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn script(s: &mut Session) -> Value {
let r = run(s, "timeline.export_script", json!({}));
// Through text, as a script file carries it.
serde_json::from_str(&r["script"].to_string()).unwrap()
}
fn replay(script: &Value) -> Session {
let mut t = Session::default();
if let Err(e) = t.run_script(script) {
panic!("replay: {e}\n{script:#}");
}
t
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn params(s: &Session) -> Vec<solidcraft_doc::Param> {
let mut v = s.doc.params.clone();
v.sort_by(|a, b| a.name.cmp(&b.name));
v
}
fn errors(s: &Session) -> Vec<(u64, Option<String>)> {
s.model.results.iter().map(|r| (r.id, r.error.clone())).collect()
}
fn same(a: &mut Session, b: &mut Session) {
assert_eq!(a.doc.features, b.doc.features, "timeline");
assert_eq!(params(a), params(b), "parameters");
assert_eq!(a.doc.marker, b.doc.marker, "marker");
assert_eq!(a.doc.favorites, b.doc.favorites, "favourites");
assert_eq!(a.doc.param_comments, b.doc.param_comments, "comments");
assert_eq!(a.doc.materials, b.doc.materials, "materials");
assert_eq!(a.doc.user_materials, b.doc.user_materials, "user materials");
assert_eq!(errors(a), errors(b), "feature results");
assert_eq!(volume(a), volume(b), "volume");
}
fn commands(script: &Value) -> Vec<&str> {
script["commands"].as_array().into_iter().flatten().filter_map(|c| c["command"].as_str()).collect()
}
/// A plate from a dimensioned sketch, with a user parameter driving a dimension.
fn plate(s: &mut Session) {
run(s, "parameters.add", json!({"name": "w", "expression": "40 mm", "comment": "plate width", "favorite": true}));
run(s, "parameters.add", json!({"name": "count", "expression": "3", "unit": ""}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "w * 0.75"}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Base", "distance": "10 mm"}));
}
#[test]
fn a_dimensioned_part_replays_into_the_same_design() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": "2 mm"}));
run(&mut s, "solid.chamfer", json!({"edges": [[20, 30, 10]], "distance": "1 mm"}));
let sc = script(&mut s);
let cmds = commands(&sc);
assert_eq!(cmds.iter().filter(|c| **c == "timeline.add").count(), 4, "{cmds:?}");
assert_eq!(cmds.first(), Some(&"parameters.add"), "user parameters come first: {cmds:?}");
let mut t = replay(&sc);
same(&mut s, &mut t);
// Replayed parameters still drive the part.
run(&mut t, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
same(&mut s, &mut t);
}
#[test]
fn ids_names_suppression_marker_and_failures_survive() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0], "name": "Gone"}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0], "name": "Kept box"}));
// A gap in the ids, a renamed feature, a suppressed one.
run(&mut s, "timeline.delete", json!({"features": ["Gone"]}));
plate(&mut s);
run(&mut s, "timeline.suppress", json!({"feature": "Kept box", "suppressed": true}));
// A feature input named by a user parameter that refers back to it.
let extrude = s.doc.features.last().unwrap().clone();
let depth = extrude.param_names.first().cloned().unwrap();
run(&mut s, "parameters.add", json!({"name": "twice_depth", "expression": format!("{depth} * 2")}));
run(&mut s, "parameters.comment", json!({"name": depth, "comment": "plate depth"}));
run(&mut s, "parameters.favorite", json!({"name": depth}));
// A fillet that a parameter change makes too big fails, and the design keeps it.
run(&mut s, "parameters.add", json!({"name": "r", "expression": "1 mm"}));
run(&mut s, "solid.fillet", json!({"edges": [[5, 0, 10]], "radius": "r"}));
let fillet = s.doc.features.last().unwrap().id;
run(&mut s, "parameters.change", json!({"name": "r", "expression": "50 mm"}));
assert!(s.model.result(fillet).and_then(|r| r.error.clone()).is_some(), "the fillet fails");
run(&mut s, "timeline.rollback", json!({"position": 3}));
let mut t = replay(&script(&mut s));
same(&mut s, &mut t);
// The script up to a feature is the design rolled back to just after it.
let full = script(&mut s);
let cmds = full["commands"].as_array().unwrap();
let cut = cmds.iter().position(|c| c["params"]["feature"]["type"] == "extrude").unwrap();
let mut part = replay(&json!({"commands": cmds[..=cut].to_vec()}));
let ids: Vec<u64> = part.doc.features.iter().map(|f| f.id).collect();
assert_eq!(ids, s.doc.features.iter().map(|f| f.id).take(ids.len()).collect::<Vec<_>>());
run(&mut s, "timeline.rollback", json!({"position": ids.len()}));
assert_eq!(volume(&mut part), volume(&mut s));
}
#[test]
fn units_materials_and_rules_come_back() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "material.create", json!({"name": "Test resin", "density_g_cm3": 1.3, "color": "#336699", "opacity": 0.5}));
run(&mut s, "solid.box", json!({"length": "1 in", "width": "1 in", "height": "1 in", "body_name": "Cube"}));
run(&mut s, "material.assign", json!({"bodies": ["Cube"], "material": "Test resin"}));
run(&mut s, "sheet.manage_rules", json!({"name": "Thin", "thickness": "0.8 mm", "active": true}));
let mut t = replay(&script(&mut s));
same(&mut s, &mut t);
assert_eq!(t.doc.units, "in");
assert_eq!(t.doc.sheet, s.doc.sheet);
let a = run(&mut s, "inspect.measure", json!({}))["total"].clone();
let b = run(&mut t, "inspect.measure", json!({}))["total"].clone();
assert_eq!(a, b, "mass comes from the replayed material");
}
#[test]
fn what_the_script_leaves_out_is_listed_and_components_are_refused() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
run(&mut s, "appearance.assign", json!({"bodies": ["B"], "color": "#ff0000"}));
let r = run(&mut s, "timeline.export_script", json!({}));
assert!(r["omitted"].to_string().contains("appearances"), "{r}");
run(&mut s, "component.create", json!({"name": "Sub"}));
let e = s.execute("timeline.export_script", &json!({})).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
}
#[test]
fn a_recorded_feature_that_does_not_fit_is_refused_and_changes_nothing() {
let mut s = Session::default();
plate(&mut s);
let sc = script(&mut s);
let sketch = sc["commands"].as_array().unwrap().iter().find(|c| c["params"]["feature"]["type"] == "sketch").unwrap()["params"].clone();
let extrude = sc["commands"].as_array().unwrap().iter().find(|c| c["params"]["feature"]["type"] == "extrude").unwrap()["params"].clone();
let before = (*s.doc).clone();
let with = |base: &Value, path: &[&str], v: Value| {
let mut p = base.clone();
let mut at = &mut p;
for k in &path[..path.len() - 1] {
at = &mut at[*k];
}
at[path[path.len() - 1]] = v;
p
};
let refused = [
("id taken", with(&extrude, &["feature", "name"], json!("Other"))),
("name taken", with(&extrude, &["feature", "id"], json!(0))),
("id out of range", with(&with(&extrude, &["feature", "id"], json!(1u64 << 60)), &["feature", "name"], json!("X"))),
("parameter taken", with(&with(&sketch, &["feature", "id"], json!(0)), &["feature", "name"], json!("S2"))),
(
"bad parameter name",
with(
&json!({"feature": {"type": "box", "length": "1", "width": "1", "height": "1"}}),
&["params"],
json!([{"name": "1x", "expression": "1"}]),
),
),
(
"unknown unit",
with(
&json!({"feature": {"type": "box", "length": "1", "width": "1", "height": "1"}}),
&["params"],
json!([{"name": "q", "expression": "1", "unit": "parsec"}]),
),
),
("bad input names", with(&extrude, &["feature", "param_names"], json!([1, 2]))),
("unknown type", json!({"feature": {"type": "warp_drive"}})),
("huge number", json!({"feature": {"type": "box", "length": "1", "width": "1", "height": "1", "corner": [1e300, 0, 0]}})),
("unknown component", json!({"feature": {"type": "box", "length": "1", "width": "1", "height": "1", "component": 9}})),
("not an object", json!({"feature": [1, 2, 3]})),
];
for (what, p) in refused {
assert!(s.execute("timeline.add", &p).is_err(), "{what}: {p}");
assert_eq!(*s.doc, before, "{what}: nothing changed");
}
// A hand-written step needs only the feature's own fields.
let r = run(&mut s, "timeline.add", json!({"feature": {"type": "box", "length": "5", "width": "5", "height": "5", "corner": [100, 0, 0]}}));
assert!(r["error"].is_null(), "{r}");
assert_eq!(r["name"], "Box1");
assert!(s.doc.feature(r["feature"].as_u64().unwrap()).is_some_and(|f| !f.param_names.is_empty()), "its inputs get parameter names");
}
#[test]
fn the_script_is_written_to_a_file_that_replays() {
let mut s = Session::default();
plate(&mut s);
let dir = std::env::temp_dir().join(format!("solidcraft-script-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("repro.json");
let r = run(&mut s, "timeline.export_script", json!({"path": path.to_string_lossy()}));
let text = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let written: Value = serde_json::from_str(&text).unwrap();
assert_eq!(written, r["script"]);
let mut t = replay(&written);
same(&mut s, &mut t);
assert!(s.execute("timeline.export_script", &json!({"path": 7})).is_err(), "a path is text");
}
#[test]
fn replayed_features_claiming_the_same_body_name_do_not_crash() {
let mut s = Session::default();
let boxed = |name: &str, x: f64| json!({"feature": {"type": "box", "name": name, "length": "5", "width": "5", "height": "5", "corner": [x, 0, 0], "body_names": ["Twin"]}});
run(&mut s, "timeline.add", boxed("A", 0.0));
let r = s.execute("timeline.add", &boxed("B", 20.0));
assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "{r:?}");
let m = run(&mut s, "inspect.measure", json!({}));
assert!(m["body_count"].as_u64().is_some_and(|n| n >= 1), "{m}");
}
+103 -78
View File
@@ -20,6 +20,9 @@ pub static COMMANDS: &[CommandSpec] = &[
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body). \
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused",
),
CommandSpec::new("sheet.perforate", "Perforate", perforate).at("SHEET METAL", "CREATE").icon("hem").params(
"diameter, pitch: expr; margin?: expr (clear of edges, cut-outs and bends; default: the diameter); pattern?: staggered (60°, default) | square; body? - a perforation field over the whole flat sheet: holes in the flat pattern (perforations), the DXF (layer PERFORATION) and the measured volume, not cut into the B-rep (#732)",
),
CommandSpec::new("sheet.hem", "Hem", hem)
.at("SHEET METAL", "CREATE")
.icon("hem")
@@ -57,7 +60,7 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
.noundo()
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
.params("body?, path?, curves?: exact|arcs (default exact: an ellipse as ELLIPSE, splines, text outlines and other Béziers as SPLINE; arcs: tangent arcs and lines within 0.001 mm, for machines that read only those) → DXF (AutoCAD 2000, mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
.noundo()
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"),
@@ -186,6 +189,23 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
}
}
fn perforate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.perforate";
let diameter = req_expr(cmd, p, "diameter")?;
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
let pitch = req_expr(cmd, p, "pitch")?;
check_expr(s, &pitch, Kind::Length, cmd, "pitch")?;
let margin = expr(p, "margin");
if let Some(m) = &margin {
check_expr(s, m, Kind::Length, cmd, "margin")?;
}
let pattern = str_(p, "pattern").map(str::to_string);
if pattern.as_deref().is_some_and(|t| t != "staggered" && t != "square") {
return Err(bad(cmd, "`pattern` is \"staggered\" (60°, default) or \"square\""));
}
add_feature(s, p, FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body: str_(p, "body").map(str::to_string) })
}
fn hem(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.hem";
let edges = points(p, "edges", cmd)?;
@@ -442,7 +462,24 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
.iter()
.map(|b| json!({"start": [b.base.x, b.base.y, b.base.z], "axis": [b.axis.x, b.axis.y, b.axis.z], "diameter": b.radius * 2.0, "length": b.height.abs()}))
.collect();
let mut out = json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat});
let mut perforations = Vec::new();
for p in &sh.perforations {
let n = sh.perforation_holes(p)?.len();
perforations.push(json!({"feature": p.feature, "diameter": p.diameter, "pitch": p.pitch, "margin": p.margin,
"pattern": if p.square { "square" } else { "staggered" }, "count": n,
"open_area_mm2": n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2)}));
}
let bend_zones: Vec<Value> = sh
.bend_zones()
.iter()
.map(|z| {
let pts = z.outer.polyline(1e-3);
let f = |g: fn(&Vec2) -> f64, m: fn(f64, f64) -> f64, s: f64| pts.iter().map(g).fold(s, m);
json!([f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)])
})
.collect();
let mut out = json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat,
"perforations": perforations, "bend_zones": bend_zones});
if !bores.is_empty() {
out["bores"] = Value::Array(bores);
}
@@ -454,8 +491,20 @@ fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
flat_json(&sh)
}
/// A DXF (R12 entities, mm) of the flat pattern on named layers.
pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
/// How the flat DXF writes the curves that are neither lines nor circular arcs.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DxfCurves {
/// Exactly: an ellipse as ELLIPSE, Béziers (spline pieces, text outlines) and the other conics as SPLINE.
#[default]
Exact,
/// As tangent arcs and lines within 0.001 mm, for machines that read only those.
Arcs,
}
/// A DXF (AutoCAD 2000, mm) of the flat pattern on named layers: OUTLINE, BEND_UP and BEND_DOWN
/// (centre lines), and one layer per pocket depth. Free-form curves as `curves` says.
pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
use solidcraft_io::{DxfEntity, DxfLayer, DxfShape};
let regions = sh.flat_regions()?;
let pockets = sh.machining()?;
let pocket_layer = |p: &solidcraft_doc::sheet::SheetPocket| {
@@ -472,102 +521,74 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
for p in &pockets {
layers.insert(pocket_layer(p));
}
let mut out = String::new();
let mut w = |c: i32, v: &str| out.push_str(&format!("{c}\n{v}\n"));
w(0, "SECTION");
w(2, "HEADER");
w(9, "$INSUNITS");
w(70, "4");
w(0, "ENDSEC");
w(0, "SECTION");
w(2, "TABLES");
w(0, "TABLE");
w(2, "LAYER");
w(70, &layers.len().to_string());
for name in &layers {
let colour = match name.as_str() {
"OUTLINE" => "7",
"BEND_UP" => "1",
"BEND_DOWN" => "5",
_ => "3",
};
w(0, "LAYER");
w(2, name);
w(70, "0");
w(62, colour);
w(6, "CONTINUOUS");
let perforation: Vec<(Vec2, f64)> = sh
.perforations
.iter()
.map(|p| sh.perforation_holes(p).map(|h| h.into_iter().map(|c| (c, p.diameter / 2.0)).collect::<Vec<_>>()))
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
if !perforation.is_empty() {
layers.insert("PERFORATION".to_string());
}
w(0, "ENDTAB");
w(0, "ENDSEC");
w(0, "SECTION");
w(2, "ENTITIES");
let f = |x: f64| format!("{x:.6}");
let line = |w: &mut dyn FnMut(i32, &str), layer: &str, a: Vec2, b: Vec2| {
w(0, "LINE");
w(8, layer);
w(10, &f(a.x));
w(20, &f(a.y));
w(11, &f(b.x));
w(21, &f(b.y));
};
let layers: Vec<DxfLayer> = layers
.into_iter()
.map(|name| {
let color = match name.as_str() {
"OUTLINE" => 7,
"BEND_UP" => 1,
"BEND_DOWN" => 5,
_ => 3,
};
DxfLayer { name, color }
})
.collect();
let mut loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|lp| (lp, "OUTLINE".to_string())).collect();
for p in &pockets {
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
loops.push((lp, pocket_layer(p)));
}
}
let mut entities = Vec::new();
for (h, layer) in loops {
// A circle is often represented by two semicircles. Keep the whole analytic
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
if let Some((center, radius)) = loop_circle(h) {
w(0, "CIRCLE");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
entities.push(DxfEntity { layer, shape: DxfShape::Circle { c: center, r: radius } });
continue;
}
for seg in &h.segs {
match *seg {
solidcraft_geom::Seg2::Arc { center, radius, start, sweep } => {
if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 {
w(0, "CIRCLE");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
continue;
}
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
w(0, "ARC");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
w(50, &f(a0.to_degrees().rem_euclid(360.0)));
w(51, &f(a1.to_degrees().rem_euclid(360.0)));
}
other => {
let pts = other.polyline(1e-3);
for p in pts.windows(2) {
line(&mut w, &layer, p[0], p[1]);
}
}
let fitted;
let h = match curves {
DxfCurves::Arcs if !h.is_line_arc() => {
fitted = h
.fit_arcs(solidcraft_kernel::PLANAR_FIT_TOL)
.ok_or_else(|| EngineError::Other("a flat-pattern curve could not be written as arcs".into()))?;
&fitted
}
}
_ => h,
};
// A whole ellipse is one ELLIPSE; every other segment is an entity of its own.
entities.extend(solidcraft_io::loop_shapes(h).into_iter().map(|shape| DxfEntity { layer: layer.clone(), shape }));
}
for b in sh.bends() {
line(&mut w, if b.up { "BEND_UP" } else { "BEND_DOWN" }, b.start, b.end);
entities.push(DxfEntity { layer: if b.up { "BEND_UP" } else { "BEND_DOWN" }.into(), shape: DxfShape::Line(b.start, b.end) });
}
w(0, "ENDSEC");
w(0, "EOF");
Ok(out)
for (c, r) in perforation {
entities.push(DxfEntity { layer: "PERFORATION".into(), shape: DxfShape::Circle { c, r } });
}
Ok(solidcraft_io::write_dxf_drawing(&layers, &entities))
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.export_dxf";
let curves = match str_(p, "curves").map(str::to_ascii_lowercase).as_deref() {
None | Some("exact") => DxfCurves::Exact,
Some("arcs") => DxfCurves::Arcs,
Some(_) => return Err(bad(cmd, "`curves` must be exact or arcs")),
};
let sh = sheet_of(s, p, cmd)?;
let text = flat_dxf(&sh)?;
let text = flat_dxf(&sh, curves)?;
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "bends": sh.bends().len()}));
@@ -626,3 +647,7 @@ mod tests;
#[cfg(test)]
#[path = "sheet_convert_tests.rs"]
mod convert_tests;
#[cfg(test)]
#[path = "sheet_dxf_tests.rs"]
mod dxf_tests;
+59 -1
View File
@@ -637,7 +637,9 @@ fn flat_circles(s: &mut Session) -> Vec<(f64, [f64; 2])> {
/// How many entities of each kind the flat pattern's DXF has.
fn dxf_entities(s: &mut Session) -> (usize, usize, usize) {
let dxf = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or("").replace("\r\n", "\n");
let n = |kind: &str| dxf.matches(&format!("\n0\n{kind}\n")).count();
// Group codes may be padded (` 0`): compare the code and value lines trimmed.
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let n = |kind: &str| lines.chunks(2).filter(|pair| pair == &["0", kind]).count();
(n("LINE"), n("ARC"), n("CIRCLE"))
}
@@ -745,3 +747,59 @@ fn a_plate_with_holes_drilled_here_converts_with_round_holes() {
assert!(circles.len() == 9 && circles.iter().all(|(radius, _)| (radius - r).abs() < 1e-9), "{circles:?}");
assert_eq!(dxf_entities(&mut s), (4, 0, 9));
}
/// A box `l` x `w` x `t` with round holes (centre, diameter) drilled through it, converted,
/// picked on its top face.
fn convert_box(l: f64, w: f64, t: f64, holes: &[([f64; 2], f64)]) -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": l, "width": w, "height": t, "body_name": "Plate"}));
for (c, d) in holes {
run(&mut s, "solid.hole", json!({"position": [c[0], c[1], t], "diameter": d}));
}
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [l * 0.4, w * 0.25, t]}));
s
}
/// A long thin strip converts. 1200 x 100 x 1 was refused with "no outline on that face": the
/// outline's tolerance was a thousandth of the body's size (1.2), more than the thickness, so the
/// far side's triangles counted as the picked face's and no boundary was left.
#[test]
fn a_long_thin_strip_converts() {
let mut s = convert_box(1200.0, 100.0, 1.0, &[]);
assert!((volume(&mut s) - 120_000.0).abs() < 1e-6 * 120_000.0, "{}", volume(&mut s));
assert!(same_size(flat_size(&mut s), (1200.0, 100.0), 1e-6), "{:?}", flat_size(&mut s));
let t = flat(&mut s)["flat_size_mm"][2].as_f64().unwrap_or(f64::NAN);
assert!((t - 1.0).abs() < 1e-9, "{t}");
assert_eq!(dxf_entities(&mut s), (4, 0, 0));
}
/// Plates further past that, a thousandth of their size two to six times their thickness: a
/// full sheet, a foil, a long bar.
#[test]
fn plates_thousands_of_times_longer_than_thick_convert() {
for (l, w, t) in [(3000.0, 1500.0, 0.5), (400.0, 100.0, 0.2), (6000.0, 20.0, 1.0)] {
let mut s = convert_box(l, w, t, &[]);
let want = l * w * t;
assert!((volume(&mut s) - want).abs() < 1e-6 * want, "{l} x {w} x {t}: {} vs {want}", volume(&mut s));
assert!(same_size(flat_size(&mut s), (l, w), 1e-6), "{l} x {w} x {t}: {:?}", flat_size(&mut s));
assert_eq!(dxf_entities(&mut s), (4, 0, 0), "{l} x {w} x {t}");
}
}
/// The strip with round holes down to its thickness across keeps every one as a circle. At 1200
/// long it was refused; just under the old limit, 990 long, it converted without the 1 mm hole:
/// the outline's chords could sag 0.99, more than that hole's radius, and it fell out.
#[test]
fn a_long_thin_strip_keeps_its_small_round_holes() {
let d = [10.0, 4.0, 2.0, 1.0];
for l in [990.0, 1200.0] {
let holes: Vec<([f64; 2], f64)> = d.iter().enumerate().map(|(i, d)| ([l * (i as f64 + 1.0) / 5.0, 50.0], *d)).collect();
let mut s = convert_box(l, 100.0, 1.0, &holes);
let want = l * 100.0 - PI / 4.0 * d.iter().map(|d| d * d).sum::<f64>();
assert!((volume(&mut s) - want).abs() < 1e-5 * want, "{l}: {} vs {want}", volume(&mut s));
let mut radii: Vec<f64> = flat_circles(&mut s).iter().map(|c| c.0).collect();
radii.sort_by(f64::total_cmp);
assert!(radii.len() == 4 && radii.iter().zip([0.5, 1.0, 2.0, 5.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{l}: {radii:?}");
assert_eq!(dxf_entities(&mut s), (4, 0, 4), "{l}");
}
}
+221
View File
@@ -0,0 +1,221 @@
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities.
use std::f64::consts::{PI, TAU};
use serde_json::{Value, json};
use solidcraft_geom::{Seg2, Vec2};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// A 100 × 60 base flange (the default 2.5 mm rule) with `curves` drawn in a sketch and cut through it.
fn plate_with(curves: &[(&str, Value)]) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
for (command, params) in curves {
run(&mut s, command, params.clone());
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
s
}
fn export(s: &mut Session, params: Value) -> String {
run(s, "sheet.export_dxf", params)["dxf"].as_str().unwrap_or_default().to_string()
}
/// The entities of a DXF text: type and group codes.
type Entity = (String, Vec<(i32, String)>);
fn entities(dxf: &str) -> Vec<Entity> {
let lines: Vec<&str> = dxf.lines().collect();
let pairs: Vec<(i32, &str)> = lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim()))).collect();
let start = pairs.iter().position(|p| *p == (2, "ENTITIES")).unwrap_or(0);
let mut out: Vec<Entity> = Vec::new();
for (code, value) in pairs.iter().skip(start + 1) {
match (*code, *value) {
(0, "ENDSEC") => break,
(0, kind) => out.push((kind.to_string(), Vec::new())),
_ => {
if let Some(last) = out.last_mut() {
last.1.push((*code, value.to_string()));
}
}
}
}
out
}
fn values(e: &Entity, code: i32) -> Vec<f64> {
e.1.iter().filter(|(c, _)| *c == code).filter_map(|(_, v)| v.parse().ok()).collect()
}
fn count(es: &[Entity], kind: &str) -> usize {
es.iter().filter(|e| e.0 == kind).count()
}
fn point(e: &Entity, code: i32) -> Vec2 {
Vec2::new(values(e, code).first().copied().unwrap_or(f64::NAN), values(e, code + 10).first().copied().unwrap_or(f64::NAN))
}
/// All loops of the flat pattern (outlines, cut-outs, islands).
fn loops(s: &Session) -> Vec<solidcraft_geom::Loop2> {
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
regions.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.clone())).collect()
}
/// The profiles of the DXF read into a new sketch: their areas, largest first.
fn reimported_areas(dxf: &str) -> Vec<f64> {
let mut s = Session::default();
let id = run(&mut s, "sketch.insert_dxf", json!({"text": dxf, "name": "Back"}))["sketch"].as_u64().unwrap_or(0);
run(&mut s, "sketch.finish", json!({}));
let mut areas: Vec<f64> = s.model.state().sketch(id).map(|ss| ss.profiles.iter().map(|p| p.area).collect()).unwrap_or_default();
areas.sort_by(|a, b| b.total_cmp(a));
areas
}
#[test]
fn an_elliptic_cut_out_is_one_ellipse() {
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
// The four lines of the outline and one ELLIPSE: no other entity.
assert_eq!((count(&es, "LINE"), count(&es, "ELLIPSE"), es.len()), (4, 1, 5), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
// By hand: centre (40, 30), the major semi-axis 15 along x (to (55, 30) or (25, 30)), the
// minor one 8, so the ratio is 8/15; the whole turn runs from parameter 0 to 2π.
let e = es.iter().find(|e| e.0 == "ELLIPSE").unwrap();
let (c, major) = (point(e, 10), point(e, 11));
assert!(c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.x.abs() - 15.0).abs() < 1e-9 && major.y.abs() < 1e-9, "{c:?} {major:?}");
assert!((values(e, 40)[0] - 8.0 / 15.0).abs() < 1e-9 && values(e, 41)[0] == 0.0 && (values(e, 42)[0] - TAU).abs() < 1e-9);
assert_eq!(values(e, 230), [1.0]);
// Our reader gets the same ellipse back, and the same plate: 6000 mm² less π·15·8.
let read = solidcraft_io::read_dxf(&dxf).unwrap();
assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Ellipse { c, major, minor }
if c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.dist(*c) - 15.0).abs() < 1e-9 && (minor - 8.0).abs() < 1e-9)));
let areas = reimported_areas(&dxf);
assert!(areas.len() == 2 && (areas[0] - (6000.0 - PI * 120.0)).abs() < 1e-6 && (areas[1] - PI * 120.0).abs() < 1e-6, "{areas:?}");
}
#[test]
fn a_spline_cut_out_is_the_splines_it_was() {
let points = json!([[20, 15], [40, 45], [65, 40], [85, 20]]);
let mut s = plate_with(&[("sketch.spline.fit_point", json!({"points": points})), ("sketch.line", json!({"points": [[85, 20], [20, 15]]}))]);
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
// The cut-out's segments of the flat pattern, one entity each: its cubic pieces are SPLINEs
// of degree 3 over the same four control points, its line a LINE.
let hole: Vec<Seg2> = loops(&s).get(1).map(|l| l.segs.clone()).unwrap_or_default();
let cubics: Vec<[Vec2; 4]> =
hole.iter().filter_map(|g| if let Seg2::Cubic { p0, p1, p2, p3 } = g { Some([*p0, *p1, *p2, *p3]) } else { None }).collect();
assert!(!cubics.is_empty() && cubics.len() + 1 == hole.len(), "{hole:?}");
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (5, cubics.len(), 5 + cubics.len()));
for (spline, want) in es.iter().filter(|e| e.0 == "SPLINE").zip(&cubics) {
assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![3.0], vec![8.0], vec![4.0]));
assert_eq!(values(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
let (xs, ys) = (values(spline, 10), values(spline, 20));
assert!(want.iter().enumerate().all(|(i, p)| (xs[i] - p.x).abs() < 1e-9 && (ys[i] - p.y).abs() < 1e-9), "{xs:?} {ys:?} {want:?}");
}
// The same plate comes back.
let areas = reimported_areas(&dxf);
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default()[0].area();
assert!((areas[0] - plate).abs() < 1e-6, "{areas:?} {plate}");
}
#[test]
fn text_is_splines_and_lines_not_chords() {
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
// One entity for each segment of the flat pattern: a straight one a LINE, a glyph's
// quadratic and cubic curves SPLINEs of degree 2 and 3.
let segs: Vec<Seg2> = loops(&s).iter().flat_map(|l| l.segs.clone()).collect();
let n = |f: &dyn Fn(&Seg2) -> bool| segs.iter().filter(|g| f(g)).count();
let (lines, quadratics, cubics) = (
n(&|g| matches!(g, Seg2::Line { .. })),
n(&|g| matches!(g, Seg2::Conic { w, .. } if (*w - 1.0).abs() < 1e-12)),
n(&|g| matches!(g, Seg2::Cubic { .. })),
);
assert_eq!(lines + quadratics + cubics, segs.len(), "only lines and Béziers");
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (lines, quadratics + cubics, segs.len()));
let degree = |d: f64| es.iter().filter(|e| e.0 == "SPLINE" && values(e, 71) == [d]).count();
assert_eq!((degree(2.0), degree(3.0)), (quadratics, cubics));
// The plate with the letters cut out comes back from the file with its area (the largest region; the islands inside the
// letters come in no fixed order).
let areas = reimported_areas(&dxf);
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default().iter().map(|r| r.area()).fold(0.0, f64::max);
assert!((areas[0] - plate).abs() < 1e-3, "{areas:?} {plate}");
}
#[test]
fn arcs_option_writes_only_lines_and_tangent_arcs_within_a_thousandth() {
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
let dxf = export(&mut s, json!({"curves": "arcs"}));
let es = entities(&dxf);
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
let arcs: Vec<(Vec2, f64, [Vec2; 2], Vec<Vec2>)> = es
.iter()
.filter(|e| e.0 == "ARC")
.map(|e| {
let (c, r, (a0, a1)) = (point(e, 10), values(e, 40)[0], (values(e, 50)[0].to_radians(), values(e, 51)[0].to_radians()));
let sweep = (a1 - a0).rem_euclid(TAU);
let samples = (0..=20).map(|k| c + Vec2::from_angle(a0 + sweep * f64::from(k) / 20.0) * r).collect::<Vec<Vec2>>();
(c, r, [c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r], samples)
})
.collect();
// Far fewer than the 336 chords of 0.001 mm.
assert!(arcs.len() >= 8 && arcs.len() < 100, "{} arcs", arcs.len());
// Distance to the ellipse ((x − 40)/15)² + ((y − 30)/8)² = 1, to first order |f| / |∇f|.
let off = |p: Vec2| {
let (x, y) = (p.x - 40.0, p.y - 30.0);
let f = (x / 15.0).powi(2) + (y / 8.0).powi(2) - 1.0;
f.abs() / Vec2::new(2.0 * x / 225.0, 2.0 * y / 64.0).len()
};
// (The joint of a biarc's two arcs lies within the tolerance of the curve, not on it.)
for (_, _, ends, samples) in &arcs {
let offs: Vec<f64> = samples.iter().map(|p| off(*p)).collect();
assert!(offs.iter().all(|d| *d <= 1e-3 + 1e-6), "{ends:?}: {offs:?}");
}
// Tangent: at each joint the radii of the two arcs lie on one line.
for (i, (c, r, ends, _)) in arcs.iter().enumerate() {
for end in ends {
let others: Vec<_> = arcs.iter().enumerate().filter(|(j, (_, _, e, _))| *j != i && e.iter().any(|q| q.dist(*end) < 1e-6)).collect();
assert_eq!(others.len(), 1, "joint {end:?}");
let (_, (c2, r2, _, _)) = others[0];
assert!((*end - *c).cross(*end - *c2).abs() < 1e-6 * r * r2, "arcs are not tangent at {end:?}");
}
}
// The plate, read back, is the exact one within the perimeter times 0.001 mm.
let areas = reimported_areas(&dxf);
assert!((areas[0] - (6000.0 - PI * 120.0)).abs() < 0.1, "{areas:?}");
}
#[test]
fn arcs_option_has_no_splines_for_text_and_the_curves_value_is_checked() {
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
let es = entities(&export(&mut s, json!({"curves": "ARCS"})));
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC") && count(&es, "ARC") > 0);
let exact = entities(&export(&mut s, json!({"curves": "exact"})));
assert!(count(&exact, "SPLINE") > 0 && count(&exact, "ARC") == 0);
assert!(s.execute("sheet.export_dxf", &json!({"curves": "chords"})).is_err());
}
#[test]
fn bend_lines_and_layers_are_still_there() {
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
let out = run(&mut s, "sheet.export_dxf", json!({}));
let es = entities(out["dxf"].as_str().unwrap_or_default());
let on = |layer: &str| es.iter().filter(|e| e.1.contains(&(8, layer.to_string()))).count();
assert_eq!(on("BEND_UP") + on("BEND_DOWN"), out["bends"].as_u64().unwrap_or(0) as usize);
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("OUTLINE") == es.len() - on("BEND_UP") - on("BEND_DOWN"));
}
+331 -5
View File
@@ -507,6 +507,139 @@ fn unfold_cut_refold_and_convert() {
assert!(t.execute("sheet.convert", &json!({"body": "Block", "face": [50, 30, 2.5]})).is_err());
}
/// A plate (15, -20) (0, 0) (-100, 0) (-100, -20) with a 20 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), unfolded,
/// and a triangle cut out with a corner on the bend line: where the line ends on the plate's
/// edge at (-5, 0), the triangle on the bend or on the base, or in the line's middle. The refold
/// failed ("sew: face 10: This wire is not simple.").
#[test]
fn a_cut_out_with_a_corner_on_a_bend_line_refolds() {
// The base (10, -20) (-5, 0) (-100, 0) (-100, -20): 2050; the bend 25 x 3π/2; the flange 25 x 16.
let area = 2450.0 + 37.5 * PI;
let cases = [
([[-5.0, 0.0], [-3.0, -1.0], [-3.0, 1.0]], 2.0),
([[-5.0, 0.0], [-7.0, -1.0], [-6.0, -3.0]], 2.5),
([[2.5, -10.0], [2.1, -12.8], [-0.3, -9.6]], 4.0),
([[2.5, -10.0], [4.7, -9.6], [3.5, -8.0]], 2.0),
];
for (triangle, cut) in cases {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "Rule", "active": true, "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
run(&mut s, "sketch.line", json!({"points": [[15, -20], [0, 0], [-100, 0], [-100, -20]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Outline", "rule": "Rule"}));
run(&mut s, "sheet.flange", json!({"edges": [[7.5, -10, 2]], "height": 20, "radius": 2, "position": "inside"}));
// The measure follows the bend by facets.
let folded = volume(&mut s);
assert!(rel(folded, area * 2.0) < 1e-4, "{folded} vs {}", area * 2.0);
run(&mut s, "sheet.unfold", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cutout"}));
run(&mut s, "sketch.line", json!({"points": triangle, "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Cutout", "through_all": true, "direction": "symmetric", "operation": "cut"}));
assert!(rel(volume(&mut s), (area - cut) * 2.0) < 1e-9, "{triangle:?} flat: {}", volume(&mut s));
run(&mut s, "sheet.refold", json!({}));
let refolded = volume(&mut s);
assert!((folded - refolded - cut * 2.0).abs() < 0.01, "{triangle:?} refolded: {refolded}, {} less", folded - refolded);
}
}
/// Plates with an inside flange on a slanted edge (2 mm sheet, R 2, K 0.5: the folded sheet
/// holds its flat area times the thickness), unfolded, and a triangle cut out across the bend
/// line with a corner 0.05 µm beside the line, or on it at x = 84.0764335, a border between
/// two cells of the 1e-6 mm grid the sew finds vertices on. The refold failed ("sew: a
/// degenerate edge", "sew: face 13: This wire is not closed."); it loses the triangle.
#[test]
fn a_cut_corner_a_hair_from_a_bend_line_refolds() {
let root820 = 820f64.sqrt();
let cases = [
// Base 1750, bend 25 x 3π/2, flange 25 x 26; a triangle of 4 mm².
(
[[0.0, 0.0], [100.0, 0.0], [85.0, 20.0], [0.0, 20.0]],
[92.5, 10.0, 2.0],
30.0,
[[87.49999996, 9.99999997], [85.49999996, 7.99999997], [89.49999996, 7.99999997]],
2400.0 + 37.5 * PI,
4.0,
),
// The edge is √820 long; the base loses 4 mm along its normal: 2444 − 4√820; the bend
// √820 x 3π/2, the flange √820 x 36; a triangle of 3 mm².
(
[[0.0, 0.0], [100.0, 0.0], [88.0, 26.0], [0.0, 26.0]],
[94.0, 13.0, 2.0],
40.0,
[[84.0764335, 24.9558467078], [82.0764335, 22.9558467078], [86.0764335, 23.9558467078]],
2444.0 + root820 * (32.0 + 3.0 * PI / 2.0),
3.0,
),
];
for (outline, edge, height, triangle, area, cut) in cases {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "Rule", "active": true, "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
run(&mut s, "sketch.line", json!({"points": outline, "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Outline", "rule": "Rule"}));
run(&mut s, "sheet.flange", json!({"edges": [edge], "height": height, "radius": 2, "position": "inside"}));
// The measure follows the bend by facets.
let folded = volume(&mut s);
assert!(rel(folded, area * 2.0) < 1e-4, "{folded} vs {}", area * 2.0);
run(&mut s, "sheet.unfold", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cutout"}));
run(&mut s, "sketch.line", json!({"points": triangle, "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Cutout", "through_all": true, "direction": "symmetric", "operation": "cut"}));
assert!(rel(volume(&mut s), (area - cut) * 2.0) < 1e-9, "flat: {}", volume(&mut s));
run(&mut s, "sheet.refold", json!({}));
let refolded = volume(&mut s);
assert!((folded - refolded - cut * 2.0).abs() < 0.01, "refolded: {refolded}, {} less", folded - refolded);
}
}
/// A 100 x 60 plate (2 mm sheet, R 2, K 0.5) with a 42 mm flange (R 1) outside its bottom edge,
/// unfolded, and a notch cut off the flat flange's free corner: an arc from the side edge,
/// through (0.6, 0.6) from the corner, to the free edge, closed by lines through a point outside
/// the sheet. The arc leaves the side edge 1.7997505 mm from the corner, in the folded flange at
/// a height of 42 − 1.7997505 = 40.2002495 mm: a border between two cells of the 1e-6 mm grid the
/// sew finds vertices on, and the arc's end and the edge's, worked out apart, differ by 7e-13
/// mm. The refold failed ("sew: face 5: This wire is not closed."); it loses the notch.
#[test]
fn a_notch_whose_arc_ends_on_a_grid_border_refolds() {
let t = 1.7997505;
// The flat flange ends at 39 + the bend allowance (π/2)(R + K T) = 39 + π below the plate.
let free = -(42.0 - 1.0 - 2.0 + FRAC_PI_2 * (1.0 + 0.5 * 2.0));
let flat = 6000.0 + 100.0 * PI + 100.0 * 39.0;
// The notch is the triangle corner, end, end less the circle's segment over its chord; the
// circle's centre (a, a) from the corner is as far from (0.6, 0.6) as from the arc's ends.
let a = (t * t - 0.72) / (2.0 * t - 2.4);
let r2 = 2.0 * (a - 0.6) * (a - 0.6);
let angle = 2.0 * (t * 2f64.sqrt() / (2.0 * r2.sqrt())).asin();
let notch = t * t / 2.0 - r2 / 2.0 * (angle - angle.sin());
assert!((notch - 0.88421).abs() < 1e-5, "{notch}");
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "Rule", "active": true, "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0], [100, 60], [0, 60]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Outline", "rule": "Rule"}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 42, "radius": 1, "position": "outside"}));
// The measure follows the bend by facets.
let folded = volume(&mut s);
assert!(rel(folded, flat * 2.0) < 1e-4, "{folded} vs {}", flat * 2.0);
run(&mut s, "sheet.unfold", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cutout"}));
run(&mut s, "sketch.arc.three_point", json!({"start": [t, free], "through": [0.6, free + 0.6], "end": [0.0, free + t]}));
run(&mut s, "sketch.line", json!({"points": [[0.0, free + t], [-1.0, -44.0]]}));
run(&mut s, "sketch.line", json!({"points": [[-1.0, -44.0], [t, free]]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Cutout", "through_all": true, "direction": "symmetric", "operation": "cut"}));
assert!(rel(volume(&mut s), (flat - notch) * 2.0) < 1e-6, "flat: {}", volume(&mut s));
run(&mut s, "sheet.refold", json!({}));
let refolded = volume(&mut s);
assert!((folded - refolded - notch * 2.0).abs() < 0.01, "refolded: {refolded}, {} less", folded - refolded);
}
/// A sketch line across a 100 x 60 plate's top face at x = 40.
fn fold_line(s: &mut Session) {
run(s, "sketch.create", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
@@ -962,6 +1095,42 @@ fn tray_shrunk_by_a_parameter_unfolds_like_one_built_at_that_size() {
}
}
/// A 55 x 30 plate whose bottom edge bends up by 1 in 100 over its last 5 mm, with a flange on
/// the straight 50 mm, unfolds at any turn. The flange keeps its start rounded to the
/// nanometre, so its bend zone meets the plate a fraction of a nanometre off the corner, and
/// the bent piece of edge, a hair from the zone's edge, crosses it a few hundredths of a
/// micrometre before the corner. Split there, the piece left a sliver shorter than the
/// tolerance, which the flat pattern's union took for a second way out of the corner and
/// closed as a loop of its own: "planar region arrangement failed" at 46 of these 90 turns.
#[test]
fn a_flange_beside_a_slightly_bent_edge_unfolds_at_any_turn() {
// Base 55 x 30 less the 5 x 0.05 triangle under the bent piece; bend π and leg 17 along 50.
let area = 55.0 * 30.0 - 5.0 * 0.05 / 2.0 + 50.0 * (PI + 17.0);
let mut failed = Vec::new();
for k in 0..90 {
let turn = (k as f64).to_radians();
let at = |x: f64, y: f64| [x * turn.cos() - y * turn.sin(), x * turn.sin() + y * turn.cos()];
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
let outline = [(95.0, 50.05), (100.0, 50.0), (150.0, 50.0), (150.0, 80.0), (95.0, 80.0)].map(|(x, y)| at(x, y));
run(&mut s, "sketch.line", json!({"points": outline, "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Outline"}));
let [x, y] = at(125.0, 50.0);
run(&mut s, "sheet.flange", json!({"edges": [[x, y, 2]], "height": 20, "angle": 90, "radius": 1, "position": "outside"}));
if let Err(e) = s.execute("sheet.unfold", &json!({})) {
failed.push(format!("{k}°: {e}"));
continue;
}
let f = flat(&mut s);
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{k}°: {f}");
assert!((flat_area(&f) - area).abs() < 1e-6, "{k}°: {} vs {area}", flat_area(&f));
assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{k}°: {} vs {}", volume(&mut s), area * 2.0);
}
assert!(failed.is_empty(), "{} of 90 turns: {failed:?}", failed.len());
}
#[test]
fn the_bend_table_and_the_dxf_follow_what_flat_cuts_leave_of_a_bend() {
// A 200 x 100 plate with a 40 mm flange along y = 0: the bend's centre line runs at y = -pi/2
@@ -1350,7 +1519,7 @@ fn flat_dxf_preserves_exact_cutout_entities() {
outer: Loop2 { segs: vec![Seg2::Arc { center: Vec2::new(40.0, 30.0), radius: 5.0, start: -PI, sweep: -2.0 * PI }] },
holes: Vec::new(),
}];
let dxf = super::flat_dxf(&sh).unwrap();
let dxf = super::flat_dxf(&sh, super::DxfCurves::Exact).unwrap();
assert_eq!(dxf.matches("0\nCIRCLE\n").count(), 1, "{dxf}");
assert!(!dxf.contains("0\nARC\n"), "a full circle must not become a zero-length ARC");
let read = solidcraft_io::read_dxf(&dxf).unwrap();
@@ -1362,10 +1531,10 @@ fn flat_dxf_preserves_exact_cutout_entities() {
Seg2::Arc { center: Vec2::new(20.0, 10.0), radius: 10.0, start: 0.0, sweep: PI },
],
};
let dxf = super::flat_dxf(&sh).unwrap();
let dxf = super::flat_dxf(&sh, super::DxfCurves::Exact).unwrap();
assert_eq!(dxf.matches("0\nLINE\n").count(), 5, "four outline lines and one exact cutout line");
assert_eq!(dxf.matches("0\nARC\n").count(), 1);
assert!(dxf.contains("50\n0.000000\n51\n180.000000\n"));
assert!(dxf.contains(" 50\n0.0\n 51\n180.0\n"), "{dxf}");
let read = solidcraft_io::read_dxf(&dxf).unwrap();
assert_eq!(read.len(), 6);
assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Arc { c, a, b } if *c == Vec2::new(20.0, 10.0) && a.dist(Vec2::new(30.0, 10.0)) < 1e-9 && b.dist(Vec2::new(10.0, 10.0)) < 1e-9)));
@@ -2752,7 +2921,11 @@ fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() {
assert!(arcs.iter().any(|(c, r)| c.dist(Vec2::new(x, y)) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}");
}
} else {
assert!(arcs.is_empty() && dxf.contains(&format!("{:.6}\n20\n{:.6}\n", s0 + 4.0, s0 + 4.0)), "{arcs:?}");
let corner = Vec2::new(s0 + 4.0, s0 + 4.0);
let read = solidcraft_io::read_dxf(&dxf).unwrap_or_default();
let at_corner =
|g: &solidcraft_io::Geom2| matches!(g, solidcraft_io::Geom2::Line(a, b) if a.dist(corner) < 1e-6 || b.dist(corner) < 1e-6);
assert!(arcs.is_empty() && read.iter().any(at_corner), "{arcs:?}");
}
// Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base.
run(&mut s, "sheet.unfold", json!({}));
@@ -2823,7 +2996,10 @@ fn a_tear_of_the_rule_reaches_the_part_the_flat_pattern_and_the_dxf() {
// The DXF: the outline in straight lines, each cut one of them, and four bend lines.
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{deg}");
assert!(dxf.contains(&format!("{:.6}\n20\n{:.6}\n", x_end(s0), s0 - ba)), "{deg}");
let cut_end = solidcraft_geom::Vec2::new(x_end(s0), s0 - ba);
let read = solidcraft_io::read_dxf(&dxf).unwrap_or_default();
let at_cut_end = |g: &solidcraft_io::Geom2| matches!(g, solidcraft_io::Geom2::Line(a, b) if a.dist(cut_end) < 1e-6 || b.dist(cut_end) < 1e-6);
assert!(read.iter().any(at_cut_end), "{deg}");
// Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base.
run(&mut s, "sheet.unfold", json!({}));
assert!((volume(&mut s) - flat0 - want).abs() < 1e-6, "{deg}: unfolded {} - {flat0} vs {want}", volume(&mut s));
@@ -3787,3 +3963,153 @@ fn perforated_sheet_takes_a_thousand_round_holes() {
let expected = 1000.0 * std::f64::consts::PI * 0.5 * 0.5 * 0.8;
assert!((removed - expected).abs() / expected < 0.05, "removed {removed:.1} mm³, 1000 holes are {expected:.1} mm³");
}
// ---- #732 sheet.perforate: a perforation field stored on the sheet, not cut into the B-rep ----------------------
/// Hole centres of a staggered (60 deg) perforation over a w x h rectangle at z 0, as `sheet.perforate` places them:
/// rows along x every pitch * sqrt(3) / 2, odd rows shifted half a pitch, starting at margin + radius from the
/// lower-left corner; a hole is kept when it stays margin away from the outline.
fn staggered_count(w: f64, h: f64, d: f64, pitch: f64, margin: f64) -> usize {
let (r, dy) = (d / 2.0, pitch * 3f64.sqrt() / 2.0);
let (lo, hix, hiy) = (margin + r, w - margin - r, h - margin - r);
let mut n = 0;
let mut row = 0;
while lo + row as f64 * dy <= hiy + 1e-9 {
let mut x = lo + if row % 2 == 1 { pitch / 2.0 } else { 0.0 };
while x <= hix + 1e-9 {
n += 1;
x += pitch;
}
row += 1;
}
n
}
fn perforation_circles(dxf: &str) -> Vec<(f64, f64, f64)> {
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let mut out = Vec::new();
let mut i = 0;
while i + 1 < lines.len() {
if lines[i] == "0" && lines[i + 1] == "CIRCLE" {
let (mut layer, mut x, mut y, mut r) = ("", 0.0, 0.0, 0.0);
let mut j = i + 2;
while j + 1 < lines.len() && lines[j] != "0" {
match lines[j] {
"8" => layer = lines[j + 1],
"10" => x = lines[j + 1].parse().unwrap_or(0.0),
"20" => y = lines[j + 1].parse().unwrap_or(0.0),
"40" => r = lines[j + 1].parse().unwrap_or(0.0),
_ => {}
}
j += 2;
}
if layer == "PERFORATION" {
out.push((x, y, r));
}
i = j;
} else {
i += 1;
}
}
out
}
/// #732: Rv 1.0-1.5 over a 100 x 60 x 0.8 sheet: every hole is in the flat pattern and in the DXF (layer
/// PERFORATION), placed on the documented grid, and the B-rep is not cut (the field is stored like a cosmetic thread).
#[test]
fn perforation_field_exports_every_hole_to_the_flat_pattern_and_dxf() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]]));
let faces = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["faces"].clone();
run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1"}));
let expected = staggered_count(100.0, 60.0, 1.0, 1.5, 1.0);
let f = flat(&mut s);
assert_eq!(f["perforations"][0]["count"].as_u64(), Some(expected as u64), "{}", f["perforations"]);
assert_eq!(f["perforations"][0]["pattern"], "staggered");
let open = f["perforations"][0]["open_area_mm2"].as_f64().unwrap();
assert!(rel(open, expected as f64 * PI * 0.25) < 1e-9);
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
let holes = perforation_circles(&dxf);
assert_eq!(holes.len(), expected);
assert!(holes.iter().all(|&(x, y, r)| (r - 0.5).abs() < 1e-9 && (1.5..=98.5).contains(&x) && (1.5..=58.5).contains(&y)));
assert!((holes[0].0 - 1.5).abs() < 1e-9 && (holes[0].1 - 1.5).abs() < 1e-9, "first hole at margin + radius: {:?}", holes[0]);
assert_eq!(run(&mut s, "inspect.measure", json!({}))["bodies"][0]["faces"], faces, "the B-rep is not cut");
}
/// #732: the measured volume and mass drop by the open area times the thickness.
#[test]
fn perforation_reduces_volume_and_mass_by_the_open_area() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]]));
let before = run(&mut s, "inspect.measure", json!({}));
run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1"}));
let after = run(&mut s, "inspect.measure", json!({}));
let n = staggered_count(100.0, 60.0, 1.0, 1.5, 1.0) as f64;
let removed = before["total"]["volume_mm3"].as_f64().unwrap() - after["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(removed, n * PI * 0.25 * 0.8) < 1e-9, "removed {removed}");
let (m0, m1) = (before["bodies"][0]["mass_g"].as_f64().unwrap(), after["bodies"][0]["mass_g"].as_f64().unwrap());
let ratio = after["total"]["volume_mm3"].as_f64().unwrap() / before["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(m1 / m0, ratio) < 1e-9);
}
/// #732: holes stay out of bend zones, also when the fold comes after the perforation; the field follows the sheet
/// through unfold / refold and through save and reopen.
#[test]
fn perforation_skips_bend_zones_and_survives_fold_unfold_and_reopen() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]]));
run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1"}));
let flat_count = flat(&mut s)["perforations"][0]["count"].as_u64().unwrap();
run(&mut s, "sheet.fold", json!({"points": [[80, 0, 0.8], [80, 60, 0.8]], "fixed": [40, 30, 0.4]}));
let folded = flat(&mut s);
let count = folded["perforations"][0]["count"].as_u64().unwrap();
assert!(count < flat_count, "holes in the new bend zone are dropped: {count} of {flat_count}");
let zone = &folded["bend_zones"][0]; // [min x, max x, min y, max y] of the bend zone, flat coordinates
let (z0, z1) = (zone[0].as_f64().unwrap_or(f64::NAN), zone[1].as_f64().unwrap_or(f64::NAN));
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
assert!(
perforation_circles(&dxf).iter().all(|&(x, _, r)| x + r + 1.0 <= z0 + 1e-9 || x - r - 1.0 >= z1 - 1e-9),
"no hole within margin of the bend zone x {z0}..{z1}"
);
let volume_folded = volume(&mut s);
run(&mut s, "sheet.unfold", json!({}));
assert_eq!(flat(&mut s)["perforations"][0]["count"].as_u64(), Some(count));
run(&mut s, "sheet.refold", json!({}));
assert!(rel(volume(&mut s), volume_folded) < 1e-9);
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(flat(&mut reopened)["perforations"][0]["count"].as_u64(), Some(count));
assert!(rel(volume(&mut reopened), volume_folded) < 1e-9);
}
/// #732: a real mesh panel - 400 x 470 with Rv 1.0-1.5 is about 94 000 holes - is quick to perforate, measure and
/// export (the holes never enter the flat-geometry budget).
#[test]
fn perforating_a_mesh_panel_with_ninety_thousand_holes_is_fast() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [400, 0], [400, 470], [0, 470]]));
let t = std::time::Instant::now();
run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1"}));
let count = flat(&mut s)["perforations"][0]["count"].as_u64().unwrap();
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().len();
volume(&mut s);
assert!(count > 90_000, "{count}");
assert!(dxf > 90_000 * 30, "every hole is in the DXF");
assert!(t.elapsed().as_secs_f64() < 20.0, "took {:?}", t.elapsed());
}
/// #732: a square pattern, and values that cannot be punched are refused.
#[test]
fn perforate_supports_a_square_pattern_and_refuses_impossible_values() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [30, 0], [30, 20], [0, 20]]));
run(&mut s, "sheet.perforate", json!({"diameter": "2", "pitch": "4", "margin": "0", "pattern": "square"}));
assert_eq!(flat(&mut s)["perforations"][0]["count"].as_u64(), Some(8 * 5));
for bad in [
json!({"diameter": "0", "pitch": "2"}),
json!({"diameter": "2", "pitch": "2"}),
json!({"diameter": "1", "pitch": "2", "pattern": "hexagon"}),
json!({"diameter": "1", "pitch": "2", "margin": "-1"}),
] {
assert!(s.execute("sheet.perforate", &bad).is_err(), "{bad} must be refused");
}
}
@@ -431,6 +431,8 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option<Vec2> {
let pt = |p: usize| sk.point(p);
match *k {
OffsetDistance { ref output, .. } => sk.offset_dimension_points(output).map(|(a, b)| (a + b) * 0.5),
PatternPoint { q, center, .. } => pt(center.unwrap_or(q)),
PatternRadius { b, .. } => mid(b),
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } => pt(p),
PointOnCurve { p, .. } => pt(p),
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
+48 -14
View File
@@ -8,7 +8,7 @@ use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch,
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
() => {
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text), reverse?: bool (the other way along the path)"
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
};
}
@@ -145,21 +145,40 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
Ok(style)
}
/// What a text's `at` must be.
const AT_MUST: &str = "`at` must be the baseline start [x, y]";
/// The number given for `key`: `None` when it is left out or null, and an error when it is
/// anything but a number (not read as left out).
fn given_num(p: &Value, cmd: &str, key: &str, what: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(_) => num(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be {what}"))),
}
}
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>, TextStyle)>) -> Result<LinkSource> {
let (t0, at0, h0, a0, f0, s0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None, TextStyle::default()));
let text = str_(p, "text").map(str::to_string).unwrap_or(t0);
let text = match p.get("text") {
None | Some(Value::Null) => t0,
Some(Value::String(t)) => t.clone(),
Some(_) => return Err(bad(cmd, "`text` must be a string")),
};
if text.trim().is_empty() {
return Err(bad(cmd, "`text` must not be empty"));
}
if text.chars().count() > solidcraft_sketch::MAX_TEXT_CHARS {
return Err(bad(cmd, "text too long"));
}
let at = p.get("at").and_then(crate::params::vec2).unwrap_or(at0);
let height = num(p, "height").unwrap_or(h0);
let at = match p.get("at") {
None | Some(Value::Null) => at0,
Some(v) => crate::params::vec2(v).ok_or_else(|| bad(cmd, AT_MUST))?,
};
let height = given_num(p, cmd, "height", "a length in mm")?.unwrap_or(h0);
if !(height > 1e-6 && height < 1e5) {
return Err(bad(cmd, "`height` must be positive"));
}
let angle = num(p, "angle").map(f64::to_radians).unwrap_or(a0);
let angle = given_num(p, cmd, "angle", "an angle in degrees")?.map_or(a0, f64::to_radians);
let font = match p.get("font") {
None | Some(Value::Null) => f0,
Some(Value::String(f)) if f.trim().is_empty() => None,
@@ -167,19 +186,16 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option
Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")),
};
let style = text_style(p, cmd, s0)?;
// A font file an agent names must lie in its folders (installed families are found by name).
if let Some(path) = font.as_deref().and_then(solidcraft_sketch::font_file_path) {
solidcraft_io::vfs::check(&path.to_string_lossy()).map_err(|e| bad(cmd, e.to_string()))?;
}
// An unknown font, an unreadable file or a style the font lacks is an error now, not a lost link later.
// An unknown font, an unreadable file or a style the font lacks is an error now, not a lost
// link later; so is a font file outside an agent's folders (the font reader asks `vfs`).
solidcraft_sketch::font_data_styled(font.as_deref(), style.bold, style.italic).map_err(|e| bad(cmd, e.to_string()))?;
Ok(LinkSource::Text { text, at, height, angle, font, style })
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.text";
if p.get("at").is_none() {
return Err(bad(cmd, "`at` must be the baseline start [x, y]"));
if p.get("at").is_none_or(Value::is_null) {
return Err(bad(cmd, AT_MUST));
}
let src = text_source(p, cmd, None)?;
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
@@ -190,11 +206,28 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
if geom.is_empty() {
return Err(bad(cmd, "the text makes no outline"));
}
let guide = guide(&mut sk, &src);
let l = sk.add_link(LinkKind::Text, src, &geom)?;
let curves = ids_of(&sk, &sk.link_curves(&l));
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(json!({"link": l, "curves": curves}))
Ok(json!({"link": l, "curves": curves, "guide": guide}))
}
/// A text's path is its guide: the curve it follows becomes construction. Left as it was, it
/// would cut the letters standing on it (and the ones hanging below it) into pieces, each
/// profile a piece of a letter and of the curve, and picking the text would pick those pieces.
/// Taking the text off the curve leaves the curve as it is. The curve made construction, if any.
fn guide(sk: &mut Sketch, src: &LinkSource) -> Option<String> {
let LinkSource::Text { style, .. } = src else { return None };
let id = style.path.as_ref()?;
let curve = sk.curve_index(id).and_then(|c| sk.curves.get_mut(c))?;
if curve.construction {
return None;
}
curve.construction = true;
curve.centerline = false;
Some(id.clone())
}
/// The text source owning an outline or one of its linked points.
@@ -271,10 +304,11 @@ fn apply_text_edit(sk: &mut Sketch, p: &Value) -> Result<Value> {
if geom.is_empty() {
return Err(bad(cmd, "the text makes no outline"));
}
let guide = guide(sk, &src);
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(sk, &sk.link_curves(&l));
Ok(json!({"link": l, "curves": curves}))
Ok(json!({"link": l, "curves": curves, "guide": guide}))
}
/// The curve ending at point `pi` and the direction leaving it there.
+11 -49
View File
@@ -13,6 +13,8 @@ use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
use crate::{Result, Session};
#[path = "sketch_pattern.rs"]
mod pattern;
#[path = "sketch_support.rs"]
mod support;
@@ -86,12 +88,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CREATE")
.icon("sketch_circ_pattern")
.enabled(in_sketch)
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)", created_doc!())),
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360). Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SKETCH", "CREATE")
.icon("sketch_rect_pattern")
.enabled(in_sketch)
.params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)", created_doc!())),
.params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction). Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
];
const TOL: f64 = 1e-7;
@@ -1495,6 +1497,9 @@ fn copy_through(
keep: &dyn Fn(Vec2) -> bool,
scale_r: f64,
) -> Result<(Vec<usize>, Vec<(usize, usize)>)> {
if sk.curves.len().saturating_add(cs.len()) > solidcraft_sketch::MAX_CURVES {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
let pts = involved_points(sk, cs, ps);
let mut map: Vec<(usize, usize)> = Vec::new();
for q in &pts {
@@ -1575,7 +1580,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
fn count(p: &Value, k: &str, cmd: &str) -> Result<usize> {
let n = num(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
if !(1.0..=MAX_COPIES as f64).contains(&n) {
if !(1.0..=MAX_COPIES as f64).contains(&n) || n.fract() != 0.0 {
return Err(bad(cmd, format!("`{k}` must be 1…{MAX_COPIES}")));
}
Ok(n as usize)
@@ -1598,55 +1603,11 @@ fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 {
}
fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.pattern.circular";
let n = count(p, "count", cmd)?;
let total = num(p, "angle").unwrap_or(360.0);
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
return Err(bad(cmd, "`angle` must be in (0, 360]"));
}
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
let c = point_or_ref(sk, p, "center", cmd)?;
let full = (total.abs() - 360.0).abs() < 1e-9;
let step = if full { total / n as f64 } else { total / (n.max(2) - 1) as f64 };
let mut made = Vec::new();
for k in 1..n {
let f = rotate(c, (step * k as f64).to_radians());
let (m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
made.extend(m);
}
Ok(ids_of(sk, &made))
})?;
Ok(json!({"curves": out, "sketch": info}))
pattern::run(s, p, true)
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.pattern.rectangular";
let n1 = count(p, "count", cmd)?;
let s1 = num(p, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be a number"))?;
let n2 = if p.get("count2").is_some() { count(p, "count2", cmd)? } else { 1 };
let s2 = num(p, "spacing2").unwrap_or(s1);
if n1 * n2 > MAX_COPIES {
return Err(bad(cmd, "too many copies"));
}
let d1 = p.get("dir").and_then(vec2).and_then(Vec2::normalized).unwrap_or(Vec2::X);
let d2 = d1.perp();
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
let mut made = Vec::new();
for i in 0..n1 {
for j in 0..n2 {
if i == 0 && j == 0 {
continue;
}
let off = d1 * (s1 * i as f64) + d2 * (s2 * j as f64);
let (m, _) = copy_through(sk, &cs, &ps, &move |q| q + off, false, &|_| false, 1.0)?;
made.extend(m);
}
}
Ok(ids_of(sk, &made))
})?;
Ok(json!({"curves": out, "sketch": info}))
pattern::run(s, p, false)
}
/// Move the points of the entities (in place) through `f`; circle radii scale by `k`.
@@ -1665,6 +1626,7 @@ fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Ve
*r *= k;
}
}
sk.follow_pattern_seeds();
Ok(())
}
+4 -1
View File
@@ -4,6 +4,9 @@ use solidcraft_sketch::{ConstraintKind, CurveKind};
use crate::Session;
#[path = "sketch_pattern_tests.rs"]
mod pattern_tests;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
@@ -1475,7 +1478,7 @@ fn offset_driver_format18_offsets_keep_fixed_distances_on_upgrade() {
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = json!("solidcraft/18");
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&json.to_string()).unwrap());
assert_eq!(reopened.doc.format, "solidcraft/19");
assert_eq!(reopened.doc.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT), "upgraded on opening");
run(&mut reopened, "sketch.edit", json!({"sketch":1}));
let sk = sketch(&reopened);
assert!((sk.offsets[0].distance - 2.0).abs() < 1e-8);
+190
View File
@@ -0,0 +1,190 @@
//! Associative sketch patterns. Editing retains ids at surviving instance indices.
use solidcraft_sketch::{PatternInstance, SketchPattern, SketchPatternKind};
use super::*;
pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
let cmd = if circular { "sketch.pattern.circular" } else { "sketch.pattern.rectangular" };
let (out, info) = edit(s, p, cmd, true, |sk, _| {
if sk.patterns.len() >= solidcraft_sketch::MAX_CONSTRAINTS {
return Err(bad(cmd, "too many sketch patterns"));
}
let old_index = str_(p, "pattern").and_then(|id| sk.patterns.iter().position(|x| x.id == id));
let old = if let Some(id) = str_(p, "pattern") {
let i = sk.patterns.iter().position(|x| x.id == id).ok_or_else(|| bad(cmd, "unknown sketch pattern"))?;
Some(sk.patterns.remove(i))
} else {
None
};
let mut params = p.clone();
if let Some(old) = &old {
if circular != matches!(old.kind, SketchPatternKind::Circular { .. }) {
return Err(bad(cmd, "use the existing pattern's type"));
}
let defaults = match &old.kind {
SketchPatternKind::Circular { center, count, angle } => json!({"entities":old.entities,"center":center,"count":count,"angle":angle}),
SketchPatternKind::Rectangular { dir, count, spacing, count2, spacing2 } => {
json!({"entities":old.entities,"dir":dir,"count":count,"spacing":spacing,"count2":count2,"spacing2":spacing2})
}
};
if let (Some(params), Some(defaults)) = (params.as_object_mut(), defaults.as_object()) {
for (key, value) in defaults {
params.entry(key.clone()).or_insert_with(|| value.clone());
}
}
}
let (mut cs, mut ps) = entities(sk, &params, cmd)?;
cs.sort_unstable();
cs.dedup();
ps.sort_unstable();
ps.dedup();
let entity_ids: Vec<String> = cs
.iter()
.filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone()))
.chain(ps.iter().filter_map(|i| sk.points.get(*i).map(|p| p.id.clone())))
.collect();
if let Some(old) = &old {
if entity_ids != old.entities {
return Err(bad(cmd, "the seed entities of an existing pattern cannot be changed"));
}
if old.instances.len() >= MAX_COPIES {
return Err(bad(cmd, "too many stored pattern instances"));
}
if old.instances.iter().any(|instance| {
instance.curves.iter().any(|id| sk.curve_index(id).is_none())
|| instance.points.iter().any(|(p, q)| sk.resolve_point(p).is_none() || sk.resolve_point(q).is_none())
|| instance.constraints.iter().any(|id| !sk.constraints.iter().any(|c| c.id == *id))
}) {
return Err(bad(cmd, "the pattern has deleted geometry or relations; undo the deletion before editing it"));
}
}
let n = count(&params, "count", cmd)?;
let (kind, center, slots) = if circular {
let total = if params.get("angle").is_some() { num(&params, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 360.0 };
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
return Err(bad(cmd, "`angle` must be nonzero and at most 360 degrees"));
}
let center = match params.get("center") {
Some(Value::String(id)) => sk.resolve_point(id).ok_or_else(|| bad(cmd, "unknown pattern center point"))?,
Some(value) => {
let pos = vec2(value).ok_or_else(|| bad(cmd, "`center` must be [x,y] or a point ref"))?;
// A centre placed in empty space is a real point that can subsequently
// be made coincident with a circle centre or dimensioned.
sk.add_point(pos, None)?
}
None => return Err(bad(cmd, "missing `center`")),
};
let id = sk.points.get(center).ok_or_else(|| bad(cmd, "missing center point"))?.id.clone();
let full = (total.abs() - 360.0).abs() < 1e-9;
let step = total / if full { n as f64 } else { (n.max(2) - 1) as f64 };
(
SketchPatternKind::Circular { center: id, count: n, angle: total },
Some(center),
(1..n).map(|i| ([i, 0], (step * i as f64).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
)
} else {
let spacing = num(&params, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be finite"))?;
let count2 = if params.get("count2").is_some() { count(&params, "count2", cmd)? } else { 1 };
let spacing2 = if params.get("spacing2").is_some() {
num(&params, "spacing2").ok_or_else(|| bad(cmd, "`spacing2` must be finite"))?
} else {
spacing
};
if n * count2 > MAX_COPIES {
return Err(bad(cmd, "too many copies"));
}
let dir = match params.get("dir") {
Some(value) => vec2(value).and_then(Vec2::normalized).ok_or_else(|| bad(cmd, "`dir` must be a finite nonzero vector"))?,
None => Vec2::X,
};
let slots = (0..n)
.flat_map(|i| {
(0..count2).filter_map(move |j| {
(i != 0 || j != 0).then_some(([i, j], 0.0, dir * (spacing * i as f64) + dir.perp() * (spacing2 * j as f64)))
})
})
.collect();
(SketchPatternKind::Rectangular { dir, count: n, spacing, count2, spacing2 }, None, slots)
};
let id = match &old {
Some(old) => old.id.clone(),
None => sk.fresh("pat")?,
};
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
let mut existing = old.map(|old| old.instances).unwrap_or_default();
// Remove retired instances first: all following indices are resolved by stable id.
for instance in existing.iter().filter(|instance| !slots.iter().any(|(index, _, _)| *index == instance.index)) {
let curves: Vec<_> = instance.curves.iter().filter_map(|id| sk.curve_index(id)).collect();
sk.constraints.retain(|c| !instance.constraints.contains(&c.id));
sk.remove_curves(&curves);
let points: Vec<_> = instance.points.iter().filter_map(|(_, id)| sk.resolve_point(id)).collect();
sk.remove_points(&points);
}
let mut instances = Vec::new();
let center = center_id.as_ref().and_then(|id| sk.resolve_point(id));
if center_id.is_some() && center.is_none() {
return Err(bad(cmd, "the center belongs to an instance removed by this edit"));
}
let mut curves = Vec::new();
let mut constraints = Vec::new();
for (index, angle, offset) in slots {
let c = center.and_then(|i| sk.point(i)).unwrap_or(Vec2::ZERO);
let transform = |p| rotate(c, angle)(p) + offset;
let instance = if let Some(i) = existing.iter().position(|instance| instance.index == index) {
let instance = existing.remove(i);
for cid in &instance.constraints {
if let Some(constraint) = sk.constraints.iter_mut().find(|constraint| constraint.id == *cid)
&& let ConstraintKind::PatternPoint { center: slot, angle: a, offset: off, .. } = &mut constraint.kind
{
*slot = center;
*a = angle;
*off = offset;
}
}
for (seed, copy) in &instance.points {
let pos = sk.resolve_point(seed).and_then(|i| sk.point(i)).ok_or_else(|| bad(cmd, "missing seed point"))?;
if let Some(point) = sk.resolve_point(copy).and_then(|i| sk.points.get_mut(i)) {
point.pos = transform(pos);
}
}
instance
} else {
let cs: Vec<_> = entity_ids.iter().filter_map(|id| sk.curve_index(id)).collect();
let ps: Vec<_> = entity_ids.iter().filter(|id| sk.curve_index(id).is_none()).filter_map(|id| sk.resolve_point(id)).collect();
let (made, map) = copy_through(sk, &cs, &ps, &transform, false, &|_| false, 1.0)?;
let mut cons = Vec::new();
for (p, q) in &map {
cons.push(add_c(sk, ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset })?);
}
for (a, b) in cs.iter().zip(&made) {
if matches!(sk.curves.get(*a).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
cons.push(add_c(sk, ConstraintKind::PatternRadius { a: *a, b: *b })?);
}
}
PatternInstance {
index,
curves: ids_of(sk, &made),
points: map.iter().filter_map(|(p, q)| Some((sk.points.get(*p)?.id.clone(), sk.points.get(*q)?.id.clone()))).collect(),
constraints: cons,
}
};
curves.extend(instance.curves.iter().cloned());
constraints.extend(instance.constraints.iter().cloned());
instances.push(instance);
}
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
let pattern = SketchPattern { id: id.clone(), entities: entity_ids, kind, instances };
if let Some(i) = old_index {
sk.patterns.insert(i.min(sk.patterns.len()), pattern);
} else {
sk.patterns.push(pattern);
}
Ok(json!({"pattern":id,"center":center_id,"curves":curves,"constraints":constraints}))
})?;
let mut out = out;
if let Some(out) = out.as_object_mut() {
out.insert("sketch".into(), info);
}
Ok(out)
}
@@ -0,0 +1,205 @@
use super::*;
fn point(sk: &solidcraft_sketch::Sketch, id: &str) -> Vec2 {
sk.point(sk.resolve_point(id).unwrap()).unwrap()
}
fn check_relations(s: &Session) {
let sk = sketch(s);
for (i, constraint) in sk.constraints.iter().enumerate() {
assert!(solidcraft_sketch::constraint_residual(&sk, i).is_some_and(|r| r < 1e-6), "{} {:?}", constraint.id, constraint.kind);
}
}
#[test]
fn circular_pattern_keeps_seed_copy_and_center_related_during_drags() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["l1"],"center":"origin","count":4}));
assert_eq!(out["sketch"]["dof"], 4);
assert_eq!(ids(&out["created"]["constraints"]).len(), 6);
run(&mut s, "sketch.drag", json!({"entity":"l2.start","from":[0,10],"to":[5,13]}));
check_relations(&s);
let sk = sketch(&s);
assert!(point(&sk, "l1.start").dist(Vec2::new(13.0, -5.0)) < 1e-6);
assert!(point(&sk, "l3.start").dist(Vec2::new(-13.0, 5.0)) < 1e-6);
run(&mut s, "sketch.drag", json!({"entity":"l1.end","from":[20,0],"to":[24,3]}));
check_relations(&s);
assert!(point(&sketch(&s), "l2.end").dist(Vec2::new(-3.0, 24.0)) < 1e-6);
}
#[test]
fn constrained_seed_prevents_independent_copy_motion() {
for (cmd, params) in [
("sketch.pattern.circular", json!({"entities":["l1"],"center":"origin","count":4})),
("sketch.pattern.rectangular", json!({"entities":["l1"],"count":4,"spacing":10})),
] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
run(&mut s, "sketch.constraint.fix", json!({"entities":["l1"]}));
let out = run(&mut s, cmd, params);
assert_eq!(out["sketch"]["dof"], 0);
let before = point(&sketch(&s), "l2.start");
run(&mut s, "sketch.drag", json!({"entity":"l2.start","from":before,"to":[7,17]}));
assert!(point(&sketch(&s), "l2.start").dist(before) < 1e-6);
check_relations(&s);
}
}
#[test]
fn circular_center_reference_follows_a_moved_circle_and_empty_space_center_is_constrainable() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[3,4],"radius":2,"id":"c1"}));
run(&mut s, "sketch.line", json!({"points":[[13,4],[23,4]],"ids":["l1"]}));
run(&mut s, "sketch.pattern.circular", json!({"entities":["l1"],"center":"c1.center","count":3,"angle":-180}));
run(&mut s, "sketch.drag", json!({"entity":"c1.center","from":[3,4],"to":[8,9]}));
check_relations(&s);
let center = point(&sketch(&s), "c1.center");
assert!(center.dist(Vec2::new(8.0, 9.0)) < 1e-6);
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["l1"],"center":[8,9],"count":2}));
let center_id = out["center"].as_str().unwrap();
run(&mut s, "sketch.constraint.coincident", json!({"a":center_id,"b":"c1.center"}));
check_relations(&s);
}
#[test]
fn rectangular_pattern_preserves_signed_two_axis_offsets_and_circle_radii() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center":[10,5],"radius":2,"id":"c1"}));
let out = run(&mut s, "sketch.pattern.rectangular", json!({"entities":["c1"],"dir":[0,1],"count":3,"spacing":-8,"count2":2,"spacing2":6}));
assert_eq!(out["sketch"]["dof"], 3);
run(&mut s, "sketch.drag", json!({"entity":"c1.center","from":[10,5],"to":[12,7]}));
run(&mut s, "sketch.drag", json!({"entity":"c1","from":[14,7],"to":[16,7]}));
check_relations(&s);
let sk = sketch(&s);
for id in ids(&out["curves"]) {
assert!(close(sk.radius(sk.curve_index(&id).unwrap()).unwrap(), 4.0));
}
assert!(point(&sk, "c2.center").dist(Vec2::new(6.0, 7.0)) < 1e-6);
assert!(point(&sk, "c6.center").dist(Vec2::new(6.0, -9.0)) < 1e-6);
}
#[test]
fn pattern_edits_retain_surviving_ids_and_work_after_undo_and_reopen() {
for circular in [false, true] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
let cmd = if circular { "sketch.pattern.circular" } else { "sketch.pattern.rectangular" };
let params =
if circular { json!({"entities":["l1"],"center":"origin","count":4}) } else { json!({"entities":["l1"],"count":4,"spacing":10}) };
let original = run(&mut s, cmd, params);
let pattern = original["pattern"].as_str().unwrap();
let before = s.doc.to_json();
let shrunk = run(&mut s, cmd, json!({"pattern":pattern,"count":2}));
assert_eq!(ids(&shrunk["curves"]), vec!["l2"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
let mut reopened =
Session { doc: solidcraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
reopened.refresh();
let grown = run(&mut reopened, cmd, json!({"pattern":pattern,"count":5,"spacing":12,"angle":-180}));
assert_eq!(ids(&grown["curves"])[0], "l2");
assert_eq!(ids(&grown["curves"]).len(), 4);
assert!(!ids(&grown["curves"]).contains(&"l3".to_string()));
check_relations(&reopened);
let sk = sketch(&reopened);
let expected = if circular { Vec2::new(10.0 / 2.0_f64.sqrt(), -10.0 / 2.0_f64.sqrt()) } else { Vec2::new(22.0, 0.0) };
assert!(point(&sk, "l2.start").dist(expected) < 1e-6);
}
}
#[test]
fn malformed_pattern_edits_roll_back_and_deleted_seed_does_not_panic() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
let out = run(&mut s, "sketch.pattern.rectangular", json!({"entities":["l1","l1"],"count":3,"spacing":10}));
assert_eq!(ids(&out["curves"]).len(), 2);
let before = s.doc.to_json();
for params in [json!({"count":2.5}), json!({"count":501}), json!({"count2":500}), json!({"dir":[0,0]}), json!({"spacing":1e308})] {
let mut params = params;
params["pattern"] = out["pattern"].clone();
assert!(s.execute("sketch.pattern.rectangular", &params).is_err());
assert_eq!(s.doc.to_json(), before);
}
run(&mut s, "sketch.delete", json!({"entities":["l1"]}));
assert!(s.execute("sketch.pattern.rectangular", &json!({"pattern":out["pattern"],"count":4})).is_err());
}
#[test]
fn pattern_seed_move_and_scale_preserve_shape_and_shared_points() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0],[20,10]],"ids":["l1","l2"]}));
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["l1","l2"],"center":"origin","count":3}));
let sk = sketch(&s);
assert_eq!(sk.resolve_point("l3.end"), sk.resolve_point("l4.start"));
assert_eq!(ids(&out["created"]["constraints"]).len(), 6);
run(&mut s, "sketch.move", json!({"entities":["l1","l2"],"translate":[5,3]}));
assert!(point(&sketch(&s), "l1.start").dist(Vec2::new(15.0, 3.0)) < 1e-6);
check_relations(&s);
run(&mut s, "sketch.scale", json!({"entities":["l1","l2"],"base":"origin","factor":2}));
assert!(point(&sketch(&s), "l1.start").dist(Vec2::new(30.0, 6.0)) < 1e-6);
check_relations(&s);
}
#[test]
fn malformed_saved_pattern_settings_and_relations_are_refused() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
run(&mut s, "sketch.pattern.circular", json!({"entities":["l1"],"center":"origin","count":3}));
let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
for key in ["count", "index", "angle", "relation", "center"] {
let mut value = saved.clone();
let sk = &mut value["features"][0]["sketch"];
match key {
"count" => sk["patterns"][0]["kind"]["count"] = json!(usize::MAX),
"index" => sk["patterns"][0]["instances"][0]["index"] = json!([usize::MAX, 0]),
"angle" => sk["patterns"][0]["kind"]["angle"] = json!(1e308),
"center" => sk["patterns"][0]["kind"]["center"] = json!("missing"),
_ => sk["constraints"][0]["offset"] = json!([1e308, 0]),
}
assert!(solidcraft_doc::format::read(&value.to_string()).is_err(), "{key}");
}
let mut legacy = saved;
legacy["format"] = json!("solidcraft/19");
let sk = legacy["features"][0]["sketch"].as_object_mut().unwrap();
sk.remove("patterns");
sk.insert("constraints".into(), json!([]));
let read = solidcraft_doc::format::read(&legacy.to_string()).unwrap();
assert!(read.sketch(s.active_sketch.unwrap()).unwrap().patterns.is_empty());
}
#[test]
fn patterned_arc_ellipse_spline_and_conic_follow_seed_geometry_exactly() {
for (cmd, params) in [
("sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,10],"connect":false})),
("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":4})),
("sketch.spline.fit_point", json!({"points":[[0,0],[6,8],[14,3],[20,0]],"connect":false})),
("sketch.conic", json!({"start":[0,0],"end":[20,0],"apex":[10,20],"rho":0.65,"connect":false})),
] {
let mut s = new_sketch();
let seed = ids(&run(&mut s, cmd, params)["curves"])[0].clone();
let out = run(&mut s, "sketch.pattern.rectangular", json!({"entities":[seed],"count":3,"spacing":40}));
run(&mut s, "sketch.scale", json!({"entities":[seed],"base":"origin","factor":1.5}));
let sk = sketch(&s);
if matches!(sk.curves[sk.curve_index(&seed).unwrap()].kind, CurveKind::Conic { .. }) {
let from = sk.exact_segs(sk.curve_index(&seed).unwrap()).unwrap()[0].point_at(0.5);
run(&mut s, "sketch.drag", json!({"entity":seed,"from":from,"to":[from.x,from.y+2.0]}));
}
check_relations(&s);
let sk = sketch(&s);
let ci = sk.curve_index(&seed).unwrap();
let original = sk.exact_segs(ci).unwrap_or_else(|| sk.segs(ci));
for (i, id) in ids(&out["curves"]).iter().enumerate() {
let ci = sk.curve_index(id).unwrap();
let copy = sk.exact_segs(ci).unwrap_or_else(|| sk.segs(ci));
assert_eq!(copy.len(), original.len());
for (a, b) in original.iter().zip(copy) {
for t in [0.0, 0.3, 0.7, 1.0] {
assert!(b.point_at(t).dist(a.point_at(t) + Vec2::new(40.0 * (i + 1) as f64, 0.0)) < 1e-6, "{cmd}");
}
}
}
assert!(solidcraft_doc::format::read(&s.doc.to_json()).is_ok());
}
}
@@ -214,3 +214,159 @@ fn text_style_parameters_are_checked() {
let LinkSource::Text { style, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert!(style.italic && style.letter_spacing == 1.5);
}
/// `base` with the keys of `extra` set (a null `extra` value is sent as null).
fn with(mut base: Value, extra: &Value) -> Value {
if let (Some(base), Some(extra)) = (base.as_object_mut(), extra.as_object()) {
for (key, value) in extra {
base.insert(key.clone(), value.clone());
}
}
base
}
const AT: &str = "`at` must be the baseline start [x, y]";
const HEIGHT: &str = "`height` must be a length in mm";
const ANGLE: &str = "`angle` must be an angle in degrees";
const TEXT: &str = "`text` must be a string";
/// Inputs of `sketch.text` and `sketch.edit_text` that were once read as "not given": a height of
/// "H" drew the text 5 mm high, `at: ["H", 25]` put it at the origin. Each with its message.
fn not_understood() -> Vec<(Value, &'static str)> {
vec![
(json!({"height":"H"}), HEIGHT),
(json!({"height":true}), HEIGHT),
(json!({"height":[10]}), HEIGHT),
(json!({"height":1e10}), HEIGHT),
(json!({"height":0}), "`height` must be positive"),
(json!({"height":-3}), "`height` must be positive"),
(json!({"angle":"A"}), ANGLE),
(json!({"angle":[15]}), ANGLE),
(json!({"angle":1e10}), ANGLE),
(json!({"at":["H",25]}), AT),
(json!({"at":[1]}), AT),
(json!({"at":[1,2,3]}), AT),
(json!({"at":[1,null]}), AT),
(json!({"at":[1e10,0]}), AT),
(json!({"at":"origin"}), AT),
(json!({"at":7}), AT),
(json!({"text":5}), TEXT),
(json!({"text":true}), TEXT),
(json!({"text":["I"]}), TEXT),
]
}
#[test]
fn sketch_text_refuses_inputs_it_does_not_understand() {
let (mut s, _, _) = setup();
let before = s.doc.to_json();
let undo = s.undo.len();
let base = json!({"text":"I","at":[0,0],"height":10});
let mut cases = not_understood();
// `at` is required: left out or null is refused too (null no longer means the origin).
cases.push((json!({"at":null}), AT));
for (extra, error) in cases {
let params = with(base.clone(), &extra);
let e = s.execute("sketch.text", &params).unwrap_err().to_string();
assert!(e.contains(error), "{params}: {e}");
assert_eq!(s.doc.to_json(), before, "{params}");
assert_eq!(s.undo.len(), undo, "{params}");
}
let e = s.execute("sketch.text", &json!({"text":"I","height":10})).unwrap_err().to_string();
assert!(e.contains(AT), "{e}");
// No text at all is still the empty-text error.
let e = s.execute("sketch.text", &json!({"at":[0,0]})).unwrap_err().to_string();
assert!(e.contains("`text` must not be empty"), "{e}");
assert_eq!(s.doc.to_json(), before);
}
#[test]
fn sketch_edit_text_refuses_inputs_it_does_not_understand() {
let (mut s, link, _) = setup();
let before = s.doc.to_json();
let undo = s.undo.len();
for (extra, error) in not_understood() {
let params = with(json!({"link":link}), &extra);
for live in [false, true] {
let params = with(params.clone(), &json!({"live":live}));
let e = s.execute("sketch.edit_text", &params).unwrap_err().to_string();
assert!(e.contains(error), "{params}: {e}");
assert_eq!(s.doc.to_json(), before, "{params}");
assert_eq!(s.undo.len(), undo, "{params}");
}
}
}
fn text_source_of(s: &Session, link: &str) -> (String, Vec2, f64, f64) {
let LinkSource::Text { text, at, height, angle, .. } = &s.doc.sketch(1).unwrap().link(link).unwrap().source else { panic!() };
(text.clone(), *at, *height, *angle)
}
#[test]
fn sketch_text_inputs_left_out_or_null_keep_their_defaults() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
// Optional inputs left out, or null: the defaults (5 mm, no angle). `at` as {x, y} is fine.
for (params, at) in [
(json!({"text":"I","at":[4,6]}), Vec2::new(4.0, 6.0)),
(json!({"text":"I","at":{"x":4,"y":6},"height":null,"angle":null,"font":null}), Vec2::new(4.0, 6.0)),
] {
let t = run(&mut s, "sketch.text", params);
let (text, got, height, angle) = text_source_of(&s, t["link"].as_str().unwrap());
assert_eq!((text.as_str(), height, angle), ("I", 5.0, 0.0));
assert!(got.dist(at) < 1e-12, "{got:?}");
}
// Given values are used, an angle of 0 and a negative position among them.
let t = run(&mut s, "sketch.text", json!({"text":"I","at":[-4.5,6],"height":12.5,"angle":0}));
let (_, at, height, angle) = text_source_of(&s, t["link"].as_str().unwrap());
assert!(at.dist(Vec2::new(-4.5, 6.0)) < 1e-12 && height == 12.5 && angle == 0.0, "{at:?} {height} {angle}");
let t = run(&mut s, "sketch.text", json!({"text":"I","at":[0,0],"angle":90}));
assert!((text_source_of(&s, t["link"].as_str().unwrap()).3 - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
// An edit keeps what it leaves out, or sends as null, and changes what it gives.
let (mut s, link, _) = setup();
let old = text_source_of(&s, &link);
run(&mut s, "sketch.edit_text", json!({"link":link,"text":null,"at":null,"height":null,"angle":null}));
assert_eq!(text_source_of(&s, &link), old);
run(&mut s, "sketch.edit_text", json!({"link":link,"height":20,"angle":0}));
let (text, at, height, angle) = text_source_of(&s, &link);
assert_eq!((text.as_str(), height, angle), ("II", 20.0, 0.0));
assert!(at.dist(Vec2::new(3.0, 4.0)) < 1e-12, "{at:?}");
}
/// A text set on a spline stands on it, its descenders below it: the spline becomes
/// construction, so it doesn't cut the letters into pieces. Every profile is then the text's
/// alone (picking the text picks letters, not pieces of letters and curve), and one undo puts
/// the text and the curve back as they were.
#[test]
fn a_text_on_a_spline_turns_its_path_into_a_guide() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
let spline = run(&mut s, "sketch.spline.fit_point", json!({"points":[[0,0],[30,15],[60,0],[90,10]]}))["created"]["curves"][0]
.as_str()
.unwrap()
.to_string();
let construction = |s: &Session| {
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
sk.curves.iter().find(|c| c.id == spline).unwrap().construction
};
let t = run(&mut s, "sketch.text", json!({"text":"Phago","at":[0,0],"height":10,"path":spline}));
assert_eq!(t["guide"], json!(spline));
assert!(construction(&s));
let state = s.world_state();
let ss = state.sketch(s.active_sketch.unwrap()).unwrap();
for (i, p) in ss.profiles.iter().enumerate() {
let alone = p.curve_ids().iter().all(|id| ss.sketch.curves.iter().any(|c| &c.id == id && c.link.is_some()));
assert!(alone, "profile {i} is bounded by the spline too");
}
// Taken off the curve, the text leaves it a guide; set on it again, nothing changes.
let link = t["link"].as_str().unwrap().to_string();
run(&mut s, "sketch.edit_text", json!({"link":link,"path":""}));
assert!(construction(&s));
assert_eq!(run(&mut s, "sketch.edit_text", json!({"link":link,"path":spline}))["guide"], json!(null));
// One undo for each step, back to the text's first state, then to before it.
for _ in 0..3 {
run(&mut s, "edit.undo", json!({}));
}
assert!(!construction(&s), "undoing the text gives the curve back");
}
+19
View File
@@ -661,6 +661,25 @@ mod tests {
assert_eq!(s.doc.features.len(), before, "a planned command changed the design");
}
/// A trim keeps its construction plane when the plane is renamed.
#[test]
fn renaming_a_plane_keeps_a_surface_trim_on_it() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.patch", json!({"sketch": "S", "body_name": "Sheet"}));
run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 25, "name": "Cut"}));
run(&mut s, "surface.trim", json!({"body": "Sheet", "plane": "Cut", "keep": [10, 10, 0], "name": "Trim"}));
let trimmed = area(&s, "Sheet");
run(&mut s, "timeline.rename", json!({"feature": "Cut", "name": "Cutter"}));
assert!(s.model.results.iter().all(|r| r.error.is_none()), "the trim still finds its plane");
let def = run(&mut s, "timeline.inspect", json!({"feature": "Trim"}));
assert_eq!(def["definition"]["plane"]["name"], "Cutter", "{def}");
assert!((area(&s, "Sheet") - trimmed).abs() < 1e-6, "{} vs {trimmed}", area(&s, "Sheet"));
assert!(trimmed < 40.0 * 20.0 * 0.9, "the sheet is trimmed: {trimmed}");
}
/// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim
/// that is not planar patched.
#[test]
+174
View File
@@ -0,0 +1,174 @@
//! Thin Extrude (#721): walls along sketch curves, their volumes worked out by hand (a wall's
//! area times its height).
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn body_count(s: &mut Session) -> u64 {
run(s, "inspect.measure", json!({}))["body_count"].as_u64().unwrap_or(0)
}
fn close(a: f64, b: f64) {
assert!((a - b).abs() <= 1e-6 * b.abs().max(1.0), "{a} != {b}");
}
/// A sketch on XY holding a 40 × 20 rectangle from (5, 5); returns its side ids.
fn rectangle(s: &mut Session) -> Vec<String> {
run(s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
let r = run(s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [45, 25]}));
run(s, "sketch.finish", json!({}));
["bottom", "right", "top", "left"].iter().map(|k| r["sides"][k].as_str().unwrap_or_default().to_string()).collect()
}
#[test]
fn a_rectangle_makes_a_ring_wall_on_either_side() {
for (side, extra, area) in [
("center", json!({}), 42.0 * 22.0 - 38.0 * 18.0),
("inside", json!({}), 40.0 * 20.0 - 36.0 * 16.0),
("outside", json!({}), 44.0 * 24.0 - 40.0 * 20.0),
("two_sides", json!({"thickness2": 1.5}), 43.0 * 23.0 - 39.0 * 19.0),
] {
let mut s = Session::default();
rectangle(&mut s);
let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": side, "distance": 10});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
if side == "two_sides" {
p["thickness"] = json!(0.5);
}
run(&mut s, "solid.extrude", p);
close(volume(&mut s), area * 10.0);
assert_eq!(body_count(&mut s), 1, "{side}");
}
}
#[test]
fn the_thickness_is_a_parameter_and_the_wall_survives_reopening() {
let mut s = Session::default();
rectangle(&mut s);
let r = run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 10, "name": "Wall"}));
assert!(r["created_bodies"].as_array().is_some_and(|b| b.len() == 1));
close(volume(&mut s), 240.0 * 10.0);
let f = s.doc.features.iter().find(|f| f.name == "Wall").cloned().unwrap();
let t = f.param_names.iter().zip(f.kind.inputs()).find(|(_, (label, _, _))| *label == "Thickness").map(|(n, _)| n.clone()).unwrap();
run(&mut s, "parameters.change", json!({"name": t, "expression": "4"}));
close(volume(&mut s), (44.0 * 24.0 - 36.0 * 16.0) * 10.0);
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
reopened.refresh();
close(volume(&mut reopened), (44.0 * 24.0 - 36.0 * 16.0) * 10.0);
// A plain extrude of the same sketch fills the rectangle.
let mut s = Session::default();
rectangle(&mut s);
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10}));
close(volume(&mut s), 800.0 * 10.0);
}
#[test]
fn chosen_curves_make_an_open_wall_that_joins_and_cuts() {
// Three sides of the rectangle: a U, inside toward the open side's middle.
let mut s = Session::default();
let sides = rectangle(&mut s);
let u = [&sides[0], &sides[1], &sides[2]];
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "curves": u, "thickness": 2, "side": "inside", "distance": 5}));
// 40 × 2 twice and 20 × 2, less the two 2 × 2 corners they share.
close(volume(&mut s), (80.0 + 40.0 + 80.0 - 8.0) * 5.0);
// Cut a groove 3 deep along the whole outline into a block under it.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"}));
rectangle(&mut s);
run(
&mut s,
"solid.extrude",
json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 3, "direction": "negative", "operation": "cut"}),
);
close(volume(&mut s), 15000.0 - 240.0 * 3.0);
assert_eq!(body_count(&mut s), 1);
// And join a rim on top of it.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"}));
rectangle(&mut s);
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 4, "operation": "auto"}));
close(volume(&mut s), 15000.0 + 240.0 * 4.0);
assert_eq!(body_count(&mut s), 1);
}
#[test]
fn a_ring_and_a_cross_of_walls_are_one_body() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Target"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 20}));
run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[0, -20], [0, 20]]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Target", "thin": true, "thickness": 1, "distance": 5}));
assert_eq!(body_count(&mut s), 1);
// The ring, plus the bars inside it less where they cross and where they run into it.
let ring = std::f64::consts::PI * (20.5f64.powi(2) - 19.5f64.powi(2));
let v = volume(&mut s);
assert!(v > (ring + 2.0 * 38.0 - 1.0) * 5.0 && v < (ring + 2.0 * 40.0) * 5.0, "{v}");
}
#[test]
fn bad_thin_walls_are_refused_and_change_nothing() {
let mut s = Session::default();
rectangle(&mut s);
let before = s.doc.to_json();
for (p, says) in [
(json!({"thin": true, "thickness": 2, "distance": 5, "curves": ["nope"]}), "no curve"),
(json!({"thin": true, "distance": 5}), "thickness"),
(json!({"thin": true, "thickness": 2, "distance": 5, "side": "two_sides"}), "thickness2"),
(json!({"thin": true, "thickness": 2, "thickness2": 1, "distance": 5}), "two_sides"),
(json!({"thin": true, "thickness": 2, "distance": 5, "side": "sideways"}), "unknown side"),
(json!({"thin": true, "thickness": 2, "distance": 5, "profiles": [0]}), "profiles"),
(json!({"thin": true, "thickness": 0, "distance": 5}), "thickness"),
(json!({"thin": true, "thickness": -1, "distance": 5}), "thickness"),
(json!({"thin": "yes", "thickness": 1, "distance": 5}), "`thin`"),
] {
let mut p = p;
p["sketch"] = json!("Outline");
let e = s.execute("solid.extrude", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains(says), "{p}: {e}");
assert_eq!(s.doc.to_json(), before);
}
// A wall thicker than an arc it follows bends.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 1}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.extrude", &json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "inside", "distance": 5}));
assert!(e.is_err_and(|e| e.to_string().contains("thicker")));
run(&mut s, "solid.extrude", json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "outside", "distance": 5}));
// Measured from the tessellation, within its 1e-4.
let (v, exact) = (volume(&mut s), std::f64::consts::PI * (16.0 - 1.0) * 5.0);
assert!((v - exact).abs() < 1e-4 * exact, "{v} vs {exact}");
}
#[test]
fn hostile_thin_params_never_panic() {
let keys = ["curves", "thickness", "thickness2", "side", "distance", "taper", "direction", "to", "operation"];
for v in crate::tests::hostile_values().into_iter().chain([json!(1e9), json!(1e-9), json!("1e6 m"), json!(["l1", "l1", "l1"])]) {
for k in keys {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
rectangle(&mut s);
let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": "two_sides", "thickness2": 1, "distance": 5});
p[k] = v.clone();
if let Err(crate::EngineError::Internal(id, msg)) = s.execute("solid.extrude", &p) {
panic!("{id} {p}: {msg}");
}
}
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ fn assert_fk(m: &UrdfModel, s: &Session, what: &str) {
}
fn temp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-urdf-{tag}-{}", std::process::id()));
let d = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-urdf-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let _ = std::fs::create_dir_all(&d);
d
+1
View File
@@ -67,6 +67,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"sheet.convert" => &[("face", Pick), ("bends", Picks), ("body", Body)],
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
"sheet.perforate" => &[("body", Body)],
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
"canvas.decal" => &[("face", Here)],
"canvas.insert" => &[("plane", HerePlane), ("at", Here)],
+21 -8
View File
@@ -173,6 +173,8 @@ struct WorldCache {
active_component: u64,
active_occurrence_path: Option<Vec<u64>>,
world: Arc<solidcraft_doc::ModelState>,
/// The moved components' bodies as placed for `world`, reused by the next world state.
placed: Arc<std::sync::Mutex<solidcraft_doc::PlacedBodies>>,
}
impl WorldCache {
@@ -395,17 +397,27 @@ impl Session {
{
return cached.world.clone();
}
let mut w = if self.pending_moves.is_empty() {
solidcraft_doc::world_state(&self.doc, &st)
} else {
let mut d = (*self.doc).clone();
for o in &mut d.occurrences {
if let Some(m) = self.pending_moves.get(&o.id) {
o.transform = *m;
// The bodies the last world state placed: unchanged ones are not transformed and meshed
// again (a design built command by command changed one body at a time).
let placed = self.world_cache.lock().ok().and_then(|c| c.as_ref().map(|c| c.placed.clone())).unwrap_or_default();
// Taken out while placing, so a scratch copy of the session sharing it never waits.
let mut reuse = placed.lock().map(|mut p| std::mem::take(&mut *p)).unwrap_or_default();
let mut w = {
if self.pending_moves.is_empty() {
solidcraft_doc::world_state_reusing(&self.doc, &st, &mut reuse)
} else {
let mut d = (*self.doc).clone();
for o in &mut d.occurrences {
if let Some(m) = self.pending_moves.get(&o.id) {
o.transform = *m;
}
}
solidcraft_doc::world_state_reusing(&d, &st, &mut reuse)
}
solidcraft_doc::world_state(&d, &st)
};
if let Ok(mut p) = placed.lock() {
*p = reuse;
}
// Sketch definitions are displayed once, at the placement currently being authored.
if self.active_occurrence_path.is_some() && self.active_component != 0 {
let first = self.occurrence_placement(&self.doc.first_path(self.active_component)).map(|p| p.world).unwrap_or(solidcraft_doc::IDENTITY);
@@ -440,6 +452,7 @@ impl Session {
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
world: w.clone(),
placed,
});
}
w
+28
View File
@@ -271,3 +271,31 @@ fn a_sketch_on_a_certified_face_survives_a_fillet_and_a_cut_upstream_of_it() {
}
assert!((top(&s) - 20.0).abs() < 1e-6, "{}", top(&s));
}
/// A fillet of a shell's inner corners names those edges. Thinning the wall past the outer
/// round removes them. The fillet must say the names are gone, not blend the seam nearest
/// the old pick, which now sits on the axis of the inner round (#775).
#[test]
fn a_shell_fillet_does_not_follow_a_gone_edge_onto_a_smooth_seam() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-27.5, -40], "p1": [27.5, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 25, "name": "Extrude1"}));
run(
&mut s,
"solid.fillet",
json!({"radius": 5, "name": "Fillet1", "edges": [[-27.5, -40, 12.5], [27.5, -40, 12.5], [27.5, 40, 12.5], [-27.5, 40, 12.5]]}),
);
run(&mut s, "solid.shell", json!({"faces": [[0, 0, 25]], "thickness": 5, "direction": "inside", "name": "Shell1"}));
run(
&mut s,
"solid.fillet",
json!({"radius": 5, "name": "Fillet2", "edges": [[-22.5, 35, 15], [22.5, 35, 15], [22.5, -35, 15], [-22.5, -35, 15]]}),
);
assert_eq!(error(&s, "Fillet2"), None);
let edited = s.execute("timeline.edit", &json!({"feature": "Shell1", "set": {"thickness": "2"}}));
let message = error(&s, "Fillet2").or_else(|| edited.err().map(|e| e.to_string())).unwrap_or_default();
assert!(message.contains("no longer exists"), "{message}");
assert!(!message.contains("smooth edge"), "{message}");
}
+6
View File
@@ -176,6 +176,12 @@ impl Session {
Ok(count)
}
/// Whether a joint's glyph is drawn (`browser.visibility`). Hiding a joint only hides its
/// drawing: it still constrains and drives the parts.
pub fn joint_visible(&self, id: u64) -> bool {
!self.visibility.hidden_joints.contains(&id)
}
pub fn body_visible(&self, name: &str) -> bool {
if self.visibility.hidden_bodies.iter().any(|n| n == name) {
return false;
+6 -3
View File
@@ -18,13 +18,16 @@ use solidcraft_doc::Document;
use crate::{EngineError, Result, Session};
/// The recovery folder: `SOLIDCRAFT_RECOVERY_DIR`, else the platform's per-user data folder.
/// Spelled with long names (`vfs::long_names`): an agent's commands reach it by this path, and
/// short (8.3) names are refused there.
pub fn default_dir() -> Option<PathBuf> {
use solidcraft_io::vfs::long_names;
// Tests never touch the user's real recovery folder.
if cfg!(test) {
return Some(std::env::temp_dir().join(format!("solidcraft-test-recovery-{}", std::process::id())));
return Some(solidcraft_io::vfs::temp_dir().join(format!("solidcraft-test-recovery-{}", std::process::id())));
}
if let Some(d) = solidcraft_geom::env::var_os("RECOVERY_DIR").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(d));
return Some(long_names(Path::new(&d)));
}
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
let root = if cfg!(windows) {
@@ -34,7 +37,7 @@ pub fn default_dir() -> Option<PathBuf> {
} else {
env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?
};
let (dir, legacy) = dirs_under(&root, cfg!(windows) || cfg!(target_os = "macos"));
let (dir, legacy) = dirs_under(&long_names(&root), cfg!(windows) || cfg!(target_os = "macos"));
// Compatibility shim (docs/compatibility.md): autosaves made before the rename (or, in the
// Flatpak sandbox, under the old app id) are copied to the new folder on first launch; the
// old folder is left as it was.
+89 -3
View File
@@ -252,13 +252,38 @@ fn join_of_two_overlapping_rings_is_not_refused() {
assert!(rel(got, want) < 1e-3, "{got} vs {want}");
}
/// A disc with a lobe on its rim, patterned six times about its centre: every copy shares the
/// whole disc with the target, and joining them was refused as too much contact work (#1029).
#[test]
fn join_of_a_lobed_disc_pattern_is_not_refused() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "diameter": 15}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 7.5], "diameter": 5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "profiles": "all", "operation": "new", "body_name": "Disc"}));
run(&mut s, "solid.pattern.circular", json!({"bodies": ["Disc"], "axis": "Z", "count": 6}));
let tools: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).filter(|n| n != "Disc").collect();
assert_eq!(tools.len(), 5);
run(&mut s, "solid.combine", json!({"target": "Disc", "tools": tools, "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
// The disc plus six lobes, each less the part inside the disc; neighbouring lobes stay apart.
let (disc, lobe) = (7.5, 2.5);
let want = (PI * disc * disc + 6.0 * (PI * lobe * lobe - disc_overlap(disc, lobe, disc))) * 10.0;
let got = volume(&mut s);
assert!(rel(got, want) < 1e-3, "{got} vs {want}");
}
mod blend_fit;
mod boolean_diagnostics;
mod component_frames;
mod large_step_import;
mod loft_cut;
mod loft_spacing;
mod mirror_planes;
mod path_scope;
mod primitive_points;
mod rim_cut;
mod robustness;
mod robustness_goldens;
mod spiral_join;
@@ -580,7 +605,7 @@ fn script_runs_commands() {
assert!(s.run_script(&json!([{"command": "Nope"}])).is_err());
}
fn hostile_values() -> Vec<Value> {
pub(crate) fn hostile_values() -> Vec<Value> {
vec![
Value::Null,
json!(true),
@@ -729,8 +754,8 @@ fn hostile_params_never_panic() {
while let Some(&id) = ids.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
for v in hostile_values() {
for k in keys {
if k == "path" && id.starts_with("parameters.") {
continue; // parameter files: covered by their own tests (no stray files)
if k == "path" && (id.starts_with("parameters.") || id == "timeline.export_script") {
continue; // parameter and script files: covered by their own tests (no stray files)
}
let mut s = Session::default();
// A model with a sketch, a body and an active sketch, so most commands get far.
@@ -2880,6 +2905,67 @@ fn renaming_a_pattern_copy_keeps_the_other_copies_names() {
assert_eq!(names, ["X", "Second", "Third"]);
}
/// Three solids in one STEP file that carry the same label: the file written for three boxes, each
/// named `X`.
fn step_three_solids_labelled_x(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-step-{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 p = dir.join("Three.step");
run(&mut s, "file.export", json!({"path": p.to_string_lossy()}));
let mut text = std::fs::read_to_string(&p).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(&p, text).unwrap();
p
}
/// Renaming one of several imported bodies that share a label leaves the names of the others alone:
/// the model lists them as `X`, `X (2)`, `X (3)`, and `X (3)` is still there after `X (2)` is renamed.
#[test]
fn renaming_an_imported_body_keeps_the_names_of_its_duplicates() {
let p = step_three_solids_labelled_x("duplicate-labels");
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": p.to_string_lossy()}));
let names = |s: &Session| s.model.state().bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&s), ["X", "X (2)", "X (3)"]);
let x_of =
|s: &mut Session, body: &str| run(s, "inspect.measure", json!({"bodies": [body]}))["bodies"][0]["bbox"]["min"][0].as_f64().map(f64::round);
assert_eq!(x_of(&mut s, "X (3)"), Some(60.0));
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
assert_eq!(names(&s), ["X", "Second", "X (3)"]);
assert_eq!(x_of(&mut s, "X (3)"), Some(60.0), "X (3) is still the solid at x = 60");
run(&mut s, "body.rename", json!({"body": "X (3)", "name": "Third"}));
assert_eq!(names(&s), ["X", "Second", "Third"]);
assert_eq!(x_of(&mut s, "Third"), Some(60.0));
// The names survive a rebuild and a reopen of the saved design.
let design = p.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!(names(&r), ["X", "Second", "Third"]);
}
/// The same when the first of the three is renamed: `X (2)` and `X (3)` do not slide down to `X` and
/// `X (2)`, and what is set on one of them (a material) stays with it.
#[test]
fn renaming_the_first_imported_duplicate_keeps_the_numbers_of_the_others() {
let p = step_three_solids_labelled_x("duplicate-labels-first");
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": p.to_string_lossy()}));
run(&mut s, "material.assign", json!({"bodies": ["X (3)"], "material": "Steel"}));
run(&mut s, "body.rename", json!({"body": "X", "name": "First"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["First", "X (2)", "X (3)"]);
assert!(s.doc.materials.contains_key("X (3)"), "the material is still on X (3): {:?}", s.doc.materials.keys().collect::<Vec<_>>());
}
/// Parameters that work are listed in the command's params doc, which is all agents see (#271).
#[test]
fn params_docs_list_working_parameters() {

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