Compare commits

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

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

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

* Tests: no unreachable! in the blend arrow helper

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

---------

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

Thanks to @kuraneko1 for this.

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

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

Fixes #1361

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

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

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

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

* CI: a test filter YAML parses

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 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
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruckandClaude Opus 5.5 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
bherbruckandClaude Opus 5.5 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
86 changed files with 4322 additions and 287 deletions

No files matched your search

Generated
+15 -15
View File
@@ -3445,7 +3445,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -3472,7 +3472,7 @@ dependencies = [
[[package]]
name = "solidcraft-cli"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3481,7 +3481,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"proc-macro2",
"quote",
@@ -3490,7 +3490,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"serde",
@@ -3507,7 +3507,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"serde",
@@ -3523,7 +3523,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -3532,7 +3532,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"naga",
@@ -3545,7 +3545,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"quick-xml",
"serde",
@@ -3560,7 +3560,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"rayon",
@@ -3578,7 +3578,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3586,7 +3586,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"png",
@@ -3597,7 +3597,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -3608,7 +3608,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3629,7 +3629,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -5107,7 +5107,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
"sha2",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.11.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+6 -1
View File
@@ -21,6 +21,8 @@ offsets and plastic face placements, XZ (+Y normal) with a v6 migration preservi
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
so edge-touching assembly neighbours stay separate regardless of body order (#226).
In scope, in priority order:
1. SOLID and SKETCH (now).
@@ -51,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| 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 | 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 | — |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
@@ -336,6 +338,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
walls through denser stations where the path has corners or the profile has holes.
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
certificate and refine within bounded node limits while retaining existing IDs.
+11 -1
View File
@@ -333,6 +333,12 @@
"ui.save_as": "Speichern unter",
"ui.save_as_ellipsis": "Speichern unter...",
"ui.scale": "Skalieren",
"ui.orientation": "Ausrichtung",
"ui.twist": "Verdrehung",
"ui.end_scale": "Endskalierung",
"ui.sweep_turn_with_path": "Mit Pfad drehen",
"ui.sweep_keep_as_drawn": "Wie gezeichnet",
"ui.sweep_follow_direction": "Richtung folgen",
"ui.section_analysis": "Querschnittsanalyse",
"ui.select": "Auswählen",
"ui.selection": "Auswahl",
@@ -594,6 +600,7 @@
"cmd.parameters_delete": "Parameter löschen",
"cmd.parameters_export": "Parameter exportieren",
"cmd.parameters_favorite": "Favorisierter Parameter",
"cmd.parameters_move": "Parameter verschieben",
"cmd.parameters_graph": "Parametergraph",
"cmd.parameters_import": "Parameter importieren",
"cmd.parameters_list": "Parameter auflisten",
@@ -896,5 +903,8 @@
"Mitre": "Gehrung",
"Reference face": "Referenzfläche",
"Round": "Rund",
"Type": "Typ"
"Type": "Typ",
"joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.",
"joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.",
"joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist."
}
+11 -1
View File
@@ -151,6 +151,7 @@
"cmd.parameters_delete": "Delete Parameter",
"cmd.parameters_export": "Export Parameters",
"cmd.parameters_favorite": "Favourite Parameter",
"cmd.parameters_move": "Move Parameter",
"cmd.parameters_graph": "Parameter Graph",
"cmd.parameters_import": "Import Parameters",
"cmd.parameters_list": "List Parameters",
@@ -636,6 +637,12 @@
"ui.save_as": "Save As",
"ui.save_as_ellipsis": "Save As...",
"ui.scale": "Scale",
"ui.orientation": "Orientation",
"ui.twist": "Twist",
"ui.end_scale": "End Scale",
"ui.sweep_turn_with_path": "Turn with Path",
"ui.sweep_keep_as_drawn": "Keep as Drawn",
"ui.sweep_follow_direction": "Follow Direction",
"ui.section_analysis": "Section Analysis",
"ui.select": "Select",
"ui.select_other_hint": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
@@ -1153,5 +1160,8 @@
"RULED SURFACE": "RULED SURFACE",
"Mitre": "Mitre",
"Reference face": "Reference face",
"Round": "Round"
"Round": "Round",
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is."
}
+136 -1
View File
@@ -4,6 +4,12 @@
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Ouvrir le brouillon GitHub ci-dessous. Votre titre et votre rapport y seront déjà remplis.",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Joindre votre capture d'écran : la copier-coller, ou enregistrer le PNG puis le téléverser.",
"3. Click Submit new issue on GitHub to send your report.": "3. Cliquer sur Submit new issue sur GitHub pour envoyer votre rapport.",
"nav.touchpad.label": "Pavé tactile",
"nav.touchpad.help": "Maintenir Maj et déplacer le pointeur pour faire un panoramique ; maintenir Alt/Option et le déplacer pour orbiter. Aucun bouton de souris n'est nécessaire. Le défilement vertical zoome.",
"nav.touchpad.orbit": "Orbite (maintenir Alt/Option et déplacer ; sans bouton de souris)",
"nav.touchpad.pan": "Panoramique (maintenir Maj et déplacer ; sans bouton de souris)",
"nav.touchpad.zoom": "Zoom (défilement vertical ou pincement)",
"nav.touchpad.scroll": "Le pavé tactile zoome avec le défilement vertical. Votre réglage de panoramique à deux doigts est conservé pour les autres préréglages.",
"browser.body_name": "Corps{number}",
"browser.component_name": "Composant{number}",
"browser.parameters": "Paramètres ({count})",
@@ -82,6 +88,9 @@
"cmd.doc_recovery_list": "Conceptions récupérables",
"cmd.document_inspect": "Inspecter la conception",
"cmd.document_units": "Changer les unités de la conception",
"cmd.document_metadata": "Propriétés de la conception",
"cmd.document_metadata_set": "Modifier les propriétés de la conception",
"cmd.document_metadata_remove": "Supprimer une propriété de la conception",
"cmd.edit_checkpoint": "Point de restauration",
"cmd.edit_restore_checkpoint": "Revenir au point de restauration",
"cmd.engine_commands": "Lister les commandes",
@@ -95,6 +104,7 @@
"cmd.file_insert_mesh": "Insérer un maillage",
"cmd.file_insert_step": "Insérer un STEP",
"cmd.file_save_mesh": "Enregistrer comme maillage",
"cmd.file_slicers": "Slicers installés",
"cmd.inspect_accessibility": "Analyse d'accessibilité",
"cmd.inspect_center_of_mass": "Centre de gravité",
"cmd.inspect_curvature_comb": "Analyse par peigne de courbure",
@@ -141,6 +151,7 @@
"cmd.parameters_delete": "Supprimer le paramètre",
"cmd.parameters_export": "Exporter les paramètres",
"cmd.parameters_favorite": "Paramètre favori",
"cmd.parameters_move": "Déplacer le paramètre",
"cmd.parameters_graph": "Graphe des paramètres",
"cmd.parameters_import": "Importer des paramètres",
"cmd.parameters_list": "Lister les paramètres",
@@ -283,6 +294,7 @@
"cmd.view_list": "Lister les vues nommées",
"cmd.view_rename": "Renommer la vue nommée",
"cmd.view_save": "Nouvelle vue nommée",
"dialog.computing": "Calcul… {seconds} s",
"dialog.custom_axis": "↖ Axe personnalisé",
"dialog.edit_title": "MODIFIER {title}",
"dialog.select": "↖ Sélectionner",
@@ -317,6 +329,16 @@
"ui.animation": "Animation",
"ui.animation_speed": "Vitesse d'animation",
"ui.anti_aliasing": "Anticrénelage",
"ui.supersampling": "Suréchantillonnage (SSAA)",
"ui.supersampling_off": "Désactivé (1×)",
"ui.supersampling_adaptive": "Seulement quand la vue est immobile",
"settings.supersampling_note": "Dessine la vue à une résolution plus élevée puis la réduit : arêtes, lignes et grille plus lisses sur les écrans de définition inférieure à la 4K. Cela coûte du temps GPU : environ 2,25× plus de pixels à 1,5× et 4× plus à 2×.",
"ui.display_quality": "Qualité d'affichage",
"ui.display_quality_coarse": "Grossière",
"ui.display_quality_normal": "Normale",
"ui.display_quality_fine": "Fine",
"ui.display_quality_ultra": "Ultra",
"settings.display_quality_note": "Les niveaux plus fins affichent plus lisses les faces et arêtes courbes, mais leur maillage prend plus de temps. Seule la vue change, jamais la conception ni les exports.",
"ui.apply": "Appliquer",
"ui.arc": "Arc",
"ui.assemble": "Assembler",
@@ -576,6 +598,10 @@
"ui.quit": "Quitter",
"ui.radial_context_menu": "Menu contextuel radial",
"ui.radius": "Rayon",
"ui.pull_direction": "Direction de démoulage",
"ui.tool_direction": "Direction de l'outil",
"ui.reachable": "Accessible",
"ui.blocked": "Inaccessible",
"ui.radius_type": "Type de rayon",
"ui.rectangle": "Rectangle",
"ui.rectangular_pattern": "Réseau rectangulaire",
@@ -590,9 +616,33 @@
"ui.revolve": "Révolution",
"ui.right": "Droite",
"ui.save": "Enregistrer",
"ui.heading_save_mesh": "ENREGISTRER COMME MAILLAGE",
"ui.format": "Format",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF avec étiquettes de faces",
"ui.send_to_slicer": "Envoyer au slicer",
"ui.slicer": "Slicer",
"ui.custom_program": "Personnalisé…",
"ui.no_slicer_found": "Aucun slicer trouvé",
"ui.send": "Envoyer",
"ui.custom_program_tip": "Choisir un slicer à ajouter à la liste",
"ui.slicer_delete_tip": "{program}\nAppuyer sur Suppr pour le retirer de la liste",
"ui.save_mesh_pick_bodies": "Sélectionner les corps à enregistrer",
"ui.save_mesh_no_slicer": "Aucun slicer trouvé : choisissez Personnalisé… et sélectionnez votre slicer, ou désactivez Envoyer au slicer",
"ui.not_a_program": "{path} n'est pas un programme exécutable",
"ui.sent_to_slicer": "Envoyé à {slicer}",
"ui.saved_to": "Enregistré : {path}",
"ui.save_mesh_temp_failed": "Impossible de créer {folder} : {error}",
"ui.save_as": "Enregistrer sous",
"ui.save_as_ellipsis": "Enregistrer sous...",
"ui.scale": "Échelle",
"ui.orientation": "Orientation",
"ui.twist": "Torsion",
"ui.end_scale": "Échelle finale",
"ui.sweep_turn_with_path": "Tourner avec la trajectoire",
"ui.sweep_keep_as_drawn": "Garder tel que dessiné",
"ui.sweep_follow_direction": "Suivre une direction",
"ui.section_analysis": "Analyse de coupe",
"ui.select": "Sélectionner",
"ui.select_other_hint": "Sélectionner un autre élément : maintenir le bouton gauche immobile sur le modèle (ou clic droit › Sélectionner un autre…) pour lister tout ce qui se trouve sous le curseur ; cliquer de nouveau au même endroit pour sélectionner l'élément suivant.",
@@ -744,6 +794,8 @@
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Les fichiers de nombreux logiciels de CAO ont Y vers le haut : ils s'ouvrent donc couchés ici (Z vers le haut).",
"Filter": "Filtrer",
"Find in Browser": "Trouver dans le navigateur",
"Copy Error Message": "Copier le message d'erreur",
"Copy Warning Message": "Copier le message d'avertissement",
"Flat size": "Dimensions à plat",
"Follow camera (Look At sketches)": "Suivre la caméra (vue normale aux esquisses)",
"Font": "Police",
@@ -932,6 +984,17 @@
"ui.pinToShortcuts": "Épingler aux raccourcis",
"ui.pinToToolbar": "Épingler à la barre d'outils",
"ui.properties": "Propriétés",
"ui.design_properties.project": "Projet",
"ui.design_properties.part_number": "Référence",
"ui.design_properties.revision": "Indice",
"ui.design_properties.author": "Auteur",
"ui.design_properties.organization": "Organisation",
"ui.design_properties.description": "Description",
"ui.design_properties.license": "Licence",
"ui.design_properties.not_text": "Enregistré sous une autre forme que du texte ; une saisie ici le remplace.",
"ui.design_properties.exports": "Les exports STEP, IGES et 3MF les incluent quand le format le permet.",
"ui.design_properties.others": "Données d'autres logiciels",
"ui.design_properties.others_note": "Conservées telles quelles dans la conception et jamais exportées.",
"ui.removeSection": "Retirer la coupe",
"ui.removeFromToolbar": "Retirer de la barre d'outils",
"ui.repeat": "Répéter",
@@ -955,6 +1018,9 @@
"ui.updateToCurrentView": "Mettre à jour avec la vue actuelle",
"ui.zoom": "Zoom",
"ui.command_done": "{command} terminé",
"drive.stopped_by_loop": "Arrêté à {value} : la boucle passant par {joint} ne peut plus se fermer",
"drive.stopped_by_contact": "Arrêté à {value} : {a} traverserait {b}",
"drive.unconnected": "Rien ne bouge : {part} n'est relié à rien de fixé",
"timeline.group": "Nouveau dossier de fonctions",
"timeline.ungroup": "Supprimer les dossiers de fonctions",
"timeline.rename_group": "Renommer le dossier de fonctions",
@@ -1029,5 +1095,74 @@
"update.install_failed": "La mise à jour ne s'est pas installée : {error}. Rien n'a été modifié ; Télécharger récupère plutôt le paquet.",
"cmd.render_settings_get": "Réglages de rendu",
"cmd.render_settings_set": "Enregistrer les réglages de rendu",
"drive.unconnected": "Rien ne bouge : {part} n'est relié à rien de fixé"
"drive.unconnected": "Rien ne bouge : {part} n'est relié à rien de fixé",
"joint.one_component": "Ces pièces sont des corps d'un même composant : {bodies}. Une liaison déplace un composant par rapport à un autre, chaque pièce a donc besoin de son propre composant.",
"joint.one_body": "Les deux sélections sont sur {body}. Choisissez la seconde sur une autre pièce.",
"joint.from_bodies_hint": "Chaque corps sélectionné passe dans un nouveau composant à lui et reste où il est.",
"cmd.render_scene": "Inspecter la scène de rendu",
"cmd.render_settings_apply": "Appliquer la caméra de rendu",
"toolbar.render": "RENDU",
"toolbar.render_scene": "SCÈNE",
"render.preview": "Aperçu intégré",
"render.preview_help": "Ombrage par rastérisation et transparence mélangée. La réfraction, l'HDRI et le rendu final ne sont pas encore disponibles.",
"render.setup_name": "Nom du préréglage",
"render.background": "Arrière-plan de la scène",
"render.perspective": "Caméra en perspective",
"render.fit": "Cadrer le produit",
"render.restore_camera": "Restaurer la caméra enregistrée",
"render.save_setup": "Enregistrer le préréglage",
"render.unsaved": "Modifications de l'aperçu non enregistrées",
"render.reload_settings": "Recharger les réglages enregistrés",
"render.unavailable_preview": "Ce préréglage demande un profil d'aperçu indisponible.",
"render.use_preview": "Utiliser l'aperçu intégré",
"render.navigation_help": "Faire glisser pour orbiter ; glisser avec le bouton du milieu pour un panoramique ; molette pour zoomer. Enregistrer le préréglage mémorise la caméra actuelle.",
"render.return_design": "Revenir à la conception",
"render.image_title": "Image de rendu",
"render.image_help": "Image d'aperçu CPU · PNG · transparence approximative",
"render.image_width": "Largeur",
"render.image_height": "Hauteur",
"render.image_transparent": "Arrière-plan transparent",
"render.image_check": "Vérifier la demande de rendu",
"render.image_supported": "La demande de rendu est prise en charge ; la file d'attente et la réserve de mémoire seront revérifiées au lancement.",
"render.image_queued": "En attente de rendu",
"render.image_preparing": "Préparation de l'image",
"render.image_running": "Rendu de l'image",
"render.image_encoding": "Encodage du PNG",
"render.image_cancelling": "Annulation",
"render.image_cancelled": "Rendu annulé",
"render.image_completed": "Image prête à exporter",
"render.image_failed": "Échec du rendu",
"render.image_unknown": "État du rendu indisponible",
"render.image_reduce": "Essayez une image plus petite ou une scène plus simple.",
"render.image_cancel": "Annuler le rendu",
"render.image_stale": "Cette image utilise la scène capturée avant les dernières modifications.",
"render.image_start": "Lancer le rendu",
"render.image_overwrite": "Remplacer le fichier existant",
"render.image_export": "Exporter en PNG",
"cmd.render_preflight": "Vérifier la demande de rendu",
"cmd.render_start": "Rendu d'image",
"cmd.render_status": "Progression du rendu",
"cmd.render_cancel": "Annuler le rendu",
"cmd.render_export": "Exporter l'image de rendu",
"toolbar.image": "IMAGE",
"appearance.surface_response": "Réponse de la surface",
"appearance.pbr_opaque": "PBR opaque",
"appearance.metallic": "Métallique",
"appearance.roughness": "Rugosité",
"appearance.dielectric_ior": "Indice de réfraction (diélectrique)",
"appearance.pbr_help": "Voir cette surface dans l'espace Rendu ou dans un PNG du produit. Sans réfraction ni transmission.",
"render.opaque_preview_help": "PBR opaque et couleurs simples ; transparence mélangée. La réfraction, l'HDRI et le rendu final ne sont pas encore disponibles.",
"render.opaque_image_summary": "PNG d'aperçu · PBR opaque · transparence mélangée",
"UNTRIM": "ANNULER L'AJUSTEMENT",
"Face": "Face",
"Holes": "Trous",
"Holes (edges on them)": "Trous (par leurs arêtes)",
"In place (surfaces)": "En place (surfaces)",
"None": "Aucun",
"Picked": "Sélectionnés",
"Restore outer boundary": "Restaurer le contour extérieur",
"RULED SURFACE": "SURFACE RÉGLÉE",
"Mitre": "Onglet",
"Reference face": "Face de référence",
"Round": "Arrondi"
}
+11 -1
View File
@@ -151,6 +151,7 @@
"cmd.parameters_delete": "パラメータを削除",
"cmd.parameters_export": "パラメータをエクスポート",
"cmd.parameters_favorite": "お気に入りパラメータ",
"cmd.parameters_move": "パラメータを移動",
"cmd.parameters_graph": "パラメータ グラフ",
"cmd.parameters_import": "パラメータをインポート",
"cmd.parameters_list": "パラメータを一覧表示",
@@ -636,6 +637,12 @@
"ui.save_as": "名前を付けて保存",
"ui.save_as_ellipsis": "名前を付けて保存…",
"ui.scale": "尺度",
"ui.orientation": "向き",
"ui.twist": "ねじれ",
"ui.end_scale": "終端スケール",
"ui.sweep_turn_with_path": "パスに沿って回転",
"ui.sweep_keep_as_drawn": "描いた向きを保持",
"ui.sweep_follow_direction": "方向に従う",
"ui.section_analysis": "断面解析",
"ui.select": "選択",
"ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。",
@@ -1153,5 +1160,8 @@
"RULED SURFACE": "ルールドサーフェス",
"Mitre": "留め継ぎ",
"Reference face": "参照面",
"Round": "丸め"
"Round": "丸め",
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。"
}
+11 -1
View File
@@ -333,6 +333,12 @@
"ui.save_as": "다른 이름으로 저장",
"ui.save_as_ellipsis": "다른 이름으로 저장...",
"ui.scale": "축척",
"ui.orientation": "배향",
"ui.twist": "비틀림",
"ui.end_scale": "끝 배율",
"ui.sweep_turn_with_path": "경로를 따라 회전",
"ui.sweep_keep_as_drawn": "그린 대로 유지",
"ui.sweep_follow_direction": "방향 따르기",
"ui.section_analysis": "단면 분석",
"ui.select": "선택",
"ui.selection": "선택",
@@ -594,6 +600,7 @@
"cmd.parameters_delete": "매개 변수 삭제",
"cmd.parameters_export": "매개 변수 내보내기",
"cmd.parameters_favorite": "즐겨찾는 매개 변수",
"cmd.parameters_move": "매개 변수 이동",
"cmd.parameters_graph": "매개 변수 그래프",
"cmd.parameters_import": "매개 변수 가져오기",
"cmd.parameters_list": "매개 변수 목록",
@@ -896,5 +903,8 @@
"Mitre": "연귀",
"Reference face": "참조 면",
"Round": "둥글게",
"Type": "유형"
"Type": "유형",
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다."
}
+11 -1
View File
@@ -140,6 +140,7 @@
"cmd.parameters_delete": "Удалить параметр",
"cmd.parameters_export": "Экспортировать параметры",
"cmd.parameters_favorite": "Избранный параметр",
"cmd.parameters_move": "Переместить параметр",
"cmd.parameters_graph": "График параметра",
"cmd.parameters_import": "Импортировать параметры",
"cmd.parameters_list": "Список параметров",
@@ -607,6 +608,12 @@
"ui.save_as": "Сохранить как",
"ui.save_as_ellipsis": "Сохранить как...",
"ui.scale": "Масштаб",
"ui.orientation": "Ориентация",
"ui.twist": "Закрутка",
"ui.end_scale": "Масштаб в конце",
"ui.sweep_turn_with_path": "Поворачивать по траектории",
"ui.sweep_keep_as_drawn": "Как нарисовано",
"ui.sweep_follow_direction": "Следовать направлению",
"ui.section_analysis": "Анализ сечения",
"ui.select": "Выбрать",
"ui.select_other_hint": "Выбрать другое: удерживайте левую кнопку на модели (или правый щелчок › Выбрать другое…), чтобы увидеть всё под курсором; повторный щелчок в том же месте выбирает следующий объект.",
@@ -1066,5 +1073,8 @@
"appearance.dielectric_ior": "Показатель преломления диэлектрика",
"appearance.pbr_help": "Просмотр поверхности во вкладке Рендер или на PNG изделия. Без преломления и пропускания света.",
"render.opaque_preview_help": "Непрозрачный PBR и простые цвета; прозрачность через смешивание. Преломление, HDRI и финальный рендер пока недоступны.",
"render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание"
"render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание",
"joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.",
"joint.one_body": "Оба выбора на {body}. Выберите второй на другой детали.",
"joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте."
}
+11 -1
View File
@@ -141,6 +141,7 @@
"cmd.parameters_delete": "Видалити параметр",
"cmd.parameters_export": "Експортувати параметри",
"cmd.parameters_favorite": "Обраний параметр",
"cmd.parameters_move": "Перемістити параметр",
"cmd.parameters_graph": "Граф параметрів",
"cmd.parameters_import": "Імпортувати параметри",
"cmd.parameters_list": "Список параметрів",
@@ -578,6 +579,12 @@
"ui.save_as": "Зберегти як",
"ui.save_as_ellipsis": "Зберегти як...",
"ui.scale": "Масштаб",
"ui.orientation": "Орієнтація",
"ui.twist": "Закручування",
"ui.end_scale": "Масштаб у кінці",
"ui.sweep_turn_with_path": "Повертати за траєкторією",
"ui.sweep_keep_as_drawn": "Як намальовано",
"ui.sweep_follow_direction": "Слідувати напрямку",
"ui.section_analysis": "Аналіз перерізу",
"ui.select": "Вибрати",
"ui.select_other_hint": "Вибір іншого: утримуйте ліву кнопку нерухомо на моделі (або клацніть правою › Вибрати інше…), щоб побачити список усього під курсором; клацніть ще раз у тому ж місці, щоб вибрати наступний об'єкт там.",
@@ -1012,5 +1019,8 @@
"update.installer_started": "Інсталятор запуститься, коли SolidCraft закриється.",
"update.dmg_opened": "Образ диска відкрито й перевірено: перетягніть SolidCraft до Applications, замінивши стару копію, а потім перезапустіть SolidCraft.",
"update.install_failed": "Оновлення не встановлено: {error}. Нічого не змінено; натомість «Завантажити» отримає пакет.",
"drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим"
"drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим",
"joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.",
"joint.one_body": "Обидва вибори на {body}. Виберіть другий на іншій деталі.",
"joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці."
}
+11 -1
View File
@@ -151,6 +151,7 @@
"cmd.parameters_delete": "删除参数",
"cmd.parameters_export": "导出参数",
"cmd.parameters_favorite": "收藏参数",
"cmd.parameters_move": "移动参数",
"cmd.parameters_graph": "参数关系图",
"cmd.parameters_import": "导入参数",
"cmd.parameters_list": "列出参数",
@@ -634,6 +635,12 @@
"ui.save_as": "另存为",
"ui.save_as_ellipsis": "另存为...",
"ui.scale": "缩放",
"ui.orientation": "定向",
"ui.twist": "扭转",
"ui.end_scale": "末端缩放",
"ui.sweep_turn_with_path": "随路径转动",
"ui.sweep_keep_as_drawn": "保持绘制方向",
"ui.sweep_follow_direction": "跟随方向",
"ui.section_analysis": "截面分析",
"ui.select": "选择",
"ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。",
@@ -1153,5 +1160,8 @@
"RULED SURFACE": "直纹曲面",
"Mitre": "斜接",
"Reference face": "参考面",
"Round": "圆角"
"Round": "圆角",
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。"
}
+42
View File
@@ -262,6 +262,26 @@ pub struct ThinWall {
pub thickness2: Option<String>,
}
/// How a swept profile turns as it follows its path.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(rename_all = "snake_case")]
pub enum SweepOrientation {
/// Square to the path, turning with it (a rotation-minimising frame).
#[default]
Perpendicular,
/// As drawn: the profile moves along the path without turning.
Parallel,
/// Square to the path, its turn about the path fixed by this direction (in the feature's
/// frame): the direction's projection onto the profile stays where it was at the start.
Direction(#[walk(dir)] Vec3),
}
impl SweepOrientation {
pub fn is_perpendicular(&self) -> bool {
*self == SweepOrientation::Perpendicular
}
}
/// 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).
@@ -701,6 +721,17 @@ pub enum FeatureKind {
#[walk(sketch)]
path_sketch: u64,
path: Vec<String>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
/// Angle the profile turns about the path over its length.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Twist")]
twist: Option<String>,
/// The profile's scale at the end of the path (linear along its length).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<String>,
#[serde(default)]
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -1327,6 +1358,17 @@ pub enum FeatureKind {
#[walk(sketch)]
path_sketch: u64,
path: Vec<String>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
/// Angle the profile turns about the path over its length.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Twist")]
twist: Option<String>,
/// The profile's scale at the end of the path (linear along its length).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(unitless = "Scale")]
scale: Option<String>,
},
/// A surface body lofted through sections in order, each one open or closed chain of
/// sketch curves: walls only, no caps.
+47 -10
View File
@@ -2276,17 +2276,24 @@ fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Featu
/// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out
/// or pocket of its flat pattern (#560). Returns the sheet bodies it machined; none, with the
/// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then
/// refuses it).
fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<more::PrimitivePrism>)]) -> Result<Vec<String>> {
/// refuses it). Pieces the hole parts from a sheet's base are named in `warning`.
fn sheet_hole_cut(
st: &mut ModelState,
f: &Feature,
tools: &[(Traced, Option<more::PrimitivePrism>)],
warning: &mut Option<String>,
) -> Result<Vec<String>> {
if st.sheets.is_empty() {
return Ok(Vec::new());
}
let saved = (st.sheets.clone(), st.bodies.clone());
let mut handled: Vec<String> = Vec::new();
// Noted only when every tool was machined here.
let mut parted = None;
for (tool, prism) in tools {
let tool = &tool.body;
let done = match prism {
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true) {
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true, &mut parted) {
Ok(done) => done,
// Drilled along a panel (into a rim) the hole is no flat cut-out: a bore of
// that panel, when it stays inside it.
@@ -2322,6 +2329,9 @@ fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<mor
}
}
}
if let Some(note) = parted {
append_warning(warning, note);
}
Ok(handled)
}
@@ -3043,7 +3053,8 @@ fn feature_tools_with_constructor(
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
Ok(vec![roles.tool(body)])
}
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => {
let ctl = sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -3061,10 +3072,10 @@ fn feature_tools_with_constructor(
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
// A path through 3D sketch curves follows them as points.
if let Some(pts) = path_points(ps, path, start)? {
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect();
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect();
}
let segs = path_segments(ps, path, start)?;
regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect()
regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect()
}
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
let ps =
@@ -3112,6 +3123,32 @@ fn feature_tools_with_constructor(
}
/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`.
/// A sweep's options from its stored orientation and its twist and end scale expressions
/// (none: no twist, scale 1). Out-of-range values are refused by the kernel.
pub(crate) fn sweep_control(
vals: &BTreeMap<String, Value>,
orientation: &crate::SweepOrientation,
twist: &Option<String>,
scale: &Option<String>,
) -> Result<kernel::SweepControl> {
let orientation = match *orientation {
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel,
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d),
};
let twist = match twist {
Some(t) => val(vals, t, Kind::Angle)?,
None => 0.0,
};
let scale = match scale {
Some(k) => val(vals, k, Kind::Unitless)?,
None => 1.0,
};
let ctl = kernel::SweepControl { orientation, twist, scale };
ctl.check()?;
Ok(ctl)
}
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
let holes = match wall {
@@ -4018,7 +4055,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
// Cuts through sheet metal become cut-outs of its flat pattern.
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref(), warning)?;
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
}
@@ -4028,7 +4065,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Hole { thread, diameter, .. } => {
let (spots, dir) = hole_spots(st, f)?;
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
if handled.is_empty() {
apply_traced(st, f, tools, Operation::Cut, &[])?;
@@ -4418,7 +4455,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false)?;
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false, warning)?;
let tools = vec![crate::lineage::box_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
@@ -4430,7 +4467,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let b = kernel::cylinder(base, *axis, radius, height)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height };
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false)?;
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false, warning)?;
let tools = vec![crate::lineage::cylinder_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
+84 -9
View File
@@ -618,7 +618,10 @@ pub(super) fn sheet_eval(
None => *p,
});
}
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
for note in notes {
append_warning(warning, note);
}
tag_flanges(&mut sh, 0, f.id);
put_sheet(st, sh)
}
@@ -761,6 +764,7 @@ pub(super) fn sheet_cut(
tools: &[Body],
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
warning: &mut Option<String>,
) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
@@ -768,7 +772,16 @@ pub(super) fn sheet_cut(
}
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some())
// The outlines of the sketch's text: the sheet remembers where its letters went.
let text: Vec<Seg2> = ss
.sketch
.links
.iter()
.filter(|l| matches!(l.source, solidcraft_sketch::LinkSource::Text { .. }))
.flat_map(|l| ss.sketch.link_curves(&l.id))
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
.collect();
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning)
}
/// Whether a cut of `f` with these targets (else its participants, else its component's
@@ -800,6 +813,7 @@ pub(super) fn sheet_primitive_cut(
prism: PrimitivePrism,
operation: Operation,
drill: bool,
warning: &mut Option<String>,
) -> Result<Vec<String>> {
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
return Ok(Vec::new());
@@ -824,7 +838,7 @@ pub(super) fn sheet_primitive_cut(
let Some(normal) = normal else { return Ok(Vec::new()) };
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
let tools = kernel::extrude(&plane, std::slice::from_ref(&region), interval.0, interval.1)?;
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false)
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false, &[], warning)
}
/// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each
@@ -911,6 +925,12 @@ impl PrimitivePrism {
}
}
/// The most outline segments of a sketch's text a cut tells letters by, and the most text regions
/// a sheet keeps (see [`crate::sheet::SheetBody::labels`]).
const MAX_TEXT_CURVES: usize = 20_000;
const MAX_LABELS: usize = 1000;
/// `text` is the outlines of the text in the cut's sketch (none for a primitive's cut).
#[allow(clippy::too_many_arguments)]
fn sheet_prism_cut(
f: &Feature,
@@ -922,6 +942,8 @@ fn sheet_prism_cut(
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
footprints_bound: bool,
text: &[Seg2],
warning: &mut Option<String>,
) -> Result<Vec<String>> {
let n = plane.normal();
let mut work = sheet::FlatWork::new();
@@ -960,7 +982,14 @@ fn sheet_prism_cut(
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
.then_some(c.interval)
});
// The regions of the cut that are letters, with their counters.
let letters: Vec<&Region2> = if operation == Operation::Cut && !text.is_empty() && text.len() <= MAX_TEXT_CURVES {
regions.iter().filter(|r| sheet::loop_lies_on(&r.outer, text)).collect()
} else {
Vec::new()
};
let mut plans = Vec::new();
let mut notes = Vec::new();
'sheets: for original in &st.sheets {
if !sheet_eligible(f, st, targets, original) {
continue;
@@ -993,6 +1022,7 @@ fn sheet_prism_cut(
sh.material_panels(Some(plane), &mut work)?
};
let mut modeled = false;
let mut labels = Vec::new();
for (pi, panel) in candidates {
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
@@ -1032,6 +1062,7 @@ fn sheet_prism_cut(
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
// Sub-resolution layers must not be silently erased by endpoint snapping.
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
labels.extend(letters.iter().filter_map(|r| sh.region_to_flat(pi, plane, r).ok()));
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
} else {
@@ -1096,19 +1127,34 @@ fn sheet_prism_cut(
}
}
}
if modeled {
for flat in labels {
if !sh.labels.contains(&flat) && sh.labels.len() < MAX_LABELS {
sh.labels.push(flat);
}
}
}
if !modeled {
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
} else if sh.flat_material()?.is_empty() {
// Empty exact material removes geometry and metadata together. Budget or
// topology errors propagate; they are never mistaken for an empty blank.
plans.push((sh.body.clone(), None));
} else {
let body = sh.solid()?;
plans.push((sh.body.clone(), Some((sh, body))));
let material = sh.flat_material()?;
if material.is_empty() {
// Empty exact material removes geometry and metadata together. Budget or
// topology errors propagate; they are never mistaken for an empty blank.
plans.push((sh.body.clone(), None));
} else {
// Every piece the cut leaves stays; the ones it parts from the base are named.
notes.extend(parted_note(&sh, &material, original));
let body = sh.solid()?;
plans.push((sh.body.clone(), Some((sh, body))));
}
}
}
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
if !notes.is_empty() {
append_warning(warning, notes.join("; "));
}
for (name, result) in plans {
match result {
Some((sh, body)) => {
@@ -1129,6 +1175,35 @@ fn sheet_prism_cut(
Ok(handled)
}
/// A cut's note on the pieces it parted from the base of `sh` (see
/// [`sheet::SheetBody::parted_from_base`]; `material` is the sheet's flat material after the cut,
/// `before` the sheet before it): how many, their area and where they lie in the flat pattern.
/// The pieces stay, as every piece a cut leaves does. None when nothing is parted, and when the
/// pieces can't be worked out: the note is no reason for a cut to fail.
fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::SheetBody) -> Option<String> {
let pieces = sh.parted_from_base(material, before).ok().filter(|p| !p.is_empty())?;
let area: f64 = pieces.iter().map(Region2::area).sum();
let at: Vec<String> = pieces
.iter()
.take(4)
.map(|r| {
let c = r.interior_point();
format!("({:.1}, {:.1})", c.x, c.y)
})
.collect();
let at = at.join(", ");
let body = &sh.body;
Some(match pieces.len() {
1 => format!("1 piece of `{body}` is no longer joined to its base ({area:.2} mm², near {at} in the flat pattern); it is kept"),
n => {
let more = if n > 4 { format!(" and {} more", n - 4) } else { String::new() };
format!(
"{n} pieces of `{body}` are no longer joined to its base ({area:.2} mm² in all, near {at}{more} in the flat pattern); they are kept"
)
}
})
}
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
+4 -3
View File
@@ -176,7 +176,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => {
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -195,8 +196,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
let body = match path_points(ps, path, start)? {
Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?,
None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?,
Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?,
None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?,
};
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
push_sheet(st, f, traced);
+11 -1
View File
@@ -75,6 +75,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
("thread", json!({"type": "thread", "face": o, "length": "thread.length"})),
("loft", json!({"type": "loft", "sections": []})),
("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})),
(
"sweep_options",
json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0, 1, 0]}, "twist": "sweep.twist", "scale": "sweep.scale"}),
),
("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})),
("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})),
("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})),
@@ -147,6 +152,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}),
),
("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})),
(
"surface_sweep_options",
json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
),
("surface_loft", json!({"type": "surface_loft", "sections": []})),
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
("unstitch", json!({"type": "unstitch", "body": "B"})),
@@ -181,7 +191,7 @@ 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"]}),
"extrude" | "extrude_thin" | "revolve" | "sweep" | "sweep_options" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip"
| "snap_fit" | "rest" | "surface_offset" => {
@@ -136,6 +136,11 @@ thread
expressions: thread.length
loft
sweep
sweep_options
inputs:
Twist = sweep.twist (Angle)
Scale = sweep.scale (Unitless)
expressions: sweep.twist, sweep.scale
shell
inputs:
Thickness = shell.thickness (Length)
@@ -317,6 +322,11 @@ surface_revolve
Angle 2 = srev.angle2 (Angle)
expressions: srev.angle, srev.angle2
surface_sweep
surface_sweep_options
inputs:
Twist = surface_sweep.twist (Angle)
Scale = surface_sweep.scale (Unitless)
expressions: surface_sweep.twist, surface_sweep.scale
surface_loft
surface_offset
inputs:
@@ -63,6 +63,9 @@ loft
profiles: 4 all, 5 all
sweep
profiles: 1 all
sweep_options
moves: orientation.direction[1], orientation.direction[2]
profiles: 1 all
shell
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
@@ -142,6 +145,7 @@ surface_extrude
surface_revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
surface_sweep
surface_sweep_options
surface_loft
surface_offset
moves: faces[0][0], faces[0][1], faces[0][2]
@@ -67,6 +67,10 @@ sweep
sketches: [1, 2]
remap_refs: path_sketch, sketch
remap_bodies: targets[0]
sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
remap_bodies: targets[0]
shell
remap_bodies: body
draft
@@ -159,6 +163,9 @@ surface_revolve
surface_sweep
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_loft
sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch
+33
View File
@@ -191,6 +191,15 @@
//! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat
//! faces tangent to it), which then is one bend at the two faces' virtual sharp with the
//! rule's radius; 0.11.0 found no edge there or refused the curved face.
//! Also under 21, a Sweep and a Surface Sweep can carry `orientation` (`parallel`, or
//! `{direction: [x, y, z]}`; absent: perpendicular), `twist` (an angle expression) and `scale`
//! (the end scale, an expression). A sweep without them saves as before; 0.11.x refuses
//! format 21, so no released build sweeps a twisted or scaled profile straight.
//! Nothing new is saved for converted sheets' corners and bend ends, but a design converts as
//! the rule says when it is evaluated: corners between the walls take the rule's corner relief
//! and a bend ending beside more of the fixed face its bend relief (0.11.0 left every corner a
//! notch and cut no bend relief), and a wall on part of an edge bends only where it stands
//! (0.11.0 refused it).
use serde_json::Value;
@@ -1763,4 +1772,28 @@ mod tests {
let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into());
assert!(read(&json).unwrap_err().to_string().contains("damaged design"));
}
/// A sweep saved before its options existed loads as a plain sweep and saves as it was; the
/// options round-trip.
#[test]
fn sweep_options_are_optional_and_round_trip() {
use crate::{FeatureKind, SweepOrientation};
let old =
serde_json::json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], "operation": "new_body"});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &k else { panic!("{k:?}") };
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let old = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let new = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"});
let k: FeatureKind = serde_json::from_value(new.clone()).unwrap();
let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") };
assert_eq!(*orientation, SweepOrientation::Direction(solidcraft_geom::Vec3::Y));
assert_eq!(serde_json::to_value(&k).unwrap(), new);
let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap();
assert_eq!(parallel, SweepOrientation::Parallel);
}
}
+19
View File
@@ -352,6 +352,25 @@ impl Document {
rows
}
/// Move user parameter `name` to just before (or with `after`, just after) user parameter
/// `target`: the order the Parameters dialog lists them in. Nothing else changes.
pub fn move_user_param(&mut self, name: &str, target: &str, after: bool) -> Result<()> {
fn user(params: &[crate::Param], n: &str) -> Option<usize> {
params.iter().position(|p| p.name == n && !p.model)
}
let (Some(from), Some(_)) = (user(&self.params, name), user(&self.params, target)) else {
let missing = if user(&self.params, name).is_none() { name } else { target };
return Err(DocError::Unknown(format!("user parameter `{missing}`")));
};
if name == target {
return Err(DocError::Invalid("a parameter cannot move next to itself".into()));
}
let moved = self.params.remove(from);
let at = user(&self.params, target).map_or(self.params.len(), |i| if after { i + 1 } else { i });
self.params.insert(at, moved);
Ok(())
}
pub fn set_favorite(&mut self, name: &str, on: bool) -> Result<()> {
if !self.name_taken(name) {
return Err(DocError::Unknown(format!("parameter `{name}`")));
+233 -11
View File
@@ -31,6 +31,8 @@ const MAX_FLAT_POINTS: usize = 16_384;
const MAX_FLAT_SAMPLES: usize = 65_536;
const MAX_FLAT_PIECES: usize = 4096;
const MAX_FLAT_WORK: usize = 500_000_000;
/// Pieces of flat material sharing less area than this (mm²) only touch.
const TOUCH_AREA: f64 = 1e-6;
/// Starts the warning a fold gives when its `fixed` point touches no material: a recompute keeps
/// the side the point lies on, and the `sheet.fold` command refuses such a point (#263).
@@ -490,6 +492,12 @@ pub struct SheetBody {
/// Perforation fields (#732), flat coordinates; their holes are computed on demand and never cut into the B-rep.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub perforations: Vec<SheetPerforation>,
/// Regions (flat coordinates) that text cut or machined, letters with their counters as
/// holes: a record of where the sheet's text went, so that its DXF can write the letters on
/// layers of their own (see [`SheetBody::letters`]). The cut-outs and pockets themselves are
/// in `holes` and `pockets`. Derived from feature history, like `pockets`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<Region2>,
/// Unfolded.
pub flat: bool,
/// Gap between flanges that meet at a corner.
@@ -1600,6 +1608,26 @@ impl SheetBody {
Ok(through.unwrap_or_default())
}
/// The text the sheet carries: what [`SheetBody::labels`] records, ready to tell whether a
/// loop of the flat pattern is a letter.
pub fn letters(&self) -> Letters {
let regions = self
.labels
.iter()
.map(|r| {
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in r.outer.polyline(1e-3) {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
// A polyline of 0.001 mm chords is within 0.001 mm of the curve.
let pad = Vec2::new(0.01, 0.01);
(lo - pad, hi + pad, std::iter::once(&r.outer).chain(&r.holes).flat_map(|lp| lp.segs.iter().copied()).collect())
})
.collect();
Letters { regions }
}
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
let material = self.flat_regions()?;
@@ -1670,6 +1698,84 @@ impl SheetBody {
let regions = if cuts.is_empty() { regions } else { material_boolean(&regions, &cuts, kernel::BoolOp::Cut, &mut work)? };
Ok(regions)
}
/// The pieces of flat material that a cut parted from the base: `after` is this sheet's
/// [`Self::flat_material`] after the cut, `before` the sheet before it. A piece is parted when
/// it holds none of the base panel, lies in a piece that held some before the cut, and no
/// other piece surrounds it: a flange cut off along its bend (with any flange on it), the
/// part of a ring's inside that lies on a flange's edge, the tip of a flange beyond a cut
/// through a closed corner. A ring's island and a letter's counter, inside other material,
/// are not counted; nor are pieces an earlier cut parted, nor anything once no piece holds
/// the base.
pub(crate) fn parted_from_base(&self, after: &[Region2], before: &SheetBody) -> Result<Vec<Region2>> {
if after.len() < 2 {
return Ok(Vec::new());
}
let mut work = FlatWork::new();
work.charge(after.len().saturating_mul(after.len()))?;
let base = [planar_region(&self.base)?];
let holds_base = |piece: &Region2, work: &mut FlatWork| -> Result<bool> {
let common = material_boolean(std::slice::from_ref(piece), &base, kernel::BoolOp::Intersect, work)?;
Ok(common.iter().map(Region2::area).sum::<f64>() > TOUCH_AREA)
};
// The cheap test first: a piece inside another's outline is an island, whatever it holds.
let outlines: Vec<(Vec<Vec2>, Vec2, Vec2)> = after
.iter()
.map(|r| {
let pts = r.outer.polyline(1e-3);
let lo = pts.iter().fold(Vec2::new(f64::MAX, f64::MAX), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
let hi = pts.iter().fold(Vec2::new(f64::MIN, f64::MIN), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
(pts, lo, hi)
})
.collect();
let (mut open, mut islands) = (Vec::new(), Vec::new());
for (i, piece) in after.iter().enumerate() {
let p = piece.interior_point();
let inside = |(pts, lo, hi): &(Vec<Vec2>, Vec2, Vec2)| (lo.x..=hi.x).contains(&p.x) && (lo.y..=hi.y).contains(&p.y) && point_in(pts, p);
if outlines.iter().enumerate().any(|(j, o)| j != i && inside(o)) {
islands.push(piece);
} else {
open.push(piece);
}
}
let (mut joined, mut loose) = (false, Vec::new());
for piece in open {
if holds_base(piece, &mut work)? {
joined = true;
} else {
loose.push(piece);
}
}
if loose.is_empty() {
return Ok(Vec::new());
}
// The base may be an island itself (a cut all round it).
for piece in islands {
if joined {
break;
}
joined = holds_base(piece, &mut work)?;
}
if !joined {
return Ok(Vec::new());
}
// A cut only takes material away: each piece lies in the piece it was part of.
let mut held = Vec::new();
for piece in before.flat_material()? {
if holds_base(&piece, &mut work)? {
held.push(piece);
}
}
Ok(loose
.into_iter()
.filter(|piece| {
let p = piece.interior_point();
held.iter().any(|h| h.contains(p))
})
.cloned()
.collect())
}
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
@@ -2201,7 +2307,7 @@ impl SheetBody {
/// such end left as it is.
fn relieve_bend_ends(&mut self, i: usize, free: (bool, bool)) -> Vec<String> {
let Some(f) = self.flanges.get(i).cloned() else { return Vec::new() };
if f.parent.is_some() || f.shape.is_some() {
if f.parent.is_some() {
return Vec::new();
}
let (width, depth) = (self.bend_relief.0, self.bend_relief.1.max(0.0));
@@ -2610,32 +2716,42 @@ impl SheetBody {
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
let corners = self.corners(made);
let mut notes = Vec::new();
let at = |s: &SheetBody, c: Vec2| {
let w = apply_point(&s.frame_mat(), v3(c));
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
};
let mut closed = Vec::with_capacity(corners.len());
for k in &corners {
match self.close_corner(k) {
Ok(()) => closed.push(*k),
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c))),
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", self.shown(k.c))),
}
}
notes.extend(self.relieve_corners(&corners, &closed));
notes
}
/// A point of the flat frame, in the part's coordinates, for a note.
fn shown(&self, c: Vec2) -> String {
let w = apply_point(&self.frame_mat(), v3(c));
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
}
/// Cut the rule's relief at each of `corners`, or tear those of them that are `closed`.
/// Returns a note for every corner without its relief or not torn.
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner]) -> Vec<String> {
let mut notes = Vec::new();
match self.relief {
ReliefShape::Notch => {}
// The reliefs come last: they move the bends' ends, which is how the corners were found.
ReliefShape::Round | ReliefShape::Rectangular => {
for k in &corners {
if let Err(why) = self.relieve_corner(k, &corners) {
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
for k in corners {
if let Err(why) = self.relieve_corner(k, corners) {
notes.push(format!("no corner relief at {}: {why}", self.shown(k.c)));
}
}
}
// A tear cuts to the flanges' closed ends, so a corner left open gets none.
ReliefShape::Tear => {
for k in &closed {
for k in closed {
if let Err(why) = self.tear_corner(k) {
notes.push(format!("the corner at {} keeps its notch: {why}", at(self, k.c)));
notes.push(format!("the corner at {} keeps its notch: {why}", self.shown(k.c)));
}
}
}
@@ -3953,6 +4069,30 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
})
}
/// Whether every segment of `lp` lies on one of `curves` (its ends and its middle, within
/// 0.0001 mm): the loop is made of those curves, or of parts of them.
pub fn loop_lies_on(lp: &Loop2, curves: &[Seg2]) -> bool {
const TOL: f64 = 1e-4;
let near = |p: Vec2| curves.iter().any(|c| c.point_at(c.closest_param(p)).dist(p) <= TOL);
!lp.segs.is_empty() && lp.segs.iter().all(|g| near(g.start()) && near(g.mid()) && near(g.end()))
}
/// Tells which loops of a sheet's flat pattern are letters (see [`SheetBody::letters`]).
#[derive(Clone, Debug)]
pub struct Letters {
/// Each text region's corners (lowest, highest) and the segments of its outlines.
regions: Vec<(Vec2, Vec2, Vec<Seg2>)>,
}
impl Letters {
/// Whether `lp` is made of the outlines of text the sheet carries: a letter, or one of its
/// counters.
pub fn is_letter(&self, lp: &Loop2) -> bool {
let Some(p) = lp.segs.first().map(Seg2::start) else { return false };
self.regions.iter().any(|(lo, hi, curves)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y && loop_lies_on(lp, curves))
}
}
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
let (a, b) = (planar_region(a)?, planar_region(b)?);
@@ -3974,6 +4114,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -4031,6 +4172,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -4175,6 +4317,41 @@ mod tests {
kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN)
}
#[test]
fn loops_lie_on_the_curves_they_are_made_of() {
let circle = Loop2::circle(Vec2::new(5.0, 5.0), 3.0);
let curves: Vec<Seg2> = circle.segs.clone();
// The loop itself, started elsewhere and run the other way, and a loop of its halves.
assert!(loop_lies_on(&circle, &curves));
assert!(loop_lies_on(&Loop2::circle_from(Vec2::new(5.0, 5.0), 3.0, 1.0).reversed(), &curves));
let halves: Vec<Seg2> = circle.segs.iter().flat_map(|g| [g.split_half().0, g.split_half().1]).collect();
assert!(loop_lies_on(&Loop2 { segs: halves }, &curves));
// Another circle, a square and an empty loop do not.
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 3.1), &curves));
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 2.0), &curves));
assert!(!loop_lies_on(&rect(Vec2::ZERO, Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y).outer, &curves));
assert!(!loop_lies_on(&Loop2 { segs: Vec::new() }, &curves));
// A loop only partly on them (a letter across the sheet's edge) is not on them.
let notch = Loop2 { segs: vec![circle.segs[0], Seg2::Line { a: circle.segs[0].end(), b: Vec2::new(50.0, 50.0) }] };
assert!(!loop_lies_on(&notch, &curves));
}
#[test]
fn a_sheet_knows_the_letters_it_carries() {
// A recorded text region with a counter: its outline and its counter are letters, the same
// circle elsewhere, a loop near it that is another size and a loop far away are not.
let mut s = plate(100.0, 60.0);
let counter = Loop2::circle(Vec2::new(20.0, 20.0), 2.0).reversed();
s.labels.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 5.0), holes: vec![counter.clone()] });
let letters = s.letters();
assert!(letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
assert!(letters.is_letter(&counter.reversed()));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 4.0)));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(70.0, 20.0), 5.0)));
// A sheet that carries none has no letters.
assert!(!plate(100.0, 60.0).letters().is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
}
#[test]
fn edge_flange_matches_fusion() {
// Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge.
@@ -7292,4 +7469,49 @@ mod tests {
holds_the_flat_material(&s, &what);
}
}
/// The 100 x 60 plate with a 20 mm flange on its y = 0 edge (2.5 mm, R 2.5, K 0.44): in the
/// flat the base starts at y = t + R = 5, the bend's zone is (π/2)·(R + K·t) long and the
/// flange's panel 20 − 5, so its far edge lies at y = 5 − (π/2)·3.6 − 15.
#[test]
fn a_cut_names_the_pieces_it_parts_from_the_base_and_no_others() {
use std::f64::consts::{FRAC_PI_2, PI};
let far = 5.0 - FRAC_PI_2 * (2.5 + 0.44 * 2.5) - 15.0;
let mut s = plate(100.0, 60.0);
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
let r = |x0: f64, y0: f64, x1: f64, y1: f64| rect(Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1));
let ring = |c: Vec2, ro: f64, ri: f64| Region2 { outer: Loop2::circle(c, ro), holes: vec![Loop2::circle(c, ri)] };
let parted = |before: &SheetBody, holes: &[Region2]| {
let mut after = before.clone();
after.holes.extend_from_slice(holes);
after.parted_from_base(&after.flat_material().unwrap(), before).unwrap()
};
// A slot across the flange beside its bend parts the rest of the flange: 100 × (−3 − far).
let slot = r(-5.0, -3.0, 105.0, -1.0);
let p = parted(&s, std::slice::from_ref(&slot));
assert!(p.len() == 1 && (p[0].area() - 100.0 * (-3.0 - far)).abs() < 1e-6, "{p:?}");
// So does a ring on the flange's far edge: the half of its inside on the flange, π·2²/2.
let p = parted(&s, &[ring(Vec2::new(50.0, far), 4.0, 2.0)]);
assert!(p.len() == 1 && (p[0].area() - 2.0 * PI).abs() < 1e-6, "{p:?}");
// A ring's island, on the base or on the flange, has material round it; a strip across the
// whole sheet leaves two halves that each keep their part of the base. None is named.
for holes in [ring(Vec2::new(50.0, 30.0), 10.0, 5.0), ring(Vec2::new(50.0, -9.0), 4.0, 2.0), r(45.0, -30.0, 55.0, 70.0)] {
assert!(parted(&s, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
}
// Pieces an earlier cut parted are not named again, nor when a later cut splits them.
let mut cut = s.clone();
cut.holes.push(slot);
for holes in [Region2 { outer: Loop2::circle(Vec2::new(30.0, 30.0), 4.0), holes: vec![] }, r(45.0, -30.0, 55.0, -2.0)] {
assert!(parted(&cut, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
}
// Nothing is named once no piece holds the base: a cut that takes the base and both bends
// of a sheet with flanges on two sides leaves the two flanges.
let mut two = plate(100.0, 60.0);
two.add_flanges(&[Vec3::new(50.0, 0.0, 2.5), Vec3::new(50.0, 60.0, 2.5)], 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
let mut after = two.clone();
after.holes.push(r(-5.0, -3.0, 105.0, 63.0));
let material = after.flat_material().unwrap();
assert_eq!(material.len(), 2);
assert!(after.parted_from_base(&material, &two).unwrap().is_empty());
}
}
+134 -3
View File
@@ -11,6 +11,14 @@
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
//! gap from the plane halfway between the two.
//!
//! The bends on the fixed face are finished as the flanges of one edge flange feature are: where
//! two meet at a corner of it, their walls are closed with the rule's gap and the corner gets the
//! rule's corner relief (round, rectangular, a tear, or the notch the bends leave), and where one
//! ends beside more of the fixed face, the rule's bend relief is cut there. A wall that stands on
//! part of an edge of the fixed face (its outside in one plane with the plate's edge, which runs
//! on past it a strip no taller than the bend's setback) bends only where it stands: the strip is
//! the fixed face's own edge.
//!
//! A bend can also be a chamfer: a flat strip between two faces, as a model without bend radii
//! draws a bend (the chord of its arc). Picked in its middle, the strip is no face of the sheet:
//! the faces on either side are taken out to where their planes meet, and that line is the
@@ -37,7 +45,7 @@
//! Not supported yet: curved faces other than bends (rolled sheet), holes and pockets that reach
//! into a bend, pockets deeper than half the sheet or with a step or a hole in their floor, round
//! edges that end at a bend or a cut edge, loops of bends, two faces that meet along more than
//! one edge.
//! one edge, a wall on part of an edge of a face other than the fixed one.
use std::collections::BTreeMap;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
@@ -500,6 +508,96 @@ fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
Ok((out, from))
}
/// A wall standing on part of an edge of the fixed face: where the outline of `wall`, the face
/// across a bend from the fixed face `base` (`ids`: the two faces), leaves the bend's edge up a
/// strip no taller than the bend's setback `by`, runs back along the edge and climbs the wall's
/// end, the strip is the fixed face's own edge (a plate's edge face in one plane with the outside
/// of a wall that stands on part of it), which the setback would use up. It is cut off the wall,
/// whose bend's edge then ends where the line of the wall's end meets it, and the fixed face's
/// edge is split there: the pieces beside the wall are no bend. `bend` says whether a face is a
/// bend of the wall. Any other wall is left as it is.
fn end_at_wall(base: &mut Face, wall: &mut Face, ids: (usize, usize), by: f64, bend: &dyn Fn(usize) -> bool, tol: f64) {
let (m, n) = (wall.pts.len(), base.pts.len());
let at_wall: Vec<usize> = (0..m).filter(|i| wall.across.get(*i).copied().flatten() == Some(ids.0)).collect();
let at_base: Vec<usize> = (0..n).filter(|i| base.across.get(*i).copied().flatten() == Some(ids.1)).collect();
let (&[k], &[i]) = (at_wall.as_slice(), at_base.as_slice()) else { return };
if m < 5 {
return;
}
// Corner `j` and edge `j` (from corner `j`) counted from the bend's edge on.
let pt = |j: usize| wall.pts.get((k + j) % m).copied().unwrap_or_default();
let (p, q) = (pt(0), pt(1));
let Some(d) = (q - p).normalized() else { return };
// Up the wall from the bend's edge (the outline runs counter-clockwise about the normal), and
// along the edge.
let up = |x: Vec3| (x - p).dot(wall.n.cross(d));
let along = |x: Vec3| (x - p).dot(d);
let free = |j: usize| {
let j = (k + j) % m;
wall.round.get(j).is_some_and(Option::is_none) && wall.across.get(j).copied().flatten().is_none_or(|g| !bend(g))
};
let low = |x: Vec3| up(x) > tol && up(x) <= by + tol;
// A strip from an end of the bend's edge up to `x1`, back along the edge (`back`: the way it
// runs) to `x2`, then the wall's end up to `x3`: where the line of that end meets the edge.
let foot = |x1: Vec3, x2: Vec3, x3: Vec3, back: f64| {
let rise = up(x3) - up(x2);
(low(x1) && low(x2) && (x2 - x1).dot(d) * back > tol && rise > tol).then(|| x2 - (x3 - x2) * (up(x2) / rise))
};
// At the edge's end the strip is edges 1 and 2, the wall's end edge 3; at its start the strip
// is edges m - 1 and m - 2, the wall's end edge m - 3.
let ok = |js: [usize; 3]| js.iter().all(|j| free(*j));
let end = if ok([1, 2, 3]) { foot(pt(2), pt(3), pt(4), -1.0) } else { None };
let start = if ok([m - 1, m - 2, m - 3]) { foot(pt(m - 1), pt(m - 2), pt(m - 3), 1.0) } else { None };
let (p1, q1) = (start.unwrap_or(p), end.unwrap_or(q));
let len = along(q);
let inside = |x: Option<Vec3>| x.is_none_or(|x| along(x) > tol && along(x) < len - tol);
if (start.is_none() && end.is_none())
|| !inside(start)
|| !inside(end)
|| along(q1) - along(p1) <= tol
|| (start.is_some() && end.is_some() && m < 7)
{
return;
}
// The wall: the strips' corners and edges go, the bend's edge runs from `p1` to `q1`.
let (mut corners_out, mut edges_out) = (Vec::new(), Vec::new());
if end.is_some() {
corners_out.extend([2, 3]);
edges_out.extend([1, 2]);
}
if start.is_some() {
corners_out.extend([m - 1, m - 2]);
edges_out.extend([m - 1, m - 2]);
}
if let Some(x) = wall.pts.get_mut(k) {
*x = p1;
}
if let Some(x) = wall.pts.get_mut((k + 1) % m) {
*x = q1;
}
let kept = |j: usize, out: &[usize]| !out.contains(&((j + m - k) % m));
wall.pts = wall.pts.iter().enumerate().filter(|(j, _)| kept(*j, &corners_out)).map(|(_, x)| *x).collect();
wall.across = wall.across.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
wall.round = wall.round.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
// The fixed face's edge: the bend from `p1` to `q1` (it runs the other way along it), no bend
// beside it.
let (Some(&a), Some(&b)) = (base.pts.get(i), base.pts.get((i + 1) % n)) else { return };
let from_a = |x: Vec3| (x - a).dot(b - a);
let (x, y) = if from_a(p1) < from_a(q1) { (p1, q1) } else { (q1, p1) };
let mut pieces: Vec<(Vec3, Option<usize>)> = Vec::with_capacity(3);
if x.dist(a) > tol {
pieces.push((a, None));
}
pieces.push((if x.dist(a) > tol { x } else { a }, Some(ids.1)));
if y.dist(b) > tol {
pieces.push((y, None));
}
let count = pieces.len();
base.pts.splice(i..=i, pieces.iter().map(|(x, _)| *x));
base.across.splice(i..=i, pieces.iter().map(|(_, g)| *g));
base.round.splice(i..=i, std::iter::repeat_n(None, count));
}
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
/// bend first trimmed to the end of the bend zone.
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
@@ -574,7 +672,8 @@ pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
/// fixed face and `bends` are points on the sharp edges that become bends, in the middle of
/// chamfers or on bend cylinders that do (the body's coordinates, mm). See the module docs.
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
/// Returns the sheet and a note for every corner or end of a bend not finished as the rule says.
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<(SheetBody, Vec<String>)> {
if bends.is_empty() || bends.len() > MAX_BENDS {
return err(format!("pick 1…{MAX_BENDS} bend edges"));
}
@@ -696,6 +795,20 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
.and_then(|(a, b)| Some((*a, (*b - *a).normalized()?)))
.and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n)))
.ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?;
// A wall on part of an edge of the fixed face bends only where it stands (a bend picked as a
// chamfer or a cylinder is taken as it is).
let walls: Vec<(usize, f64)> = panels.iter().filter(|(_, p)| p.parent == Some(f0)).map(|(g, p)| (*g, p.setback)).collect();
for (g, by) in walls {
if chamfers.iter().any(|c| (c.a, c.b) == (f0, g) || (c.a, c.b) == (g, f0)) {
continue;
}
let kids: Vec<usize> = panels.iter().filter(|(_, p)| p.parent == Some(g)).map(|(h, _)| *h).collect();
let Some(mut wall) = panels.remove(&g) else { continue };
if let Some(base) = panels.get_mut(&f0) {
end_at_wall(&mut base.face, &mut wall.face, (f0, g), by, &|h| h == f0 || kids.contains(&h), tol);
}
panels.insert(g, wall);
}
let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default());
// How far behind a face of the sheet the body's face `g` starts and ends: a hole's wall
// reaches through the sheet, a pocket's ends on its floor, an island's starts at the face.
@@ -872,7 +985,25 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
queue.push((g, Some(sh.flanges.len() - 1)));
}
}
Ok(sh)
// The bends on the fixed face finished as an edge flange feature finishes its own
// (`SheetBody::add_flanges_each`): the rule's bend relief where one ends beside more of the
// fixed face; where two meet at a corner of it, their walls closed with the rule's gap (which
// the cuts above already are) and the rule's corner relief.
let made: Vec<usize> = (0..sh.flanges.len()).collect();
let mut notes = Vec::new();
for &i in &made {
notes.extend(sh.relieve_bend_ends(i, (false, false)));
}
let corners = sh.corners(&made);
let mut closed = Vec::with_capacity(corners.len());
for k in &corners {
match sh.close_corner(k) {
Ok(()) => closed.push(*k),
Err(why) => notes.push(format!("the corner at {} stays cut open: {why}", sh.shown(k.c))),
}
}
notes.extend(sh.relieve_corners(&corners, &closed));
Ok((sh, notes))
}
#[cfg(test)]
+9 -4
View File
@@ -128,8 +128,9 @@ fn xy(v: Vec2) -> Value {
}
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2)> {
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
/// The sketch is left active.
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2, String)> {
let none = || match face_body {
Some(b) => bad(cmd, format!("no face of `{b}` at `face`")),
None => bad(cmd, "no body face at `face`"),
@@ -199,7 +200,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
// The face's edges came in as linked projections: they follow the face when earlier
// features change, so nothing is copied.
if r.get("projected").is_some_and(|x| !x.is_null()) {
return Ok((sid, plane.to_local(author_inside)));
return Ok((sid, plane.to_local(author_inside), body));
}
for e in &edges {
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
@@ -237,9 +238,13 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
}
};
}
Ok((sid, plane.to_local(author_inside)))
Ok((sid, plane.to_local(author_inside), body))
}
#[cfg(test)]
#[path = "face_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "face_join_tests.rs"]
mod join_tests;
+127
View File
@@ -0,0 +1,127 @@
//! #226: a face's outward extrusion must not join an assembly neighbour it only touches.
#![allow(clippy::unwrap_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::{Document, FeatureKind};
use solidcraft_geom::Vec3;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|error| panic!("{command}: {error}"))
}
fn assembly(cap_first: bool) -> Session {
let mut s = Session::default();
let wall = json!({"length":40,"width":30,"height":20,"body_name":"Wall"});
let cap = json!({"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Cap"});
for params in if cap_first { [cap, wall] } else { [wall, cap] } {
run(&mut s, "solid.box", params);
}
s
}
fn check_wall(s: &Session, count: usize) {
assert!(s.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", s.model.results);
let state = s.model.state();
assert_eq!(state.bodies.len(), count);
let wall = state.body("Wall").unwrap();
let measure = solidcraft_kernel::measure(&wall.body).unwrap();
assert!((measure.volume - 30_000.0).abs() < 1e-6, "{measure:?}");
assert!(measure.bbox.max.dist(Vec3::new(50.0, 30.0, 20.0)) < 1e-9);
assert!(wall.body.contains(Vec3::new(49.0, 15.0, 10.0)));
let cap = solidcraft_kernel::measure(&state.body("Cap").unwrap().body).unwrap();
assert!((cap.volume - 6000.0).abs() < 1e-6);
assert!(cap.bbox.max.dist(Vec3::new(40.0, 30.0, 25.0)) < 1e-9);
}
#[test]
fn face_join_and_press_pull_leave_the_edge_touching_cap_in_either_body_order() {
for cap_first in [true, false] {
for operation in ["join", "auto", "press_pull"] {
let mut s = assembly(cap_first);
let before = s.model.state();
let params = json!({"face":[40,15,10],"distance":10,"operation":operation});
let command = if operation == "press_pull" { "solid.press_pull" } else { "solid.extrude" };
let result = run(&mut s, command, params);
assert_eq!(result["changed_bodies"], json!(["Wall"]));
assert_eq!(result["removed_bodies"], json!([]));
assert!(s.model.state().body("Cap").unwrap().body.same(&before.body("Cap").unwrap().body));
check_wall(&s, 2);
let saved = s.doc.to_json();
run(&mut s, "edit.undo", json!({}));
assert!(s.model.state().body("Wall").unwrap().body.same(&before.body("Wall").unwrap().body));
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), saved);
check_wall(&Session::new(Document::from_json(&saved).unwrap()), 2);
}
}
}
#[test]
fn face_join_respects_explicit_targets_and_participants() {
for key in ["targets", "participants"] {
for all in [false, true] {
let mut s = assembly(!all);
let mut params = json!({"face":[40,15,10],"distance":10,"operation":"join"});
// Joining the wall first permits the cap's full base face to join afterwards.
params[key] = if all { json!(["*"]) } else { json!(["Wall", "Cap"]) };
run(&mut s, "solid.extrude", params);
let state = s.model.state();
assert_eq!(state.bodies.len(), 1, "{key}");
assert!((solidcraft_kernel::measure(&state.body("Wall").unwrap().body).unwrap().volume - 36_000.0).abs() < 1e-6);
}
}
}
#[test]
fn face_join_maps_the_picked_instance_body_and_point_together() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Repeated"}))["component"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Wall"}));
run(&mut s, "component.activate", json!({"component":0}));
let occurrence = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[100,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":component,"occurrence":occurrence}));
let world = s.world_state();
let body = world.bodies.iter().find(|body| body.body.contains(Vec3::new(139.0, 15.0, 10.0))).unwrap().name.clone();
assert_ne!(body, "Wall", "exercise an instance body name");
let result = run(&mut s, "solid.extrude", json!({"face":[140,15,10],"face_body":body,"distance":10,"operation":"join"}));
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
assert_eq!(targets, &["Wall"]);
let world = s.world_state();
assert_eq!(world.bodies.len(), 2);
for point in [Vec3::new(49.0, 15.0, 10.0), Vec3::new(149.0, 15.0, 10.0)] {
assert!(world.bodies.iter().any(|body| body.body.contains(point)), "{point:?}");
}
}
#[test]
fn face_join_of_a_step_import_keeps_all_32_neighbours_unchanged() {
let mut source = assembly(true);
for i in 0..31 {
run(&mut source, "solid.box", json!({"corner":[100+20*i,100,0],"length":5,"width":5,"height":5,"body_name":format!("Distant{i}")}));
}
let step = solidcraft_io::export(&source.model.state(), &[], solidcraft_io::Format::Step, "Original assembly").unwrap();
let imported = solidcraft_io::step_import_feature(&step, "original-assembly.step").unwrap();
let mut doc = Document::new("Imported assembly");
let feature = doc.add_feature(imported.kind, None).unwrap();
doc.feature_mut(feature).unwrap().body_names = imported.body_names;
let mut s = Session::new(doc);
assert_eq!(s.doc.features.len(), 1);
let before = s.model.state();
let result = run(&mut s, "solid.extrude", json!({"face":[40,15,10],"distance":"10 mm","direction":"positive","operation":"join"}));
assert_eq!(result["changed_bodies"], json!(["Wall"]));
check_wall(&s, 33);
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
check_wall(&reopened, 33);
for body in before.bodies.iter().filter(|body| body.name != "Wall") {
let after = s.model.state();
assert!(body.body.same(&after.body(&body.name).unwrap().body), "{}", body.name);
let cold = reopened.model.state();
let original = solidcraft_kernel::measure(&body.body).unwrap();
let restored = solidcraft_kernel::measure(&cold.body(&body.name).unwrap().body).unwrap();
assert!((original.volume - restored.volume).abs() < 1e-9, "{}", body.name);
assert!(original.bbox.min.dist(restored.bbox.min) < 1e-9 && original.bbox.max.dist(restored.bbox.max) < 1e-9);
}
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
assert_eq!(targets, &["Wall"]);
}
+74 -13
View File
@@ -1,7 +1,9 @@
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
use serde_json::{Value, json};
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, ThinSide, ThinWall, expr::Kind, point_expr};
use solidcraft_doc::{
AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
};
use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch};
@@ -13,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; 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"),
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -21,7 +23,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?, targets?, name?, body_name?"),
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
@@ -512,12 +514,12 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.extrude";
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }))
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
}
None => (feature_sketch(s, p, cmd)?, None),
None => (feature_sketch(s, p, cmd)?, None, None),
};
let extent = extent_param(s, p, cmd)?;
let thin = match bool_(p, "thin") {
@@ -530,6 +532,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
_ if p.get("thin").is_some_and(|v| !v.is_boolean()) => return Err(bad(cmd, "`thin` must be true or false")),
_ => None,
};
let operation = match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
};
let mut targets = string_list(p, "targets");
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
// A face's outward extension joins that body. Assembly neighbours can touch only an
// edge of the tool: trying to join them first fails, or absorbs unrelated parts (#226).
// Store the resolved target so recomputes and reopening keep the same scope.
targets.extend(face_body);
}
let kind = FeatureKind::Extrude {
sketch,
profiles: match face_profile {
@@ -538,11 +551,8 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
None => profiles(p, cmd)?,
},
extent,
operation: match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
},
targets: string_list(p, "targets"),
operation,
targets,
thin,
};
add_feature(s, p, kind)
@@ -657,7 +667,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
// A planar body face revolves like a profile (its edges projected into a sketch on it).
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
let (id, inside, _) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
s.active_sketch = None;
(id, Some(ProfileSel::Points { points: vec![inside] }))
}
@@ -1621,6 +1631,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
if path.is_empty() {
return Err(bad(cmd, "`path` must list curve ids"));
}
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
add_feature(
s,
p,
@@ -1629,12 +1640,58 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
profiles: profiles(p, cmd)?,
path_sketch,
path,
orientation,
twist,
scale,
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
},
)
}
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<String>, Option<String>)> {
let orientation = match p.get("orientation") {
None | Some(Value::Null) => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
Some(v) => {
let d = vec3(v)
.or_else(|| v.get("direction").and_then(vec3))
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
if !(d.len() > 1e-9) {
return Err(bad(cmd, "the `orientation` direction must not be zero"));
}
SweepOrientation::Direction(d)
}
};
let twist = match p.get("twist") {
None | Some(Value::Null) => None,
Some(_) => {
let t = expr(p, "twist").ok_or_else(|| bad(cmd, "`twist` must be an angle or an expression"))?;
let v = s.doc.eval(&t, Kind::Angle).map_err(|e| bad(cmd, format!("twist: {e}")))?;
if !(v.is_finite() && v.abs() <= solidcraft_kernel::SweepControl::MAX_TWIST) {
return Err(bad(cmd, "`twist` must be within four turns (1440 deg)"));
}
Some(t)
}
};
let scale = match p.get("scale") {
None | Some(Value::Null) => None,
Some(_) => {
let k = expr(p, "scale").ok_or_else(|| bad(cmd, "`scale` must be a number or an expression"))?;
let v = s.doc.eval(&k, Kind::Unitless).map_err(|e| bad(cmd, format!("scale: {e}")))?;
let range = solidcraft_kernel::SweepControl::MIN_SCALE..=solidcraft_kernel::SweepControl::MAX_SCALE;
if !(v.is_finite() && range.contains(&v)) {
return Err(bad(cmd, "`scale` (the end scale) must be between 0.01 and 100"));
}
Some(k)
}
};
Ok((orientation, twist, scale))
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.loft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
@@ -1753,6 +1810,10 @@ mod patterns_tests;
#[path = "revolve_convention_tests.rs"]
mod revolve_convention_tests;
#[cfg(test)]
#[path = "sweep_options_tests.rs"]
mod sweep_options_tests;
#[cfg(test)]
#[path = "offset_topology_tests.rs"]
mod offset_topology_tests;
+4
View File
@@ -245,6 +245,10 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
if let Some(o) = v.as_object_mut() {
o.insert("visible".into(), json!(s.body_visible(&b.name)));
if s.body_removed_later(&b.name) {
// Editing a sketch rolls the timeline back; this body is deleted further on.
o.insert("removed_later".into(), json!(true));
}
}
v
})
+7 -1
View File
@@ -318,8 +318,14 @@ fn add_joint(s: &mut Session, j: Joint, p: &Value) -> Result<Value> {
if s.doc.assembly.joints.len() >= 10_000 {
return Err(EngineError::Other("too many joints".into()));
}
// Several parts as bodies of one component (a multi-body STEP import, #1239): say how to
// get two components.
if j.a.occurrence == j.b.occurrence {
return Err(bad("joint.create", "a joint needs two different occurrences"));
return Err(bad(
"joint.create",
"a joint needs two different occurrences: both origins are on one component; give each part a component of its own first \
(component.from_bodies)",
));
}
let id = j.id;
let name = j.name.clone();
+16
View File
@@ -247,6 +247,22 @@ fn faces_mate_by_default() {
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
}
/// #1239: two parts as bodies of one component (a multi-body STEP import). The refusal names the
/// way out, and after Create Components from Bodies the same origins make the joint.
#[test]
fn bodies_of_one_component_get_components_first() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let p = json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}});
let e = s.execute("joint.create", &p).map(|_| ()).unwrap_err().to_string();
assert!(e.contains("two different occurrences") && e.contains("component.from_bodies"), "{e}");
run(&mut s, "component.from_bodies", json!({"bodies": ["Body1", "Body2"]}));
run(&mut s, "joint.create", p);
// The first joint's A side stays; B lands on top of it.
assert!(near(place(&s, "Body2", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
}
/// #527: an assembly with its fasteners passes 200 bodies quickly; the clash check must still
/// cover the whole design. 201 separate 1 mm cubes 2 mm apart, plus one cube pushed half-way
/// into the first: exactly that pair overlaps, by 0.5 mm³.
+15
View File
@@ -24,6 +24,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("name, new_name (expressions, feature inputs and dimensions that use it follow)"),
CommandSpec::new("parameters.delete", "Delete Parameter", delete).params("name (refused while something uses it; the error lists the users)"),
CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).params("name, favorite?: bool (default true)"),
CommandSpec::new("parameters.move", "Move Parameter", move_param)
.params("name, before | after: another user parameter; the user parameter is listed just before or after it"),
CommandSpec::new("parameters.comment", "Comment Parameter", comment).params("name, comment"),
CommandSpec::new("parameters.users", "Parameter Users", users)
.noundo()
@@ -179,6 +181,19 @@ fn favorite(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"name": name, "favorite": on}))
}
fn move_param(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.move";
let name = param_name(cmd, p)?.to_string();
let (target, after) = match (str_(p, "before"), str_(p, "after")) {
(Some(t), None) => (t.to_string(), false),
(None, Some(t)) => (t.to_string(), true),
_ => return Err(bad(cmd, "give one of `before` or `after`: another user parameter")),
};
s.doc_mut().move_user_param(&name, &target, after)?;
let order: Vec<&str> = s.doc.params.iter().filter(|q| !q.model).map(|q| q.name.as_str()).collect();
Ok(json!({"name": name, "order": order}))
}
fn comment(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.comment";
let name = param_name(cmd, p)?.to_string();
+49
View File
@@ -544,3 +544,52 @@ fn switching_configurations_back_reuses_the_later_features() {
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
assert!(hits(&s) > before, "{r}");
}
/// The user parameters' names in the order the Parameters dialog lists them.
fn user_order(s: &mut Session) -> Vec<String> {
let v = run(s, "parameters.list", json!({"filter": "user"}));
v["parameters"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn user_parameters_reorder_with_undo() {
// #1187: move a user parameter before or after another; nothing else changes.
let mut s = Session::default();
for (n, e) in [("a", "1 mm"), ("b", "2 mm"), ("c", "a + b")] {
run(&mut s, "parameters.add", json!({"name": n, "expression": e}));
}
assert_eq!(user_order(&mut s), ["a", "b", "c"]);
let r = run(&mut s, "parameters.move", json!({"name": "c", "before": "a"}));
assert_eq!(r["order"], json!(["c", "a", "b"]));
assert_eq!(user_order(&mut s), ["c", "a", "b"]);
run(&mut s, "parameters.move", json!({"name": "c", "after": "a"}));
assert_eq!(user_order(&mut s), ["a", "c", "b"]);
// Values and references are untouched; the order is kept in the design file.
let list = run(&mut s, "parameters.list", json!({"filter": "user"}));
let c = list["parameters"].as_array().unwrap().iter().find(|r| r["name"] == "c").unwrap().clone();
assert_eq!(c["expression"], "a + b");
assert!((c["value"].as_f64().unwrap() - 3.0).abs() < 1e-9, "{c}");
let back = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let names: Vec<&str> = back.doc.params.iter().filter(|p| !p.model).map(|p| p.name.as_str()).collect();
assert_eq!(names, ["a", "c", "b"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(user_order(&mut s), ["c", "a", "b"]);
}
#[test]
fn moving_a_parameter_refuses_bad_targets() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "a", "expression": "1 mm"}));
run(&mut s, "parameters.add", json!({"name": "b", "expression": "2 mm"}));
let before = s.doc.to_json();
for p in [
json!({"name": "a", "before": "a"}),
json!({"name": "a", "before": "nope"}),
json!({"name": "nope", "before": "a"}),
json!({"name": "a"}),
json!({"name": "a", "before": "b", "after": "b"}),
] {
err(&mut s, "parameters.move", p);
}
assert_eq!(s.doc.to_json(), before);
}
+214 -32
View File
@@ -64,7 +64,14 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
.noundo()
.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, and pockets on POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM"),
.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), \
text?: cut|etch (default cut: letters that text cut through the sheet are cut contours on layer TEXT; etch: single contours on layer ETCH and the sheet keeps its material there; text machined to a depth is always on ETCH), \
bend_notes?: bool (default false: a note beside each bend line on layer BEND_NOTES, `B1 UP 90%%d R1`), note_height?: mm (default 3), \
per_body?: bool (a file for every sheet body, `<path>-<body>.dxf`, or their DXF texts without a path → files: [{body, path | dxf, bends}]; not with `body`) \
→ DXF (AutoCAD 2000, mm): layers OUTLINE (the outer contour), CUT (what is cut out inside it), TEXT or ETCH (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES, \
POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM (one per side and depth) and PERFORATION; inner contours come before the outline",
),
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"),
@@ -506,12 +513,79 @@ pub enum DxfCurves {
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};
/// Where the letters that text cut through the sheet go in the flat DXF.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DxfText {
/// Cut contours on layer TEXT: the sheet has holes in the shape of the letters.
#[default]
Cut,
/// Single contours on layer ETCH: the sheet keeps its material there.
Etch,
}
/// What `sheet.export_dxf` is asked for.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct DxfOptions {
pub curves: DxfCurves,
pub text: DxfText,
/// The height (mm) of the note beside each bend line, when it is to have one.
pub bend_notes: Option<f64>,
}
/// The layer of a role: its AutoCAD colour index (1 red, 2 yellow, 3 green, 4 cyan, 5 blue,
/// 6 magenta, 7 white or black, 8 grey) and, for the bend lines, a dashed line. The pocket layers
/// and PERFORATION are green.
fn dxf_layer(name: &str) -> solidcraft_io::DxfLayer {
use solidcraft_io::DxfLayer;
match name {
"OUTLINE" => DxfLayer::new(name, 7),
"CUT" => DxfLayer::new(name, 4),
"TEXT" => DxfLayer::new(name, 2),
"ETCH" => DxfLayer::new(name, 6),
"BEND_UP" => DxfLayer::new(name, 1).dashed(),
"BEND_DOWN" => DxfLayer::new(name, 5).dashed(),
"BEND_NOTES" => DxfLayer::new(name, 8),
_ => DxfLayer::new(name, 3),
}
}
/// A number in a bend note: at most three decimals, none when they are zeros (`90`, `2.5`).
fn compact(v: f64) -> String {
let t = format!("{v:.3}");
let t = t.trim_end_matches('0').trim_end_matches('.');
if t.is_empty() || t == "-0" { "0".into() } else { t.into() }
}
/// The note beside a bend line, `B1 UP 90%%d R2.5` (number, direction, angle, inner radius): as
/// high as `height`, centred half a height beside the middle of the line, parallel to it and
/// readable from below or from the right. (`%%d` is AutoCAD's degree sign.)
fn bend_note(b: &solidcraft_doc::sheet::BendInfo, height: f64) -> solidcraft_io::DxfShape {
let along = (b.end - b.start).normalized().unwrap_or(Vec2::X);
let mut angle = along.angle().to_degrees();
if angle > 90.0 + 1e-9 {
angle -= 180.0;
} else if angle <= -90.0 + 1e-9 {
angle += 180.0;
}
let beside = Vec2::from_angle(angle.to_radians()).perp();
solidcraft_io::DxfShape::Text {
at: (b.start + b.end) * 0.5 + beside * (height * 0.5),
height,
angle_deg: angle,
text: format!("B{} {} {}%%d R{}", b.index, if b.up { "UP" } else { "DOWN" }, compact(b.angle_deg), compact(b.radius)),
}
}
/// A DXF (AutoCAD 2000, mm) of the flat pattern on layers by role: OUTLINE (the outer contour of
/// each piece), CUT (what is cut out inside it, and the loose pieces of a cut), TEXT or ETCH
/// (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES (when asked for), one layer
/// per pocket depth and PERFORATION. Inner contours are written before the outline, so a program
/// that follows the file's order cuts inside-out. Free-form curves as `o.curves` says.
pub fn flat_dxf(sh: &SheetBody, o: &DxfOptions) -> Result<String> {
use solidcraft_io::{DxfEntity, DxfShape};
let regions = sh.flat_regions()?;
let pockets = sh.machining()?;
let letters = sh.letters();
let pocket_layer = |p: &solidcraft_doc::sheet::SheetPocket| {
let side = if p.z0 == 0.0 {
"BOTTOM"
@@ -522,10 +596,6 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
};
format!("POCKET_{side}_Z{:.6}_TO_{:.6}_MM", p.z0, p.z1)
};
let mut layers = std::collections::BTreeSet::from(["OUTLINE".to_string(), "BEND_UP".to_string(), "BEND_DOWN".to_string()]);
for p in &pockets {
layers.insert(pocket_layer(p));
}
let perforation: Vec<(Vec2, f64)> = sh
.perforations
.iter()
@@ -534,29 +604,54 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
.into_iter()
.flatten()
.collect();
if !perforation.is_empty() {
layers.insert("PERFORATION".to_string());
}
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 }
// A piece inside another piece's hole (a letter's counter, the middle of a ring cut) is no
// outline of the sheet but a loose piece the cut leaves.
let nested: Vec<bool> = regions
.iter()
.enumerate()
.map(|(j, r)| {
let probe = r.outer.segs.first().map(solidcraft_geom::Seg2::mid);
probe.is_some_and(|p| regions.iter().enumerate().any(|(i, other)| i != j && other.outer.contains(p)))
})
.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)));
// The loops, in the order they are written: inner contours, markings, pockets, the outline.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Pass {
Cut,
Etch,
Pocket,
Outline,
}
let mut loops: Vec<(Pass, &solidcraft_geom::Loop2, String)> = Vec::new();
for (j, r) in regions.iter().enumerate() {
for (k, lp) in std::iter::once(&r.outer).chain(&r.holes).enumerate() {
let (pass, layer) = if k == 0 && !nested.get(j).copied().unwrap_or(false) {
(Pass::Outline, "OUTLINE")
} else if letters.is_letter(lp) {
match o.text {
DxfText::Cut => (Pass::Cut, "TEXT"),
DxfText::Etch => (Pass::Etch, "ETCH"),
}
} else {
(Pass::Cut, "CUT")
};
loops.push((pass, lp, layer.to_string()));
}
}
for p in &pockets {
let layer = pocket_layer(p);
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
// Text machined to a depth is a marking, whatever its depth.
if letters.is_letter(lp) {
loops.push((Pass::Etch, lp, "ETCH".to_string()));
} else {
loops.push((Pass::Pocket, lp, layer.clone()));
}
}
}
loops.sort_by_key(|(pass, ..)| *pass);
let mut entities = Vec::new();
for (h, layer) in loops {
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) {
@@ -564,7 +659,7 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
continue;
}
let fitted;
let h = match curves {
let h = match o.curves {
DxfCurves::Arcs if !h.is_line_arc() => {
fitted = h
.fit_arcs(solidcraft_kernel::PLANAR_FIT_TOL)
@@ -578,23 +673,110 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
}
for b in sh.bends() {
entities.push(DxfEntity { layer: if b.up { "BEND_UP" } else { "BEND_DOWN" }.into(), shape: DxfShape::Line(b.start, b.end) });
if let Some(height) = o.bend_notes {
entities.push(DxfEntity { layer: "BEND_NOTES".into(), shape: bend_note(&b, height) });
}
}
for (c, r) in perforation {
entities.push(DxfEntity { layer: "PERFORATION".into(), shape: DxfShape::Circle { c, r } });
}
// The four layers of every flat pattern, and those the rest is on.
let mut names: std::collections::BTreeSet<String> = ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"].map(String::from).into();
names.extend(entities.iter().map(|e| e.layer.clone()));
let layers: Vec<_> = names.iter().map(|name| dxf_layer(name)).collect();
Ok(solidcraft_io::write_dxf_drawing(&layers, &entities))
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.export_dxf";
/// What a `sheet.export_dxf` call asks for; a value it cannot use is refused.
fn dxf_options(p: &Value, cmd: &str) -> Result<DxfOptions> {
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 text = match p.get("text").filter(|v| !v.is_null()) {
None => DxfText::Cut,
Some(v) => match v.as_str().map(str::to_ascii_lowercase).as_deref() {
Some("cut") => DxfText::Cut,
Some("etch") => DxfText::Etch,
_ => return Err(bad(cmd, "`text` must be cut or etch")),
},
};
let height = match p.get("note_height").filter(|v| !v.is_null()) {
None => 3.0,
Some(v) => {
v.as_f64().filter(|h| (0.1..=100.0).contains(h)).ok_or_else(|| bad(cmd, "`note_height` must be a number of mm from 0.1 to 100"))?
}
};
let bend_notes = match p.get("bend_notes").filter(|v| !v.is_null()) {
None | Some(Value::Bool(false)) => None,
Some(Value::Bool(true)) => Some(height),
Some(_) => return Err(bad(cmd, "`bend_notes` must be true or false")),
};
Ok(DxfOptions { curves, text, bend_notes })
}
/// The files of an export of every sheet body: `path` with `-<body>` before its extension
/// (`.dxf` when it has none). Characters a file name cannot hold become `_`, and a name another
/// body's file already has (in any case) gets `_2`, `_3`…
fn body_paths(path: &str, bodies: &[String]) -> Vec<String> {
let p = std::path::Path::new(path);
let stem = p.file_stem().map_or_else(|| "flat".to_string(), |s| s.to_string_lossy().to_string());
let ext = p.extension().map_or_else(|| "dxf".to_string(), |e| e.to_string_lossy().to_string());
let mut taken: Vec<String> = Vec::new();
bodies
.iter()
.map(|body| {
let clean: String = body.chars().map(|c| if c.is_control() || "<>:\"/\\|?*".contains(c) { '_' } else { c }).collect();
let clean = clean.trim().trim_end_matches(['.', ' ']);
let base = if clean.is_empty() { "body" } else { clean };
let mut name = base.to_string();
let mut n = 2;
while taken.contains(&name.to_lowercase()) {
name = format!("{base}_{n}");
n += 1;
}
taken.push(name.to_lowercase());
p.with_file_name(format!("{stem}-{name}.{ext}")).to_string_lossy().to_string()
})
.collect()
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.export_dxf";
let options = dxf_options(p, cmd)?;
let per_body = match p.get("per_body").filter(|v| !v.is_null()) {
None => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`per_body` must be true or false")),
};
let path = str_(p, "path").filter(|x| !x.trim().is_empty());
if per_body {
if p.get("body").is_some_and(|b| !b.is_null()) {
return Err(bad(cmd, "`per_body` writes every sheet body: leave `body` out"));
}
let sheets = &s.model.state().sheets;
if sheets.is_empty() {
return Err(bad(cmd, "there is no sheet metal body"));
}
// Every file is made before the first is written.
let texts = sheets.iter().map(|sh| flat_dxf(sh, &options)).collect::<Result<Vec<_>>>()?;
let paths = path.map(|path| body_paths(path, &sheets.iter().map(|sh| sh.body.clone()).collect::<Vec<_>>()));
let mut files = Vec::new();
for (i, (sh, text)) in sheets.iter().zip(&texts).enumerate() {
let bends = sh.bends().len();
if let Some(file) = paths.as_ref().and_then(|paths| paths.get(i)) {
solidcraft_io::vfs::write(file, text.as_bytes()).map_err(|e| EngineError::Other(format!("{file}: {e}")))?;
files.push(json!({"body": sh.body, "path": file, "bends": bends}));
} else {
files.push(json!({"body": sh.body, "dxf": text, "bends": bends}));
}
}
return Ok(json!({"files": files}));
}
let sh = sheet_of(s, p, cmd)?;
let text = flat_dxf(&sh, curves)?;
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
let text = flat_dxf(&sh, &options)?;
if let Some(path) = path {
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()}));
}
@@ -1492,3 +1492,232 @@ fn holes_beside_chamfer_bends_land_from_the_virtual_sharps() {
let expect = t * 100.0 * (2.0 * (30.0 - (r + t)) + (40.0 - 2.0 * (r + t)) + PI * (r + t / 2.0)) - 2.0 * t * 9.0 * PI;
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
}
/// The rule of [`rule`] with its corner relief: `shape` 8 mm across.
fn relief_rule(s: &mut Session, shape: &str) {
rule(s, "Test", 2.0, 0.44, 2.0, 0.5);
run(s, "sheet.manage_rules", json!({"name": "Test", "corner_relief_shape": shape, "corner_relief": "8 mm"}));
}
/// The flat outline's corners, from the low corner of their box.
fn outline_corners(s: &mut Session) -> Vec<[f64; 2]> {
let f = flat(s);
let loops = f["outline"].as_array().cloned().unwrap_or_default();
let pts: Vec<[f64; 2]> =
loops.iter().flat_map(|l| l.as_array().cloned().unwrap_or_default()).filter_map(|q| Some([q[0].as_f64()?, q[1].as_f64()?])).collect();
let lo = [pts.iter().map(|q| q[0]).fold(f64::MAX, f64::min), pts.iter().map(|q| q[1]).fold(f64::MAX, f64::min)];
pts.iter().map(|q| [q[0] - lo[0], q[1] - lo[1]]).collect()
}
/// Whether the flat outline has a corner at each of `want` (from the low corner of its box), the
/// flat as it lies or turned in its box: half round, or seen from its other face. (The flat frame
/// runs along a longest edge of the fixed face, which may be either of two, and from a shell's
/// inner skin it is seen from the other side.)
fn outline_has(s: &mut Session, want: &[[f64; 2]]) -> bool {
let pts = outline_corners(s);
let hi = [pts.iter().map(|q| q[0]).fold(0.0, f64::max), pts.iter().map(|q| q[1]).fold(0.0, f64::max)];
let turned = |q: [f64; 2], (fx, fy): (bool, bool)| [if fx { hi[0] - q[0] } else { q[0] }, if fy { hi[1] - q[1] } else { q[1] }];
[(false, false), (true, true), (true, false), (false, true)].iter().any(|&turn| {
want.iter().all(|w| {
pts.iter().any(|q| {
let p = turned(*q, turn);
(p[0] - w[0]).abs() < 1e-6 && (p[1] - w[1]).abs() < 1e-6
})
})
})
}
/// The corners of a converted tray are a flanged tray's: closed with the rule's gap and given the
/// rule's corner relief, in the folded part and the flat pattern. The shelled tray of [`tray`] and
/// the same tray made from a base and four 30 mm edge flanges, T 2, R 2, K 0.44, gap 0.5, a relief
/// 8 mm across: the bends meet at the base's corners, R + T = 4 in from the sharps, and are
/// BA = π/2 (R + K T) = 1.44 π wide in the flat. A notch leaves them there (92 and 52 long); a
/// round or a square relief takes 4 off each bend at each corner and r²·π/4 or r² (r = 4) of the
/// base; a tear runs each bend on to its corner and cuts its end from there to where its wall's
/// closed end starts, g/√2 + T from the sharp: R − g/√2 longer. The conversion used to bend every
/// corner as a notch, whatever the rule said.
#[test]
fn a_converted_trays_corners_are_closed_and_relieved_as_a_flanged_trays_are() {
let (t, r, k, gap) = (2.0, 2.0, 0.44, 0.5);
let ba = FRAC_PI_2 * (r + k * t);
let grow = r - gap / SQRT_2;
// (bend lengths, flat area taken off the notch's at each corner)
let cases = [
("notch", (92.0, 52.0), 0.0),
("round", (84.0, 44.0), 4.0 * PI + 8.0 * ba),
("rectangular", (84.0, 44.0), 16.0 + 8.0 * ba),
("tear", (92.0 + grow, 52.0 + grow), -grow * ba),
];
let cut = t + gap / SQRT_2;
let walls = 26.0 * 2.0 * ((100.0 - 2.0 * cut) + (60.0 - 2.0 * cut));
let flat_notch = t * (92.0 * 52.0 + ba * 2.0 * (92.0 + 52.0) + walls);
for (shape, (long, short), taken) in cases {
let mut s = Session::default();
relief_rule(&mut s, shape);
tray(&mut s, t);
let out = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
assert!(out["warning"].is_null(), "{shape}: {out}");
let mut lengths: Vec<f64> = bend_rows(&mut s).iter().map(|row| row.2).collect();
lengths.sort_by(f64::total_cmp);
let want = [short, short, long, long];
assert!(lengths.len() == 4 && lengths.iter().zip(want).all(|(l, w)| (l - w).abs() < 1e-6), "{shape}: {lengths:?}");
// The same tray made from flanges.
let mut f = Session::default();
relief_rule(&mut f, shape);
run(&mut f, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut f, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut f, "sketch.finish", json!({}));
run(&mut f, "sheet.flange", json!({"sketch": "Base"}));
run(&mut f, "sheet.flange", json!({"edges": [[50, 0, 2], [100, 30, 2], [50, 60, 2], [0, 30, 2]], "height": 30}));
let mut flanged: Vec<f64> = bend_rows(&mut f).iter().map(|row| row.2).collect();
flanged.sort_by(f64::total_cmp);
assert!(lengths.iter().zip(&flanged).all(|(a, b)| (a - b).abs() < 1e-9), "{shape}: {lengths:?} vs {flanged:?}");
// The flat patterns are congruent, corner for corner.
let corners = outline_corners(&mut f);
assert!(
outline_corners(&mut s).len() == corners.len() && outline_has(&mut s, &corners),
"{shape}: {:?} vs {corners:?}",
outline_corners(&mut s)
);
let (v, vf) = (volume(&mut s), volume(&mut f));
assert!((v - vf).abs() < 1e-6 * vf, "{shape}: {v} vs {vf}");
run(&mut s, "sheet.unfold", json!({}));
run(&mut f, "sheet.unfold", json!({}));
let (flat_v, flat_f) = (volume(&mut s), volume(&mut f));
assert!((flat_v - flat_f).abs() < 1e-6 * flat_f, "{shape}: {flat_v} vs {flat_f}");
// The flat takes the relief off the notch's (whose walls end g/√2 + T from the sharps).
let want = flat_notch - 4.0 * t * taken;
assert!((flat_v - want).abs() < 0.1, "{shape}: flat {flat_v} vs {want}");
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{shape}");
// A round relief is an arc of radius 4 about each point where two bends meet: the base's
// corners R + T in from the sharps, 92 and 52 apart.
if shape == "round" {
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
let arcs: Vec<(solidcraft_geom::Vec2, f64)> = solidcraft_io::read_dxf(&dxf)
.unwrap_or_default()
.iter()
.filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some((*c, a.dist(*c))) } else { None })
.collect();
assert_eq!(arcs.len(), 4, "{arcs:?}");
let lo = solidcraft_geom::Vec2::new(
arcs.iter().map(|(c, _)| c.x).fold(f64::MAX, f64::min),
arcs.iter().map(|(c, _)| c.y).fold(f64::MAX, f64::min),
);
for (x, y) in [(0.0, 0.0), (92.0, 0.0), (92.0, 52.0), (0.0, 52.0)] {
let at = lo + solidcraft_geom::Vec2::new(x, y);
assert!(arcs.iter().any(|(c, r)| c.dist(at) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}");
}
}
}
}
/// A plate 80 x 50, `plate` thick, with a wall 2 thick and 30 high standing on its edge y = 0
/// from `x0` to `x1`, joined (with a 2 mm plate, a shell).
fn bracket(s: &mut Session, x0: f64, x1: f64, plate: f64) {
run(s, "solid.box", json!({"length": 80, "width": 50, "height": plate, "body_name": "Bracket"}));
run(s, "solid.box", json!({"length": x1 - x0, "width": 2, "height": 30, "corner": [x0, 0, 0], "operation": "join", "targets": ["Bracket"]}));
}
/// A wall standing on part of an edge (an L-bracket whose flange covers part of the plate's edge,
/// a tab): the plate's edge face and the wall's outside are one face, the plate's edge running on
/// past the wall a strip T high. Its bend ends where the wall does, and the rule's bend relief
/// (2 wide, from 1 past the bend line through the plate's edge) is cut into the plate beside each
/// end of the bend that has more plate beside it, as for an edge flange. The strip, lower than the
/// bend's setback R + T = 4, was used up by it and the conversion refused; from the inner skin the
/// bend ended against the plate without a relief. T 2, R 2, K 0.44: BA = 1.44 π, BD = 8 − BA.
#[test]
fn a_bend_ends_where_its_wall_ends_with_the_rules_bend_relief_beside_it() {
let (t, r, k) = (2.0, 2.0, 0.44);
let ba = FRAC_PI_2 * (r + k * t);
let bd = deduction(FRAC_PI_2, r, t, k);
// Folded and flat: the plate less the bend's setback under the wall and the reliefs (2 x 5
// each), the wall past the bend (30 − 4 high), and the bend on its neutral radius or as its
// allowance.
let parts = |wall: f64, reliefs: f64| {
let flat = 80.0 * 50.0 - 4.0 * wall - 10.0 * reliefs + 26.0 * wall;
(t * flat + FRAC_PI_2 * t * (r + t / 2.0) * wall, t * (flat + ba * wall))
};
// From the flat's low corner (the wall's far edge, 26 + BA − 4 past the plate's edge).
let edge = 26.0 + ba - 4.0;
// The wall in the middle, picked from the outside or the inside: the same sheet, the reliefs
// x 18 … 20 and 60 … 62, through the plate's edge to 1 past the bend line.
for (face, bend, way) in [([40.0, 25.0, 0.0], [40.0, 0.0, 0.0], "up"), ([40.0, 25.0, 2.0], [40.0, 2.0, 2.0], "down")] {
let mut s = Session::default();
rule(&mut s, "Test", t, k, r, 0.5);
bracket(&mut s, 20.0, 60.0, 2.0);
let out = run(&mut s, "sheet.convert", json!({"body": "Bracket", "face": face, "bends": [bend]}));
assert!(out["warning"].is_null(), "{way}: {out}");
let rows = bend_rows(&mut s);
assert!(rows.len() == 1 && rows[0].0 == 90 && rows[0].1 == way && (rows[0].2 - 40.0).abs() < 1e-6, "{way}: {rows:?}");
assert!(same_size(flat_size(&mut s), (80.0, 80.0 - bd), 1e-6), "{way}: {:?}", flat_size(&mut s));
let reliefs = [[18.0, edge], [18.0, edge + 5.0], [20.0, edge + 5.0], [60.0, edge + 5.0], [62.0, edge + 5.0], [62.0, edge]];
assert!(outline_has(&mut s, &reliefs), "{way}: {:?}", outline_corners(&mut s));
let (folded, flat) = parts(40.0, 2.0);
let v = volume(&mut s);
assert!((v - folded).abs() < 1e-4 * folded, "{way}: {v} vs {folded}");
run(&mut s, "sheet.unfold", json!({}));
assert!((volume(&mut s) - flat).abs() < 1e-6 * flat, "{way}: {} vs {flat}", volume(&mut s));
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{way}");
}
// A wall at the plate's end: its bend runs from the plate's corner, one relief at its end.
let mut s = Session::default();
rule(&mut s, "Test", t, k, r, 0.5);
bracket(&mut s, 0.0, 40.0, 2.0);
run(&mut s, "sheet.convert", json!({"body": "Bracket", "face": [40, 25, 0], "bends": [[20, 0, 0]]}));
let rows = bend_rows(&mut s);
assert!(rows.len() == 1 && (rows[0].2 - 40.0).abs() < 1e-6, "{rows:?}");
assert!(outline_has(&mut s, &[[0.0, 0.0], [40.0, 0.0], [40.0, edge + 5.0], [42.0, edge + 5.0], [42.0, edge]]), "{:?}", outline_corners(&mut s));
let (folded, _) = parts(40.0, 1.0);
assert!((volume(&mut s) - folded).abs() < 1e-4 * folded, "{} vs {folded}", volume(&mut s));
// A strip taller than the setback (a 5 mm plate, the rule's T 2 taken into the block) stays a
// low wall of the sheet beside the wall, as before: one bend the plate's length, no relief.
let mut s = Session::default();
rule(&mut s, "Test", t, k, r, 0.5);
bracket(&mut s, 20.0, 60.0, 5.0);
run(&mut s, "sheet.convert", json!({"body": "Bracket", "face": [40, 25, 0], "bends": [[40, 0, 0]]}));
let rows = bend_rows(&mut s);
assert!(rows.len() == 1 && (rows[0].2 - 80.0).abs() < 1e-6, "{rows:?}");
let low = t * (80.0 * 46.0 + 80.0 * 1.0 + 40.0 * 25.0) + FRAC_PI_2 * t * (r + t / 2.0) * 80.0;
assert!((volume(&mut s) - low).abs() < 1e-4 * low, "{} vs {low}", volume(&mut s));
}
/// Both together: a plate 100 x 60 x 2 with a wall along its whole edge y = 0 and one on its edge
/// x = 100 from y = 0 to 30 only, a round corner relief 8 across. The walls meet at the corner,
/// closed with the gap (each ends g/√2 + T from the other's outside) and relieved there; the
/// shorter one's bend ends at y = 30 with a bend relief beside it. In the flat from the plate's
/// corner: the plate from y = 4 and up to x = 96 beside the short wall, 100 past it; bends 96 − 4
/// and 26 − 4 long; the bend relief x 95 … 100, y 30 … 32.
#[test]
fn a_wall_on_part_of_an_edge_meets_a_wall_at_a_relieved_corner_and_is_relieved_at_its_end() {
let (t, r, k, gap) = (2.0, 2.0, 0.44, 0.5);
let ba = FRAC_PI_2 * (r + k * t);
let mut s = Session::default();
relief_rule(&mut s, "round");
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "body_name": "Part"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 2, "height": 30, "operation": "join", "targets": ["Part"]}));
run(&mut s, "solid.box", json!({"length": 2, "width": 30, "height": 30, "corner": [98, 0, 0], "operation": "join", "targets": ["Part"]}));
let out = run(&mut s, "sheet.convert", json!({"body": "Part", "face": [50, 30, 0], "bends": [[50, 0, 0], [100, 15, 0]]}));
assert!(out["warning"].is_null(), "{out}");
let mut lengths: Vec<f64> = bend_rows(&mut s).iter().map(|row| row.2).collect();
lengths.sort_by(f64::total_cmp);
assert!(lengths.len() == 2 && (lengths[0] - 22.0).abs() < 1e-6 && (lengths[1] - 92.0).abs() < 1e-6, "{lengths:?}");
// The plate less the setbacks, the round relief and the bend relief; the walls 26 high, each
// from the corner's closed end to its own end.
let cut = t + gap / SQRT_2;
let base = 100.0 * 56.0 - 4.0 * 26.0 - 4.0 * PI - 10.0;
let walls = 26.0 * ((100.0 - cut) + (30.0 - cut));
let folded = t * (base + walls) + FRAC_PI_2 * t * (r + t / 2.0) * 114.0;
let v = volume(&mut s);
assert!((v - folded).abs() < 1e-4 * folded, "{v} vs {folded}");
// From the flat's low corner (the long wall's far edge, 26 + BA − 4 past y = 0): the bend
// relief's corners.
let edge = 26.0 + ba - 4.0;
assert!(outline_has(&mut s, &[[95.0, edge + 30.0], [95.0, edge + 32.0], [100.0, edge + 32.0]]), "{:?}", outline_corners(&mut s));
run(&mut s, "sheet.unfold", json!({}));
let flat = t * (base + walls + ba * 114.0);
assert!((volume(&mut s) - flat).abs() < 0.1, "{} vs {flat}", volume(&mut s));
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
}
+304 -3
View File
@@ -1,4 +1,6 @@
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities.
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities, and
//! a laser-cutting shop can use it as it is: layers by role, bend notes, letters cut or etched,
//! one file per sheet body.
use std::f64::consts::{PI, TAU};
@@ -16,11 +18,18 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
/// 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();
plate_in(Session::default(), curves)
}
/// A 100 × 60 base flange in `s` (of its rule), with `curves` drawn in a sketch and cut through it.
fn plate_in(mut s: Session, curves: &[(&str, Value)]) -> Session {
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"}));
if curves.is_empty() {
return s;
}
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
for (command, params) in curves {
run(&mut s, command, params.clone());
@@ -30,6 +39,31 @@ fn plate_with(curves: &[(&str, Value)]) -> Session {
s
}
/// A session whose active rule is 2 mm sheet with an inner bend radius of 1 mm.
fn sheet_of_2mm() -> Session {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
s
}
/// A 100 × 60 tray of 2 mm sheet with `curves` cut through its base: three 20 mm walls bend up and
/// one bends down.
fn tray_with(curves: &[(&str, Value)]) -> Session {
let mut s = plate_in(sheet_of_2mm(), curves);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2], [100, 30, 2], [50, 60, 2]], "height": 20}));
run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2]], "height": 20, "flip": true}));
s
}
/// What a tray is marked with: a Ø5 hole, a 20 × 5 slot and the label "AB-12", 10 mm high at (20, 28).
fn marks() -> Vec<(&'static str, Value)> {
vec![
("sketch.circle.center", json!({"center": [15, 15], "diameter": 5})),
("sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]})),
("sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10})),
]
}
fn export(s: &mut Session, params: Value) -> String {
run(s, "sheet.export_dxf", params)["dxf"].as_str().unwrap_or_default().to_string()
}
@@ -217,5 +251,272 @@ fn bend_lines_and_layers_are_still_there() {
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"));
// The ellipse is a cut-out, on CUT; the outline is the plate's.
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("CUT") == 1);
assert_eq!(on("OUTLINE") + on("CUT"), es.len() - on("BEND_UP") - on("BEND_DOWN"));
}
/// The layer an entity is on.
fn layer_of(e: &Entity) -> String {
e.1.iter().find(|(c, _)| *c == 8).map(|(_, v)| v.clone()).unwrap_or_default()
}
/// The text of an entity's group code.
fn string(e: &Entity, code: i32) -> String {
e.1.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default()
}
/// What the entities of a layer are, without their handles and the layer: to compare layers of two files.
fn on_layer(es: &[Entity], layer: &str) -> Vec<(String, Vec<(i32, String)>)> {
es.iter().filter(|e| layer_of(e) == layer).map(|e| (e.0.clone(), e.1.iter().filter(|(c, _)| ![5, 8].contains(c)).cloned().collect())).collect()
}
/// The LAYER table: name → (colour, linetype).
fn layer_table(dxf: &str) -> std::collections::BTreeMap<String, (i64, String)> {
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, "LAYER")).unwrap_or(0);
let mut records: Vec<Vec<(i32, &str)>> = Vec::new();
for p in pairs.iter().skip(start + 1) {
match *p {
(0, "ENDTAB") => break,
(0, _) => records.push(Vec::new()),
_ => {
if let Some(record) = records.last_mut() {
record.push(*p);
}
}
}
}
records
.iter()
.filter_map(|r| {
let get = |code: i32| r.iter().find(|(c, _)| *c == code).map(|(_, v)| *v);
Some((get(2)?.to_string(), (get(62)?.parse().ok()?, get(6)?.to_string())))
})
.collect()
}
fn vec2(v: &Value) -> Vec2 {
Vec2::new(v[0].as_f64().unwrap_or(f64::NAN), v[1].as_f64().unwrap_or(f64::NAN))
}
#[test]
fn the_flat_pattern_is_on_layers_by_role_and_inner_contours_come_first() {
let mut s = tray_with(&marks());
let out = run(&mut s, "sheet.export_dxf", json!({}));
let dxf = out["dxf"].as_str().unwrap_or_default();
let es = entities(dxf);
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
// OUTLINE: the tray's outer contour alone, one entity for each of its segments (lines and arcs).
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
let plate =
regions.iter().max_by(|a, b| a.area().total_cmp(&b.area())).map(|r| r.outer.clone()).unwrap_or(solidcraft_geom::Loop2 { segs: Vec::new() });
assert_eq!(on("OUTLINE").len(), solidcraft_io::loop_shapes(&plate).len());
assert!(on("OUTLINE").iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", on("OUTLINE").iter().map(|e| &e.0).collect::<Vec<_>>());
// CUT: what is cut out inside it that is no text: the Ø5 hole as one CIRCLE, the slot's four lines.
let cut = on("CUT");
assert_eq!((count_of(&cut, "CIRCLE"), count_of(&cut, "LINE"), cut.len()), (1, 4, 5), "{:?}", cut.iter().map(|e| &e.0).collect::<Vec<_>>());
// TEXT: the letters of "AB-12" and the counters of A and B, all within the label's box (5 letters
// of at most 10 mm from (20, 28)).
let letters = on("TEXT");
assert!(letters.len() > 20, "{} letter entities", letters.len());
for e in &letters {
let p = point(e, 10);
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
}
// The bend lines by direction: three walls up, one down.
assert_eq!((on("BEND_UP").len(), on("BEND_DOWN").len(), out["bends"].as_u64()), (3, 1, Some(4)));
// Inner contours are written before the outline, so a program that follows the file cuts inside-out.
let last_inner = es.iter().rposition(|e| matches!(layer_of(e).as_str(), "CUT" | "TEXT"));
let first_outline = es.iter().position(|e| layer_of(e) == "OUTLINE");
let (Some(last_inner), Some(first_outline)) = (last_inner, first_outline) else { panic!("{last_inner:?} {first_outline:?}") };
assert!(last_inner < first_outline, "an inner contour at {last_inner}, the outline from {first_outline}");
// The layers, with the colours and linetypes the guide gives; nothing else is declared.
let want = [
("0", 7, "Continuous"),
("OUTLINE", 7, "Continuous"),
("CUT", 4, "Continuous"),
("TEXT", 2, "Continuous"),
("BEND_UP", 1, "DASHED"),
("BEND_DOWN", 5, "DASHED"),
];
assert_eq!(layer_table(dxf), want.map(|(n, c, l)| (n.to_string(), (c, l.to_string()))).into_iter().collect());
}
/// How many entities of `kind` there are among `list`.
fn count_of(list: &[&Entity], kind: &str) -> usize {
list.iter().filter(|e| e.0 == kind).count()
}
#[test]
fn letters_can_be_etched_instead_of_cut() {
let mut s = tray_with(&marks());
let cut = entities(&export(&mut s, json!({})));
let etched_dxf = export(&mut s, json!({"text": "etch"}));
let etched = entities(&etched_dxf);
// The same contours, on ETCH instead of TEXT: nothing of them is cut.
assert!(!on_layer(&cut, "TEXT").is_empty());
assert_eq!(on_layer(&etched, "ETCH"), on_layer(&cut, "TEXT"));
assert!(on_layer(&etched, "TEXT").is_empty());
// The hole and the slot are no text: they stay where they were, as do the outline and the bends.
for layer in ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"] {
assert_eq!(on_layer(&etched, layer), on_layer(&cut, layer), "{layer}");
}
let layers = layer_table(&etched_dxf);
assert_eq!(layers.get("ETCH"), Some(&(6, "Continuous".to_string())));
assert!(!layers.contains_key("TEXT"));
// `cut` is the default, and a value that is neither is refused.
assert_eq!(export(&mut s, json!({"text": "cut"})), export(&mut s, json!({})));
assert!(s.execute("sheet.export_dxf", &json!({"text": "fill"})).is_err());
}
#[test]
fn engraved_text_is_etched_and_other_pockets_keep_their_depth_layer() {
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": "Marks"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]}));
run(&mut s, "sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
// Part way through the sheet: pockets, 1 mm deep from the bottom.
run(&mut s, "solid.extrude", json!({"sketch": "Marks", "distance": 1, "operation": "cut"}));
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
// The rectangle is a pocket of the depth its layer names; the letters are a marking, on ETCH.
let pocket = "POCKET_BOTTOM_Z0.000000_TO_1.000000_MM";
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
assert_eq!((count_of(&on(pocket), "LINE"), on(pocket).len()), (4, 4));
let letters = on("ETCH");
assert!(letters.len() > 20, "{} letter entities", letters.len());
for e in &letters {
let p = point(e, 10);
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
}
let layers = layer_table(&dxf);
assert!(layers.contains_key(pocket) && layers.contains_key("ETCH") && !layers.contains_key("TEXT"), "{layers:?}");
// `text` is about letters cut through: it changes nothing here.
assert_eq!(on_layer(&entities(&export(&mut s, json!({"text": "etch"}))), "ETCH"), on_layer(&es, "ETCH"));
}
#[test]
fn a_bend_note_sits_beside_each_bend_line_when_asked_for() {
let mut s = tray_with(&[]);
let flat = run(&mut s, "sheet.flat_pattern", json!({}));
let bends = flat["bends"].as_array().cloned().unwrap_or_default();
assert_eq!(bends.len(), 4);
// Not unless asked for: a program that follows the file would etch them.
let plain = export(&mut s, json!({}));
assert!(entities(&plain).iter().all(|e| e.0 != "TEXT") && !layer_table(&plain).contains_key("BEND_NOTES"));
let dxf = export(&mut s, json!({"bend_notes": true}));
let notes: Vec<Entity> = entities(&dxf).into_iter().filter(|e| e.0 == "TEXT").collect();
assert_eq!(notes.len(), 4);
assert!(notes.iter().all(|e| layer_of(e) == "BEND_NOTES"));
assert_eq!(layer_table(&dxf).get("BEND_NOTES"), Some(&(8, "Continuous".to_string())));
for b in &bends {
// The walls bend by 90° round an inner radius of 1 mm: "B1 UP 90° R1" (`%%d` is the degree sign).
let up = b["direction"] == "up";
let text = format!("B{} {} 90%%d R1", b["bend"].as_u64().unwrap_or(0), if up { "UP" } else { "DOWN" });
let Some(note) = notes.iter().find(|e| string(e, 1) == text) else {
panic!("no note `{text}` among {:?}", notes.iter().map(|e| string(e, 1)).collect::<Vec<_>>())
};
// Centred (72 = 1), 3 mm high, parallel to the line: along x it reads left to right, along y upwards.
let (start, end) = (vec2(&b["start"]), vec2(&b["end"]));
let angle = if (end - start).x.abs() > (end - start).y.abs() { 0.0 } else { 90.0 };
assert!(values(note, 72) == [1.0] && values(note, 40) == [3.0] && (values(note, 50)[0] - angle).abs() < 1e-9, "{note:?}");
// On the left of the way it reads, half its height from the middle of the line.
let off = point(note, 11) - (start + end) * 0.5;
assert!(off.dist(Vec2::from_angle(angle.to_radians()).perp() * 1.5) < 1e-9, "{text}: {off:?}");
}
// The height is the caller's.
let tall = entities(&export(&mut s, json!({"bend_notes": true, "note_height": 5})));
let note = tall.iter().find(|e| e.0 == "TEXT");
assert!(note.is_some_and(|e| values(e, 40) == [5.0]));
}
/// Two sheet bodies: a lid and, 200 mm away, a side wall with one bend.
fn lid_and_wall() -> Session {
let mut s = Session::default();
for (sketch, p0, p1, body) in [("Lid", [0, 0], [100, 60], "Lid"), ("Wall", [200, 0], [250, 40], "Side Wall")] {
run(&mut s, "sketch.create", json!({"plane": "XY", "name": sketch}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": sketch, "body_name": body}));
}
run(&mut s, "sheet.flange", json!({"edges": [[225, 0, 2.5]], "height": 15, "body": "Side Wall"}));
s
}
#[test]
fn each_sheet_body_can_have_a_file_of_its_own() {
let mut s = lid_and_wall();
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true}));
let files = r["files"].as_array().cloned().unwrap_or_default();
assert_eq!(files.iter().map(|f| f["body"].as_str().unwrap_or_default()).collect::<Vec<_>>(), ["Lid", "Side Wall"]);
assert_eq!(files.iter().map(|f| f["bends"].as_u64()).collect::<Vec<_>>(), [Some(0), Some(1)]);
// Each is the file `body` asks for: the lid's has no bend line, the wall's has its one.
for f in &files {
let single = export(&mut s, json!({"body": f["body"]}));
assert_eq!(f["dxf"].as_str(), Some(single.as_str()), "{}", f["body"]);
}
let bend_lines = |f: &Value| entities(f["dxf"].as_str().unwrap_or_default()).iter().filter(|e| layer_of(e) == "BEND_UP").count();
assert_eq!((bend_lines(&files[0]), bend_lines(&files[1])), (0, 1));
// With a path, each body is written to a file named after it, beside the path, and no text comes back.
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-dxf-per-body-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_default();
let path = dir.join("panel.dxf").to_string_lossy().to_string();
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true, "path": path}));
let written = r["files"].as_array().cloned().unwrap_or_default();
let names: Vec<_> = written
.iter()
.map(|f| std::path::Path::new(f["path"].as_str().unwrap_or_default()).file_name().map(|n| n.to_string_lossy().to_string()))
.collect();
assert_eq!(names, [Some("panel-Lid.dxf".to_string()), Some("panel-Side Wall.dxf".to_string())]);
for (f, written) in files.iter().zip(&written) {
assert!(written.get("dxf").is_none());
let on_disk = std::fs::read_to_string(written["path"].as_str().unwrap_or_default()).unwrap_or_default();
assert_eq!(f["dxf"].as_str(), Some(on_disk.as_str()));
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bad_export_options_are_refused_not_ignored() {
let mut s = tray_with(&[]);
for p in [
json!({"text": "fill"}),
json!({"text": 1}),
json!({"bend_notes": "yes"}),
json!({"bend_notes": 1}),
json!({"note_height": 0}),
json!({"note_height": -3}),
json!({"note_height": 1e9}),
json!({"note_height": "tall"}),
json!({"per_body": "yes"}),
json!({"per_body": true, "body": "Body1"}),
] {
assert!(s.execute("sheet.export_dxf", &p).is_err(), "{p}");
}
// Nothing was left half done.
assert!(s.execute("sheet.export_dxf", &json!({})).is_ok());
}
#[test]
fn the_file_of_a_body_is_named_after_it_and_no_two_share_a_name() {
// (Windows writes the separator it adds as `\`.)
let names = |path: &str, bodies: &[&str]| -> Vec<String> {
super::body_paths(path, &bodies.iter().map(|b| b.to_string()).collect::<Vec<_>>()).into_iter().map(|p| p.replace('\\', "/")).collect()
};
assert_eq!(names("out/panel.dxf", &["Lid", "Side Wall"]), ["out/panel-Lid.dxf", "out/panel-Side Wall.dxf"]);
// The extension is the path's, and .dxf when it has none.
assert_eq!(names("out/panel", &["Lid"]), ["out/panel-Lid.dxf"]);
assert_eq!(names("panel.DXF", &["Lid"]), ["panel-Lid.DXF"]);
// What a file name cannot hold becomes `_`; a name that is nothing but that stays a name.
assert_eq!(names("p.dxf", &["a/b:c", " ", "wall."]), ["p-a_b_c.dxf", "p-body.dxf", "p-wall.dxf"]);
// Two bodies that would share a file (in any case) get a number.
assert_eq!(names("p.dxf", &["Lid", "lid", "LID", "Lid_2"]), ["p-Lid.dxf", "p-lid_2.dxf", "p-LID_3.dxf", "p-Lid_2_2.dxf"]);
}
+151 -3
View File
@@ -1551,7 +1551,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, super::DxfCurves::Exact).unwrap();
let dxf = super::flat_dxf(&sh, &super::DxfOptions::default()).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();
@@ -1563,7 +1563,7 @@ 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, super::DxfCurves::Exact).unwrap();
let dxf = super::flat_dxf(&sh, &super::DxfOptions::default()).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.0\n 51\n180.0\n"), "{dxf}");
@@ -2001,7 +2001,9 @@ fn dxf_outline_only(text: &str) -> String {
for end in 1..=pairs.len() {
if end == pairs.len() || pairs.get(end).is_some_and(|p| p.0 == "0") {
let entity = &pairs[start..end];
if entity.first().is_some_and(|p| matches!(p.1, "LINE" | "ARC" | "CIRCLE")) && entity.iter().any(|p| p.0 == "8" && p.1 == "OUTLINE") {
if entity.first().is_some_and(|p| matches!(p.1, "LINE" | "ARC" | "CIRCLE"))
&& entity.iter().any(|p| p.0 == "8" && matches!(p.1, "OUTLINE" | "CUT"))
{
for (code, value) in entity {
out.push_str(&format!("{code}\n{value}\n"));
}
@@ -4145,3 +4147,149 @@ fn perforate_supports_a_square_pattern_and_refuses_impossible_values() {
assert!(s.execute("sheet.perforate", &bad).is_err(), "{bad} must be refused");
}
}
/// A 100 x 60 tray with one 45° flange 40 high on its y = 0 edge (2.5 mm, R 2.5, K 0.44), and on
/// it a return 10 high when asked. Returns the far edge of the last panel in the flat pattern:
/// the base starts at the setback s = (t + R)·tan 22.5°, each bend's zone is (π/4)·(R + K·t) long,
/// and a panel h high is h − s long, or h − 2s with a return on its far edge.
fn tray_with_a_45_degree_flange(s: &mut Session, with_return: bool) -> f64 {
base(s, 100.0, 60.0);
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 40, "angle": 45}));
let (setback, ba) = (5.0 * (PI / 8.0).tan(), PI / 4.0 * (2.5 + 0.44 * 2.5));
if with_return {
run(s, "sheet.flange", json!({"edges": [[50, -27.5, 26.5]], "height": 10, "angle": 45}));
return setback - ba - (40.0 - 2.0 * setback) - ba - (10.0 - setback);
}
setback - ba - (40.0 - setback)
}
/// A point on that flange's inside face, the plane y + z = 2.5·√2 (tangent to the bend's inside
/// radius, 2.5 about y = 5·tan 22.5°, z = 5).
const FLANGE_FACE: [f64; 3] = [50.0, 2.5 * std::f64::consts::SQRT_2 - 15.0, 15.0];
fn last_warning(s: &Session) -> String {
s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default()
}
fn parted_note(area: f64, at: [f64; 2]) -> String {
format!("1 piece of `Body1` is no longer joined to its base ({area:.2} mm², near ({:.1}, {:.1}) in the flat pattern); it is kept", at[0], at[1])
}
fn flat_pieces(s: &Session) -> Vec<f64> {
s.model.state().sheets[0].flat_regions().unwrap().iter().map(solidcraft_geom::Region2::area).collect()
}
#[test]
fn a_cut_that_parts_a_flange_from_the_base_keeps_it_and_says_so_folded_or_flat() {
// A slot across the flange beside its bend (flat y −3 to −1.5) leaves the flange's panel past
// it, with any return on it, joined to nothing: 100 × (−3 − far). Cut on the folded flange's
// face or in the unfolded sheet, the piece stays, and the cut names it in its reply and on the
// timeline, with the middle of the piece in the flat pattern.
for (with_return, unfolded) in [(false, false), (false, true), (true, false), (true, true)] {
let what = format!("return {with_return}, unfolded {unfolded}");
let mut s = Session::default();
let far = tray_with_a_45_degree_flange(&mut s, with_return);
let out = if unfolded {
run(&mut s, "sheet.unfold", json!({}));
rectangle_tool(&mut s, "Slot", [-5.0, -3.0], [105.0, -1.5]);
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"}))
} else {
run(&mut s, "sketch.create", json!({"plane": {"face": FLANGE_FACE}, "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, -3], "p1": [105, -1.5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "negative", "operation": "cut"}))
};
let note = parted_note(100.0 * (-3.0 - far), [50.0, (far - 3.0) / 2.0]);
assert!(last_warning(&s).contains(&note), "{what}: {}", last_warning(&s));
assert_eq!(out["warning"], json!(last_warning(&s)), "{what}: {out}");
// Both pieces stay, folded and flat: the base with its bend left, and the flange.
let pieces = flat_pieces(&s);
assert!(pieces.len() == 2 && pieces.iter().any(|a| (a - 100.0 * (-3.0 - far)).abs() < 1e-6), "{what}: {pieces:?}");
for command in if unfolded { ["sheet.refold", "sheet.unfold"] } else { ["sheet.unfold", "sheet.refold"] } {
run(&mut s, command, json!({}));
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2), "{what} {command}");
assert_eq!(flat_pieces(&s), pieces, "{what} {command}");
if command == "sheet.unfold" {
assert!((volume(&mut s) - pieces.iter().sum::<f64>() * 2.5).abs() < 1e-3, "{what}: {}", volume(&mut s));
}
}
}
}
#[test]
fn a_ring_over_a_flanges_edge_names_the_piece_of_its_inside_on_the_flange_but_not_an_island() {
// A ring Ø16 / Ø10 cut on the folded flange's face, centred on its far edge: the half of its
// inside on the flange (π·5²/2, its centroid 4·5/3π inside the edge) has nothing round it,
// and is named. Centred on the flange, its inside is an island, and nothing is named.
for (y, named) in [(None, true), (Some(-20.0), false)] {
let mut s = Session::default();
let far = tray_with_a_45_degree_flange(&mut s, false);
let y = y.unwrap_or(far);
run(&mut s, "sketch.create", json!({"plane": {"face": FLANGE_FACE}, "name": "Ring"}));
run(&mut s, "sketch.circle.center", json!({"center": [50, y], "diameter": 16}));
run(&mut s, "sketch.circle.center", json!({"center": [50, y], "diameter": 10}));
run(&mut s, "sketch.finish", json!({}));
let out = run(
&mut s,
"solid.extrude",
json!({"sketch": "Ring", "profiles": [{"point": [56.5, y]}], "through_all": true, "direction": "negative", "operation": "cut"}),
);
let warning = out["warning"].as_str().unwrap_or_default().to_string();
let note = parted_note(12.5 * PI, [50.0, far + 20.0 / (3.0 * PI)]);
assert_eq!(warning.contains(&note), named, "{y}: {warning}");
assert!(named || !warning.contains("no longer joined"), "{y}: {warning}");
assert_eq!(flat_pieces(&s).len(), 2, "{y}");
}
}
#[test]
fn cuts_that_split_a_sheet_or_cut_it_beside_the_base_name_only_the_pieces_they_part() {
// Unfolded, a strip across tray and flange leaves two pieces that each keep their part of
// the base: nothing to name. A box over the smaller piece's base and bend then leaves its
// flange panel, 26 × (−3 − far), named; a hole in the base after it names nothing again.
let mut s = Session::default();
let far = tray_with_a_45_degree_flange(&mut s, false);
run(&mut s, "sheet.unfold", json!({}));
rectangle_tool(&mut s, "Strip", [70.0, -60.0], [74.0, 70.0]);
let out = run(&mut s, "solid.extrude", json!({"sketch": "Strip", "through_all": true, "direction": "symmetric", "operation": "cut"}));
assert!(out["warning"].is_null() && last_warning(&s).is_empty(), "{out}");
rectangle_tool(&mut s, "Box", [72.0, -3.0], [101.0, 61.0]);
let out = run(&mut s, "solid.extrude", json!({"sketch": "Box", "through_all": true, "direction": "symmetric", "operation": "cut"}));
assert_eq!(out["warning"], json!(parted_note(26.0 * (-3.0 - far), [87.0, (far - 3.0) / 2.0])), "{out}");
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 30], "diameter": 8}));
run(&mut s, "sketch.finish", json!({}));
let out = run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
assert!(out["warning"].is_null(), "{out}");
run(&mut s, "sheet.refold", json!({}));
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2));
// Folded, a slot across the base beside the bend leaves the flange with a strip of the base;
// a plain plate cut in two halves. Neither names anything.
let mut s = Session::default();
tray_with_a_45_degree_flange(&mut s, false);
rectangle_tool(&mut s, "Slot", [-5.0, 3.0], [105.0, 6.0]);
let out = run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"}));
assert!(out["warning"].is_null() && flat_pieces(&s).len() == 2, "{out}");
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
rectangle_tool(&mut s, "Strip", [45.0, -5.0], [55.0, 65.0]);
let out = run(&mut s, "solid.extrude", json!({"sketch": "Strip", "through_all": true, "direction": "symmetric", "operation": "cut"}));
assert!(out["warning"].is_null() && flat_pieces(&s).len() == 2, "{out}");
}
#[test]
fn a_hole_that_parts_a_flange_from_the_base_names_it() {
// A 10 x 60 plate with a 90° flange 40 high on its y = 0 edge: the flange's panel runs from
// flat y = 5 − BA (world z = 5) for 35 mm. A Ø12 hole drilled into it at z = 15 (flat
// y = 5 − BA − 10) takes its whole width, and leaves the 25 mm below the hole's centre less
// the half disc there: 10 × 25 − ∫ √(36 − u²) du over |u| ≤ 5 = 250 − (5√11 + 36·asin(5/6)).
let mut s = Session::default();
base(&mut s, 10.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[5, 0, 2.5]], "height": 40}));
let out = run(&mut s, "solid.hole", json!({"position": [5, 0, 15], "diameter": 12}));
let area = 250.0 - (5.0 * 11f64.sqrt() + 36.0 * (5.0f64 / 6.0).asin());
let warning = out["warning"].as_str().unwrap_or_default();
assert!(warning.starts_with(&format!("1 piece of `Body1` is no longer joined to its base ({area:.2} mm², near (5.0, ")), "{warning}");
assert_eq!(out["warning"], json!(last_warning(&s)));
assert_eq!(flat_pieces(&s).len(), 2);
}
+3 -2
View File
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
.at("SURFACE", "CREATE")
.icon("surface_sweep")
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); name?, body_name? (a surface body, no caps)"),
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
.at("SURFACE", "CREATE")
.icon("surface_loft")
@@ -192,7 +192,8 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
if path.is_empty() || path.len() > 1000 {
return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
}
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path })
let (orientation, twist, scale) = super::features::sweep_options(s, p, cmd)?;
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale })
}
fn surface_loft(s: &mut Session, p: &Value) -> Result<Value> {
@@ -0,0 +1,163 @@
//! Sweep options through the commands: orientation, twist and end scale on solid.sweep and
//! surface.sweep, checked against analytic values; naming, saving and hostile values.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::f64::consts::PI;
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// A circle `C` (radius 10, curve `c`), a square `Q` (side 10, curves `a`…`d`) on XY, and a
/// path `P`: a line 40 up the Z axis (`up`).
fn setup() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "C"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "c"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Q"}));
run(&mut s, "sketch.line", json!({"points": [[-5, -5], [5, -5], [5, 5], [-5, 5]], "closed": true, "ids": ["a", "b", "c", "d"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "P"}));
// The XZ sketch's y runs down world Z.
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -40]], "ids": ["up"]}));
run(&mut s, "sketch.finish", json!({}));
s
}
fn measure(s: &Session, body: &str) -> solidcraft_kernel::BodyMeasure {
solidcraft_kernel::measure(&s.world_state().body(body).unwrap().body).unwrap()
}
fn sweep(s: &mut Session, sketch: &str, body: &str, extra: Value) -> Result<Value, String> {
let mut p = json!({"sketch": sketch, "path_sketch": "P", "path": ["up"], "operation": "new", "body_name": body});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("solid.sweep", &p).map_err(|e| e.to_string())
}
fn face_names(s: &mut Session, body: &str) -> Vec<String> {
let f = run(s, "model.faces", json!({ "body": body }));
let mut n: Vec<String> = f["faces"]
.as_array()
.into_iter()
.flatten()
.filter_map(|x| x["name"].as_str())
// Without the feature's own prefix (`F7:`), to compare features.
.map(|x| x.split_once(':').map_or(x, |(_, rest)| rest).to_string())
.collect();
n.sort();
n
}
/// A circle swept up a line to end scale 0.5 is a cone frustum; the surface sweep of its curve
/// is the frustum's lateral face. Saved and reopened, the design gives the same solid.
#[test]
fn scaled_sweep_is_a_frustum() {
let mut s = setup();
let (big, small, h) = (10.0, 5.0, 40.0);
let volume = PI * h * (big * big + big * small + small * small) / 3.0;
let lateral = PI * (big + small) * (h * h + (big - small) * (big - small)).sqrt();
sweep(&mut s, "C", "Frustum", json!({"scale": 0.5})).unwrap();
let m = measure(&s, "Frustum");
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
assert!((m.area - PI * (big * big + small * small) - lateral).abs() < 2e-3 * lateral, "area {}", m.area);
run(&mut s, "surface.sweep", json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"], "scale": "1/2", "body_name": "Cone"}));
let a = measure(&s, "Cone").area;
assert!((a - lateral).abs() < 2e-3 * lateral, "sheet {a} vs {lateral}");
// The options are saved with the feature and evaluate the same when reopened.
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let reopened = Session::new(doc);
let again = measure(&reopened, "Frustum");
assert!((again.volume - m.volume).abs() < 1e-9 * volume);
}
/// A twisted square keeps its faces' names (each wall after its profile curve), and the twist is
/// a parameter: editing it changes the solid, a plain sweep has the same names.
#[test]
fn twisted_sweep_faces_follow_the_profile_curves() {
let mut s = setup();
sweep(&mut s, "Q", "Plain", json!({})).unwrap();
sweep(&mut s, "Q", "Twisted", json!({"twist": "90 deg", "name": "Twist"})).unwrap();
let (plain, twisted) = (face_names(&mut s, "Plain"), face_names(&mut s, "Twisted"));
assert_eq!(plain.len(), 6, "{plain:?}");
assert_eq!(plain, twisted);
for curve in ["a", "b", "c", "d"] {
assert!(twisted.iter().any(|n| n.ends_with(&format!(":{curve}"))), "{curve} in {twisted:?}");
}
// The end cap turns with the twist: at 90 deg a square's corners are back where they were,
// at 45 deg its top reaches out to the corner radius along the axes.
let reach = |s: &Session| {
let mesh = s.world_state().body("Twisted").unwrap().body.tessellate(0.01).unwrap();
mesh.positions.iter().filter(|p| (p.z - 40.0).abs() < 1e-6).map(|p| p.x).fold(f64::MIN, f64::max)
};
assert!((reach(&s) - 5.0).abs() < 1e-3, "{}", reach(&s));
run(&mut s, "timeline.edit", json!({"feature": "Twist", "set": {"twist": "45 deg"}}));
assert!((reach(&s) - 50f64.sqrt()).abs() < 1e-2, "{}", reach(&s));
let v = measure(&s, "Twisted").volume;
assert!((v - 4000.0).abs() < 0.01 * 4000.0, "{v}");
}
/// Parallel along a straight path leaning out of the profile's plane gives a sheared prism (the
/// volume of the upright one); so does a direction, along a straight path.
#[test]
fn orientations() {
let mut s = setup();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Lean"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, -40]], "ids": ["lean"]}));
run(&mut s, "sketch.finish", json!({}));
let lean = |s: &mut Session, body: &str, o: Value| {
s.execute(
"solid.sweep",
&json!({"sketch": "Q", "path_sketch": "Lean", "path": ["lean"], "operation": "new", "body_name": body, "orientation": o}),
)
};
lean(&mut s, "Sheared", json!("parallel")).unwrap();
let m = measure(&s, "Sheared");
assert!((m.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", m.volume);
// Its ends stay level: flat at z = 0 and z = 40.
assert!(m.bbox.min.z.abs() < 1e-6 && (m.bbox.max.z - 40.0).abs() < 1e-6, "{:?}", m.bbox);
// Along a straight path every frame is the first: a direction changes nothing, the profile
// keeps its relation to the path as in a plain sweep.
lean(&mut s, "Directed", json!([0, 1, 0])).unwrap();
let d = measure(&s, "Directed");
assert!((d.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", d.volume);
assert!(d.bbox.max.y <= 5.0 + 1e-6 && d.bbox.min.y >= -5.0 - 1e-6);
}
/// Hostile options are refused with an error and leave the design as it was.
#[test]
fn hostile_sweep_options_are_refused() {
let mut s = setup();
let before = s.doc.features.len();
for bad in [
json!({"twist": "NaN"}),
json!({"twist": 1e300}),
json!({"twist": "1e9 deg"}),
json!({"twist": "1441 deg"}),
json!({"twist": "nope"}),
json!({"twist": [1, 2]}),
json!({"scale": 0}),
json!({"scale": -1}),
json!({"scale": "0"}),
json!({"scale": "1e9"}),
json!({"scale": "1/0"}),
json!({"orientation": "sideways"}),
json!({"orientation": [0, 0, 0]}),
json!({"orientation": [1e308, 0, 0]}),
json!({"orientation": {}}),
json!({"orientation": 7}),
// Along the path itself: no turn about the path is defined.
json!({"orientation": [0, 0, 1]}),
] {
assert!(sweep(&mut s, "Q", "Bad", bad.clone()).is_err(), "{bad}");
let mut p = json!({"sketch": "Q", "curves": ["a", "b"], "path_sketch": "P", "path": ["up"]});
p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
assert!(s.execute("surface.sweep", &p).is_err(), "surface {bad}");
assert_eq!(s.doc.features.len(), before, "{bad}");
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ use K::*;
/// The parameters each command takes in world coordinates.
fn table(id: &str) -> &'static [(&'static str, K)] {
match id {
"solid.extrude" => &[("face", Pick), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)],
"solid.extrude" => &[("face", Pick), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)],
"solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)],
"solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)],
"solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)],
@@ -390,7 +390,7 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
command_frames.sketch = frame;
}
let mut m = Mapper { s, f: command_frames, first: None, changed: false, here: false };
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
let body = obj.get("body").or_else(|| obj.get("face_body")).and_then(Value::as_str).map(str::to_string);
let mut out: Map<String, Value> = obj.clone();
// Picks first: their frame turns the directions.
for pass in [true, false] {
+5
View File
@@ -219,6 +219,7 @@ pub struct Session {
pub pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
world_cache: std::sync::Mutex<Option<WorldCache>>,
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
removed_later_cache: std::sync::Mutex<Option<occurrences::RemovedLaterCache>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solidcraft_doc::Feature>,
/// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden).
@@ -323,6 +324,7 @@ impl Session {
pending_moves: Default::default(),
world_cache: Default::default(),
occurrence_visibility_cache: Default::default(),
removed_later_cache: Default::default(),
clipboard: Vec::new(),
section: None,
analysis: None,
@@ -788,6 +790,7 @@ impl Session {
pending_moves: self.pending_moves.clone(),
world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())),
occurrence_visibility_cache: Default::default(),
removed_later_cache: Default::default(),
clipboard: self.clipboard.clone(),
section: None,
analysis: None,
@@ -946,6 +949,8 @@ mod profile_curves_tests;
#[cfg(test)]
mod profile_refs_tests;
#[cfg(test)]
mod sketch_edit_removed_tests;
#[cfg(test)]
mod split_face_tests;
#[cfg(test)]
mod tests;
+70 -2
View File
@@ -1,6 +1,6 @@
//! Exact placement identity and session view/authoring state.
use crate::{EngineError, Result, Sel, Session};
use solidcraft_doc::{IDENTITY, Mat, mat_mul};
use solidcraft_doc::{FeatureKind, IDENTITY, Mat, PlaneRef, mat_mul};
#[derive(Clone, Debug)]
pub struct OccurrenceBody {
@@ -31,6 +31,14 @@ pub(crate) struct VisibilityCache {
bodies: std::collections::BTreeSet<String>,
}
/// [`Session::body_removed_later`] for one revision, sketch and history marker.
pub(crate) struct RemovedLaterCache {
revision: u64,
sketch: u64,
marker: usize,
bodies: std::collections::BTreeSet<String>,
}
impl Session {
/// Document with uncaptured occurrence positions applied, without changing saved geometry.
pub fn placed_document(&self) -> std::borrow::Cow<'_, solidcraft_doc::Document> {
@@ -183,7 +191,7 @@ impl Session {
}
pub fn body_visible(&self, name: &str) -> bool {
if self.visibility.hidden_bodies.iter().any(|n| n == name) {
if self.visibility.hidden_bodies.iter().any(|n| n == name) || self.body_removed_later(name) {
return false;
}
if self.visibility.hidden_occurrences.is_empty() {
@@ -205,6 +213,66 @@ impl Session {
self.doc.body_frames(&self.model.state()).iter().find(|(n, _, _, _)| n == name).is_none_or(|(_, _, path, _)| self.occurrence_visible(path))
}
/// Is this body deleted by a feature the history marker has rolled off, while a sketch is
/// being edited? Edit Sketch rolls the timeline back to the sketch, so a body made before the
/// sketch and removed after it is part of the shown model again; it stays out of the view,
/// as it does once the timeline runs to the end. Nothing else changes: the model, its state
/// and every command still see the history as it was rolled to. A body the sketch is
/// built on (the face it sits on, an edge it projects) stays.
pub fn body_removed_later(&self, name: &str) -> bool {
let (Some(sketch), Some(marker)) = (self.active_sketch, self.doc.marker) else { return false };
if marker >= self.doc.features.len() {
return false;
}
let Ok(mut cache) = self.removed_later_cache.lock() else { return false };
if cache.as_ref().is_none_or(|c| c.revision != self.revision || c.sketch != sketch || c.marker != marker) {
*cache = Some(RemovedLaterCache { revision: self.revision, sketch, marker, bodies: self.removed_later_bodies(sketch, marker) });
}
cache.as_ref().is_some_and(|c| c.bodies.contains(name))
}
/// The names (as shown in the world) of the bodies that features from `marker` on remove,
/// leaving out the bodies `sketch` is built on.
fn removed_later_bodies(&self, sketch: u64, marker: usize) -> std::collections::BTreeSet<String> {
let none = std::collections::BTreeSet::new();
let Some(FeatureKind::Sketch { plane, sketch: made }) = self.doc.feature(sketch).map(|f| &f.kind) else { return none };
let mut kept: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
let mut plane = plane;
loop {
match plane {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base,
// A face attachment that names no exact source: which body it sits on is unknown,
// so none is taken from the view.
PlaneRef::Face { anchor: None, .. } => return none,
PlaneRef::Face { anchor: Some(anchor), .. } => {
kept.insert(anchor.target.body.as_str());
break;
}
_ => break,
}
}
for link in &made.links {
kept.extend(link.source.bodies());
}
let shown = self.model.state();
let mut removed = std::collections::BTreeSet::new();
for feature in self.doc.features.iter().skip(marker).filter(|f| !f.suppressed) {
if let FeatureKind::Remove { bodies } = &feature.kind {
removed.extend(bodies.iter().filter(|b| !kept.contains(b.as_str()) && shown.body(b).is_some()).cloned());
}
}
if removed.is_empty() || self.doc.occurrences.is_empty() {
return removed;
}
// A component's bodies are shown once per placement, each under its own name.
self.doc
.body_frames_with_source(&shown)
.into_iter()
.filter(|(_, source, _, _, _)| removed.contains(source))
.map(|(world, ..)| world)
.collect()
}
pub(crate) fn prune_occurrence_state(&mut self) {
self.pending_moves.retain(|id, _| self.doc.occurrences.iter().any(|o| o.id == *id));
if self.active_component != 0 && !self.doc.components.iter().any(|c| c.id == self.active_component) {
@@ -0,0 +1,161 @@
//! Edit Sketch rolls the timeline back to the sketch. A body made before the sketch and deleted
//! after it is then part of the model again, but it must not be shown while the sketch is edited
//! (#1167): the history is rolled back, the view of it is not. A body the sketch is built on stays.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_sketch::LinkSource;
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 10 x 10 x 5 block `name`, drawn on the XY plane at `x`.
fn block(s: &mut Session, name: &str, x: f64) {
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name}Sketch")}));
run(s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 10.0, 10]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": format!("{name}Sketch"), "distance": 5, "body_name": name}));
}
/// Whether `inspect` lists the body, and what it says of it.
fn inspected(s: &mut Session, body: &str) -> Value {
let r = run(s, "document.inspect", json!({"measure": false, "sketches": false}));
r["bodies"].as_array().and_then(|bodies| bodies.iter().find(|b| b["name"] == body)).cloned().unwrap_or(Value::Null)
}
/// `Kept` and `Gone` (removed after the sketch `Edited`), then Edit Sketch on `Edited`.
fn edit_with_a_later_removal(s: &mut Session) {
block(s, "Kept", 0.0);
block(s, "Gone", 20.0);
run(s, "sketch.create", json!({"plane": "XY", "offset": "30 mm", "name": "Edited"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [5, 5]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.remove", json!({"bodies": ["Gone"]}));
run(s, "timeline.roll_to", json!({"feature": "Edited"}));
run(s, "sketch.edit", json!({"sketch": "Edited"}));
}
#[test]
fn a_body_deleted_later_is_not_shown_while_a_sketch_is_edited() {
let mut s = Session::default();
edit_with_a_later_removal(&mut s);
// The rolled-back history still has the body: commands and measurements see it.
assert!(s.model.state().body("Gone").is_some());
assert!(!s.body_visible("Gone"));
assert!(s.body_removed_later("Gone"));
let gone = inspected(&mut s, "Gone");
assert_eq!(gone["visible"], json!(false), "{gone}");
assert_eq!(gone["removed_later"], json!(true), "{gone}");
// What is not deleted is shown as before.
assert!(s.body_visible("Kept"));
let kept = inspected(&mut s, "Kept");
assert_eq!(kept["visible"], json!(true), "{kept}");
assert!(kept.get("removed_later").is_none(), "{kept}");
}
#[test]
fn the_body_is_shown_again_when_the_sketch_is_finished() {
let mut s = Session::default();
edit_with_a_later_removal(&mut s);
run(&mut s, "sketch.finish", json!({}));
// Rolled back and not editing: the history as it was, which has the body.
assert!(s.body_visible("Gone"));
assert!(inspected(&mut s, "Gone").get("removed_later").is_none());
// Rolled forward, it is gone.
run(&mut s, "timeline.roll_to", json!({}));
assert!(s.model.state().body("Gone").is_none());
}
#[test]
fn rolling_back_by_hand_shows_the_history() {
let mut s = Session::default();
block(&mut s, "Gone", 0.0);
run(&mut s, "solid.remove", json!({"bodies": ["Gone"]}));
run(&mut s, "timeline.roll_to", json!({"position": 2}));
assert!(s.body_visible("Gone"));
}
#[test]
fn a_suppressed_removal_does_not_hide_the_body() {
let mut s = Session::default();
edit_with_a_later_removal(&mut s);
run(&mut s, "sketch.finish", json!({}));
let removal = s.doc.features.iter().find(|f| f.kind.type_name() == "RemoveFeature").map(|f| f.id).unwrap_or(0);
run(&mut s, "timeline.suppress", json!({"feature": removal, "suppressed": true}));
run(&mut s, "sketch.edit", json!({"sketch": "Edited"}));
assert!(s.body_visible("Gone"));
}
#[test]
fn a_body_the_sketch_projects_stays_shown() {
let mut s = Session::default();
block(&mut s, "Gone", 0.0);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "5 mm", "name": "Edited"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 5, 5], "body": "Gone"}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.remove", json!({"bodies": ["Gone"]}));
run(&mut s, "timeline.roll_to", json!({"feature": "Edited"}));
run(&mut s, "sketch.edit", json!({"sketch": "Edited"}));
assert!(s.body_visible("Gone"));
}
#[test]
fn a_body_the_sketch_sits_on_stays_shown() {
let mut s = Session::default();
block(&mut s, "Gone", 0.0);
let top = {
let world = s.world_state();
let face = world
.body("Gone")
.and_then(|body| body.body.faces(0.01).ok())
.and_then(|faces| faces.into_iter().find(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::Z) > 0.999)))
.unwrap();
json!({"face": [face.centroid.x, face.centroid.y, face.centroid.z], "body": "Gone", "index": face.index})
};
run(&mut s, "sketch.create", json!({"plane": top, "name": "Edited"}));
let anchored = match s.doc.find_feature("Edited").map(|f| &f.kind) {
Some(solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor, .. }, .. }) => anchor.is_some(),
other => panic!("not attached to a face: {other:?}"),
};
assert!(anchored, "the face attachment names its body");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.remove", json!({"bodies": ["Gone"]}));
run(&mut s, "timeline.roll_to", json!({"feature": "Edited"}));
run(&mut s, "sketch.edit", json!({"sketch": "Edited"}));
assert!(s.body_visible("Gone"));
}
#[test]
fn every_placement_of_a_component_body_is_left_out() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name": "Block"}))["component"].as_u64().unwrap_or(0);
block(&mut s, "Gone", 0.0);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "30 mm", "name": "Edited"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [5, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.remove", json!({"bodies": ["Gone"]}));
run(&mut s, "occurrence.copy", json!({"component": component, "translate": [50, 0, 0]}));
run(&mut s, "timeline.roll_to", json!({"feature": "Edited"}));
run(&mut s, "sketch.edit", json!({"sketch": "Edited"}));
let world = s.world_state();
assert_eq!(world.bodies.len(), 2, "one body, placed twice");
for body in &world.bodies {
assert!(!s.body_visible(&body.name), "{} is deleted later", body.name);
}
}
#[test]
fn a_link_reads_the_bodies_of_its_sources() {
let at = Vec3::ZERO;
let edge = LinkSource::Edge { body: "A".into(), at };
let face = LinkSource::Face { body: "B".into(), at };
assert_eq!(edge.bodies(), ["A"]);
assert_eq!(LinkSource::Intersection { a: Box::new(edge), b: Box::new(face) }.bodies(), ["A", "B"]);
assert!(LinkSource::Origin.bodies().is_empty());
}
+3
View File
@@ -769,6 +769,9 @@ fn hostile_params_never_panic() {
"filter",
"kind",
"prefix",
"orientation",
"twist",
"scale",
];
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
let ids: Vec<&str> = command_specs()
+196 -9
View File
@@ -7,18 +7,50 @@
//! what the entities refer to and what release 2000 needs to read them: the version, the
//! handle seed, the units and the extents; the linetype, layer and block-record tables; the
//! model and paper space blocks; handles, owners and the subclass markers of each entity; the
//! root dictionary. Group codes follow Autodesk's public DXF reference.
//! root dictionary. Layers can be dashed and a drawing can carry TEXT (for annotations). Group
//! codes follow Autodesk's public DXF reference.
use std::f64::consts::TAU;
use std::fmt::Write as _;
use solidcraft_geom::{Loop2, Seg2, Vec2};
/// A layer: its name and AutoCAD colour index (1 red, 3 green, 5 blue, 7 white or black).
/// A layer's linetype, defined in the file's LTYPE table so a viewer needs nothing else.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DxfLinetype {
#[default]
Continuous,
/// AutoCAD's DASHED: a dash of 12.7 mm and a gap of 6.35 mm.
Dashed,
}
impl DxfLinetype {
fn name(self) -> &'static str {
match self {
DxfLinetype::Continuous => "Continuous",
DxfLinetype::Dashed => "DASHED",
}
}
}
/// A layer: its name, AutoCAD colour index (1 red, 3 green, 5 blue, 7 white or black) and linetype.
#[derive(Clone, Debug, PartialEq)]
pub struct DxfLayer {
pub name: String,
pub color: i16,
pub linetype: DxfLinetype,
}
impl DxfLayer {
/// A continuous layer.
pub fn new(name: impl Into<String>, color: i16) -> DxfLayer {
DxfLayer { name: name.into(), color, linetype: DxfLinetype::Continuous }
}
/// The same layer with a dashed line.
pub fn dashed(self) -> DxfLayer {
DxfLayer { linetype: DxfLinetype::Dashed, ..self }
}
}
/// The geometry of one entity (millimetres).
@@ -54,6 +86,15 @@ pub enum DxfShape {
ctrl: Vec<Vec2>,
weights: Option<Vec<f64>>,
},
/// One line of text, centred on `at` (its baseline) and turned `angle_deg` counter-clockwise.
/// `%%d`, `%%p` and `%%c` are AutoCAD's degree, plus-minus and diameter signs; other letters
/// outside ASCII are written as `\U+XXXX`.
Text {
at: Vec2,
height: f64,
angle_deg: f64,
text: String,
},
}
/// An entity on a layer.
@@ -180,6 +221,13 @@ impl DxfShape {
}
// A spline lies within the hull of its control points.
DxfShape::Spline { ctrl, .. } => out.extend(ctrl.iter().copied()),
// Where a viewer puts the letters depends on its font: 0.8 heights for each.
DxfShape::Text { at, height, angle_deg, text } => {
let (along, up) = (Vec2::from_angle(angle_deg.to_radians()), Vec2::from_angle(angle_deg.to_radians()).perp());
let half = 0.4 * height * text.chars().count() as f64;
out.extend([-half, half].map(|s| *at + along * s));
out.extend([-half, half].map(|s| *at + along * s + up * *height));
}
}
}
}
@@ -256,6 +304,19 @@ impl Out {
}
}
/// A text on one line, as a TEXT entity holds it: at most 250 characters, control characters as
/// spaces, letters outside ASCII as `\U+XXXX` (`?` beyond the first 65536).
fn text_value(s: &str) -> String {
s.chars()
.take(250)
.map(|c| match c {
c if c.is_ascii_control() => " ".to_string(),
c if c.is_ascii() => c.to_string(),
c => u16::try_from(u32::from(c)).map_or_else(|_| "?".to_string(), |u| format!("\\U+{u:04X}")),
})
.collect()
}
/// A DXF drawing (AutoCAD 2000, millimetres) of `entities`. `layers` are the layers they are on
/// (layer `0` is always there).
pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String {
@@ -271,7 +332,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
})
.unwrap_or((Vec2::ZERO, Vec2::ZERO));
let mut layer_list = vec![DxfLayer { name: "0".into(), color: 7 }];
let mut layer_list = vec![DxfLayer::new("0", 7)];
for l in layers {
if !layer_list.iter().any(|x| x.name == l.name) {
layer_list.push(l.clone());
@@ -285,15 +346,27 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
o.code(0, "SECTION");
o.code(2, "TABLES");
let t = o.table("LTYPE", 3);
for (name, description) in [("ByBlock", ""), ("ByLayer", ""), ("Continuous", "Solid line")] {
// The three every drawing has, and DASHED, which the dashed layers name: dash and gap lengths
// (a gap is negative) after the pattern's total.
let linetypes: [(&str, &str, &[f64]); 4] = [
("ByBlock", "", &[]),
("ByLayer", "", &[]),
("Continuous", "Solid line", &[]),
("DASHED", "Dashed __ __ __ __ __ __ __ __ __ __", &[12.7, -6.35]),
];
let t = o.table("LTYPE", linetypes.len());
for (name, description, pattern) in linetypes {
o.record("LTYPE", "AcDbLinetypeTableRecord", &t);
o.code(2, name);
o.code(70, 0);
o.code(3, description);
o.code(72, 65);
o.code(73, 0);
o.num(40, 0.0);
o.code(73, pattern.len());
o.num(40, pattern.iter().map(|d| d.abs()).sum());
for d in pattern {
o.num(49, *d);
o.code(74, 0);
}
}
o.code(0, "ENDTAB");
let t = o.table("LAYER", layer_list.len());
@@ -302,9 +375,22 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
o.code(2, &l.name);
o.code(70, 0);
o.code(62, l.color);
o.code(6, "Continuous");
o.code(6, l.linetype.name());
}
o.code(0, "ENDTAB");
// The one text style, which a TEXT entity uses when it names none.
let t = o.table("STYLE", 1);
o.record("STYLE", "AcDbTextStyleTableRecord", &t);
o.code(2, "Standard");
o.code(70, 0);
o.num(40, 0.0);
o.num(41, 1.0);
o.num(50, 0.0);
o.code(71, 0);
o.num(42, 2.5);
o.code(3, "txt");
o.code(4, "");
o.code(0, "ENDTAB");
let t = o.table("BLOCK_RECORD", 2);
for (h, name) in [(&model, "*Model_Space"), (&paper, "*Paper_Space")] {
o.code(0, "BLOCK_RECORD");
@@ -332,6 +418,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
DxfShape::Arc { .. } => "ARC",
DxfShape::Ellipse { .. } => "ELLIPSE",
DxfShape::Spline { .. } => "SPLINE",
DxfShape::Text { .. } => "TEXT",
};
let h = o.handle();
o.code(0, kind);
@@ -391,6 +478,19 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
o.point(10, *p);
}
}
DxfShape::Text { at, height, angle_deg, text } => {
o.code(100, "AcDbText");
o.point(10, *at);
o.num(40, *height);
o.code(1, text_value(text));
o.num(50, *angle_deg);
// Centred (72 = 1): a reader that places the text by the second alignment point
// and one that takes the first both put its middle on `at`.
o.code(72, 1);
o.point(11, *at);
o.code(100, "AcDbText");
o.code(73, 0);
}
}
}
o.code(0, "ENDSEC");
@@ -561,7 +661,7 @@ mod tests {
DxfEntity { layer: "OUTLINE".into(), shape: DxfShape::Circle { c: Vec2::new(5.0, 5.0), r: 4.0 } },
DxfEntity { layer: "CUT".into(), shape: DxfShape::Line(Vec2::ZERO, Vec2::X) },
];
let dxf = write_dxf_drawing(&[DxfLayer { name: "OUTLINE".into(), color: 7 }, DxfLayer { name: "CUT".into(), color: 1 }], &ents);
let dxf = write_dxf_drawing(&[DxfLayer::new("OUTLINE", 7), DxfLayer::new("CUT", 1)], &ents);
let p = pairs(&dxf);
let after = |var: &str| &p[p.iter().position(|(c, v)| (*c, v.as_str()) == (9, var)).unwrap() + 1..];
assert_eq!(after("$ACADVER")[0], (1, "AC1015".to_string()));
@@ -667,4 +767,91 @@ mod tests {
let last = |code: i32| e.1.iter().rposition(|(c, _)| *c == code).unwrap();
assert!(last(40) < at(41) && last(41) < at(10), "{:?}", e.1);
}
/// The records of a symbol table, each as its group codes after the type.
fn table_records(p: &[(i32, String)], table: &str) -> Vec<Vec<(i32, String)>> {
let start = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, table)).unwrap();
let mut out: Vec<Vec<(i32, String)>> = Vec::new();
for (c, v) in &p[start + 1..] {
match (*c, v.as_str()) {
(0, "ENDTAB") => break,
(0, _) => out.push(Vec::new()),
_ => {
if let Some(record) = out.last_mut() {
record.push((*c, v.clone()));
}
}
}
}
out
}
fn field(record: &[(i32, String)], code: i32) -> &str {
record.iter().find(|(c, _)| *c == code).map_or("", |(_, v)| v.as_str())
}
#[test]
fn a_dashed_layer_names_its_linetype_and_the_table_defines_it() {
let dxf = write_dxf_drawing(&[DxfLayer::new("BEND", 1).dashed(), DxfLayer::new("CUT", 4)], &[]);
let p = pairs(&dxf);
// The linetypes of every drawing, then DASHED: a dash of 12.7 mm and a gap of 6.35 mm, 19.05 mm in all.
let types = table_records(&p, "LTYPE");
assert_eq!(types.iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["ByBlock", "ByLayer", "Continuous", "DASHED"]);
let dashed = &types[3];
let numbers = |code: i32| dashed.iter().filter(|(c, _)| *c == code).map(|(_, v)| v.parse::<f64>().unwrap()).collect::<Vec<_>>();
assert!(near(&numbers(49), &[12.7, -6.35]) && near(&numbers(40), &[19.05]) && near(&numbers(73), &[2.0]) && near(&numbers(72), &[65.0]));
// The table says how many records it holds.
let at = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, "LTYPE")).unwrap();
assert_eq!(p[at..].iter().find(|(c, _)| *c == 70).map(|(_, v)| v.as_str()), Some("4"));
// Each layer names its linetype.
let layers = table_records(&p, "LAYER");
assert_eq!(
layers.iter().map(|r| (field(r, 2), field(r, 62), field(r, 6))).collect::<Vec<_>>(),
[("0", "7", "Continuous"), ("BEND", "1", "DASHED"), ("CUT", "4", "Continuous")]
);
}
#[test]
fn text_is_centred_on_its_point_and_the_reader_gets_it_back() {
let at = Vec2::new(30.0, 12.0);
let note = DxfShape::Text { at, height: 3.0, angle_deg: 90.0, text: "AB%%d\nC\u{b0}".into() };
let dxf = write_dxf_drawing(&[DxfLayer::new("NOTES", 8)], &[DxfEntity { layer: "NOTES".into(), shape: note }]);
let file = entities(&dxf);
let [text] = file.as_slice() else { panic!("{file:?}") };
assert_eq!(text.0, "TEXT");
// One line (the newline is a space), the sign outside ASCII a code of its own, `%%d` AutoCAD's degree sign.
assert_eq!(text.1.iter().find(|(c, _)| *c == 1).map(|(_, v)| v.as_str()), Some("AB%%d C\\U+00B0"));
// Centred (72 = 1) on the second alignment point, which is the first as well; 3 mm high, turned 90°.
assert!(
near(&values(text, 11), &[30.0])
&& near(&values(text, 21), &[12.0])
&& near(&values(text, 10), &[30.0])
&& near(&values(text, 20), &[12.0])
);
assert!(
near(&values(text, 72), &[1.0]) && near(&values(text, 73), &[0.0]) && near(&values(text, 40), &[3.0]) && near(&values(text, 50), &[90.0])
);
// Its style is in the STYLE table, and the layer it is on in the LAYER table.
let p = pairs(&dxf);
assert_eq!(table_records(&p, "STYLE").iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["Standard"]);
assert!(text.1.contains(&(8, "NOTES".to_string())));
// The reader finds the text where it was put, as high and as turned.
let back = read_dxf(&dxf).unwrap();
assert!(
matches!(&back[..], [Geom2::Text { at: found, height, angle, .. }] if found.dist(at) < 1e-9 && (height - 3.0).abs() < 1e-9 && (angle - FRAC_PI_2).abs() < 1e-9),
"{back:?}"
);
}
#[test]
fn the_extents_hold_the_text() {
// Five letters of 3 mm, turned to read upwards: 0.8 × 3 × 5 = 12 mm long around y = 12, and
// 3 mm to the left of the baseline at x = 30.
let shape = DxfShape::Text { at: Vec2::new(30.0, 12.0), height: 3.0, angle_deg: 90.0, text: "ABCDE".into() };
let dxf = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape }]);
let p = pairs(&dxf);
let after = |var: &str| &p[p.iter().position(|(c, v)| (*c, v.as_str()) == (9, var)).unwrap() + 1..];
assert_eq!(&after("$EXTMIN")[..2], &[(10, "27.0".to_string()), (20, "6.0".to_string())]);
assert_eq!(&after("$EXTMAX")[..2], &[(10, "30.0".to_string()), (20, "18.0".to_string())]);
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub mod urdf;
pub mod vfs;
pub use dxf::{read_dxf, write_dxf};
pub use dxf_out::{DxfEntity, DxfLayer, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_path};
pub use safe_write::{backup_path, write_atomic};
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
+33 -9
View File
@@ -1786,6 +1786,38 @@ pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
}
/// The path as a polyline: its first point, then each segment's points (arcs within 0.01 mm of
/// their chords).
pub(crate) fn path_polyline(path: &[PathSeg]) -> Result<Vec<Vec3>> {
let first = match path.first() {
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
};
let mut pts = vec![first];
for seg in path {
pts.extend(seg.points_after_start(1e-2));
}
Ok(pts)
}
/// [`sweep`] with sweep options. The default options are exactly [`sweep`]; any other
/// orientation, a twist or an end scale follow the path as a polyline through stations (see
/// [`crate::sweep_path_with`]).
pub fn sweep_with(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
if ctl.is_default() {
return sweep(plane, region, path);
}
ctl.check()?;
region_ok(region)?;
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
if path.iter().any(|s| s.tangents().is_none()) {
return Err(KernelError::Invalid("a path segment has no direction".into()));
}
crate::sweep_path::sweep_path_with(plane, region, &path_polyline(path)?, ctl)
}
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
/// is swept as a polyline, mitred at the corners.
@@ -1800,15 +1832,7 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
if corner {
let first = match path.first() {
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
};
let mut pts = vec![first];
for seg in path {
pts.extend(seg.points_after_start(1e-2));
}
return crate::sweep_path::sweep_path(plane, region, &pts);
return crate::sweep_path::sweep_path(plane, region, &path_polyline(path)?);
}
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let profile = region_wires(plane, &oriented(region)?)?;
+4 -3
View File
@@ -118,7 +118,8 @@ pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fill
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use build::{
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep, torus,
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
sweep_with, torus,
};
pub use delete_face::delete_faces;
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
@@ -148,9 +149,9 @@ pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface};
pub use surface_untrim::{UntrimHoles, untrim};
pub use surfaces::{
copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet,
sweep_sheet_points, thicken, trim, unstitch,
sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch,
};
pub use sweep_path::sweep_path;
pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
+22
View File
@@ -356,6 +356,11 @@ pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Optio
// whether the pieces' boundaries come out consistent: try a few tolerances, and take the
// first whose result is watertight and agrees with the complementary boolean (A − B and
// A ∩ B add up to A; A ∪ B is A plus B less A ∩ B).
// The BSP takes each list's first polygon as its splitting plane. Facets in sweep order
// (a thread's groove, turn after turn) make every splitter cut nothing, so the tree is as
// deep as the list is long: quadratic time and memory, and a long thread ran out of depth
// (#1250). The same lists in a fixed shuffled order give a shallow tree.
let (pa, pb) = (shuffled(pa), shuffled(pb));
let (va, vb) = (polys_volume(&pa), polys_volume(&pb));
let mut last = KernelError::Failed("faceted boolean".into());
for rel in [1e-8, 1e-7, 3e-8, 1e-9, 3e-7] {
@@ -383,6 +388,23 @@ pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Optio
Err(last)
}
/// `polys` in a fixed pseudo-random order (the same every run, so results are reproducible).
fn shuffled(mut polys: Vec<Poly>) -> Vec<Poly> {
let mut state = 0x9E37_79B9_7F4A_7C15u64 ^ polys.len() as u64;
let mut next = || {
// SplitMix64.
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
};
for i in (1..polys.len()).rev() {
polys.swap(i, (next() % (i as u64 + 1)) as usize);
}
polys
}
/// Boolean of two all-planar bodies.
pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
+26 -11
View File
@@ -211,16 +211,8 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
}
let ends: Vec<(Vec3, Vec3)> =
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
let first = match path.first() {
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
};
if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) {
let mut pts = vec![first];
for seg in path {
pts.extend(seg.points_after_start(1e-2));
}
return sweep_sheet_points(plane, chains, &pts);
return sweep_sheet_points(plane, chains, &crate::build::path_polyline(path)?);
}
let mut sides = Vec::new();
let mut offset = 0;
@@ -254,16 +246,39 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
/// B-spline wall per segment where the path has no corners, else ruled walls between the path's
/// points, mitred at the corners (see [`sweep_sheet`]).
pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Body> {
sweep_sheet_points_with(plane, chains, path, &crate::SweepControl::default())
}
/// [`sweep_sheet`] with sweep options. The default options are exactly [`sweep_sheet`]; any
/// other orientation, a twist or an end scale follow the path as a polyline through stations
/// (see [`sweep_sheet_points_with`]).
pub fn sweep_sheet_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
if ctl.is_default() {
return sweep_sheet(plane, chains, path);
}
ctl.check()?;
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
if path.iter().any(|s| s.tangents().is_none()) {
return Err(KernelError::Invalid("a path segment has no direction".into()));
}
sweep_sheet_points_with(plane, chains, &crate::build::path_polyline(path)?, ctl)
}
/// [`sweep_sheet_points`] with sweep options: orientation, twist and end scale.
pub fn sweep_sheet_points_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &crate::SweepControl) -> Result<Body> {
ctl.check()?;
if !crate::build::frame_ok(plane) {
return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into()));
}
if chains.is_empty() || chains.len() > MAX_CHAINS {
return Err(KernelError::Invalid("pick the curves or profiles to sweep".into()));
}
let ruled = sheet_body(crate::sweep_path::ruled_sheet(plane, chains, path)?)?;
let ruled = sheet_body(crate::sweep_path::ruled_sheet(plane, chains, path, ctl)?)?;
// Smooth walls where the path has no corners, kept when their area agrees with the ruled
// walls' (a fit that went wrong falls back to those).
if let Ok(Some(sides)) = crate::sweep_path::smooth_sheet(plane, chains, path)
if let Ok(Some(sides)) = crate::sweep_path::smooth_sheet(plane, chains, path, ctl)
&& let Ok(smooth) = sheet_body(sides)
&& let (Ok(a), Ok(b)) = (crate::measure(&smooth), crate::measure(&ruled))
&& (a.area - b.area).abs() <= 0.02 * b.area.abs()
+387 -16
View File
@@ -1,7 +1,8 @@
//! Sweeps along 3D point paths (3D sketch curves: projected, intersection and included curves).
//! The profile follows the path with a rotation-minimising frame. Smooth paths get one B-spline
//! wall per profile segment; paths with sharp corners (or profiles with holes) get ruled walls
//! between stations, mitred at the corners.
//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale; see
//! [`SweepControl`]) reshape the stations; their default leaves them exactly as described.
use mt::builder;
use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
@@ -23,6 +24,83 @@ const MAX_RULED_STATIONS: usize = 2000;
const CORNER: f64 = 0.35;
/// Samples per profile segment of the smooth walls.
const SEG_SAMPLES: usize = 12;
/// Stations of a smooth sweep that twists or scales, at the least.
const MIN_CONTROLLED_STATIONS: usize = 16;
/// Stations of a smooth sweep that twists or scales, at the most.
const MAX_CONTROLLED_STATIONS: usize = 160;
/// Stations of a ruled sweep that twists or scales, at the least.
const MIN_CONTROLLED_RULED: usize = 64;
/// The most twist one station of a smooth sweep may carry (radians).
const SMOOTH_TWIST_STEP: f64 = std::f64::consts::PI / 18.0;
/// The most twist one station of a ruled sweep may carry (radians).
const RULED_TWIST_STEP: f64 = std::f64::consts::PI / 36.0;
/// How the profile turns as it follows a sweep path.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum SweepOrientation {
/// Square to the path, turned along it by a rotation-minimising frame.
#[default]
Perpendicular,
/// As drawn: the profile only moves along the path, never turning.
Parallel,
/// Square to the path, its turn about the path fixed by a direction: the direction's
/// projection onto the profile's plane stays where it was at the start.
Direction(Vec3),
}
/// Sweep options beyond following the path. `twist` turns the profile about the path by that
/// many radians over the path's length (right hand about the direction of travel, in proportion
/// to the length run); `scale` is the profile's size at the end, reached linearly along the
/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1)
/// is a plain sweep.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SweepControl {
pub orientation: SweepOrientation,
pub twist: f64,
pub scale: f64,
}
impl Default for SweepControl {
fn default() -> Self {
SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0 }
}
}
impl SweepControl {
/// The most twist accepted: four turns (radians).
pub const MAX_TWIST: f64 = 4.0 * std::f64::consts::TAU;
/// The smallest end scale accepted.
pub const MIN_SCALE: f64 = 0.01;
/// The largest end scale accepted.
pub const MAX_SCALE: f64 = 100.0;
/// A plain sweep: the options change nothing.
pub fn is_default(&self) -> bool {
*self == SweepControl::default()
}
/// Whether the profile changes along the path (it twists or scales).
fn varies(&self) -> bool {
self.twist != 0.0 || self.scale != 1.0
}
/// Refuses options out of range: a twist beyond [`Self::MAX_TWIST`], a scale outside
/// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length.
pub fn check(&self) -> Result<()> {
if !(self.twist.is_finite() && self.twist.abs() <= Self::MAX_TWIST) {
return Err(KernelError::Invalid("the sweep twist must be within four turns (1440 deg)".into()));
}
if !(self.scale.is_finite() && (Self::MIN_SCALE..=Self::MAX_SCALE).contains(&self.scale)) {
return Err(KernelError::Invalid("the sweep end scale must be between 0.01 and 100".into()));
}
if let SweepOrientation::Direction(d) = self.orientation
&& !(d.is_finite() && d.len() > 1e-9 && d.len() < 1e9)
{
return Err(KernelError::Invalid("the sweep orientation direction must be a finite, non-zero vector".into()));
}
Ok(())
}
}
/// A linear map of world vectors (columns) plus the station it is centred at.
#[derive(Clone, Copy)]
@@ -127,6 +205,124 @@ fn stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>]) -> R
Ok(out)
}
/// Frames square to the path whose turn about it keeps `d`'s projection: none where the path
/// runs along `d`.
fn frames_toward(tangents: &[Vec3], d: Vec3) -> Result<Vec<[Vec3; 3]>> {
let d = d.normalized().ok_or_else(|| KernelError::Invalid("the sweep orientation direction has no length".into()))?;
tangents
.iter()
.map(|t| {
let across = d - *t * t.dot(d);
// Within about 0.06 degrees of the direction the profile's turn is undefined.
if across.len() < 1e-3 {
return Err(KernelError::Invalid("the sweep path runs along the orientation direction".into()));
}
let r = across.normalized().ok_or_else(|| KernelError::Invalid("the sweep path runs along the orientation direction".into()))?;
Ok([*t, r, t.cross(r)])
})
.collect()
}
/// Stations shaped by sweep options: each maps a world vector at the first station to its image
/// at its own (twisted and scaled about the profile's normal through the first station, then
/// turned by the orientation's frames), optionally stretched across a corner (mitre). Twist and
/// scale grow with the length run along `pts`.
fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result<Vec<Station>> {
let bad = || KernelError::Invalid("sweep path".into());
let t0 = *tangents.first().ok_or_else(bad)?;
let f: Vec<[Vec3; 3]> = match ctl.orientation {
SweepOrientation::Perpendicular => frames(pts, tangents).ok_or_else(|| KernelError::Invalid("the sweep path doubles back".into()))?,
SweepOrientation::Parallel => vec![[Vec3::X, Vec3::Y, Vec3::Z]; pts.len()],
SweepOrientation::Direction(d) => frames_toward(tangents, d)?,
};
let f0 = *f.first().ok_or_else(bad)?;
// Twist and scale act about the profile's normal, pointing the way the path leaves it.
let n = plane.normal().normalized().ok_or_else(|| KernelError::Invalid("sweep frame".into()))?;
let axis = if n.dot(t0) < 0.0 { n * -1.0 } else { n };
// Kept as drawn, the profile flattens to nothing where the path runs along its plane: the
// path has to cross it at 5 degrees or more all the way.
if ctl.orientation == SweepOrientation::Parallel && tangents.iter().any(|t| t.dot(axis) < 5f64.to_radians().sin()) {
return Err(KernelError::Invalid(
"kept parallel, the profile would lie along the sweep path: the path must keep crossing the profile's plane (by 5 deg or more)".into(),
));
}
let mut run = Vec::with_capacity(pts.len());
let mut total = 0.0;
for (i, p) in pts.iter().enumerate() {
if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) {
total += prev.dist(*p);
}
run.push(total);
}
if !(total > 1e-12) {
return Err(KernelError::Invalid("the sweep path has no length".into()));
}
let mut out = Vec::with_capacity(f.len());
for (i, fi) in f.iter().enumerate() {
let u = run.get(i).copied().unwrap_or(total) / total;
let (sin, cos) = (ctl.twist * u).sin_cos();
let k = 1.0 + (ctl.scale - 1.0) * u;
let shape = |v: Vec3| {
let along = axis * v.dot(axis);
let r = (v - along) * k;
along + r * cos + axis.cross(r) * sin
};
let col = |e: Vec3| {
let v = shape(e);
fi[0] * f0[0].dot(v) + fi[1] * f0[1].dot(v) + fi[2] * f0[2].dot(v)
};
let mut m = [col(Vec3::X), col(Vec3::Y), col(Vec3::Z)];
if ctl.orientation != SweepOrientation::Parallel
&& let Some(Some((b, s))) = mitre.get(i)
{
for c in &mut m {
*c += *b * ((s - 1.0) * c.dot(*b));
}
}
out.push(Station { at: *pts.get(i).ok_or_else(bad)?, m });
}
Ok(out)
}
/// The volume a profile of `area` on `plane` sweeps through the stations: each station's
/// section area times how squarely the path crosses it, summed along the path (trapezoids).
fn swept_volume(plane: &Plane, area: f64, st: &[Station], tangents: &[Vec3]) -> f64 {
let rate = |i: usize| match (st.get(i), tangents.get(i)) {
(Some(s), Some(t)) => area * s.apply(plane.x).cross(s.apply(plane.y)).dot(*t).abs(),
_ => 0.0,
};
st.windows(2).enumerate().map(|(i, w)| 0.5 * (rate(i) + rate(i + 1)) * w[0].at.dist(w[1].at)).sum()
}
/// The stations of a smooth sweep: as many as the path has points (4…[`MAX_SMOOTH_STATIONS`]),
/// more when the profile twists or scales.
fn smooth_count(points: usize, ctl: &SweepControl) -> usize {
let n = points.saturating_sub(1).clamp(4, MAX_SMOOTH_STATIONS);
if !ctl.varies() {
return n;
}
let twist = (ctl.twist.abs() / SMOOTH_TWIST_STEP).ceil() as usize;
n.max(MIN_CONTROLLED_STATIONS).max(twist).min(MAX_CONTROLLED_STATIONS)
}
/// The path with points added along its straight runs (its corners kept) when the profile
/// twists or scales, so ruled walls follow the change.
fn densify(pts: &[Vec3], ctl: &SweepControl) -> Vec<Vec3> {
if !ctl.varies() {
return pts.to_vec();
}
let total: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let want = MIN_CONTROLLED_RULED.max((ctl.twist.abs() / RULED_TWIST_STEP).ceil() as usize).min(MAX_RULED_STATIONS / 2);
let mut out = Vec::with_capacity(want + pts.len());
out.extend(pts.first().copied());
for w in pts.windows(2) {
let pieces = if total > 0.0 { ((w[0].dist(w[1]) / total) * want as f64).ceil() as usize } else { 1 };
let pieces = pieces.clamp(1, want.max(1));
out.extend((1..=pieces).map(|j| w[0].lerp(w[1], j as f64 / pieces as f64)));
}
out
}
/// The profile plane carried to a station.
fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane {
Plane { origin: st.at + st.apply(plane.origin - s0), x: st.apply(plane.x), y: st.apply(plane.y) }
@@ -135,6 +331,12 @@ fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane {
/// Sweep a planar region along a 3D polyline path. The profile keeps its relation to the path
/// (it need not touch it); the path should start near the profile.
pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body> {
sweep_path_with(plane, region, path, &SweepControl::default())
}
/// [`sweep_path`] with sweep options: orientation, twist and end scale.
pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &SweepControl) -> Result<Body> {
ctl.check()?;
if region.area().abs() < 1e-9 {
return Err(KernelError::Invalid("profile has no area".into()));
}
@@ -147,16 +349,21 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body
if !corner && region.holes.is_empty() {
// Smooth: evenly spaced stations, tangents from neighbours.
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS);
let n = smooth_count(pts.len(), ctl);
let sp = resample(&pts, n);
let tangents = central_tangents(&sp)?;
let st = stations(&sp, &tangents, &[])?;
let expected = region.area().abs() * len * start_cos(plane, &tangents);
let (st, expected) = if ctl.is_default() {
(stations(&sp, &tangents, &[])?, region.area().abs() * len * start_cos(plane, &tangents))
} else {
let st = controlled_stations(plane, &sp, &tangents, &[], ctl)?;
let v = swept_volume(plane, region.area().abs(), &st, &tangents);
(st, v)
};
if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
return Ok(b);
}
}
ruled(plane, &profile, &pts)
ruled(plane, &profile, &pts, ctl)
}
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
@@ -177,8 +384,9 @@ fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 {
/// Stations at the path's points (at most [`MAX_RULED_STATIONS`]), mitred at corners, with the
/// points they stand at.
fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> {
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() };
fn ruled_stations(plane: &Plane, pts: &[Vec3], ctl: &SweepControl) -> Result<(Vec<Vec3>, Vec<Station>)> {
let pts = densify(pts, ctl);
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(&pts, MAX_RULED_STATIONS) } else { pts };
let n = pts.len();
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
let mut tangents = Vec::with_capacity(n);
@@ -201,15 +409,16 @@ fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> {
tangents.push(t);
mitre.push(m);
}
let st = stations(&pts, &tangents, &mitre)?;
let st = if ctl.is_default() { stations(&pts, &tangents, &mitre)? } else { controlled_stations(plane, &pts, &tangents, &mitre, ctl)? };
Ok((pts, st))
}
/// Walls of chains of sketch segments on `plane` (open, or closed) carried along a 3D polyline
/// path that starts near them: ruled between stations at the path's points, mitred at corners,
/// with no caps. Neighbouring walls share their edges.
pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Vec<Side>> {
let (pts, st) = ruled_stations(&clean(path)?)?;
pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Vec<Side>> {
ctl.check()?;
let (pts, st) = ruled_stations(plane, &clean(path)?, ctl)?;
let edges: usize = chains.iter().map(Vec::len).sum();
if st.len().saturating_mul(edges) > 400_000 {
return Err(KernelError::Invalid("the sweep is too detailed (curves × path points)".into()));
@@ -249,7 +458,8 @@ pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) ->
/// turn sharper than [`CORNER`]): one B-spline wall per segment through evenly spaced,
/// rotation-minimising stations, neighbouring walls sharing their seam edges; no caps. `None`
/// for a path with corners (see [`ruled_sheet`]).
pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Option<Vec<Side>>> {
pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Option<Vec<Side>>> {
ctl.check()?;
let pts = clean(path)?;
if pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER) {
return Ok(None);
@@ -258,10 +468,10 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -
if chains.iter().map(Vec::len).sum::<usize>() > 4096 {
return Err(fail("too many curves for a smooth sweep"));
}
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS);
let n = smooth_count(pts.len(), ctl);
let sp = resample(&pts, n);
let tangents = central_tangents(&sp)?;
let st = stations(&sp, &tangents, &[])?;
let st = if ctl.is_default() { stations(&sp, &tangents, &[])? } else { controlled_stations(plane, &sp, &tangents, &[], ctl)? };
let s0 = st.first().map(|s| s.at).unwrap_or_default();
let at = |i: usize, q: Vec2| st.get(i).map(|s| s.at + s.apply(plane.to_world(q) - s0)).unwrap_or_default();
let n = st.len() - 1;
@@ -340,8 +550,8 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -
/// Ruled walls between stations at the path's points (mitred at corners), every loop of the
/// (oriented) profile.
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result<Body> {
let (pts, st) = ruled_stations(pts)?;
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepControl) -> Result<Body> {
let (pts, st) = ruled_stations(plane, pts, ctl)?;
let s0 = pts.first().copied().unwrap_or_default();
let loops: Vec<&NumberedLoop> = profile.loops().collect();
let mut wires: Vec<Vec<mt::Wire>> = Vec::with_capacity(st.len());
@@ -438,9 +648,11 @@ fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected:
#[cfg(test)]
mod tests {
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use super::sweep_path;
use super::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
fn circle(r: f64) -> Region2 {
Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() }
@@ -476,4 +688,163 @@ mod tests {
assert!(sweep_path(&plane, &region, &[Vec3::ZERO]).is_err());
assert!(sweep_path(&plane, &region, &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err());
}
fn square(h: f64) -> Region2 {
Region2 { outer: Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]), holes: Vec::new() }
}
fn up(h: f64) -> [crate::PathSeg; 1] {
[crate::PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, h) }]
}
/// A circle swept along a line to an end scale is a cone frustum: its volume and lateral
/// area are analytic, for the solid and for the surface sweep.
#[test]
fn scaled_sweep_is_a_frustum() {
let (big, h, k) = (10.0, 40.0, 0.5);
let small = big * k;
let ctl = SweepControl { scale: k, ..SweepControl::default() };
let b = crate::sweep_with(&Plane::XY, &circle(big), &up(h), &ctl).unwrap();
let m = b.tessellate(0.005).unwrap().measure();
let volume = PI * h * (big * big + big * small + small * small) / 3.0;
let lateral = PI * (big + small) * (h * h + (big - small).powi(2)).sqrt();
let caps = PI * (big * big + small * small);
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
assert!((m.area - caps - lateral).abs() < 2e-3 * lateral, "lateral {} vs {lateral}", m.area - caps);
// The end cap is the scaled circle.
let top = b.tessellate(0.005).unwrap().positions.iter().filter(|p| (p.z - h).abs() < 1e-6).map(|p| p.x.hypot(p.y)).fold(0.0, f64::max);
assert!((top - small).abs() < 1e-3, "end radius {top}");
// The surface sweep of the circle's curve: the frustum's lateral face alone.
let sheet = crate::sweep_sheet_with(&Plane::XY, std::slice::from_ref(&circle(big).outer.segs), &up(h), &ctl).unwrap();
let a = crate::measure(&sheet).unwrap().area;
assert!((a - lateral).abs() < 2e-3 * lateral, "sheet {a} vs {lateral}");
}
/// The corners of a twisted square: each seam edge's angle at `z`.
fn corner_angles(b: &crate::Body, z: f64) -> Vec<f64> {
let mesh = b.tessellate(0.002).unwrap();
let mut out = Vec::new();
for e in &mesh.edges {
for w in e.windows(2) {
let (a, c) = (w[0], w[1]);
if (a.z - z) * (c.z - z) <= 0.0 && (a.z - c.z).abs() > 1e-9 {
let p = a.lerp(c, (z - a.z) / (c.z - a.z));
out.push(p.y.atan2(p.x));
}
}
}
out
}
/// A square swept along a line with a twist: the section halfway along is turned by half the
/// twist, the end by all of it; the volume is the prism's.
#[test]
fn twisted_sweep_turns_in_proportion() {
let (h, twist) = (40.0, 90f64.to_radians());
let ctl = SweepControl { twist, ..SweepControl::default() };
let b = crate::sweep_with(&Plane::XY, &square(5.0), &up(h), &ctl).unwrap();
// Twisted walls are saddles: a fine mesh to measure them.
let v = b.tessellate(0.0005).unwrap().measure().volume;
assert!((v - 100.0 * h).abs() < 2e-3 * 100.0 * h, "volume {v}");
// Corners start at 45 deg (and 135, -45, -135): each seam turns by the twist so far.
let off = |a: f64, want: f64| {
let d = (a - want).rem_euclid(FRAC_PI_2);
d.min(FRAC_PI_2 - d)
};
for (z, turned) in [(h / 2.0, twist / 2.0), (h, twist), (h / 4.0, twist / 4.0)] {
let angles = corner_angles(&b, z);
assert!(angles.len() >= 4, "seams at {z}: {angles:?}");
for a in &angles {
assert!(off(*a, FRAC_PI_4 + turned) < 0.2f64.to_radians(), "at {z}: {} vs {}", a.to_degrees(), (FRAC_PI_4 + turned).to_degrees());
}
}
// Right hand about the direction of travel: a positive twist going up turns
// counter-clockwise seen from above (the corner at 45 deg reaches 90 deg + 45 deg).
let end = corner_angles(&b, h);
assert!(end.iter().any(|a| (a - (FRAC_PI_4 + twist)).abs() < 0.2f64.to_radians()), "{end:?}");
// One wall per side and two caps, as an untwisted sweep.
assert_eq!(b.faces(0.01).unwrap().len(), 6);
}
/// Parallel keeps the profile as drawn along a curved path: the volume is its area times
/// the path's rise across the profile's plane.
#[test]
fn parallel_sweep_keeps_the_profile_square_to_its_normal() {
// An eighth of a circle: the path ends leaning 45 deg from the profile's normal.
let r = 30.0;
let path: Vec<Vec3> = (0..=60)
.map(|i| {
let t = i as f64 / 60.0 * FRAC_PI_4;
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
})
.collect();
let rise = r * FRAC_PI_4.sin();
let ctl = SweepControl { orientation: SweepOrientation::Parallel, ..SweepControl::default() };
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
let mesh = b.tessellate(0.005).unwrap();
let v = mesh.measure().volume;
let want = 36.0 * rise;
assert!((v - want).abs() < 5e-3 * want, "volume {v} vs {want}");
// The end cap is still horizontal: the body tops out flat at the path's end.
let top: Vec<&Vec3> = mesh.positions.iter().filter(|p| (p.z - rise).abs() < 1e-6).collect();
assert!(top.len() >= 4, "flat top");
assert!(mesh.positions.iter().all(|p| p.z <= rise + 1e-6));
// A path that turns to run along the profile's plane (a quarter circle) is refused.
let quarter: Vec<Vec3> = (0..=60)
.map(|i| {
let t = i as f64 / 60.0 * FRAC_PI_2;
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
})
.collect();
assert!(sweep_path_with(&Plane::XY, &square(3.0), &quarter, &ctl).is_err());
}
/// A direction keeps the square's sides lined up with it along a curved path; in a plane
/// square to the direction, that is the rotation-minimising frame too.
#[test]
fn direction_orientation_follows_the_vector() {
let r = 30.0;
let path: Vec<Vec3> = (0..=60)
.map(|i| {
let t = i as f64 / 60.0 * FRAC_PI_2;
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
})
.collect();
let ctl = SweepControl { orientation: SweepOrientation::Direction(Vec3::Y), ..SweepControl::default() };
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
let mesh = b.tessellate(0.005).unwrap();
let len = r * FRAC_PI_2;
assert!((mesh.measure().volume - 36.0 * len).abs() < 5e-3 * 36.0 * len);
// The sides facing ±Y stay at y = ±3.
assert!(mesh.positions.iter().all(|p| p.y.abs() <= 3.0 + 1e-6));
// The path running along the direction is refused.
let along = SweepControl { orientation: SweepOrientation::Direction(Vec3::Z), ..SweepControl::default() };
assert!(crate::sweep_with(&Plane::XY, &square(3.0), &up(10.0), &along).is_err());
}
/// Out-of-range options are refused, never a panic; the default options are a plain sweep.
#[test]
fn sweep_options_refuse_hostile_values() {
let bad = [
SweepControl { twist: f64::NAN, ..SweepControl::default() },
SweepControl { twist: 1e9, ..SweepControl::default() },
SweepControl { twist: SweepControl::MAX_TWIST * 1.01, ..SweepControl::default() },
SweepControl { scale: 0.0, ..SweepControl::default() },
SweepControl { scale: -1.0, ..SweepControl::default() },
SweepControl { scale: f64::INFINITY, ..SweepControl::default() },
SweepControl { scale: 1e6, ..SweepControl::default() },
SweepControl { orientation: SweepOrientation::Direction(Vec3::ZERO), ..SweepControl::default() },
SweepControl { orientation: SweepOrientation::Direction(Vec3::new(f64::NAN, 0.0, 1.0)), ..SweepControl::default() },
];
for ctl in bad {
assert!(crate::sweep_with(&Plane::XY, &circle(2.0), &up(10.0), &ctl).is_err(), "{ctl:?}");
assert!(crate::sweep_sheet_with(&Plane::XY, std::slice::from_ref(&circle(2.0).outer.segs), &up(10.0), &ctl).is_err(), "{ctl:?}");
assert!(sweep_path_with(&Plane::XY, &circle(2.0), &[Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0)], &ctl).is_err(), "{ctl:?}");
}
// The largest twist still builds (a holed profile: ruled walls through dense stations).
let holed = Region2 { outer: square(3.0).outer, holes: vec![Loop2::circle(Vec2::ZERO, 1.0).reversed()] };
let max = SweepControl { twist: SweepControl::MAX_TWIST, scale: 0.5, ..SweepControl::default() };
assert!(crate::sweep_with(&Plane::XY, &holed, &up(10.0), &max).is_ok());
assert!(SweepControl::default().is_default());
}
}
+18
View File
@@ -44,3 +44,21 @@ fn faceted_cut_into_a_bore() {
let want = 4.0 * PI * 0.2 * (1.5f64.powi(2) - 1.23f64.powi(2));
assert!((removed - want).abs() < 0.03 * want, "{removed} vs {want}");
}
/// #1250: a long thread (48 turns of an M8 groove on a 60 mm shaft). The groove's facets come in
/// sweep order; taken as BSP splitters in that order they made the tree as deep as the facet
/// list ("planar boolean: tree too deep", after minutes and gigabytes). It cuts now, closed.
#[test]
fn a_long_thread_cuts_without_a_deep_tree() {
let (r, len, p) = (4.0, 60.0, 1.25);
let shaft = cylinder(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), r, len).unwrap();
let k = Vec3::new(0.0, 0.0, 1.0);
let base = Vec3::new(0.0, 0.0, -p);
let plane = Plane { origin: base, x: Vec3::new(1.0, 0.0, 0.0), y: k };
let section = [Vec2::new(3.3, -0.08), Vec2::new(4.1, -0.5), Vec2::new(4.1, 0.5), Vec2::new(3.3, 0.08)];
let groove = sweep_helix_mesh(&plane, &section, base, k, p, len / p + 2.0, 48).unwrap();
let cut = faceted_boolean(&shaft, &groove, BoolOp::Cut, p * 0.005).unwrap().unwrap();
let v = cut.tessellate(0.01).unwrap().measure().volume;
let v0 = PI * r * r * len;
assert!(v < v0 * 0.95 && v > v0 * 0.6, "{v} of {v0}");
}
+74 -1
View File
@@ -178,10 +178,51 @@ fn arc_pts(c: Vec2, r: f64, a0: f64, sweep: f64) -> Vec<Vec2> {
(0..=n).map(|i| c + Vec2::from_angle(a0 + sweep * i as f64 / n as f64) * r).collect()
}
/// The lines a linear dimension's two extension lines may start from (#1361), for its `p0` and
/// `p1` sides: a dimension to a line reaches the nearest part of that line, not the foot of the
/// measured point, and a dimension between two parallel lines reaches the nearest part of the
/// line whose end it measures from. `None` for a side that measures a point.
pub fn dim_reach(sk: &Sketch, k: &ConstraintKind) -> [Option<(Vec2, Vec2)>; 2] {
use ConstraintKind::*;
let (p, l, to_line) = match *k {
PointLineDistance { p, l, .. } => (p, l, true),
LinearDiameter { p, l, .. } => (p, l, false),
_ => return [None, None],
};
let Some((a, b)) = line_ends(sk, l) else { return [None, None] };
let d = b - a;
// A line ending at `p` that runs parallel to `l`.
let parallel = sk.curves.iter().enumerate().find_map(|(i, c)| match c.kind {
CurveKind::Line { a: pa, b: pb } if i != l && (pa == p || pb == p) => {
let (u, v) = line_ends(sk, i)?;
let e = v - u;
(e.len() > 1e-12 && d.cross(e).abs() <= 1e-9 * d.len() * e.len()).then_some((u, v))
}
_ => None,
});
[to_line.then_some((a, b)), parallel]
}
/// The point of segment `s` nearest `q`.
fn nearest_on(s: (Vec2, Vec2), q: Vec2) -> Vec2 {
let d = s.1 - s.0;
let l2 = d.dot(d);
if !(l2 > 1e-24) {
return s.0;
}
s.0 + d * ((q - s.0).dot(d) / l2).clamp(0.0, 1.0)
}
/// Lay a dimension out. `text` is the stored text position (None: the default place), `px`
/// the sketch length of one screen pixel and `half` the half size of the text box (sketch
/// units), which the dimension line leaves a gap for.
pub fn dim_layout(f: &DimFrame, text: Option<Vec2>, px: f64, half: Vec2) -> DimLayout {
dim_layout_reaching(f, text, px, half, [None, None])
}
/// [`dim_layout`] with the extension lines of a linear dimension starting from the nearest
/// point of the geometry in `reach` ([`dim_reach`]) instead of the measured points.
pub fn dim_layout_reaching(f: &DimFrame, text: Option<Vec2>, px: f64, half: Vec2, reach: [Option<(Vec2, Vec2)>; 2]) -> DimLayout {
let px = if px.is_finite() && px > 0.0 { px } else { 1.0 };
let arrow = 9.0 * px;
let gap = 3.0 * px;
@@ -196,7 +237,8 @@ pub fn dim_layout(f: &DimFrame, text: Option<Vec2>, px: f64, half: Vec2) -> DimL
// The dimension line sits `h` across from the middle of the measured points.
let q0 = p0 + n * (h - (p0 - m).dot(n));
let q1 = p1 + n * (h - (p1 - m).dot(n));
for (p, q) in [(p0, q0), (p1, q1)] {
for ((p, q), r) in [(p0, q0), (p1, q1)].into_iter().zip(reach) {
let p = r.map_or(p, |r| nearest_on(r, q));
let v = q - p;
if let Some(u) = v.normalized() {
out.lines.push(vec![p + u * gap.min(v.len()), q + u * over]);
@@ -375,4 +417,35 @@ mod tests {
assert_eq!(l.arrows.len(), 2);
assert!(l.text.x > 0.0 && l.text.y > 0.0);
}
#[test]
fn extension_lines_reach_the_nearest_part_of_the_lines_measured() {
// #1361: two parallel lines dimensioned from the low end of one; the dimension placed
// across their middles needs no extension lines, one placed above them reaches their
// top ends, not the low end.
let mut sk = Sketch::new();
let l = sk.add_line(Vec2::new(0.0, -100.0), Vec2::new(0.0, 100.0), None, None, None).unwrap_or(0);
let m = sk.add_line(Vec2::new(30.0, -80.0), Vec2::new(30.0, 90.0), None, None, None).unwrap_or(0);
let p = sk.curves[m].kind.point_ids()[0];
let k = ConstraintKind::PointLineDistance { p, l, value: 30.0 };
let f = dim_frame(&sk, &k).unwrap_or(DimFrame::Radial { center: Vec2::ZERO, radius: 1.0, diameter: false });
let reach = dim_reach(&sk, &k);
assert!(reach[0].is_some() && reach[1].is_some());
let extension_ys = |at: Vec2| -> Vec<(f64, f64)> {
let lay = dim_layout_reaching(&f, Some(encode_text(&f, at)), 0.1, Vec2::new(2.0, 1.0), reach);
lay.lines.iter().filter(|line| line.len() == 2 && (line[0].x - line[1].x).abs() < 1e-9).map(|line| (line[0].y, line[1].y)).collect()
};
assert!(extension_ys(Vec2::new(15.0, 50.0)).is_empty(), "{:?}", extension_ys(Vec2::new(15.0, 50.0)));
let above = extension_ys(Vec2::new(15.0, 120.0));
assert_eq!(above.len(), 2);
assert!((above[0].0 - 100.3).abs() < 1e-9 && (above[1].0 - 90.3).abs() < 1e-9, "{above:?}");
// Without the lines' reach the extension lines start at the measured point's height.
let lay = dim_layout(&f, Some(encode_text(&f, Vec2::new(15.0, 120.0))), 0.1, Vec2::new(2.0, 1.0));
assert!(lay.lines.iter().any(|line| (line[0].y + 79.7).abs() < 1e-9));
// A point that is not the end of a parallel line keeps its own extension line.
let mut sk = Sketch::new();
let l = sk.add_line(Vec2::new(0.0, -100.0), Vec2::new(0.0, 100.0), None, None, None).unwrap_or(0);
let q = sk.add_point(Vec2::new(30.0, -80.0), None).unwrap_or(0);
assert!(dim_reach(&sk, &ConstraintKind::PointLineDistance { p: q, l, value: 30.0 })[1].is_none());
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ pub use curves::{
spline_end_tangent, spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode,
};
pub use decompose::Budget;
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text};
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom};
+25
View File
@@ -108,6 +108,31 @@ impl LinkSource {
LinkSource::Text { text, .. } => format!("text \"{}\"", text.chars().take(20).collect::<String>()),
}
}
/// The bodies this source reads geometry from (an intersection reads two).
pub fn bodies(&self) -> Vec<&str> {
match self {
LinkSource::Edge { body, .. }
| LinkSource::Face { body, .. }
| LinkSource::Body { body }
| LinkSource::Vertex { body, .. }
| LinkSource::OnSurface { body, .. }
| LinkSource::Iso { body, .. }
| LinkSource::Spun { body, .. } => vec![body.as_str()],
LinkSource::Intersection { a, b } => {
let mut bodies = a.bodies();
bodies.extend(b.bodies());
bodies
}
LinkSource::SketchCurve { .. }
| LinkSource::SketchPoint { .. }
| LinkSource::Origin
| LinkSource::Axis { .. }
| LinkSource::SketchAxis { .. }
| LinkSource::Plane { .. }
| LinkSource::Text { .. } => Vec::new(),
}
}
}
/// Half length of the line a sketch axis link produces.
+103
View File
@@ -0,0 +1,103 @@
//! Fillet and chamfer drag arrows on outside and inside edges (#1384): the arrow points where
//! the value grows (into the body on an outside edge, into the open corner on an inside one),
//! and dragging out along it increases the value.
use crate::canvas::{anchor, blend_grow_dir};
use crate::dialogs::Kind;
use crate::scenario::Harness;
use serde_json::json;
use solidcraft_engine::Sel;
use solidcraft_engine::geom::Vec3;
/// A 40 x 30 x 20 block with a 20 x 30 x 10 step on its left half: the edge along y at x 20,
/// z 20 is inside (concave); the block's right top edge (x 40, z 20) and the step's top right
/// edge (x 20, z 30) are outside (convex).
fn l_step() -> Harness {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
h.app.run("solid.box", json!({"length": 20, "width": 30, "height": 10, "corner": [0, 0, 20], "operation": "join"})).unwrap();
h.frames(2);
h
}
fn edge(h: &Harness, p: Vec3) -> Sel {
let st = h.app.session.world_state();
let m = st.body("Body1").unwrap().mesh();
let i = m.edges.iter().position(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) < 1e-9)).expect("an edge there");
Sel::Edge { body: "Body1".into(), index: i, point: p }
}
fn grow(h: &Harness, s: &Sel) -> Vec3 {
let Sel::Edge { body, index, point } = s else { panic!("an edge") };
blend_grow_dir(&h.app.session.world_state().body(body).unwrap().mesh(), *index, *point).unwrap()
}
fn close(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-6
}
const INSIDE: Vec3 = Vec3::new(20.0, 15.0, 20.0);
const OUTSIDE: Vec3 = Vec3::new(40.0, 15.0, 20.0);
const STEP_TOP: Vec3 = Vec3::new(20.0, 15.0, 30.0);
/// The direction function on its own: the corner's open side on the inside edge, into the
/// material on the outside ones, each edge of a mixed chain on its own.
#[test]
fn grow_direction_follows_each_edges_convexity() {
let h = l_step();
let s = std::f64::consts::FRAC_1_SQRT_2;
let open = Vec3::new(s, 0.0, s);
assert!(close(grow(&h, &edge(&h, INSIDE)), open), "inside: {:?}", grow(&h, &edge(&h, INSIDE)));
assert!(close(grow(&h, &edge(&h, OUTSIDE)), Vec3::new(-s, 0.0, -s)), "outside: {:?}", grow(&h, &edge(&h, OUTSIDE)));
// Same face normals as the inside edge, but outside: the opposite way.
assert!(close(grow(&h, &edge(&h, STEP_TOP)), -open), "step top: {:?}", grow(&h, &edge(&h, STEP_TOP)));
}
/// A plain box's outside edge keeps its arrow into the body.
#[test]
fn box_edge_arrow_still_points_into_the_body() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
let e = edge(&h, Vec3::new(20.0, 0.0, 20.0));
for id in ["solid.fillet", "solid.chamfer"] {
h.app.session.selection = vec![e.clone()];
h.app.start(id);
let dir = anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().1.unwrap();
assert!(dir.y > 0.5 && dir.z < -0.5, "{id}: {dir:?}");
h.app.dialog = None;
}
}
/// The value of the open fillet or chamfer window.
fn value(h: &Harness) -> f64 {
let Some(Kind::Fillet { radius, .. }) = h.app.dialog.as_ref().map(|d| &d.kind) else { panic!("a fillet window") };
h.app.session.doc.eval(radius, solidcraft_engine::doc::expr::Kind::Length).unwrap()
}
/// On an inside edge (and on an outside one, a mixed chain's first edge either way), the arrow
/// points along the edge's own grow direction and dragging outward along it increases the value.
#[test]
fn dragging_out_along_the_arrow_grows_fillet_and_chamfer_on_both_kinds_of_edge() {
for first in [INSIDE, OUTSIDE] {
for id in ["solid.fillet", "solid.chamfer"] {
let mut h = l_step();
h.step(&json!({"call": "ui.view", "params": {"view": "iso"}})).unwrap();
h.step(&json!({"call": "ui.view", "params": {"view": "fit"}})).unwrap();
let chain = if first == INSIDE { vec![edge(&h, INSIDE), edge(&h, OUTSIDE)] } else { vec![edge(&h, OUTSIDE), edge(&h, INSIDE)] };
h.app.session.selection = chain;
h.app.start(id);
h.frames(3);
let shown = h.app.dialog.as_ref().unwrap().inputs[0].items[0].clone();
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 2);
let want = grow(&h, &shown);
assert!(close(want, grow(&h, &edge(&h, first))) || matches!(&shown, Sel::Edge { point, .. } if *point != first));
let (at, dir) = anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert!(close(dir.unwrap(), want), "{id} at {first:?}: {dir:?} vs {want:?}");
let before = value(&h);
let to = at + want * 6.0;
h.step(&json!({"drag": [{"handle": "arrow"}, {"world": [to.x, to.y, to.z]}], "ctrl": true})).unwrap();
let after = value(&h);
assert!(after > before && (after - 6.0).abs() < 0.3, "{id} at {first:?}: {before} -> {after}");
}
}
}
+9 -3
View File
@@ -1248,7 +1248,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
.state()
.bodies
.iter()
.filter(|b| doc.model_body_component(b) == comp)
.filter(|b| doc.model_body_component(b) == comp && !app.session.body_removed_later(&b.name))
.map(|b| Entry {
key: b.name.clone(),
row_key: format!("b:{}", b.name),
@@ -1818,7 +1818,7 @@ fn triad(app: &mut SolveApp, ui: &mut egui::Ui, m: &mut OccMove) {
/// viewport like the sketch being edited draws its own (`crate::dim_view`), but not editable.
pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) {
use solidcraft_engine::geom::Vec2;
use solidcraft_engine::sketch::{DimFrame, chain_centres, default_text, dim_frame, dim_layout};
use solidcraft_engine::sketch::{DimFrame, chain_centres, default_text, dim_frame, dim_layout_reaching, dim_reach};
let Some(rect) = app.viewport.rect else { return };
if app.ui.shown_dims.is_empty() {
return;
@@ -1856,7 +1856,7 @@ pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) {
);
let size = galley.size() + vec2(6.0, 2.0);
let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px);
let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, middle, px)), px, half);
let lay = dim_layout_reaching(&frame, c.text.or_else(|| default_text(&frame, middle, px)), px, half, dim_reach(sk, &c.kind));
for line in &lay.lines {
painter.add(egui::Shape::line(line.iter().filter_map(|q| to(*q)).collect(), Stroke::new(1.0, col)));
}
@@ -1960,6 +1960,12 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option<solidcraft_engin
Some(solidcraft_engine::doc::apply_point(&solidcraft_engine::doc::mat_mul(&parent, &own), solidcraft_engine::geom::Vec3::ZERO))
}
/// The names the Bodies folder lists at the top level, for tests.
#[cfg(test)]
pub(crate) fn body_rows(app: &SolveApp) -> Vec<String> {
entries(app, 0, "bodies").into_iter().map(|e| e.key).collect()
}
#[cfg(test)]
#[path = "section_browser_tests.rs"]
mod section_browser_tests;
+49 -19
View File
@@ -116,7 +116,13 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
(at, normal.or(dir.map(|d| -d)))
}
Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
// Fillet radii, chamfer distances and shell thickness grow into the body.
// Fillet radii and chamfer distances grow the way `blend_grow_dir` gives (into the body
// on an outside edge, into the open corner on an inside one); shell thickness grows into
// the body.
Kind::Fillet { .. } if matches!(first, Sel::Edge { .. }) => {
let Sel::Edge { body, index, .. } = first else { return None };
(at, st.body(body).and_then(|b| blend_grow_dir(&b.mesh(), *index, at)))
}
Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)),
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
Kind::Plastic(_) => (at, normal),
@@ -178,23 +184,46 @@ fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solidcraft_
Some(solidcraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p))
}
/// The outward direction between the two faces at an edge (a fillet's radius arrow points the
/// other way, into the body).
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
/// The two faces at an edge, near `p`: for each, the normal and centroid of its triangle nearest
/// `p`.
fn edge_sides(m: &Mesh, index: usize, p: Vec3) -> Option<Vec<(Vec3, Vec3)>> {
let faces = m.edge_faces.get(index)?;
let mut sum = Vec3::ZERO;
for f in faces {
let n = m
.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, tf)| *tf == f)
.filter_map(|(t, _)| m.tri(t))
.min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(p).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(p)))
.and_then(|[a, b, c]| (b - a).cross(c - a).normalized())?;
sum += n;
}
sum.normalized()
faces
.iter()
.map(|f| {
m.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, tf)| *tf == f)
.filter_map(|(t, _)| m.tri(t))
.min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(p).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(p)))
.and_then(|[a, b, c]| Some(((b - a).cross(c - a).normalized()?, (a + b + c) / 3.0)))
})
.collect()
}
/// The outward direction between the two faces at an edge: the mean of their normals.
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
edge_sides(m, index, p)?.iter().fold(Vec3::ZERO, |sum, (n, _)| sum + *n).normalized()
}
/// Whether the edge is an inside (concave) one: each face near it lies in front of the other's
/// plane, where on an outside edge it lies behind. Only the offset across the edge counts, since
/// each normal is square to the edge.
fn edge_concave(m: &Mesh, index: usize, p: Vec3) -> Option<bool> {
let sides = edge_sides(m, index, p)?;
let [(n1, c1), (n2, c2)] = sides.as_slice() else { return None };
let s = (*c1 - p).dot(*n2) + (*c2 - p).dot(*n1);
Some(s > 0.0)
}
/// The direction a fillet's radius or a chamfer's distance grows from an edge, along the bisector
/// of its faces: into the body on an outside edge, where the blend takes material away, and out
/// into the open corner on an inside edge, where it adds material (#1384). Dragging along it
/// increases the value either way.
pub(crate) fn blend_grow_dir(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
let n = edge_bisector(m, index, p)?;
Some(if edge_concave(m, index, p).unwrap_or(false) { n } else { -n })
}
/// Whether a dynamically registered canvas handle owns this point before navigation.
@@ -609,8 +638,9 @@ fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Pain
let (spline, start) = law.as_ref()?;
let other = if start.dist(*a) <= start.dist(*b) { *b } else { *a };
let mid = *start + (other - *start) * 0.5;
// Into the body, between the two faces (the edge is straight between planar faces).
let n = -edge_bisector(mesh, *index, mid)?;
// Between the two faces, the way this edge's radius grows: into the body on an outside
// edge, into the corner on an inside one (the edge is straight between planar faces).
let n = blend_grow_dir(mesh, *index, mid)?;
let curve = (0..=48)
.filter_map(|k| {
let u = f64::from(k) / 48.0;
+1 -1
View File
@@ -31,7 +31,7 @@
//! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists)
//! - `ui.shortcut {command, key?, replace?}`: a command's key, or bind one ("" clears)
//! - `ui.help {item?: about|shortcuts|report}`: the Help menu; returns the version and diagnostics
//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa,
//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_around, msaa,
//! length_decimals, angle_decimals); returns them all
//! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question
//! - `ui.agent_cursor {show?, speed?: 1–5 (times) | "instant", follow_camera?}`: the agent cursor
+19 -12
View File
@@ -108,6 +108,7 @@ pub enum Kind {
/// Sweep a profile along a path of sketch curves.
Sweep {
operation: usize,
opts: crate::dialogs_sweep::SweepOpts,
},
/// A construction plane offset from a plane.
OffsetPlane {
@@ -544,8 +545,8 @@ impl Dialog {
),
"solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]),
"solid.sweep" => Dialog::new(
Kind::Sweep { operation: usize::from(has_bodies) },
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)],
Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() },
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()],
),
"solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)),
"solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]),
@@ -2537,7 +2538,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
combo(ui, "lf_op", &OP_LABELS, operation);
ui.end_row();
}
Kind::Sweep { operation } => {
Kind::Sweep { operation, opts } => {
enter |= crate::dialogs_sweep::rows(ui, opts, "sw");
row_label(ui, "Operation");
combo(ui, "sw_op", &OP_LABELS, operation);
ui.end_row();
@@ -3393,8 +3395,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
out.push(("solid.extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx}))));
}
// Each planar body face extrudes on its own.
for p in face_points(0) {
out.push(("solid.extrude".to_string(), with(json!({ "face": p }))));
for pick in sels(d, 0) {
if let Sel::Face { body, point, .. } = pick {
out.push(("solid.extrude".to_string(), with(json!({"face": pt(*point), "face_body": body}))));
}
}
return Ok(out);
}
@@ -3548,16 +3552,15 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect();
("solid.loft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
}
Kind::Sweep { operation } => {
Kind::Sweep { operation, opts } => {
need(0, "a profile")?;
need(1, "the path")?;
let (sketch, idx) = profiles();
let path: Vec<String> = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?;
(
"solid.sweep",
json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
)
let mut p = json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")});
crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), &mut p)?;
("solid.sweep", p)
}
Kind::OffsetPlane { offset } => {
need(0, "a plane")?;
@@ -4614,9 +4617,13 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Sweep { sketch, profiles, path, operation, targets, .. } => {
FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, .. } => {
let mut d = start("solid.sweep")?;
d.kind = Kind::Sweep { operation: op_index(operation) };
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component));
d.kind = Kind::Sweep { operation: op_index(operation), opts };
if let Some(inp) = d.inputs.get_mut(2) {
inp.items = direction.into_iter().collect();
}
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
if let Some(inp) = d.inputs.get_mut(0) {
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
+51
View File
@@ -450,6 +450,7 @@ fn shifted(s: &SnapPt, x: f64, y: f64) -> Value {
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [SelInput], active: &mut usize) -> bool {
let t = Tokens::get();
let mut enter = false;
let problem = joint_problem(app, k, inputs);
match k {
Asm::NewComponent { name, activate } => {
row_label(ui, "Name");
@@ -465,6 +466,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
if f.as_built && *active == 0 && inputs.first().is_some_and(|i| i.items.len() >= 2) {
*active = 1;
}
// Picks that make no joint say why, under them, and offer the way out (#1239).
if let Some((why, bodies)) = problem {
row_label(ui, "");
ui.vertical(|ui| {
ui.add(egui::Label::new(RichText::new(why).color(t.warning)).wrap());
if !bodies.is_empty()
&& ui.button(crate::i18n::tr("cmd.component_from_bodies")).on_hover_text(crate::i18n::tr("joint.from_bodies_hint")).clicked()
{
// A refusal shows on the status line; the picks stay either way.
let _ = app.run("component.from_bodies", json!({ "bodies": bodies }));
}
});
ui.end_row();
}
row_label(ui, "Motion");
ui.label("");
ui.end_row();
@@ -729,6 +744,42 @@ fn sel_items(inputs: &[SelInput], i: usize) -> &[Sel] {
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
}
/// The body a pick is on.
fn body_of(sel: &Sel) -> Option<&str> {
match sel {
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(body),
Sel::Body { name } => Some(name),
_ => None,
}
}
/// Why the Joint dialog's picks, all in, make no joint: without it the preview and OK just go
/// quiet, and no option seems to do anything (#1239). With it, the bodies Create Components from
/// Bodies would part (none when that would not help). The usual case is every pick on one
/// component: a STEP file with several parts often brings them as bodies of one component.
fn joint_problem(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Option<(String, Vec<String>)> {
let Asm::Joint(f) = k else { return None };
let picks: Vec<&Sel> =
if f.as_built { sel_items(inputs, 0).iter().collect() } else { (0..2).filter_map(|i| sel_items(inputs, i).first()).collect() };
if f.editing.is_some() || picks.len() < 2 {
return None;
}
let mut bodies: Vec<String> = Vec::new();
for b in picks.iter().filter_map(|p| body_of(p)) {
if !bodies.iter().any(|x| x == b) {
bodies.push(b.to_string());
}
}
let occs: Vec<Option<u64>> = bodies.iter().map(|b| occurrence_of_body(&app.session, b)).collect();
if occs.first().is_some_and(|first| first.is_some() && occs.iter().all(|o| o == first)) {
return Some(match bodies.as_slice() {
[body] => (crate::i18n::format("joint.one_body", &[("body", body.as_str())]), Vec::new()),
_ => (crate::i18n::format("joint.one_component", &[("bodies", &bodies.join(", "))]), bodies),
});
}
commands(app, k, inputs).err().map(|e| (e, Vec::new()))
}
/// The occurrences of picked bodies (deduplicated, in pick order).
fn occurrences(s: &Session, items: &[Sel]) -> Vec<u64> {
let mut out = Vec::new();
@@ -207,6 +207,66 @@ fn ok_click_survives_a_busy_preview() {
assert!(app.dialog.is_none());
}
/// #1239: two parts as bodies of one component (a multi-body STEP import). Picks on both used to
/// make no joint with nothing said, so no option seemed to do anything. The dialog now says why
/// under the picks and offers Create Components from Bodies, after which the same picks join.
#[test]
fn picks_on_one_component_say_why_and_offer_components() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10})).unwrap();
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10, "corner": [30, 0, 0]})).unwrap();
let mut app = SolveApp::new(s, Services::default());
let bodies = vec!["Body1".to_string(), "Body2".to_string()];
// As-built: two bodies of one component are one component.
app.session.selection = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }];
app.start("joint.as_built");
let d = app.dialog.take().unwrap();
let Kind::Assembly(k) = &d.kind else { panic!("{:?}", d.kind) };
assert_eq!(joint_problem(&app, k, &d.inputs).map(|p| p.1), Some(bodies.clone()));
// Two faces of one body: pick another part (nothing to part).
app.session.selection.clear();
app.start("joint.create");
let mut d = app.dialog.take().unwrap();
d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap());
d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 0.0)).unwrap());
let Kind::Assembly(k) = &d.kind else { panic!("{:?}", d.kind) };
assert_eq!(joint_problem(&app, k, &d.inputs).map(|p| p.1), Some(vec![]));
// A face of each body.
app.start("joint.create");
let mut d = app.dialog.take().unwrap();
d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap());
d.pick(&app.session, face_sel(&app.session, Vec3::new(40.0, 10.0, 0.0)).unwrap());
assert!(apply_commands(&app, &d).is_err(), "one component: no joint");
app.dialog = Some(d);
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let frame = |app: &mut SolveApp, events: Vec<egui::Event>| {
let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()));
// Nothing is painted: drop the texture changes (an unapplied delta panics on drop).
out.textures_delta.clear();
out
};
// The auto-sized dialog takes a few frames to settle its width.
for _ in 0..3 {
frame(&mut app, vec![]);
}
let out = frame(&mut app, vec![]);
let why = crate::i18n::format("joint.one_component", &[("bodies", "Body1, Body2")]);
assert!(text_at(&out, &why).is_some(), "the dialog says why");
let at = text_at(&out, crate::i18n::tr("cmd.component_from_bodies")).expect("and offers the way out");
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(at)]);
frame(&mut app, vec![button(true)]);
frame(&mut app, vec![button(false)]);
assert!(bodies.iter().all(|b| app.session.doc.find_component(b).is_some()), "each body has a component");
let d = app.dialog.as_ref().expect("the dialog stays open");
let c = apply_commands(&app, d).unwrap();
assert_eq!((c[0].1["a"]["occurrence"].clone(), c[0].1["b"]["occurrence"].clone()), (json!(occ(&app, "Body1")), json!(occ(&app, "Body2"))));
let out = frame(&mut app, vec![]);
assert!(text_at(&out, &why).is_none(), "nothing left to say");
}
/// Two top faces mate face to face as in Fusion: B ends up upside down on A, not inside it;
/// Flip turns it back over (into A).
#[test]
+23 -10
View File
@@ -25,7 +25,7 @@ pub enum Surf {
symmetric: bool,
axis: Option<Value>,
},
Sweep,
Sweep(crate::dialogs_sweep::SweepOpts),
Loft,
Patch,
Unstitch,
@@ -68,7 +68,7 @@ impl Surf {
match self {
Surf::Extrude { .. } => "SURFACE EXTRUDE",
Surf::Revolve { .. } => "SURFACE REVOLVE",
Surf::Sweep => "SURFACE SWEEP",
Surf::Sweep(_) => "SURFACE SWEEP",
Surf::Loft => "SURFACE LOFT",
Surf::Patch => "PATCH",
Surf::Unstitch => "UNSTITCH",
@@ -110,7 +110,11 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
match k {
Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)],
Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)],
Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)],
Surf::Sweep(_) => vec![
SelInput::new("Profiles or curves", PROFILES | CURVES, true),
SelInput::new("Path", CURVES, true),
crate::dialogs_sweep::direction_input(),
],
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
@@ -137,7 +141,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
let k = match id {
"surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 },
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
"surface.sweep" => Surf::Sweep,
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
"surface.loft" => Surf::Loft,
"surface.patch" => Surf::Patch,
"surface.unstitch" => Surf::Unstitch,
@@ -243,7 +247,8 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
ui.checkbox(in_place, "");
ui.end_row();
}
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {}
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
}
enter
}
@@ -345,7 +350,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
}
("surface.revolve", p)
}
Surf::Sweep => {
Surf::Sweep(opts) => {
need(0, "profiles or curves to sweep")?;
need(1, "the path")?;
let mut p = sheet_picks(app, items(inputs, 0))?;
@@ -354,6 +359,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
p["path_sketch"] =
json!(crate::dialogs::curves_sketch(app, &path, p["sketch"].as_u64()).ok_or("the path curves must be in one other sketch")?);
p["path"] = json!(path);
crate::dialogs_sweep::params(app, opts, items(inputs, 2).first(), &mut p)?;
("surface.sweep", p)
}
Surf::Loft => {
@@ -578,10 +584,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
}
return Some((Kind::Surface(k), ins, extra));
}
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, .. } => (
Surf::Sweep,
vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()],
),
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, .. } => {
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame);
(
Surf::Sweep(opts),
vec![
sheet_sels(app, *sketch, profiles.as_ref(), curves),
path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(),
direction.into_iter().collect(),
],
)
}
FeatureKind::SurfaceLoft { sections } => {
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()])
}
+116
View File
@@ -0,0 +1,116 @@
//! The options Sweep and Surface Sweep share (#771): how the profile turns along the path, a
//! twist over the path's length and the profile's scale at the end.
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{Mat, SweepOrientation};
use solidcraft_engine::geom::Vec3;
use crate::SolveApp;
use crate::dialogs::{combo, field, row_label};
use crate::selection::{AXES, EDGES, SelInput};
/// The orientation choices, in combo order: perpendicular, parallel, a direction.
const ORIENTATIONS: [&str; 3] = ["Turn with Path", "Keep as Drawn", "Follow Direction"];
/// The orientation choice that takes a picked direction.
const DIRECTION: usize = 2;
/// A twist field that means no twist.
const NO_TWIST: &str = "0 deg";
/// An end scale field that means no scaling.
const NO_SCALE: &str = "1";
/// A sweep dialog's options.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepOpts {
/// An index into [`ORIENTATIONS`].
pub orientation: usize,
pub twist: String,
pub scale: String,
/// A stored direction (world), used while no direction is picked.
pub direction: Option<Vec3>,
}
impl Default for SweepOpts {
fn default() -> Self {
SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None }
}
}
impl SweepOpts {
/// A feature's stored options as dialog values; `frame` maps the feature's component frame to
/// the world (a stored direction is in the component's frame). A direction along an origin
/// axis comes back as a pick of that axis.
pub fn of(orientation: &SweepOrientation, twist: &Option<String>, scale: &Option<String>, frame: &Mat) -> (SweepOpts, Option<Sel>) {
let mut o = SweepOpts {
twist: twist.clone().unwrap_or_else(|| NO_TWIST.into()),
scale: scale.clone().unwrap_or_else(|| NO_SCALE.into()),
..SweepOpts::default()
};
let mut pick = None;
match orientation {
SweepOrientation::Perpendicular => {}
SweepOrientation::Parallel => o.orientation = 1,
SweepOrientation::Direction(d) => {
o.orientation = DIRECTION;
let world = solidcraft_engine::doc::apply_vector(frame, *d);
match world.normalized().and_then(world_axis) {
Some(name) => pick = Some(Sel::Axis { name: name.into() }),
None => o.direction = Some(world),
}
}
}
(o, pick)
}
}
/// The origin axis a direction runs along (its way), if any.
fn world_axis(d: Vec3) -> Option<&'static str> {
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0).map(|(n, _)| n)
}
/// The input that picks a Follow Direction sweep's direction: an axis, a sketch line or a
/// straight edge.
pub fn direction_input() -> SelInput {
SelInput::new("Direction", AXES | EDGES, false)
}
/// The option rows; true when Enter was pressed in a field.
pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool {
let mut enter = false;
row_label(ui, "Orientation");
combo(ui, &format!("{id}_orient"), &ORIENTATIONS, &mut o.orientation);
ui.end_row();
row_label(ui, "Twist");
enter |= field(ui, &mut o.twist);
ui.end_row();
row_label(ui, "End Scale");
enter |= field(ui, &mut o.scale);
ui.end_row();
enter
}
/// The options as command parameters, added to `p`: the orientation, and the twist and end
/// scale when they change anything. `picked` is the direction input's selection.
pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, p: &mut Value) -> Result<(), String> {
match o.orientation {
0 => {}
1 => p["orientation"] = json!("parallel"),
_ => {
let d = match (picked, o.direction) {
(Some(x), _) => crate::dialogs::axis_of(app, x).ok_or("the direction must be an axis, a sketch line or a straight edge")?.1,
(None, Some(d)) => d,
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) for the profile to follow".into()),
};
p["orientation"] = json!([d.x, d.y, d.z]);
}
}
let twist = o.twist.trim();
if !twist.is_empty() && twist != NO_TWIST {
p["twist"] = json!(twist);
}
let scale = o.scale.trim();
if !scale.is_empty() && scale != NO_SCALE {
p["scale"] = json!(scale);
}
Ok(())
}
+2 -2
View File
@@ -16,7 +16,7 @@ use egui::{FontId, Pos2, Rect, Shape, Stroke, vec2};
use serde_json::json;
use solidcraft_engine::Sel;
use solidcraft_engine::geom::Vec2;
use solidcraft_engine::sketch::{ConstraintKind, DimFrame, chain_centres, default_text, dim_frame, dim_layout};
use solidcraft_engine::sketch::{ConstraintKind, DimFrame, chain_centres, default_text, dim_frame, dim_layout_reaching, dim_reach};
use crate::SolveApp;
use crate::theme::Tokens;
@@ -516,7 +516,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px);
// Unplaced linear dimensions sit outside the shape they measure.
let away = first_point(sk, &c.kind).and_then(|p| centres.get(p).copied()).unwrap_or(anchor);
let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, away, px)), px, half);
let lay = dim_layout_reaching(&frame, c.text.or_else(|| default_text(&frame, away, px)), px, half, dim_reach(sk, &c.kind));
let stroke = Stroke::new(1.0, col);
let mut segs = Vec::new();
for line in &lay.lines {
+49 -2
View File
@@ -1314,10 +1314,36 @@ impl Resources {
}
}
/// A device of its own for an offscreen render or check (`None` without a GPU adapter).
/// The backends the offscreen tests start, chosen once per test process as the app chooses them
/// (`apps/solidcraft/src/startup.rs`, `choose_backends`): what `WGPU_BACKEND` names; on Windows one
/// backend, the first of D3D12, Vulkan and OpenGL with a hardware adapter (else D3D12); elsewhere
/// wgpu's default. wgpu's default on Windows starts Vulkan, D3D12 and OpenGL together, and tests
/// starting them side by side hung or crashed the test process inside the Vulkan driver.
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn offscreen_backends() -> wgpu::Backends {
static CHOSEN: std::sync::OnceLock<wgpu::Backends> = std::sync::OnceLock::new();
*CHOSEN.get_or_init(|| {
let default = wgpu::InstanceDescriptor::new_without_display_handle_from_env().backends;
if !cfg!(target_os = "windows") || std::env::var("WGPU_BACKEND").is_ok_and(|v| !v.trim().is_empty()) {
return default;
}
let hardware = |backends: wgpu::Backends| {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = backends;
let adapters = pollster::block_on(wgpu::Instance::new(desc).enumerate_adapters(backends));
adapters.iter().any(|a| a.get_info().device_type != wgpu::DeviceType::Cpu)
};
[wgpu::Backends::DX12, wgpu::Backends::VULKAN, wgpu::Backends::GL].into_iter().find(|b| hardware(*b)).unwrap_or(wgpu::Backends::DX12)
})
}
/// A device of its own for an offscreen render or check (`None` without a GPU adapter), on
/// [`offscreen_backends`].
#[cfg(all(test, not(target_arch = "wasm32")))]
fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
desc.backends = offscreen_backends();
let instance = wgpu::Instance::new(desc);
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
@@ -1572,6 +1598,27 @@ mod tests {
assert!(!super::hangs_on_gpu_tessellation(wgpu::Backend::Vulkan, "AMD FirePro W5100"));
}
/// The offscreen tests start the backends the app would: on Windows without `WGPU_BACKEND`
/// exactly one, from the app's order (D3D12, Vulkan, OpenGL); otherwise wgpu's default.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn offscreen_tests_start_the_backend_the_app_would() {
use egui_wgpu::wgpu::Backends;
let chosen = super::offscreen_backends();
let env = std::env::var("WGPU_BACKEND").is_ok_and(|v| !v.trim().is_empty());
if cfg!(target_os = "windows") && !env {
assert!([Backends::DX12, Backends::VULKAN, Backends::GL].contains(&chosen), "one backend, not {chosen:?}");
} else {
assert_eq!(chosen, egui_wgpu::wgpu::InstanceDescriptor::new_without_display_handle_from_env().backends);
}
assert_eq!(super::offscreen_backends(), chosen, "chosen once per test process");
// And the offscreen device is made on them (skipped without a GPU adapter).
if let Some((adapter, _, _)) = super::offscreen_device() {
let used = Backends::from(adapter.get_info().backend);
assert!(chosen.contains(used), "the offscreen device runs on {used:?}, not on {chosen:?}");
}
}
/// The viewport shader parses and validates (a bad shader would only fail at run time).
#[test]
fn shader_is_valid() {
+5
View File
@@ -32,6 +32,7 @@ pub mod dialogs_plastic;
pub mod dialogs_save_mesh;
pub mod dialogs_sheet;
pub mod dialogs_surface;
pub mod dialogs_sweep;
pub mod dim_view;
#[cfg(test)]
mod dimension_create_ui_tests;
@@ -88,6 +89,8 @@ mod file_load;
mod file_picker;
pub use file_picker::FilePickerFuture;
#[cfg(test)]
mod blend_arrow_tests;
#[cfg(test)]
mod file_load_ui_tests;
#[cfg(test)]
mod fillet_spline_handle_tests;
@@ -141,6 +144,8 @@ pub mod smart_extrude;
#[cfg(test)]
mod smart_extrude_ui_tests;
pub mod stand_up;
#[cfg(test)]
mod sweep_dialog_tests;
pub mod theme;
#[cfg(test)]
mod thin_extrude_tests;
+24 -6
View File
@@ -226,12 +226,30 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
ui.label(RichText::new(h).color(t.text_dim).small());
}
ui.end_row();
for r in rows {
// Favourite star.
let star = if r.favorite { RichText::new("★").color(t.accent) } else { RichText::new("☆").color(t.text_dim) };
if ui.add(egui::Button::new(star).frame(false)).on_hover_text(crate::i18n::tr_label("Favourite")).clicked() {
d.error = app.run("parameters.favorite", json!({"name": r.name, "favorite": !r.favorite})).err();
}
for (i, r) in rows.iter().enumerate() {
ui.horizontal(|ui| {
// Favourite star.
let star = if r.favorite { RichText::new("★").color(t.accent) } else { RichText::new("☆").color(t.text_dim) };
if ui.add(egui::Button::new(star).frame(false)).on_hover_text(crate::i18n::tr_label("Favourite")).clicked() {
d.error = app.run("parameters.favorite", json!({"name": r.name, "favorite": !r.favorite})).err();
}
// Up and down past the neighbouring user parameter (#1187), in the user table only:
// the favourites table lists favourites first whatever their order.
if salt == "user" {
for (glyph, tip, neighbour, side) in
[("▲", "Move up", i.checked_sub(1).and_then(|j| rows.get(j)), "before"), ("▼", "Move down", rows.get(i + 1), "after")]
{
let button = egui::Button::new(RichText::new(glyph).small().color(t.text_dim)).frame_when_inactive(false);
let resp = ui.add_enabled(neighbour.is_some(), button).on_hover_text(crate::i18n::tr_label(tip));
crate::scenario::publish_handle(&format!("param:{}:{}", side, r.name), resp.rect.center());
if resp.clicked()
&& let Some(n) = neighbour
{
d.error = app.run("parameters.move", json!({"name": r.name, side: n.name})).err();
}
}
}
});
// Name: double-click to rename.
match &mut d.renaming {
Some((old, new)) if *old == r.name => {
+135 -27
View File
@@ -94,7 +94,7 @@ use NavButton::{Left, Middle, Right};
use NavMode::{Orbit, Pan, Zoom};
/// The mouse mappings. The first is the default.
pub const NAV_PRESETS: [NavPreset; 7] = [
pub const NAV_PRESETS: [NavPreset; 8] = [
NavPreset {
id: "fusion",
label: "Fusion",
@@ -140,6 +140,13 @@ pub const NAV_PRESETS: [NavPreset; 7] = [
bind(Right, NavKeys::NONE, Orbit),
],
},
NavPreset {
id: "blender",
label: "Blender",
help: "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone",
reverse_zoom: false,
bindings: &[bind(Middle, NavKeys::SHIFT, Pan), bind(Middle, NavKeys::CTRL, Zoom), bind(Middle, NavKeys::NONE, Orbit)],
},
NavPreset {
id: "tinkercad",
label: "Tinkercad",
@@ -229,6 +236,19 @@ impl NavigatorStyle {
}
}
/// What an orbit turns around (Preferences: "Orbit around").
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OrbitAround {
/// The world origin: the view turns about it and keeps it still on screen.
Origin,
/// The view's centre: what Pan moves and Fit frames (the default, as it always was).
#[default]
ViewCentre,
/// The model point under the cursor when the drag began.
Cursor,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Prefs {
@@ -242,7 +262,11 @@ pub struct Prefs {
pub radial_context_menu: bool,
/// The wheel zooms out when turned forward.
pub zoom_reverse: bool,
/// Orbit around the point under the cursor (else around the view's centre).
/// What an orbit turns around.
pub orbit_around: OrbitAround,
/// The cursor preference as releases before the origin option saved it (`true` = the point
/// under the cursor). [`migrate`] folds it into `orbit_around` when saved preferences are
/// read, so an existing choice survives the upgrade; this version only writes `false`.
pub orbit_cursor: bool,
/// Whether a connected 3D mouse moves the view.
pub space_mouse_enabled: bool,
@@ -339,6 +363,7 @@ impl Default for Prefs {
navigator_style: NavigatorStyle::default(),
radial_context_menu: false,
zoom_reverse: false,
orbit_around: OrbitAround::default(),
orbit_cursor: false,
space_mouse_enabled: true,
space_mouse_speed: 1.0,
@@ -475,6 +500,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.zoom_to_cursor = d.zoom_to_cursor;
p.smooth_wheel_zoom = d.smooth_wheel_zoom;
p.trackpad_pan = d.trackpad_pan;
p.orbit_around = d.orbit_around;
p.orbit_cursor = d.orbit_cursor;
p.space_mouse_enabled = d.space_mouse_enabled;
p.space_mouse_speed = d.space_mouse_speed;
@@ -578,25 +604,36 @@ pub fn zoom_sign(app: &SolveApp) -> f64 {
if app.preferences.zoom_reverse { -1.0 } else { 1.0 }
}
/// Orbit by pixel deltas: around the view's centre, or, with "orbit around the cursor", around
/// the model point that was under the cursor when the drag began (kept still on screen). A drag
/// that begins over empty space turns around the centre of the visible geometry instead, so the
/// model never swings around a far-off point. The pivot is chosen once, when the drag begins.
/// Orbit by pixel deltas about the configured centre: the view's centre (default), the world
/// origin, or the model point that was under the cursor when the drag began. The origin and the
/// cursor pivot are kept still on screen; a cursor drag that begins over empty space turns around
/// the centre of the visible geometry instead, so the model never swings around a far-off point.
/// The cursor pivot is chosen once, when the drag begins.
pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Option<Pos2>) {
if !app.preferences.orbit_cursor {
let pivot = match app.preferences.orbit_around {
OrbitAround::ViewCentre => None,
OrbitAround::Origin => Some(Vec3::ZERO),
OrbitAround::Cursor => Some(match app.viewport.orbit_pivot {
Some(p) => p,
None => {
let proj = crate::viewport::projection(app, rect);
let at = press.unwrap_or(rect.center());
let p = pivot_under(app, &proj, at);
app.viewport.orbit_pivot = Some(p);
p
}
}),
};
let Some(pivot) = pivot else {
app.cam.orbit(dx, dy);
return;
}
let pivot = match app.viewport.orbit_pivot {
Some(p) => p,
None => {
let proj = crate::viewport::projection(app, rect);
let at = press.unwrap_or(rect.center());
let p = pivot_under(app, &proj, at);
app.viewport.orbit_pivot = Some(p);
p
}
};
orbit_about(app, rect, dx, dy, pivot);
}
/// Orbit by pixel deltas, keeping `pivot` still on screen: the camera turns about it and a pan
/// correction puts it back where it was.
fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) {
let before = crate::viewport::projection(app, rect).to_screen(pivot);
app.cam.orbit(dx, dy);
let after = crate::viewport::projection(app, rect).to_screen(pivot);
@@ -1043,9 +1080,11 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
});
ui.end_row();
ui.label(crate::i18n::tr_label("Orbit around"));
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.orbit_cursor, false, crate::i18n::tr_label("View centre"));
ui.radio_value(&mut app.preferences.orbit_cursor, true, crate::i18n::tr_label("Point under the cursor"));
// Wrapped: three choices, and German or Russian labels are long.
ui.horizontal_wrapped(|ui| {
ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::Origin, crate::i18n::tr_label("Origin"));
ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::ViewCentre, crate::i18n::tr_label("View centre"));
ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::Cursor, crate::i18n::tr_label("Point under the cursor"));
});
ui.end_row();
ui.label(crate::i18n::tr_label("Middle double-click"));
@@ -1305,12 +1344,22 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::
Ok(out)
}
/// Fold a setting an older release kept in its own field into the one this version reads.
/// `orbit_cursor` (`true` = the point under the cursor) became `orbit_around`.
pub fn migrate(mut p: Prefs) -> Prefs {
if p.orbit_cursor {
p.orbit_around = OrbitAround::Cursor;
p.orbit_cursor = false;
}
p
}
/// Saved preferences, keeping every one that still reads. A value this version doesn't
/// understand (a setting a newer or older release saved differently, a hand edit) falls back to
/// its default on its own instead of discarding all of them.
pub fn lenient(saved: &serde_json::Value) -> Prefs {
if let Ok(p) = serde_json::from_value::<Prefs>(saved.clone()) {
return p;
return migrate(p);
}
let Ok(mut cur) = serde_json::to_value(Prefs::default()) else { return Prefs::default() };
if let (Some(o), Some(src)) = (cur.as_object_mut(), saved.as_object()) {
@@ -1324,7 +1373,7 @@ pub fn lenient(saved: &serde_json::Value) -> Prefs {
}
}
}
serde_json::from_value(cur).unwrap_or_default()
serde_json::from_value(cur).map(migrate).unwrap_or_default()
}
/// Set preferences from JSON (control channel); returns them all.
@@ -1344,7 +1393,7 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
{
return Err("`color_vision` must be off, protan, deutan or tritan".into());
}
let new: Prefs = serde_json::from_value(cur).map_err(|e| e.to_string())?;
let new: Prefs = migrate(serde_json::from_value(cur).map_err(|e| e.to_string())?);
if !UNITS.contains(&new.default_units.as_str()) {
return Err(format!("units must be one of {}", UNITS.join(", ")));
}
@@ -1659,6 +1708,29 @@ mod tests {
assert!(!app.preferences.trackpad_pan);
}
#[test]
fn blender_preset_moves_the_camera_with_the_middle_button_only() {
// #1090: orbit, pan and zoom all on the middle button; no left or right drag binding, so
// those stay selection and the context menu (Ctrl+left on empty space orbits in every
// preset, outside the bindings).
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
set(&mut app, &json!({"nav": "blender"})).unwrap();
let none = Modifiers::default();
let shift = Modifiers { shift: true, ..Default::default() };
let ctrl = Modifiers { ctrl: true, ..Default::default() };
assert_eq!(nav_mode(&app, NavButton::Middle, none), Some(NavMode::Orbit));
assert_eq!(nav_mode(&app, NavButton::Middle, shift), Some(NavMode::Pan));
assert_eq!(nav_mode(&app, NavButton::Middle, ctrl), Some(NavMode::Zoom));
for mods in [none, shift, ctrl] {
assert_eq!(nav_mode(&app, NavButton::Left, mods), None);
assert_eq!(nav_mode(&app, NavButton::Right, mods), None);
}
assert_eq!(zoom_sign(&app), 1.0, "the wheel turns the usual way");
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.nav, "blender");
}
#[test]
fn presets_map_the_mouse_and_prefs_round_trip() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
@@ -1684,7 +1756,7 @@ mod tests {
assert_eq!(nav_mode(&app, NavButton::Left, none), None, "a plain left drag still selects");
assert_eq!(nav_mode(&app, NavButton::Left, shift), None);
assert_eq!(nav_mode(&app, NavButton::Right, none), Some(NavMode::Orbit));
assert!(set(&mut app, &json!({"nav": "blender"})).is_err());
assert!(set(&mut app, &json!({"nav": "no-such-preset"})).is_err());
assert!(set(&mut app, &json!({"default_units": "furlong"})).is_err());
assert!(set(&mut app, &json!({"nope": 1})).is_err());
// Kept between runs.
@@ -1802,11 +1874,47 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Orbiting about the world origin keeps the origin where it was drawn, even when Pan has
/// moved it off to the side.
#[test]
fn orbiting_around_the_origin_keeps_it_still() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
app.fit_view();
assert_eq!(app.preferences.orbit_around, OrbitAround::ViewCentre, "the view centre stays the default");
app.preferences.orbit_around = OrbitAround::Origin;
app.cam.pan(60.0, -25.0, 600.0);
let was = (app.cam.yaw, app.cam.pitch);
let before = crate::viewport::projection(&app, rect).to_screen(Vec3::ZERO).unwrap();
orbit(&mut app, rect, 40.0, 25.0, None);
let after = crate::viewport::projection(&app, rect).to_screen(Vec3::ZERO).unwrap();
assert!(after.distance(before) < 2.0, "{before:?} -> {after:?}");
assert_ne!((app.cam.yaw, app.cam.pitch), was, "the view must turn");
}
/// Preferences saved before the origin option had `orbit_cursor`: that choice survives, and
/// the control channel still accepts the old key.
#[test]
fn saved_cursor_preference_migrates_to_the_origin_option() {
assert_eq!(lenient(&json!({"orbit_cursor": true})).orbit_around, OrbitAround::Cursor);
assert_eq!(lenient(&json!({"orbit_cursor": false})).orbit_around, OrbitAround::ViewCentre);
assert_eq!(lenient(&json!({})).orbit_around, OrbitAround::ViewCentre);
assert_eq!(lenient(&json!({"orbit_around": "origin"})).orbit_around, OrbitAround::Origin);
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
set(&mut app, &json!({"orbit_around": "origin"})).unwrap();
assert_eq!(app.preferences.orbit_around, OrbitAround::Origin);
set(&mut app, &json!({"orbit_cursor": true})).unwrap();
assert_eq!(app.preferences.orbit_around, OrbitAround::Cursor, "the old key still moves the choice");
assert!(!app.preferences.orbit_cursor, "and is folded away");
}
#[test]
fn orbiting_around_the_cursor_keeps_that_point_still() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
app.preferences.orbit_cursor = true;
app.preferences.orbit_around = OrbitAround::Cursor;
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
app.fit_view();
let at = egui::pos2(500.0, 250.0);
@@ -1822,7 +1930,7 @@ mod tests {
fn orbiting_from_empty_space_turns_around_the_visible_geometry() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
app.preferences.orbit_cursor = true;
app.preferences.orbit_around = OrbitAround::Cursor;
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
app.fit_view();
let corner = egui::pos2(10.0, 10.0);
@@ -1835,7 +1943,7 @@ mod tests {
assert_eq!(app.viewport.orbit_pivot, Some(centre));
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.preferences.orbit_cursor = true;
app.preferences.orbit_around = OrbitAround::Cursor;
app.cam.target = solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0);
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
assert_eq!(app.viewport.orbit_pivot, Some(solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0)));
+18
View File
@@ -9,6 +9,24 @@ use solidcraft_engine::{Sel, Session};
use crate::dialogs::{Dialog, Kind, apply_commands};
use crate::{Services, SolveApp};
#[test]
fn a_face_extrude_keeps_the_picked_body_when_faces_coincide() {
let mut app = app_with(json!([
{"command":"solid.box","params":{"length":40,"width":30,"height":20,"body_name":"Other"}},
{"command":"solid.box","params":{"length":40,"width":30,"height":20,"body_name":"Wall"}}
]));
let before = app.session.model.state();
let pick = Sel::Face { body: "Wall".into(), index: 2, point: Vec3::new(40.0, 15.0, 10.0) };
let d = start(&mut app, vec![pick], "solid.extrude");
let (command, params) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!(params["face_body"], json!("Wall"));
app.session.execute(&command, &params).unwrap();
let after = app.session.model.state();
assert_eq!(after.bodies.len(), 2);
assert!(after.body("Other").unwrap().body.same(&before.body("Other").unwrap().body));
assert!(after.body("Wall").unwrap().body.contains(Vec3::new(49.0, 15.0, 10.0)));
}
#[test]
fn path_plane_click_retains_source_and_previews_then_edits_in_place() {
let mut h = crate::scenario::Harness::new();
@@ -41,6 +41,8 @@ fn pixel_preview_temporary_curves_match_committed_geometry_and_sampling() {
let cases: Vec<(&str, Vec<[f64; 2]>, [f64; 2], Option<f64>, Option<f64>, Value)> = vec![
("sketch.circle.center", vec![[0., 0.]], [1., 0.], None, None, json!({"center":[0,0],"radius":1})),
("sketch.circle.two_point", vec![[-1., 0.]], [1., 0.], None, None, json!({"p0":[-1,0],"p1":[1,0]})),
// #1358: the whole line, both sides of the middle.
("sketch.line.midpoint", vec![[0., 0.]], [1., 0.5], None, None, json!({"mid":[0,0],"end":[1,0.5]})),
("sketch.circle.three_point", vec![[1., 0.], [-1., 0.]], [0., 1.], None, None, json!({"p0":[1,0],"p1":[-1,0],"p2":[0,1]})),
("sketch.arc.three_point", vec![[1., 0.], [-1., 0.]], [0., -1.], None, None, json!({"start":[1,0],"end":[-1,0],"through":[0,-1]})),
(
+101
View File
@@ -0,0 +1,101 @@
//! The Sweep and Surface Sweep dialogs' options (#771): orientation, twist and end scale reach
//! the commands, and editing a sweep keeps them.
use crate::dialogs::Kind;
use crate::dialogs_surface::Surf;
use crate::scenario::Harness;
use serde_json::json;
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{FeatureKind, SweepOrientation};
use solidcraft_engine::geom::Vec3;
/// A square `Q` on XY and a path `P` 40 up the Z axis; the square's sketch id.
fn setup(h: &mut Harness) -> u64 {
h.app.run("sketch.create", json!({"plane":"XY","name":"Q"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[5,5]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("sketch.create", json!({"plane":"XZ","name":"P"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[0,-40]],"ids":["up"]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.session.doc.find_feature("Q").unwrap().id
}
#[test]
fn a_new_sweep_takes_its_options_from_the_dialog() {
let mut h = Harness::new();
let sketch = setup(&mut h);
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
h.frames(3);
assert!(h.widget("Twist").is_some() && h.widget("End Scale").is_some() && h.widget("Orientation").is_some());
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
d.inputs[2].items = vec![Sel::Axis { name: "X".into() }];
let Kind::Sweep { opts, .. } = &mut d.kind else { panic!("{:?}", d.kind) };
opts.orientation = 2;
opts.twist = "90 deg".into();
opts.scale = "0.5".into();
h.frames(2);
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
let f = h.app.session.doc.features.last().unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(*orientation, SweepOrientation::Direction(Vec3::X));
assert_eq!(twist.as_deref(), Some("90 deg"));
assert_eq!(scale.as_deref(), Some("0.5"));
}
#[test]
fn a_plain_sweep_from_the_dialog_stores_no_options() {
let mut h = Harness::new();
let sketch = setup(&mut h);
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
h.frames(3);
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
let f = h.app.session.doc.features.last().unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
}
#[test]
fn editing_sweeps_keeps_their_options() {
let mut h = Harness::new();
setup(&mut h);
h.app
.run(
"solid.sweep",
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":[1,1,0],"twist":"30 deg","scale":"2","operation":"new","name":"Solid"}),
)
.unwrap();
h.app
.run(
"surface.sweep",
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":"parallel","twist":"45 deg","scale":"0.5","name":"Sheet"}),
)
.unwrap();
h.frames(4);
for name in ["Solid", "Sheet"] {
let before = h.app.session.doc.find_feature(name).unwrap().clone();
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
h.frames(8);
match &h.app.dialog.as_ref().unwrap().kind {
Kind::Sweep { opts, .. } | Kind::Surface(Surf::Sweep(opts)) => assert_eq!(opts.twist, if name == "Solid" { "30 deg" } else { "45 deg" }),
k => panic!("{k:?}"),
}
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
// The options come back as they were (the profile pick is stored by its curves).
let options = |k: &FeatureKind| match k {
FeatureKind::Sweep { orientation, twist, scale, .. } | FeatureKind::SurfaceSweep { orientation, twist, scale, .. } => {
(*orientation, twist.clone(), scale.clone())
}
k => panic!("{k:?}"),
};
let after = h.app.session.doc.find_feature(name).unwrap();
assert_eq!(options(&after.kind), options(&before.kind), "{name}");
assert_eq!(after.param_names, before.param_names, "{name}");
}
}
+8
View File
@@ -586,6 +586,14 @@ pub(crate) fn preview_curve(t: &Tool, hover: Vec2, sweep: Option<f64>, rho: Opti
("sketch.circle.two_point", [a]) => {
sk.add_circle((*a + hover) * 0.5, a.dist(hover) * 0.5, None, None).ok()?;
}
// The first click is the middle: the line runs from the cursor's mirror image through
// it to the cursor (#1358), not from the middle to the cursor.
("sketch.line.midpoint", [m]) => {
if m.dist(hover) <= 1e-9 {
return None;
}
sk.add_line(*m * 2.0 - hover, hover, None, None, None).ok()?;
}
("sketch.circle.three_point" | "sketch.arc.three_point", [a, b]) => {
let ab = *b - *a;
let ah = hover - *a;
+57 -12
View File
@@ -19,6 +19,10 @@ use crate::{SolveApp, icons};
#[path = "viewport_appearance_tests.rs"]
mod appearance_tests;
#[cfg(test)]
#[path = "viewport_removed_later_tests.rs"]
mod removed_later_tests;
#[cfg(test)]
#[path = "viewport_plane_target_tests.rs"]
mod plane_target_tests;
@@ -2300,16 +2304,22 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if let Some((mode, delta)) = motion {
if delta != egui::Vec2::ZERO {
let mut params = json!({"motion":if mode == NavMode::Pan {"pan"} else {"orbit"},"delta":[delta.x,delta.y]});
if mode == NavMode::Orbit && app.preferences.orbit_cursor {
let pivot = match app.viewport.orbit_pivot {
Some(pivot) => pivot,
None => {
let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta);
app.viewport.orbit_pivot = Some(pivot);
pivot
}
if mode == NavMode::Orbit {
let pivot = match app.preferences.orbit_around {
crate::prefs::OrbitAround::ViewCentre => None,
crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO),
crate::prefs::OrbitAround::Cursor => Some(match app.viewport.orbit_pivot {
Some(pivot) => pivot,
None => {
let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta);
app.viewport.orbit_pivot = Some(pivot);
pivot
}
}),
};
params["pivot"] = json!(pivot);
if let Some(pivot) = pivot {
params["pivot"] = json!(pivot);
}
}
camera_motion(app, rect, params);
}
@@ -3341,7 +3351,17 @@ fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option<Po
target.pitch = previous.target.pitch;
}
}
let pivot = app.viewport.orbit_pivot.filter(|_| around_cursor).and_then(|p| {
// The cursor and origin pivots stay still while a snap eases in; the axis gizmo has no
// pivot (it orbits about the view centre, see `axis_view`).
let pivot = if around_cursor {
match app.preferences.orbit_around {
crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO),
_ => app.viewport.orbit_pivot,
}
} else {
None
}
.and_then(|p| {
app.cam = displayed;
projection(app, rect).to_screen(p).map(|at| (p, at))
});
@@ -4425,7 +4445,7 @@ mod tests {
let ctx = egui::Context::default();
let mut app = hover_app(false);
app.preferences.nav = "touchpad".into();
app.preferences.orbit_cursor = true;
app.preferences.orbit_around = crate::prefs::OrbitAround::Cursor;
let p = pos2(350.0, 300.0);
let alt = egui::Modifiers { alt: true, ..Default::default() };
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
@@ -5495,10 +5515,35 @@ mod tests {
}
}
/// The origin pivot holds through a Shift drag that snaps to an axis: the spring eases the
/// view round without letting the origin slide on screen.
#[test]
fn snapping_keeps_the_origin_still() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.orbit_around = crate::prefs::OrbitAround::Origin;
h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap();
h.app.fit_view();
let rect = h.app.viewport.rect.unwrap();
let at = rect.center() + vec2(50.0, -20.0);
orbit_drag(&mut h.app, rect, 0.0, 0.0, Some(at), true, true);
assert_eq!(
h.app.viewport.orbit_snap.as_ref().and_then(|s| s.pivot).map(|(p, _)| p),
Some(Vec3::ZERO),
"the origin is the pivot while snapping"
);
let before = projection(&h.app, rect).to_screen(Vec3::ZERO).unwrap();
for _ in 0..30 {
orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true);
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
let after = projection(&h.app, rect).to_screen(Vec3::ZERO).unwrap();
assert!(after.distance(before) < 2.0, "{before:?} -> {after:?}");
}
}
#[test]
fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.orbit_cursor = true;
h.app.preferences.orbit_around = crate::prefs::OrbitAround::Cursor;
h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap();
h.app.fit_view();
let rect = h.app.viewport.rect.unwrap();
@@ -0,0 +1,55 @@
//! Editing a sketch rolls the timeline back to it; a body deleted after the sketch is not drawn
//! then (#1167), and is back in neither the viewport nor the browser's Bodies folder.
use super::build_scene;
use crate::{Services, SolveApp};
use serde_json::json;
use solidcraft_engine::Session;
/// `Kept` and `Gone` blocks, a sketch `Edited` made after both, and `Gone` deleted after it.
fn app() -> SolveApp {
let mut s = Session::default();
for (name, x) in [("Kept", 0.0), ("Gone", 20.0)] {
s.execute("sketch.create", &json!({"plane": "XY", "name": format!("{name}Sketch")})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [x, 0], "p1": [x + 10.0, 10]})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"sketch": format!("{name}Sketch"), "distance": 5, "body_name": name})).unwrap();
}
s.execute("sketch.create", &json!({"plane": "XY", "offset": "30 mm", "name": "Edited"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.remove", &json!({"bodies": ["Gone"]})).unwrap();
let mut app = SolveApp::new(s, Services::default());
app.ui.ground_shadow = false;
app.ui.show_origin = false;
app
}
fn triangles(app: &SolveApp) -> usize {
let scene = build_scene(app).merged();
scene.tris.len() + scene.pbr.len() + scene.trans.len() + scene.glass.len()
}
#[test]
fn a_body_deleted_later_is_not_drawn_while_a_sketch_is_edited() {
let mut app = app();
let drawn = triangles(&app);
assert!(drawn > 0, "the kept block is drawn");
let sketch = app.session.doc.find_feature("Edited").map(|f| f.id).unwrap();
app.edit_sketch(sketch);
assert_eq!(app.session.active_sketch, Some(sketch));
assert!(app.session.model.state().body("Gone").is_some(), "the rolled-back history has it");
assert_eq!(triangles(&app), drawn, "only the kept block is drawn");
app.finish_sketch();
assert_eq!(triangles(&app), drawn);
}
#[test]
fn the_bodies_folder_leaves_out_a_body_deleted_later() {
let mut app = app();
let sketch = app.session.doc.find_feature("Edited").map(|f| f.id).unwrap();
let names = |app: &SolveApp| crate::browser::body_rows(app).into_iter().collect::<Vec<_>>();
assert_eq!(names(&app), ["Kept"]);
app.edit_sketch(sketch);
assert_eq!(names(&app), ["Kept"], "the body is not listed with an eye that cannot show it");
}
+30
View File
@@ -0,0 +1,30 @@
//! The Parameters dialog's ▲ / ▼ buttons reorder user parameters (#1187).
use serde_json::json;
use solidcraft_ui_egui::scenario::Harness;
fn order(h: &mut Harness) -> Vec<String> {
let v = h.app.session.execute("parameters.list", &json!({"filter": "user"})).unwrap();
v["parameters"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn arrows_move_user_parameters_up_and_down() {
let mut h = Harness::new();
for (n, e) in [("a", "1 mm"), ("b", "2 mm"), ("c", "3 mm")] {
h.app.session.execute("parameters.add", &json!({"name": n, "expression": e})).unwrap();
}
h.step(&json!({"widget":"Parameters"})).unwrap();
assert!(h.app.params.is_some());
h.frames(3);
h.run(&json!([{"click":{"handle":"param:after:a"}}])).unwrap();
h.frames(2);
assert_eq!(order(&mut h), ["b", "a", "c"]);
h.run(&json!([{"click":{"handle":"param:before:c"}}])).unwrap();
h.frames(2);
assert_eq!(order(&mut h), ["b", "c", "a"]);
// The first row cannot move up, the last cannot move down.
h.run(&json!([{"click":{"handle":"param:before:b"}}, {"click":{"handle":"param:after:a"}}])).unwrap();
h.frames(2);
assert_eq!(order(&mut h), ["b", "c", "a"]);
assert!(h.app.params.as_ref().unwrap().error.is_none());
}
@@ -2,6 +2,8 @@
{"note": "Alias preset (#304): Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms (down: out, up: in). Other presets don't navigate with Shift+Alt+left."},
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 5}}},
{"call": "ui.prefs", "params": {"nav": "alias"}},
{"note": "The orbit centre is its own preference, and the origin and cursor pivots move the camera's target: pin the view centre (the default) here so `panned` measures the binding, not the pivot."},
{"call": "ui.prefs", "params": {"orbit_around": "view_centre"}},
{"expect": {"prefs": {"nav": "alias"}}},
{"note": "Orbit: like a right drag (from the drag threshold on), and Shift (part of the binding) doesn't snap."},
@@ -0,0 +1,13 @@
[
{"note":"#226: a cap created before a wall touches only the edge of the wall's outward extrusion. Picking the wall face in Extrude must extend Wall and preserve Cap as a separate body."},
{"call":"engine.execute","params":{"command":"solid.box","params":{"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Cap"}}},
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":30,"height":20,"body_name":"Wall"}}},
{"call":"ui.camera","params":{"target":[20,15,12.5],"yaw":-0.785398,"pitch":0.61548,"distance":110}},
{"start":"solid.extrude"},
{"click":{"world":[40,15,10]}},
{"text":"10"},
{"key":"Enter"},
{"expect":{"dialog":null,"bodies":2,"errors":0,"body_volume":{"body":"Wall","volume":30000},"bbox":{"body":"Wall","min":[0,0,0],"max":[50,30,20]}}},
{"expect":{"body_volume":{"body":"Cap","volume":6000},"bbox":{"body":"Cap","min":[0,0,20],"max":[40,30,25]}}},
{"shot":"face_join_keeps_cap"}
]
+7
View File
@@ -28,6 +28,13 @@ made. Contact sets stop motion where bodies would pass through each other,
**Motion Study** keys joint values along a timeline, and **Interference** shows overlapping
volumes in red.
A joint moves one component against another, so its two picks must be on different components.
A STEP file with several parts often comes in as bodies of one component, and picks on two of
those bodies make no joint. The Joint and As-built Joint dialogs say so under the picks and offer
**Create Components from Bodies**: each picked body gets a component of its own and stays where it
is, the picks are kept, and the joint previews. `joint.create` refuses such origins with the same
advice.
Joints may close a loop, as in a four-bar linkage or a slider-crank. When one joint of the loop
is driven, the other joints in the loop follow so that every joint still meets, and the
mechanism stays in the assembly mode it started in (an open four-bar does not flip to the
+51 -8
View File
@@ -185,13 +185,51 @@ consume an undo step.
{"command":"sheet.export_dxf","params":{"path":"bracket-flat.dxf"}}
```
The DXF is an AutoCAD 2000 (`AC1015`) drawing in millimetres. The `OUTLINE`, `BEND_UP` and
`BEND_DOWN` layers separate cuts from bend centres. A round hole is a `CIRCLE`, a circular arc an
`ARC`, a whole ellipse one `ELLIPSE` and an arc of an ellipse an elliptical `ELLIPSE`; the pieces
of a spline, the outlines of text and the other conics are `SPLINE`s of their own degree, so no
curve is written as chords. For a machine that reads only lines and arcs, pass
`"curves": "arcs"` to `sheet.export_dxf`: the free-form curves are then tangent arcs and lines
within 0.001 mm.
The DXF is an AutoCAD 2000 (`AC1015`) drawing in millimetres, made for a laser-cutting shop's
nesting or CAM software: each kind of line is on a layer of its own, with a colour (AutoCAD's
index) and, for the bends, a linetype that the file defines.
| Layer | Colour | Line | Holds |
|---|---|---|---|
| `OUTLINE` | 7 white or black | continuous | the outer contour of each piece of the sheet |
| `CUT` | 4 cyan | continuous | what is cut out inside it: holes, slots, and the loose pieces a cut leaves (the middle of a ring) |
| `TEXT` | 2 yellow | continuous | the letters that text cut through the sheet, with their counters; only with `"text": "cut"`, the default |
| `ETCH` | 6 magenta | continuous | letters as single contours, to be marked and not cut: `"text": "etch"`, and always text machined to a depth |
| `BEND_UP`, `BEND_DOWN` | 1 red, 5 blue | `DASHED` | the bend centre lines, by direction |
| `BEND_NOTES` | 8 grey | continuous | the note beside each bend line, only with `"bend_notes": true` |
| `POCKET_…` | 3 green | continuous | pockets, a layer for each depth (see below) |
| `PERFORATION` | 3 green | continuous | the holes of a perforation field |
`OUTLINE`, `CUT`, `BEND_UP` and `BEND_DOWN` are always in the layer table, the others when the
sheet has something for them. The inner contours (`CUT`, `TEXT`) are written before the outline,
so a program that follows the file's order cuts inside-out.
A round hole is a `CIRCLE`, a circular arc an `ARC`, a whole ellipse one `ELLIPSE` and an arc of
an ellipse an elliptical `ELLIPSE`; the pieces of a spline, the outlines of text and the other
conics are `SPLINE`s of their own degree, so no curve is written as chords. For a machine that
reads only lines and arcs, pass `"curves": "arcs"` to `sheet.export_dxf`: the free-form curves
are then tangent arcs and lines within 0.001 mm.
Letters are told from other cut-outs by where they came from: a cut whose sketch holds text
(Sketch Text) marks the cut-outs it makes as letters, counters included, while the design is
built; nothing more is saved in the design file. A letter that another cut-out overlaps, or
that the sheet's edge clips, has become one shape with it and stays on `CUT`.
```json
{"command":"sheet.export_dxf","params":{"path":"tray-flat.dxf","text":"etch","bend_notes":true}}
```
With `"bend_notes": true` each bend line gets a note on `BEND_NOTES`, `B1 UP 90° R1` (the bend's
number, its direction, its angle and its inner radius; `%%d` is AutoCAD's degree sign). It is 3 mm
high (`"note_height"` changes that), centred half a height beside the middle of the line,
parallel to it and readable from below or from the right. A bend that cut-outs split has a note
for each piece. Notes are off by default, because a program that cuts or marks every layer would
mark them.
A design with several sheet bodies exports one file for each with `"per_body": true`: the files
are `<path>-<body>.dxf` (characters a file name cannot hold become `_`; two bodies that would
share a name get `_2`, `_3`…), and the reply lists them in `files`. Without a `path` it lists
their DXF texts instead.
Base-flange profiles keep their interior holes. Through cuts that cross a sheet edge become
notches in the flat outline; overlapping and repeated cuts remove their combined area once.
@@ -206,6 +244,10 @@ exactly (`cutout_shapes`): a round hole as `{"circle": {"center", "radius"}}`, a
Cuts can leave separate material pieces, including the centre of a ring cut. These pieces
remain one logical sheet group through Unfold / Refold and export as separate flat outlines.
A cut that parts a piece from the base (a flange cut off along its bend, the part of a ring's
inside on a flange's edge) keeps it too, folded or flat, and its warning says how many pieces
are no longer joined to the base, their area and where they lie in the flat pattern. Islands
inside other material (a ring's centre, a letter's counter) are not counted.
Large profiles that exceed the geometry processing budget report an error instead of
exporting a partial outline.
@@ -225,7 +267,8 @@ become a through hole wherever they remove the whole thickness. Flat-pattern JSO
`pockets` with their side, depth and bottom/top thickness coordinates in millimetres.
DXF pocket boundaries use `POCKET_BOTTOM_Z…_TO_…_MM`, `POCKET_TOP_Z…_TO_…_MM`, or
`POCKET_INTERNAL_Z…_TO_…_MM` layers. These are machining operations, so do not treat every
pocket layer as a through-cut contour when importing the drawing into a CAM tool.
pocket layer as a through-cut contour when importing the drawing into a CAM tool. (Text
machined to a depth is a marking, on `ETCH`.)
Extrude Intersect on a straight base or an unfolded sheet retains the material inside both
the current tool footprint and its depth interval, including interior holes, detached islands
+1 -1
View File
@@ -25,7 +25,7 @@
Esc or a click elsewhere closes the list. Clicking again on the same spot, with nothing changed
in between, selects the next thing in that list. The
wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse
preset in Preferences switches to SolidWorks, Inventor, Alias, Tinkercad, OpenSCAD or Touchpad style). On a
preset in Preferences switches to SolidWorks, Inventor, Alias, Blender, Tinkercad, OpenSCAD or Touchpad style). On a
trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans;
off: scroll zooms like the wheel); mouse wheels zoom and the thumb wheel pans sideways.
Shift+middle orbits freely
+1 -1
View File
@@ -132,7 +132,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
+7 -5
View File
@@ -1,6 +1,6 @@
# Features
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **365 commands.** Planned (registered, not built yet): 4. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **367 commands.** Planned (registered, not built yet): 4. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
## Sketch (99)
@@ -315,7 +315,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Measure (I) | `inspect.measure` | SOLID › INSPECT |
| Section Analysis | `inspect.section` | SOLID › INSPECT |
## Files (7)
## Files (8)
| Command | Id | Toolbar |
|---|---|---|
@@ -325,7 +325,8 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Save (Ctrl+S) | `file.save` | – |
| Save As | `file.save_as` | – |
| Export | `file.export` | – |
| Save As Mesh | `file.save_mesh` | – |
| Save As Mesh | `file.save_mesh` | MESH › MODIFY |
| Installed Slicers | `file.slicers` | – |
## Documents (4)
@@ -462,7 +463,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Command | Id | Toolbar |
|---|---|---|
| Convert Mesh | `mesh.convert` | MESH › MODIFY |
| Convert Mesh | `mesh.convert` | – |
## `model.*` (3)
@@ -491,7 +492,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Move Occurrence | `occurrence.move` | – |
| Ground | `occurrence.ground` | – |
## `parameters.*` (12)
## `parameters.*` (13)
| Command | Id | Toolbar |
|---|---|---|
@@ -501,6 +502,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Rename Parameter | `parameters.rename` | – |
| Delete Parameter | `parameters.delete` | – |
| Favourite Parameter | `parameters.favorite` | – |
| Move Parameter | `parameters.move` | – |
| Comment Parameter | `parameters.comment` | – |
| Parameter Users | `parameters.users` | – |
| Parameter Graph | `parameters.graph` | – |
+1 -1
View File
@@ -145,7 +145,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t
| `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. |
| `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. |
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`) and every result before it. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. |
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. |
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). |
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
+1 -1
View File
@@ -738,7 +738,7 @@ def main():
mcp.send("notifications/initialized", notify=True)
d = Demo(a.port, mcp, open(a.log, "w") if a.log else None, a.speed, a.minimum_caption)
# The pi story's camera moves orbit about the point under the pointer (Preferences > Navigation).
d.ui("ui.prefs", {"language": "en", "theme": "dark", "ui_scale": 1, "nav": "fusion", "orbit_cursor": a.story.startswith("pi"), "button": "apply"})
d.ui("ui.prefs", {"language": "en", "theme": "dark", "ui_scale": 1, "nav": "fusion", "orbit_around": "cursor" if a.story.startswith("pi") else "view_centre", "button": "apply"})
d.ui("ui.resize", {"width": 1920, "height": 1080})
d.ui("ui.home", {"open": False})
d.ui("ui.documents", {"action": "new"})
+1 -1
View File
@@ -129,7 +129,7 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft
| A linked joint stops following | Its value is clamped to its own limits. Hinge 2 in the example has `[[-60, 0]]`, so a drive of Hinge below 0 leaves it at 0. Give a mirrored joint the mirrored limits. |
| A drive stops short, `stopped_by_contact` | Two bodies of a contact set touch. `fraction` is how far the move got. |
| `occurrence.ground` says there is no occurrence `Frame` | It takes occurrence names only: `Frame:1`. |
| `a joint needs two different occurrences` | Both origins found the same part. Give `occurrence` on both. |
| `a joint needs two different occurrences` | Both origins found the same part. Give `occurrence` on both. If the parts are bodies of one component (an imported STEP file often brings them so), run `component.from_bodies {"bodies": [...]}` first. |
| "unknown command" for `file.open` | Open a design with the `open` tool or `doc.open {path}`. |
| Rigid hoses, cables or springs drift as the mechanism moves | Bodies don't bend. Hide them with `browser.visibility`, or model them per link. |
| `solidcraft-cli snapshot SCRIPT --in design` runs on an empty design | `snapshot` ignores `--in`. Run `solidcraft-cli run SCRIPT --in design --save out.solidcraft` first. |
+57
View File
@@ -11620,6 +11620,63 @@
"Body1/v[F6:end & F6:side:l7 & F6:side:l8]": "vertex.plane_three_points vertex.point",
"design": "load"
},
"typical/multi-body-face-extrude": {
"Cap/F1:+x": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
"Cap/F1:+x|F1:+y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:+x|F1:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:+x|F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:+x|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:+y": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
"Cap/F1:+y|F1:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:+y|F1:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:+y|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:+z": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
"Cap/F1:+z|F1:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:+z|F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:-x": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
"Cap/F1:-x|F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:-x|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:-y": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
"Cap/F1:-y|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Cap/F1:-z": "face.angle_plane face.draft_neutral !face.extrude face.hole face.offset_plane !face.press_pull face.shell face.sketch",
"Cap/body": "body.copy body.mirror body.move body.pattern",
"Cap/v[F1:+x & F1:+y & F1:+z]": "vertex.plane_three_points vertex.point",
"Cap/v[F1:+x & F1:+y & F1:-z]": "vertex.plane_three_points vertex.point",
"Cap/v[F1:+x & F1:+z & F1:-y]": "vertex.plane_three_points vertex.point",
"Cap/v[F1:+x & F1:-y & F1:-z]": "vertex.plane_three_points vertex.point",
"Cap/v[F1:+y & F1:+z & F1:-x]": "vertex.plane_three_points vertex.point",
"Cap/v[F1:+y & F1:-x & F1:-z]": "vertex.plane_three_points vertex.point",
"Cap/v[F1:+z & F1:-x & F1:-y]": "vertex.plane_three_points vertex.point",
"Cap/v[F1:-x & F1:-y & F1:-z]": "vertex.plane_three_points vertex.point",
"Wall/F2:+x": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
"Wall/F2:+x|F2:+y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:+x|F2:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:+x|F2:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:+x|F2:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:+y": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
"Wall/F2:+y|F2:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:+y|F2:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:+y|F2:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:+z": "face.angle_plane face.draft_neutral !face.extrude face.hole face.midplane face.offset_plane !face.press_pull face.shell face.sketch",
"Wall/F2:+z|F2:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:+z|F2:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:-x": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
"Wall/F2:-x|F2:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:-x|F2:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:-y": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
"Wall/F2:-y|F2:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wall/F2:-z": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
"Wall/body": "body.copy body.mirror body.move body.pattern",
"Wall/v[F2:+x & F2:+y & F2:+z]": "vertex.plane_three_points vertex.point",
"Wall/v[F2:+x & F2:+y & F2:-z]": "vertex.plane_three_points vertex.point",
"Wall/v[F2:+x & F2:+z & F2:-y]": "vertex.plane_three_points vertex.point",
"Wall/v[F2:+x & F2:-y & F2:-z]": "vertex.plane_three_points vertex.point",
"Wall/v[F2:+y & F2:+z & F2:-x]": "vertex.plane_three_points vertex.point",
"Wall/v[F2:+y & F2:-x & F2:-z]": "vertex.plane_three_points vertex.point",
"Wall/v[F2:+z & F2:-x & F2:-y]": "vertex.plane_three_points vertex.point",
"Wall/v[F2:-x & F2:-y & F2:-z]": "vertex.plane_three_points vertex.point",
"design": "load"
},
"typical/multi-body-join-cut": {
"Block/F1:+x": "face.angle_plane !face.draft_neutral face.extrude face.extrude+edit face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
"Block/F1:+x|F1:+y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.normal_plane+edit edge.project",
@@ -0,0 +1,7 @@
{
"description": "Issue #226: the upper cap precedes the wall in body order and touches only the edge of a 10 mm outward extrusion from the wall's x=40 face.",
"commands": [
{"command":"solid.box","params":{"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Cap"}},
{"command":"solid.box","params":{"length":40,"width":30,"height":20,"body_name":"Wall"}}
]
}